ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

鸿蒙Flutter适配jenny:Yarn Spinner分支剧情实践指南

2026/10/6 13:24:59 拓冰建站 浏览量
鸿蒙Flutter适配jenny:Yarn Spinner分支剧情实践指南 最近在把一个互动叙事 Demo 从 Android 往鸿蒙设备上搬结果卡在了一个不太起眼的三方库里——jenny。Flutter 圈子里这个库不算热门却是 Yarn Spinner 爱好者绕不开的那道门。Yarn Spinner 是游戏领域经典的叙事脚本框架专门用纯文本驱动多分支剧情而 jenny 就是它在 Dart/Flutter 世界的移植实现。如果你正在做互动小说、RPG 对话系统、带分支剧情的应用又想在鸿蒙生态里跑起来这篇指南能让你少踩很多我踩过的坑。这篇文章围绕 jenny 在鸿蒙 Flutter 工程里的完整适配过程展开包含选型逻辑、代码怎么接、资源文件怎么灌、运行时会炸在哪以及最后跑通分支剧情时的一些观察。老实说jenny 本身几乎全是纯 Dart 代码理论上鸿蒙和 Android 都能跑但一旦牵扯资源加载、状态恢复和平台差异事情就没那么简单了。1. 为什么我把 jenny 锁死在这套鸿蒙 Flutter 架构里1.1 Yarn Spinner 的机会窗口先说清楚 Yarn Spinner 到底是什么。做过 AVG、视觉小说或者 RPG 的人都知道剧情系统最容易写成一坨 if-else 加 switch 的意大利面。Yarn Spinner 换了个思路把剧情写成一种近乎自然语言的脚本每一段对话是一个节点节点之间通过选项和跳转连接变量和条件插入在文本里叙事设计师不用碰代码就能把分支剧情铺出来。jenny 就是这个思路在 Dart 侧的落地实现。它能解析 .yarn 格式的文本文件构建语法树维护一个运行时状态机把我们写的节点、选项、变量、命令变成 Flutter 里可以消费的数据流。我在项目里选择 jenny而不是自己写对话框架原因其实挺粗暴剧情内容迭代太快策划希望自己能改文本、加分支而不是每次都要找开发改代码。jenny 把剧本和程序解耦得够彻底天然适合这种协作模式。在鸿蒙生态里互动叙事类应用并不算多但需求一直都在。游戏、教育、心理测试、数字人导览都需要一套能承载复杂剧情分支的脚本系统。jenny 的纯 Dart 特性让它在鸿蒙化适配这件事上有天然优势但也正因为太纯很多人会低估它的边界成本。1.2 鸿蒙化适配的真正难点很多开发者第一次接触鸿蒙 Flutter 工程时会有一个错觉Flutter 是跨平台的Dart 代码随便跑。这话对一半。Flutter 本身确实跨平台但鸿蒙既不是 Android 也不是 iOSFlutter 引擎在鸿蒙上的支持主要靠社区 SDK 链路推进和官方 Android/iOS 支持相比天然多出不少不确定性。适配 jenny 这类纯 Dart 库时编译层面通常不会有障碍问题集中在四个边界上资源加载、插件交互、路径系统、异步线程。jenny 本身不包含原生代码也没有 platform channel这部分很省心。但它的输入是 .yarn 文件这个文件放在哪里、怎么进 bundle、运行时怎么读到字符串恰恰是鸿蒙和其他平台差异最大的地方。更隐蔽的是Flutter 在鸿蒙上可能使用独立的资源管理策略asset 的查找路径和 Android 不完全一致。如果你沿用 Android 时期的 File 路径读取方式或者硬编码了沙箱目录到了鸿蒙设备上大概率会踩空。所以 jenny 鸿蒙化适配的关键不是改解析器而是把“数据从哪进来、状态存在哪、事件怎么回调”这三件事想清楚。2. jenny 的三层实现拆解哪里需要动刀哪里可以原样搬2.1 脚本解析层从 .yarn 到语法树想把 jenny 接到鸿蒙工程里先得知道它内部是怎么干活的。我习惯把 jenny 拆成三层解析层、运行时层、外部依赖层。解析层负责把 .yarn 文本变成结构化数据。Yarn 脚本的语法不算复杂节点用 title 开头用三个短横线 --- 分隔头部和正文用三个等号 结束节点。正文里的每一行都是剧情内容[[选项文字|跳转节点]] 是玩家可点击的分支选项、 、 这种双尖括号结构是命令和控制流。这就好比把一份详尽的菜谱文字转换成程序能逐条执行的步骤清单解析器干的就是这一步。jenny 在解析时会做词法分析和语法分析把节点里的文本、选项、命令拆开归类生成节点列表和节点之间的跳转关系。这个过程本身完全依赖 Dart 标准库不碰任何平台 API。所以无论你在 Android、iOS 还是鸿蒙上这一层都不用动理论上可以直接原样搬过去。但这里有一个容易忽略的点解析结果是一次性的成本挺高。如果每次进入页面都重新解析一份 .yarn 文本体验会非常差。我会在工程里把解析得到的 YarnProject 对象缓存成全局单例或者页面级长生命周期对象只在第一次加载时解析后面的对话推进全部走运行时层。2.2 运行时驱动层变量、分支、状态机解析层产出的是一棵静态的语法树真正驱动剧情往前走的是运行时层。jenny 的运行时对象里面维护着当前节点、当前行号、变量表、选项列表这本质上就是一个状态机。举个例子剧情里出现 [[打听消息|AskNews]]运行时读到这个选项后会把“打听消息”作为可选项暴露给 UI。玩家点击后运行时执行跳转进入 AskNews 节点从第一行开始继续推进。碰到 if $know_merchant true 这样的条件指令它会去查当前变量表里的值决定走哪条分支。这套状态机有一个非常值得利用的特性变量存储被抽象出来了。剧情里所有 写入的变量都存放在一个 VariableStorage 对象里这意味着我可以清空、重置、导出、导入整套剧情状态。存档功能在这套设计下会变得异常简单后面我会专门讲怎么接。在鸿蒙适配过程中运行时层也没有太多需要改的地方唯一要注意的是异步调度。如果剧情脚本里包含 wait、延时之类的命令运行时可能会依赖 Future、Timer 这类异步机制而这些机制的回调可能触发在 Dart 的微任务队列里。鸿蒙的 Flutter 引擎对微任务的调度和 Android 理论上不会有本质差别但在实测中我发现一些引擎版本对 isolate 调度有差异建议不要把耗时的剧情流程丢在 UI isolate 里裸跑。2.3 依赖与平台边界纯 Dart 的优势和隐患jenny 的依赖树很干净通常只有 collection、meta 这类纯 Dart 包不依赖 dart:ffi也没有原生插件。这意味着在鸿蒙的 arm64 架构上编译完全无障碍不需要单独写鸿蒙的 .so 或者 ets 桥接层。但“纯 Dart”也有它的隐患。第一个隐患是 dart:io 的使用。如果 jenny 内部用 File 读取 .yarn 文件那么在不同的平台沙箱规则下就会表现不一。第二个隐患是命令系统。Yarn Spinner 原本支持注入自定义命令例如 、 这些命令必然要回调到 Flutter UI 层。如果你的工程里用 platform channel 播放音频那这条链路在鸿蒙上需要验证插件是否已适配。jenny 本身不背这个锅但你接入的业务命令会炸。所以我做鸿蒙化适配时给自己定了一条规矩把所有平台相关的读取和回调都收敛到一个薄薄的适配层里jenny 只负责纯逻辑我负责给它喂字符串它给我吐剧情对象中间不出现任何 File、Platform 判断和原生调用。3. 鸿蒙化适配实操从 DevEco 工程到首个分支剧情跑通3.1 搭一个带 Flutter 能力的鸿蒙工程鸿蒙侧开发目前主要通过 DevEco Studio 完成要跑 Flutter你需要先准备一套适配鸿蒙的 Flutter SDK。社区常说的 flutter_flutter 分支或者 flutter_ohos 链路本质上就是把 Flutter 引擎编译到鸿蒙系统上。建议先确认自己手头的 DevEco Studio 版本和 Flutter SDK 版本能够匹配否则 hvigor 构建时会有各种奇怪的版本冲突。创建工程的流程大致是这样的先用 DevEco Studio 创建一个 HarmonyOS 应用工程确定包名和 module 结构一般是 entry 模块然后在工程里接入 Flutter 的模块能力。我用的方式是 Flutter module 嵌入鸿蒙工程这样可以在原生页面里拉起 Flutter 页面也可以让 Flutter 页面作为独立入口存在。这里有一个关键配置在鸿蒙工程的模块配置文件里要正确声明 Flutter 相关的依赖能力和动态库路径。很多迁移失败不是 Dart 代码的问题而是原生工程找不到 libflutter.so 或者 Flutter 引擎初始化失败。如果你第一次跑起来看到白屏、闪退先查这一步不要急着怀疑 jenny。3.2 引入 jenny 与资源声明创建好工程后在 pubspec.yaml 里加上 jenny 依赖。可以选择从 pub.dev 拉取也可以指向自己的 fork。我这边因为后续可能要打补丁直接用 git 依赖指向 fork这样修改解析逻辑时不用等官方发版。然后要做的第一件事就是声明资源目录。我会把所有的 .yarn 文件放在 assets/scripts 目录下并在 pubspec.yaml 里这样做flutter: assets: - assets/scripts/这里面的坑是Flutter 的 asset 构建在鸿蒙工程里同样会生效但你必须在工程同步完成后重新构建一次否则新加的资源不会被打进 bundle。很多人在 Android 上习惯了热重载自动带资源到了鸿蒙工程里发现资源读不到其实只是没有重新执行完整的构建流程。还有一个细节资源文件名尽量不要包含大写字母和特殊符号。鸿蒙上偶尔会踩到文件系统对大小写的处理差异统一用小写加下划线命名可以省掉一堆莫名其妙的烦恼。3.3 编写鸿蒙侧的资源读取与对话驱动代码资源声明完后正式写加载代码。我强烈建议统一用 Flutter 的 rootBundle 读取 .yarn 内容而不是 dart:io 的 File。rootBundle 是 Flutter 引擎提供的资源访问入口在鸿蒙适配版上也有对应实现是最不容易出问题的姿势。import package:flutter/services.dart show rootBundle; import package:jenny/jenny.dart; class StoryController { YarnProject? _project; Dialogue? _dialogue; Futurevoid loadStory(String assetPath) async { final content await rootBundle.loadString(assetPath); // 解析 .yarn 文本生成编译后的项目对象 _project YarnProject(); _project.parse(content); _dialogue Dialogue(project: _project); } void start() { _dialogue?.continueDialogue(); } }这段代码里的 API 名称可能因 jenny 版本不同而略有差异但整体调用链路就是这样加载文本、解析项目、创建 Dialogue、推进对话。注意 loadStory 只调用一次后续对话推进全部走 Dialogue 对象防止重复解析导致剧情状态丢头。3.4 第一个剧情分支跑通的验证清单接完第一段剧情后我通常会按下面的清单逐项验证每项不过关就回去查资源是否能从 rootBundle 读到完整字符串无 BOM 头、无乱码。YarnProject 解析是否成功节点标题和跳转关系是否正确。Dialogue 能否正确输出当前行文本。含选项的节点是否能正确暴露选项列表。变量赋值和条件判断是否能影响分支走向。在鸿蒙设备上息屏、切后台再回来对话状态是否还保持。我第一次接的时候前四项很快就过了第五项发现变量类型推断出了问题。原因是剧本里写的是 set $know_merchant false结果另一个地方读了 $know_merchant 的字符串状态两边类型对不上分支一直走不进去。这个问题在 Android 上也会有但鸿蒙的调试信息更少排查起来明显更费劲后来统一了变量命名规范才消停。4. 接入实战让一个多分支剧情在鸿蒙设备上完整跑起来4.1 示例剧本三个节点的分支对话理论知识讲完特地上一个可以复现的例子。我写了个极简酒馆场景包含两个入口选项和一个条件分支。title: Start tags: intro --- 你推开吱呀作响的木门酒馆里只有零星几个客人。 set $know_merchant false 老板抬眼看了看你。 [[上前打听|AskNews]] [[找个角落坐下|SitDown]] title: AskNews --- 老板放下手里的杯子“新面孔啊要找谁” if $know_merchant true [[问起那个行商|MerchantInfo]] else [[想打听行商的事|MerchantIntro]] endif title: SitDown --- 你在角落坐下窗外狂风呼啸。 [[离开|End]] title: End --- 故事暂告一段落。 注意这里有个小技巧我把 $know_merchant 的初始值放在了 Start 节点里而不是在代码里硬编码。这样做的好处是剧情自动拥有“重置能力”只要重新走 Start 节点所有变量都会重新初始化存档状态不会串场。4.2 UI 绑定与选项交互剧本有了接下来把它接到 Flutter UI 上。我会用一个 StatefulWidget 承载对话流监听 Dialogue 的状态变化。核心思路是拿到当前行显示文本如果有选项就把选项渲染成按钮列表点击某个选项后通知 Dialogue 选择。class StoryPage extends StatefulWidget { override StateStoryPage createState() _StoryPageState(); } class _StoryPageState extends StateStoryPage { StoryController controller StoryController(); String currentLine ; ListString currentOptions []; bool storyEnded false; override void initState() { super.initState(); _initStory(); } Futurevoid _initStory() async { await controller.loadStory(assets/scripts/tavern.yarn); controller.start(); _syncFromDialogue(); } void _syncFromDialogue() { setState(() { if (controller.dialogue.isComplete) { storyEnded true; currentLine 剧情结束; currentOptions []; } else { currentLine controller.dialogue.currentLine?.text ?? ; currentOptions controller.dialogue.currentOptions .map((o) o.text) .toList(); } }); } void _chooseOption(int index) { controller.dialogue.chooseOption(index); controller.dialogue.continueDialogue(); _syncFromDialogue(); } override Widget build(BuildContext context) { return Scaffold( body: Padding( padding: EdgeInsets.all(24), child: Column( children: [ Expanded( child: Center(child: Text(currentLine)), ), ...currentOptions.asMap().entries.map( (e) ElevatedButton( onPressed: () _chooseOption(e.key), child: Text(e.value), ), ), ], ), ), ); } }这段代码在 Android 和鸿蒙上没有任何区别因为 UI 层完全跑在 Flutter 引擎里。但我建议在整个页面生命周期里只维护一个 StoryController 实例不要在 build 方法里反复创建。原因很简单每次重建都会把 Dialogue 状态清掉玩家点了一下选项界面闪一下剧情就回到开头了体验极差。4.3 变量存储与存档系统扩展剧情能跑通之后我立刻想接存档功能。毕竟互动叙事应用经常需要中途退出下次进入接着玩。jenny 的 VariableStorage 抽象在这时候帮了大忙。接存档的思路是每次剧情推进后导出当前变量表的快照存到本地下次冷启动时再导回来从当前节点继续。import dart:convert; import package:shared_preferences/shared_preferences.dart; class SaveManager { static Futurevoid saveDialogueState(Dialogue dialogue) async { final storage dialogue.variableStorage; final snapshot storage.toJson(); final prefs await SharedPreferences.getInstance(); await prefs.setString(story_save, jsonEncode(snapshot)); } static FutureMapString, dynamic? loadDialogueState() async { final prefs await SharedPreferences.getInstance(); final raw prefs.getString(story_save); if (raw null) return null; return jsonDecode(raw) as MapString, dynamic; } }需要注意的是SharedPreferences 插件在鸿蒙上是否有适配版本取决于你用的 Flutter 鸿蒙 SDK 镜像带了多少第三方插件。如果官方实现的 shared_preferences 还没覆盖鸿蒙可以改用鸿蒙原生侧的 preferences 接口通过平台通道暴露给 Dart。这算是一个典型的“纯 Dart 库不需要适配但周边存储插件需要确认”的场景。5. 鸿蒙化适配的问题速查与避坑实录5.1 编译期问题编译期最常见的问题是依赖拉不下来。如果你的网络环境访问 pub.dev 不稳定可以在 pubspec 里配置镜像源或者直接设置 PUB_HOSTED_URL 环境变量。这个坑和鸿蒙本身无关但很多第一次搭鸿蒙 Flutter 工程的人会卡在这一步误以为是鸿蒙的依赖冲突。另一个问题是 hvigor 构建报错指向某个 .so 文件找不到。这种大概率是 Flutter 引擎组件没有正确集成检查一下工程里是否引用了正确的本田 Flutter SDK 路径。一定要确认 dev 环境变量里配置的 Flutter SDK 是鸿蒙适配版而不是官方标准版否则编译时有你哭的。5.2 运行期问题运行期最常见的就是 Flutter 统一异常日志长这样e/flutter (31173): [error:flutter/runtime/dart_vm_initializer.cc(41)] unhandled exception: ...我第一次看到这个日志时误以为是 Flutter 引擎崩溃了后来发现这其实是 Dart 侧异常的统一输出入口。dart_vm_initializer.cc 是引擎内部文件真正的异常内容在冒号后面。看到 unhandled exception 不要慌先往上翻几行看看到底是哪个 Dart 方法抛出来的。多数时候问题出在变量类型转换、空安全判断和资源读取失败上跟引擎本身无关。还有一种运行期问题进入带 platform view 的场景就黑屏或者无法响应手势。Flutter 在鸿蒙上对 platform view 的支持还不像 Android 那么成熟如果剧情里嵌入了 webview、地图这类原生视图建议严格测试实在不行就降级成纯 Flutter 绘制。5.3 资源与打包问题资源问题集中在两点。第一rootBundle.loadString 找不到 asset先检查 pubspec.yaml 的 assets 路径是否和实际目录完全一致再检查是否完整构建过。第二Debug 模式能读到资源Release 模式读不到这是打包配置的问题需要在打包流程里把 assets 目录同步到鸿蒙应用沙箱或者确认 Flutter 层资源是否已经被正确合入 HAP 包。我还遇到过一个很低级的问题脚本文件第一行带着 BOM 头导致解析器把节点标题识别成带特殊字符的字符串跳转怎么都对不上。后来所有 .yarn 文件都统一用 UTF-8 without BOM 保存问题彻底消失。这个建议对所有 Flutter 项目都适用。下表整理一下我遇到的典型问题和解法问题现象可能原因排查方向pub get 失败网络源问题配置 PUB_HOSTED_URL 或切换镜像asset 加载不到路径配置错误或未重新构建检查 pubspec.yaml执行完整构建unhandled exceptionDart 层异常未捕获看完整堆栈定位具体方法剧情状态丢失重复创建 Dialogue使用单例或页面级长生命周期对象平台 channel 报 no implementation插件未适配鸿蒙查询插件鸿蒙适配版本或改用原生通道桥接中文乱码/解析失败编码带 BOM 或非 UTF-8统一 UTF-8 without BOM5.4 异步调度与微任务队列的一个隐患比较隐蔽的一个问题是剧情推进和 UI 刷新的顺序。Flutter 的 Future 回调默认走微任务队列而微任务在当前事件循环任务结束后才会被处理。如果你的代码里用 await 加载 .yarn 文本后立刻调用 continueDialogue然后又立刻读取当前行有可能会拿到一个空的 Dialogue 状态。这不是鸿蒙特有的问题但在鸿蒙引擎上我发现同样的代码更容易触发时序错乱可能是因为资源读取的完成时机和引擎事件循环配合得没有 Android 上那么顺滑。解决办法很简单所有剧情推进的链路严格串行保证 await loadString 之后再 continueDialogue再之后才允许 UI 读取当前行。流程图里那套“先加载、再解析、再推进、再渲染”的顺序不要打乱。6. 适配完成后的工程实践体会6.1 我踩过的最隐蔽的几个坑整个适配下来最让我头疼的不是 jenny 的解析器而是 Flutter 鸿蒙链路里那些“看起来能用实际上差一点”的细节。第一个坑就是 Platform.isAndroid 这种判断。在鸿蒙设备上有些 Flutter 鸿蒙适配版返回的 platform 值可能和 Android 相同也可能返回一个自定义值写死在业务逻辑里是非常危险的。我的做法是单独封装一个环境检测方法把所有平台判断收敛到一处不要散布在项目里。第二个坑是 build 阶段反复创建资源。我第一次写的时候把加载 .yarn 文件的逻辑放在了 build 方法外部调用本来没问题但后来改成动态加载本地化文件时不小心让它在 setState 时也被调用结果每点一个选项就重新解析一遍整个剧本页面肉眼可见地卡顿。这个问题在 Android 上也有但鸿蒙低端设备上帧率掉得更明显所以会更敏感。第三个坑是 wait 命令。Yarn 脚本里如果有 wait 1 之类的延时指令底层会转成 Future.delayed。这个延时期间如果用户切后台鸿蒙系统可能会冻结应用等回到前台后剧情直接跳了好几行。我最终的方案是禁用 wait 指令改成在 Flutter 层用动画控制节奏虽然麻烦一点但状态稳定。6.2 这套方案的后续扩展空间jenny 鸿蒙化跑通之后能做的事情其实还挺多的。首先是本地化 JSON 接入。Yarn Spinner 生态里有一套自定义命令配合 JSON 做多语言的方案jenny 也支持类似思路只要把每段文本映射到本地化键值鸿蒙上的中文、英文切换就能做到运行时热切换。其次是可视化编辑器的对接。Yarn Spinner 官方有一款图形化编辑器导出的 .yarn 文件可以直接喂给 jenny我现在已经在流程里加入了“策划在编辑器里编排剧情开发直接把导出文件扔进 assets”的协作模式迭代速度提升非常明显。再往后是做剧情状态的可视化调试。在鸿蒙 DevEco 的日志面板里我用统一前缀打印 Dialogue 的节点跳转记录、变量变更记录这样策划在测试机上跑剧情时可以直接把日志导出发给开发。这个习惯帮我省了大量“为什么这里分支不对”的沟通成本。最后说点个人的体会在鸿蒙上做 Flutter 叙事应用最大的成本根本不是语法适配而是把所有不确定边界前置。资源读取、变量持久化、异步调度这三件事先搭好骨架后面堆剧情内容就是纯粹的体力活。jenny 本身值得信任关键在于给它一个稳定的运行环境。