Kotlin Multiplatform Native 鸿蒙跨平台开发实战

Viewed 0

前言

之前在《Kotlin Multiplatform 跨平台支持鸿蒙》和《Kotlin Multiplatform 封装鸿蒙API》中介绍了利用 Kotlin/JS 能力支持鸿蒙,通过把逻辑放在 commonMain 中,实现 Android、iOS、Harmony 共享逻辑。在这个过程中,遇到一些限制,比如访问文件、数据库、多线程、多媒体、网络功能等,不能编写出一个共用 API 让 Android、iOS、Harmony 平台都调用,只有定义接口让各平台实现才能适配共享逻辑。为了突破限制,利用 Kotlin/Native 从底层访问系统能力以达到更丝滑、更简单和更容易的方式支持跨平台共享逻辑。

鸿蒙 NDK

要进行 Native 开发,Android 有 NDK,鸿蒙也有 NDK。

鸿蒙 NDK 前置知识包括:Linux C语言编程知识、CMake使用知识、Node Addons开发知识以及Clang/LLVM编译器使用知识。

现在需要复用已有C或C++库,也就是在鸿蒙项目中依赖三方库( *.so)-Kotlin/Native 产物。主要了解一下鸿蒙 Native 通用能力、目标架构和 Node-API。

通用能力:鸿蒙基于 linux 内核,可以使用 libc,libc++,zlib,OpenGL,以及 POSIX 标准 等,所以完全支持文件操作、线程、网络等能力。

目标架构:linux_arm64。鸿蒙手机、平板等移动设备使用 arm64 架构。

Node-API:Java/Kotlin 代码与 Native(C/C++)代码进行交互需要 JNI,ArkTS/JS 代码与 Native(C/C++)代码需要 Node-API。鸿蒙 NDK 和 Android NDK 的角色相似,Node-API 的角色和 JNI 的角色相似。

毕昇编译器简介

毕昇编译器是基于LLVM开源软件开发的一款用于C/C++等语言的native编译器,能将C/C++代码工程编译链接成可以在设备上运行的二进制。在无需改动用户代码的条件下,相比业界主流的开源LLVM或GCC编译器,毕昇编译器能提供更强大的优化能力,使编译链接出来的二进制的运行时长更短、指令数更少,帮助提升应用在设备上的运行流畅度。

如果在鸿蒙中依赖三方库( *.so),那么三方库最好使用毕昇编译器编译。鸿蒙 llvm 路径: /Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/native/llvm

交叉编译时指定 target 和 sysroot:使用 --target=aarch64-linux-ohos 参数通知编译器生成相应架构下符合HarmonyOS ABI的二进制文件:linux_arm64;使用 --sysroot=/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/native/sysroot 参数告知编译器HarmonyOS系统头文件的所在位置。

Kotlin/Native

对 Linux 提供的基础能力

Kotlin/Native 对 Linux 提供的基础能力可以在 ~/.konan/kotlin-native-prebuilt-macos-aarch64-2.1.10-RC2/klib 文件目录中找到,包括 common(标准库)和 platform(平台库)。在 platform 中支持 linux_arm32_hfplinux_arm64linux_x64

linux_arm64 目录包含以下 klib:

  • org.jetbrains.kotlin.native.platform.builtin:C 标准库(内存、数学、字符串处理)
  • org.jetbrains.kotlin.native.platform.iconv:字符编码转换
  • org.jetbrains.kotlin.native.platform.linux:Linux 专用接口(文件监听、高性能 I/O)
  • org.jetbrains.kotlin.native.platform.posix:POSIX 标准接口(文件、线程、网络通信)
  • org.jetbrains.kotlin.native.platform.zlib:数据压缩与解压

通过 klib 就能访问这些能力,例如 org.jetbrains.kotlin.native.platform.posix 提供了 platform.posix.* API,通过 import platform.posix.* 就可以访问文件、线程、网络等能力。

通过 @CName 指定导出符号

下载 Kotlin/Native 项目模版,将 Kotlin 版本更新到最新。

首先通过 org.jetbrains.kotlin.native.platform.posix 写一个简单的文件读取 FileUtil.kt

import kotlinx.cinterop.ExperimentalForeignApi
import platform.posix.EOF
import platform.posix.fclose
import platform.posix.fgetc
import platform.posix.fopen
import platform.posix.fputs

@OptIn(ExperimentalForeignApi::class)
fun writeFile(filePath: String, content: String) {
    val file = fopen(filePath, "w") ?: throw Exception("File cannot be opened")
    fputs(content, file)
    fclose(file)
}

@OptIn(ExperimentalForeignApi::class)
fun readFile(filePath: String): String {
    val file = fopen(filePath, "r") ?: throw Exception("File cannot be opened")
    val content = mutableListOf<Char>()
    var c: Int
    while (true) {
        c = fgetc(file)
        if (c == EOF) break
        content.add(c.toChar())
    }
    fclose(file)
    return content.joinToString("")
}

FileUtil.kt 放在 nativeMain 中,可以给 Android、iOS、Harmony 使用。

linuxArm64Main 中给 Harmony 使用,新建立 HarmonyFileUtil.kt

import kotlin.experimental.ExperimentalNativeApi

@OptIn(ExperimentalNativeApi::class)
@CName("writeFile")
fun harmonyWriteFile(filePath: String, content: String) {
    return writeFile(filePath, content)
}

@OptIn(ExperimentalNativeApi::class)
@CName("readFile")
fun harmonyReadFile(filePath: String): String {
    return readFile(filePath)
}

@CName 注解的作用是使顶层函数可从 C/C++ 代码中访问。@CName(externName="writeFile") 指定导出的符号为 writeFile@CName(externName="readFile") 指定导出的符号为 readFile

对于 Android,在 androidNativeArm64Main 中建立 AndroidFileUtil.kt 并生成符合 JNI 规范的 .so,通过符号 Java_com_wj_mylibrary_NativeLib_writeFileJava_com_wj_mylibrary_NativeLib_readFile 访问相应函数。

回到鸿蒙,鸿蒙是否能直接生成符合 Node-API 规范的 .so?利用 Kotlin/Native 的 C Interop@CName 是可以做到的。

编译目标架构

在 build.gradle.kts 中配置:

kotlin {
    val linuxTargets = listOf(linuxArm64())
    linuxTargets.forEach {
        it.binaries {
            executable()
            sharedLib {
                baseName = "kn"
            }
        }
    }
}

给鸿蒙使用,指定 linuxArm64() 即可。在命令行执行 ./gradlew linkReleaseSharedLinuxArm64 --info,在 build/bin/linuxArm64/releaseShared 目录下会生成动态库 libkn.so 和头文件 libkn_api.h

头文件 libkn_api.h 中包含外部函数声明,如 extern const char* readFile(const char* filePath);extern void writeFile(const char* filePath, const char* content);,以及类型映射和入口函数 extern libkn_ExportedSymbols* libkn_symbols(void);。通过添加 @CName 注解可以直接访问 writeFilereadFile,或者通过 libkn_symbols() 函数访问 harmonyWriteFileharmonyReadFile

查看 libkn.so 信息,显示为 ARM 64 位架构编译的动态共享库。查看依赖时,发现 libkn.so 依赖多个库,如 libresolv.so.2、libm.so.6 等。在 FileUtil.kt 中只使用了文件操作功能,明显不需要那么多库。

在 gradle.build.kts 中添加按需链接 -as-needed

val linuxTargets = listOf(linuxArm64())
linuxTargets.forEach {
    it.binaries {
        executable {
            this.compilation.compileTaskProvider.configure {
                this.compilerOptions.freeCompilerArgs.addAll(
                    listOf("-linker-options", "-as-needed")
                )
            }
        }
        sharedLib {
            baseName = "kn"
        }
    }
}

编译后依赖减少,但仍依赖 libgcc_s.so.1。由于鸿蒙使用毕昇编译器(基于LLVM),libkn.so 不能依赖 libgcc_s.so.1,否则在鸿蒙中使用会崩溃。要让 libkn.so 不依赖 libgcc_s.so.1,可以选择静态链接或使用鸿蒙毕昇编译器交叉编译。这里选择静态链接。

Kotlin/Native 项目构建时,编译工具链配置文件 konan.properties 中的 linkerGccFlags 配置强制链接 libgcc_s。去除依赖 libgcc_s.so.1,在 gradle.build.kts 修改 linkerGccFlags:

val linuxTargets = listOf(linuxArm64())
linuxTargets.forEach {
    it.binaries {
        executable {
            entryPoint = "main"
            this.compilation.compileTaskProvider.configure {
                this.compilerOptions.freeCompilerArgs.addAll(
                    listOf(
                        "-Xoverride-konan-properties=linkerGccFlags=-lgcc",
                        "-linker-options", "-as-needed",
                    )
                )
            }
        }
        sharedLib {
            baseName = "kn"
        }
    }
}

再编译后,libkn.so 依赖进一步减少,符合要求,可以在鸿蒙中使用。

鸿蒙接入 Kotlin/Native So

首先在鸿蒙项目中创建 Native C++ 模块 khn。将 libkn.so 放在 khn/libs/arm64-v8a/ 目录下,将 libkn_api.h 放在 khn/libs/arm64-v8a/include 目录下。然后修改配置文件 CMakeLists.txt

cmake_minimum_required(VERSION 3.5.0)
project(khn)

set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})

if(DEFINED PACKAGE_FIND_FILE)
    include(${PACKAGE_FIND_FILE})
endif()

get_filename_component(PROJECT_MAIN_DIR "${CMAKE_CURRENT_SOURCE_DIR}" DIRECTORY)
get_filename_component(PROJECT_SRC_DIR "${PROJECT_MAIN_DIR}" DIRECTORY)
get_filename_component(PROJECT_DIR "${PROJECT_SRC_DIR}" DIRECTORY)

include_directories(${NATIVERENDER_ROOT_PATH}
                    ${NATIVERENDER_ROOT_PATH}/include)

add_library(khn SHARED napi_init.cpp)
target_include_directories(khn PUBLIC ${PROJECT_DIR}/libs/arm64-v8a/include)
target_link_libraries(khn PUBLIC libace_napi.z.so)
target_link_libraries(khn PUBLIC ${PROJECT_DIR}/libs/arm64-v8a/libkn.so)

接下来在 napi_init.cpp 中利用 Node_API 实现 writeFile 和 readFile 功能:

#include "napi/native_api.h"
#include "libkn_api.h"
#include <cstring>

static napi_value NAPI_Global_writeFile(napi_env env, napi_callback_info info) {
    size_t argc = 2;
    napi_value args[2] = {nullptr};
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    size_t filePathLength = 0;
    napi_get_value_string_utf8(env, args[0], nullptr, 0, &filePathLength);
    char *filePath = new char[filePathLength + 1];
    std::memset(filePath, 0, filePathLength + 1);
    napi_get_value_string_utf8(env, args[0], filePath, filePathLength + 1, &filePathLength);
    size_t contentLength = 0;
    napi_get_value_string_utf8(env, args[1], nullptr, 0, &contentLength);
    char *content = new char[contentLength + 1];
    std::memset(content, 0, contentLength + 1);
    napi_get_value_string_utf8(env, args[1], content, contentLength + 1, &contentLength);
    writeFile(filePath, content);
    delete[] filePath;
    delete[] content;
    return nullptr;
}

static napi_value NAPI_Global_readFile(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1] = {nullptr};
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    size_t filePathLength = 0;
    napi_get_value_string_utf8(env, args[0], nullptr, 0, &filePathLength);
    char *filePath = new char[filePathLength + 1];
    std::memset(filePath, 0, filePathLength + 1);
    napi_get_value_string_utf8(env, args[0], filePath, filePathLength + 1, &filePathLength);
    const char *content = readFile(filePath);
    napi_value result = nullptr;
    napi_status status = napi_create_string_utf8(env, content, strlen(content), &result);
    delete[] filePath;
    if (status != napi_ok) {
        return nullptr;
    };
    return result;
}

EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
    napi_property_descriptor desc[] = {
        {"writeFile", nullptr, NAPI_Global_writeFile, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"readFile", nullptr, NAPI_Global_readFile, nullptr, nullptr, nullptr, napi_default, nullptr}};
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}
EXTERN_C_END

static napi_module demoModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "khn",
    .nm_priv = ((void *)0),
    .reserved = {0},
};

extern "C" __attribute__((constructor)) void RegisterKhnModule(void) { napi_module_register(&demoModule); }

通过 #include "libkn_api.h" 访问 writeFile 和 readFile 或 harmonyWriteFile 和 harmonyReadFile。

实现功能后,在 khn/src/main/cpp/types/libkhn/Index.d.ts 定义方法导出:

export const writeFile: (filePath: string, content: string) => void;
export const readFile: (filePath: string) => string;

在 khn/src/main/ets/pages/KotlinNative.ets 再包装一下:

import { readFile, writeFile } from 'libkhn.so';

export function nativeWriteFile(filePath: string, content: string): void {
  writeFile(filePath, content);
}

export function nativeReadFile(filePath: string): string {
  return readFile(filePath);
}

将 KotlinNative.ets 给其它模块使用,khn/Index.ets 导出。鸿蒙项目中任意模块依赖 khn 模块:

"dependencies": {
  "khn": "file:../khn",
}

在模块中使用测试代码,点击按钮后,通过 Kotlin/Native 生成的 libkn.so 在 Harmony 平台成功运行,实现文件读写功能。

总结

通过 Kotlin/Native 直接访问系统底层文件、网络、多媒体、多线程等功能,可以突破 Kotlin/Android、Kotlin/iOS、Kotlin/JS 上层的限制,达到真正的一个API在 Android、iOS、Harmony 平台使用,而且还能保证良好的性能。

在 Kotlin/Native 给鸿蒙使用的过程中,由于 Kotlin/Native 构建 linux_arm64 与鸿蒙 Native 构建 linux_arm64 编译工具链的不同,导致 Kotlin/Native 生成的动态库在鸿蒙平台运行会有找不到符号、找不到依赖库的问题。虽然在去除依赖上能解决问题,但是最好还是可以使用鸿蒙 NDK 编译工具链进行交叉编译。

0 Answers