在 OBS Studio 鸿蒙迁移的探索篇里,语音入口是最像"样板戏"的场景:声明一份 insight_intent.json、写一个执行器、返回成功,看上去就完事了。但录屏软件的特殊性在于,它的"录制"不是一个函数调用,背后是一整个 libobs 内核的启动。官方文档里那张轻描淡写的生命周期时序图,到了真实应用里就变成了主战场——执行器比内核先醒,语音命令到了,接收方还没出生。
本文梳理这次适配中遇到的问题、解法与验证方式,涉及 Intents Kit 意图声明、InsightIntentExecutor 生命周期时序、pending 队列、幂等护栏,以及一个容易误判为 bug 的排查插曲。
[B]一、目标与验收终态[/B]
目标是熄屏状态下说一句"小艺,用 OBS 开始录制",小艺拉起应用、界面出现、录制自行开始,全程手不碰设备。
意图框架(Intents Kit)的形态是:应用把"我能干什么"声明成标准意图,系统语音助手、智慧搜索、小艺建议等入口在合适时机把任务派给应用。对主播场景而言这很实用——麦克风架到嘴边、灯光调到一半时,一句"开始录制"比任何手势都顺手。
[B]二、声明:用一份 JSON 把应用挂上系统意图总线[/B]
入口在 resources/base/profile/insight_intent.json,两个意图各约 20 行:
- {
- "insightIntents": [
- {
- "domain": "MediaDomain",
- "intentName": "StartRecording",
- "intentVersion": "1.0.0",
- "srcEntry": "./ets/insightintents/OBSIntentExecutor.ets",
- "uiAbility": {
- "ability": "EntryAbility",
- "executeMode": ["foreground"]
- },
- "displayName": "开始录制",
- "keywords": ["录制", "录屏", "recording", "record"]
- },
- { "…": "StartLive 同款,keywords 换直播/推流/开播" }
- ]
- }
复制代码
三个字段值得展开:
1)domain 必须归入系统预定义的领域分类,媒体控制就在 MediaDomain 的标准词表内,这是助手能"听懂"的语义地基;自造领域没有入口流量。
2)executeMode 只声明 foreground,刻意不声明 background。理由很实际:录制管线需要 UIAbility context 存活(窗口、授权、XComponent 表面都在上面),后台执行等于让内核在没有家的地方出生。语音拉起本来就会把应用带到前台,foreground 模式正好顺水推舟。
3)keywords 是召回辅助词,把"开播""推流"这类主播黑话都塞进去,用户怎么说都能命中。
执行器侧继承 InsightIntentExecutor,实现 onExecuteInUIAbilityForegroundMode,收到 StartRecording / StartLive 后各自入队,返回 code: 0。到这里全部是官方文档的照抄,真正的仗在下面。
[B]三、时序问题:命令到达时,内核还没出生[/B]
官方生命周期文档给出的冷启动路径大致是:语音入口触发、意图框架冷启动拉起 UIAbility、框架调用执行器的 onExecute,随后页面 loadContent → aboutToAppear,页面里才执行 ObsController.initialize(),这一步需要数百毫秒,内核就绪后才真正执行 toggleRecording()。
问题出在中段:执行器的 onExecute 会在 onWindowStageCreate 之后、主页面 aboutToAppear() 之前被框架调用。此刻 libobs 没启动、场景不存在、输出对象没创建——直接执行 toggleRecording(),等于对着一个还不存在的内核按按钮。
这是所有"语音拉起 + 立即执行"类应用都会撞上的通用坑,解法也通用:把"收到命令"和"执行命令"解耦成两拍。实现上用一个约 60 行的 pending 队列:
- export class IntentActions {
- private static queue: ObsActionKind[] = [];
- private static controllerReady: boolean = false;
- static push(kind: ObsActionKind): void {
- if (IntentActions.controllerReady) { IntentActions.drainOne(kind); return; }
- IntentActions.queue.push(kind); // 冷启动:先存着
- }
- static markReady(): void { // initialize() 完成后调用
- IntentActions.controllerReady = true;
- const pending = IntentActions.queue;
- IntentActions.queue = [];
- for (const kind of pending) IntentActions.drainOne(kind); // 补执行
- }
- }
复制代码
逻辑很直接:谁先就绪谁 drain。热启动(应用本来就在)走 push 即执行;冷启动(执行器先响、initialize 后完成)走 markReady 补执行,一套代码覆盖两种时序。
drainOne 里还留了一个 setTimeout(800) 的余量,给 initialize() 刷完一轮 UI 状态留时间,避免"内核刚起、按钮还没绑好"的亚秒级竞态。
[B]四、执行器保持零重依赖,动作靠反向注册[/B]
执行器文件刻意保持零重依赖:它 import 的只有 Logger 和自家队列,不碰 ObsModel。原因是执行器被框架加载的时机极早,import 图上任何大块头都可能引入新的初始化顺序问题。真正的动作分发通过 IntentDispatcher.register(handler) 反向注册——ObsModel 初始化时把自己的处理函数挂进来。
"早加载的模块保持轻,晚加载的模块来认领",这条规律在 napi、插件加载等场景中已反复出现,意图框架只是换了个马甲又考了一遍。
[B]五、幂等护栏:语音世界里没有"切换"[/B]
dispatcher 里还有一道不起眼但必要的闸:start 类意图遇到"已在推流 / 已在录制"时直接忽略并返回成功。
为什么必须这么做?前端按钮是 toggle 语义(点一下开、再点一下关),手指操作下天经地义;但语音命令是 start 语义。用户说"开始录制",如果此刻已在录制,把 toggle 原样接过去,结果就是录制被"开始"掉了——更糟的是语音场景下用户往往看不见屏幕,他会以为录制开始了,实际是停了。
所以意图入口必须做语义翻译:StartRecording 的含义是"确保正在录制",而不是"翻转录制状态",已在录就静默成功。所有把 GUI 动作暴露给语音或 Agent 调用的接口,都需要补这一课:面向人的交互是状态翻转,面向指令的接口是幂等断言。
[B]六、一个"假故障":bm dump 里为什么查不到我的意图[/B]
代码闭环当天做自检,用 bm dump 翻应用信息想找意图注册字段,结果没有,差点给自己立一个"注册失败"的 bug。
后来想明白:意图框架是懒加载架构——insight_intent.json 打进 HAP profile 即完成注册,框架在命中调用时才动态装载 srcEntry 指向的执行器。注册面本来就没有常驻的 dump 字段,查无此字段是正常态。
这类插曲值得记录下来,因为它属于典型的"环境假象":验证手段和实现机制没对齐时,"查不到"不等于"不存在"。真正的验证是端到端跑一遍——熄屏状态下说出那句指令,应用被拉起并真的开始录制。
[B]七、关于"兼容 MCP"那行小字[/B]
声明意图时,官方文档里有一行不起眼但值得单独说的话:意图框架兼容 MCP(Model Context Protocol)。
这意味着今天挂在意图总线上的"开始录制 / 开始直播"两个原子技能,其声明语义(domain + intentName + params + keywords)与 Agent 工具声明的结构天然同构。往前看一步:本地 Agent 生态里,内容助手类智能体可以直接把录屏软件当"取证工具"调用;再往下,Agent Framework Kit 的 A2A(AgentAbilityExtension,API 26.0.0 新增)让应用甚至可以注册为 A2A 服务端,被跨设备的其他智能体发现和调用。
这些都是展望,当前一步都没做。但"兼容"二字的价值在于:今天为语音助手写的意图声明,明天 Agent 生态可以直接读,不用重做。
[B]八、入口选型的复盘[/B]
把这一系列探索放在一起看,恰好是同一应用的四种入口形态:软总线管控制面、socket 管数据面、Share Kit 管内容面、意图框架管语义面。四条通道的选型路径没有一条是"因为先进所以用":每条都先问数据特征(20 字节信令还是 2 Mbps 洪水),再问生命周期(谁活得久谁能收),最后才挑协议。混着用(比如拿协同通道传视频),每个能力都会以最尴尬的方式教你一遍。
[B]特性核对[/B]
Intents Kit 意图框架(insight_intent.json 声明 + InsightIntentExecutor,MediaDomain 标准意图,API 12+ 起持续演进,本机 API 26 sysroot 核实 onExecuteInUIAbilityForegroundMode 签名与 ExecuteResult 结构);官方文档明示意图框架兼容 MCP;Agent Framework Kit A2A(AgentAbilityExtension,26.0.0)为展望项、未实装。冷启动执行器时序以官方生命周期文档为准。 |