ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter对话引擎jenny鸿蒙化适配:从Yarn脚本到ArkUI集成

Flutter对话引擎jenny鸿蒙化适配:从Yarn脚本到ArkUI集成 Flutter 三方库 jenny 的鸿蒙化适配指南今年接手一个偏叙事向的鸿蒙应用产品里有一段很重的分支对话剧情玩家要在码头、酒馆、地下室三张地图里反复选择每个选择都会影响后续角色态度最后还能解锁三个完全不同的大结局。需求方一开始打算自己用 JSON 写状态机我看了两天代码就头皮发麻——分支嵌套、变量回溯、对话回放、条件跳转这些逻辑如果全部手写光 bug 就能养一支测试团队。后来我把方案切成 Yarn Spinner 生态在 Flutter 侧用三方库 jenny 跑剧情运行时再针对鸿蒙的开发环境做适配整个人才从泥潭里爬出来。这篇就聊一聊整套过程的思路和实操适合已经在用 Flutter 做鸿蒙应用、又需要对话分支或剧本编排能力的开发者参考。1. 先搞清楚我为什么在这些框架里选了一圈1.1 对话系统最怕的不是剧情复杂而是状态不可控如果你的叙事只有 A 说完说 B那确实不值得投入框架。但只要出现“根据上个章节是否拿到钥匙决定这扇门能不能开”“同一个 NPC 在不同时段说不同台词”“任务完成后再回来角色说的话要完全换掉”这种需求纯手写列表就开始失控了。业务上我们把这种能力叫剧情分支技术底子里其实就是图结构遍历。Yarn Spinner 原本是游戏圈里用来做互动对话的开源工具核心思路是让编剧用接近自然语言的文本文件写剧情再用编译器生成数据供运行时读取。它的玩法脚本天然支持选项分支、变量、条件判断、命令回调和节点跳转相当于把“对话状态机”从业务代码里彻底剥离出来。jenny 这个 Flutter 三方库做的就是同一件事的 Dart 移植把 Yarn Spinner 的运行时搬到 Flutter 里让你可以在纯 Dart 层加载剧情、解析节点、推进对话不需要依赖 Unity 或者其他原生引擎。选型时的对比也很明确市面上专门的 Flutter 剧情库非常少不少团队会选 state machine 插件自己拼但那只解决了“状态流转”并没有解决“编剧怎么写剧情、策划怎么调分支”。用 jenny 的价值在于把复杂交互逻辑放进了 Yarn 脚本里业务代码只需要关心怎么渲染和接收命令边界非常舒服。1.2 鸿蒙化的本质适配不是重写很多人一听“鸿蒙化”就误以为要把整条链路推倒重来。实际恰恰相反。jenny 是纯 Dart 实现的三方库这决定了它的核心逻辑并不依赖 Android 或者 iOS 的原生能力天然就给鸿蒙适配留了余地。真正需要改的是那些“和平台打交道的边缘”比如读取资源的方式、平台通道的注册方式、文本渲染差异、以及嵌入 ArkUI 页面时的布局策略。也就是说鸿蒙化 jenny 保留 Yarn 运行时本身 重新接通 Flutter 引擎与鸿蒙系统之间的资源通道。理解这层之后你读下面的步骤就不会觉得散装始终知道自己是在补适配层。1.3 定一条底线能不动 engine 就不动我把项目里对 jenny 的引入策略定成“黑盒依赖”所有对剧本的操作都通过 jenny 开放出来的加载器与运行时接口不继承、不魔改内部对象遇到需要扩展的能力优先用命令回调和自定义 View 解决。这条底线在后来的鸿蒙升级中救了我两次。因为三方库跟着 Flutter 鸿蒙分支走时API 偶发变化是常态如果你到处直接依赖内部实现每次版本升级都得跟着重构一遍。保持黑盒升级成本就只剩下“重新验证一遍边界能不能跑”。2. 环境准备与接入验收清单2.1 Flutter 鸿蒙开发环境的版本选择要跑鸿蒙侧的 Flutter 应用直接用官方 flutter 命令是不行的。你需要使用适配 OpenHarmony 的 Flutter SDK 分支或厂商提供的构建工具链并配合 DevEco Studio 完成工程配置。我这里建议两个原则第一不要追新选择和你鸿蒙 SDK 版本匹配度最高的 Flutter 分支因为 Flutter 引擎版本与 ArkUI 的接入方式有强关联第二装好后第一时间跑flutter doctor把环境里的报错清干净再继续。开发机上需要安装的东西大致有鸿蒙 SDK、Node.js、DevEco Studio、对应版本的 Flutter SDK、以及构建 hap 包所需的命令行工具。千万注意工程的签名与设备调试配置要提前在 DevEco Studio 里建好否则后面真机调试时应用根本装不上鸿蒙设备。2.2 把 jenny 加进依赖并做构建路径检查依赖接入本身不复杂在 pubspec.yaml 里加入 jenny 的版本声明然后执行flutter pub get。但真正的坑在构建产物路径鸿蒙打包后Flutter 的资源会被映射到 hap 包里的resources/rawfile/flutter_assets目录而 jenny 在运行时如果通过 AssetBundle 读取剧情 JSON它不知道外面包了一层 rawfile需要你确认资源的 key 是否和本地路径完全一致。我建议在接入第一天就写一个“最小资源探测”脚本加载一个小 JSON读不到就立刻打印 key 和完整路径不要在剧情文件几千行时才被卡住。资源读取崩溃的报错往往非常难看有时候只是大小写差一个字符排查成本却很高。2.3 用三段式极小剧情完成冒烟不要上来就导入完整剧情。我习惯用一个只有三个节点的厂商测试文件跑通一条最简单链路启动应用 - 加载剧本 - 显示第一句台词 - 点击选项 - 跳到下一个节点。这个链路做完相当于证明了 jenny 在鸿蒙运行时上剧本数据能被解析、运行时能推进、UI 能渲染后面再上真实剧情就只是内容量的变化。冒烟阶段还有一件事要提前做确认页面生命周期切换时对话状态会不会丢。鸿蒙应用在前后台切换、窗口尺寸变化时Flutter 视图可能会重建。如果你的剧情进度只存在内存里一切后台就从头开始用户会骂人。这个问题必须在第一时间摸底决定用 shared_preferences 还是文件存档不要等剧情做完了才补。3. 理解 Yarn Spinner 运行时才能知道鸿蒙化要改哪里3.1 节点、行、选项和跳转的运行时模型Yarn 脚本的基本单元是节点每个节点有标题和正文。正文里普通文本会被当成对白台词[[选项|跳转节点]]会生成分支选项命令是运行时指令。jenny 在 Dart 端把剧本编译后的数据加载成一张图当前“跑在第几个节点、经历了哪些行、下一步能选什么”完全由运行时决定。这套模型的含义是你的业务代码根本不需要保存“当前故事进度”只需要问 jenny“现在有哪些选项”然后渲染用户选择后说“去节点 x”剩下的交给运行时。分支嵌套的复杂度在 Yarn 文件里就能看清而不是散落在几十个 if 语句里。这一点对鸿蒙适配也非常关键因为业务代码被大幅简化真正要处理平台差异的就只剩加载和渲染两头。3.2 变量与命令如何穿过进程边界Yarn 脚本里的set $hasKey true、if $hasKey这类语法由运行时的变量表维护。当跳转或者选项依赖这些变量时引擎内部自己判断完全不需要你插手。但command就不一样了它相当于剧本向宿主应用发消息比如“播放一段音效”“展示一个立绘”“保存当前进度”。鸿蒙化适配的重点就在命令这一层。你可以把自定义命令理解成事件回调剧本说“playSound(water)”jenny 拿到回调后由你决定交给哪段 Dart 代码执行。这个过程跨不跨平台取决于你的实现。我的做法是把所有命令回调都集中到一个 manager 上内部再去调鸿蒙的音频播放或者震动接口。这样剧情文件里永远只写抽象命令不写任何平台专属代码。3.3 平台资源与 dart:ui 依赖的适配映射这部分是真正的“鸿蒙化”主战场。我做了张映射表接入的时候逐项核对依赖点常规 Flutter 上的行为鸿蒙环境下的注意项AssetBundle 读取直接按资源路径读取构建后进入 rawfile路径差异要验证本地文件读写path_provider 获取目录后读写鸿蒙沙箱目录获取方式可能不同先确认授权平台通道注册由原生插件自动注册需要确保 hap 工程生成时包含插件注册否则触发 MissingPluginException字体渲染系统字体自动 fallback中文字体、特殊标点要注意可能需要显式指定字体生命周期监听WidgetsBindingObserver 通用鸿蒙窗口销毁逻辑有差异要在真机上验证列表/滚动布局纯 Flutter 渲染嵌入 ArkUI 容器时要按外部容器高度让位这张表我建议你在自己的项目里再扩充一遍把用到的 flutter 插件逐个加进去看。大多数插件有 Android 实现但鸿蒙实现未必有选型时就要提前绕开不支持的。4. 剧情分支与互动脚本的实战写法4.1 一个可以直接塞进项目的 .yarn 小剧本下面这段是我实际用来验证分支能力的测试剧本包含变量、条件、选项和跳转可以直接存成 story.yarn 使用title: Start --- 【雾】你站在灯塔脚下手里攥着一封没有署名的信。 set $courage 0 灯塔管理员探出头来“要听一段旧闻吗” [[听|Listen]] [[拒绝|Leave]] title: Listen --- “十年前有一本纪录册”他压低声音“后来被锁进地下室了。” set $courage $courage 1 [[你带我去看看|Basement]] [[该走了|Leave]] title: Basement --- if $courage 0 你推开铁门灰尘在灯光里晃成一片金色。 桌角的录音机自己转了起来。 [[按下播放键|Ending]] else 铁门纹丝不动你只好退回走廊。 [[算了|Leave]] endif title: Leave --- 你把信装回口袋决定下次再来。 stop title: Ending --- 旧录音机里传来沙哑的声音“你找到的不是答案是问题本身。” stop 这里set管变量if管条件分支[[文本|节点]]管选项跳转stop表示对话结束。编剧改剧情时只需要动这个文件业务代码一个字符都不用改。4.2 用运行时加载剧本并渲染对话与选项在我的工程里接入层大致是这样组织的final runner JennyRunner( source: await rootBundle.loadString(assets/story.json), startNode: Start, ); JennyDialogueView( runner: runner, onCommand: (command) { // 这里处理 playSound / saveGame 等命令 appCommandHub.dispatch(command.name, command.args); }, onFinish: () { // 对话结束后的业务逻辑 viewModel.unlockEnding(endingId); }, )运行时驱动JennyDialogueView展示当前台词和选项点击选项后内部自动切换节点。你在视图层拿到的是一套稳定的 UI 状态不需要自己维护剧本指针。值得强调的是不同版本的三方库类名和构造参数可能有出入重点要理解“runner 负责推进view 负责渲染command 是剧本对宿主发的信号”这三个角色的分工。4.3 用 Provider 把剧情状态送进全局组件剧情分支往往不是孤立页面角色好感度要显示在右上角地图上某个地点要因为有前置条件才解锁这些都需要跨组件通信。我推荐用 Provider 把你的剧本运行时包装成全局可读状态比如做成StoryProvider里面暴露当前节点、最近一句台词、剩余好感度集合。其他组件只要context.watch对应的值就会在剧情推进时自动刷新。这比手动传参爽太多。比如说地图页的小图标它只需要监听storyProvider.hasUnlockedBasement这个值在对话选择后被 Yarn 变量表更新UI 就会立刻响应。组件之间不需要互相认识也不需要考虑事件通知顺序状态源唯一行为可预测。5. 鸿蒙化踩坑记录从 MissingPluginException 到字体缩进5.1 Unhandled Exception 与插件注册的完整排查链路鸿蒙环境里最典型的报错长这样[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: MissingPluginException(No implementation found for method getAll on channel ...)第一眼容易以为 plugin 没装实际上多数时候是 hap 包在构建时没有把插件注册信息打进去。我的排查顺序是固定的这一步一步来不要跳查看 pubspec 里是否真把插件依赖写进去了有没有被注释或版本冲突执行一次干净构建删除 build 目录后重新生成 hap排除增量编译残留用 DevEco Studio 打开 hap 工程检查ohos_module里是否生成了对应插件的注册代码如果注册缺失手动确认插件是否声明了鸿蒙平台的实现文件很多 Flutter 插件仓库只做了 Android/iOS压根没有鸿蒙目录最后在真机上重装不能用模拟器验证平台通道。这条链路我每次都会贴给团队因为大家在这上浪费的时间最多。要记住鸿蒙生态的插件支持度参差不齐报错背后往往不是代码问题而是“这个插件没做鸿蒙适配”。5.2 中文字体、标点和文本自适应问题剧情文本是以中文为主的话字体问题最好尽早测。我用鸿蒙真机跑的时候发现两种情况一是个别特殊标点或生僻字会触发 fallback导致一句话里的字形粗细不一致二是默认字体渲染下的行高和 Android 有差异选项按钮之间容易出现莫名空隙或挤压。解决办法不算复杂在JennyDialogueView外层显式指定fontFamily如果剧情里有大量中文标点就准备一个覆盖更全的中文字体同时把 TextStyle 的height调成 1.3 左右。文本自适应也要处理遇到超长台词要有自动换行策略选项文字超过容器宽度时要允许折行不要固定单行。5.3 嵌进 ArkUI 页面时的布局容器配合如果你的应用外壳是 ArkUI而 Flutter 页面是通过容器嵌入的布局主要看外部想怎么放。热搜里频繁出现的 RelativeContainer、Flex、Tabs 都是 ArkUI 布局手段在实际项目里我是这样用的整页聊天场景把 Flutter 视图直接放进一个Stack让 Flutter 自绘内部所有内容外部只给一个全屏容器下方有剧情选项、上方有地图的场景用Tabs做主框架剧情页作为其中一个 Tab底部需要固定按钮时用RelativeContainer把按钮锚定在底部Flutter 视图放到剩余区域如果剧情文本高度不固定建议给 Flutter 容器一个动态高度否则内容长了会被裁剪。核心原则是不要指望一个固定高度的 Flutter 容器能自适应肉眼需求。你先确认是“Flutter 满屏”还是“Flutter 嵌入局部”两种模式的布局策略完全不同。剧情类内容我强烈建议让 Flutter 掌握整个对话区域外部容器只留安全区边距把复杂的自适应交给 Flutter 内部处理。6. 这套方案后续还能往哪推6.1 剧情回归测试要写成一个自动化流程互动叙事产品有一个麻烦分支太多手工回归根本点不过来。我后来把测试剧本设计成“可自动通关”的结构写一个测试入口自动选择所有选项、遍历所有节点在 Dart 测试环境里直接跑通全图。这样每次升级 jenny 或鸿蒙 SDK 后先跑一遍自动化再把崩溃列表丢给我去修效率是手点设备的五倍不止。测试脚本里的断言也别只断言“不崩溃”要把关键变量值也断言进去。比如$courage必须在一定条件下大于 0某个节点只能被特定分支到达。不然剧情逻辑悄悄变了测试还是绿的那就失去意义了。6.2 存档、本地化与语音扩展剧情应用基本都会遇到存档系统。我的建议是每段关键对话结束时保存一份进度存档内容 当前节点名 运行时变量表 已经看过的节点集合这三个合起来就足够恢复剧情现场。本地化比较折腾Yarn 原始文件里直接写多语言会乱我见过更干净的做法是导出一套 key 表显示层再映射当前语言文本这样新增语言不需要动剧本逻辑。语音的话playVoice(文件名)这种命令已经是顺手的事了命令回调层扩展不要太方便。顺带一提像 Flutter 的 Impeller 渲染引擎在鸿蒙分支上不一定默认开启。剧情界面本身是文字和简单图形为主我实测下来不必强求 Impeller盯紧 Skia 路径下的文本渲染和滚动性能即可。如果后续要加大量动画转场再单独评估渲染引擎的收益。我个人最大的体会是把“剧情逻辑”和“平台适配”彻底分离整个项目才能健康往前走。jenny 帮我挡掉了状态机的复杂度鸿蒙化则只是在一堆边缘接口上做修补。每一次遇到新坑先问自己“这是 Yarn 的问题还是 jenny 的问题还是鸿蒙桥接的问题”问题定位一准解决速度就快。希望这套从选型到踩坑的路径能让你在鸿蒙上做叙事产品时少走几段弯路。
返回列表