
一、先看最终形态ArkTS 不认识 Rust 指针HashVault是一个离线图片去重工具输入一张 1024 × 768 的 RGBA 图片调用 Rust 编写的感知哈希库返回 64 位哈希。最终联调结果是任务HASH-20260930-024在arm64-v8aRelease 构建中耗时 36 ms结果为a93f-71c2-4d88-b50e故意传入短缓冲区时页面收到结构化错误ERR_NATIVE_BUFFER_RANGE应用没有崩溃。这个结果并不是把 Rust 函数直接暴露给 ArkTS 得到的。实际链路有三层ArkTS 负责参数模型与页面状态C Node-API 胶水负责读取ArrayBuffer、检查类型并创建返回对象Rust 只接收 C ABI 能表达的指针、长度和整数并把所有失败收敛成错误码。这条边界看起来保守却很实用。ArkTS 不应知道 Rust 的所有权和生命周期Rust 也不应持有napi_env或 ArkTS 对象。中间层只做翻译不承载算法。这样升级图像库、替换并发模型或定位崩溃时每一层都有清楚的责任。二、第一天先固定接口契约再接动态库最初的接口是hash(buffer): string。它的问题不是不能用而是任何失败都只能抛异常字符串尺寸错误、缓冲区不够、库未加载、算法内部 panic全都混在一起。更麻烦的是调用者忘了传宽高和像素步长Native 侧只能猜测内存布局。我们把契约改成compute(request): HashResult。请求必须包含jobId、width、height、stride和像素缓冲区结果必须包含状态、错误码、哈希、耗时和实际读取字节数。对本次图片期望字节数严格等于1024 × 768 × 4 3,145,728。这段代码解决什么问题。它在 ArkTS 入口完成可读的前置校验并把 Native 返回值转换成页面可以稳定消费的领域结果。importhashvaultfromlibhashvault.soexportinterfaceHashRequest{jobId:stringwidth:numberheight:numberstride:numberpixels:ArrayBuffer}exportinterfaceHashResult{jobId:stringstate:SUCCEEDED|REJECTEDcode:OK|ERR_NATIVE_BUFFER_RANGE|ERR_NATIVE_PANIChash:stringelapsedMs:numberconsumedBytes:number}exportclassHashVaultAdapter{compute(request:HashRequest):HashResult{constexpectedrequest.stride*request.heightif(request.width0||request.height0||request.striderequest.width*4||request.pixels.byteLengthexpected){return{jobId:request.jobId,state:REJECTED,code:ERR_NATIVE_BUFFER_RANGE,hash:,elapsedMs:0,consumedBytes:0}}returnhashvault.computeHash(request)}}前置校验不是为了取代 Native 校验而是尽早给业务层可理解的反馈。跨语言边界必须“双方都不信任对方”ArkTS 检查可以减少无意义调用C 和 Rust 仍要重新验证指针与长度防止未来其他入口绕过适配器。stride必须显式传递。很多图片并不是紧密排列行尾可能有填充若简单按width × 4递增算法会从第二行开始读错位置。当前 Demo 的stride4096恰好等于宽度乘四但接口没有把这个偶然条件写死。DevEco Studio 图中左侧能看到HashVaultAdapter.ets、napi_init.cpp与hashvault_ffi中间代码正在校验byteLength3145728右侧模拟器显示任务成功底部 HiLog 打印LOADED → VALIDATED → EXECUTING → SUCCEEDED。红色标注只圈出缓冲区长度和 Native 错误码。三、第二天C 胶水只做翻译不做图像算法Node-API 是 ArkTS/JS 与 C/C 交互的稳定接口面。我们在 C 中读取对象字段、取得ArrayBuffer数据指针调用 Rust 导出的 C 函数再组装 ArkTS 对象。算法逻辑不进入这一层否则同一个错误可能在 C 和 Rust 各实现一遍。这段代码解决什么问题。它在 Node-API 边界验证参数确保传给 Rust 的指针、长度和尺寸互相一致并把 Rust 错误码映射为结构化对象。#includenapi/native_api.h#includehashvault_ffi.hstaticnapi_valueComputeHash(napi_env env,napi_callback_info info){size_t argc1;napi_value args[1]{nullptr};napi_get_cb_info(env,info,argc,args,nullptr,nullptr);if(argc!1)returnMakeError(env,ERR_ARGUMENT_COUNT);uint32_twidthGetUint32(env,args[0],width);uint32_theightGetUint32(env,args[0],height);uint32_tstrideGetUint32(env,args[0],stride);napi_value bufferValueGetNamed(env,args[0],pixels);void*datanullptr;size_t length0;boolisArrayBufferfalse;napi_is_arraybuffer(env,bufferValue,isArrayBuffer);if(!isArrayBuffer||napi_get_arraybuffer_info(env,bufferValue,data,length)!napi_ok){returnMakeError(env,ERR_NATIVE_BUFFER_RANGE);}constsize_t expectedstatic_castsize_t(stride)*height;if(datanullptr||stridewidth*4||lengthexpected){returnMakeError(env,ERR_NATIVE_BUFFER_RANGE);}HvOutput output{};int32_tcodehv_compute_rgba(static_castconstuint8_t*(data),length,width,height,stride,output);returnMakeResult(env,code,output);}这里最重要的不是 API 调用数量而是生命周期。data指向的是 ArkTSArrayBuffer管理的内存只能在它有效的范围内读取。同步调用时Rust 必须在函数返回前完成读取不能偷偷保存这个指针。如果改成异步工作应该把必要数据复制到 Native 自己拥有的缓冲区或者建立明确的引用与释放机制。expected使用size_t并且在乘法前要考虑溢出。示例为了易读省略了完整的安全乘法函数生产代码应检查stride SIZE_MAX / height。尺寸来自外部文件时这不是理论问题错误元数据可能让整数回绕随后绕过长度检查。四、第三天Rust 侧把 panic 留在语言边界以内第一次压力测试时某个调试断言触发 panic。桌面测试程序能看到堆栈装到设备后却表现为整个应用退出。原因很直接Rust panic 穿过了 C ABI。跨 FFI 边界展开栈没有可靠语义不能期待 C 帮忙接住。我们做了两层处理Release 构建避免依赖 panic 作为业务控制流导出函数最外层使用catch_unwind把意外 panic 转成HV_ERR_PANIC。这并不能捕获进程被系统杀死、非法指针或 abort但能封住可展开的 Rust panic。这段代码解决什么问题。它为 Rust 导出函数建立唯一的异常出口验证裸指针范围并只通过 C 兼容结构返回结果。usestd::{panic::catch_unwind,slice,time::Instant};#[repr(C)]pubstructHvOutput{pubhash:u64,pubelapsed_ms:u32,pubconsumed_bytes:usize,}constHV_OK:i320;constHV_ERR_BUFFER_RANGE:i321001;constHV_ERR_PANIC:i321900;#[no_mangle]pubunsafeexternCfnhv_compute_rgba(data:*constu8,len:usize,width:u32,height:u32,stride:u32,out:*mutHvOutput,)-i32{ifdata.is_null()||out.is_null()||width0||height0{returnHV_ERR_BUFFER_RANGE;}letexpectedmatch(strideasusize).checked_mul(heightasusize){Some(value)ifvaluelenstridewidth.saturating_mul(4)value,_returnHV_ERR_BUFFER_RANGE,};matchcatch_unwind(||{letstartedInstant::now();letpixelsslice::from_raw_parts(data,expected);lethashperceptual_hash_rgba(pixels,width,height,stride);HvOutput{hash,elapsed_ms:started.elapsed().as_millis()asu32,consumed_bytes:expected,}}){Ok(result){out.write(result);HV_OK}Err(_)HV_ERR_PANIC,}}所有unsafe都被压缩在导出函数里核心算法perceptual_hash_rgba()接收安全切片。这样代码审查可以集中检查三件事指针是否为空、长度是否足够、输出指针是否可写。Rust 内部不接触 Node-API也不分配需要 ArkTS 释放的字符串避免跨语言分配器不一致。哈希通过u64返回C 再格式化为a93f-71c2-4d88-b50e。这比 Rust 分配 C 字符串再让 C 释放更简单。若必须返回变长数据要明确“谁分配、谁释放”并提供成对的alloc/free接口绝不能让 ArkTS、C 和 Rust 各自猜测所有权。五、第四天把耗时任务从 UI 线程拿走36 ms 对单次按钮操作不算长但连续处理相册时足以造成掉帧。同步 Node-API 调用会占用发起调用的线程不能因为算法写在 Rust 里就自动获得并发。我们最终让页面提交任务后台执行 Native 调用完成后只把可序列化结果送回 UI。这段代码解决什么问题。它让页面状态沿LOADED → VALIDATED → EXECUTING → SUCCEEDED推进并对成功与缓冲区拒绝采用同一套结果模型。EntryComponentstruct HashVaultPage{StatejobId:stringHASH-20260930-024Statestate:stringLOADEDStatehash:string--Statelatency:string--StateerrorCode:stringOKprivateadapternewHashVaultAdapter()privateasyncrun(pixels:ArrayBuffer):Promisevoid{this.stateVALIDATEDconstrequest:HashRequest{jobId:this.jobId,width:1024,height:768,stride:4096,pixels}this.stateEXECUTINGconstresultawaitHashTaskRunner.execute(request,this.adapter)this.stateresult.statethis.hashresult.hash||--this.latency${result.elapsedMs}msthis.errorCoderesult.code}build(){Column({space:16}){Text(HashVault).fontSize(28).fontWeight(FontWeight.Bold)Text(this.state).fontSize(32).fontColor(#6C3FD1)Text(this.jobId)Text(1024 × 768 · RGBA · 3,145,728 bytes)Text(this.hash).fontFamily(monospace)Text(耗时${this.latency})Text(this.errorCode).fontColor(this.errorCodeOK?#067647:#D92D20)}.padding(24).width(100%)}}HashTaskRunner是项目自己的任务调度抽象底层可以根据当前 SDK 与数据传递约束选择异步工作、TaskPool、Worker 或子进程。关键不是名字而是 UI 线程不直接承担批量计算跨线程对象也不能偷偷携带不可转移的 Native 指针。如果目标是隔离普通 panicFFI 最外层转换错误码已经足够如果目标是隔离非法内存访问线程并不能提供进程级保护。对于不可信图片解析或历史包袱很重的 Native 库可以评估 HarmonyOS 的 ArkTS/Native 子进程机制把故障域进一步缩小。但子进程带来数据拷贝、启动成本和生命周期管理不能为了“看起来安全”一律使用。运行页展示成功路径状态为SUCCEEDED输入1024 × 768、缓冲区3,145,728 bytes、结果a93f-71c2-4d88-b50e、耗时36 ms。红色箭头指向哈希值和耗时和正文、HiLog 保持一致。六、第五天用错误注入验证边界而不是只测成功页跨语言库最危险的状态往往无法靠正常操作触发。我们做了三组错误注入把缓冲区裁成3,145,724 bytes验证短 4 字节也会被拒绝把stride改成 2048验证行跨度小于width × 4会被拒绝在测试构建中让算法主动 panic验证返回ERR_NATIVE_PANIC。短缓冲区的 HiLog 如下12:51:24.006 I HashVault: jobHASH-20260930-024 stateLOADED abiarm64-v8a 12:51:24.011 I HashVault: expected3145728 actual3145724 stateVALIDATED 12:51:24.012 E HashVault: codeERR_NATIVE_BUFFER_RANGE stateREJECTED 12:51:24.012 I HashVault: native_call_skippedtrue process_alivetrue最后一行很关键错误在 ArkTS 前置校验阶段就被发现Native 调用被跳过进程仍然存活。随后我们绕过 ArkTS 适配器直接调用 C 测试入口C 也返回同一个错误码说明两层验证没有出现语义分叉。诊断页与成功页明显不同它展示期望长度3,145,728、实际长度3,145,724、错误码ERR_NATIVE_BUFFER_RANGE和process alive。红圈落在 4 字节差值上箭头指向“Native 调用已跳过”让读者看到崩溃隔离是怎样发生的。七、构建与发布时真正容易遗漏的东西库能在本机跑起来不代表可以发布。首先要确认产物 ABI 与设备一致本次只验证了arm64-v8a如果同时提供其他架构Rust target、CMake 导入路径和打包目录必须一一对应。其次要固定 Rust 工具链与依赖锁文件避免构建机升级后生成不同二进制。符号策略也要提前决定。发布包可以剥离符号减小体积但必须安全保存与版本完全对应的未剥离符号文件否则线上 Native 崩溃只有地址没有可读堆栈。libpercept_hash.so的版本号、Git 提交、Rust 编译器版本和 HAP 版本应写入构建清单HiLog 至少打印库版本不打印用户图片内容。性能测试要区分冷启动与热调用。第一次加载动态库、初始化查找表与分配缓存会放大耗时后续 36 ms 不能代表首次体验。批处理还要观察峰值内存避免为每张图片重复复制 3 MB 缓冲区。只有确认生命周期安全后才考虑共享缓冲区或零拷贝优化不能倒过来。八、最终保留下来的边界清单这次接入最后稳定在四条约束上。ArkTS 只接触领域对象不接触 Rust 指针C Node-API 层只做类型、长度和结果映射不写算法Rust 导出层只使用 C ABI 数据结构所有unsafe集中审查耗时和高风险任务根据故障模型选择线程或子进程UI 只订阅状态。成功路径的指标是36 ms / 3,145,728 bytes / SUCCEEDED失败路径的指标是ERR_NATIVE_BUFFER_RANGE / native_call_skippedtrue / process_alivetrue。后者甚至比前者更重要因为三方库接入的质量不只看它能不能算出结果还要看输入错误、算法异常和版本漂移发生时应用能否给出可定位、可恢复的结果。参考资料HarmonyOS Node-API 跨语言调用HarmonyOS Node-API 异步任务HarmonyOS ArkTS 子进程开发指导