FlexDesk 系列结束以后,这一轮换到一个明显不同的方向:HarmonyOS 7 三方 Native 库适配。

新 Demo 叫 JsonPulse。

它不做 UI 新特性,也不碰分布式能力,主线只有一件事:把一个成熟 C++ 三方库真正接进 ArkTS 工程,再把“能编译”推进到“能稳定调用、能测性能、能解释生命周期”。

这次选的库是 simdjson。

原因也很直接。它是一个成熟的 C++ JSON 解析库,官方提供单头文件接入方式,并长期强调 parser 复用、padding、输入缓冲区生命周期等工程边界。HarmonyOS 侧则通过 Node-API 把 ArkTS/JS 与 C/C++ 连接起来,非常适合用来做一条完整的三方 Native 适配链。

整个系列固定 6 篇:

01 CMake × Node-API:三方库编译、模块注册与首个解析接口
02 Uint8Array × Buffer Lifetime:TypedArray、padding 与零拷贝边界
03 napi_async_work × Background Parse:大文件异步解析与主线程回调
04 ThreadSafeFunction × Progress:Native 子线程进度、取消与线程安全
05 HAR × Prebuilt SO:预构建库、ABI、符号与分发打包
06 Metrics × Regression:多数据集、多ABI、性能与资源回归

第一篇先不追求极限性能。

只把最基础的事情做稳:

simdjson 能被 HarmonyOS 工程编译进来,Node-API 模块注册正确,ArkTS 可以调用一个稳定的 parseSummary() 接口。

本轮统一数据:

taskId:
native_20261003_01

project:
JsonPulse

library:
simdjson

nativeLib:
libjsonpulse.so

sourceFile:
orders_2_4mb.json

inputBytes:
2,516,582

records:
12,480

fields:
24

maxDepth:
6

buildType:
Release

cxxStandard:
C++17

moduleRegistered:
true

arktsCallSuccess:
true

parseCost:
18.6ms

bridgeCost:
2.9ms

totalCost:
21.5ms

parseErrors:
0

status:
NATIVE_BRIDGE_READY

一、第一篇不先谈 SIMD,而先解决“库到底属于谁”

Native 库适配最容易一上来就写:

ArkTS 调 C++
C++ 调 simdjson

但真正落到工程里,至少有三层:

ArkTS / JS
Node-API Bridge
Third-party C++

JsonPulse 先把目录拆开:

entry/src/main/cpp/
├─ native/
│  ├─ napi_init.cpp
│  ├─ jsonpulse.cpp
│  └─ jsonpulse.h
└─ third_party/
   └─ simdjson/
      ├─ simdjson.h
      └─ simdjson.cpp

这个结构有一个很实际的好处。

后面如果 simdjson 升级,只替换:

third_party/simdjson

业务桥接代码仍然在 native/。

三方源码不会和项目自己的 Node-API 逻辑搅在一起。

二、CMake 先把 C++17 和依赖关系写清楚

simdjson 官方文档当前推荐现代 C++ 编译环境,单头文件方式也很适合嵌进应用工程。

JsonPulse 当前用 Release 构建,并明确指定:

C++17

第一段代码解决的是:

如何让三方源码、项目 Native 源码和 HarmonyOS Node-API 依赖一次性进入同一个 so。

cmake_minimum_required(VERSION 3.10)
project(jsonpulse)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

add_library(jsonpulse SHARED
    native/napi_init.cpp
    native/jsonpulse.cpp
    third_party/simdjson/simdjson.cpp
)

target_include_directories(jsonpulse
    PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}/native
    ${CMAKE_CURRENT_SOURCE_DIR}/third_party/simdjson
)

target_compile_definitions(jsonpulse
    PRIVATE
    NDEBUG
)

target_link_libraries(jsonpulse
    PUBLIC
    libace_napi.z.so
    libhilog_ndk.z.so
)

这里 libace_napi.z.so 是 Node-API 桥接真正依赖的系统库。

libhilog_ndk.z.so 则用于 Native 侧 HiLog。

这一层如果没写对,ArkTS 代码再漂亮也没有意义。

三、模块名必须和 so 名对得上

HarmonyOS 当前 Node-API 规范对模块注册有明确要求:

一个 so 注册一个模块;

nm_modname
必须和 so 二进制名称匹配;

注册函数需要避免和其他 so 的构造函数重名。

JsonPulse 最终 so:

libjsonpulse.so

模块名:

jsonpulse

第二段代码解决的是“模块能不能被 ArkTS 正确 import”。

#include "napi/native_api.h"

static napi_value
ParseSummary(
    napi_env env,
    napi_callback_info info);

EXTERN_C_START

static napi_value Init(
    napi_env env,
    napi_value exports)
{
    napi_property_descriptor props[] = {
        {
            "parseSummary",
            nullptr,
            ParseSummary,
            nullptr,
            nullptr,
            nullptr,
            napi_default,
            nullptr
        }
    };

    napi_define_properties(
        env,
        exports,
        sizeof(props) /
        sizeof(props[0]),
        props);

    return exports;
}

EXTERN_C_END

static napi_module jsonPulseModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "jsonpulse",
    .nm_priv = nullptr,
    .reserved = { 0 }
};

extern "C"
__attribute__((constructor))
void RegisterJsonPulseModule()
{
    napi_module_register(
        &jsonPulseModule);
}

本轮:

moduleRegistered=true

这个状态不是页面自己写出来的。

Native 初始化成功以后才进入 Ready。

四、第一版接口故意只返回摘要,不把整个 DOM 搬回 ArkTS

2.4MB JSON 如果完整解析以后,又把整个对象树重新构造成 ArkTS Object:

Native 解析很快
→ Native → ArkTS 大量对象转换
→ 桥接成本重新变大

第一篇先定义一个很克制的接口:

parseSummary(json)

只返回:

records
fields
maxDepth
parseCost
errors

也就是说,先证明:

simdjson 的核心能力确实已经进入工程,而不是先把跨语言边界做成另一个性能瓶颈。

五、Native 侧 parser 负责 JSON,Node-API 只做语言边界

第三段代码解决的是:

Bridge 不参与 JSON 业务解析,只负责取参数和返回结果。

#include "jsonpulse.h"
#include "simdjson.h"
#include <chrono>

ParseSummaryResult
JsonPulse::Parse(
    const std::string& json)
{
    simdjson::dom::parser parser;

    const auto start =
        std::chrono::steady_clock::now();

    simdjson::dom::element doc;

    auto error =
        parser.parse(json)
              .get(doc);

    const auto end =
        std::chrono::steady_clock::now();

    ParseSummaryResult result {};

    result.parseCostMs =
        std::chrono::duration<double,
        std::milli>(
            end - start
        ).count();

    if (error) {
        result.errors = 1;
        return result;
    }

    result.records =
        CountRecords(doc);

    result.fields =
        CountTopLevelFields(doc);

    result.maxDepth =
        MeasureDepth(doc);

    return result;
}

这里真正“知道 JSON 结构”的是 JsonPulse C++ 层。

napi_init.cpp 只做:

ArkTS string
→ std::string
→ JsonPulse::Parse
→ napi object

边界非常清楚。

六、为什么第一篇先用 string,而不是马上上 Uint8Array

因为第一篇要验证的是:

Build
Register
Import
Call
Result

String 是最容易观察的输入方式。

如果 01 就同时引入:

ArrayBuffer
TypedArray
padding
native pointer
buffer lifetime

一旦出错,很难判断是模块注册有问题,还是 Buffer 生命周期有问题。

所以先用简单路径建立:

NATIVE_BRIDGE_READY

02 再专门优化输入边界。

七、ArkTS 侧的类型声明必须跟 so 一起维护

Node-API 成功注册以后,ArkTS 还需要清晰的类型定义。

export interface ParseSummary {
  records: number
  fields: number
  maxDepth: number

  parseCostMs: number
  errors: number
}

export const parseSummary:
  (json: string) =>
    ParseSummary

页面最终:

import jsonpulse
  from 'libjsonpulse.so'

const result =
  jsonpulse.parseSummary(
    jsonText
  )

这个 .d.ts 不是“可有可无的 IDE 提示”。

它是 ArkTS 和 Native API 契约的一部分。

后面接口升级时,也要跟 C++ 同步版本化。

八、2,516,582 bytes 不等于“2.4MB 很小,可以随便拷”

当前输入:

2,516,582 bytes

约 2.4MB。

字符串桥接时仍然会发生:

ArkTS string
→ UTF-8 length query
→ Native string buffer

本轮:

bridgeCost=2.9ms

占总耗时并不算特别高。

但输入继续变成 5MB、20MB、50MB 时,桥接成本会越来越值得关注。

这就是 02 为什么直接换成 Uint8Array。

九、18.6ms 只是 JsonPulse 当前测试数据

本轮:

records=12,480
fields=24
maxDepth=6
parseCost=18.6ms

这个数字来自当前测试设备、当前输入、当前 Release 构建。

不能写成:

simdjson 在 HarmonyOS 固定只要 18.6ms

simdjson 官方也强调:

数据结构
数字密度
parser 复用
输入方式
CPU 实现

都会影响性能。

这篇只把它当成后续回归基线。

十、为什么 Release 构建要显式 NDEBUG

simdjson 官方性能建议里提到:

Release
建议定义 NDEBUG

因为:

-O2 / -O3

本身不一定自动设置这个宏。

JsonPulse 当前 CMake 显式:

target_compile_definitions(
    jsonpulse
    PRIVATE
    NDEBUG
)

第一篇就把构建配置记录下来,后面性能数据才有比较意义。

十一、解析错误必须从 Native 返回,不要在 C++ 里吞掉

如果输入 JSON 非法:

ParseSummary

不能只返回:

records=0

否则 ArkTS 根本不知道:

真的没有记录
还是解析失败。

JsonPulse 当前返回:

errors
errorCode

并在 Bridge 层转成稳定的业务结果或抛出明确异常。

本轮:

parseErrors=0

只是测试输入有效,不代表错误处理可以省。

十二、DevEco 图重点看三层工程边界

开发图:

左侧目录明确分:

native
third_party
ets

中间:

CMake
模块注册
ParseSummary

右侧模拟器统一展示:

taskId=
native_20261003_01

records=
12,480

parse=
18.6ms

bridge=
2.9ms

total=
21.5ms

status=
NATIVE_BRIDGE_READY

底部 HiLog 则把同一套数据打印出来。

十三、手机图不是“性能宣传”,而是一次可追踪任务

运行图:

这次任务完整信息:

orders_2_4mb.json

2,516,582 bytes

12,480 records

24 fields

depth 6

18.6ms native parse

2.9ms bridge

21.5ms total

0 errors

最终:

NATIVE_BRIDGE_READY

说明第一条 ArkTS → Node-API → simdjson → ArkTS 的闭环已经成立。

十四、第一篇最后固定六组反向测试

第一组,合法 2.4MB JSON,正常返回摘要。

第二组,非法 JSON,errors 不为 0。

第三组,ArkTS 传空字符串,Native 返回明确参数错误。

第四组,模块名与 so 名不匹配,构建阶段直接发现。

第五组,Debug 与 Release 分别构建,性能数据只采 Release。

第六组,连续调用 20 次,模块不会重复注册,结果一致。

全部通过以后:

NATIVE_BRIDGE_READY

才成立。

十五、下一篇真正开始处理“数据怎么跨语言”

01 当前最明显的额外成本来自:

ArkTS string
→ Native string

而 JsonPulse 实际业务里的 JSON 很多来自:

文件
网络二进制响应
压缩包
数据库 blob

它们本来就可以表现成:

Uint8Array

02 会继续同一个工程,改成:

Uint8Array
→ napi_get_typedarray_info
→ Native pointer
→ simdjson parse_unpadded

重点处理:

padding
buffer 生命周期
同步零拷贝边界
异步场景不能偷留裸指针

真正进入 Native 性能优化阶段。

十六、Node-API 的模块边界要比业务接口更稳定

很多 Native Demo 的第一版会直接把 C++ 函数一股脑导出来:

parse
parseFile
parseCount
getField
getValue
getArray
...

短期很方便,长期会让 ArkTS 和 C++ 紧紧绑在一起。

JsonPulse 第一篇只暴露:

parseSummary

并不是因为 simdjson 只能做这点事,而是因为跨语言 API 一旦进入业务代码,就应该尽量稳定。

内部以后可以从:

simdjson DOM

换成:

On-Demand

也可以增加 parser pool,ArkTS 都不需要知道。

跨语言边界最好表达业务能力,不要把三方库的每一个方法一比一翻译成 ArkTS API。

否则未来三方库升级、函数签名调整,会把改动扩散到整个页面层。

十七、C++ 异常不要直接穿过 Node-API 边界

三方 C++ 库可能:

返回错误码;
返回 result<T>;
或者抛异常。

Node-API 边界不应该让 C++ 异常直接逃出。

JsonPulse 的 Bridge 会统一:

try
→ Native parse

catch std::exception
→ napi_throw_error

未知异常
→ napi_throw_error(NATIVE_UNKNOWN)

ArkTS 最终只接收两种结果:

成功的 ParseSummary

或
明确的 JS / ArkTS Error

不会出现 Native 层异常栈直接把应用打崩的情况。

这个错误归一化层会继续沿用到 03 的异步任务。

十八、Parser 是否复用要先考虑线程模型

simdjson 官方文档建议复用 parser,避免重复分配内部容量。

JsonPulse 01 没有急着做:

global static parser

因为 03 以后会进入异步线程。

如果一个全局 parser 被多个任务同时使用,会直接引入线程安全和文档生命周期问题。

当前策略是:

01 / 02
同步单任务,局部 parser;

03
异步任务每个 work item 自己持有 parser;

06
再根据实际数据决定是否做 per-thread parser cache。

优化应该建立在明确线程 Owner 之上,而不是为了少一次分配先放一个全局单例。

十九、Native API 的返回对象也要控制字段数量

跨语言调用还有一个容易忽略的成本:

napi_create_object
napi_set_named_property
napi_create_string
napi_create_double

如果 C++ 解析完 12,480 条记录,然后给 ArkTS 创建 12,480 个对象,桥接成本可能远高于解析本身。

所以第一篇故意只返回:

records
fields
maxDepth
parseCost
errors

这是一种“Native 计算,ArkTS 消费摘要”的接口形态。

以后如果页面确实要展示 100 条数据,可以再做:

分页
字段投影
局部结果

而不是默认把完整 DOM 跨语言复制一遍。

二十、模块注册成功也要检查 ArkTS 声明和实际导出是否一致

一种常见错误是:

C++ 导出 parseSummary

.d.ts 写 parseJson

ArkTS 编译可能通过某些宽松声明
运行时却找不到方法。

JsonPulse 在启动自检里会执行:

typeof jsonpulse.parseSummary

并记录:

arktsCallSuccess=true

后续 HAR 打包时,这条检查仍然保留。

因为 Native 库最难排查的故障之一就是:

so 已经加载
但导出的属性名和声明文件不一致。

二十一、第三方源码不要直接在业务目录里魔改

适配三方库时很容易为了“先编过去”直接改:

third_party/simdjson/simdjson.cpp

几行。

下一次升级库版本时,这些改动就很难找回来。

JsonPulse 采用:

third_party/
保持接近上游源码;

native/compat/
放项目适配层;

CMake
负责编译开关。

如果必须补 Patch,也单独保存 patch 文件或 Git commit。

这样 05 做预构建库和版本升级时,才能清楚知道哪些是上游代码,哪些是 HarmonyOS 适配代码。

二十二、C++ ABI 边界从第一篇就要避免泄漏 STL 类型

HarmonyOS 当前 C/C++ 标准库机制说明里专门提醒了系统和应用侧 C++ 运行库的隔离问题。

虽然 JsonPulse 现在所有代码都编进同一个应用 so,但为了后面把 simdjson 单独封成预构建库,接口层已经避免对外暴露:

std::string
std::vector
std::map

这类 STL 类型作为二进制 ABI。

对外层更倾向:

const uint8_t*
size_t
POD struct
C function

或者直接通过 Node-API 返回 ArkTS 值。

这样未来三方库换编译版本时,不会让业务模块被 STL ABI 细节绑死。

二十三、HiLog 里必须能看出“桥接慢”还是“解析慢”

第一篇最终日志拆成:

bridgeCost
parseCost
totalCost

如果以后变慢:

parseCost 18.6 → 18.9
bridgeCost 2.9 → 20

问题明显在跨语言或字符串转换。

反过来:

bridge 仍然 3ms
parse 变成 80ms

才去查:

输入结构
simdjson 配置
构建优化
CPU 实现

这种分段证据比一个 total=83ms 更有工程价值。

二十四、第一篇真正形成的是一条可升级的 Native 接入协议

做到这里,JsonPulse 01 已经固定:

三方源码独立目录;

CMake 明确 C++ 标准和系统库;

so 名与 nm_modname 一致;

模块只注册一次;

Node-API 只做语言边界;

业务解析留在 C++ Service;

ArkTS 用 d.ts 描述稳定接口;

日志拆分 bridge / parse / total。

这套协议后面即使把 simdjson 换成其他 C++ 库,也能继续复用。

第一篇的价值不是“JSON 解析快了多少”,而是 HarmonyOS 工程终于有了一条可维护的三方 Native 入口。

二十五、首个 Native 接口还要做版本号自检

JsonPulse 在模块里额外暴露一个轻量 getNativeVersion(),返回当前 Bridge 版本、simdjson 集成修订号和编译类型。页面启动时会把它和 ArkTS 侧期望版本比较。如果应用更新了 .d.ts,但安装包里仍然带着旧 so,就能在真正调用解析前直接报出 NATIVE_VERSION_MISMATCH。这类问题在增量构建、共享 HAR 或多人协作时并不少见。相比“方法不存在”或“字段少了一个”这种运行时故障,启动自检更容易定位。

同时,版本号不直接等同于上游 simdjson 版本。JsonPulse 维护的是自己的 Bridge Revision,因为同一个上游版本可能对应不同的 CMake 开关、Patch 和导出接口。后续 05 做预构建 so 时,这个版本信息还会进入包内 Manifest。

二十六、第一篇的工程验收还要包含“卸载后重新安装”

Native 模块最容易被本地开发环境掩盖的问题之一,是旧 so 残留。JsonPulse 因此把干净安装也放进 01 的验收:清理应用数据、重新构建 Release、安装后首次启动,再执行 parseSummary。如果只有热更新或增量编译能工作,而干净安装失败,说明打包链并没有真正闭环。最终这条用例通过以后,才把 NATIVE_BRIDGE_READY 当作可继续演进的基线。

参考资料

  • HarmonyOS Node-API 跨语言调用:
    https://developer.huawei.com/consumer/cn/doc/doccenter-games/games-universal-using-napi-interaction-0000002411166425
  • Node-API 开发规范:
    https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/napi-guidelines
  • C/C++ 标准库机制:
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/c-cpp-overview
  • simdjson:
    https://github.com/simdjson/simdjson
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐