面向读者:已经用 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 框架没有这个插件,怎么办?」

先给结论,再展开:

  1. 不需要等框架发版,也不需要改框架源码——电量是「纯数据」能力,写成应用侧的一个自定义插件即可;
  2. 真正会卡住你的不是插件机制,而是原生库怎么被链接进去:仓颉 SDK 6.1 没有 battery 的绑定,只能 FFI 直连系统 .so,而符号必须出现在「最终链接」那一侧;
  3. 这个口子本仓已经补上:清单的 [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.tomlexecutable
鸿蒙targets/ohos/entry/cjpm.tomldynamic(模块 .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
    }
}

四个容易踩的点:

  1. @When 的判据是 env,不是 os。鸿蒙上 os 取到的就是 "Linux",arch 也不可用(桌面 aarch64 同样是 aarch64),唯一可靠的是 env == "ohos"。
  2. foreign func 返回 Int32,交给 JsonInt 前要 Int64(...) 转一次(JsonInt 收 Int64)。
  3. 桌面分支必须如实失败,回 CommandException 而不是 0——否则前端拿到 0% 电量,看起来像设备没电了,比报错难查得多。
  4. 每个 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

真机取证的三条判据(按「先便宜后贵」排):

  1. 清单确实进了生成物:grep link-option targets/ohos/entry/cjpm.toml 能看到你的库;
  2. 库确实进了模块:llvm-readelf -d <模块.so> | grep ohbattery 出现 libohbattery_info.so——这一步是剪贴板那轮缺的,缺了就启动 SIGSEGV;
  3. 数值确实来自设备:页面上打一次 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 的完整样板
Logo

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

更多推荐