slicedeque4cj 鸿蒙适配实录:仓颉连续双端队列与 真机实测
前言
先说这次接入的方向:CJMP 的 logic-module/native,也就是公共逻辑用仓颉写,应用界面继续使用平台原生技术。仓颉是语言,CJMP 是框架,两者的名字不能混着填。这篇用 slicedeque4cj 做例子,把库构建成鸿蒙 HAR,再让 MyApplication42 通过库接口调用它。
队列有个值得动手验证的特点:队尾绕回开头以后,逻辑数据仍然能用一段连续指针访问。这个“连续”靠相邻虚拟地址映射实现,没有把 toArray 复制出的快照当作底层连续内存。后面既看如何拆库、打包,也看跨界以后指针究竟读到了什么。
最初做通的是 ArkTS 直接调用 C NAPI 的原生示例。它能运行,但还没有 CJMP 的逻辑模块配置。这里补上官方模板、cjpm 公共包、HAR 门面和应用依赖,并重新安装到模拟器与 MatePad Edge。下面这段就是新版库调用路径的真实操作。

保留 slice-deque 0.3.0 固定源码与原始许可,根目录 src 是仓颉队列实现,harmony/native 是鸿蒙后端。CJMP 接入示例新增在 example/cjmp/slicedeque4cj;旧 example/harmony 示例作为原生桥接参考保留。当前复现以新 CJMP 目录为准。
CJMP 示例:公共逻辑模块、鸿蒙 HAR 与 MyApplication42 页面
开发环境:把版本说清楚
基础环境搭建直接参考 CPF-RN 组织的环境初始化文件,本文集中写库接入。CJMP 的 SDK 与仓颉配套关系还要对照官方开发准备:本次使用 Tools 0.2.2 的 native 逻辑模块模板,鸿蒙端编译由本机 DevEco 和仓颉工具链完成。
| 项目 | 本次实际版本或设备 |
| 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 尚未实测。

适配过程:先把库从页面里拆出来
先保留原始 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;数据有疑问,就核对库返回值和界面显示。后续要进入正式投稿与组织验收,还要按活动表完成查重、版本核对和质量登记,本次实测范围已经在开头列清楚。
更多推荐




所有评论(0)