女儿十岁,五年级了,经检查有多动症ADHD 加阅读障碍,注意力很难长时间挂在一件事上。医生说要多做辨字训练,我就想做个小游戏:把小学一到五年级那些长得像的汉字凑成一组,让她在一堆"长得很像"的字里找出不一样的那个。用一个周末,给十岁的女儿做了个形近字游戏(使用仓颉语言的 cj-tauri 跨段端开发框架)。

方案是我自己先写了一份文档:五个玩法、字库分级、奖励机制都列好了。真正难的从来不是设计,是把它变成能跑的东西——尤其当它得先在电脑上跑,之后还要挪到鸿蒙平板上。这就是cj-tauri赞的地方:同一套源码,换个构建方式就可以支持鸿蒙PC啦。开发起来先在电脑上跑,再跑到设备上一样的效果。

这篇就记这一路:选了什么框架、栽在哪三个坑里、最后怎么连cj-tauri上游框架的 bug 一起修了并顺带给cj-tauri提了个pr。


在这里插入图片描述

一、选型:为什么是 cj-tauri

方案文档里原本写的是 Vue 3 + Phaser + localStorage,纯 H5。但我想让它在平板上是个真应用,不是浏览器里的一个页面——孩子会自己切走,家长还得开"引导式访问"锁屏,太折腾。

cj-tauri 的形态正好:仓颉后端 + 系统 WebView 前端,仓颉静态编译出一个 exe,前端还是我熟悉的 HTML/CSS/JS。更关键的是它一份业务代码可以同时出桌面和鸿蒙两个产物——现在先跑windows电脑桌面,等鸿蒙平板接上,重新编一次就行在平板上了。

hanzi-game/
├── cj-tauri.toml          # 唯一真相:应用 id / 窗口 / 页面入口 / 能力目录
├── src/                   # 仓颉后端(存档命令)
├── assets/ui/             # 前端:index.src.html + css/ + js/
└── tools/build-ui.js      # 前端内联器(后面会讲为什么必须有它)

二、第一版就栽了:页面起来了,但零样式零脚本

按 skill 的说法,“页面超过一屏就独立成 assets/ui/index.html”,我就老老实实拆成了 index.html + css/style.css + js/app.js 七八个文件,页面里正常写相对引用:

<link rel="stylesheet" href="css/style.css">
<script src="js/app.js"></script>

编译通过,窗口也起来了。然后我看到的是这个:

字都在,但完全没有样式——原本用 display:none 藏起来的五个页面(首页、游戏页、结算页、图鉴、家长中心)全部摊在一屏上。而且桥日志里一行错误都没有:

[cj-bridge] controller ready, loading page
[cj-bridge] NavigateToString -> hr=0x00000000      ← 看着完全正常
[cj-bridge] html navigation requested

我一开始以为是自己 CSS 写错了,改了两轮才反应过来:JS 也没执行——首页的年级列表、玩法卡片应该是由 JS 渲染的,全是空的。也就是说整个 js/ 目录一个文件都没加载。

翻框架源码才明白:PageSource.Asset 是"把 HTML 文件读成字符串",Windows 侧再走 NavigateToString——文档没有真实来源,相对路径的 <link href> / <script src> 一律解析不到。

判定方法我后来写进了踩坑记录:NavigateToString -> hr=0x0 是"成功"行,别被骗;真正的判据是页面自检行出不来。

// 页面启动成功后回投一行到仓颉 stderr
bridge.invoke('report', { line: 'boot ok · bridged=' + bridged + ' · groups=' + groups });

日志里出现 [frontend] boot ok · bridged=true · groups=102,才算页面真的活着。

解法:源码分文件,构建时内联成一个自包含页面

我不愿意为了这个把 CSS/JS 全塞进一个一万行的 HTML 里——那没法维护。所以加了一个 30 行的内联器:源码仍然是分文件的,跑一下 node tools/build-ui.js 生成自包含的 index.html。

// tools/build-ui.js 的核心就两条正则
html = html.replace(/[ \t]*<link\b[^>]*href="([^"]+)"[^>]*>/gi,
  (m, href) => '<style>\n' + readAsset(href) + '\n</style>');

html = html.replace(/[ \t]*<script\b[^>]*src="([^"]+)"[^>]*><\/script>/gi,
  (m, src) => '<script>\n' + readAsset(src) + '\n</script>');

// 内联完还有相对引用就当场失败,别让下一次运行再当侦探
const leftover = html.match(/(?:src|href)="(?!https?:|data:|#)[^"]+"/g);
if (leftover) process.exit(1);

生成的是 110 KB 的单文件页面。同一份代码,跑起来是这样:

在这里插入图片描述

这一坑后来提成上游 issue #9:框架内部其实知道这个限制(AGENTS.md 提过一句),但面向使用者的 skill 和模板都没说,照做就必然踩。我在 issue 里建议了三步——check 扫到相对引用就告警、skill 补一句、将来用 SetVirtualHostNameToFolderMapping 真支持。


三、游戏本体:一局 5 题,五种玩法

字库我按方案文档整理成 102 组,覆盖一到五年级(13/14/22/31/22 组),每组带拼音、词组、偏旁和一句记忆口诀:

['点画系列', '大下加点是太,大右加点是犬',
  [['大', 'dà', '大小', '大'], ['太', 'tài', '太阳', '大'], ['犬', 'quǎn', '警犬', '犬']]],

数据结构是紧凑的一行一组,改字库不用碰仓颉代码,改完 node tools/build-ui.js 就生效(我写了个校验脚本,确认 102 组里每个词组都真的含那个字)。

五种玩法

玩法练什么怎么玩
🔍 火眼金睛整体字形辨识一堆相同的字里找出不一样的那个(或者找出和样例一样的)
🔗 汉字配对字 ↔ 词义左栏字、右栏词语,点着连起来
🕵️ 偏旁侦探偏旁归因给一个字,从偏旁里挑出它的偏旁
👂 听音辨字听觉 → 视觉听读音选字,读不出来就退化成看拼音选
📖 组词挑战语义理解词语挖个空,选能填进去的字

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

难度是自适应的:连对就多一个干扰项(最多 6 个选项),答错就退一格。答错也不惩罚——不给星,但也不扣星;同一题错到第 3 次,自动把记忆口诀显示出来:

口诀:日里一横是太阳,目里两横是眼睛

奖励与家长端

星星一颗一颗攒,攒够就喂宠物升级(6 个形态,从蛋宝宝到汉字神龙)。徽章 7 枚,最早解锁的那个是"初出茅庐——完成第 1 局",答对 0 题也给你:

在这里插入图片描述

注意那句鼓励语——“今天这几个字有点难,你都坚持做完了,明天再来一次就会更熟”。夸过程不夸结果是方案的硬要求,代码里所有文案都按这条写,答错时说"再看看这两个字差在哪",绝不出现"错了"“笨”。

家长中心单独设了密码(默认 1234),进去能看到今日时长、正确率趋势,以及反复答错的字组自动进"重点复习区":

在这里插入图片描述

折线图是我自己用 Canvas 画的,没引第三方库——一个 chart.js 加进来就要考虑离线包体积,不值得。


四、仓颉后端只干一件事:把进度存下来

前端用 localStorage 也能存,但那东西在 WebView 里不保险(那些页面没有真实来源,origin 是 opaque,某些情况下直接抛 SecurityError)。孩子的星星和徽章不能丢,所以我让仓颉侧写文件。

四条命令,都在 src/storage.cj 里:

progress:load   读存档(返回 JSON 文本,空存档回空串)
progress:save   覆盖写(入参 { data: "<JSON 文本>" })
progress:path   回存档路径(家长备份用)
app:about       版本与数据目录

cj-tauri 有个"三处联动"的铁律,漏一处前端就用不了:

// ① 实现 + 注册
public func build(): AppSpec {
    return AppSpec()
        .window(WindowConfig("形近字辨别训练", 1024, 720))
        .wire({ app =>
            app.register("progress:load", ProgressLoadCommand())
            app.register("progress:save", ProgressSaveCommand())
            // ...
        })
}
// ② assets/capabilities/default.json 里放行(否则前端拿到 command not allowed)
{ "commands": ["progress:load", "progress:save", "progress:path", "app:about", "report"] }
// ③ 前端 invoke
bridge.invoke('progress:save', { data: JSON.stringify(state) })

只做 ①③ 会得到 command not allowed,只做 ①② 会得到 command not registered。我第一次就漏了清单,报错信息倒是很清楚。

存档落在 %LOCALAPPDATA%\cj-hanzi-game\progress.json——应用自己的目录,不是项目目录,这样换工作目录启动也不丢进度。写之前先校验一遍 JSON 合不合法,不合法就拒写:“宁可丢一次保存,也不要把存档写坏”。

前端还留了兜底:后端不可用时退回 localStorage,并且每次保存都同时写一份本地缓存。真出问题时至少不是全丢。


五、两个把我卡了半小时的环境坑

这两个都不算框架的锅,但都不给任何提示,值得记下来。

坑 A:cjpm 在完整 PATH 下静默退出 127

$ cjpm -v
$ echo $?
127

一个字符都不打。查了半天才发现是 PATH 顺序:把仓颉的 bin、tools/bin、runtime/lib/windows_x86_64_cjnative 放到 PATH 最前面就好了。同样的二进制,同样的参数,顺序不一样结果就不一样——这种问题没有任何报错,只能靠对照实验。

坑 B:少了 WebView2Loader.dll,窗口一闪而过

编译一切正常,双击产物,桥只打一行就销毁窗口:

[cj-bridge] host thread started
[cj-bridge] WebView2Loader.dll not found: put it next to the exe or on PATH
[cj-bridge] host destroyed: 000001575fcd8780

而 cj-tauri info 会告诉你 C 桥已构建、cjc/stdx/cjpm 全部解析正常——info 全绿不代表能跑。把框架 native/webview2/WebView2Loader.dll 拷到 exe 旁边就完事。

这两条我都提进了上游 issue #11,顺带还报了两个:cj-tauri check 的三方比对失败时只回十进制 code=3221225781(我是自己换算成 0xC0000135 才认出是 DLL 未找到),以及 cj-tauri create --help 会把 --help 当项目名建出一个目录。


六、顺手把上游的一个 bug 修了

做这个游戏的过程中我跑了 cj-tauri check,它说:

[cj-tauri] 骨架漂移:targets/desktop/cjpm.toml 与生成器输出不一致(运行 cj-tauri sync desktop 重新生成)

行,我按提示重跑 sync,再 check——还是这条。再 sync,再 check,还是。这就不是"我改坏了",是它自相矛盾。

翻了 cli/src/sync.cj 才看明白,是三个小问题叠在一起:

  1. 读业务包 cjpm.toml 里 path = "E:\\test\\a" 时没做 TOML 反转义,\\ 原样带下去,最后渲染成 E://test//a 写进我的 targets/;
  2. sync 用业务包声明渲染,check 却用 resolveFrameworkRoot() 重新渲染再比字符串——两条来源形式不同,比对永远不成立;
  3. toTomlPath 只把 \ 换成 /,不合并重复分隔符,所以老骨架里那个 E:// 永远比不相等。

修法也就三行思路:还原转义、两处共用一个取根入口、比对时两侧都归一化。

// 唯一入口:sync 与 check 都必须走它,否则就等着"sync 完立刻报漂移"
func resolveSkeletonFrameworkRoot(verbose: Bool): Option<String> {
    match (readBizFrameworkPath()) {
        case Some(p) =>
            if (verbose) { eprintln("[cj-tauri] 框架路径取自业务包声明:${p}") }
            return Some(p)
        case None => return resolveFrameworkRoot()
    }
}
// 归一化时保留开头的 UNC `//`:那是 Windows 共享路径,不能合并掉
func toTomlPath(p: String): String {
    let s = p.replace("\\", "/")
    // ... 合并重复分隔符 ...
}

我还补了 5 条回归测试,守两条不变量:TOML 转义必须还原(未知转义不许被吃掉)、分隔符必须合并且幂等。

验证时我特意做了个 A/B

cd cli && cjpm test 跑出来 10 条里 2 条红。我第一反应是"我改坏了",但没敢直接改测试——先把我的改动 stash 掉,在基提交上重跑:

基提交(无我的改动):TOTAL 5,PASSED 3,FAILED 2   ← 同样这两条
带上我的改动:       TOTAL 10,PASSED 8,FAILED 2   ← 新增 5 条全过,红的还是那两条

结论清楚了:那两条 [links] 用例在基提交上就是红的,不是我引入的。测试红了先做对照实验,别急着改测试让它变绿——那是掩盖问题,不是修问题。

修复提成了 PR #25,文档沉淀(速查表 4 条 + AGENTS.md §4 三条)提成了 PR #24。

顺带还有个没修的:scripts/test.sh 在我这机器上链接期失败,libstdx.crypto.x509.a 缺 CertOpenSystemStoreA 一类的符号(mingw 的 link-option 少了 -lcrypt32,基提交就这样)。这条我如实写进了 PR 说明,没顺手夹带——一个 PR 只做一件事,不然 review 的人会很难受。


七、上鸿蒙平板:装包三十秒

桌面跑通只是半场。平板接上那天,真正的门槛是签名——鸿蒙真机不认自签,它只信「包名 + 设备 UDID」双匹配的 profile。

我一开始先跑离线体检:

[sign] 包名: com.example.hanzi_game
[sign] 设备 UDID: (取不到)
[sign] 原因: 拿不到设备 UDID(设备没连?跑 `hdc shell bm get --udid` 看看)

设备明明连着。真相是 hdc 不在 PATH 里(它躺在 DevEco 的 toolchains 目录下)。把这个目录加进 PATH,同一条命令立刻改口:

[sign] 设备 UDID: 667BA4F7…DCE1443
[sign] 原因: 本机没有「含本机 UDID 且包名为 com.example.hanzi_game」的材料
[sign] 下一步: 跑 `cj-tauri sign --login` + `sign --auto` 为这个包名现签一份

这条我也拿去提了上游(见文末),因为它给出的第一句结论会把人往「是不是线坏了」的方向带。

按提示走自签名:sign --login 弹一次浏览器、登录 AGC(令牌加密落盘、约 4 小时有效),sign --auto --newKeys 本地现生成密钥对——私钥永不上云,只把 CSR 交上去换证书,再把 profile 和密文口令写回 build-profile.json5。整套一分钟,cj-tauri的自签名,比在 DevEco 里点自动化流程还快。

然后我踩了个顺序坑

改完样式重新生成页面后,我按习惯跑了 cj-tauri sync ohos —— 它把 build-profile.json5 按模板重写了一遍,signingConfigs 变回空数组。接着打包:

> hvigor Finished :entry:default@SignHap... after 2 ms          ← 快得可疑
[ohos] HAP = …/entry-default-unsigned.hap
[ohos] 注意:这是未签名包,不能装进设备。

再跑一次 sign --auto(不带 --newKeys,想复用已有材料)时,它到第 ⑥ 步就结束了,没打印第 ⑦ 步「已写回 signingConfigs」、也没报错——因为那份 p12 的口令是上一次随机生成、只在当时存在过,写回需要的密文它拼不出来,于是静默跳过。于是你会拿着一个「签名成功过」的工程,编出未签名的包。

落法很简单,但得知道:顺序是 sync → sign → build,签完不要再 sync;真要重来就 sign --auto --newKeys,它会把口令重新生成一遍并写回。

装上去之后,一次就成了:

hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.example.hanzi_game

鸿蒙真机上的样子

同一份 assets/ui/,在 1840×2800 的平板竖屏上(SLG-W10):

在这里插入图片描述

第一次装上去(上图是调过之后的)我看着别扭:.big-btn { margin-top: auto } 在 2800px 高的屏上把「开始练习」顶到了屏幕最底,中间空出一大片,像布局没写完。于是加了一段针对高窗口的媒体查询——内容成组居中、字号整体放大、字卡和选项卡也跟着放大(孩子不用凑近屏幕):

@media (min-height: 900px) {
  #view-home { justify-content: center; }
  #view-home .big-btn { margin-top: 22px; }
  .home-title { font-size: 40px; }
  .target-char { font-size: 176px; }
  .opt-card .opt-char { font-size: 104px; }
}

游戏页在鸿蒙平板上:

在这里插入图片描述

到这里,一份业务代码,桌面和鸿蒙两端都跑起来了——src/ 里那 200 行仓颉、assets/ui/ 里那 10 个前端文件,两端共用的就是同一份,差别只在 cj-tauri.toml 派生出两个骨架。

八、现在它能干什么,以及下一步

已验证的(都有实机截图和桥日志对应):

  • 五种玩法都能玩,作答判定、进度点、连击、鼓励气泡都对;
  • 一局 5 题走完会进结算页,星星计入存档、徽章解锁;
  • 家长中心的密码门、统计、折线图、重点复习区都正常;
  • 存档经仓颉命令落盘,文件内容我核对过;
  • 鸿蒙平板实机跑通:签名、装机、启动、1840×2800 竖屏布局都验过。

没验证的也得说清楚:

  • 音效和语音朗读——Web Audio 合成 + SpeechSynthesis,桌面和平板都只验证了不抛错、不阻塞玩法,好不好听得孩子耳朵说了算;
  • 平板上的长时间使用(半小时以上会不会内存涨、ArkWeb 会不会掉帧)没测。

下一步就是等她放学试玩,然后接平板:

export CANGJIE_STDX_OHOS=<鸿蒙版 stdx>/linux_ohos_aarch64_cjnative/dynamic/stdx
cd targets/ohos && npx cj-tauri build
cd targets/ohos && npx cj-tauri run --install     # 先 cj-tauri sign 检查签名材料

对她来说,这个游戏是不是用仓颉写的、有没有踩坑、上游修了几个 bug,全都不重要。她只关心一件事:自己选的宠物今天长大了没有。


链接汇总

Logo

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

更多推荐