☰
Flutter对话引擎jenny鸿蒙化适配:从Yarn脚本到ArkUI集成
2026/10/7 3:05:45 网站建设 项目流程

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 包在构建时没有把插件注册信息打进去。我的排查顺序是固定的,这一步一步来,不要跳:

  1. 查看 pubspec 里是否真把插件依赖写进去了,有没有被注释或版本冲突;
  2. 执行一次干净构建,删除 build 目录后重新生成 hap,排除增量编译残留;
  3. 用 DevEco Studio 打开 hap 工程,检查ohos_module里是否生成了对应插件的注册代码;
  4. 如果注册缺失,手动确认插件是否声明了鸿蒙平台的实现文件,很多 Flutter 插件仓库只做了 Android/iOS,压根没有鸿蒙目录;
  5. 最后在真机上重装,不能用模拟器验证平台通道。

这条链路我每次都会贴给团队,因为大家在这上浪费的时间最多。要记住,鸿蒙生态的插件支持度参差不齐,报错背后往往不是代码问题,而是“这个插件没做鸿蒙适配”。

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 的问题,还是鸿蒙桥接的问题”,问题定位一准,解决速度就快。希望这套从选型到踩坑的路径,能让你在鸿蒙上做叙事产品时少走几段弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询