前言

先说这次接入的方向:CJMP 的 logic-module/native,也就是公共逻辑用仓颉写,应用界面继续使用平台原生技术。仓颉是语言,CJMP 是框架,两者的名字不能混着填。这篇用 slicedeque4cj 做例子,把库构建成鸿蒙 HAR,再让 MyApplication42 通过库接口调用它。

队列有个值得动手验证的特点:队尾绕回开头以后,逻辑数据仍然能用一段连续指针访问。这个“连续”靠相邻虚拟地址映射实现,没有把 toArray 复制出的快照当作底层连续内存。后面既看如何拆库、打包,也看跨界以后指针究竟读到了什么。

欢迎加入 CJMP 社区,交流跨平台逻辑库与鸿蒙接入

最初做通的是 ArkTS 直接调用 C NAPI 的原生示例。它能运行,但还没有 CJMP 的逻辑模块配置。这里补上官方模板、cjpm 公共包、HAR 门面和应用依赖,并重新安装到模拟器与 MatePad Edge。下面这段就是新版库调用路径的真实操作。

上游 slice-deque 源码与实现说明

保留 slice-deque 0.3.0 固定源码与原始许可,根目录 src 是仓颉队列实现,harmony/native 是鸿蒙后端。CJMP 接入示例新增在 example/cjmp/slicedeque4cj;旧 example/harmony 示例作为原生桥接参考保留。当前复现以新 CJMP 目录为准。

项目完整源码:aasd23/slicedeque4cj

README:接口、适配改动、构建与验证说明

CJMP 示例:公共逻辑模块、鸿蒙 HAR 与 MyApplication42 页面

开发环境:把版本说清楚

基础环境搭建直接参考 CPF-RN 组织的环境初始化文件,本文集中写库接入。CJMP 的 SDK 与仓颉配套关系还要对照官方开发准备:本次使用 Tools 0.2.2 的 native 逻辑模块模板,鸿蒙端编译由本机 DevEco 和仓颉工具链完成。

CJMP 官方开发准备与配套工具

CPF-RN:环境初始化

仓颉官方文档与 SDK 入口

项目

本次实际版本或设备

CJMP Tools

0.2.2;logic-module / native 模式

DevEco Studio

6.1.1.300

HarmonyOS SDK

6.1.1(24)

仓颉编译器 / cjpm

1.1.3

模拟器

x86_64 ABI

真机

HUAWEI MatePad Edge,arm64-v8a,6.1.1.120

应用包名

com.aasd23.slicedeque42

活动表优先使用 HarmonyOS 7.0 / API 26,这次实际跑的是 API 24,不能把版本号改成 7.0 就算验证。官方 CJMP v0.2.2 文档推荐 6.1 开发配套,并列出仓颉 1.1.0-beta.22;本机是 1.1.3,因此对编译目录和原生桥做了明确移植。这篇记录的是这个版本组合的结果,API 26 尚未实测。

CJMP v0.2.2 发布说明:配套版本和支持范围

适配过程:先把库从页面里拆出来

先保留原始 Rust 代码,再把队列接口和鸿蒙后端分开。新增 CJMP 工程时选 logic-module/native:common 放公共算法,hos 放平台桥,hos/slicedeque4cj 产出 HAR,hos/entry 提供调用页面。这样查问题时能沿着“库、桥、应用”三层往下看。

文件或目录

为什么要改

改动内容

src/slice_deque.cj

提供可复用队列

泛型、双端操作、连续指针、Resource 生命周期

harmony/native/mirror.c、mirror.h

补鸿蒙存储后端

memfd、文件后备、相邻 mmap、错误清理

CJMP 示例/project.conf

建立正式逻辑模块

type=logic-module,appType=native

logic-module/cjpm.toml、common

把公共代码构建成库包

两个鸿蒙目标、Native 链接、核心内容一致性检查

logic-module/hos/native、arkts

集中鸿蒙接入代码

C ABI/NAPI 桥及 SliceDequeBridge 门面

hos/slicedeque4cj/hvigorfile.ts

在 HAR 打包前编译真实库

执行 cjpm、整理两个 ABI 的动态依赖

hos/entry/oh-package.json5、Index.ets

由应用消费库

依赖 slicedeque4cj 模块,通过门面调用

tests/test_runner.cj、docs/verification

重新验证移植结果

共享断言、设备记录、HAR/HAP 构建日志

公共核心与根目录 src 保持一致,构建前会比较内容;不一致就停止,避免文章对应一份代码、应用又跑另一份。例子里的测试也来自同一套断言,界面只负责展示库返回的容量、head 和元素。

队列核心:跨界以后为什么还能连续读取

3.1 两段地址,映射同一份存储

后端先申请两倍长度的连续虚拟地址范围,再把同一个文件描述符、同一个偏移分别映射到前后两段。第二段相当于第一段的另一扇窗口,改动一边,另一边能读到同样的数据。队列起点靠近尾部时,指针继续往后走,就会进入第二段映射。

这里直接给出 harmony/native/mirror.c 的完整分配函数。MAP_FIXED 使用的地址来自前面自行预留的范围;第一个或第二个映射失败,都要连同已经取得的资源一起清理。

void *sd_alloc(uint64_t requested_bytes, uint64_t stride, const char *backing_dir) {
    saved_error = 0;
    uint64_t page = sd_page_size();
    if (!page || !stride || !requested_bytes) { saved_error = EINVAL; return NULL; }
    uint64_t divisor = gcd(page, stride);
    if (stride > SIZE_MAX / (page / divisor)) { saved_error = EOVERFLOW; return NULL; }
    uint64_t unit = (page / divisor) * stride;
    if (requested_bytes > SIZE_MAX - (unit - 1)) { saved_error = EOVERFLOW; return NULL; }
    size_t bytes = (size_t)(((requested_bytes + unit - 1) / unit) * unit);
    if (bytes > PTRDIFF_MAX / 2) { saved_error = EOVERFLOW; return NULL; }

    int backend = 1;
    int fd = memfd_create("slicedeque4cj", MFD_CLOEXEC);
    if (fd >= 0 && ftruncate(fd, (off_t)bytes) != 0) {
        saved_error = errno; close(fd); fd = -1;
    }
    if (fd < 0) {
        if (!saved_error) saved_error = errno;
        if (!backing_dir || backing_dir[0] != '/') return NULL;
        char path[PATH_MAX];
        int length = snprintf(path, sizeof(path), "%s/slicedeque-XXXXXX", backing_dir);
        if (length < 0 || (size_t)length >= sizeof(path)) { saved_error = ENAMETOOLONG; return NULL; }
        fd = mkstemp(path); // exclusive creation, mode 0600
        if (fd < 0) { saved_error = errno; return NULL; }
        if (unlink(path) != 0 || fcntl(fd, F_SETFD, FD_CLOEXEC) != 0 || ftruncate(fd, (off_t)bytes) != 0) {
            saved_error = errno; close(fd); unlink(path); return NULL;
        }
        backend = 2;
    }
    unsigned char *base = mmap(NULL, bytes * 2, PROT_NONE,
                              MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (base == MAP_FAILED) { saved_error = errno; close(fd); return NULL; }

    // MAP_FIXED is used only inside the virtual address range reserved above.
    void *first = mmap(base, bytes, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, fd, 0);
    if (first == MAP_FAILED) {
        saved_error = errno; munmap(base, bytes * 2); close(fd); return NULL;
    }
    void *second = mmap(base + bytes, bytes, PROT_READ | PROT_WRITE,
                        MAP_SHARED | MAP_FIXED, fd, 0);
    if (second == MAP_FAILED) {
        saved_error = errno; munmap(base, bytes * 2); close(fd); return NULL;
    }
    close(fd);
    Mirror *m = malloc(sizeof(*m));
    if (!m) { saved_error = ENOMEM; munmap(base, bytes * 2); return NULL; }
    m->base = base; m->bytes = bytes; m->backend = backend;
    saved_error = 0;
    return m;
}

memfd 是优先使用的后端。不过,在这次模拟器的 HDC shell 进程里,memfd 后的 ftruncate 返回过权限错误;进入应用进程以后,两台设备页面显示的后端都是 memfd。因此不能把某个调用身份下的错误直接推断成整台设备不支持。

为覆盖受限环境,调用者可以传入可写目录。后备方案用 mkstemp 创建临时文件,立即解除目录链接,再由文件描述符支撑映射。应用中传的是自己的 cacheDir,命令行测试使用 /data/local/tmp。文件后备可能发生磁盘 I/O,解除目录链接也不意味着数据一定只存在 RAM 中。

3.2 页大小之外,还要考虑元素大小

容量不能只按“元素个数乘元素大小”来分配。映射长度既要满足系统页对齐,也要能整除元素大小。代码按两者的最小公倍数向上取整;测试中还放了一个 24 字节的 C 兼容结构体,专门检查不能简单拿页大小除元素大小的情况。

所以,申请几个 Int64 后看到实际容量为 512,并不是多插入了元素。这次系统页为 4096 字节,一个 Int64 占 8 字节,最小的一份后备存储就能容纳 512 个值。元素数和容量是两回事,页面把它们分别显示。

3.3 仓颉侧的入队与扩容

SliceDeque<T> 的类型约束是 T <: CType,适用于整数、浮点及满足 C 互操作要求的 @C struct。String 和普通类实例不在这版支持范围内;里面若包含外部指针,复制元素也不会自动复制指针指向的对象。

队首插入时先确保还有容量,再移动 head。head 已经为零就移到容量末端;后面的连续访问交给映射处理。下面是源码中的两个完整入队方法:

public func pushFront(value: T): Unit {
        reserve(1)
        head = if (head == 0) { cap - 1 } else { head - 1 }
        unsafe { (base + head).write(value) }
        count++
    }
public func pushBack(value: T): Unit {
        reserve(1)
        unsafe { (base + head + count).write(value) }
        count++
    }

扩容则重新申请一组更大的映射,按当前逻辑顺序复制元素,成功后再释放旧映射。这样新分配失败时,原队列还保留着。扩容完成后 head 回到零,数据顺序不变。

private func allocate(elements: Int64): Unit {
        if (stride <= 0) { throw IllegalArgumentException("Zero-sized CType is unsupported") }
        if (elements > Int64.Max / stride) { throw IllegalArgumentException("Capacity overflow") }
        let path = unsafe { LibC.mallocCString(backingDir) }
        let next = unsafe { sd_alloc(UInt64(elements * stride), UInt64(stride), path) }
        unsafe { LibC.free(path) }
        if (next.isNull()) {
            let error = unsafe { sd_last_error() }
            throw IllegalStateException("Mirror mapping failed, errno=${error}")
        }
        let nextBase = unsafe { CPointer<T>(sd_data(next)) }
        let nextCap = Int64(unsafe { sd_bytes(next) }) / stride
        for (i in 0..count) {
            unsafe { (nextBase + i).write((base + head + i).read()) }
        }
        unsafe { sd_free(handle) }
        handle = next; base = nextBase; cap = nextCap; head = 0
    }

连续指针接口只有一行,不过使用时要认真对待它的生命周期。这个指针没有转移所有权;扩容和关闭会使它失效,队头变动后也要重新取得逻辑起点。在用它的期间,不能同时修改队列。

public unsafe func data(): CPointer<T> { ensureOpen(); base + head }

资源释放使用 Resource:短作用域可以放在 try(resource) 中,长期持有的对象在使用结束后显式 close。close 可重复调用,之后继续访问会抛出异常。析构也会释放 native 资源,但应用不应等到析构才处理已经不用的映射。

接入 CJMP:公共逻辑包到鸿蒙 HAR

队列属于可复用的数据结构,采用官方 logic-module/native 模式就够了。没必要为了接这个库把页面全部重写成 CJMP UI。框架提供工程组织和构建入口,页面通过 HAR 的门面调用公共包。

官方工程结构:logic-module 的 CJMP 与 native 两种模式

[project]
name = MyApplication42
packageName = slicedeque4cj
description = CJMP native logic-module: contiguous deque, HarmonyOS verification
type = logic-module
appType = native
organization = com.aasd23

上面的 project.conf 是实际工程配置。公共源码在 logic-module/common,cjpm.toml 分别声明 aarch64-linux-ohos 与 x86_64-linux-ohos。build-logic.ps1 先编译映射后端,再执行 cjpm build,最后构建测试入口和 NAPI;HAR 的 ProcessLibs 前置任务调用这份脚本。

原模板的鸿蒙桥依赖 ark_interop loader 和另一套 SDK 路径。本机使用仓颉 1.1.3,继续套用这些假设不能复现,所以改成已在设备验证过的 C ABI/NAPI,并把平台代码放进 logic-module/hos/native。工具模板的 0.2.2 版本与本机编译器版本分别记录,不把它们写成同一个 SDK 版本。

仓颉入口继续用 @C 导出 sd_perform,NAPI 负责初始化运行时、找到入口和处理输出字符串。队列逻辑仍在公共包里,桥接层没有重新实现一套容器。示例同步执行按钮调用;如果用于耗时业务,需要另行安排任务调度。

对页面暴露的门面非常短,完整代码如下。它只收集操作与应用 cacheDir,返回真实队列结果。

import native, { DequeResult } from 'libslicedeque_napi.so';

export { DequeResult } from 'libslicedeque_napi.so';

/** CJMP native logic-module's HarmonyOS facade. The queue is implemented in Cangjie. */
export class SliceDequeBridge {
  static perform(action: string, cacheDir: string): DequeResult {
    return native.perform(action, cacheDir);
  }
}

应用依赖的是库模块。这个依赖经鸿蒙构建生成 HAR 并合入 HAP,原来 entry 中直接编译动态库的任务已经移到库模块。

{
  "name": "entry",
  "version": "1.0.0",
  "dependencies": {
    "slicedeque4cj": "file:../slicedeque4cj"
  }
}

private apply(action: string): void {
    try {
      const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
      const result: DequeResult = SliceDequeBridge.perform(action, context.cacheDir);
      this.healthy = result.ok;
      this.message = result.message;
      if (result.ok) {
        this.values = result.values.length ? result.values.split(',').map((v: string) => Number(v)) : [];
        this.count = result.size;
        this.capacity = result.capacity;
        this.head = result.head;
        this.bytes = result.bytes;
        this.page = result.page;
        this.backend = result.backend;
        this.alias = result.alias;
        this.contiguous = result.contiguous;
        this.wrapped = result.wrapped;
        this.report = result.report;
      }
      console.info('[slicedeque42] ' + JSON.stringify(result));
    } catch (error) {
      this.healthy = false;
      this.message = String(error);
    }
  }

红框中的 import 来自 slicedeque4cj 库,apply 调用的是 SliceDequeBridge。截图来自本地 MyApplication42 的真实 DevEco 窗口,后台采集时没有切换前台。源码示例使用 hos/entry;本地原工程为方便继续 Run,把相同模块放在 harmony/cjmp/slicedeque4cj,再由根工程声明依赖。

先生成 HAR,再运行应用

配置好本机 SDK 路径、准备两个 ABI 的实际运行依赖后,在 CJMP 示例根目录执行官方命令。官方 OpenSDK 分支为 open-sdk-windows-v0.2.2;本次使用它的创建和构建工具,没有把尚未使用的 Android 编译器或 UI 引擎列作已验证。

& "$env:CJMP_SDK_HOME\cjmp-tools\bin\keels.bat" build har --platform ohos-arm64

构建确实生成了 hos/slicedeque4cj/build/default/outputs/default/slicedeque4cj.har,并返回 Build har success。官方入口选择 ohos-arm64,本项目模块任务同时产出 x86_64 库,用于当前 PC 模拟器。应用通过 DevEco 编译 HAP,真机使用签名产物,签名仍对应 com.aasd23.slicedeque42。

运行库依赖:文件名相同,ABI 可能不同

之前页面曾报 Cannot read property perform of undefined,设备日志才暴露实际的运行库加载问题。这个仓颉版本依赖平台 libc++.so 的 __h ABI,而 Native SDK 中可见的 libc++_shared.so 使用 __n1;改个文件名不能解决符号不匹配。

prepare-runtime.ps1 从对应测试设备只读取得依赖,检查架构与符号,放到本机 hos/.runtime-deps。源码不包含设备系统库;换 SDK 或 ROM 必须重新核对。已有 MyApplication42 的签名已保留,不需要重建包名,但从公开源码复现的读者仍须为自己的设备生成签名。

运行效果:在模拟器和真机上试一轮

这次先用 CJMP Tools 构建 HAR,再编译调用这个模块的 HAP,并重新安装到两台设备。录制时逐项核对队首/队尾插入、两端弹出、跨界、扩容的元素值、元素数、容量与 head,最后在应用内运行同一套仓颉断言。

5.1 模拟器:完整操作和测试

下面是新版 HAR 调用示例在 x86_64 模拟器上的实录。首次安装后有一次页面尚未绘制就读取布局的情况,确认启动日志和真实界面后重新测试;没有把空布局写成通过。跨界、扩容和应用内断言都在页面上完成。

5.2 真机:跨界时看 head,再看指针检查

MatePad Edge 上点“制造跨界”,先把原来的队列清空并填满,再从队首弹出到只剩三个元素,最后追加八个。容量仍是 512,head 来到 509,逻辑元素为 509 到 519。这正好越过第一段映射的尾部,适合直接验证连续读取。

红框按操作顺序标出按钮、head 和读取结果。这里“通过”来自实际指针比较以及映射别名检查,没有提前在页面写入成功状态。

接着点“验证扩容”,容量由 512 增加到 1024,head 回到零,509 到 519 的顺序保留。测试使用独立队列,因此点“运行测试”不会把演示数据重置掉。

5.3 测试具体检查了什么

同一套断言覆盖空队列、两端顺序、插入删除、索引越界、扩容保序、独立克隆、重复关闭及关闭后访问。类型检查包括 Int64、UInt8、Float64,以及 24 字节的 C 兼容结构体。

连续性也没有只看 toArray:测试会让 head 靠近尾部,用 data 返回的原始指针跨边界读取和写入,再检查两段映射的双向别名。另有固定种子的 10000 步操作,每一步都和独立数组模型比较长度及元素顺序。

验证项

实际结果

双 ABI 编译与 HAP 签名

BUILD SUCCESSFUL

模拟器应用内测试

PASS:34570 assertions;10000 deterministic operations

MatePad Edge 应用内测试

PASS:34570 assertions;10000 deterministic operations

模拟器命令行测试

同一共享断言集通过

真机命令行程序

HDC shell 执行受设备策略限制,使用已签名 HAP 内测试

两台设备的页面检查

每步元素值、元素数、容量、head 与预期一致

真机的结果来自签名应用内调用的共享测试。新日志单独命名为 cjmp-har-build、cjmp-hap-build 与 cjmp-deveco-build,避免和原生桥演示的旧记录混淆。源码和 README 也同时更新,运行数据不会只改在文章中。

写在最后

这次接入的关键,是把能跑的队列变成可依赖的库:CJMP 的 logic-module/native 组织公共逻辑与平台桥,cjpm 构建仓颉包,鸿蒙模块产出 HAR,MyApplication42 通过门面调用。跨界以后检查原始指针,扩容以后检查顺序,这两项把数据结构的特点落实到了设备运行结果。

当前实现支持 C 兼容数据,没有逐项移植 Rust 的全部迭代器和 trait,也没有给出性能优于普通队列的结论。对于只存几个元素、频繁创建销毁的场景,页粒度分配和系统调用成本需要先考虑;若业务确实需要连续的跨界数据区间,再结合真实负载做性能测试会更合适。

复现可先构建 HAR,再用自己的签名运行 hos/entry 示例。碰到加载错误,先看设备日志和依赖 ABI;数据有疑问,就核对库返回值和界面显示。后续要进入正式投稿与组织验收,还要按活动表完成查重、版本核对和质量登记,本次实测范围已经在开头列清楚。

欢迎加入 CJMP 社区,继续交流逻辑库适配与问题复现

参考:上游实现与原始许可

参考:仓颉语言官方文档

参考:仓颉运行时 Cangjie.h

Logo

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

更多推荐