仓颉语言cj-tauri 跨端开发框架实战:给鸿蒙侧加一个「读设备电量」的原生能力
面向读者:已经用
cj-tauri create起过工程,想接一个框架没提供的系统原始能力使用,这是这些跨端框架面临的常见需求,如何方便的桥接系统原生能力。
全文可复现:API 签名取自本机 SDK 的真实头文件,链接开关取自本仓[links]清单段,构建与校验的输出都是实际跑出来的。
框架概览见atomgit仓的docs/使用文档.md,插件设计见docs/RFC-插件体系.md。
cj-tauri 是借鉴tauri的静态编译后端 + 系统 WebView 前端思想的仓颉语言的Tauri。用华为仓颉语言实现的桌面应用混合开发框架 —— 仓颉静态编译后端 + 系统 WebView 前端,产物不带 Chromium、几 MB 级。
项目开源地址:https://atomgit.com/qq8864/cj-tauri

0. 要解决的问题
「我要读设备电量,cj-tauri 框架没有这个插件,怎么办?」
先给结论,再展开:
- 不需要等框架发版,也不需要改框架源码——电量是「纯数据」能力,写成应用侧的一个自定义插件即可;
- 真正会卡住你的不是插件机制,而是原生库怎么被链接进去:仓颉 SDK 6.1 没有 battery 的绑定,只能 FFI 直连系统
.so,而符号必须出现在「最终链接」那一侧; - 这个口子本仓已经补上:清单的
[links]段(本次新增),写完生成物就带上你的库。
所以这篇文分两条线:插件怎么写(框架侧)与库怎么链(构建侧)。
1. 第一步:判断这个能力该落在哪一层
不是所有「框架没有的能力」都该写成插件。先问一个关键问题:
这个能力需要宿主句柄(窗口 / WebView)或者必须跑在宿主 UI 线程上吗?
| 你的能力 | 落点 | 要不要改框架 |
|---|---|---|
| 读电量、取传感器、发网络请求、加解密、读文件 | 自定义插件(本文走这条) | 不用 |
| 托盘、全局快捷键、拖放、原生菜单、窗口可见性 | 扩 WebViewHost 接口 | 要,框架级改动 |
要画 ArkUI 组件(@Component / Web / 声明式弹窗) | 只能在应用侧 | 不用,但框架包不能碰 kit.ArkUI |
电量只读一个整数,不碰宿主,所以走第一行。三条路的完整对比与代码见另一篇docs/扩展系统插件指南.md。
2. 第二步:找 API——SDK 有绑定就直接 import,没有才 FFI
这一步决定后面所有工作量,别跳过。
2.1 先查仓颉有没有现成的包
看 SDK 的鸿蒙包目录:
ls <SDK>/api/lib/linux_ohos_aarch64_cjnative/{ohos,kit} | grep -i batt
本机(SDK 6.1,apiVersion 24)没有任何 battery 包。作为对照,kit.NetworkKit 是有的——有绑定的话你只需要:
import kit.NetworkKit.* // 零额外链接配置,path-option 里已经给全了
剪贴板那轮也是同一个结论(ohos.pasteboard 在 6.1 里不存在),所以「SDK 没有绑定」在鸿蒙侧并不罕见,是必须掌握的一条路。
2.2 再确认原生 C API 存在
在 DevEco 的 sysroot 里找头文件:
find <sysroot>/usr/include -iname "*batter*"
本机结果:
.../usr/include/BasicServicesKit/ohbattery_info.h
.../usr/lib/aarch64-linux-ohos/libohbattery_info.so
读头文件拿到准确签名(不要凭记忆写 FFI):
/* @library libohbattery_info.so
* @kit BasicServicesKit
* @syscap SystemCapability.PowerManager.BatteryManager.Core
* @since 13 */
int32_t OH_BatteryInfo_GetCapacity(); /* 返回 0..100 */
BatteryInfo_BatteryPluggedType OH_BatteryInfo_GetPluggedType();
两个要点:
- 返回的是
int32_t,仓颉侧对应Int32; @since 13意味着设备 API 版本必须 ≥ 13,低于这个版本的设备上符号不存在,运行期会直接缺符号。
3. 第三步:清单声明链接(没有这步,编译过、启动崩)
这是本文唯一需要动配置的地方:
# cj-tauri.toml
[links]
# 每个目标端各自追加;留空 = 只用框架默认
ohos = "-lohbattery_info"
为什么必须写在这里
符号要进最终链接,而最终链接只发生在两个地方,都是 cj-tauri sync 生成的:
| 目标端 | 最终链接的文件 | 产出类型 |
|---|---|---|
| 桌面 | targets/desktop/cjpm.toml | executable |
| 鸿蒙 | targets/ohos/entry/cjpm.toml | dynamic(模块 .so) |
这两份都是生成物,手改会被下次 sync 覆盖、并让 check 判漂移。而框架自己的 cjpm.toml写 link-option 对鸿蒙不生效——最终链接不在那一层。所以必须在清单里留口子。
这不是理论推断:剪贴板那轮漏了这条,产物里 U OH_Pasteboard_* 无处解析,应用启动即 SIGSEGV,而编译期一行错都不报。
语义:追加,不是覆盖
生成的 link-option 是「框架默认值 + 你的值」:
link-option = "-lpasteboard -ludmf -lohbattery_info"
-lpasteboard / -ludmf / -lcjtbridge 这些必需项顶不掉。三端分开写(desktop-linux /desktop-windows / ohos)是因为同一个库在三端上名字通常不同。不写 [links] 段的老工程生成物逐字节不变,cj-tauri check 不会因为你升级 CLI 而变红。
验证一下
cj-tauri sync ohos
grep link-option targets/ohos/entry/cjpm.toml
# link-option = "-lpasteboard -ludmf -lohbattery_info"
清单值写错(比如混进了双引号)会在 cj-tauri check 当场报:
[cj-tauri] 清单错误:[links] ohos 不能含双引号(会破坏生成 toml 的字符串)
4. 第四步:写插件
4.1 文件放哪
框架内置插件按「一个插件一个子包」放在 src/plugins/<名字>/,那是框架仓的组织方式(为了链接粒度:不用的插件不被链进来)。你应用里的插件不需要遵守它,最省事的落法是直接放进业务包,一个新文件:
src/battery_plugin.cj # package 与业务包同名
4.2 原生声明 + 命令实现 + 插件本体
package ohosCapabilities // 与业务包同名
import cjTauri.*
import std.collection.*
import stdx.encoding.json.*
/* ---------- 原生声明:只有鸿蒙的构建会包含它们 ---------- */
@When[env == "ohos"]
@C
foreign func OH_BatteryInfo_GetCapacity(): Int32
@When[env == "ohos"]
@C
foreign func OH_BatteryInfo_GetPluggedType(): Int32
/* ---------- 取数:两端各一份 ----------
* 注意 @When 只能标在**声明**上,不能标在语句块上,所以按平台写两份同名函数。 */
@When[env == "ohos"]
func batteryLevel(): JsonValue {
return JsonInt(Int64(OH_BatteryInfo_GetCapacity()))
}
@When[env != "ohos"]
func batteryLevel(): JsonValue {
// 桌面没有这个能力:如实拒绝,不假装成功
throw CommandException("battery: reads the device battery; HarmonyOS only")
}
@When[env == "ohos"]
func batteryPlugged(): JsonValue {
// 0 = 未插电, 1 = AC, 2 = USB, 3 = 无线, 4 = 未知(对应头文件里的枚举顺序)
return JsonInt(Int64(OH_BatteryInfo_GetPluggedType()))
}
@When[env != "ohos"]
func batteryPlugged(): JsonValue {
throw CommandException("battery: reads the device battery; HarmonyOS only")
}
/* ---------- 命令处理器 ---------- */
public class BatteryCommands <: CommandHandler {
public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
let _ = args
let _ = ipc
match (cmd) {
case "level" => return batteryLevel()
case "plugged" => return batteryPlugged()
case _ => throw CommandException("battery: unknown command " + cmd)
}
}
}
/* ---------- 插件本体 ---------- */
public class BatteryPlugin <: Plugin {
public func name(): String {
return "battery"
}
public func commands(): HashMap<String, CommandHandler> {
let m = HashMap<String, CommandHandler>()
m.add("level", BatteryCommands())
m.add("plugged", BatteryCommands())
return m
}
}
四个容易踩的点:
@When的判据是env,不是os。鸿蒙上os取到的就是"Linux",arch也不可用(桌面 aarch64 同样是 aarch64),唯一可靠的是env == "ohos"。foreign func返回Int32,交给JsonInt前要Int64(...)转一次(JsonInt收Int64)。- 桌面分支必须如实失败,回
CommandException而不是0——否则前端拿到0%电量,看起来像设备没电了,比报错难查得多。 - 每个
foreign func都要带@When[env == "ohos"]。漏了而该平台又没有这个符号,编译期不报、运行期模块加载失败(桌面跑鸿蒙构建、或反之)。
5. 第五步:装配(一行)
插件的命令名由框架补前缀:commands() 里写短名 level,注册出来就是 battery:level。
键里再写一次前缀就得到 battery:battery:level,所有命令都会以「未注册」告终。
main(): Int64 {
TauriApp()
.plugin(BatteryPlugin()) // ← 只有这一行
.run()
return 0
}
装配必须在 run() / runUrl() 之前——页面加载前命令表要定稿。
6. 第六步:能力清单放行(漏了就是 command not allowed)
插件只声明「我提供什么」,不自动放行(默认最小权限)。你必须在应用的能力清单里显式开:
{
"identifier": "battery",
"description": "读取设备电量",
"commands": ["battery:level", "battery:plugged"]
}
assets/capabilities/ 下的 json 会在 run() 时自动扫描。
三处联动,缺一处的报错各不相同:
| 漏了哪一处 | 前端看到的错误 |
|---|---|
没实现 CommandHandler / 没在 commands() 里登记 | command not registered |
没装配(没 .plugin(...)) | command not registered |
| 能力清单没写 | command not allowed |
7. 第七步:前端调用
页面只经桥的 invoke,不要自己 postMessage:
async function showBattery() {
try {
const level = await window.__CJ_TAURI__.invoke('battery:level');
const plugged = await window.__CJ_TAURI__.invoke('battery:plugged');
document.getElementById('battery').textContent = level + '%';
} catch (e) {
// 桌面端会走到这里(如实拒绝),界面给一句可读的降级文案
document.getElementById('battery').textContent = '本端不支持电量读取';
}
}
务必写 catch:桌面分支抛的是 CommandException,不接的话失败只在控制台里,页面上表现为「数字一直是空的」,很容易被读成能力没接上。
8. 第八步:构建与验证
cj-tauri sync ohos # 重新派生 targets/(清单改了必须跑)
cd targets/ohos && ./build-hap.sh
真机取证的三条判据(按「先便宜后贵」排):
- 清单确实进了生成物:
grep link-option targets/ohos/entry/cjpm.toml能看到你的库; - 库确实进了模块:
llvm-readelf -d <模块.so> | grep ohbattery出现libohbattery_info.so——这一步是剪贴板那轮缺的,缺了就启动 SIGSEGV; - 数值确实来自设备:页面上打一次
battery:level,与系统「设置 → 电池」的读数对一眼。
鸿蒙侧框架的
stderr不进 hilog,页面驱动的断言要写 hilog 才留得下凭证(examples/ohos/ohos-capabilities的业务包probeLog就是这么做的)。
9. 同一个插件在桌面上怎么办
插件的骨架(Plugin / CommandHandler / 装配 / 能力清单)跨平台一样,只有取数那一层分叉,本文的写法已经处理好了:桌面分支抛 CommandException。
如果桌面也要真能读(Windows 用 GetSystemPowerStatus、Linux 读/sys/class/power_supply/BAT0/capacity),做法是再写一份 @When[env != "ohos"] 的实现,
并把库名写进 [links] desktop-linux / desktop-windows。不要在插件里散落运行期平台判断,平台差异只允许出现在 @When 条件编译里。
10. 坑清单(都是本仓实际踩过的)
| 现象 | 原因 | 怎么办 |
|---|---|---|
| 编译过、启动即崩 | foreign func 找到了声明但没链上库 | 清单 [links] ohos 补库,sync 后复查生成物 |
command not registered | 命令没登记 / 插件没装配 / 键里重复写了前缀 | 按 §6 的表逐项排 |
command not allowed | 能力清单没写 | 加到 assets/capabilities/*.json |
桌面跑出 0% 电量 | 桌面分支没有如实失败 | 回 CommandException,别回默认值 |
| 改清单没生效 | 忘了 sync;或手动改了 targets/ 被覆盖 | 只改清单,从不手改生成物 |
| 清单报「不能含双引号」 | [links] 值会被原样拼进 toml | 去掉引号,写 -lfoo |
| 设备上没任何日志 | 框架 stderr 不进 hilog | 用 Hilog.info 打,流式抓 hdc shell hilog |
附:相关文件
| 文件 | 作用 |
|---|---|
cj-tauri.toml | 清单,[links] 段在这里 |
src/<你的插件>.cj | 插件 + FFI + 命令实现 |
assets/capabilities/*.json | 放行命令 |
targets/ohos/entry/cjpm.toml | 生成物,验证库里有没有进去 |
src/plugin.cj / src/ipc_hub.cj | 框架侧 Plugin / CommandHandler 契约 |
src/clipboard_bridge.cj | 鸿蒙 FFI 直连系统 .so 的完整样板 |
更多推荐




所有评论(0)