先说点实际的。前段时间我在业余时间做了一个三国杀攻略App,目标平台里明确包含OpenHarmony,技术栈选了Flutter。说实话,最初让我心里没底的不是业务逻辑,而是性能:武将列表、卡牌图鉴这种内容本身就是“长列表 + 高密度图片 + 高频筛选”的组合,在Android和桌面端Flutter已经比较成熟,但放在OpenHarmony这种适配方案还在快速迭代的平台上,一不小心就会卡成PPT。折腾完整个项目之后,性能优化和最佳实践基本都摸了一遍,这篇文章就把能直接复用的经验写出来,尤其是那些常规文档里不会写清楚的部分。
1. 背景与选型逻辑:为什么是三国杀攻略、Flutter和OpenHarmony
1.1 三国杀攻略内容形态决定了性能难点在哪里
先说结论:三国杀攻略类App看着简单,核心就是“武将库、卡牌图鉴、技能说明、阵容组合推荐”这几大块,但性能问题恰恰藏在“简单”里面。武将数量多,每张卡牌又带立绘、技能描述、阵营、体力值、编号等多维字段,天然就是一组高密度数据集合。用户在刷列表的时候,一条到底要展示图片、名字、阵营标签、技能摘要,手指稍微一快,帧率压力就上来了。
更关键的是筛选和搜索。三国杀攻略里常见的过滤条件非常多:按阵营、按体力、按技能类型、按上线版本。每次筛选重新遍历全部数据,会产生新的列表项集合,如果列表项Widget的构建逻辑里堆了大量计算,滚动就很难保持60fps。这个品类的内容以静态数据为主、操作以查询为主,所以最见真章的地方不是华丽动画,而是“列表滚动流畅度”和“页面切换跟手度”,这正是Flutter渲染管线的优势区域,也是优化最值得投入的区域。
1.2 多端覆盖需求下,为什么没有选原生方案
我当时的需求比较明确:同一套三国杀攻略功能,既要跑在Android设备上,又要覆盖OpenHarmony生态,后续还想出桌面版本。如果全部走原生实现,等于把武将列表、筛选逻辑、详情页重写至少两遍,后续数据字段一调整,就要同步改多端,维护成本会快速失控。
选型时我对比过原生声明式UI方案、Web容器方案和Flutter适配分支方案,情况如下表所示:
| 技术路线 | 开发效率 | 长列表性能 | 离线能力 | OpenHarmony适配成本 |
|---|---|---|---|---|
| 原生声明式UI | 中 | 好 | 强 | 原生支持,但多端工作量大 |
| Web容器方案 | 高 | 一般,滚动容易掉帧 | 弱 | 容器适配即可,但体验受限 |
| Flutter适配分支 | 高 | 好 | 强 | 需要跟随适配分支版本走 |
最终我选了Flutter,因为它在UI一致性和渲染性能之间平衡得最好。还有一个现实原因:Flutter生态里成熟的状态管理、网络、缓存、路由库,在适配分支上大多能直接复用,团队不需要额外学一套新框架。跨端开发有一个潜在的效率陷阱,就是“两套代码长得差不多,但细节行为不一致”,Flutter用同一套渲染引擎和Widget层级去面对不同平台,至少能把这部分不一致性压到最低。
1.3 “能跑”和“流畅”之间隔着一整轮优化
第一次把App跑上OpenHarmony设备的时候,功能层面一切正常,武将列表能出来,详情页能跳转,我当时心很大,以为这就完事了。结果真机用起来明显不对:快速滚动列表时画面有可见顿挫,详情页切换动画偶尔掉帧,从App启动到看到首页内容也偏慢。这些现象如果放在一个只有十几个页面的工具小程序里,可能忍忍就过去了,但三国杀攻略这种高频查询工具,卡顿会直接影响用户翻看武将和卡牌的心情。
于是我专门留出时间,做了比较系统的Profile分析和逐项优化。这篇文章后续的内容就是完整的踩坑链路:从数据建模、页面渲染,到图片、动画、启动、包体,每一块我都会写出问题根因和实际改法,末尾还有一份可以直接复用的最佳实践清单。需要提醒的是,性能优化本身没有银弹,只有“量->改->再量”的循环。
2. 工程搭建与数据模型设计:先把地基打对
2.1 环境版本对齐比什么都重要
先把环境版本这件事放在最前面,因为它是容易被忽略、又最容易导致性能问题变形的坑。“Flutter for OpenHarmony”实际上依赖的是社区维护的适配分支,这个分支会同步上游Flutter引擎,同时把OpenHarmony作为新的构建目标接入。这意味着Flutter引擎版本、Dart SDK版本、OpenHarmony SDK版本、构建工具链版本,四者的匹配关系是一个具体组合,不能随便挑最新版。
我当时使用的版本组合大致如下,硬性建议是:动手之前先看适配分支的Release说明,确认你本机的OpenHarmony SDK版本在支持范围里。
| 组件 | 版本选择 | 说明 |
|---|---|---|
| Flutter适配分支 | 跟随分支最新稳定版 | 不要用官方Flutter直接编OpenHarmony目标 |
| Dart SDK | 随Flutter版本自动绑定 | 单独升级Dart容易引发引擎不匹配 |
| OpenHarmony SDK | 与Release说明保持一致 | API级别差异可能导致桥接层链接失败 |
| 构建环境 | 使用OpenHarmony IDE命令行工具 | 负责native壳工程与HAP打包 |
为什么版本一致性如此重要?因为适配分支里包含了对OpenHarmony平台通道的C++桥接实现,而这套桥接是绑定具体API版本的。SDK API版本一旦差距过大,链接时就会报一堆奇怪的符号错误,看起来像代码问题,实际只是版本矩阵没对上。我的习惯是:配置完环境后的第一件事,先跑一遍官方示例工程,确认能出包、能装进设备,再开始写业务代码。这一步能省掉后面大量排错时间。
2.2 模块划分决定后续优化能不能局部生效
项目结构我也专门规划过。三国杀攻略App功能域很清晰:武将库、卡牌图鉴、技能查询、阵容推荐、设置。如果全部平铺在一个lib目录里,后面做性能优化时很难定位哪些页面属于同一条渲染链路,改一处优化可能会影响所有页面。
我当时采用按功能域拆分目录,加上统一的data与core层:
lib/ core/ # 路由、主题、通用组件 data/ models/ # General、Card、Skill等模型 repositories/ # 数据仓库,负责JSON加载与缓存 features/ generals/ # 武将列表与详情 cards/ # 卡牌图鉴 skills/ # 技能查询 settings/ # 设置与数据更新 app.dart main.dart这个分层带来的直接好处是:武将列表的优化只在features/generals内部进行,不会误伤卡牌页;数据缓存统一走repositories入口,后续要加内存缓存或者换成本地数据库,只需要替换一个实现文件。性能优化最怕的就是“该局部的时候全局跑,该全局的时候到处打补丁”,模块边界清晰以后,所有优化手段都能精准落位。
2.3 数据模型设计里的性能伏笔
三国杀攻略数据以静态JSON为主,我选择了打包成assets资源,而不是启动时从网络拉取。这样做有两个收益:加载速度稳定不依赖用户网络,同时首帧路径变得更短。但静态数据也有坑,比如模型字段设计得不好,每次列表构建都要做多层嵌套查询。
我最终建模时用了“扁平化 + 索引”的思路。武将模型大致长这样:
class General { final String id; final String name; final String faction; // 阵营 final int maxHp; // 体力上限 final List<Skill> skills; final String portraitAsset; // 立绘资源名 const General({ required this.id, required this.name, required this.faction, required this.maxHp, required this.skills, required this.portraitAsset, }); factory General.fromJson(Map<String, dynamic> json) { final skillList = (json['skills'] as List) .map((e) => Skill.fromJson(e as Map<String, dynamic>)) .toList(growable: false); return General( id: json['id'] as String, name: json['name'] as String, faction: json['faction'] as String, maxHp: json['maxHp'] as int, skills: skillList, portraitAsset: json['portrait'] as String, ); } }几个细节对性能影响深远:skills列表用了toList(growable: false),避免后续遍历时产生无谓的扩容;faction、name这些高频筛选字段全部放在模型顶层,这样筛选时不需要逐条遍历技能数组;所有模型的构造方法尽量声明为const,方便编译器优化。三国杀攻略的筛选逻辑经常要遍历全部武将数据,如果武将的技能还是拼接好的长字符串,每筛选一次就要重新拆一次文本,这个开销在滚动场景下会被放大得非常明显。
3. 页面渲染架构:列表、详情与图片资源的正确姿势
3.1 列表页优化的关键:复用与裁剪
三国杀攻略首页是“武将库”,这块列表极具代表性。如果直接用ListView(children: [...])来构建,等于一次性把上百个列表项全部build出来,内存和CPU都会被浪费。正确做法是使用ListView.builder做懒加载子项:
ListView.builder( itemExtent: 96, cacheExtent: 240, itemCount: generals.length, itemBuilder: (context, index) { return GeneralListItem(general: generals[index]); }, )itemExtent: 96是我优化后加上的关键参数。它告诉Flutter所有列表项高度固定为96逻辑像素,布局阶段就省掉了对每个列表项的高度测量。省下来的布局时间在大列表里非常可观,具体数据我在下一节会列出来。cacheExtent: 240则是控制预构建范围,既保证划入视口前已构建好,又不会一次性囤太多不可见组件。
列表项组件本身也要“减负”。我给自己的要求是:GeneralListItem里只做一件事,把模型字段映射到固定布局的文本和图片上,不再做任何条件判断、筛选、排序计算。像“这个武将属于哪个阵营,应该显示什么颜色标签”这种逻辑,我会在构建items列表之前就算好,放进一个轻量view model里,让列表项彻底变成纯展示组件。
class GeneralListItem extends StatelessWidget { final General general; const GeneralListItem({super.key, required this.general}); @override Widget build(BuildContext context) { return RepaintBoundary( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.asset( general.portraitAsset, width: 64, height: 64, cacheWidth: 64, fit: BoxFit.cover, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(general.name, style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text( '${general.maxHp}体力 · ${general.faction}', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ], ), ), ); } }这里有个容易被忽略的小点:列表项最外层包了RepaintBoundary。它的作用是把组件从父级绘制流程里隔离出来,列表滚动时其他区域变化就不会连带重绘整个列表项。对包含图片的列表项而言,RepaintBoundary能让渲染引擎把图片层单独管理,避免无谓的图层合并,在OpenHarmony设备上效果同样明显。
3.2 路由与详情页参数传递的微妙效率差
详情页的优化经验相对简单,但同样影响体验:页面间跳转时,不要把整个数据对象都丢给路由。有些团队图省事,跨页面直接传General实例,这样确实不用二次查找,但在Flutter里,MaterialPageRoute的settings默认会对参数做处理,大对象传参必然增加跳转开销。正确做法是只传id,目标页面通过repository按id从缓存里取:
Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/general_detail'), builder: (_) => GeneralDetailPage(generalId: general.id), ), );详情页内部展示哪些内容,从哪里取数据,都由页面自己决定。这种模式下,跳转只传递一个字符串,内存开销小,同时也让详情页具备了独立加载的可能。比如我可以等页面build完成后再通过Future加载完整技能说明,弱化跳转帧的压力。三国杀攻略这类工具型App的特点是用户会在“列表→详情→返回”之间频繁切换,这种设计能有效减少页面切换时的卡顿感。
3.3 图片资源加载的“四板斧”
三国杀攻略App里图片占比极高:武将立绘、卡牌原画、技能图标。图片如果处理不好,再好的代码也白搭。我在项目里严格执行了下面四点,效果立竿见影。
第一,非网络图全部转成WebP并按尺寸分级。原来切的PNG立绘动辄几百KB,转成WebP后体积通常能降一半以上,解码速度也更快。尺寸分级的意思是列表缩略图、详情大图、放大查看图各用一套资源,绝不出现列表去加载2048像素大图再缩小显示的情况。第二,Image.asset和Image.network都主动传cacheWidth或cacheHeight,让引擎按实际显示尺寸解码,而不是解码原始尺寸再交给GPU缩放。第三,所有图标尽量走Icon字体,不用一堆小体积PNG替代。第四,列表项的图片尺寸固定,不给图片容器频繁重新布局的机会。
这四条共享一个底层逻辑:图片解码是开销很重的操作,尤其在大列表场景下,更不要做“解码大图再交给GPU缩小”这种反向优化。我在OpenHarmony设备上测过,同一个列表,加载大图时滚动帧率能掉到48fps左右,改成缩略图规格后稳定在60fps附近,差异非常直观。
4. 性能优化实录:从帧率图到稳定60fps的完整链路
4.1 优先相信Profile数据,不要靠肉眼猜
进入优化阶段后,我没有直接盲改代码,而是先用Flutter DevTools的Frame chart工具抓了一轮Profile模式下的帧时间数据。具体做法是:设备连电脑,跑Profile构建,在App里快速滑动武将列表两到三分钟,然后观察UI线程和Raster线程各自的花费时间分布。
这一看就看出了问题:列表滚动时,Raster线程存在大量高尖峰,说明渲染端在反复处理重活;UI线程反而相对平稳。结合代码,我立刻怀疑是图片解码和图层绘制开销过大,因为当时列表还在用原始尺寸的PNG立绘,每个item都让Raster线程反复做图片缩放和混合。第一版性能数据里,Raster线程帧时间经常冲到20ms以上,意味着在60Hz刷新率下已经逼近甚至突破帧预算。
有了准确的方向,后面每做一步优化,我就重跑一次同样的滑动场景,观察Frame chart变化。我强烈建议所有做性能优化的同学都走这条路径:先量,再改,再量。不要凭感觉觉得“这里应该会慢”,更不要优化完连验证都不做,那样改出来的东西很可能只是自我安慰。
4.2 列表优化三步走:从源头砍掉冗余工作
针对列表滚动卡顿,我按“数据结构→图片解码→组件构建”的顺序做了三层优化,每一步都能单独验证效果。
第一步是固定itemExtent,前面已经讲过。这一步主要帮助布局阶段,让UI线程更稳定。实测对比中,固定高度之后UI线程的构建时间下降明显,列表滚动的整体节奏也更稳了。第二步是缩小解码尺寸,我把所有列表项里的图片换成64x64规格的缩略图,同时加上cacheWidth参数,让Flutter按显示尺寸解码。这步收益最直接:Raster线程的图片缩放压力基本消失,Frame chart上的尖峰高度大幅回落。第三步是精简组件构建,我把列表项里所有不必要嵌套去掉,原来一个简单条目里套了三层Container、两层Padding,后来全部合并成间距控制,减少了Widget树深度。同时,所有能在列表外算好的数据都提前算好,列表项build方法里没有一次循环、没有一次条件判断,变成了彻底的纯展示节点。
这三步做完后,我重跑了一遍同样的场景:Raster线程峰值时间基本被压到8ms以下,UI线程稳定在6ms左右,肉眼滑动已经感觉不到掉帧。需要强调的是,这三步没有哪一步是“锦上添花”,而是层层递进:先解决布局,再解决绘制,最后解决组件构建,每一个环节都在为下一个环节创造稳定的性能基线。
4.3 详情页动画卡顿的根因与修复
列表问题解决后,我把注意力转到详情页。详情页里有一段“技能说明展开”的动画,原来用AnimatedContainer加嵌套的Opacity实现,实际使用中在OpenHarmony设备上有一点掉帧。用Profile细看,问题出在两处:一是Opacity对整个内容区域做透明混合,这个区域尺寸又比较大,每次都让Raster线程做整块混合操作;二是AnimatedContainer在动画期间会不断触发隐式布局变化,把不需要布局的子节点也牵动起来。
我把这段动画改成了显式AnimationController加Transform,让动画只作用于一个轻量级容器,同时给内容区域单独套RepaintBoundary,隔离动画层的重绘范围。改完之后,同一台设备上动画过程稳定了很多,不再出现肉眼可见的跳动。这类问题的通用处理原则是:能用Transform完成的动画,绝不用会触发布局属性的硬凹;透明混合范围控制在最小;隔离变化区域,不让动画拖累整棵渲染树。
4.4 内存波动与图片缓存的临界值
滚动性能稳定后,我又盯上内存。三国杀攻略App数据虽然是静态的,但用户会不断打开详情页、返回列表、再打开下一个详情页,图片和页面对象积累起来以后,内存曲线会缓慢上升,如果GC不及时,就会出现“用久了变卡”的经典问题。
我做了两件事。第一,给图片缓存设置了一个合理上限,不让进程内缓存无限膨胀。Flutter的ImageCache默认有一定容量,但对于这种高频图片App,我主动调小了容量上限,让超过阈值的图片尽快释放,代价是重复打开时可能重新解码,但这个代价远小于内存膨胀导致的不可控卡顿。第二,在详情页返回时主动清掉页面级的临时状态,不让Page对象被全局单例长期引用。这里最值得警惕的是各种“顺手”的全局缓存,如果什么东西都想存到static变量里方便下次取,内存就会悄悄失控,而且很难查。
5. 启动耗时与包体瘦身:让App更有“轻快感”
5.1 首帧路径优化:把加法全部放到首帧之后
三国杀攻略App启动偏慢,这个问题一开始没被重视,因为它不影响功能,但用户感受很直接:点开App等半天才看到首页,列表再流畅也白搭。我用启动时间统计抓了一下首帧时间,发现主要开销集中在两块:Dart isolate初始化阶段提前加载了全部三国杀数据JSON,以及首页首帧之前构建了一个较深的嵌套布局。
优化思路很直接:能延后的都延后。数据加载从同步改为异步,首页只加载武将列表前50条作为首屏数据,剩余数据在后台继续加载;首页布局重新收紧层级,去掉一些不必要的装饰性Container,让首帧路径短到极限。同时,启动页在Flutter首帧完成前先显示原生欢迎图,避免用户看到白屏或黑屏。
优化之后,首帧时间比基线明显下降。具体数值因设备和版本差异很大,关键不是记住某个数字,而是理解这个原则——首帧之前干的活越少,App体感就越快。所有非核心的数据加载、组件构建、插件初始化都在首帧之后再做,用户感受到的启动速度会好很多。
5.2 包体瘦身:先看大块头,再抠细节
包体这块,OpenHarmony的HAP包和Android的APK结构不完全一样,但分析思路类似。我先按体积递增排列产物里的资源,发现体量主要来自这三块:native so库、图片资源、字体。三国杀攻略App的字体其实只用系统字体就够,我一开始为了标题效果塞了一整套中文字体进去,后来直接换掉,用系统字体加字重的做法,包体立刻瘦了一截。
图片资源用前面说的WebP化和按尺寸分级处理,又能压缩不少。最后在编译参数上,我开启编译优化和资源裁剪,并移除了不用的Icon字体。一套操作下来,包体体积下降比例相当可观,App在OpenHarmony设备上安装、更新的时间都变短了,对体验提升是实打实的。
做包体瘦身时要注意边界:不要为了追求极致体积,把后续可能要用的资源全部拆到网络下载,那样反而增加首屏加载的不确定性。折中方案是核心页面的图片和配置留在包内,长尾资源走增量更新,这样既能控制包体,又保证离线可用性。
6. 上线前的检查清单与可复用经验
6.1 平台差异:这些坑在OpenHarmony上尤其常见
同样的代码在不同平台行为不完全一致,这几乎是跨端开发必交的学费。我在OpenHarmony设备上踩到过几类问题,必须拿出来分享。一是部分依赖原生导航、原生存储的Flutter插件在OpenHarmony上没有对应实现,需要降级到通用存储方案,或者自己包一层平台通道。二是图片解码库差异:某些WebP参数在Android上能解,在OpenHarmony上可能走的是不同的解码路径,测试时必须把图片场景全部过一遍,别只看列表验证。三是字体渲染差异:同一种字重在不同系统下呈现的粗细不同,界面细节需要逐台设备核对。
这类问题的通用解决办法是“代码里做能力探测,而不是默认可用”。我把所有第三方能力都包了一层服务抽象,比如图片加载、轻量存储。这样在OpenHarmony上遇到不适配时,只需替换一个服务实现,不影响页面层代码。这个抽象层看起来很薄,但在平台适配期能省掉大量“改一处漏十处”的重复工作量。
6.2 数据更新策略:给静态内容加上“版本感”
三国杀攻略类App的内容会随版本更新而变化,比如新武将上线、技能数值调整。我的策略是:把武将卡牌数据作为版本化JSON放在assets里,启动后异步请求远程增量包,再与本地版本号比较,需要更新时才下载。整个过程完全不阻塞UI,把网络失败或慢速当成常态来处理,而不是异常。
这样做最大的好处是“离线优先”:用户就算没有网络也能正常刷武将列表和技能说明,网络只负责数据补全。对工具型攻略App,离线可用性和在线更新能力同样重要,而且这个策略对性能也非常友好——没有网络请求阻塞主线程,没有启动时等待,体验自然会稳定。具体实现上,我在repository里统一管理版本号比较和增量合并,页面层只关心“读数据”,不用感知数据是从assets还是网络来的。
6.3 可以直接抄的最佳实践清单
最后分享一份我自己整理过多次的清单,基本覆盖了这次三国杀攻略App性能优化的全部关键点。以后再做同类型App,我大概率会从这份清单开始核对:
- 列表页统一使用
ListView.builder,并按实际情况设置itemExtent和cacheExtent - 列表项只做展示,所有筛选、排序、条件判断提前到构建列表之前完成
- 图片按显示尺寸分级,优先WebP,解码时主动传
cacheWidth或cacheHeight - 需要隔离的重绘制区域包
RepaintBoundary,动画优先用Transform和显式AnimationController - 路由传参只传id等轻量标识,不传大对象,详情页数据由页面自行加载
- 首帧路径尽量短,数据加载延后到首帧之后,启动时使用原生启动图
- 包体资源先看大块头:so库、字体、图片。能省的绝不手软,但别乱拆造成加载不稳定
- 第三方能力统一封装,做平台能力探测,避免默认某平台一定有对应实现
- 内存监控纳入日常回归,即使是静态数据App也有内存膨胀风险
- Profile记录优化前后的帧时间对比,让每个优化结论都有数据支撑
这次项目做完之后,我的体会是:在OpenHarmony上用Flutter做攻略类App,难点不在功能,而在“把同样一套优化方法论落实到不同平台渲染细节上”。只要先咬住数据模型、列表构建、图片解码这些基础环节,再用帧率数据反复验证,最终效果完全可以达到日常使用感知不到卡顿的状态。后面如果我再接触其他高密度内容类型的项目,这份清单大概率还能继续用,只是参数和阈值需要根据实际数据重新调一遍。