鸿蒙上跑通Flutter叙事引擎jenny:Yarn Spinner脚本跨端适配实践
发布时间:2026/10/6 16:45:58 锦皓数字建站

如果你正在鸿蒙App里做一套带剧情分支的对话系统Flutter 生态里的三方库 jenny 很值得关注——它把 Yarn Spinner 的解析和运行能力搬到了 Dart 世界让互动叙事脚本可以跨端复用。我最近把基于 jenny 的互动叙事项目完整迁移到鸿蒙中间踩了脚本加载、平台通道、构建配置、异步调度一堆坑最终跑通了整套流程。这篇文章把整个适配过程拆开来讲从 jenny 的架构、鸿蒙环境准备到 Yarn 脚本实战和常见排查全部记录下来给准备在鸿蒙上做叙事项目的同学当一份可复用的操作手册。1. 为什么要在鸿蒙上使用 jenny 做叙事脚本1.1 认识 jennyFlutter 世界里的 Yarn SpinnerYarn 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 --stylecompact把 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里添加 jennydependencies: 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 而不是 Filejenny 通常支持传入字符串加载脚本所以最稳妥的方式是用 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 文件会自动带上 BOMjenny 的解析器遇到不可见字符会直接抛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 (ListJennyOption 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中可以做快照导出MapString, 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 Flutters 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或者Futurevoid.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建议按这个顺序推进先跑通示例再桥接音频和存档最后优化长对话性能。叙事系统的价值在于内容本身选好工具、踩平平台差异之后剩下的精力就能全部放在写剧本上了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。