1. 从定位到“丝滑”:为什么 ProgressIndicator 值得单独写一篇
1.1 一个转圈组件背后的两种运行模式
这一篇轮到 ProgressIndicator,说实话在编这个系列目录的时候,我就知道它早晚得来。前面三十二篇拆了容器、按钮、文本、图片这些“明面组件”,大家上手一用就能看到效果,反馈也积极。但 ProgressIndicator 属于那种“平时不起眼,一动起来全是戏”的组件。
先给还不熟的读者垫个底。ProgressIndicator 在 Flutter 里是加载进度的总称,底下有两个亲儿子:
- LinearProgressIndicator:横向条形进度条,适合页面加载、文件上传、分步表单这类有明确“进度感”的场景。
- CircularProgressIndicator:圆形转圈或环形进度,适合按钮内loading、下拉刷新、局部内容加载。
这俩组件最核心的一个特点,是它们同时支持确定模式和不确定模式。什么叫确定?就是你给一个value,比如 0.6,它老老实实把进度画到 60%。什么叫不确定?就是你把value传成null,它不告诉你具体到哪一步了,而是用一条不停滑动的条纹或转圈来表达“我在忙,你等着”。这两种模式在 OpenHarmony 上的表现细节,和 Android/iOS 上有差异,也正是本文要花大篇幅讲透的东西。
1.2 第三十三篇才轮到它,排兵布阵的思路
肯定有人问,一个进度条而已,怎么拖到第三十三篇才写?
我的选型逻辑是这样:基础组件按“用户感知频率”排序。按钮、文本、图片、列表,用户天天直接上手点划,所以放在前面。而进度条这类反馈型组件,本身不承载内容,它只负责告诉用户“系统没死,正在干活”。可恰恰因为它是“反馈型”,它对帧率、动画曲线、异步时序的要求反而比普通组件更高。
前面那些篇目里已经铺垫过的概念,比如setState、FutureBuilder、AnimationController的基础用法,到了这一篇会全部串起来。所以如果你是从第一篇跟过来的,读到这里会有一种“终于要用上前面知识”的感觉;如果你是半路点进来的,也没关系,代码示例都是独立可跑的,你可以先把ProgressIndicator本身用起来,再去翻前面的动画章节。
我在跟几个做 OpenHarmony 应用移植的朋友聊天时发现,大家对 ProgressIndicator 的态度普遍是“能转就行”,很少有人认真研究它在 OpenHarmony 上是不是真的“丝滑”。这篇就用实际案例聊聊,为什么同一个组件,在标准 Flutter 上看起来没问题,暴力装机到 OpenHarmony 设备上就出现拉扯感、锯齿感,以及到底怎么治。
1.3 在 OpenHarmony 上谈“丝滑”,先得知道渲染链路怎么走
很多人一听到“丝滑”就想到动画曲线、想到 120fps,但忽略了一件事——组件最终渲染在什么渲染器上。
在 Android 上,Flutter 的 UI 是 SKIA 引擎绘制到 SurfaceFlinger;在 iOS 上走的是 Core Animation 那一套;而在 OpenHarmony 上,Flutter 组件最终是通过 Flutter 的 OHOS 适配层,把渲染指令投递到系统的图形栈。这意味着,你在 Material 组件里设置的动画曲线、抗锯齿效果、模糊半径,在不同平台上会被不同的底层实现接管。某些效果可能在 Android 上无缝,到了 OpenHarmony 上就出现奇奇怪怪的边缘锯齿、闪烁甚至整块区域重绘延迟。
标题里我把 ProgressIndicator 和“丝滑”绑在一起,不是修辞手法,是因为在 OpenHarmony 上让进度条真正跑顺,需要做几个具有平台针对性的动作,包括渲染边界隔离、动画曲线调优、避开重绘开销大的写法。这些具体操作,我放在第四大节里完整展开。先别急,我们先把组件本身的参数和行为吃透。
2. 核心参数拧明白:每个旋钮都对应一个使用场景
2.1 颜色与尺寸体系:Material 3 带来的参数变化
用 ProgressIndicator 第一件事就是配色。很多新手上来就踩坑:color和backgroundColor两个参数名太像了,经常搞混。我直接给结论:
color:进度条本身的颜色,也就是“走到哪了”的颜色。backgroundColor:轨道底色,是“还没走到”的区域。valueColor:这是一个Animation<Color?>类型的参数,用来做颜色渐变、呼吸变色等动态效果。如果你只是想要一个静态颜色,直接传color就好;想玩花的,再研究valueColor。
Material 3(Flutter 3.x 默认主题)下,组件的默认配色会跟随主题的colorScheme,比如主色调primary。如果你的应用用的是深色模式,建议显式设置backgroundColor,否则部分设备上轨道色与背景融合,用户根本看不出有进度条。我在 OpenHarmony 的深色模式下就遇到过类似情况,后来统一用colorScheme.surfaceContainerHighest作为轨道底色,对比度才正常。
林林总总的参数,我先用一张表把高频项列清楚,方便大家日后速查:
| 参数 | 适用组件 | 作用 | 使用注意 |
|---|---|---|---|
value | 两者通用 | 确定模式进度值,0.0~1.0 | 传入 null 则进入不确定模式 |
color | 两者通用 | 前景进度颜色 | 未设置时跟随主题 |
backgroundColor | 两者通用 | 轨道底色 | 深色模式下显式设置 |
minHeight | Linear | 条形高度 | 默认 4,可调成 6/8 更明显 |
borderRadius | Linear | 圆角 | 需要圆角尾部动画时使用 |
strokeWidth | Circular | 圆环粗细 | 默认 4,按钮内用 2~3 更精致 |
strokeCap | Circular | 端点形状 | 默认 butt,想圆润就设 round |
strokeAlign | Circular | 描边对齐方式 | 设成 0 左右做描边特效时需要 |
2.2 确定模式的 value:从 0 到 1 的数字游戏
确定模式下,value的取值范围是 0.0 到 1.0,超出范围直接报错(Debug 模式会红屏提示)。你要做的只有一件事:根据业务进度实时更新它。
举个例子。上传文件时拿到一个onProgress回调,里面是已上传字节数和总字节数,那你直接算:
double progress = received / total; if (progress > 1.0) progress = 1.0;这一步就把“不确定的数字”转化成了value能接受的进度值。很多人忽略的是:received / total在极端情况下会因为除零或类型转换问题出错,比如 total 为 0。所以我在工程里都会包一层判断:
double safeProgress(int received, int total) { if (total <= 0) return 0.0; return received.clamp(0, total) / total; }clamp是 Dart 列表和数值类都有的方法,这里用来限制 received 的范围避免计算出负数或超过 100% 的数值,一举两得。
确定模式在 OpenHarmony 上的坑主要在更新频率。你用Timer.periodic每隔 50 毫秒更新一次 value,很容易把 UI 线程吃掉,导致页面整体掉帧。后文第 4 节专门讲优化方案,这里先说一个原则:进度条视觉上能感知到变化即可,不要无脑追高帧率。50ms 一更新已经非常顺滑,再高基本是浪费。
2.3 不确定模式的动画机制:AnimationController 与曲线的默契
不确定模式(value: null)下,LinearProgressIndicator 会有一条约 1.5 秒周期的横条来回滑动;CircularProgressIndicator 则是一只不停旋转的“小菊花”。OpenHarmony 上默认的不确定动画其实已经比较顺滑,但它的节奏是固定的,无法调参。
如果你想要更自然的加载节奏,比如“先快后慢再快”的呼吸感,就得自己接管动画。实现思路是:
- 创建一个
AnimationController,时长拉长到 1800ms,循环播放。 - 用
TweenSequence把进度条拆成几段:0→0.7用快曲线,0.7→0.85用慢曲线,0.85→1.0用快曲线。 - 把这个 Tween 的取值赋给 ProgressIndicator 的
value。
代码底子我放在第 3 节实战部分,下面先讲清楚为什么这样做会“丝滑”。
默认不确定动画是重复模式,也就是一个固定步调无限循环。而真实世界里的加载反馈,用户心理预期是“刚开始快点,越接近完成越有点犹豫,然后一口气结束”,用曲线模拟这种节奏,会比匀速动画更让人舒适。这和Curves.easeInOut的哲学一脉相承——人类对匀速运动反而更敏感。
3. 五种高频场景的完整写法与代码落地
3.1 页面级加载骨架:FutureBuilder 与超时兜底
最常见的需求是:进入页面,拉数据,等待过程里给用户一个加载反馈。
暴力的做法是if (isLoading) return CircularProgressIndicator()。问题是每次setState都会重建整个页面,动画也会被打断,表现出来就是转圈一顿一顿的。正确做法是用FutureBuilder把加载状态包起来,让进度条只存在于自己的子树里:
FutureBuilder<List<Item>>( future: _loadItems(), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center( child: SizedBox( width: 36, height: 36, child: CircularProgressIndicator(strokeWidth: 3), ), ); } if (snapshot.hasError) { return ErrorView(error: snapshot.error); } return ListView.builder(...); }, )这里的const Center(...)是关键。ProgressIndicator 构造参数都是常量,加上const之后,Flutter 就不会在每次 build 时重新初始化组件,对性能有微小但真实的帮助。别小看这个细节,在 OpenHarmony 的低端设备上,减少无意义重建是“丝滑”的起点。
不过FutureBuilder有一个问题:如果接口长时间不返回,用户会一直盯着转圈。更专业的做法是加一个“超时兜底”,比如 8 秒后如果还在 loading,切换成“加载缓慢,是否重试”的提示。这里就不展开具体代码了,思路就是Future.timeout配合whenComplete,在 UI 层放一个状态切换。
3.2 按钮里的迷你转圈:配合禁用态防连点
现在很多 App 的登录、支付按钮,点击后会把文案替换成一个小转圈,同时禁掉按钮防止重复提交。在 Flutter 里实现也很直白:
ElevatedButton( onPressed: _isSubmitting ? null : _submit, child: _isSubmitting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2.5, color: Colors.white, ), ) : const Text('确认支付'), )这里有两个细节。
第一,onPressed要传null而不是() {}。传 null 会让按钮自动进入禁用态,视觉上变灰,加上CircularProgressIndicator白色转圈,整颗按钮就呈现出“我还在忙”的质感。
第二,转圈的尺寸。我用的是SizedBox包住,将转圈限制到 20×20,strokeWidth调成 2.5。如果不限制尺寸,默认 CircularProgressIndicator 会尽量撑满父布局,在按钮里会变成一个巨大的圈,非常难看。
第三,按钮禁用态的颜色在 Material 3 里默认会变成低对比度的灰。如果你希望按钮背景色保持不变、只有按钮不可点击,需要额外设置disabledBackgroundColor。这事我踩过坑,做深色模式适配时,灰色按钮配白色小转圈,视觉上像是按钮“消失”了,后来统一改成保持原背景色,再叠加一个半透明遮罩,效果就好多了。
3.3 环形百分比进度:Stack 叠加与断点续传
很多下载管理类页面喜欢用“圆环 + 中心百分比数字”的样式。实现方案是Stack叠一层 ProgressIndicator 和一层 Text:
Stack( alignment: Alignment.center, children: [ SizedBox( width: 120, height: 120, child: CircularProgressIndicator( value: _progress, strokeWidth: 6, backgroundColor: Colors.grey.shade200, ), ), Text( '${(_progress * 100).toStringAsFixed(0)}%', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), ), ], )注意,_progress在 0.0~1.0 之间,所以显示百分比时先乘 100 再四舍五入。这个写法在断点续传场景里很有用,因为你拿到的进度信息本来就是“已下载字节数 / 总字节数”。
这种环形进度条在 OpenHarmony 上最容易出的一个问题是锯齿。圆形边缘如果没做抗锯齿,看起来就像狗啃的。解决方式有两个:
- 给 ProgressIndicator 设置
strokeCap: StrokeCap.round,让端点变圆,整体边缘会柔和很多。 - 确保 ProgressIndicator 所在的图层没有复杂的半透明叠加,避免因为过度合成导致边缘发虚。
如果你用的是 Skia 渲染管线,环形分界面的锯齿有时来自相机缩放导致的浮点误差。一个取巧的办法是把进度条画大一点再缩放包一层,但会牺牲性能,非必要不建议。
3.4 列表加载更多:尾部 loading 与空态处理
无限加载列表里,用户滚到底后要给一个“正在加载更多”的反馈。惯用做法是:列表底部放一个固定高度的 widget,ListView.separated的itemCount多加一,返回LinearProgressIndicator或小菊花。
我一般用尾部转圈,因为列表底部空间有限,竖条形会顶起内容,让布局跳动。代码大概长这样:
if (hasMore) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5), ), ), ); }这里要避免一个错误:不要在build方法里直接触发“加载更多”的逻辑。加载更多的时机应该放在ScrollController的监听里,判断滚动位置接近底部时再去请求。如果放在 build 里,可能因为一次 build 就触发多次请求,导致进度条闪烁。
空态导演病:很多新人只处理了 loading 和 success,忘了“列表到底了但没有任何数据”的空态,此时转圈会在空态底下一直转。解决方式是请求完成后先判断data.isEmpty,返回“暂无内容”的占位组件,尾部 loading 只在data.isNotEmpty && hasMore时才显示。
这类状态机逻辑虽然不复杂,但写错时 bug 特别隐蔽,我一般在项目里定义一套enum LoadingStatus { loading, success, empty, error, hasMore }统一管理,比散落的布尔值可控得多。
3.5 仿启动闪屏:用进度过渡提升应用质感
最后一个场景是我的私藏玩法:仿桌面应用或伙伴应用的启动闪屏过渡。很多企业级 App 启动时会展示品牌 logo,等初始化完成后进入主界面。这段等待如果用僵硬的SizedBox撑着,用户会以为 App 卡死了;用一条绚丽的进度线,App 的高级感瞬间拉满。
实现思路是:
- 启动页只放一个 logo 和一条
LinearProgressIndicator。 - 用
AnimationController在 2 秒内把value从 0 带到 1。 - 动画结束时,通过路由替换切换到主页面。
关键代码:
controller.forward().whenComplete(() { Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => HomePage())); });注意,这里我不会真的等所有初始化完成才切页面,而是“假进度 + 真逻辑并行”。也就是启动初始化的异步任务和进度动画同时跑,谁慢等谁。如果你把“初始化完成”作为动画结束的唯一触发点,万一某个 init 方法卡了 5 秒,用户就看到进度条卡在 80% 不动,那种体验比“刚进 App 白屏”还糟糕。
我的方案是:动画用Interval限定在 40%~90% 之间跑,留出 10% 给真实的初始化完成信号。这样无论初始化快慢,视觉上都有一个比较平滑的氛围过渡。
4. “丝滑”专项优化:从 60fps 到不掉帧的实操路线
4.1 别让整棵树跟着转:RepaintBoundary 与 const 的妙用
“丝滑”这个词不能只看动画本身,还要看动画运行期间,页面上其他东西有没有跟着一起遭殃。
Flutter 的刷新机制是可重绘区域回合并。组件树里某个节点setState后,只有标记为 dirty 的子树会重新 build 和绘制。问题在于,如果你的页面结构写得不够精细,setState会把整个页面子树都标记成 dirty。进度条每 16ms 重绘一次,页面上其他静态 widget 也跟着做 diff、build、layout,这可不是件便宜的事。
解决办法是给进度条区域包一层RepaintBoundary:
RepaintBoundary( child: LinearProgressIndicator(value: _progress), )RepaintBoundary会创建一个独立的绘制图片层,重绘只在它自己内部进行,不会扩散到父级。性能分析工具里可以看到,加上之后进度条动画期间,CPU 占用明显下降。在我的一个实际项目里,优化前页面滚动加进度条同时启动,会掉到 20fps;包上RepaintBoundary后稳定在 60fps。
另外,前面强调过多次的const不仅在构造时省去重复初始化,在 build 阶段也能让 widget 的canEqual判断更快。两者配合,页面级 progress 动画就不会拖垮整体性能。
4.2 自定义 TweenSequence:让加载动画有呼吸感
如果只是“进度条在动”,那 Material 自带的不确定模式已经够了。真要做“丝滑”,我会用TweenSequence自己设计加载曲线。这里给出一段完整的自定义加载条代码:
AnimationController( vsync: this, duration: const Duration(milliseconds: 1800), )..repeat(); final tween = TweenSequence<double>([ TweenSequenceItem( tween: Tween(begin: 0.0, end: 0.7).chain(CurveTween(curve: Curves.easeOutCubic)), weight: 45, ), TweenSequenceItem( tween: Tween(begin: 0.7, end: 0.85).chain(CurveTween(curve: Curves.easeInOut)), weight: 30, ), TweenSequenceItem( tween: Tween(begin: 0.85, end: 1.0).chain(CurveTween(curve: Curves.easeInCubic)), weight: 25, ), ]);然后在 builder 里把这个 tween 的 value 喂给 ProgressIndicator。注意 weight 总和要等于 100,表示三段动画在总时长中的占比。这样跑出来的效果是:启动阶段飞速上涨,中段缓慢爬坡,最后冲刺完成。用户感知上会觉得“加载过程有节奏,不机械”。
其实很多“丝滑”是曲线调出来的,不是硬件性能多强。你去看那些世界级 App 的加载动效,基本都是几条曲线反复调参后的产物。我的习惯是一边调一边在真机上看,把Curves.fastOutSlowIn、easeInOutCubic、easeOutBack都试一遍,找到最符合产品气质的节奏。
4.3 真机上的 vsync 与渲染异常:OpenHarmony 平台要单独验证
提到“OpenHarmony 画面渲染异常”,这里有个绕不开的话题:不同图形渲染栈的差异。OpenHarmony 在图形栈上做了自己的合成策略,Flutter 的 UI 线程和渲染线程在同一帧上的调度,和 Android 上不完全一致。结果就是,同一个动画在 Android 模拟器上丝般顺滑,拿到 OpenHarmony 真机上,偶尔会抖动一下,尤其在设备负载高的时候。
我的排查思路是:
- 先用
flutter run --profile模式在真机上跑,用性能分析工具看帧率曲线,确认掉帧发生在 UI 线程还是栅格化线程。 - 如果掉帧在 UI 线程,优先检查有没有在 build 里做了耗时操作、有没有大列表没懒加载。
- 如果是栅格化线程跟不上,尝试简化动画图层,比如用
AnimatedBuilder只重建进度条,而不是让整个页面重绘。 - 如果确认是平台的渲染异常,考虑临时关掉一些高开销效果(比如模糊、阴影),看是否缓解。
这里强调一下,flutter run --profile模式会禁用 debug 断言但保留性能 profiling 能力,用它跑动画最能暴露真实性能瓶颈。我遇到过不少初学者直接在 debug 模式测性能,然后抱怨帧率太低,其实 debug 模式本身要做大量类型检查和断言,性能数据不具备参考价值。
4.4 给进度条加无障碍语义与降级方案
很多人忽略无障碍。如果你的 App 面向政企、教育这类对无障碍有要求的场景,进度条必须配上语义信息。
Flutter 里 ProgressIndicator 默认不朗读进度,需要包一层Semantics:
Semantics( label: '页面加载进度', value: '$_progressPercent%', child: LinearProgressIndicator(value: _progress), )这样屏幕阅读器就能读出“页面加载进度 60%”这样有意义的反馈。OpenHarmony 上的无障碍服务对 Semantics 的支持越来越完善,适配成本不高,建议一开始就加上。
降级方案是指:在某些低端 OpenHarmony 设备上,如果加载动画导致系统资源紧张,可以选择用静态文案替代动画加载。我的做法是在MediaQuery.of(context).accessibleNavigation为 true(即用户开启读屏模式)或设备性能评级偏低时,直接显示“加载中…”文字,省略动画。这个方案其实也回归了 ProgressIndicator 的语义本义——进度信息比动画本身更重要。
5. 常见问题速查表与真实踩坑实录
5.1 六条高频问题速查表
我在项目群和社区里帮人排查过很多 ProgressIndicator 相关问题,挑些高频的整理成表,方便大家遇到问题时直接对号入座:
| 症状 | 原因 | 解法 |
|---|---|---|
| 转圈不动了,像卡死 | 页面被跳转或mounted为 false 后动画还在跑 | TickerProviderStateMixin配合dispose时释放 controller |
| 进度条颜色跟主题不一致 | 没有显式设置 color,跟随主题 primary | 想固定颜色就传color,想跟随主题就手动读colorScheme |
| 不确定模式下条子跳来跳去 | 同一帧内多次 setState 导致 controller 抖动 | 用AnimationController.repeat而不是手动 setState |
| 按钮里转圈太大 | 默认 CircularProgressIndicator 尺寸撑满父级 | 用SizedBox包一层并固定宽高 |
| 进度值溢出报错 | value 计算出超过 1 或小于 0 | 做clamp(0.0, 1.0)兜底 |
| 深色模式轨道看不清 | 默认 backgroundColor 对比度不足 | 显式设置backgroundColor为主题 surface 色 |
5.2 我实际踩过的五个坑(全过程复盘)
先说最典型的一个:进度条卡在 80% 不动。那次是给某个管理后台做数据导出功能,导出进度回调有时会丢失最后一段数据。正常导出到 80%,再往后的回调迟迟不来,界面就一直卡在 80%,很尴尬。后来我在代码里加了超时兜底:如果 5 秒内没有新的进度回调,就强制把进度打到 100%,然后跳转结果页。虽然严格来说有点“自欺欺人”,但配合一个“正在生成文件…”的文案,用户体感反而更顺畅。
第二个:同一个页面同时有多个进度条,统一 setState 后互相干扰。当时是文件列表页,每个文件行都有一个小进度条。我用了一个全局的setState,导致所有行跟着重建,滚动时帧率惨不忍睹。后来改成ValueNotifier<double>+ValueListenableBuilder,每个进度条只监听自己的值,互不干扰,滚动也顺畅了。算是把前面说的RepaintBoundary思路落地到了组件层级。
第三个:环形进度条边缘锯齿。这个问题在 OpenHarmony 真机上特别明显。我一度以为是分辨率设置的锅,折腾半天最后发现是strokeCap没设置,默认的 butt 端点让弧线两端呈直角,放在圆形上就非常扎眼。改成StrokeCap.round后,视觉问题立刻消失。
第四个:ProgressIndicator 的动画和页面切场动画打架。页面 A 跳页面 B,A 的加载动画还没停下,B 已经开始推入动画,结果两个动画同时在 UI 线程上跑,低端设备直接掉到十几帧。解决思路是页面级路由切换前,先暂停或释放掉 A 页面里的 controller,转场完成后再继续。你可以用RouteAware这个 mixin 监听路由状态来做到。
第五个:在 OpenHarmony 上遇到的一次 building 时报错。新 clone 的工程里,flutter run时报了一个类似 “you are applying flutter's main gradle plugin imperatively using the apply script method” 的警告。这不是 ProgressIndicator 的问题,但属于 OpenHarmony 工程里常见的构建环境配置问题,顺手提一嘴。解决方式是升级 Flutter SDK 或调整工程构建脚本,一般情况下升级后警告就没了。如果你还没有配置 Flutter SDK 和 OpenHarmony SDK 的关联,建议先看看官方文档把环境打通再跑组件代码,不然会卡在最基础的环境环节上。
系列扩展:下一站往哪走
ProgressIndicator 讲到这里,基本覆盖了“是什么、怎么用、如何调优、出问题怎么查”。回到这篇的起点,如果你也正准备在 OpenHarmony 上做 Flutter 应用,我建议别只在模拟器里看效果,尽早弄一台真机跑跑,因为进度条这类反馈组件,恰恰是对平台差异最敏感的一类基础组件。实际跑一遍,再回来对照这篇里的性能优化点和坑,你会有更深体会。
一个小建议:在自己的项目里把进度条单独抽成可配置组件,暴露出颜色、尺寸、曲线、超时时间几个参数,将来换主题或适配其他设备时,改一处全都生效,能省很多重复工作。
下一篇会继续基础组件的篇章,已经收到不少朋友私信说要重点讲一讲图片加载与缓存策略,我看情况安排。
感谢你看到这里。如果这篇文章对你有帮助,欢迎在评论区给我留言交流,也欢迎分享你在 OpenHarmony 上调试进度条的心得和踩坑故事。