做跨平台客户端这几年,我一直觉得 Flutter 是个“用力过猛”的框架——单代码库覆盖 Android、iOS、Windows、macOS、Linux 还不够,现在连鸿蒙系统也要进来分一杯羹。正好我最近把一个小说人物生成APP从 Android 侧平滑迁移到鸿蒙,标题里说的这个流程我完整踩了一遍,把选型思路、环境搭建、核心实现、还有一堆官方文档里不写的坑,一次性聊清楚。
这个项目本身不复杂:用户输入一句话或者几个关键词,APP 在本地拼装出一张完整的小说人物卡,包括姓名、外貌、性格、口头禅、关系网、剧情灵感等等。难点不在算法,而在“UI 体验要足够流畅”和“鸿蒙适配要足够干净”这两件事上。如果你正打算用 Flutter 做鸿蒙落地,或者想给自己的创作工具类 APP 加个新平台,这篇值得花十分钟看完。
1. 跨平台技术路线的真实考量:为什么这次我押注 Flutter + 鸿蒙
1.1 先泼盆冷水:跨平台方案不是越多越好
每次新项目启动,团队里总有人问“要不要顺便支持鸿蒙”。答案从来不是“顺便”,而是一次完整的工程决策。
市面上的跨平台方案我基本都试过。uni-app 胜在上手快,Vue 语法写起来舒服,但遇到高性能 Canvas 绘制和复杂动画时就有点力不从心;Taro 在微信小程序生态里是王者,出小程序版本可以无脑选它;React Native 的社区生态大,但鸿蒙适配层做得比较重的版本需要自己补很多胶水代码。这次的小说人物生成 APP,核心体验在于人物卡片的动态排版、性格标签的拖拽排序、关系图谱的缩放平移,这些交互对渲染帧率和触摸响应都敏感。
最后定 Flutter,核心原因是它的渲染管线是自绘的。Skia 引擎直接把 UI 画到画布上,不依赖原生控件树,这就意味着鸿蒙系统只需要提供一个“壳”,剩下的绘制、布局、事件分发全部由 Flutter 自己控制。跨平台适配层薄,出问题的概率就小。
1.2 Flutter 的渲染管线升级:Impeller 对鸿蒙意味着什么
Flutter 3.x 版本开始逐步把 iOS 端的渲染引擎从 Skia 切换到 Impeller。Impeller 的核心理念是预编译 shader,避免 Skia 在运行期做 shader 编译导致的首帧卡顿。这个机制在移动端的效果非常明显,尤其是列表滑动时不会再出现“滑动几下突然卡一下”的掉帧现象。
在鸿蒙适配的过程中,Impeller 并不是开箱即用的。鸿蒙设备上如果直接跑默认配置,部分 GPU 驱动对 Vulkan 的支持不完整,Impeller 会回退到软件渲染路径,此时 UI 依然能跑,但帧率会打折。我建议在鸿蒙侧先以 Skia 为稳定基线,把 Impeller 作为后续体验优化项,功能验证阶段不要同时引入两个变量,否则出了性能问题很难定位是渲染引擎的锅还是业务代码的锅。
顺带说一下,Flutter 的“自绘”特性也为鸿蒙带来一个额外红利:系统字体渲染差异导致的布局错乱大大减少。Android 和鸿蒙在字体度量上有细微差别,用原生控件经常出现文本溢出,而 Flutter 内部的文本布局引擎统一处理了这些差异。
1.3 鸿蒙生态现状:API 兼容与 SDK 选择
鸿蒙的开发资料这两年终于没那么稀缺了,但“够用”和“好用”之间还有距离。做 Flutter 鸿蒙适配,要区分两个概念:OpenHarmony SDK 和 HarmonyOS SDK。
OpenHarmony 是开源底座,Flutter 社区 SIG 主要基于它做适配;HarmonyOS 是商业发行版,会在开源底座之上叠加华为自家的 API。对于 Flutter 应用来说,目标 API 尽量依赖 OpenHarmony 公共能力,少碰厂商私有扩展。我这次的小说人物生成 APP 用到的能力,比如文件存储、剪贴板、系统分享、通知栏,OpenHarmony 平台上都有对应接口,不需要走 HarmonyOS 专属通道。
版本选择上,我踩过一个坑:开发机装的是 DevEco Studio 5.0,但 Flutter 工具链默认链接的鸿蒙 SDK 版本比较旧,导致编译出的 hsp 包在真机上安装后弹“SDK版本过低”的提示。后来我把 Flutter 的鸿蒙 SDK 路径显式指向 DevEco 自带的 sdk 目录,问题才解决。这个细节放到后面环境搭建部分细说。
2. 项目整体设计与工程结构:小说人物生成不是“随机取名”那么简单
2.1 需求拆解:从一句话到一张可用的“人物卡”
很多外行以为小说人物生成 APP 就是一个随机名字生成器,实际上真正写作者需要的是“有逻辑的人物设定”。我第一版就是这么做的,结果用户反馈“名字是好听的,但性格和职业完全不搭”。后来我重新梳理了需求:
- 用户输入可以是职业设定、时代背景、一句话剧情,甚至只是“阴郁”、“民国”、“医生”三个标签
- 系统需要基于这些输入生成完整的人物档案:姓名、别名、外貌特征、性格标签(不少于 6 个)、口头禅、行为习惯、秘密与动机、人物关系提示、适合加入的剧情冲突
- 每次生成的卡片可以重新随机微调局部内容,而不是整张全部重生
- 生成结果支持导出成图片或文本,方便用户直接粘贴到写作文档里
这个需求列表决定了技术选型:本地规则引擎为主,不重度依赖云端大模型。原因很现实,写作场景经常在飞机上、地铁里,没网的时候产品不能变成废品。云端生成作为增强功能可以后续接,但核心链路必须本地可跑。
2.2 分层架构:UI、状态、数据、能力四层隔离
我采用了一个传统但稳定的四层结构:
- 表现层(UI):Flutter Widget 树,负责渲染人物卡、编辑页、关系图
- 状态层(State):负责业务状态管理,Bloc 方案
- 数据层(Data):本地词库、模板库、生成配置
- 能力层(Service):系统能力接口,包括文件导出、剪贴板、分享
分层最大的好处是“鸿蒙适配”被压缩到了能力层。UI 和状态层完全不用感知当前跑在 Android 还是鸿蒙上。这次迁移,我只改了能力层的两个文件,UI 层零改动。这种隔离效果,在后续新增 Window 桌面支持时大概率还能复用。
2.3 状态管理选型:Bloc 的确定性胜过魔法
Flutter 的状态管理方案多到让人选择困难,Provider、Riverpod、Bloc、GetX 各有拥趸。我在这个项目里选了 Bloc,理由是它的单向数据流模型最适合“生成类”业务。
人物生成的过程天然适合事件驱动:用户点击“生成” → 派发 GenerateRequest 事件 → Bloc 调数据层生成结果 → 发出 GenerateSuccess 状态 → UI 层渲染新卡片。每一步都可预测、可测试、可回溯,调试时 log 打出来一目了然。Riverpod 写起来更轻,但团队协作时约束力弱,容易写出“全局变量式”的临时状态。鸿蒙适配阶段事情已经够多了,不能再让状态管理增加认知负担。
2.4 模块化组织:part 关键字不是摆设
工程里有个被很多人忽略的小工具:Dart 的 part / part of 机制。Flutter 大型项目里,单文件动辄上千行,阅读和冲突成本都很高。我习惯用 part 把一个领域对象拆成多个文件:数据模型、序列化、扩展方法、样例数据。
比如 PersonCharacter 类,原版 400 行,拆成 character_model.dart、character_serializer.dart、character_faker.dart 三个 part 文件,每个文件聚焦单一职责。part 和普通 import 的区别在于,part 共享库的私有成员访问权限,这对封装“只允许通过 Builder 修改人物属性”这类约束非常有用。鸿蒙适配中我也用这个方式组织 PlatformChannel 相关代码,把方法通道的参数解析、事件监听、错误码映射拆开,定位问题的时候不用在一个大文件里反复翻。
3. 鸿蒙环境搭建与工程初始化:从零到真机跑起来
3.1 前置准备:DevEco Studio、Flutter SDK 与版本对齐
鸿蒙开发的环境搭建比 Android 略繁琐,核心原因是生态工具链还没完全一体化。
我本机是 MacBook(Apple Silicon),安装流程如下:
- 下载 DevEco Studio 5.0,安装时勾选 HarmonyOS SDK 和 OpenHarmony SDK 组件,路径默认在 /Applications/DevEco-Studio.app/Contents/sdk
- 从 Flutter 官方渠道安装稳定版 Flutter SDK,同时通过 git 拉取鸿蒙社区维护的 Flutter 分支,两个 SDK 目录分开存放
- 在 shell 配置文件里设置环境变量:FLUTTER_OHOS_HOME 指向鸿蒙分支目录,DEVECO_SDK_HOME 指向 DevEco 的 sdk 目录
- 运行 flutter doctor 验证,重点看 “OpenHarmony toolchain” 是否绿色
版本对齐是最大的坑。社区分支通常对应 Flutter 3.x 某个具体小版本,如果你本机 Flutter 是 3.24,但社区分支是基于 3.22 做的适配,大概率会编不过。解决办法是先确定目标鸿蒙适配分支的版本号,然后用 fvm 或直接改 PATH 把 Flutter 版本固定到匹配版本。我试过强行用高版本 Flutter 编鸿蒙工程,错误信息五花八门,最后老老实实对齐版本,五分钟编过。
3.2 创建支持 ohos 平台的工程
现在的 Flutter 鸿蒙适配工具链已经支持一键创建工程:
flutter create --platforms=android,ios,ohos --org com.yourcompany novel_character_app命令执行完后,工程目录下会多出 ohos 文件夹(注意不是 native 目录,是新版适配专用的 ohos 目录)。里面是标准的鸿蒙工程结构,entry 是应用入口模块,oh-package.json5 负责依赖声明。
首次编译建议先跑默认模板,不要急着植入业务代码。我在默认模板上运行flutter build ohos --debug,第一次拉依赖花了不少时间,因为需要从鸿蒙的 ohpm 仓库下载依赖包。网络通畅的前提下,整个过程十分钟内能搞定。如果卡在某个 ohpm 包下载失败,可以检查 oh-package.json5 里的依赖版本是否和本地 DevEco SDK 匹配。
3.3 鸿蒙侧的原生能力桥接:Platform Channel 与 EventChannel 各司其职
Flutter 和鸿蒙原生通信,核心还是平台通道机制。鸿蒙侧的适配层做了对齐,MethodChannel、EventChannel、BasicMessageChannel 都在 OpenHarmony 上实现了。
小说人物生成 APP 里用到了三个原生能力:复制人物介绍到剪贴板、把人物卡保存为图片、调用系统分享面板。这三个功能我都封装成一个名为 NativeBridge 的类,统一走 MethodChannel。
class NativeBridge { static const _channel = MethodChannel('com.example.novel/native'); static Future<bool> copyToClipboard(String text) async { return await _channel.invokeMethod('copyText', {'text': text}); } static Future<String?> saveCardToGallery(String bytesBase64) async { return await _channel.invokeMethod('savePng', {'data': bytesBase64}); } }鸿蒙侧则在 EntryAbility 或页面生命周期里注册对应的 handleMethodCall,解析参数后调用鸿蒙 API 完成实际操作。
EventChannel 的使用场景是另一类需求:我需要在 App 进入后台再回前台时,同步一次词库热更新状态。这个用 MethodChannel 也可以做,但频繁的主动查询会浪费电量,EventChannel 天然是“推送式”的,更适合这种被动感知事件。实现上鸿蒙侧持有 eventSink,在生命周期回调里往 Flutter 侧推事件,Flutter 侧监听流做状态刷新即可。
3.4 无真机、无虚拟机调试的“曲线救国”方案
很多初学者一开始没有鸿蒙真机,DevEco 自带的模拟器又依赖虚拟化,在老款 Windows 电脑上根本起不来。这个场景下有两条路:
一是用本地预览能力。Flutter 的 debug 模式支持“热重载到桌面窗口”,鸿蒙工程也可以先以桌面版宿主跑起来做 UI 验证。方法是在创建工程时同时带上 windows 平台,日常开发时在桌面上调整 UI,每半小时编译一次鸿蒙目标产物验证 API 兼容性。UI 层的 bug 在桌面宿主上就能暴露绝大部分,真正要验证的系统能力再拿到真机上去测。
二是找云真机资源。部分厂商开放了鸿蒙云真机调试时间,可以在云端跑自动化测试。我在适配分享面板功能时,就是靠云真机截屏确认了丑得离谱的默认分享缩略图,然后回来加参数修正。云真机有延迟,不适合做触摸交互调试,但适合做功能正确性验证。
3.5 鸿蒙工程里容易踩的编译坑两则
编译阶段有两个报错高频出现,我贴出来给大家一个心理预期。
第一个是You are applying Flutter's main Gradle plugin imperatively using the apply。这个报错在 Android 侧也常见,根因是 build.gradle 里用旧式 apply plugin 语法引入 Flutter Gradle 插件,而新版 Flutter 工具要求改用 plugins DSL。鸿蒙工程里如果同时混有 android/ 和 ohos/ 目录,清理 Android 旧模块时特别容易触发,直接按报错提示把 apply 改掉即可。
第二个是The current configured Flutter SDK is not known to be fully supported。这个警告出现在 Flutter 版本比鸿蒙适配分支的预期版本更新的时候。本质是版本兼容性检查没通过。很多人的第一反应是忽略,但我建议严格处理,否则后面跑代码生成、热重载、编译产物都可能出现隐蔽的不一致行为。直接换到匹配版本,别抱侥幸心理。
4. 小说人物生成核心功能实现:规则引擎、数据模型与 UI 细节
4.1 人物档案的数据模型:别把 JSON 当 Schema 用
小说人物卡的数据结构,第一版我懒省事直接用 Map 存,写起来确实快,但迭代到第三周就后悔了——每次加字段都要全局搜哪里用了这个 key,漏改一处就运行期报警。
后来老老实实设计了强类型模型,核心结构简化如下:
class PersonCharacter { final String id; final String name; final String alias; final String appearance; // 外貌描写 final List<String> personalityTags; // 性格标签,6-10个 final String speechStyle; // 口头禅/说话风格 final String background; // 身世背景 final List<String> secrets; // 不为人知的秘密 final List<Relationship> relationships; // 人物关系 final List<String> plotSeeds; // 剧情灵感种子 }强类型带来的第二个好处是可以给字段加元数据约束。比如 personalityTags 至少有 4 个才能导出完整卡片,relationships 里的关系类型只能是预设枚举值之一。这些约束在模型层就拦住了,不用等到渲染时才报错。
4.2 生成引擎的设计:确定性 + 随机性 + 模板拼接三层构建
小说人物生成的“智能感”主要来自一个朴素但有效的机制:主题约束下的组合爆炸。
整个生成过程分三步:
- 主题解析:用户输入的关键词映射到人物模板类别。比如“医生+民国”会命中“职业-时代”双维度组合模板,“阴郁”会命中性格倾向模板
- 局部随机:在命中的模板里,对每个字段从对应词库做加权随机抽取。权重设置是关键,比如“表面温和但内心算计”这类反差组合的权重必须比“温和且善良”这类顺拐组合高,因为故事人物需要张力
- 逻辑一致性校验:抽完后跑一遍规则检查。比如人物年龄设定 14 岁,就不能随机出一个“沉稳老练的将军”性格标签组合;时代背景是古代,就不能生成“手机重度依赖”的现代习惯。校验不通过则重新抽取该冲突字段
这个三层结构最棒的地方在于可调试。如果用户反馈“生成的某个名字不好听”,我可以直接定位到词库权重问题,而不是整条生成链路重启。第一版我试图把所有逻辑塞进一个巨大的 if-else 函数,后来代码读起来像天书,重构后半小时就能理清。
4.3 本地词库与存储:Hive 在大数据量下的表现
小说人物生成涉及大量词库数据,姓名字库、外貌描述库、性格标签库、职业库加起来有上万条记录。直接把所有词库打进 assets 里作为 JSON 文件,启动时一次性加载,内存占用在低端机会很紧张。
我最后用 Hive 做本地数据库,把词库在首次启动时从 assets 导入到 Hive box 中,后续查询走 Hive 索引。Hive 是纯 Dart 实现,不依赖原生 SQLite,这在鸿蒙适配阶段又省了一层功夫,不需要额外接入 sqlite3 的原生库。
大数据量下的性能调优有一个关键点:避免全表扫描。性格标签筛选(比如“既要反派感又要带点喜剧色彩”)这类复合条件,在 Hive 里用 filter 写起来快,但跑起来慢。我的做法是提前把词库按预定义分组建好索引,比如“反派+喜剧”直接对应一个预生成的候选 id 列表,查询时只要做集合交集,毫秒级返回。
4.4 UI 实现要点:卡片排版、拖拽排序与字体设置
人物卡片的 UI 是这个 App 的门面,我花了接近一半的开发时间在打磨这个界面。
卡片主体是一张竖向长图,顶部是姓名和别名,中间是外貌描述文本,下方是性格标签的瀑布流。布局上用 Flutter 的 Wrap 组件实现标签流式排布,每个标签是一个带有圆角背景的 Chip,支持拖拽排序。拖拽排序这块用的是 long_press_moveable 之类的手势库,但真正要注意的是“拖拽过程中的布局重建”问题——如果每移动一个像素就重建整个卡片 Widget,帧率会瞬间掉到 20 帧以下。解法是拖拽过程中只更新被拖拽项的位置偏移,松手后才触发完整的重排序。
字体设置也要多说一句。中文排版和英文差异很大,默认字体在加粗、斜体、数字对齐上的表现都不理想。我在卡片上显式指定了 fontFamily:中文字体用“鸿蒙黑体”,在小字号下比系统默认体清晰度高不少。同时给数字和英文设置单独的 fallback 字体,避免中西文混排时高低不一。
4.5 跨平台 UI 一致性的隐藏工程:系统字体与文本渲染
Flutter 的默认字体在 Android、iOS、鸿蒙上表现不完全一致。鸿蒙系统自带的 HarmonyOS Sans 和 Android 的 Roboto 在行高和字重上就有差异。
我的经验是:凡是涉及到“固定高度文本容器”的组件,都要给自己留出安全边距。比如标签 Chip 的高度,如果写死 28 逻辑像素,在部分鸿蒙设备上可能出现中文文字被截断的现象。原因不是字体变大了,而是不同系统默认字体渲染中文时的内边距不同。解决办法是使用 FloatingActionButton 类似的尺寸伸缩策略,或者干脆对文本组件设置 overflow: TextOverflow.ellipsis 并保证最大行数。
5. 常见问题与排查技巧实录
5.1 热重载失效:鸿蒙侧代码修改后的冷启动错觉
初用 Flutter 写鸿蒙时,我发现修改了 Dart 代码后点击热重载,UI 经常没有反应。排查后发现问题在鸿蒙侧的宿主工程:如果 native 代码或 oh-package.json5 发生了变更,必须执行完整的flutter build ohos才会生效,单纯热重载不会重建原生层。
这个“坑”其实符合预期,但它很隐蔽。因为报错信息不会弹出来,只会表现为 UI 完全没变化。我现在的工作习惯是:只改 Dart 代码 → 热重载;改了鸿蒙原生代码 → 冷启动全量编译。两种操作的节奏区分清楚,能省下大量无效等待时间。
5.2 SDK 版本不匹配导致的神秘崩溃
某次真机调试,App 在首页正常,但一点击“分享人物卡”就闪退。logcat 里的错误栈指向一个 NativeBridge 的未实现方法。检查发现:本机 DevEco SDK 更新后,分享模块的 API 包名变了,旧代码里硬编码了旧包名,导致运行期找不到类。
这类问题最有效的排查路径是:先用hdc shell hilog查看鸿蒙侧的运行日志,找到 C++/ArkTS 层的异常堆栈;再回到 Flutter 侧检查 MethodChannel 的方法名和参数名是否和鸿蒙侧 register 的完全一致。一个字符不匹配都调用不上,而且不会报编译错误,只在运行期静默失败。
5.3 用 Charles 抓鸿蒙 App 的网络包
小说人物生成 App 有一个云端灵感库功能,需要验证网络请求。Charles 抓包在鸿蒙上的配置比 Android 略麻烦:
- 鸿蒙系统对用户 CA 证书的信任策略和 Android 不同,部分版本不支持用户直接装代理证书
- 解决方法是把 Charles 的证书通过 DevEco 的“加密导入”功能装进系统信任区,或者开发阶段直接关闭代理校验
- 调试阶段我一般临时将网络库的代理指向本机 Charles 端口,并关闭 SSL 校验
抓包时的数据解析有一个小技巧:Charles 的 SSL Proxying 设置里,需要把域名精确匹配到测试服务器,不要用通配符*,否则会拖慢所有请求的解密速度。大流量场景下这类配置优化效果立竿见影。
5.4 性能优化记录:启动速度与滑动流畅度
人物卡列表页在大 data 集合下的性能是本项目优化的重点。
首先做的是启动优化。App 启动时原来会初始化整个词库,导致冷启动要 2 秒。优化方案是把词库加载改成“按需加载”:首屏只有首页,首页只需要展示最近生成的 20 张卡片,此时词库还没必要全量加载。等到用户进入了生成页,才触发全量词库的预热,这时候加载可以异步进行,用户无感知。
滑动流畅度方面,最有效的一步是给长列表卡片加上RepaintBoundary。每张人物卡是一个独立的绘制层,列表滚动时系统只需要合成各层,而不需要重绘卡片内部的复杂布局。加了 RepaintBoundary 后,真机滑动帧率从偶发掉帧稳定到 60 帧。
写在最后:关于跨平台鸿蒙开发的几点真实体感
做这个项目前后花了三周时间,从我个人的真实体感来看,Flutter 在鸿蒙上的适配成熟度已经达到“可以认真做生产项目”的水平,但还没有到“闭眼上车”的程度。
最明显的感觉是:Dart 侧代码完全跨平台,鸿蒙侧的原生桥接层才需要额外维护。如果你的 App 没有重度依赖系统能力,迁移成本可能只需要一周;但如果像我们这样用到剪贴板、分享、文件存储、后台事件推送,至少要预留两周来调试平台通道的各种边界情况。
我会建议所有想尝试 Flutter 鸿蒙开发的朋友:不要一上来就追求大而全的架构,先跑通最小闭环,把默认模板在真机上点亮,确认工具链稳定后再逐步添加业务功能。另外,鸿蒙社区更新速度快,别把教程里的版本号当作永恒真理,遇到诡异问题先检查 SDK 版本对齐。
这个项目后续我还会继续迭代,下一步计划把云端大模型生成接进来作为“增强模式”,同时把人物卡导出能力扩展到支持 PNG 长图分享到更多平台。如果你也在做类似的跨平台创作工具类应用,欢迎一起交流踩坑经验。