1. 为什么偏偏是Flutter:多端复用的现实考量
1.1 OpenHarmony生态的App开发格局
如果你最近关注过开源鸿蒙(OpenHarmony)的设备生态,会发现一个很有意思的现象:市面上的OpenHarmony设备并不像安卓/iOS那样形态统一,而是从几百块钱的开发板、带屏智能家居中控,到部分厂商推出的平板和轻量级手机,跨度非常大。这些设备的系统版本、屏幕尺寸、硬件性能参差不齐,给应用开发带来的第一个问题就是:你到底要为几种平台各写一套UI?
目前OpenHarmony应用开发的主流方式确实是以ArkTS/ArkUI为主,这套声明式UI框架的学习曲线不算陡峭,写起来也颇有几分Compose的味道。但现实是,很多团队手上已经积累了大量Dart/Flutter代码,或者团队成员本身就是跨端出身,让他们为了一个工具类App全面切换到ArkTS,前期学习成本和代码迁移成本都不小。更关键的是,攻略类应用不像系统级应用那样需要深度的系统能力调用,绝大多数页面就是图片、文字、列表、表单,用Flutter这种成熟跨端方案完全能覆盖。
我做这个三国杀攻略App的时候,目标用户手里的设备恰好是"安卓为主、OpenHarmony设备正在变多"的混合状态。与其分别维护安卓原生版本和鸿蒙版本,不如直接把UI层用Flutter统一掉。实测下来,Flutter在OpenHarmony上的2D渲染能力已经能比较稳定地支撑这种轻量工具应用,而且大部分社区依赖包如果是纯Dart实现,几乎不用改就能直接跑起来。这个结论对我后续选型影响很大。
1.2 一套代码,三个平台
武将对比这个功能,从产品视角看是"帮用户快速判断两个武将哪个更适合当前阵容",但从工程视角看,它涉及了页面路由、数据解析、列表渲染、状态切换、图片缓存、甚至一点简单的文本相似度算法。如果用原生三端分别实现,同样的逻辑要写三遍,后续改一个评分公式就要同步改三处,维护成本会成倍增加。
Flutter在这里的真实收益体现在三个层面。第一,UI层复用:双栏对比卡片、技能差异高亮、VS分割线这些组件在三个平台上表现一致,不需要为某个平台单独调样式。第二,逻辑层复用:武将体力、技能覆盖度、爆发/防御评分这些计算逻辑全部写在Dart层,和平台无关。第三,热重载效率:调试对比页布局时,改完代码保存,模拟器上几乎秒级刷新,这比原生改完编译再部署的循环快太多了。如果你后续还想覆盖iOS端,这套代码不需要大改,因为武将对比功能几乎没有用到平台专属能力,纯Flutter实现就够了。
当然,跨端不等于零成本,Flutter for OpenHarmony的适配层目前还属于快速迭代阶段,有一些工程层面的坑需要提前踩一遍。接下来我就从工程配置开始,把整个实战过程中最关键的部分拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. OpenHarmony适配要注意的工程配置细节
2.1 从标准Flutter工程到OpenHarmony工程
先明确一个概念:Flutter官方主分支默认生成的工程是不带OpenHarmony适配目录的。我用的方式是创建标准Flutter工程后,用OpenHarmony侧的适配工具生成ohos目录,再把业务代码迁过去。整个流程大致是这样:
- 创建Flutter工程,flutter create hero_compare,语言选Dart,平台先勾选android、ios。
- 确认本机Flutter SDK版本。这里要特别留意,OpenHarmony的Flutter适配分支对SDK版本有明确要求,版本太新或太旧都可能出现编译错误,后面我会详细说。
- 在工程根目录执行OpenHarmony适配初始化命令,生成ohos目录。生成后目录结构大概是这样:ohos/entry/src/main/ets/pages/Index.ets是原生入口页,它负责加载Flutter容器。
- 修改ohos工程的包名和应用图标,把默认的com.example替换成你自己的applicationId,这一步在后续发版签名时会直接影响到产物id,别拖到最后才改。
- 在ohos/local.properties里配置本地SDK路径,在ohos/build-profile.json5里配置签名信息。
这一步做完,理论上已经可以在OpenHarmony设备或者模拟器上跑起来一个空Flutter页面。但我第一次在这个环节就卡住了,而且报错信息非常经典,就是热搜词里那个"you are applying flutter's main gradle plugin imperatively using the apply"问题。
2.2 最大的坑:Gradle插件方式冲突
我当时用芬利(flutter)的方式把工程同步到OpenHarmony后,编译时直接抛出一个看似莫名其妙的错误,大意是:你正在使用apply方式应用Flutter主Gradle插件,这种方式在新版SDK里不支持,请改用plugins block方式声明。这个报错对刚接触OpenHarmony适配的人来说很不友好,因为它并不会直接告诉你要改哪个文件。
问题根源在于:OpenHarmony的Flutter工程的Gradle构建体系,和标准Flutter工程的Gradle构建体系是两套逻辑。标准Flutter工程里,老版本的Flutter插件确实支持在app/build.gradle里写apply plugin: 'com.android.application'和apply plugin: 'flutter'这种命令式应用方式,但新版Flutter SDK已经在向更声明式的plugins block迁移。在OpenHarmony适配分支上,这个迁移要求被强制执行了,所以你必须打开ohos/entry/build.gradle,把旧的apply方式改成:
gradle复制plugins {
id 'org.openharmony.ohos'
id 'org.openharmony.flutter'
}
同时,还要把顶层settings.gradle里声明的插件版本和Flutter SDK自带的插件版本对齐。我第一次就是漏了对齐SDK版本,又接着报了"the current configured flutter sdk is not known to be fully supported"——这个报错提醒你配置的SDK不在官方支持的已知列表内。解决办法不是换一个奇怪的SDK分支,而是把Flutter SDK固定到适配层验证过的版本号上:打开ohos/gradle.properties,把flutter.sdk参数指向正确的本地路径,同时把distributionUrl对应的Gradle版本降/升到指定版本,JDK也最好用适配层要求的那一版本。这里没有太多技巧,就是老老实实对齐版本号,盲目更新反而会制造更多不确定问题。
2.3 跑通第一个页面时容易被忽略的两件事
第一次成功把工程跑起来之后,你会发现一个不大不小的问题:首帧时间偏长。在安卓真机上,Flutter页面首帧通常一两秒内就能出来,但在OpenHarmony设备上(尤其是低端开发板),首帧可能要三到五秒。这不算适配bug,而是Flutter引擎在OpenHarmony上的GPU光栅化链路还在持续优化中,CPU侧的初始化开销也比安卓稍大。我的处理方式是在原生入口页加了一个品牌闪屏页,等Flutter容器回调首帧渲染完成后,再平滑移除闪屏层。这样用户在体验上不会觉得是卡死了,只以为是正常的启动过渡。
另一个容易被忽略的细节是dart入口和原生页面的绑定关系。在ohos/entry/src/main/ets/pages/Index.ets里,你要明确指定加载的Flutter模块名称和路由,比如通过FlutterManager加载"hero_compare"模块,而Dart侧的main.dart要保持和这个模块名一致。如果两边名称对不上,最典型的症状就是应用打开后一直白屏,而且没有任何报错日志,排查起来非常费劲。我建议在任何业务代码开始之前,先把一个最简单的Text页面跑通,确认链路没问题再开始写功能。
跑通空页面之后,接下来才进入真正的业务开发。这个阶段的核心任务是把三国杀武将对比这个功能做出来,而功能的第一步,是想清楚数据模型和对比逻辑。
3. 武将数据模型与对比逻辑:先想清楚再写UI
3.1 武将的核心属性怎么抽象
三国杀的武将体系比很多人想象的要复杂。一个武将不仅有体力、势力、性别这些基础属性,还有技能描述、技能类型、品级、定位标签、强度口碑等等。如果对比功能只做字段一对一展示,那还不如直接截两张图让人肉眼看。所以我在设计数据模型时,特意分成了"展示字段"和"计算字段"两部分。
展示字段解决的是"用户需要看到什么"这个问题。我定义在Dart类里大概是这样:
dart复制class HeroInfo {
final String id;
final String name;
final String kingdom; // wei, shu, wu, qun, shen
final String gender;
final int hp;
final String title; // 武将称号,如"桃园结义者"
final String rarity; // 品级:标准/风/火/林/山/一将...
final List<String> tags; // 定位标签:爆发/防御/控场/辅助
final List<Skill> skills; // 技能列表
final Map<String, double> quadrantScore; // 四维评分
}
计算字段解决的是"用户想看出什么"这个问题。三国杀玩家在对比两个武将时,真正关心的其实不是"刘备体力4、曹操体力4"这种静态数据,而是"如果我把这个武将放进当前牌局,它的技能能触发多少次、能覆盖哪些场景、进攻强还是防守强"。所以我在quadrantScore里维护了四个维度:输出、防御、续航、干扰,每个维度取值0.0到10.0,这个值不是人工手填的,而是由后面的对比算法自动产出。
3.2 对比算法:不要只做字段差集
我不希望用户看到的只是"刘备技能1:仁德,曹操技能1:奸雄"这种简单的并排罗列。所以算法上做了一层简单的语义归类。具体做法是先从技能描述里提取关键词,比如"判定""摸牌""伤害""回复""免疫""转化""弃置""翻面"等,然后根据这些关键词把技能归到输出、防御、续航、干扰四个象限中去,再根据技能触发频率和覆盖范围计算一个加权分。
举个例子,刘备的仁德技能描述里包含"摸牌""回复"相关关键词,触发频率高,所以续航和辅助得分会比较高;曹操的奸雄技能包含"伤害""获得牌",输出和防御都有一定覆盖,但续航得分不如刘备。象限得分出来后,我再对两个武将的每个维度做差值计算,差值超过设定阈值(比如1.5分)才在UI上高亮标注,避免页面上到处标红标绿看起来像警报器。
最后给一个综合评分,我的加权公式是这样:
dart复制double overallScore(HeroInfo hero) {
return hero.hp * 0.2 +
(hero.quadrantScore['output']! * 0.3) +
(hero.quadrantScore['defense']! * 0.2) +
(hero.quadrantScore['sustain']! * 0.2) +
(hero.quadrantScore['control']! * 0.1);
}
综合评分用来生成一个五颗星的显示条,这个改动在后期调优时很方便——想调整权重,只改常量重跑热重载就行。
3.3 数据来源:本地JSON与异步加载
武将数据量不大,满打满算目前版本也就一百多位武将,每个人物的技能文本和定位标签加起来不超过几个KB。所以我没有引入数据库,直接把这些数据打包成assets/heroes.json,在应用启动后一次性加载到内存。
读取JSON这部分我用了一个简单的FutureBuilder包裹加载状态,定义三个状态:loading、success、error。加载失败时会展示一个可重试的按钮,而不是白屏。这里有个细节:不要在主Isolate里解析大JSON反复卡UI,但三国杀攻略这份JSON解析一次也就几十毫秒,在主Isolate里做其实问题不大,真正要注意的是图片资源别一次性全量加载,这个我后面会讲。
数据模型和算法敲定之后,UI实现就有明确方向了。接下来是整个功能最花时间的部分:对比页面的交互设计。
4. 对比页面实现:布局、状态管理与交互动效
4.1 双栏卡片布局:怎么放才不显得拥挤
武将对比页常见的布局有两种:一种是上下两栏,另一种是左右两栏。在OpenHarmony的平板或者大屏设备上,左右两栏更合适;但在手机这类窄屏设备上,如果左右各放一套完整的武将信息,文字会压缩得很厉害,技能描述根本读不完。
我最终采用了一个折中方案:页面分成上下两个区域。上方是"武将头像对比区",两个头像从屏幕左右两侧向中间靠拢,中间放一个大大的VS标识;下方是"属性对比明细区",采用纵向条目流的形式,每一行左右各一个值,中间是差异指示。这样的好处是窄屏上也可以清楚地看到每个属性的左右对比,不至于横向压缩。
头像对比区我用了一个简单的Stack和Align组合,保证两个头像在不同屏幕宽度下都能对称排布。如果你手头有多个不同尺寸的OpenHarmony设备,建议把这块写成百分比布局而不是固定像素,否则在平板上正常、在手机上头像会溢出屏幕。至于下方明细区,我用CustomScrollView加SliverList,把每个对比条目变成一个SliverGrid的Cell,这样技能特别多的武将也只需要滚动列表,不会一次性构建大量Widget导致掉帧。
4.2 差异高亮和技能对比:视觉引导的核心
用户打开对比页,最想一眼看到的就是"这两个武将到底差在哪"。所以差异高亮是这个页面最重要的视觉逻辑。具体实现是:每个对比条目根据差值设置不同的侧边提示色。差值大于阈值偏向绿色,差值小于负阈值偏向红色,两者接近则灰色。颜色尽量用低饱和度,避免刺眼。
技能对比这里我多做了点工作:按描述相似度做行内对齐。思路很简单,算两个武将技能描述之间的Jaccard相似度,如果相似度高于0.4,就把两个技能放在同一行对比;如果低于阈值,则各自独立展示一行,空缺侧显示"——"。这样用户看到的技能对比不是机械的两列,而是语义上相关的技能被拉到一起,阅读体验会自然很多。
行内对齐用到的相似度计算不复杂,核心代码大概是这样:
dart复制double jaccard(List<String> a, List<String> b) {
final setA = a.toSet();
final setB = b.toSet();
final union = setA.union(setB).length;
if (union == 0) return 0;
return setA.intersection(setB).length / union;
}
这里用到的分词在中文场景下可以先按字符bigram处理,命中率比纯单词高不少。实测下来,这套简单方案的效果远超预期,用户反馈"一眼就能看出来刘备和曹操的核心差异在哪"。
4.3 切换动画与状态持久化
对比页不能只是静态展示,用户一定要能切换武将。我实现方式是点击任一武将的头像区域,从底部弹出一个搜索选择器(BottomSheet),里面用ListView展示全部武将列表,支持按势力筛选和按名称搜索。选择完成后,右侧卡片用AnimatedSwitcher做一个平滑的旧卡片翻出、新卡片翻入的过渡,整个过程不需要额外引入路由跳转,也不需要重建整个页面。
状态管理方面,我没有引入bloc或者provider这种重量级方案。对比页的状态其实非常有限:两个武将的ID、当前选中的对比维度排序方式、以及用户是否已经选择了两名武将。我直接用ValueNotifier组合维护这三个状态,在build方法里通过ValueListenableBuilder监听变化。这样做的好处是代码直观、依赖包少,而且在OpenHarmony这种需要精简依赖的场景下,能少引入一个Native插件就少一分适配风险。
另外一个容易忽略的点是状态持久化。用户对比到一半退出App,下次打开如果要从头再选一遍会很烦躁。我用SharedPreferences把最近一次对比的两个武将ID存下来,启动时自动恢复。这个功能代码量不大,但体验提升很明显,属于典型的"小改动高收益"。
UI部分做完之后,真正的麻烦才刚刚开始。Flutter for OpenHarmony的运行时问题比纯UI代码要复杂得多,接下来把我在开发过程中踩到的高频问题一个个列出来。
5. 踩坑实录:开发中遇到的高频问题
5.1 EventChannel与原生通信卡死问题
我的App里除了武将对比功能,还有一个牌局记录器,需要读取OpenHarmony侧的一些系统状态,比如屏幕常亮状态和应用前后台切换。这些信息在Dart侧拿不到,必须通过EventChannel从原生侧监听,再通知Dart侧。
第一次实现时,我在OpenHarmony的ets侧每秒钟上报一次系统状态,想着模拟器上没问题,结果真机上对比页滑动的时候出现了极其诡异的"页面假死":UI能显示,但点击无响应,几秒后才恢复。最开始怀疑是列表渲染阻塞,排查了很久才发现问题出在EventChannel的消息频率上。每秒钟高频次的平台通道消息会占用大量事件循环资源,Flutter引擎在OpenHarmony上的消息队列处理能力相比安卓要弱一些,高频事件会让界面交互事件排在后面,表现就是"点了没反应"。
解决方案有两步:第一步,原生侧改为只在关键生命周期节点发送事件,比如onPause、onResume、onWindowFocusChanged,不做周期性上报;第二步,Dart侧接收端加一层debounce,同一类型的事件在一秒内只处理一条。改完后卡死现象完全消失。这个经验我现在每次做Flutter和原生通信都会复用得,不只是OpenHarmony平台通用有效。
5.2 武将头像加载慢与图片缓存策略
三国杀武将头像数量多,每张图都是几百KB甚至更大的PNG。初始版本我直接在列表和对比页里原图加载,结果在OpenHarmony低端设备上滚动对比列表时,帧率惨不忍睹——热重载打开性能分析器看,每帧光栅化耗时直接飙到50ms以上。
问题出在两方面:一是GPU纹理上传开销,大量大图同时进入GPU造成显存带宽跑满;二是ImageCache的默认缓存策略对大图不友好。我的优化方案是给列表页的头像统一指定cacheWidth,设置为手机逻辑像素的两倍左右(比如360的逻辑宽度对应720的物理分辨率),让解码输出尺寸直接缩小,而不是让引擎解码全尺寸再缩放。同时设置ImageCache的最大字节数,避免缓存里堆满大尺寸头像把内存撑爆。
还有一个小坑:OpenHarmony上Flutter对asset路径的大小写敏感程度比安卓更强。我把部分头像放在类似assets/HeroImages/这样的目录下,代码里误写成了小写,在安卓上能正常显示(因为文件系统不区分大小写),但在OpenHarmony上直接报找不到资源。这个调试起来很隐蔽,建议初始化时就约定所有资源路径统一小写下划线风格。
5.3 低端设备上的滚动卡顿优化
如果说图片问题是性能的起点,那布局结构则是卡顿的下一个瓶颈。最初的对比明细区我用的是SingleChildScrollView包Column,里面每个对比条目都套了两三层Container加BoxDecoration,比如加圆角边框、渐变底、阴影。这套东西在安卓中端机上问题不大,在OpenHarmony的某些低配开发板上,阴影和圆角的光栅化开销被放大得很明显,只要列表条目超过20个,滚动就开始掉帧。
优化核心是两招。第一招,把每个对比条目的不变部分用RepaintBoundary隔离开,这样条目滚动出屏幕时不会触发重复绘制。第二招,把SingleChildScrollView+Column改成ListView.builder,让列表项真正懒加载。这两招做完,滚动帧率在低端设备上从个位数帧提升到能接受的水平。把所有跑积分和装饰物检查一遍后发现,BoxDecoration的阴影在低端OpenHarmony设备上deactivate得非常慢,这也是一个不像直觉但真实存在的开销来源,能省就省。
到这里,功能基本稳定,接下来该用数据说话,看看这个页面在真实设备上的表现,也顺便聊聊后续还能怎么扩展。
6. 性能实测与后续扩展方向
6.1 实测数据:优化前后对比
开发过程中我把优化前后的性能记录拿来做了一组对比,设备分别是OpenHarmony开发板、某厂商OpenHarmony平板和一台类手机形态的低端设备。测试方式统一:进入武将对比页,连续滚动10秒记录平均FPS,用Profile模式跑。
| 设备类型 | 优化前平均FPS | 优化后平均FPS | 首帧时长(优化后) |
|---|---|---|---|
| 开发板 | 8 ~ 12 | 38 ~ 45 | 3.8s |
| 平板 | 20 ~ 26 | 52 ~ 58 | 2.5s |
| 低端手机 | 12 ~ 16 | 42 ~ 50 | 2.9s |
首帧时长基本由闪屏页策略兜底,用户体验上感知不强。滚动FPS提升最明显的原因是做了列表懒加载和图片缩略,这两个优化优先级最高。内存占用方面,原来全尺寸头像加载会到220MB左右,优化后稳定在95MB上下,说明cacheWidth和ImageCache上限设置效果明显。
6.2 同一套基础还能扩展什么
武将对比做完之后,这套架构其实可以很自然地扩展到几个新玩法,而且不需要改动底层框架。第一个是阵容对比:同时选择五个武将,用同一个quadrantScore体系输出整体攻防曲线,帮助用户判断当前牌组的关键弱点。第二个是技能克制关系图谱:基于技能关键词碰撞,生成两个技能之间的相对克制推荐,这个不涉及复杂图算法,用邻接表就能实现。第三个是上位替代推荐:用户在某个武将上觉得不顺手,系统基于四维评分相似度推荐定位接近、但品级更高的武将,本质上就是一次向量余弦相似度计算。
这些扩展的共同点是都复用同一份heroes.json数据结构,只是在上层增加不同的计算视图。如果你也想在OpenHarmony上做一个攻略类、工具类App,我建议先把数据模型设计得足够健壮,再考虑UI细节,这样后续加功能时会非常省力。
最后再分享一个小技巧。我在实际调试中发现,Flutter for OpenHarmony的Debug模式首帧性能会比Release模式差很多,所以在评估交互流畅度时一定要用Release模式测。有些开发者在Debug模式下觉得"这玩意卡得不能用",但打Release包之后完全是两个应用。这一点在安卓上差距没这么大,但在OpenHarmony适配层上格外明显。记住这句话,能帮你省下一整天的无用优化时间。
