在windows平台上基于OpenHarmony sdk编译三方库并暴露给ArkTS使用(详细)
这篇文章的写作初衷源于最近的一次突发奇想:我决定将之前写的二维和三维地图移植到鸿蒙系统上。在开始移植之前,我想着肯定需要使用 HarmonyOS SDK 来编译第三方库。所以我先尝试将 FreeType 库编译通过,接下来的工作应该也会按照这个步骤进行。这篇文章详细总结了我在这一过程中遇到的各种问题和解决方案。
目录
一、环境配置
二、编译
2.1 预编译
2.2 编译
2.3 编译结果
三、创建native工程
3.1 native层
3.2 ArkTS层
3.2.1 应用沙箱目录
A. 定义
B、应用沙箱目录与应用沙箱路径
C、应用文件目录与应用文件路径
3.2.2 rawfile目录
3.2.3 整合
一、环境配置
以下操作均在windows上进行,另外,HarmonyOS SDK已嵌入IDE中,无需额外下载配置,当然,也可以自己搭建流水线,具体的可以参考如下文档(为了方便起见,我这边使用的是IDE中内置的SDK):
搭建流水线-命令行工具 - 华为HarmonyOS开发者https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-command-line-building-app
IDE:
HarmonyOS SDK:
以上是相关的环境准备。
接下来,我们需要下载freetype的源码,下载链接如下:
Index of /releases/freetype/https://download.savannah.gnu.org/releases/freetype/ 下载完成之后,我们给它解压,就会得到这样一个目录结构:
在开始编译之前呢,我们可以先打开CMakeLists这个文件,看看有没有什么关键信息:
果然,文件的开头就说到了,如果我们需要编译成动态库的话,就需要在编译时将BUILD_SHARED_LIBS这人参数设置成true,如果不设置的话呢,那它编译出来的就默认是静态库文件。
静态库和动态库各有其优势和使用场景,以下是一些详细的说明:
动态库的优势:
1.方便管理和更新
- 共享库:动态库(如.so、.dll或.dylib文件)是可以在多个应用程序之间共享的。多个程序可以依赖同一个库,并且不需要重复加载多个副本
- 独立更新:如果你有多个应用程序使用同一个动态库,更新这个动态库时,只需要更新这个库本身,不需要重新编译应用程序。这样可以简化升级还有维护的工作
- 节省磁盘空间:多个程序共享一个动态库,而不是每个程序都携带一个副本
2.运行时链接
动态库是在程序运行时链接的,也就是说只有在实际需要时才加载库,而静态库会在编译时直接链接到程序中,这样可以让应用程序的初始启动变得更快,并且还可以减少应用程序包的体积
3.平台适配
在某些平台上,动态库可能是标准做法,尤其是在Unix系统(如Linux、macOS)中,库文件通常采用动态链接的形式。动态库还可以根据不同的平台架构进行优化,减少重复的编译工作,就比如,我们可以使用针对不同硬件架构的动态库版本
静态库的优势
1、不依赖外部库文件
静态库(比如.a或.lib文件)会在编译阶段被直接嵌入到最终的可执行文件中,这样,生成的可执行文件不依赖于外部的库文件,即使系统中没有安装相应的动态库,也可以正常运行。这种方式适用于在没有操作系统支持的情况下,或者说你希望确保目标设备中库的版本一致性时,静态库是最佳的选择。
2、性能更优
因为静态库在编译时就已经链接到应用程序中,程序在运行时就不需要再去查找或加载动态库,因此在某些情况下,静态库可能比动态库更具有优势,特别是在启动时。
3、部署简便
对于一些不方便管理外部库文件的场景(比如嵌入式设备、单一平台应用等),使用静态库可以避免在每次部署时都需要确保动态库文件的存在和路径设置,另外,静态库不会有动态库带来的兼容性问题,就比如因为库版本不匹配而导致的崩溃或错误。
另外,静态库将所有代码嵌入到程序中,因此无法在程序运行时被替换或篡改,而动态库虽然能够提供更灵活的方式,但也存在被恶意替换或篡改的风险。
总的来说,动态库适合需要共享库文件、便于升级和维护、节省磁盘空间、并且希望程序文件小的场景。如果你不希望依赖外部库文件、需要更高性能或更好安全性的场景,尤其是在部署环境限制较多的情况下,静态库会是一个更佳的选择。
我们继续看CMakeLists文件,如果你需要通过CMake命令行配置选项来启用或禁用特定功能,你可以找到option()语句,就比如:
我们就以FT_WITH_ZLIB为例,这里是说由我们决定是否使用系统中安装的zlib库,如果设置为ON,将使用系统的zlib,否则使用内部的zlib库。
剩下的都是一些基础配置了,如果看不懂这个文件的,建议先好好的学一遍CMake,不然直接上手的话可能有点吃力~
二、编译
环境搭建好之后,我们通过IDE的Terminal进入到freetype的源码目录下:
2.1 预编译
执行以下命令(这里有个小细节,我打开的是windows powershell, 如果路径中有空格,需要使用双引号将路径进行包装):
& "C:\Program Files\Huawei\DevEco Studio\sdk\default\openharmony\native\build-tools\cmake\bin\cmake" -G Ninja -B out2 -DCMAKE_TOOLCHAIN_FILE="C:\Program Files\Huawei\DevEco Studio\sdk\default\openharmony\native\build\cmake\ohos.toolchain.cmake" -DCMAKE_MAKE_PROGRAM="C:\Program Files\Huawei\DevEco Studio\sdk\default\openharmony\native\build-tools\cmake\bin\ninja.exe" -DCMAKE_BUILD_WITH_INSTALL_RPATH=true -DBUILD_SHARED_LIBS=true
接下来,我们看一下每个参数的含义:
C:\Program Files\Huawei\DevEco Studio\sdk:这个是SDK路径,需要根据自己SDK目录进行配置(注意:此处路径必须是绝对路径!!!)
-G Ninja: 配置cmake使用ninja编译
-B out2:在源码目录用 -B 直接创建out2目录并生成out2/Makefile
-DCMAKE_TOOLCHAIN_FILE:配置交叉编译的toolchain文件
-DCMAKE_MAKE_PROGRAM:配置编译命令为ninja
-DCAMKE_BUILD_WITH_INSTALL_RPATH=true:解决预编译时的错误"freetype目标安装需要从构建中更改RPATH树,但Ninja生成器不支持这一点"的问题
-DBUILD_SHEAD_LIBS:设置编译成动态库
-DOHOS_ARCH=arm64-v8a:配置交叉编译架构为arm64(默认编译的是arm64,所以我们这里进行了省略),如需编译32位的需配置:-DOHOS_ARCH=armeabi-v7a
2.2 编译
cmake预编译完成后执行cmake --build进行编译:
& "C:\Program Files\Huawei\DevEco Studio\sdk\default\openharmony\native\build-tools\cmake\bin\cmake" --build out2
2.3 编译结果
三、创建native工程
接下来,我们在IDE工具中创建native工程
3.1 native层
我们需要找到cpp这个目录,并在其下创建thirdparty目录,并创建freetype文件夹,接着将freetype源码中的include目录,以及编译之后的out目录,:
接着,为了让当前这个工程链接我们编译之后的freetype,我们需要对当前工程中的CMakeLists文件进行如下修改(注意:不是freetype源码中的CMakeList文件):
# the minimum version of CMake.
cmake_minimum_required(VERSION 3.5.0)
project(NDKDevelop)set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})if(DEFINED PACKAGE_FIND_FILE)include(${PACKAGE_FIND_FILE})
endif()include_directories(${NATIVERENDER_ROOT_PATH}${NATIVERENDER_ROOT_PATH}/thirdparty/freetype-2.10.0/out/include${NATIVERENDER_ROOT_PATH}/thirdparty/freetype-2.10.0/include)link_directories(${NATIVERENDER_ROOT_PATH}/thirdparty/freetype-2.10.0/out)add_library(entry SHARED napi_init.cpp)
# z是zlib库
target_link_libraries(entry PUBLIC libace_napi.z.so freetype z)
好了,完事具备,接下来我们可以使用freetype将文字渲染为RGBA位图,而要想上层ArkTS去调用这个接口,我们需要将其封装成NAPI的形式。这里有一个小问题,native层在将结果返回给ArkTS后,ArkTS将其转换成base64编码,但是一直无法在界面上显示,
起初,我怀疑是base64转换出错,但是我将转换后的编码与相关转换工具的转换结果对比,发现一致,之后,我怀疑是图片过大的问题,我试着将小图片转成base64编码,发现可以在界面上进行显示。这里我们先详细的对base64进行分析:
首先,我们需要明确一点,base64编码是一种将二进制数据转换为ASCII字符串的方式,经常用于需要将二进制数据嵌入到文本文件(如HTML或CSS)中时使用。它将每三个字节的数据编码为四个字符。所以,Base64编码会使原始数据的大小膨胀,通常会增加约 33% 的体积。就比如,一张1MB的图片经过Base64编码后,可能会变成1.33MB,这会增加数据传输和处理的开销。
我在ArkTS层将native层返回的数据进行转换成Base64编码之后,发现Image无法渲染,我不太确定是否是鸿蒙中的Image组件无法渲染超过一定大小的Base64图片,当然,这仅仅是我的猜测。
出于文件大小的考量,我选择在native层将其转成PNG的形式,这里我选择使用stb工具来进行转换,我们只需要在这个链接中下载std_image_write.h头文件即可:
https://github.com/nothings/stb/blob/master/stb_image_write.hhttps://github.com/nothings/stb/blob/master/stb_image_write.h
下载完成之后,需要将它放到native工程的cpp目录下,为了规范起见,我们在cpp目录下新建一个include目录,并将std_image_write.h放入其中,同时别忘了修改CMakeLists文件,最终效果如下:
CMakeList修改:
include_directories(${NATIVERENDER_ROOT_PATH}${NATIVERENDER_ROOT_PATH}/include${NATIVERENDER_ROOT_PATH}/thirdparty/freetype-2.10.0/out/include${NATIVERENDER_ROOT_PATH}/thirdparty/freetype-2.10.0/include)\
转成PNG字节流之后,为了验证native层的转换是否正确,我们可以将其存放到沙箱目录中,最终,这就是我们完整的 native 层的业务逻辑代码:
napi_value RenderTextToBitmap(napi_env env, napi_callback_info info) {size_t argc = 3;napi_value argv[3];napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);// 1. 解析参数size_t textLen;napi_get_value_string_utf8(env, argv[0], nullptr, 0, &textLen);std::string text(textLen, 0);napi_get_value_string_utf8(env, argv[0], &text[0], textLen + 1, &textLen);size_t fontPathLen;napi_get_value_string_utf8(env, argv[1], nullptr, 0, &fontPathLen);std::string fontPath(fontPathLen, 0);napi_get_value_string_utf8(env, argv[1], &fontPath[0], fontPathLen + 1, &fontPathLen);int32_t fontSize;napi_get_value_int32(env, argv[2], &fontSize);// 2. 初始化freetypeFT_Library ft;if (FT_Init_FreeType(&ft)) {napi_throw_error(env, nullptr, "Could not init FreeType Library");return nullptr;}FT_Face face;if (FT_New_Face(ft, fontPath.c_str(), 0, &face)) {FT_Done_FreeType(ft);napi_throw_error(env, nullptr, "Failed to load font");return nullptr;}FT_Set_Pixel_Sizes(face, 0, fontSize);// 3. 计算位图宽高int width = 0, height = 0, baseline = 0;for (char c : text) {if (FT_Load_Char(face, c, FT_LOAD_RENDER))continue;width += face->glyph->bitmap.width;if ((int)face->glyph->bitmap.rows > height)height = face->glyph->bitmap.rows;if ((int)(face->glyph->bitmap_top) > baseline)baseline = face->glyph->bitmap_top;}height = baseline;// 4. 创建RGBA缓冲区std::vector<uint8_t> buffer(width * height * 4, 0);// 5. 渲染每个字符int x = 0;for (char c : text) {if (FT_Load_Char(face, c, FT_LOAD_RENDER))continue;FT_Bitmap &bitmap = face->glyph->bitmap;int y_offset = baseline - face->glyph->bitmap_top;for (int row = 0; row < bitmap.rows; ++row) {for (int col = 0; col < bitmap.width; ++col) {int buf_x = x + col;int buf_y = y_offset + row;if (buf_x < 0 || buf_x >= width || buf_y < 0 || buf_y >= height)continue; // Check boundsint buf_idx = (buf_y * width + buf_x) * 4;uint8_t gray = bitmap.buffer[row * bitmap.width + col];buffer[buf_idx + 0] = 255; // Rbuffer[buf_idx + 1] = 255; // Gbuffer[buf_idx + 2] = 255; // Bbuffer[buf_idx + 3] = gray; // A}}x += bitmap.width;}FT_Done_Face(face);FT_Done_FreeType(ft);// 6、使用stb_image_write.h 将 RGBA 转为 PNG 字节流std::vector<uint8_t> pngBuffer;auto pngWriteCallback = [](void *context, void *data, int size) {std::vector<uint8_t> *out = reinterpret_cast<std::vector<uint8_t> *>(context);out->insert(out->end(), (uint8_t *)data, (uint8_t *)data + size);};stbi_write_png_to_func(pngWriteCallback, &pngBuffer, width, height, 4, buffer.data(), width * 4);// 7、返回Uint8Array (PNG字节流)napi_value result;void *data;napi_create_arraybuffer(env, pngBuffer.size(), &data, &result);memcpy(data, pngBuffer.data(), pngBuffer.size());// 存放到沙箱中FILE* fp = fopen("/data/storage/el2/base/haps/entry/files/output_image2.png", "wb"); // /data/storage/el2/base/haps/entry/filesif (fp) {fwrite(pngBuffer.data(), 1, pngBuffer.size(), fp);fclose(fp);}napi_value uint8arr;napi_create_typedarray(env, napi_uint8_array, pngBuffer.size(), result, 0, &uint8arr);napi_value obj;napi_create_object(env, &obj);napi_set_named_property(env, obj, "data", uint8arr);napi_value w, h;napi_create_int32(env, width, &w);napi_create_int32(env, height, &h);napi_set_named_property(env, obj, "width", w);napi_set_named_property(env, obj, "height", h);return obj;
}
接着,在Init函数中对这个函数进行注册:
static napi_value Init(napi_env env, napi_value exports) {napi_property_descriptor desc[] = {{"renderTextToBitmap", nullptr, RenderTextToBitmap, nullptr, nullptr, nullptr, napi_default, nullptr}};napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);return exports;
}
最后一步,在index.d.ts文件将这个接口进行导出:
export function renderTextToBitmap(text: string,fontPath: string,fontSize: number
): { data: Uint8Array; width: number, height: number };
3.2 ArkTS层
在我们的renderTextToBitmap函数中,需要传入一个参数fontPath,字体路径(ttf文件),但是我将它放在rawfile目录下时,一直无法正确传入,所以我将ttf文件从rawfile目录中拷贝到沙箱目录,再将沙箱目录的存放路径传入native层,以下代码实现的是将ttf文件存入沙箱中:
// 拷贝到沙箱中
export async function copyTTfToSandBox(context: common.UIAbilityContext, fontFile: string): Promise<string> {try {const filesDir = context.filesDirconst resourceManager = context.resourceManagerconst dstDir = filesDir + '/font/' // /data/storage/el2/base/haps/entry/filesconsole.info('filesDir:', filesDir)// 检查目录是否存在,不存在则创建if (!fs.accessSync(dstDir)) {fs.mkdirSync(dstDir)}// 读取rawfile/font目录下的内容const arrayBuffer = await resourceManager.getRawFileContent('font/' + fontFile)if (!arrayBuffer || arrayBuffer.byteLength === 0) {console.error('rawfile 读取失败:', fontFile)return ""}const uint8Array = new Uint8Array(arrayBuffer)const dstPath = dstDir + fontFile// 如果已存在,先删除if (fs.accessSync(dstPath)) {fs.unlinkSync(dstPath)}// 写入沙箱目录const fd = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC)fs.writeSync(fd.fd, uint8Array.buffer)fs.closeSync(fd)// 检查写入结果const stat = fs.statSync(dstPath)console.info(`字体${fontFile}拷贝完成,大小:${stat.size}`)return dstPath} catch (e) {let err: BusinessError = e as BusinessErrorconsole.error('copyTTfFIleToSandBox Failed:' + err.message)return ""}
}
3.2.1 应用沙箱目录
再接着往下走之前,我想先向大家解释为什么需要将文件移到沙箱目录中,我们先来看看沙箱目录与rawfile目录的区别
A. 定义
应用沙箱是一种以安全防护为目的的隔离机制,避免数据受到恶意路径穿越访问。在这种沙箱的保护机制下,应用可见的目录范围即为”应用沙箱目录“。
- 对于每个应用,系统会在内部存储空间映射出一个专属的”应用沙箱目录“,它是”应用文件目录“与一部分系统文件(应用运行必需的少量系统文件)所在的目录组成的集合。
- 应用沙箱限制了应用可见的数据范围。在”应用沙箱目录“中,应用仅能看到自己的应用文件以及少量的系统文件。因此,本应用的文件也不为其它应用可见,从而保护了应用文件的安全。
- 应用以在”应用沙箱目录“下保存和处理自己的应用文件;系统文件及其目录对于应用是只读的;而应用若访问用户文件,则需要通过特定API同时经过用户的相应的授权才能进行。
下图展示了应用沙箱下,应用可访问的文件范围和方式:
B、应用沙箱目录与应用沙箱路径
在应用沙箱的保护机制下,应用无法获知除自身应用文件目录以外 的其它应用或用户的数据目录位置及存在。同时,所有应用的目录可见范围均经过权限隔离与文件路径挂载隔离,形成了独立的路径视图,屏蔽了实际物理路径:
- 如下图所示,在普通应用(也称三方应用)视角下,不仅可见的目录与文件数量限制了范围,并且可见的目录与文件路径也与系统进程等其它进程看到的不同。我们将普通应用视角下看到的“应用沙箱目录”下某个文件或具体目录的路径,称为“应用沙箱路径”
- 实际物理路径与沙箱路径并非1:1的映射关系,沙箱路径总是少于系统进程视角可见的物理路径。部分调试进程视角下的物理路径在对应的沙箱目录下没有对应的路径
C、应用文件目录与应用文件路径
如前文所述:“应用沙箱目录”内分两类:应用文件目录和系统文件目录
系统文件目录对应用的可见范围由HarmonyOS系统预置,开发者无需关注。
在此主要介绍应用文件目录,如下图所示。应用文件目录下的文件或目录路径称为应用文件路径,各文件路径具有不同的属性和特征
注意:禁止直接使用上图中四级目录之前的目录名组成的路径字符串,否则可能导致后续应用版本因应用文件路径变化导致的不兼容问题。
1.一级目录data/:应用文件目录
2.二级目录storage/:应用持久化文件目录
3.三级目录el1~el5:不同文件加密类型
EL1(Encryption Level 1):
- 保护设备上所有文件的基础安全能力。设备开机后,用户无需完成身份验证即可访问EL1保护的文件。除非有特殊需求,否则不建议使用此方法。
- 如果直接窃取设备存储介质上的密文,攻击者无法脱机进行解密。
EL2(Encryption Level 2):
- 在EL1的基础上,增加首次认证后的文件保护能力。设备开机后,用户在通过首次认证后,通过EL2能力保护的文件才能被访问。此后只要设备没有关机,通过EL2能力保护的文件一直可被访问。推荐应用默认使用该方式。
- 如果在关机后丢失手机,攻击者无法读取EL2保护的文件。
EL3(Encryption Level 3):
- 与EL4整体能力类似,但和EL4的区别是,在锁屏下可创建新的文件,但无法读取。如无特殊必要,无需使用该方式。
EL4(Encryption Level 4):
- 在EL2的基础上,增加设备锁屏时的文件保护能力。在用户锁屏时,通过EL4能力保护的数据将无法被访问。如无特殊必要,无需使用该方式。
- 如果设备在锁屏状态下被盗,攻击者无法读取EL4保护的文件。
EL5(Encryption Level 5):
- 在EL2的基础上,增加设备锁屏时的文件保护能力。在用户锁屏后,满足一定条件时,通过EL5能力保护的数据将无法被访问,但可以继续创建和读写新的文件。如无特殊必要,无需使用该方式。
- 默认情况下不会生成EL5的相关目录,应用若需要使用EL5目录,则需要配置访问E类加密数据库的权限。具体配置方法详见E类加密数据库的使用
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/encrypted_estore_guidelines
四级、五级目录:
通过ApplicationContext获取distributedfiles目录或base下的files、cache、preferences、temp等目录的路径,应用全局信息存放在这些目录下。
通过UIAbilityContext、AbilityStageContext、ExtensionContext可以获取HAP级别应用文件路径。HAP信息可以存放在这些目录下,存放在此目录的文件会跟随HAP的卸载而删除,不会影响App级别目录下的文件。在开发态,一个应用包含一个或者多个HAP,详见Stage模型应用程序包结构https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/application-package-structure-stage
3.2.2 rawfile目录
rawfile目录通常用于存放静态资源文件(比如字体、图片等)的目录,这些文件在应用打包时就被打包到应用中,并且可以在应用启动时直接访问
尽管rawfile中的资源文件可以在应用内部进行访问,但有时由于安全和权限限制,它可能无法直接作为路径提供给native层或与系统交互。例如,某些平台对访问rawfile资源有严格的权限控制,或者需要特殊的处理方式来获取这些资源。
咱们上面将字体文件从rawfile目录移到沙箱目录,并不仅仅是简单的路径切换问题,而是基于权限管理、安全性和操作系统文件访问机制的综合考虑。在沙箱中存放文件,可以保证应用数据的隔离性与安全性,同时避免权限限制和路径问题,确保文件能够正确传递到native层并进行处理
3.2.3 整合
一切都准备好了,现在我们可以在index中将这些小功能都整合在一起:
async aboutToAppear() {this.requestStoragePermission() // 向用户申请授权await copyTTfToSandBox(getContext(this) as common.UIAbilityContext, this.fontFile).then((fontPath: string) => { // 将ttf文件拷贝到沙箱目录下console.info('fontPath:', fontPath)this.fontPath = fontPath})console.info('this.fontPath:', this.fontPath)let result = testNapi.renderTextToBitmap("HarmonyOS", this.fontPath, 48) // 调用native层this.font_width = result.widththis.font_height = result.height}
最后一步,我们将图片在界面上进行渲染,在native层已经在沙箱目录中存放了output_image.png这张图片,说明native层的转换并未出错,而base64编码我也经过验证了,同样没有问题,但是依旧无法显示,此外,我还验证了使用转换工具将我PC上的一张大小为 10kb 的图片转成base64编码,发现可以正常渲染。无奈,我只能选择从沙箱目录中将native层存放的图片拉到本地,直接使用路径进行显示:
build() {Column() {Image($r('app.media.output_image2')).width(this.font_width).height(this.font_height)}.width('100%').height('100%').backgroundColor(Color.Black)}
我们来看看最终的渲染效果:
如果有朋友了解这个 Base64 渲染的问题,恳请不吝赐教,非常感谢!