Rust + Tauri调用鸿蒙系统原生API,打通桥接实战经验总结
真不容易啊,网上这方面资料很少,探索尝试了很久终于打通了调用鸿蒙原生API的方法。这有什么用?使用Tauri做跨端APP是个不错的技术方案,可以一次可以跨N端。但是呢肯定避免不了依赖和使用系统的原生API来扩展能力。当前Tauri在android和ios端,官方都有插件支持。但鸿蒙还不支持,有待探索。
目标读者:
正在(或即将)把 Tauri 应用往鸿蒙(HarmonyOS)上搬、想调用系统原生能力(相册、分享、通知、剪贴板……)的开发者。
本文是一篇实战记录,不是官方文档复述。所有结论都经过真机验证和源码核实, 证据在文末。踩过的坑都在,能让你少走两天弯路。
更多交流学习,欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
猫哥的博客:https://blog.csdn.net/qq8864

0. 先说结论
Tauri v2 在鸿蒙上调用原生 API,绕不开一件事:让 Rust 代码在"主线程"上执行。
不是"最好在主线程",是必须。因为 openharmony-ability 的get_main_thread_env() 是一个 thread_local,只有主线程能拿到 Env,而拿到 Env 才能调 ArkTS 侧的函数。
tauri 的同步命令却跑在 IPC 线程——两头对不上,就是看到的 main thread env not ready 的全部原因。
解法(本仓库验证通过):AppHandle::run_on_main_thread + channel,把调用投递到主线程,同步等结果。就这么简单,但想明白这行代码,花了些时间探索。
1. 背景:我们要做什么
应用(习惯树)有一个"里程碑卡片":庆祝动画画一张词句落款卡片,用户点"保存卡片"把它存到相册/下载目录,用户自己能看见。
习惯树开源地址:https://atomgit.com/qq8864/habit-tree
桌面端简单:Rust 写文件到下载目录。鸿蒙端不行——应用沙箱用户看不到,媒体库直写要敏感权限(READ_MEDIA 之类,上架审核麻烦)。
正路是 PhotoViewPicker:系统弹窗让用户选保存位置,免权限,用户确认后返回 uri,写入即可。完美符合需求。
但 PhotoViewPicker 是 ArkTS 的 API(方舟运行时),前端 JS(WebView 引擎)和 Rust 都摸不到。所以要搭一条 Rust → ArkTS 的桥:
前端 JS ──invoke──▶ Rust 命令 ──NAPI──▶ ArkTS 全局函数 ──▶ PhotoViewPicker
这条链,每一步都有坑。逐个说。
2. 桥的骨架:全局分发器
ArkTS 侧(EntryAbility)挂一个全局函数,作为统一入口:
// EntryAbility.ets
interface DispatchPayload { base64: string; filename: string; }
interface DispatchMsg { plugin: string; cmd: string; payload: DispatchPayload; }
// onCreate 里:
(globalThis as ESObject).__ohos_dispatch = (msg: string): string => {
try {
const parsed = JSON.parse(msg) as DispatchMsg;
if (parsed.plugin === 'grove' && parsed.cmd === 'save_card') {
this.saveCardToGallery(parsed.payload.base64, parsed.payload.filename);
return JSON.stringify({ ok: true, note: 'picker opened' });
}
return JSON.stringify({ ok: false, err: 'unknown cmd' });
} catch (e) {
return JSON.stringify({ ok: false, err: String(e) });
}
};
协议是 {plugin, cmd, payload},以后想加"分享"“通知”,就是加一个 cmd 分支,分发器不用动。
注意两个 ArkTS 严格模式的雷(编译期强制,跑都跑不起来):
(globalThis as any)→arkts-no-any-unknown,禁止 any/unknown。用as ESObject(方舟的动态类型逃生口)。const { plugin, cmd, payload } = JSON.parse(msg)→arkts-no-destruct-decls, 禁止解构声明。老老实实定义 interface 逐字段访问。
Rust 侧(ohos_bridge.rs):
pub fn call_arkts(msg: &str) -> Result<String> {
let guard = get_main_thread_env();
let env: &Env = guard.borrow().as_ref()
.ok_or_else(|| napi_ohos::Error::from_reason("main thread env not ready"))?;
let global = env.get_global()?;
let dispatch: Function<'_, String, String> = global.get_named_property("__ohos_dispatch")?;
dispatch.call(msg.to_string())
}
编译这里还踩一个:Env::get_global() 返回 JsGlobal,get_named_property是 JsObjectValue trait 的方法,不 import 编译不过:
use napi_ohos::bindgen_prelude::{Function, JsObjectValue};
好,桥搭好了,真机一跑——main thread env not ready。开坑。
3. 最大的坑:线程模型
3.1 事实:get_main_thread_env 是 thread_local
源码(openharmony-ability 的 helper/mod.rs):
thread_local! {
static MAIN_THREAD_ENV: Rc<RefCell<Option<Env>>> = Rc::new(RefCell::new(None));
}
pub fn get_main_thread_env() -> Rc<RefCell<Option<Env>>> {
MAIN_THREAD_ENV.with(Rc::clone)
}
thread_local。每个线程各一份,只有设置它的那条线程(主线程/ArkTS UI 线程)能读到 Some(env),其它线程永远 None。
它什么时候被设置?xcomponent.rs 的 render() 里:set_main_thread_env(*env)。这是 ArkTS 调原生时把 Env 交过来的。
3.2 事实:tauri 同步命令不在主线程
之前一直以为"tauri v2 同步命令默认在主线程执行"(一些资料这么写)。
真机实测打脸:toast 显示
打开保存窗口失败: GenericFailure, main thread env not ready
就是 get_main_thread_env() 返回了 None。
而 wry 的 Webview 方法(evaluate_script、页面加载)全都正常——因为它们在主线程的 event loop 里跑,get_main_thread_env() 有值。
同一个函数,两条执行路径,两个结果。这就是最迷惑人的地方。
3.3 三条出路
| 方案 | 思路 | 结论 |
|---|---|---|
| 直接调 | 命令线程直接 get_main_thread_env() |
❌ 实测 None |
| TSFN | ThreadsafeFunction 投递,回调自带 Env |
✅ 可行但要主线程先创建(拿到 env 创建 TSFN 本身就需要 env,鸡生蛋) |
| run_on_main_thread | tauri 自带的投递主线程 | ✅ 最终选择,零 ArkTS 改动 |
TSFN 那条其实是最"正统"的 N-API 解法:ThreadsafeFunction 的回调在主线程执行,回调上下文直接给你 Env(ThreadsafeCallContext { env, .. }),连 get_main_thread_env 都不用。但它有个鸡生蛋问题:创建 TSFN 需要 env,
而你在命令线程上拿不到 env。除非在 app 初始化(主线程)时预先创建好存全局,那又得加一个 napi 导出函数让 ArkTS 侧来调,链路长一截。
run_on_main_thread 是 tauri 现成的:
pub fn save_card_to_gallery(app: &tauri::AppHandle, base64_png: String, filename: String) -> Result<String> {
let msg = serde_json::json!({
"plugin": "grove", "cmd": "save_card",
"payload": { "base64": base64_png, "filename": filename },
}).to_string();
let (tx, rx) = std::sync::mpsc::channel::<Result<String>>();
app.run_on_main_thread(move || {
let _ = tx.send(call_arkts(&msg));
})?;
rx.recv()??; // 等主线程执行完,透传错误
Ok("PICKER:已打开保存窗口".to_string())
}
3.4 run_on_main_thread 为什么能行
tauri-runtime-wry 的实现(send_user_message):
if current_thread().id() == context.main_thread_id {
// 本来就在主线程,直接执行
handle_user_message(...);
} else {
context.proxy.send_event(message); // 投递到主线程 event loop
}
主线程(main_thread_id 在 tauri run 启动时就是主线程)的 event loop处理 Message::Task(closure),闭包就在主线程跑 → 此时get_main_thread_env() 有值 → 桥打通。
我们的 save_card 在 IPC 线程,run_on_main_thread 走 proxy.send_event,主线程执行闭包,mpsc 把结果送回来,同步命令原地等。干净利落。
小风险提示:如果在主线程 event loop 内部再同步等主线程(自己等自己),会死锁。我们的命令由 JS invoke 触发(IPC 线程),不会撞上。
4. 第二个坑:PhotoViewPicker 的 API 真身
最初按印象写的:
import { photoAccessHelper } from '@kit.MediaLibraryKit';
const picker = new photoAccessHelper.PhotoViewPicker(); // 编译通过
const opts = new photoAccessHelper.PhotoSaveOptions(); // ❌ 不存在
hvigor 报错:
Property 'PhotoSaveOptions' does not exist on type 'typeof photoAccessHelper'.
Did you mean 'PhotoSelectOptions'?
Property 'save' does not exist on type 'PhotoViewPicker'.
@kit.MediaLibraryKit 的 photoAccessHelper.PhotoViewPicker 只有 select(),没有 save。别被名字骗了。
SDK 里搜 save,真相在 @ohos.file.picker(经 @kit.CoreFileKit 导出为 picker):
import { fileIo as fs, picker } from '@kit.CoreFileKit';
const photoPicker = new picker.PhotoViewPicker();
const opts = new picker.PhotoSaveOptions();
opts.newFileNames = [filename]; // ⚠️ 必须带扩展名!官方示例 'photo1.jpg'
const uris = await photoPicker.save(opts); // 返回 string[],不是 {photoUris}
const uri = uris[0];
三个细节:
- newFileNames 要带扩展名。别学 iOS 那套"系统自动补",鸿蒙不会。
- 返回值是
Promise<Array<string>>(保存后的 uri 数组),不是PhotoSelectResult那种对象,没有.photoUris。 - 这个 API 标注
@deprecated since 18 → useinstead SaveButton。编译只给 warning 不报错,API 12 设备上正常用。鸿蒙的"推荐替代"SaveButton 是嵌页面的 UI 组件,跟"命令触发"的场景不搭,暂用旧 API。
拿到 uri 后写入(免权限的最后一环):
const bytes = new util.Base64Helper().decodeSync(base64);
const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
const file = await fs.open(uri, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
await fs.write(file.fd, buf);
await fs.close(file);
保存完成后补一个原生 toast 反馈(用户要点"保存完告诉一声"):
promptAction.showToast({ message: '卡片已保存' });
取消保存别报错:用户点掉弹窗,save() 抛错码 13900042,静默处理:
} catch (e) {
if ((e as ESObject).code !== 13900042) {
promptAction.showToast({ message: '卡片保存失败,请重试' });
}
}
5. 验证方法论(怎么确认真的通了)
UI 自动化驱动庆祝动画有点费劲(要连续打卡到里程碑),说几个好用的招:
- 补记打卡触发动画:日历格子直接补记,
mutate后maybeCelebrate检测到新阶段就播动画。不用改设备时间。 - hilog 是唯一可信的落盘证据:ArkTS 侧
save_card ok: <uri>在fs.write/close成功后才打印。看到它 = 文件真的写了。 - hdc shell 能读 app 沙箱:
/data/app/el2/100/base/<bundle>/files/,验证"没有写沙箱"(老版本会往这写 PNG,新版本这里只有数据库)。 - 前端 toast 显示真实错误:
invoke失败别吞,catch (e)里把String(e)打出来——main thread env not ready就是这么暴露的。 - Rust 的 eprintln 在鸿蒙上没人看:hilog 不捕获 Rust stderr,诊断要么走前端 toast,要么写沙箱文件。
6. 调试期绕过的另一个坑:签名
打包默认 signingConfig: "release"(企业证书 E:\iyuba),真机安装报:
error: install sign info inconsistent / not trusted app source
设备上现装的是 debug 签名包(appDistributionType: none)。真机调试要切 default(debug,DevEco 自动签名材料在C:\Users\<user>\.ohos\config),装完再切回 release。别问,切就是了。
7. 干货清单(抄作业版)
给要做"Tauri 调鸿蒙原生 API"的你,最小可行路径:
- ArkTS 侧:EntryAbility.onCreate 挂
(globalThis as ESObject).__ohos_dispatch分发器,协议{plugin, cmd, payload}。别用as any,别解构。 - Rust 侧:
get_main_thread_env()→get_global()→get_named_property→call。记得use ...JsObjectValue。 - 线程:命令里用
app.run_on_main_thread(|| { call_arkts(msg) })+ channel, 同步等结果。这是全篇最关键的一行。 - 原生能力:查 SDK 的 d.ts 再写代码——
@kit.MediaLibraryKit和@kit.CoreFileKit是两个世界,PhotoViewPicker.save在后者。 - 反馈:保存类操作完成后 ArkTS 侧
promptAction.showToast,取消码13900042静默。 - 验证:hilog
save_card ok: <uri>是落盘铁证;前端 toast 显示真实错误。
8. 还能往哪走
- TSFN 异步回传:现在是一期"打开弹窗就返回",ArkTS 完成后自己 toast。想要前端 toast「已保存到相册」,走 TSFN 回调把
photoUris送回 Rust 再 emit。 - 更多能力:分发器已按
{plugin, cmd}路由,加分支即可:分享@kit.ShareKit、保存任意文件DocumentViewPicker.save()、剪贴板/震动/通知同理。 - 插件化:抽
crates/tauri-plugin-ohos(与官方插件同构)。官方插件生态在鸿蒙还是空白,自己造轮子之前,先想清楚要不要,可以给官方做贡献啦,因为你已经学会打通了桥接的方法。
附:证据


- 提交:
bf8736f fix: 保存卡片鸿蒙真机验证修复(桥主线程投递 + PhotoViewPicker API 修正 + 完成反馈) - 真机:nova 14(HarmonyOS),hilog 实测:
grove: save_card ok: file://docs/storage/Users/currentUser/Download/习惯树-里程碑-树苗-2026-08-21.png
- 线程模型源码:
openharmony-abilitycrates/ability/src/helper/mod.rs(thread_local MAIN_THREAD_ENV)、tauri-runtime-wrylib.rs的send_user_message - 错误实证:
main thread env not ready(未投递主线程时);修复后无此错
全文共四个坑:编译期 2 个(JsObjectValue、ArkTS 严格模式)、运行期 2 个
更多推荐



所有评论(0)