用仓颉语言写一个漂亮的桌面音乐播放器:cj-tauri 实战
从一条脚手架命令开始,做一个能拉榜单、能搜歌、能看同步歌词的桌面播放器,以及我在这个过程中定下来的取舍和踩过的坑。
cj-tauri项目地址: https://atomgit.com/qq8864/cj-tauri
音乐播放器项目地址: https://gitcode.com/qq8864/cj-tauri/tree/main/examples/music
体验windows下可执行文件地址:https://gitcode.com/qq8864/cj-tauri/blob/main/examples/music/dist-single/music.exe
注: 本项目免费听歌,仅用于学习研究,禁止用于其他用途!
起因:我想要一个「双击就开」的播放器
我写代码的时候习惯开着歌。这些年试过的在线播放器都差不多:一个标签页,切来切去就忘了它还在放;十几个标签页开下来,浏览器先把内存吃满;偶尔还会弹一个会员提示框,把我的注意力从代码上拽走。
我真正想要的很简单:一个双击就开的窗口。小、快、不联网也能开(当然取数据要联网)、收藏和最近播放存在自己机器上、歌词跟着播放进度滚。它不需要曲库——我只想让它把我常听的榜单和搜索摆在我面前。

需求清楚了,接下来是技术选型。摆在我面前的是三档:
| 选择 | 结果 |
|---|---|
| Electron | 上手最快,但一个只为播 <audio> 的窗口要背一整个 Chromium(安装包上百 MB、内存几百 MB),我不甘心 |
| Qt / 原生 GUI | 体积和性能都对,但界面要重画一遍,歌词滚动、主题色、卡片悬停这些 Web 最擅长的事全得手写 |
| 系统 WebView + 原生后端 | 界面用 HTML/CSS/JS(我最熟的那套),后端编译成原生可执行文件——正是 Tauri 的思路 |
第三档就是我要的。而我在写的 cj-tauri 恰好就是这个东西——用华为仓颉语言实现的类 Tauri 2 框架。于是就有了仓库里的这个示例:examples/music,一个桌面音乐播放器。

cj-tauri 是什么
一句话:用仓颉写的 Tauri。
Tauri 的卖点是「Rust 后端 + 系统 WebView 前端」——界面交给系统自带的浏览器内核渲染,而不是像 Electron 那样把整个 Chromium 打包进去。cj-tauri 把这一套搬到仓颉上,三件套严格对位:
| Tauri | cj-tauri | 干什么的 |
|---|---|---|
tauri::Builder | TauriApp | 装配应用:窗口、命令、插件 |
#[tauri::command] | CommandHandler 接口 | 前端能调的后端函数 |
@tauri-apps/api | window.__CJ_TAURI__ | 前端的 invoke / listen / emit |
capabilities/*.json | capabilities/*.json | 命令 / 事件白名单,默认最小权限 |
tao + wry | src/host.cj + native/bridge_*.c | WebView 宿主(Windows Win32 + WebView2 / Linux GTK + WebKitGTK) |
架构从上到下就四层,很好记:
前端 (HTML/CSS/JS) ← window.__CJ_TAURI__.invoke / listen / emit
│ JSON over postMessage
▼
IPC 消息桥(src/ipc_hub.cj) 命令注册 / 分发 / 校验
▼
能力安全模型(src/capability.cj) 白名单,没声明的一律拒绝
▼
WebView 宿主(src/host.cj + 各平台实现)
├─ Windows: WebView2(Win32 消息循环跑在宿主线程)
├─ Linux: webkit2gtk-4.1(C 桥跑在原生 pthread 上)
└─ 鸿蒙: ArkWeb(架构预留位)
平台状态我得说清楚,免得你照着这篇做却发现跑不起来:
- Windows + WebView2:已实机跑通——这个播放器就是我日常在 Windows 上用的;
- Linux + WebKitGTK:已实机跑通(框架与示例的自检都在上面跑过);
- 鸿蒙 ArkWeb:只有架构预留位,还没实现。
开源地址(双托管,一次 git push 两个远端都同步):
git clone https://atomgit.com/qq8864/cj-tauri.git # AtomGit(国内直连推荐)
git clone https://github.com/yangyongzhen/cj-tauri.git # GitHub
我写这篇的时候框架是 0.7.0:README.md 有总览,docs/使用文档.md 是使用指南,
docs/前端入门教程.md 适合第一次上手,AGENTS.md 是开发契约(也是我踩坑的登记簿)。
五分钟起一个自己的应用
先说实话:这个「五分钟」是指装好仓颉 SDK 之后五分钟。
SDK 去 cangjie-lang.cn 下载(要华为账号,没有匿名直链),再配上 stdx。
我这边用的是 仓颉 SDK 1.2.0 + stdx 1.2.0.1。Windows 上要么把 SDK 的
runtime/lib/<平台>、bin、tools/bin、tools/lib 都塞进 PATH(也就是官方 envsetup.bat 的那套布局),
要么只设 CANGJIE_HOME、让启动器自己补齐——后者省事得多。这一步没对,症状是进程退出码 127 且一行输出都没有,
很难猜,所以先验证 cjc --version 能跑通再往下。
环境好了,一条命令就够。CLI 就在框架仓库里(仓颉源码,cli/src/ 5 个文件),不用单独安装——
首次运行它会自动用 cjpm build 构建 CLI 本体,并按 SDK envsetup.bat 的目录布局设好 PATH:
# Windows
cli\cj-tauri.bat create music
# Linux / Git Bash
bash cli/cj-tauri.sh create music
真实输出(本机实跑,两处本机绝对路径已换成占位符):
[cj-tauri] 已创建项目 music/(6 个文件)
项目名 : music
模板 : app
仓颉包名 : music
框架依赖 : <框架仓库根目录>
stdx : <stdx 动态库目录>
下一步:
cd music
cj-tauri dev # 构建 C 桥 + 编译 + 启动窗口
cj-tauri build # 仅构建
提示: cjpm.toml 里的路径是本机绝对路径;换机器需重新执行 create 或手改这两处。
生成的 6 个文件(行数是实测的):
| 文件 | 行数 | 里面是什么 |
|---|---|---|
cjpm.toml | 17 | 应用构建配置:[dependencies] cjTauri = { path = … } + [target.<triple>] 链接参数 |
src/main.cj | 88 | 入口:greet / timer(推事件)/ 菜单演示 + app.run(html) |
ui/index.html | 118 | 演示页:invoke / listen / 插件 shim 的用法样板 |
capabilities/default.json | 7 | 白名单:greet / timer / system:*,权限集 menu:state,事件 tick / menu:click |
README.md | 80 | 模板自带说明:加命令、推事件、窗口配置怎么写 |
.gitignore | 13 | 构建产物 + 平台动态库 |
几条 create 的规矩,我第一次用的时候都撞过:
- 项目名要能推出仓颉包名:必须以字母开头,只含字母 / 数字 /
-_.;分隔符后面首字母会转大写
(my-app→ 包名myApp)。推不出来直接报错退出。 - 目标目录必须不存在:已存在就报
[cj-tauri] 错误: 目录已存在: …,不会覆盖——我一开始想在空目录里
「重跑一遍」看输出,结果每次都得多改一个名字。 - 模板有四种:
--template app(缺省,内联 HTML 单文件、零 Node 依赖)、vue(Vue 3 + Vite)、
react(React 18 + Vite),也可以直接写仓库里自建的模板目录名(cli/templates/<名字>)。
vue/react会多带一套ui/前端工程,cj-tauri dev会接管它的 Vite dev server,
改前端源码不重启应用就能看到效果。 - 换机器要重新
create或手改两处:cjpm.toml里的框架依赖路径与 stdxpath-option是本机绝对路径。
日常就六个子命令:
| 子命令 | 干什么 | 什么时候用 |
|---|---|---|
cj-tauri dev | 构建 C 桥 + cjpm build + 启动窗口 | 开发时最常用(Vue / React 模板还会顺带起 dev server) |
cj-tauri build | 构建 C 桥 + cjpm build | 只想要产物 |
cj-tauri run | 运行已构建产物(不再编译) | 产物是最新的、只想再开一次 |
cj-tauri info [--json] | 环境自检 / 打印插件装配清单 | 排障第一站;--json 给 CI 用 |
cj-tauri create / help | 建项目 / 帮助 | —— |
有一个坑我要提前说,因为它不是「代码错」而是「环境错」:native/libcjtbridge.dll(或 .so)是本地产物、不入库。
dev / build 会替你构建它,但如果你拉了「改过 C 桥导出」的提交却没重建,会在链接期炸出一串
undefined reference to cj_bridge_*——看着像代码问题,其实是产物过期。重建一条命令:
Windows 是 native\build_win.bat,Linux 是 bash native/build_linux.sh。
从空模板到播放器:先画界面,再分文件
模板给的是一个「问候 + 计时器」的演示页。我要把它改成一个播放器,所以第一件事不是写代码,而是把界面画出来——
因为一个播放器的信息密度决定了它的布局,布局定了,后端要提供什么数据也就定了。
我想要的是这样的一个窗口(1280×820):
┌───────────────────────────────────────────────────────────────────────┐
│ 仓颉爱音乐 🔍 搜索歌曲 / 歌手 🎨 │
├──────────┬────────────────────────────────────────┬───────────────────┤
│ 发现 │ 热门歌曲 后台音乐榜单 [刷新] [全部播放] │ │
│ · 热门歌曲│ ┌───────────────────────────────────┐ │ ╭─────────╮ │
│ · 后台歌单│ │ 榜单 Top 5 轮播(封面+信息+按钮) │ │ │ 唱片 │ │
│ · 搜索结果│ └───────────────────────────────────┘ │ ╰─────────╯ │
│ 我的 │ 后台歌单 共 9 张 [查看全部] │ 正在播放 │
│ · 我的收藏│ ┌────┐┌────┐┌────┐┌────┐┌────┐┌────┐ │ 歌名 / 歌手 │
│ · 最近播放│ └────┘└────┘└────┘└────┘└────┘└────┘ │ │
│ │ 1 缩略图 曲名 / 歌手 时长 ▶ │ 同步歌词 │
│ │ 2 …… │ 跟着进度滚动 │
├──────────┴────────────────────────────────────────┴───────────────────┤
│ 正在播放 ⏮ ▶ ⏭ ⏹ 00:18 ──────●────── 03:00 🔊 ─── │
└───────────────────────────────────────────────────────────────────────┘
左侧导航 212px、右侧「正在播放 + 歌词」320px、中间自适应——在这个宽度下三栏都不用折行。想清楚了就是分文件:
examples/music/
├── cjpm.toml 应用构建配置(框架依赖 + 两个平台的链接参数)
├── capabilities/
│ └── default.json 能力白名单:12 条命令
├── src/ 仓颉后端(6 个文件,1 371 行)
│ ├── main.cj 装配:窗口 + 注册命令 + 读页面
│ ├── commands.cj 命令层:参数校验 → 调接口 / 读写收藏 / 取封面
│ ├── api.cj HTTP 传输层(超时、redirect、TLS、浏览器 UA)
│ ├── cover.cj 封面取回:图源白名单、剥代理、缩略计划
│ ├── lrc.cj LRC 解析(多时间戳行、毫秒换算)
│ └── store.cj 本机收藏库(JSON 读写 + 上限夹取)
├── ui/
│ └── index.html 整个前端界面与交互(3 657 行,独立文件)
├── run.bat 一键构建 + 起窗口 + 两档自检 + stderr 落盘
└── README.md 这个示例的用法 / 取证 / 踩坑清单
为什么前端不内联在仓颉的三引号字符串里,而是单独一个 ui/index.html?模板默认是内联的,但一个播放器的前端是
一整个页面(左侧导航 + 榜单 / 搜索 / 歌单三个视图 + 播放器 + 歌词 + 弹窗 + 样式),内联会同时踩两个坑:
- 仓颉的三引号字符串会处理反斜杠转义——JS 里的
\n、正则表达式会被仓颉先吃掉,整段<script>静默变成
语法错误,页面里一行都不执行,而 stderr 毫无提示; - 字符串插值
${...}与 JS 的模板字面量同形,写模板串就等于往仓颉的插值里塞代码。
改成「启动时读文件、把 HTML 字符串交给 run(html)」之后,这两个问题一起消失,而且页面还能用编辑器的 JS 语法检查
(node --check 验一遍)。代价是必须从项目根启动——ui/ 与 capabilities/ 都是按相对路径读的:
let MUSIC_UI_PATH = "ui/index.html"
func loadMusicUi(): String {
var bytes: Array<Byte> = []
try {
bytes = File.readFrom(MUSIC_UI_PATH)
} catch (e: Exception) {
throw Exception("读不到前端页面 ${MUSIC_UI_PATH}(${e})——请在 examples/music 目录下启动:" +
"capabilities/ 与 ui/ 都按项目根相对路径读")
}
return String.fromUtf8(bytes)
}
从空模板到成品,改动的就是这几处:
| 位置 | create music 生成的 | 这个播放器 |
|---|---|---|
cjpm.toml | 依赖是本机绝对路径,只有本机那一个 [target.<triple>] | 依赖改仓库内相对路径 ../..;补上 Linux + Windows 两个 target;包名 music → music_app |
src/ | 单文件 88 行(greet / timer / 菜单演示) | 6 个文件 1 371 行,按单一职责拆分 |
ui/index.html | 118 行演示页 | 3 657 行:深色三栏播放器 + 同步歌词 + 页面自检 |
capabilities/default.json | greet / timer / system:*,事件 tick | 12 条命令(music:* / fav:* / report / system:version),不再用事件 |
.gitignore | 构建产物 + *.dll | 多一行运行期数据 music-library.local.json(本机收藏与最近播放) |
run.bat | 无(模板只给 cj-tauri dev) | 新增纯 ASCII 的 run.bat:一键构建 + 起窗口 + 两档自检 + stderr 落盘 |
README.md | 模板自带「怎么写命令 / 事件」 | 换成这个示例的用法、取证与踩坑清单 |
一句话总结:脚手架负责「能跑起来」,我负责「业务 + 取证」。
后端:一个命令就是十来行仓颉
框架的设计很「Tauri」——加一个前端能调的后端函数,要三处联动,漏一处就用不了:
| 要加的东西 | 三处联动 |
|---|---|
| 一个命令 | ① 实现 CommandHandler ② app.register("名字", …) ③ 在 capabilities/default.json 的 commands 里声明 |
| 一个插件 | ① 实现 Plugin ② app.plugin(XxxPlugin()) ③ 能力清单里声明 "<插件名>:<短名>" |
| 一个宿主能力 | ① 扩 WebViewHost 接口 ② 改各平台实现 ③ C 桥加同名同签名的 cj_bridge_* 导出 |
漏 ① 或 ③ 会得到 command not registered;只注册不声明会得到 command not allowed。这行日志我见过太多次,
现在是我排障的第一判据——报错说得很具体,不用猜。
装配长这样(src/main.cj,这里只留关键几行):
main(): Int64 {
eprintln("[music] main start")
// ... 读收藏库、打日志
// 1280×820:三栏(导航 212 / 内容自适应 / 正在播放 320)在这个宽度下不用折行
let winCfg = WindowConfig("仓颉爱音乐 · cj-tauri", 1280, 820)
var app = TauriApp().window(winCfg)
app.register("music:hot", MusicHotCommand())
app.register("music:search", MusicSearchCommand())
// ... 其余 9 条(`system:version` 由框架在 run() 里注册)
let html = loadMusicUi()
app.run(html) // 阻塞;能力清单由 run() 自动扫 capabilities/ 目录
return 0
}
命令本身很薄——校验参数,拼请求,把结果交给页面:
/**
* `music:hot` —— 热门歌曲:`{start?, count?}` → `POST /api/v1/musicmenus`
*/
public class MusicHotCommand <: CommandHandler {
public init() {}
public func handle(cmd: String, args: JsonObject, ipc: IpcContext): JsonValue {
let (start, count) = pagedArgs(argInt(args, "start", 0), argInt(args, "count", MUSIC_PAGE_SIZE), 50)
var body = JsonObject()
body.put("kind", JsonString(MUSIC_HOT_KIND))
body.put("start", JsonInt(start))
body.put("count", JsonInt(count))
return musicApiCall("/api/v1/musicmenus", Some(body))
}
}
能力清单就是一条一条列出来,没写的调不动(默认最小权限):
{
"identifier": "default",
"windows": ["main"],
"commands": [
"music:hot", "music:search", "music:menus", "music:lyric",
"music:image", "music:share", "fav:get", "fav:set",
"music:config", "music:quit", "report", "system:version"
],
"events": []
}
这个应用一共 12 条命令,跟后台的对应关系是:
| 命令 | 参数 | 后台 | 备注 |
|---|---|---|---|
music:hot | {start?, count?} | POST /api/v1/musicmenus(kind=topWyMusic) | 实测只有这一个 kind 有数据,其余一律 400 mongo: no documents in result |
music:search | {q, start?, count?} | POST /api/v1/musicsearch | 空 q 在仓颉侧就拦掉,不发请求 |
music:menus | {start?, count?} | GET /api/v1/getsongmenu | 不带 start 会 400(field start is not set),参数必须给全 |
music:lyric | {sid, kind?} | GET /api/v1/musicsearchlrc | 返回 LRC 文本,解析在仓颉侧做 |
music:image | {url, size?} | —— | 宿主侧取图;size 是缩略边长,0 表示原图 |
music:share | {title, user, email, note?, cover?, songs[]} | POST /api/v1/createsongmenu | 把收藏当歌单提交;必填项在仓颉侧校验 |
fav:get / fav:set | {} / {songs?, recent?} | —— | 读写本机收藏库 |
music:config | {} | —— | 把 apiBase / libraryPath / selfCheck / pageSize 告诉页面 |
music:quit | {} | —— | 退出应用(自检收尾用) |
report | {line} | —— | 页面把结论回投仓颉侧 stderr |
system:version | {} | —— | 框架内置命令,界面上显示框架版本 |
参数校验是系统边界,不是可选动作
这一点我吃过教训,所以单独写一段:命令的参数全部来自页面,页面可控的字符串一个都不能直接信。
它们要么拼进 URL、要么落盘、要么原样提交给后台——不校验就等于把后端变成别人的跳板。我在命令层做的三件事:
- 拼 URL 的字段只放行允许的字符:
sid只接受数字(它要拼进?id=<sid>),歌词源的kind只接受字母; - 长度一律按字节夹取,而且不能切断汉字:UTF-8 的汉字占 3 字节,从中间切开就是非法序列——
clipText
从切点往回退掉续接字节; - 数组类参数逐条过一遍白名单:分享歌单时的每一首歌都重新抽成
{sid, song, sing, url, cover}五个字段,
脏条目直接跳过,不让它带坏整份收藏。
分页这类数字同样是边界:start 夹进 [0, 1000]、count 夹进 [1, 该命令自己的上限]
(榜单与搜索 50、后台歌单 30),页面传什么都不当真。
命令是异步分发的,所以慢命令不会冻窗口
框架的 IpcHub.handleInvoke 只做校验:「未授权 / 未注册」在调用线程上同步拒绝,
通过校验的命令会被 spawn 到 worker 线程执行,结果再回投给页面。
这对音乐应用很关键:榜单、搜索、歌词、取封面都是网络请求(我给了 20 秒超时),如果跑在宿主 UI 线程上,
窗口会直接卡住。契约上的代价是同一命令被并发调用时不保证执行顺序——页面按 promise id 匹配就好。
另外 worker 线程里只能通过框架给的 jsSink 回投结果,不能直接碰宿主(Linux 上直接调 GTK 会被运行时的栈边界校验 abort)。
前端:只经 window.CJ_TAURI,一处 fetch 都没有
桥(window.__CJ_TAURI__)是宿主在 document-start 时注入的——两平台对齐(Linux 用
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,Windows 用 AddScriptToExecuteOnDocumentCreated),
都比页面里的 <script> 早。但应用侧仍然不该假设「脚本一跑桥就在」,所以我照框架模板的做法先等它:
/*
* 等桥出现再跑页面逻辑。
* 两平台的桥都是 document-start 注入,但应用侧仍不该假设「脚本一跑桥就在」。
*/
function waitForBridge() {
return new Promise(function (resolve) {
(function poll() {
if (window.__CJ_TAURI__) { resolve(window.__CJ_TAURI__); return; }
setTimeout(poll, 30);
})();
});
}
/* 所有后端能力都走 invoke——本文件没有一处 fetch / XMLHttpRequest */
function invoke(cmd, args) {
return api.invoke(cmd, args || {});
}
/* 页面侧的结论回投仓颉 stderr(report 命令):实机取证以 stderr 为准,不开 devtools 也能看 */
function report(line) {
if (api) { api.invoke('report', { line: line }); }
}
为什么不让页面直接 fetch 后台,三条理由(写在这里是因为这是整个示例最容易被"优化"掉的设计):
- 契约:前端只经
invoke/listen/emit说话,业务代码里不出现postMessage,也不出现fetch; - 页面来源是 opaque:界面由宿主经
run(html)从字符串载入,Origin: null——直连后台要靠对方 CORS 放行,
那是不该写进业务代码的依赖,失败形状还是静默的(请求发出去了、控制台一行红字,用户只看到空白); - 取证:走
invoke才有端到端日志——每次调用都在 stderr 留一行[music] …,
实机验收时不开 devtools 就能看到「哪个命令调了、后台回了几字节」。
调用的形状就这样,没有中间层:
invoke('music:hot', { start: 0, count: 30 })
.then(function (res) {
S.hot = Array.isArray(res.data) ? res.data : [];
report('[music-page] 热门已加载 ' + S.hot.length + ' 首');
})
.catch(function (e) { report('[music-page] 热门加载失败:' + e); });
页面上有一处细节值得抄走:后台同一个接口回的音频地址scheme 不统一(搜索接口回 http、榜单接口回 https),
两个实测都能播。页面统一优先改用 https(少一层混合内容的风险),万一带 https 播不了,
播放器的 error 处理会换回另一种 scheme 再试一次。
至于界面本身——深色主题(默认蓝紫 / 日落 / 森林 / 深海四套配色)、左侧导航、中间榜单 / 搜索 / 歌单三个视图、
右侧「正在播放 + 同步歌词」、底部播放条,首页顶部是榜单 Top 5 轮播,下面一行横滑的后台歌单:

CSS 里有几条是我抓图之后人眼才发现的(自检断言全绿也照样错),后面「踩坑清单」里会讲。
五个值得单独讲的设计
1. 封面:防盗链 + 缩略,709 KB → 4 KB
后台给的封面全是百度图片代理(image.baidu.com/search/down?url=…),而这个代理按 Referer 放行。
我用同一张网易封面实测了一轮(2026-10-08):
| 取法 | 结果 |
|---|---|
百度代理 + Referer: https://image.baidu.com/ | 200 + 真图(149 706 / 709 326 字节) |
| 百度代理 + 别的 Referer 或不带 | 200 + image/jpeg + 0 字节 |
| 直连网易图床,不带 Referer | 200 + 原图(709 326 字节) |
直连网易图床 + ?param=200y200 | 200 + 4 194 字节 |
直连网易图床 + ?param=500y500 | 200 + 21 996 字节 |
「200 + 空 body」是最坑的失败形状——不是 403、不是 404,日志里看着像成功。而页面侧无解:
浏览器只允许用 Referrer-Policy 减少 Referer、不允许替换,何况本应用的页面来源是 opaque 的。
所以封面只能由仓颉侧带对 Referer 取回,再以 data: URL 交给页面。
缩略只对网易图床做(地址在代理串里是明文,?param= 是它自己的参数——注意是 y 不是 x),
其它图源保持原尺寸:没实测过的图源,宁可大一点也不要猜错参数把图取空。榜单一次 30 首,
按原图就是约 21 MB base64 灌进 DOM,而列表里那个框只有 56 px——列表按 200 取、主视觉与「正在播放」按 500 取,
拿不到就回退原图;尺寸一律在仓颉侧夹进 [48, 2048],页面传来的值不当真。
前端跟着做了三件事:按 url 缓存 Promise(同一张图只取一次)、并发上限 4(一次 30 张不至于把命令线程打满)、
失败 resolve 成 null 而不是 reject(一张图取不到不该让整屏报错)。
2. 歌词在仓颉侧解成「毫秒 → 行」
页面拿到的不是 LRC 原文,而是 {at, text} 数组。三个理由:
- 页面拿不到文件:内联来源是 opaque origin,相对地址与
file://都不成立,读歌词只能走invoke;
既然要走 IPC,就别把「解析」这件确定的事拆两次做——仓颉侧解完,页面只管显示; - 口径统一:多时间戳行(
[00:12.34][01:20.00]同一句)、[ti:]/[ar:]元信息、空行、
半角与全角空白、1/2/3 位小数,全部在一处收口;页面侧只做一次二分查找定位当前行; - 可断言:自检能直接数出行数、跳到第 5 行的时刻再看高亮索引——「歌词跟得上」变成一条日志。
解析里踩过一个坑:去空白只该去 ASCII 空白。仓颉里 String.size 与迭代 String 得到的都是字节
(见「踩坑清单」),拿「字符数」当长度去找文本,汉字(3 字节)就会错位。
3. 音频播放留在页面侧,不走 IPC
播放地址由 music:hot / music:search 随条目一起返回,页面直接 audio.src = item.url。
把音频字节搬进 IPC 再解码成 blob 是没必要的(几十 MB 走一次 invoke,还得自己管缓冲),
而进度条、duration、error 这些事件本来就在页面侧。
两个实证细节:
- 自动播放策略会拦下「带声音的起播」:自检里先
muted = true起播,验完再解除静音——
否则只会拿到一句NotAllowedError,什么都没验到; currentTime > 0 && duration > 0 && readyState >= 2才算「真的在解码播放」,
光看src设上了没用。
4. 收藏与「最近播放」写在本机 JSON,不用 localStorage
fav:get / fav:set 读写项目根下的 music-library.local.json(路径可用 CJ_MUSIC_LIBRARY 覆盖)。
放仓颉侧的理由:清浏览器缓存不该把收藏清掉;而且这份数据还要参与「分享歌单」——数据在仓颉侧,
提交时不用再从页面搬一遍。三条纪律:
- 整份回写 + 上限夹取:
songs最多 500、recent最多 100,写入前夹一次(页面可控的数组长度不当真); - 落盘失败必须喊:写不进去要打
[music] fav set … saved=false并把错误回投给页面,
不能让界面以为存上了——「日志里没有错误行」不等于「成功了」; - 写后读回:
fav:set返回前会把文件重新读一次再报songs=N,自检拿这个数对账。
5. 后台没有时长字段,我就不编
这是个很小的取舍,但我觉得它比上面四条更值得写:后台的歌曲对象只有
{sid, song, sing, url, cover} 五个字段,没有时长。
第一版我给列表的「时长」列填了看起来很像样的数字(按文件大小估的),抓图的时候盯着那一列越看越不对:
那是我编的。于是改成:只在真的播放过一次之后,把播放器报上来的 duration 记在内存里再显示
(durTextOf),没播过的就是 --:--。
同一个原则也用在歌词、封面、歌单上:取不到就显示「暂无歌词 / 暂无封面 / 没有数据」,
不拿占位数字把界面填满——用户分不出真假数据,但迟早会发现。
踩坑清单(这个示例真正踩到的)
网络与后台
- 仓颉的 HTTP 客户端不带默认 TLS。发 https 请求会直接抛
HttpException: TLS must be configured when HTTPS requests are sent.——必须显式
ClientBuilder().tlsConfig(TlsClientConfig())。这个坑我是先在examples/movie上撞的:
后台接口本身是http://,一直正常,一旦取 https 封面就 54 次全败。只跑 http 的代码完全看不出来。 - 百度图片代理按
Referer放行,失败形状是「200 + 0 字节」——所以每行取图日志我都带上字节数
([music] image … bytes=…),光看状态码会以为成功。 - 网易图床的缩略参数是
?param=WxH(y分隔),不是常见的?w=&h=;而且必须先剥掉百度代理,
直接对代理串拼参数没用。只对实测过的图源做缩略。 GET /api/v1/getsongmenu不带start直接 400(field start is not set),参数要一次给全。/api/v1/musicmenus只有kind=topWyMusic有数据,其余 kind 一律 400mongo: no documents in result。
所以命令层干脆不暴露kind——让页面能传只会传出一堆 400。
界面(这几条只能靠眼睛)
- 三栏布局里标题会被挤成两行。
.view-head是 flex 行,标题默认可以收缩,中间那栏一窄就把
「热门歌曲」折成「热门歌 / 曲」。修法是标题flex: 0 0 auto; white-space: nowrap,
让说明文字那段(min-width: 0; flex: 1 1 auto)去收缩截断。这条是抓图时人眼发现的,
自检断言全绿照样有这种毛病。 - 封面被拉伸,根因是给
div写了object-fit。封面是用innerHTML插进去的<img>,
没有类别、也没有内联尺寸,所以只写.hero .art { object-fit: cover }是写在div上的死规则——
换来的是每张封面都被拉长。正确做法是给每个容器各配一条img规则
(.banner .art img { width: 100%; height: 100%; object-fit: cover }这类)。 aspect-ratio+max-height会把宽度反推回去。首页顶部那个榜单轮播我写的是
aspect-ratio: 16/6+max-height: 268px+width: auto——浏览器为了同时满足前两条,
把宽度反算成了 714px,于是轮播右边缘比下面的列表短了 260 多像素,怎么调都"对不齐"。
修法很反直觉:补一个width: 100%。高度用什么封顶都行,宽度必须由容器给,
永远不要让它从比例反推。这条同样是抓图后发现的——断言的"能渲染"证明不了"对得齐"。
平台与工具链
- 窗口最大化后内容不铺满,一半是设计、一半是 bug。我一开始以为整个都是 bug,查完发现两件事:
① 页面里有一条max-width: 1560px; margin: 0 auto的护栏(故意的:三栏在大屏上拉太长反而难读),
所以窗口从 1280×820 拉到 1936×1048 时内容是居中 + 两侧留白,但内部确实在重排
(中间栏 628 → 1028px、歌单从 4 张变 6 张);② 真正的问题是上面第 8 条那个轮播宽度。
我把护栏留下了,也把「这是设计,不是 bug」写进了文档——不然下一个看到的人会重新查一遍。 .bat必须保持纯 ASCII:cmd.exe 按 OEM 码页读.bat,UTF-8 的中文注释会吞掉后续行,
脚本解析直接崩。所以run.bat里所有说明都是英文的(应用自己的日志仍然是中文,日志不受这个约束)。PATH里的 SDK 路径在 Git Bash 下必须是 POSIX 形式(/d/Program Files (x86)/Cangjie/...)。
写成D:/...会被 MSYS 破坏,症状是依赖仓颉运行时 DLL 的原生进程退出码 127 且一行输出都没有。- 别在用户正听歌时起第二个实例取证:两个实例会共用同一个 WebView2 用户数据目录,
而且都要写同一个收藏库。要跑自检就复制一份独立副本(见下一节)。
仓颉语言本身
- 三引号字符串会先处理反斜杠转义。内联 HTML + JS 写进
"""…"""时,JS 里的\n会被仓颉先
变成真换行,把 JS 字符串或单行注释拆断 → 整段<script>语法错误 → 页面里一行都不执行,
而 stderr 毫无提示。这个示例干脆把整个界面放进独立的ui/index.html(启动时读文件),
绕开转义与${}插值的全部问题;改完用node --check验一遍。 - 迭代
String得到的是UInt8字节(String.size也是字节数)。拿字节数当"字符数",
ASCII 的检查照样对,只有汉字会错成 3 倍——isSafeSid判数字、clipText防切断汉字都建立在这条上。 - 子包(单测)调不了框架的
foreign func:foreign func没有函数体、也就没有跨包符号,
在src/tests/里直接调会在链接期报undefined symbol: cjTauri:cj_bridge_create(编译期不报)。
办法是框架包内包一层包装函数。
怎么证明它真的能用
桌面应用最难受的一点是"看起来能跑"和"真的能跑"之间隔着一层。我给自己定的规矩是:
行为变更必须给出可观测证据,而证据落到 stderr——仓颉的 println 走 stdout 有缓冲,
进程被强杀时日志会丢,桥和框架的 eprintln、C 桥的 stderr 才是每行即时落盘的。
所以 run.bat 干的事就是:构建 → 从项目根启动 → 把 stderr 收进日志 → 按三个前缀分段打出来:
| 前缀 | 谁写的 | 能看出什么 |
|---|---|---|
[music] | 仓颉后端(命令层 / 接口层) | 装配到了 main()、页面读了多少字节、每条接口调用的状态码与字节数、歌词解出几行、收藏落盘结果 |
[cj-bridge] | C 桥(宿主) | 窗口建起来了、WebView2 就绪、脚本执行 hr=0x00000000(这个值不是 0 就说明注入失败) |
[frontend] | 页面(经 report 命令回投) | 页面的判断:真的在解码播放、歌词高亮跟上了、某项断言失败在哪 |
日志里我重点看这几行:
[music] main start -- 装配真的走到了 main()
[music] ui page loaded (N bytes) -- ui/index.html 读到了
[cj-bridge] ... window ... -- 宿主窗口起来了
[music] music:hot -> 200 ok bytes=… -- 页面的一次点击真的经仓颉打到了后台
[music] lyric sid=… lines=N -- LRC 在仓颉侧解出了 N 行
[music] fav set: songs=N saved=true -- 本机收藏库写成功
还有一条纪律:失败必须留痕。框架在命令抛异常时会打两行不同前缀的 stderr——
应用级拒绝是 [cj-tauri] command rejected (<命令>): <message>,非预期失败是
… command failed (<命令>): <异常>,前缀不同,grep 就能分流。
但我还是把这条教训写在 README 里:「日志里没有错误行」不等于「命令成功了」——
页面没接 catch 时失败只在页面上可见。所以页面侧每条请求都挂了 catch 并 report。
两档无人值守自检
应用支持 CJ_MUSIC_SELFCHECK=1/2:页面启动后自己走一遍全链路,每条结论经 report 回投 stderr,
跑完调 music:quit 退出——这样 run.bat selfcheck 才有终点(退出码约定照搬 examples/typing-poem)。
| 等级 | 覆盖 | 会不会写东西 |
|---|---|---|
1(run.bat selfcheck) | ①~⑩:榜单 / 可播地址 / 封面 / 真的在解码播放 / 歌词行数 / 高亮跟随 / 收藏落盘与读回 / 最近播放 / 后台歌单列表 | 只写本机收藏库(临时的收藏会撤掉),后台只读 |
2(run.bat selfcheck2) | 追加 ⑪ 分享歌单到后台、⑫ 写后读回(在后台列表里读到刚提交的标题) | 会往共享后台提交一条歌单 |
第 ⑫ 项存在的理由很实在:只断言 code=0 证明不了「别人也能看到」。后台这条链路的承诺是
「提交完出现在歌单页」,所以取 50 张列表、按标题精确比一遍(实测新提交的排在列表尾部,
一开始按 20 张取差点刚好漏掉)。默认那档不去动别人的共享数据——这条我认为比"多验一项"重要。
实测日志(2026-10-08,Windows 实机):
[frontend] [自检] PASS ③ 封面经宿主代理取回 — 5615 字节,前缀 data:image/jpeg;base64…
[frontend] [自检] PASS ④ 音频真的在解码播放 — paused=false currentTime=0.44s duration=180.2s readyState=4
[frontend] [自检] PASS ⑥ 歌词高亮跟随播放位置 — 跳到第 5 行(35220ms)后 lyricIdx=4
[frontend] [自检] PASS ⑩ 后台歌单列表取回 — 7 张;首张「猫哥的收藏」(30 首,分享者 王工)
[music] share title=自检歌单 10/8 22:03 songs=1 skipped=0 -> code=0 message=Success
[frontend] [自检] PASS ⑫ 分享的歌单能在后台列表里读到 — 第 8/8 张,含 1 首
[frontend] [自检] 汇总:10 passed / 0 failed ← 等级 1
[frontend] [自检] 汇总:12 passed / 0 failed ← 等级 2
想自己取证、又不想打扰正在跑的窗口,就在 .atomcode/probe/ 里放一份独立副本
(拷 ui/ + capabilities/ + main.exe,副本自己带 music-library.local.json 与 main.exe.WebView2/),
和真实示例完全隔离。
最后一句是给「只能靠眼睛」的那部分留的:自检证明不了"好看"。上面第 6~8 条坑全都是断言全绿时
截图抓出来的。Windows 上抓 WebView2 的窗口还得用 PrintWindow(handle, dc, 2)(PW_RENDERFULLCONTENT),
普通 BitBlt 拍到的是一片空白。
打包分发
和 examples/movie 一样,仓库脚本可以直接打成免 SDK 的便携包:
bash scripts/pack-win.sh # 目录式便携包
bash scripts/pack-win-single.sh # 单文件 exe(WebView2 loader 内嵌释放)
有一条必须记住:便携包要自带 WebView2Loader.dll 与 libssl-3-x64.dll / libcrypto-3-x64.dll
(仓颉运行时的 TLS 加载器,SDK 与 stdx 都不带)。这两类依赖不在 PE 导入表里,
所以"递归解析导入表收 DLL"一定会漏——漏掉 OpenSSL 的症状极像网络问题:窗口照起、页面照加载、
http:// 照 200,只有 https 全灭(TlsException: Can not load openssl library or function …)。
直接用脚本,别手写 DLL 清单。
小结
这个示例一共 6 个仓颉文件 1 371 行 + 一个 3 657 行的 ui/index.html,做到了:榜单浏览、搜索、
后台歌单、播放、同步歌词、收藏与最近播放落本机、把收藏当歌单分享到后台,外加两档无人值守自检和
一套 stderr 取证通道。整个过程里我改框架代码的次数是 0——脚手架给的模板、能力清单、命令接口,
一路用下来没有被逼着动框架,这大概是对"框架是否够用"最直接的答案。
如果你也想试:
git clone https://atomgit.com/qq8864/cj-tauri.git
bash cj-tauri/cli/cj-tauri.sh --version # 首次运行会自动构建 CLI 本体
cd <你要放项目的父目录> # create 在父目录里执行
bash <框架仓库>/cli/cj-tauri.sh create myplayer # Windows: <框架仓库>\cli\cj-tauri.bat create myplayer
cd myplayer
bash <框架仓库>/cli/cj-tauri.sh dev # 构建 C 桥 + 编译 + 起窗口
想先看这个播放器本身的代码,它在仓库的 examples/music:
README.md 里有更细的接口表、取证命令与完整踩坑清单;run.bat selfcheck 是零动手的验收入口。
相关文档:
docs/使用文档.md——框架整体用法(环境 → 创建 → 开发 → 排障)docs/前端入门教程.md——第一次上手:目录约定 → 桥 API → 事件 → 能力清单 → 调试docs/cj-tauri-介绍与movie实战.md——另一个业务向示例(观影应用)的逐层拆解docs/仓颉版Tauri-打字游戏实战.md——同系列实战:3D 场景、注入通道与版面自检docs/IPC-通信机制.md——IPC 报文协议、完整时序与实测性能AGENTS.md——开发契约:架构约束 + 「禁止重踩」的坑清单
更多推荐




所有评论(0)