前阵子给一个银行客户做App首页改版需求清单里有一项写着“公告轮播无限循环不能看到回弹”。我心想这不就是个跑马灯嘛一行Text加个AnimationController十分钟搞定。结果真写起来才发现跑马灯这三个字背后全是细节文本宽度要不要动态测、循环边界怎么做到肉眼无感、动画在页面不可见时还转不转、到了鸿蒙设备上字体测量会不会和你较劲。这篇文章把我在Flutter里实现跑马灯无极滚动算法时踩过的坑和最终的方案完整复盘一遍给准备自己做这块的同学一个能直接落地的参考也聊聊鸿蒙适配里那些文档不会写的点。1. 跑马灯需求与主流实现的真实差距1.1 业务里常见的跑马灯长什么样跑马灯在我的项目里出现频率比我预想的高得多。银行App首页的滚动公告、券商行情条里的滚动报价、电商活动页的中奖名单、直播间里的礼物墙甚至连视频弹幕的横向漂移底层思路都跟跑马灯是一回事。它们表面上都是“文字从左往右或者从右往左不停移动”但业务方提需求时往往还会带上几个附加条件不能有跳变、不能有回弹、滚动速度要均匀、内容超出一屏才滚动、用户点了要能暂停。这些附加条件才是真正的分水岭。多数人最开始做跑马灯就是一个AnimationController从0到1再用Transform.translate把Text往左推推到末尾就让它从右边界重新出来。这个方案的观感是“消失一条再冒出一条”中间有明显的视觉断层。业务方看一眼就会说不对我要的是无缝无限循环。这就是我标题里说的“无极滚动”——滚动过程没有起点、没有终点、没有肉眼可以察觉的循环边界。1.2 现成插件和简单实现为什么总差一口气社区里搜flutter marquee能翻出一堆现成包。我试过其中一个比较流行的日常用用还行一旦碰上动态数据就会露馅公告标题长度每天都在变有些文本短到不足一屏有些文本长到要滚十几秒插件往往只处理“内容超出容器”这一种理想情况对文本宽度和容器宽度的关系判断得模棱两可。还有几个包用的是Timer或者Future循环setState每帧都重建整个Widget树CPU占用感人页面一发热就开始掉帧。我自己也写过一版基于ScrollController的外面套一个ListView监听滚动offset滚到maxScrollExtent就jumpTo(0)。这个方法最大的问题是“露馅”滚动到末尾后突然跳回起点有经验的用户一眼就能看出整个公告重新加载了一遍。而且ListView本身是为复杂列表设计的放到跑马灯场景里既笨重又浪费内存。真正解决问题的关键是换一种思考方式不要问“怎么滚回去”而要问“怎么让滚回去这个动作从视觉上消失”。1.3 无极滚动算法的三个硬指标我给自己定了一个验收标准满足这三点才算过关无缝任意时刻暂停画面当前帧和下一周期的同一时刻帧视觉像素完全一致找不到循环切点。匀速位移随时间线性增长不加速、不减速、不卡顿。跑马灯在首尾加缓动曲线是个常见的错误反而会把用户的注意力引到循环点上。节能滚动动画不能每帧重建文本、不能大范围触发layout。纯Dart动画应该只走paint层。下面要讲的方案就是围绕这三个指标展开的。2. 双份内容位移重置无极滚动算法的核心拆解2.1 想清楚循环点在哪里比写代码更重要先说结论真正的无缝循环不需要“判断到没到末尾再跳回”而是把同样一份内容连续排两遍让位移在一个固定周期内完成周期结束的瞬间第二份内容刚好顶到第一份内容原本的位置此时把位移重置为0用户盯着屏幕看看到的像素排列没有发生任何变化。拆开说。把两个内容完全相同的Text放进同一个Row中间留一个gapRow [Text A][gap][Text B]其中Text A和Text B的文本、字体、字号、颜色全都一样。让整个Row从初始位置向左平移dx。当dx从0走到-(textWidth gap)时Text B恰好移动到Text A一开始占据的位置。因为A、B渲染结果完全一致此刻把dx重置为0让Text A重新出现在起始位置用户看到的画面和重置前一瞬间是连续的。这个“重置点”就是循环闭点肉眼不可感知。用一个生活化的类比两本一模一样的书并排放在桌上你从左边开始往右看当第一本完全超出视野时第二本恰好递补到同一个位置。你从头到尾看到的都是同一本“书”只是你不知道第二本已经悄悄进来了。等第二本读完你再从第一本重新读起也一样察觉不到。2.2 文本宽度测量不要用估算值也不要相信第一次测量这个方案唯一需要的前置参数是textWidth。它直接决定循环周期长度测量不准就会导致跳变点不在视觉衔接处整个无缝效果白搭。我的建议是用GlobalKey挂到第一个Text上等首帧渲染完成后取RenderBox.size.width。为什么不用TextPainterTextPainter在纯静态场景下测出来的宽度确实足够精准但它默认不考虑环境的textScaler系统字体缩放、默认字体家族和个别平台的字体fallback规则。GlobalKey拿到的是文本真正渲染出来的宽度最贴近用户实际看到的画面。但这里有个坑如果项目里用了自定义字体字体文件是异步加载的第一帧渲染用的还是fallback字体宽度是错的。即使没有自定义字体鸿蒙、Android、iOS三套系统的默认字体度量也不同。我的处理方式是在字体加载完成的回调里重新测量一次并更新动画周期。具体到Flutter里用FontLoader加载自定义字体后拿到Future再setState重测没有自定义字体的项目至少要在addPostFrameCallback之后再测。测量时机还有一个容易被忽视的细节不要把测量放在initState里那个阶段BuildContext还没有完成布局拿到的size一定是0或null。老老实实放到WidgetsBinding.instance.addPostFrameCallback里等这一帧画出去之后再去取。2.3 完整代码一个够用的InfiniteMarquee下面这个组件是我在实际项目里一直沿用的版本去掉了跟业务耦合的部分只保留跑马灯核心。它接受文本、字号、颜色、间距和滚动速度默认文本宽度小于容器宽度时不滚动。import package:flutter/material.dart; class InfiniteMarquee extends StatefulWidget { const InfiniteMarquee({ super.key, required this.text, this.fontSize 14, this.fontWeight FontWeight.normal, this.color const Color(0xFF333333), this.gap 60, this.speed 40, this.pauseOnTap true, }); final String text; final double fontSize; final FontWeight fontWeight; final Color color; final double gap; // 两条文本之间的视觉间距 final double speed; // 每秒滚动的逻辑像素 final bool pauseOnTap; override StateInfiniteMarquee createState() _InfiniteMarqueeState(); } class _InfiniteMarqueeState extends StateInfiniteMarquee with SingleTickerProviderStateMixin { late final AnimationController _controller; final GlobalKey _textKey GlobalKey(); double _textWidth 0; double _totalWidth 0; bool _shouldScroll false; override void initState() { super.initState(); _controller AnimationController( vsync: this, duration: const Duration(seconds: 1), ); } override void didChangeDependencies() { super.didChangeDependencies(); // 每次依赖变化比如字体加载完成触发重建都优先安排一次测量 WidgetsBinding.instance.addPostFrameCallback((_) _measureAndStart()); } void _measureAndStart() { final ctx _textKey.currentContext; if (ctx null) return; final size ctx.size; if (size null || size.width 0) return; final newTextWidth size.width; if (newTextWidth _textWidth) return; final constraints ctx.findRenderObject()?.parent; final containerWidth constraints is RenderBox ? constraints.size.width : 0.0; setState(() { _textWidth newTextWidth; _totalWidth newTextWidth widget.gap; _shouldScroll containerWidth 0 || newTextWidth containerWidth; }); if (!_shouldScroll) { _controller.stop(); _controller.value 0; return; } final durationMs (_totalWidth / widget.speed * 1000).round(); _controller ..stop() ..duration Duration(milliseconds: durationMs) ..forward(from: 0) ..repeat(); } void _togglePause() { if (!widget.pauseOnTap) return; if (_controller.isAnimating) { _controller.stop(); } else if (_shouldScroll) { _controller.repeat(); } } override void dispose() { _controller.dispose(); super.dispose(); } override Widget build(BuildContext context) { final style TextStyle( fontSize: widget.fontSize, fontWeight: widget.fontWeight, color: widget.color, ); return GestureDetector( behavior: HitTestBehavior.opaque, onTap: _togglePause, child: ClipRect( child: AnimatedBuilder( animation: _controller, builder: (context, child) { final dx _shouldScroll ? -_controller.value * _totalWidth : 0.0; return Transform.translate( offset: Offset(dx, 0), child: child, ); }, child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(widget.text, key: _textKey, style: style), SizedBox(width: widget.gap), Text(widget.text, style: style), ], ), ), ), ); } }用的时候很简单外层给它一个宽度约束即可SizedBox( width: 320, child: InfiniteMarquee( text: 尊敬的客户我行将于本周六系统升级期间部分服务暂停敬请谅解。, speed: 50, gap: 80, ), )2.4 边界情况短文本、空文本和动态数据代码里有几处边界处理是我自己吃过大亏后补上的。第一文本宽度短于容器宽度时不滚动。这个判断很多人不做结果就是一句短短的“欢迎光临”也在那里永无止境地转用户看了会觉得很蠢。判断方法我在代码里写得很清楚拿到Text的RenderBox后再拿父级的RenderBox宽度两者比较。如果容器宽度拿不到比如直接放在无约束环境里我的策略是保守地让它转起来总比不转好。第二空文本和纯空白文本需要拦截。Text()虽然不会影响宽度测量但Row里塞一个空Text会让_textWidth变成0_totalWidth变成gap动画行为变得没意义。我在_measureAndStart里判断一下widget.text.trim().isEmpty为空就直接返回一个静态SizedBox最省事。第三动态修改文本内容时didChangeDependencies不一定触发。如果父组件直接setState换了个更长或更短的text最稳妥的办法是在didUpdateWidget里比较新旧text如果变了就重置控制器和测量值。要养成这个习惯否则文本已经变了跑马灯还在按旧宽度计算循环周期无缝就碎了。3. 从能用到好用交互细节与多方向扩展3.1 点击暂停、悬停暂停与手势冲突银行客户的需求里有一条很常见“用户点一下公告要能停下来看再点一下继续”。这就是我在组件里加pauseOnTap的原因。实现起来无非是stop和repeat的切换但有一个交互细节容易踩坑跑马灯容器通常和公告页跳转的点击事件是叠加的GestureDetector的onTap是全局的一旦用户想点公告详情手指按下去就会先把滚动停掉这就没问题。但如果父层还有横向滑动手势比如跑马灯嵌在PageView里你的onTap会和PageView的滑动抢手势表现就是点击偶尔失灵。我的处理方式是跑马灯本身只接管tap事件不做拖拽识别如果业务需要点击跳转就通过onTap回调抛给外部不要在跑马灯内部同时监听多个手势。另外桌面端或Web端可以考虑加MouseRegion鼠标悬停时暂停移开后继续。这个体验很高级成本却很低return MouseRegion( onEnter: (_) _controller.stop(), onExit: (_) { if (_shouldScroll) _controller.repeat(); }, child: GestureDetector( onTap: _togglePause, child: ClipRect(...), ), );3.2 为什么要坚持匀速而不是加缓动很多人写动画时习惯性加上Curves.easeInOut觉得这样更顺滑。跑马灯恰恰相反“无极”感的核心是“恒定速度”。如果一段公告在滚动周期开头很慢、中间加速、结尾又慢下来减速逼近下一个循环点用户反而会意识到“哦它到头了又要从头开始了”。匀速滚动给大脑的暗示是这条内容一直在源源不断地流过来没有尽头。当然也有例外。如果你做的是类似“弹幕飘屏”那种从右侧出现、移动到左侧消失的效果可以对每条弹幕分别用线性动画每条弹幕的“出现”和“消失”其实天然就是循环点这时候加一点透明度渐入渐出是合理的。但那是普通跑马灯不是无极滚动别搞混。3.3 从横向到纵向公告、列表和弹幕都能用同一套算法这套“双份内容位移重置”的算法换个方向就是纵向跑马灯常见于直播间的礼物墙、榜单滚动。把Row改成Column把dtx换成dty测宽度换成测高度循环周期变成textHeight gap。纵向版本有一个需要注意的地方高度测量要考虑到文本换行。横向跑马灯我们默认是单行不换行的Text的宽度就是所有字符的累计宽度纵向跑马灯通常要限定容器宽度让Text在容器宽度内自然换行再用GlobalKey去取换行后的总高度。这里不能用TextPainter直接layout(maxWidth: 容器宽度)因为TextPainter不会考虑父级Text的软换行策略。我吃过一次亏后来统一用RenderBox测出来的值。多行组合场景也很常见跑马灯上方放一行主标题下方放一行副标题两者速度不同形成层次感。这里可以共用一个AnimationController也可以用两个独立控制器。我在实际项目里试过共用同一个controller但速度比不同副标题会有对不齐的感觉视觉上很怪。建议每个跑马灯独立控制器各管各的互不干扰。4. 鸿蒙设备适配跑马灯在鸿蒙上的实测要点4.1 鸿蒙上跑Flutter的工程接入现状标题里的“鸿蒙”放到开发场景里很多人第一反应是“Flutter到底能不能跑鸿蒙”。这里先把事实说清楚鸿蒙生态上跑Flutter应用当前主要路径是使用OpenHarmony社区的Flutter适配分支来编译Flutter引擎和Dart运行时再把产物打包进鸿蒙工程。也就是说Flutter业务代码本身不需要重写功过得失都在最底层的引擎那一层。跑马灯这种纯Dart组件理论上是不需要改业务代码的。但我在真机调试时遇到的问题几乎都集中在“渲染差异”和“生命周期映射”上。这跟跨平台开发的老话一致代码能运行≠表现一致每个平台都会在某个犄角旮旯给你上眼药。4.2 字体、文本测量与渲染差异鸿蒙默认字体是HarmonyOS Sans跟Android的Roboto、iOS的SF Pro在相同fontSize下度量完全不同。同一个字符串在Android上测得宽度200px在鸿蒙上可能变成215px。我的跑马灯方案对文本宽度极其敏感宽度差几像素都会让循环闭点出现微小的错位。解决方法是双保险。第一项目中如果对品牌字体有强制要求就明确设置fontFamily不要依赖系统默认字体第二测量宽度必须用真实渲染后的RenderBox而不是编码时拍脑袋的估算值。我在鸿蒙上实测下来只要字体度量变了跑马灯就会在循环点出现轻微抖动排查半天发现就是默认字体不同导致的。重新测量之后立即恢复丝滑。还有一个字形渲染的坑鸿蒙上中文字符的fallback规则和Android不一样生僻字、带emoji的公告尤其明显。跑马灯的Text如果包含emoji宽度测量会把emoji算成一个宽字符但实际渲染时emoji的宽度可能跟测量值不符。最稳妥的做法是公告类内容过滤emoji或者至少不要让它出现在跑马灯里。4.3 生命周期映射页面不可见时动画必须停鸿蒙的前后台切换和Android不完全等价Flutter层的AppLifecycleState回调在鸿蒙适配分支上有自己的映射方式。我见过同事写的跑马灯在Android上切后台就会暂停因为Android能正确触发inactive或hidden到了鸿蒙上切换后台后AnimationController还在疯狂跑CPU占用直接顶上去。正确做法是不依赖任何单个平台的隐式行为统一在WidgetsBindingObserver里处理class MarqueeLifecycleObserver with WidgetsBindingObserver { final AnimationController controller; MarqueeLifecycleObserver(this.controller) { WidgetsBinding.instance.addObserver(this); } override void didChangeAppLifecycleState(AppLifecycleState state) { if (state AppLifecycleState.resumed) { controller.repeat(); } else { controller.stop(); } } void dispose() { WidgetsBinding.instance.removeObserver(this); } }这样无论将来跑在哪个平台行为都是一致的。鸿蒙适配分支今天的生命周期映射和未来的新版本可能又不一样自己兜底才是最有安全感的。4.4 真机上见到的字体闪烁和整段重绘问题鸿蒙真机调试时我遇到过一个很反常的现象跑马灯在滚动过程中文字边缘偶尔会出现轻微闪烁尤其在页面刚打开、字体文件还在加载时最明显。排查到最后是一个很基础的东西——RepaintBoundary。跑马灯的Transform.translate每一帧都在产生新的位图如果上层没有RepaintBoundary做隔离它周围的其他UI元素也会跟着被反复合成渲染压力一大闪烁就来了。在跑马灯组件外层包一个RepaintBoundary能显著降低合成开销。此外不要在跑马灯Text上叠加阴影、模糊等每帧都会变化的绘制效果这些效果会让GPU合成成本翻倍。用ShaderMask或ImageFilter驱动文字漂移是最糟糕的方案能用Transform解决的永远不要引入额外的绘制层。5. 性能复盘与项目落地清单5.1 为什么我最后没选ScrollController方案我在前面提过最早版本用的是ScrollControllerListView后来整个推翻了。复盘下来有三个理由。第一ListView/Scrollable是为“用户主动滚动”设计的它有一套滚动物理机制包括惯性、回弹、overscroll。这些机制在跑马灯场景里不是助力而是负担你想让内容匀速、精确、控制器化地移动Scrollable却在底下偷偷给边界加效果。第二maxScrollExtent依赖布局完成动态文本会反复触发layout对性能不友好。第三长文本在ListView里的item复用机制会让内存占用高于简单Row加Transform的组合。纯Dart的AnimationControllerTransform.translate方案动画帧只改一个offset值不触发任何layout测量工作全在首帧做完后续每一帧都是纯粹的绘制这是跑马灯该有的性能模型。5.2 从fps抖动到稳定流畅的优化记录我把跑马灯嵌在一个大型首页里时出现过几次肉眼可见的卡顿。用Flutter的PerformanceOverlay一看帧时间反复横跳。逐步排查后做了几个改动效果显著。第一把AnimatedBuilder的重建范围缩小到最内层。不要把Text直接放在builder里反复重建而是把静态的Row作为child传给AnimatedBuilderbuilder只负责计算位移并包裹Transform。具体看我上面的代码child: Row(...)是外置的builder里只动offset。这个细节能省掉大量无谓的Widget重建。第二给Text加isComposited相关的隔离。最好直接在组件外层包RepaintBoundary让跑马灯的合成层和页面其他内容隔离开。这样一个跑马灯的重绘不会波及整个页面。第三减少透明度动画和阴影。在跑马灯场景里渐变、模糊、阴影都是奢侈品。业务方要的是清晰滚动的文字不是特效大片。我实测的优化效果在同样一台骁龙中端机上优化前首页整帧时间约20-25ms优化后稳定在8-12ms。跑马灯本身的开销其实非常小。5.3 我在项目中坚持的落地规则经验积累到最后我会给团队发一份检查清单凡是接跑马灯需求的人都先过一遍检查项说明文本宽度是否短于容器宽度是则静态展示不滚动是否在帧回调后测量宽度避免拿0值或首帧错误宽度自定义字体是否已加载完成未完成前测量是无效的需要重测是否监听页面生命周期前后台切换时停/启动画避免空转是否使用Transform而非setState每帧setState会让整棵树重建必须避免是否用ClipRect/RepaintBoundary裁剪控制绘制边界防止全页面合成开销多跑马灯是否各自独立控制共用控制器会导致速度绑定视觉僵硬这套规则跟着我经历了三四个App版本迭代目前还没有客户再回来抱怨公告栏“跳一下”“转着转着停住”之类的问题。最后再分享一个小技巧如果你想让跑马灯的公告供给端切换文字时也保持无缝可以把新的文本先塞进组件但动画不停等下一个循环周期再让新文本自然露出来。这样用户在切换瞬间看到的还是旧文本的尾部新文本从右侧沿着同一条轨道滑入视觉上像一条公告滑过去接住另一条公告观感比生硬替换不知道好多少。这个顺手做的细节好几个客户都专门提过“你们这个公告栏效果很舒服”。跑马灯不大但把每一处视觉闭环打磨到位用户是能感受到的。