如果你正在鸿蒙App里做一套带剧情分支的对话系统,Flutter 生态里的三方库 jenny 很值得关注——它把 Yarn Spinner 的解析和运行能力搬到了 Dart 世界,让互动叙事脚本可以跨端复用。我最近把基于 jenny 的互动叙事项目完整迁移到鸿蒙,中间踩了脚本加载、平台通道、构建配置、异步调度一堆坑,最终跑通了整套流程。这篇文章把整个适配过程拆开来讲,从 jenny 的架构、鸿蒙环境准备,到 Yarn 脚本实战和常见排查,全部记录下来,给准备在鸿蒙上做叙事项目的同学当一份可复用的操作手册。
1. 为什么要在鸿蒙上使用 jenny 做叙事脚本
1.1 认识 jenny:Flutter 世界里的 Yarn Spinner
Yarn Spinner 最早是 Unity 生态里非常出名的对话/叙事脚本框架,它的核心思想是用一套叫 Yarn 的纯文本文档描述对话内容,支持节点跳转、选项分支、变量判断、自定义命令。相比直接拿 JSON 或者手写状态机,Yarn 脚本的可读性和策划友好度高出好几个量级。jenny 做的就是把这个能力移植到 Flutter/Dart:解析 Yarn 脚本、维护运行时状态、触发行输出、抛出选项事件、执行命令回调,整条链路都在 Dart 层完成。
选择 jenny 而不是自己从零写剧情系统,最大的原因是叙事逻辑和 UI 彻底分离。剧情作者只要维护.yarn文本,程序员只需要处理界面和命令桥接。对于一个需要同时支持多个平台的项目来说,剧情资产能跟着 Flutter 到处跑,这笔账非常划算。鸿蒙化适配的重点也在这里:jenny 的核心解析器是纯 Dart,理论上不依赖任何平台能力;但一旦脚本里出现音频播放、震动反馈、语音合成这类需求,就必须把原生通道打通,否则整个叙事系统只能停留在静态文本阶段。
1.2 鸿蒙化适配到底改什么
鸿蒙和 Android/iOS 的 Flutter 集成方式不同,插件侧需要多一份ohos原生工程,平台通道需要对接 ArkTS API。jenny 这种库如果只依赖纯 Dart 包,那适配工作相对简单,只要保证pubspec.yaml里所有依赖在鸿蒙环境都有对应实现即可;但如果 jenny 内部或你的业务代码依赖了path_provider、shared_preferences、audioplayers这类插件,就需要逐个确认它们是否支持 OpenHarmony。
我在项目里遇到的情况是:jenny 本身可以用,但业务侧通过自定义命令调用了音频播放和本地存档,这两块原生能力在鸿蒙上需要自己桥接。所以说,鸿蒙化适配不是改 jenny,而是改 jenny 和系统之间的那层胶水。先把胶水层搞清楚,后面所有适配工作都会变得非常明确。
2. jenny 的架构拆解与适配准备
2.1 先搞清依赖树,哪些是纯 Dart,哪些触碰到平台
动手之前,建议先用flutter pub deps --style=compact把 jenny 的依赖树打出来。我当时的输出里,核心依赖只有meta和collection这类纯 Dart 库,说明 jenny 的解析和运行层没有硬性依赖原生能力。但业务侧往往没这么干净,音频、文件读写、状态持久化都需要插件。
判断一个依赖是否会被鸿蒙卡住,有个简单方法:去 OpenHarmony 的三方库索引里搜包名,看有没有对应的鸿蒙原生实现。比如shared_preferences在 OpenHarmony 社区有移植版本,直接替换即可;某些插件只有 Android/iOS 代码,就必须走两个方案:要么找功能相似的鸿蒙原生实现,要么自己写一个最简插件,把能力通过 MethodChannel 暴露给 Dart 侧。
2.2 环境准备:Flutter 的 OpenHarmony SDK 分支
鸿蒙上跑 Flutter,首先得有一套能产出ohos工程的 Flutter SDK。目前主流做法是使用支持 OpenHarmony 的 Flutter 社区分支,配合 DevEco Studio 做原生侧联调。环境变量配好后,执行:
flutter create --platforms ohos my_story_app命令会生成ohos目录,里面是完整的鸿蒙工程。接着在pubspec.yaml里添加 jenny:
dependencies: jenny: ^0.6.0然后flutter pub get。要注意的是,鸿蒙工程的 API 版本必须和 SDK 匹配,我遇到过module.json5里targetSdkVersion比 DevEco 内置 SDK 高导致编译失败的情况,统一改成当前 IDE 支持的版本就行。
2.3 拉取 jenny 自带示例并跑通基础流程
jenny 仓库里一般有example工程,里面包含了最基本的对话示例。先别急着接入自己项目,把示例工程在鸿蒙模拟器或真机上跑通,能省掉后面 70% 的排查时间。
我在这一步踩到最大一个坑是签名配置。鸿蒙真机调试需要给工程配置签名证书,如果直接用 DevEco 的自动签名,还得把build-profile.json5里的签名信息对齐。跑通示例后,你能看到完整的对话逐行输出、选项弹出以及分支跳转,这就说明 jenny 的运行核心已经没问题,可以开始往项目里接了。
3. 核心适配实操:让 Yarn Spinner 脚本在鸿蒙上跑起来
3.1 脚本加载路径:用 rootBundle 而不是 File
jenny 通常支持传入字符串加载脚本,所以最稳妥的方式是用 Flutter 的rootBundle读取 assets 资源。
String yarnContent = await rootBundle.loadString('assets/story.yarn'); await dialogue.loadScript(yarnContent);一开始我图方便,想把story.yarn放到鸿蒙原生资源目录,再用File读取沙箱路径。结果发现鸿蒙的沙箱路径规则和 Android 差异很大,调试起来非常曲折。后来统一走 Flutter 的 asset 机制,所有平台用一套代码,省心很多。
另外,要注意 Yarn 文件的 BOM 头。有些编辑器保存 UTF-8 文件会自动带上 BOM,jenny 的解析器遇到不可见字符会直接抛unexpected token。稳妥做法是在加载后清洗:
if (yarnContent.startsWith('\uFEFF')) { yarnContent = yarnContent.substring(1); }这个坑在 Windows 上开发时特别容易出现,鸿蒙设备本身不会帮你处理,必须在 Dart 侧过滤。
3.2 通过 MethodChannel 扩展自定义命令
Yarn 脚本支持<<play_voice "你好">>这类自定义命令,jenny 会把这个命令抛给注册的回调。要接鸿蒙的语音合成能力,就在回调里走一次平台通道:
const channel = MethodChannel('com.example.story_native'); jenny.commands.register('play_voice', (args) async { final text = args['text'] as String; try { await channel.invokeMethod('playVoice', {'text': text}); } on MissingPluginException { // 鸿蒙侧未注册时降级处理 debugPrint('play_voice: native channel not available'); } });鸿蒙原生侧的实现需要建一个 Plugin 入口,在生成ohos工程里找到对应的Plugin类,注册MethodChannel的 handler。语音合成可以调用系统 TTS 模块,这里只给出最简框架:
class StoryNativePlugin implements IPlugin { onInitialize(ctx: IPluginContext): void { const channel = ctx.getMethodChannel('com.example.story_native'); channel.setMethodCallHandler((call) => { if (call.method === 'playVoice') { let text = call.arguments['text']; // 调用 TTS 系统能力 } }); } }需要特别提醒:MethodChannel的调用默认跑在原生主线程,耗时的语音合成、音频播放操作不要直接放在 handler 里,应该丢到 TaskPool 或者异步方法中,否则容易出现 ANR 或者掉帧。
3.3 中文与编码问题
鸿蒙上跑 Yarn 脚本,中文是绕不开的话题。jenny 的解析器对脚本内容的字符集没有做任何预判,只要 Yarn 文件本身是 UTF-8 编码,中文就完全没问题。但我建议变量名和节点名尽量用英文,原因是有些版本的 Yarn 解析器对非 ASCII 标识符支持不稳定。中文文本只出现在对话行里,这样既能保证分支逻辑稳定,又不影响玩家看到的中文内容。
界面渲染层也要注意字体。鸿蒙默认字体对中文支持不错,但如果你在 Flutter 侧设置了自定义字体包,记得确认字体的 license 和字符集完整。我在一个测试机上发现某些生僻字显示成了方块,后来换用系统默认字体才正常。
3.4 处理 Flutter 引擎 AAR 与渲染差异
在鸿蒙工程里集成 Flutter 时,不少项目沿用 Android 时代的思路去操作 AAR 和 Gradle 依赖。鸿蒙侧虽然也用 Flutter 引擎产物,但集成方式更偏 CMake 和 hvigor,不要直接把 Android 的flutter.aar复制到鸿蒙工程里用,这会导致初始化崩溃。
渲染层面主要关注 Flutter 使用 Impeller 还是 Skia。鸿蒙适配初期,Impeller 的兼容性可能存在问题,如果发现某些文字模糊或动画异常,可以在 Flutter 初始化时切换到 Skia 渲染。jenny 的文本行输出并没有依赖特殊渲染 API,所以一般不受影响,但这种底层差异会表现为界面整体卡顿,排查起来容易让人误判成业务代码问题。
4. 剧情分支与互动脚本实战
4.1 设计一个双分支 Yarn 脚本
先看一段最典型的 Yarn 脚本,包含了变量、条件分支和选项跳转:
title: Start --- 你是冒险者,站在岔路口。 <<if $has_key>> 你带了钥匙,可以打开东边的门。 <<set $route = "east">> <<else>> 你两手空空,只能先去森林。 <<set $route = "forest">> <</if>> -> 去东门 <<if $has_key>>[[开门|EastDoor]]<<endif>> -> 去森林 [[进入森林|Forest]] === title: EastDoor --- 门开了,你进入了密室。 <<jump End>> === title: Forest --- 你在森林里遇到了兔子。 <<jump End>> === title: End --- 故事暂时告一段落。 ===语法拆解开来说很简单:title定义节点名,---和===之间是节点内容,->是选项,<<if>>是条件判断,[[目标节点]]是跳转。jenny 的解析器会把这些元素映射成内部运行对象,并在逐行推进时触发对应事件。
设计分支脚本时,我有个建议:保持单一出口原则。每个分支节点最终都<<jump>>到统一的收敛节点,比如上面的End,这样存档和流程控制会简单很多,也方便后续扩展多章节剧情。
4.2 Flutter 侧驱动 jenny:从初始化到选项展示
jenny 在 Flutter 侧的使用套路非常固定。先创建一个JennyDialogue实例,加载脚本,然后监听事件:
final dialogue = JennyDialogue(); await dialogue.loadScript(yarnContent); dialogue.onLine = (String line) { setState(() => currentLine = line); }; dialogue.onOptions = (List<JennyOption> options) { setState(() => currentOptions = options); }; dialogue.onCommand = (JennyCommand command) { handleCommand(command); }; await dialogue.start();当玩家点击某个选项时:
void onOptionSelected(int index) { dialogue.choose(index); }这种方式的好处是,jenny 内部已经帮你做了节点调度,你只需要让 UI 呈现onLine和onOptions的状态。剧情推进过程中的任何异步操作,比如打字机效果、音效播放,都可以挂在这几个事件里。
我在鸿蒙真机上测试发现,如果选项弹出时 UI 里还有打字机动画没有结束,需要先移除动画再调用choose,否则会出现动画和剧情同时跳变的违和感。解决办法是在onOptions事件里强制归零动画控制器。
4.3 状态存档:把变量快照存到鸿蒙本地
分支剧情项目最怕玩家重开游戏后一切归零。jenny 的变量都存在variableStore中,可以做快照导出:
Map<String, dynamic> snapshot = dialogue.variableStore.export(); String json = jsonEncode(snapshot);然后通过shared_preferences的鸿蒙实现写入本地。恢复时更简单:
dialogue.variableStore.import(jsonDecode(savedJson)); await dialogue.jumpTo('Start');需要注意export/import只能保存变量,不能保存当前节点。想做到真正的断点续玩,可以把$route这类节点标识也存成变量,恢复后根据这个变量做一次条件跳转。我在实际项目里就是这么干的,玩家退出再进入,能回到离开时的那段剧情。
5. 常见问题与排查技巧实录
5.1 构建报错:Gradle 插件命令式调用
有段时间我用 dev 分支的 Flutter SDK 处理鸿蒙工程,构建时总弹出一个眼熟的错误:You are applying Flutter's main Gradle plugin imperatively using the apply script。原因是工程里同时残留了 Android 构建配置,而新版 Flutter Gradle 插件要求用声明式插件方式接入。
修复思路是打开settings.gradle,把原来的:
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"改成:
plugins { id 'dev.flutter.flutter-gradle-plugin' }然后同步build.gradle,确保应用插件不要再用apply。这个报错虽然不直接影响鸿蒙侧的 hvigor 构建,但会在执行flutter build之类的混合操作时打断流程。我的建议是:鸿蒙适配期间把 Android 构建配置统一收口,避免两套逻辑互相干扰。
5.2 异步回调顺序:Future 的 then 不一定按你想的顺序跑
Dart 的事件循环里,Future.then回调会被安排到微任务队列,但它前面如果还积压了其他微任务,则执行顺序会被影响。jenny 的脚本命令很多是异步的,比如等待选择、播放声音、加载资源,这些回调混在一起后,偶尔会出现剧情行已经刷新但 UI 状态还没更新的情况。
排查思路是:不要假设await之后的界面一定是“当前帧”的,必要时用scheduleMicrotask或者Future<void>.delayed(Duration.zero)把 UI 更新拆到下一轮微任务。我在做选项高亮时遇到过这个问题,加上微任务隔离后,状态刷新就稳定了。
5.3 平台通道未实现:MissingPluginException
在鸿蒙设备上跑起来后,最常见的就是调用某个插件时抛出MissingPluginException。这基本可以确定当前插件没有鸿蒙原生实现,或者实现没有被正确注册。
排查可以从三个方向入手:
| 检查点 | 操作 | 判断标准 |
|---|---|---|
| 插件依赖 | flutter pub deps看包是否在依赖树中 | 缺失则重新添加 |
| 鸿蒙实现 | 查看oh_modules目录下插件是否有ohos代码 | 没有则需换库或自研 |
| 通道注册 | 查看 Debug 日志中是否出现Plugin not registered | 出现则检查插件初始化代码 |
我当时遇到audioplayers在鸿蒙上没有实现,最终方案是写了个极简音频插件,只封装音频播放和停止两个方法,用MethodChannel暴露给 jenny 命令调用。
5.4 长对话卡顿与内存问题
鸿蒙真机上,如果一场剧情脚本有上千行且附带大量选项,连续推进时容易出现明显的掉帧。主要原因是每次onLine都触发 UI 重建,加上 Yarn 脚本的解析结果如果不断重建,GC 压力就上来了。
我的优化方案是复用同一个 Dialogue 实例,不要每次开启剧情都 new 一个新对象;同时在 UI 层做“分页”处理,只把当前行和当前选项渲染出来,历史聊天记录用懒加载列表缓存。这样即便剧情线很长,界面也能保持流畅。实测在低端鸿蒙设备上,一屏只渲染 20 条左右的文本节点,比一次性塞几百条清晰得多。
6. 适配过程中的独家避坑心得
最后分享几个我在整个适配过程中体会最深的小技巧。第一,永远先跑通纯 Dart 单测再加鸿蒙原生层。jenny 的核心行为完全可以在桌面端调试验证,等逻辑无误了再上鸿蒙真机,能避免把 UI 和平台通道的问题混在一起查。
第二,日志过滤要会用。鸿蒙端和 Flutter 端的日志输出格式差异很大,我习惯在 Dart 侧加一个全局 tag,输出剧情跳转、脚本错误时统一带上[JENNY]前缀,再用flutter logs过滤,排查效率肉眼可见地提升。
第三,给项目留一份最小 Yarn 基线脚本,包含一段对话、一个分支、一个自定义命令。每次环境变化或依赖升级后,先跑这份基线,如果都通过,再跑全量剧情。这套方法帮我快速定位了很多次“我什么都没改,怎么突然跑不起来”的问题。
如果你也在鸿蒙上折腾 jenny 和 Yarn Spinner,建议按这个顺序推进:先跑通示例,再桥接音频和存档,最后优化长对话性能。叙事系统的价值在于内容本身,选好工具、踩平平台差异之后,剩下的精力就能全部放在写剧本上了。