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这是一个AnimationColor?类型的参数用来做颜色渐变、呼吸变色等动态效果。如果你只是想要一个静态颜色直接传color就好想玩花的再研究valueColor。Material 3Flutter 3.x 默认主题下组件的默认配色会跟随主题的colorScheme比如主色调primary。如果你的应用用的是深色模式建议显式设置backgroundColor否则部分设备上轨道色与背景融合用户根本看不出有进度条。我在 OpenHarmony 的深色模式下就遇到过类似情况后来统一用colorScheme.surfaceContainerHighest作为轨道底色对比度才正常。林林总总的参数我先用一张表把高频项列清楚方便大家日后速查参数适用组件作用使用注意value两者通用确定模式进度值0.0~1.0传入 null 则进入不确定模式color两者通用前景进度颜色未设置时跟随主题backgroundColor两者通用轨道底色深色模式下显式设置minHeightLinear条形高度默认 4可调成 6/8 更明显borderRadiusLinear圆角需要圆角尾部动画时使用strokeWidthCircular圆环粗细默认 4按钮内用 2~3 更精致strokeCapCircular端点形状默认 butt想圆润就设 roundstrokeAlignCircular描边对齐方式设成 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把加载状态包起来让进度条只存在于自己的子树里FutureBuilderListItem( 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×20strokeWidth调成 2.5。如果不限制尺寸默认 CircularProgressIndicator 会尽量撑满父布局在按钮里会变成一个巨大的圈非常难看。第三按钮禁用态的颜色在 Material 3 里默认会变成低对比度的灰。如果你希望按钮背景色保持不变、只有按钮不可点击需要额外设置disabledBackgroundColor。这事我踩过坑做深色模式适配时灰色按钮配白色小转圈视觉上像是按钮“消失”了后来统一改成保持原背景色再叠加一个半透明遮罩效果就好多了。3.3 环形百分比进度Stack 叠加与断点续传很多下载管理类页面喜欢用“圆环 中心百分比数字”的样式。实现方案是Stack叠一层 ProgressIndicator 和一层 TextStack( 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 与空态处理无限加载列表里用户滚到底后要给一个“正在加载更多”的反馈。惯用做法是列表底部放一个固定高度的 widgetListView.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这可不是件便宜的事。解决办法是给进度条区域包一层RepaintBoundaryRepaintBoundary( 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 TweenSequencedouble([ 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 默认不朗读进度需要包一层SemanticsSemantics( 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导致所有行跟着重建滚动时帧率惨不忍睹。后来改成ValueNotifierdoubleValueListenableBuilder每个进度条只监听自己的值互不干扰滚动也顺畅了。算是把前面说的RepaintBoundary思路落地到了组件层级。第三个环形进度条边缘锯齿。这个问题在 OpenHarmony 真机上特别明显。我一度以为是分辨率设置的锅折腾半天最后发现是strokeCap没设置默认的 butt 端点让弧线两端呈直角放在圆形上就非常扎眼。改成StrokeCap.round后视觉问题立刻消失。第四个ProgressIndicator 的动画和页面切场动画打架。页面 A 跳页面 BA 的加载动画还没停下B 已经开始推入动画结果两个动画同时在 UI 线程上跑低端设备直接掉到十几帧。解决思路是页面级路由切换前先暂停或释放掉 A 页面里的 controller转场完成后再继续。你可以用RouteAware这个 mixin 监听路由状态来做到。第五个在 OpenHarmony 上遇到的一次 building 时报错。新 clone 的工程里flutter run时报了一个类似 “you are applying flutters main gradle plugin imperatively using the apply script method” 的警告。这不是 ProgressIndicator 的问题但属于 OpenHarmony 工程里常见的构建环境配置问题顺手提一嘴。解决方式是升级 Flutter SDK 或调整工程构建脚本一般情况下升级后警告就没了。如果你还没有配置 Flutter SDK 和 OpenHarmony SDK 的关联建议先看看官方文档把环境打通再跑组件代码不然会卡在最基础的环境环节上。系列扩展下一站往哪走ProgressIndicator 讲到这里基本覆盖了“是什么、怎么用、如何调优、出问题怎么查”。回到这篇的起点如果你也正准备在 OpenHarmony 上做 Flutter 应用我建议别只在模拟器里看效果尽早弄一台真机跑跑因为进度条这类反馈组件恰恰是对平台差异最敏感的一类基础组件。实际跑一遍再回来对照这篇里的性能优化点和坑你会有更深体会。一个小建议在自己的项目里把进度条单独抽成可配置组件暴露出颜色、尺寸、曲线、超时时间几个参数将来换主题或适配其他设备时改一处全都生效能省很多重复工作。下一篇会继续基础组件的篇章已经收到不少朋友私信说要重点讲一讲图片加载与缓存策略我看情况安排。感谢你看到这里。如果这篇文章对你有帮助欢迎在评论区给我留言交流也欢迎分享你在 OpenHarmony 上调试进度条的心得和踩坑故事。
