骨架屏这套东西,老 Android 开发其实都不陌生,早期就有用占位 View 模拟加载态的做法。后来 Flutter 生态里出现了 shimmer_pro 这类三方库,把扫光动效封装得极其顺手,一个Shimmer.fromColors包一层,页面骨架立刻有了质感和方向感。但这次鸿蒙适配任务让我意识到,库好用是一回事,跨平台能用是真另一回事。shimmer_pro 在 Android 上跑得丝滑,不代表在鸿蒙的自绘渲染管线上也能稳如老狗。我们项目在适配过程中经历了扫光闪烁、整体掉帧、甚至首帧直接白屏的现象,花了一周时间定位、重写渲染层、配合原生侧做帧同步,才把体验拉回来。这篇文章不聊虚的,直接把 shimmer_pro 鸿蒙适配的完整思路、关键代码、性能参数和踩坑记录摊开来讲,给同样要做 Flutter 鸿蒙兼容的团队一个可复用的参考路径。
1. 适配前的需求拆解:骨架屏到底在解决什么问题
1.1 骨架屏不是花架子,是体验的兜底方案
很多团队追求首屏速度,把精力全砸在接口耗时和缓存策略上,但网络再快也有空窗期,骨架屏恰恰是这个空窗期里能拿得出手的东西。它让用户感知到“页面正在加载内容”,而不是一片死白或转圈圈。从产品数据看,好的骨架屏能显著降低跳出率,尤其内容型应用,首屏有没有骨架,用户体感差异非常大。所以这个需求不是视觉彩头,是体验兜底。
shimmer_pro 在这类方案里属于“重动效”派。它不只画静态灰块,而是通过渐变扫光让骨架块动起来,模拟内容刷新时的流动感。这种动效做得好,用户会下意识觉得加载变快了,这是心理学上的“感知性能”优化。但“感知变快”的前提是动效本身稳定,一旦扫光卡顿,用户感知到的不是快,而是系统卡。骨架屏的价值因此和动效的稳定程度直接绑定,这也是为什么在鸿蒙适配时要格外较真渲染链路。
1.2 shimmer_pro 的核心能力与选型理由
选 shimmer_pro 而不是自己写,理由很直接:
- 内置多方向扫光:左到右、右到左、上到下、下到上,甚至自定义角度,覆盖绝大多数骨架场景。
- 参数化程度高:颜色、周期、曲线、渐变面积都能调,适配不同品牌的视觉风格。
- 与 Flutter 布局天然融合:直接包在任意 Widget 外层,不需要侵入业务布局。
我们项目的页面里有列表、卡片、详情页三种骨架形态,shimmer_pro 用统一的 API 就能覆盖,改造量小。但当时的选型优势,在后来的鸿蒙适配里变成了压力:深层的渐变渲染逻辑是库内部实现的,跨端出现问题后,改造面比预期大得多。建议所有团队在选型时把这一点提前摆上台面,热门库的适配风险和生态兼容性,应该作为选型评估的一个正式维度。
2. 鸿蒙侧渲染环境与 Flutter 桥接逻辑
2.1 Flutter 在鸿蒙上面的渲染路径
Flutter 在 Android 和鸿蒙上的渲染路径有一个核心差异:Android 侧 Flutter 直接通过系统级合成器输出,而鸿蒙环境下 Flutter 的绘制结果要先映射到鸿蒙的组件能力上,再由鸿蒙的渲染管线统一输出。这意味着 Dart 层创建的 Shader、DrawOp 等对象,都要经过一层桥接转换,性能损耗比 Android 略高,对高频动画更不友好。
shimmer_pro 默认用的ShaderMask会在每一帧创建一次LinearGradient的 Shader,这个操作在 Android 上可以接受,但在鸿蒙桥接层里会被放大,直接体现为 CPU 占用升高和帧时间抖动。我实测下来,同一段骨架动画在 Android 显示的平均帧时间约 8ms,在鸿蒙上直接涨到 13ms 左右,长帧次数翻倍。这种差距不是库本身的代码质量问题,而是跨平台渲染架构带来的默认成本。
2.2 工程搭建与依赖改造实战
第一步,先把 Flutter 鸿蒙侧的工程跑通。整体步骤分为四段:
- 准备鸿蒙开发工具链,创建支持 Flutter 的工程模板。
- 在原生工程里集成 Flutter 模块,配置依赖与入口,确认基础页面能加载。
- 创建一个空 Flutter 页面验证桥接是否正常,跑一个简单动画确认帧回调。
- 在业务工程里引入 shimmer_pro,编译并跑通默认效果。
跑通之后,下一步才是引三方库。我们当时直接执行依赖添加命令,编译没问题,但运行时就发现扫光明显不如 Android 流畅。这说明桥接层没问题,问题出在渲染实现上。如果你也在做同样的适配,我建议先不要急着改业务代码,而是用 shimmer_pro 自带的基础 Demo 页面做一个对照压测,把 Android、鸿蒙两端的帧数据先记录下来,后面每次优化都有基线可对。
提示:鸿蒙侧 Flutter 版本要跟官方 Flutter SDK 版本保持一致,否则三方库的 Dart 侧代码可能出现 API 兼容问题。先用一个最简页面跑通,再引库,能省下大量排查时间。
3. 核心实现:从 ShaderMask 到自定义 Painter
3.1 默认实现跑起来是什么效果
默认用法很简单,把骨架内容包一层 Shimmer 就行:
Shimmer.fromColors( baseColor: Color(0xFFE0E0E0), highlightColor: Color(0xFFF5F5F5), period: Duration(milliseconds: 1500), direction: ShimmerDirection.ltr, child: _buildSkeletonList(), )跑起来之后,动效方向、周期都没问题,但稳帧率测试显示帧时间毛刺明显,10 秒里出现 6 次超过 30ms 的长帧。这个问题在普通调试时不容易察觉,接上性能工具后一眼就看出来了。当时我把这个现象归结为“桥接层处理 ShaderMask 的额外开销”,后来验证猜想时,直接换掉 ShaderMask 再看数据,帧时间立刻回落,说明猜想方向是对的。
3.2 用 CustomPainter 重写扫光渲染
定位到ShaderMask的逐帧 Shader 创建是瓶颈后,我决定用CustomPainter接管扫光渲染。核心思路是:把渐变 Shader 的创建逻辑保留,但省掉ShaderMask的组件树层级和 mask 混合计算,让绘制路径更短。
class ShimmerOverlayPainter extends CustomPainter { ShimmerOverlayPainter({ required Animation<double> animation, required Color baseColor, required Color highlightColor, }) : super(repaint: animation); @override void paint(Canvas canvas, Size size) { final t = animation.value; final gradient = LinearGradient( begin: Alignment(-1 + 2 * t, 0), end: Alignment(1 + 2 * t, 0), colors: [baseColor, highlightColor, baseColor], stops: [0.35, 0.5, 0.65], ); final shader = gradient.createShader(Offset.zero & size); canvas.drawRect(Offset.zero & size, Paint()..shader = shader); } @override bool shouldRepaint(ShimmerOverlayPainter oldDelegate) => true; }这里我特意把stops设置在 0.35、0.5、0.65 三个点上,让高亮区集中在中部,营造出“扫光带”而不是“全屏渐变”的效果。这样改完后,Shader 每帧还是创建一次,但绘制链路短了,实测帧时间毛刺明显减少,掉帧次数从 10 秒 6 次降到 2 次左右。后面我把渐变方向、扫光范围、速度曲线也参数化,统一暴露给业务侧配置,原项目里用到的ShimmerDirection各方向都能映射过来。
3.3 多维动效:方向、曲线与节奏的组合
多维动效不单指方向,更多是“方向 + 时间曲线 + 周期性停顿”的组合效果。比如列表页用高频轻扫,详情页用慢速宽扫,底部操作面板用局部扫光。为了实现这些,我在自定义 Painter 里加入了速度曲线参数:
final curvedT = Curves.easeInOut.transform(t);再用CurveTween驱动,可以让扫光不是匀速移动,而是像真实光线一样有加速减速。搭配不同的周期,就能形成明显的节奏差异。这个多维组合在最终版本里成了主要卖点,页面不再是一种扫法走到底,而是根据模块自动切换动效强度。
实际使用中,我建议把动效参数收敛到一份配置表里,而不是散落在每个页面。比如列表页统一用 1200ms 周期加 easeInOut,详情页用 1800ms 加 easeIn,局部组件用 800ms 加 linear。这样产品侧要调整风格时,只需要改一个配置对象,不用逐个页面翻代码。
4. 性能调优:让扫光稳稳保持 60 帧
4.1 先量化,再优化
优化首要原则是量化。每次改动前,先记录以下指标:
- 平均帧时间
- 长帧(超过 16ms)占比
- CPU 使用率
- 内存峰值
我用的方法是固定一个测试页面,循环播放骨架动画 30 秒,采集数据后对比改动前后的差异。之前提到的 10 秒内 6 次长帧,就是在这种测试下发现的。采集工具方面,Dart 侧可以用debugProfilePaintsEnabled打开绘制 Profile,配合鸿蒙侧的 trace 工具抓取原生层耗时,双端数据对齐之后,才能判断瓶颈到底是 Dart 侧还是桥接层。
有个容易被忽略的细节:测试时手机屏幕亮度、系统后台进程数都会影响帧数据。建议同一台设备、同一亮度、同一时间段跑对比测试,否则数据抖动会让你误判优化效果。我一开始没有固定这些变量,前两轮改动的结论几乎被环境噪声掩盖。
4.2 四个立竿见影的优化手段
改完 Painter 后,我又做了四个优化,效果一个比一个直接:
- 缩小重绘区域:骨架页不是全屏都扫光,把 Painter 挂在真正需要动效的容器上,避免全屏重绘。
- 复用 Shader 对象:同一方向的渐变色在像素上没变化时,直接复用前帧 Shader,减少每帧的对象分配。
- 减少层级嵌套:原本为了视觉叠加套了多层组件,适配后全部压平,只保留必要的 Container 和 Painter 两层。
- 配合原生帧回调:让动画驱动绑定在鸿蒙侧的垂直同步信号上,而不是 Dart 的 Timer,保证动画节奏与系统刷新同步。
这四步做完,长帧占比从 20% 左右降到了 5% 以内,体感已经非常顺滑。其中第 4 步最容易被忽略,但影响最大。Dart 里默认的AnimationController在部分设备上会走定时器补偿路径,帧边界不精准,鸿蒙环境下手绘 Shader 动画对这种偏差尤其敏感。绑定原生帧回调之后,扫光的移动轨迹才真正做到“每一帧都在正确的位置”。
4.3 帧率和功耗的平衡
骨架屏动效本质上是持续动画,帧率越高不代表体验越好,功耗和发热也是要考虑的指标。我在实践中对骨架屏的动画开启了限帧策略:在页面快速滚动时降为 30 帧,在静止预览时维持 60 帧。这个策略通过一个简单的帧率切换逻辑实现,核心代码如下:
final ticker = createTicker((elapsed) { if (_isScrolling) { if (elapsed % 2 == 0) return; } setState(() => _t = elapsed / _periodMs); }); ticker.start();虽然代码很朴素,但效果很明显:发热体感下降,电池消耗减少,动效流畅度没有损失。如果你是做工具类应用,可以再激进一点,把页面不可见时的动画直接暂停,用TickerMode包裹骨架图层,省掉大量无意义的绘帧。
5. 常见问题与排查技巧实录
5.1 首帧白屏
项目第一次在鸿蒙真机上跑,骨架页首帧直接白屏,闪了一下才有内容。定位后发现是 Flutter 引擎首帧还没渲染完成,原生容器就先透出了白底。解决办法是让原生侧在 Flutter 首帧回调完成前,先盖一层跟骨架底色一致的占位层,首帧回调后再移除。这个方案不复杂,但需要在原生代码里监听 Flutter 的首帧渲染完成事件,注意不同版本的鸿蒙桥接 API 名称略有差异,写之前先查一下当前版本的事件名。
5.2 扫光条带感严重
部分低端测试机上,扫光区域能明显看到一条条色带,颜色过渡不自然。原因不是渐变算法问题,而是 Shader 精度在低端 GPU 上被压缩。处理方案是把渐变 stops 间距拉开,减少颜色过渡跨度,同时把 highlightColor 调亮一个等级,条带感就基本看不出来了。这里要注意,不同屏幕分辨率下同样的 stops 参数效果不同,最好在真机上多做几轮视觉验收,别只盯着模拟器看。
5.3 内存抖动与监听器泄漏
骨架动画跑久以后,内存曲线出现周期性波动。排查发现是动画监听器在页面销毁时没有及时移除,导致 Shader 对象被反复持有而无法释放。自己实现 Painter 后,我显式在shouldRepaint里做了 Shader 复用,并把不需要的动画监听器在页面销毁时移除,内存曲线就平了。这个坑在 Android 上也存在,只是鸿蒙侧回收机制不同,问题更明显。
我把遇到的三个问题整理成了一张速查表,后面适配团队可以直接照着排查:
| 问题现象 | 主要原因 | 处理建议 |
|---|---|---|
| 首帧白屏 | Flutter 引擎首帧未完成 | 原生侧加占位层 |
| 扫光条带感 | 低端 GPU Shader 精度不足 | 拉开渐变 stops,提高亮色 |
| 内存周期性波动 | Shader 未复用、监听器未释放 | 复用 Shader,清理监听器 |
这些只是我这次适配里碰到的高频问题。如果你的场景里出现其他异常,建议先抓原生侧的 trace,再看 Dart 侧监控,双端数据对比定位会快很多。
6. 统一封装与扩展方向
6.1 业务侧 API 设计
适配完成后,我没有让业务侧直接依赖自定义 Painter,而是封装了一层和 shimmer_pro 原版保持一致的 API。这样业务代码几乎不用改,只把引入的包从 shimmer_pro 替换成内部适配包即可。封装层内部根据平台自动选择渲染方式,Android 走原库,鸿蒙走自定义 Painter,这个策略让我后来接其他 Flutter 三方库时也有了参考模板。
封装时有几个细节值得注意:对外暴露的参数要尽量保留原库的命名,减少业务侧的学习成本;内部的平台判断不要用硬编码字符串散落在各文件里,集中放在一个配置类里,方便后续新增平台拓展;还有一点是要把默认参数对齐原库,这样才能保证替换包后出现问题时的排查路径一致。
6.2 后续可以继续扩展的方向
骨架屏这套方案还有两个可以玩的方向:一个是把扫光动效和深色模式联动,根据主题亮度自动调整渐变颜色,避免暗色模式下高亮色刺眼;另一个是结合数据预取,在页面真正拿到数据前提前渲染骨架,配合过渡动画无缝切换到真实内容。后者对体验的提升更明显,也是我下一步打算尝试的方向。
最后说一点个人体会。跨平台适配这件事,最难的不是写代码,而是建立一套可量化的性能验收流程。鸿蒙和 Android 的渲染路径不一样,单靠眼睛看流畅度完全不够。把帧时间、长帧占比这些指标固定下来,每次改动都用数据说话,适配的效率会高很多。这套方法同样适合其他 Flutter 三方库的鸿蒙改造,尤其是那些依赖自定义渲染的动画库,提前做基线压测,比出问题后再找原因要省力得多。