假如你给一个手偶小剧场当导演。幕后有一位舞台画家,他的任务是按照剧本在布景板上画房子、画月亮;你手里有一只节拍器,只要一拨,它就匀速地从 0 走到 1,让灯光渐渐亮起。Flutter 里的自定义绘制与动画,正是这位画家和这只节拍器的双人舞。CustomPainter 是那位画家,它在画布上自由作画;AnimationController 是那只节拍器,它只负责均匀地报出 0 到 1 的进度;而 Tween 和 Curve 是剧本里的“从矮到高”和“先快后慢”的走位说明。为了防止每次换背景都要搬空整个舞台,Flutter 还准备了 RepaintBoundary 隔板,把重绘挡在局部。今天我们就走进这个舞台,看一位画家如何跟着节拍,画出一张会动的数据图表。
对 AI 从业者来说,掌握这组工具意味着能在移动端与跨平台场景里,为模型指标、数据看板或 AI 生成内容打造流畅、有质感的可视化界面,让“数据会说话”这件事从后端走到用户眼前。
可视化演示
展示Flutter Canvas绘图API、动画曲线插值(Lin/eIO/Bnc)、RepaintBoundary重绘隔离及Tween补间动画机制,含星座网络背景和发光动画。
本节配有交互动效(进入视口后自动加载,约 3KB)。
画家角色定位
剧场的舞台后面总有一位画家,他不需要负责售票和灯光,只需要对着那块布景板专心作画。Flutter 里的 CustomPainter 就是这位画家:它唯一的职责是在一块矩形区域内绘制形状、颜色和线条。你只需要继承 CustomPainter,然后告诉它画什么,Flutter 会在合适的时机把它请上台。
继承 CustomPainter 时,必须实现两个方法:paint(Canvas canvas, Size size) 和 shouldRepaint(CustomPainter oldDelegate)。paint 是画家动笔的地方,size 告诉你画板多大;shouldRepaint 则是画家的工作审批单,让旧 delegate 和新属性作比较,如果发现外观真变了,就返回 true,让系统重画。
在实际代码中,我们通常会用一个 CustomPaint widget 挂在组件树中,把 painter 作为参数传进去。为了让画笔更细腻,还会创建 Paint 对象:设置 color 颜色、strokeWidth 线宽、style 是描边还是填充。CustomPainter 本身不响应手势,也不会自动动画,它的世界只有静态的画面。
这种严格的审批机制,让舞台画家不会为每一次观众喧哗而浪费力气。
- 旧画家参数old 保存上一次的绘制配置,用来和当前 painter 对比。
- 颜色不等颜色变化意味着画面需要更新。
- 线宽不等线条粗细变化同样是可见差异。
- 或运算合并任意一个条件成立,都会触发重绘。
画布绘图手艺
画家的工具箱里有铅笔、圆规和曲线板;Canvas 就是 Flutter 发给这个工具箱。你可以在上面画直线、圆、矩形,也可以用 Path 把多段曲线缝合成一个复杂形状。
最常用的 API 有:drawLine 画两点之间的直线,drawCircle 画圆,drawRect 画矩形,drawOval 画椭圆,drawPath 则喂给一个 Path 对象,绘制任意轮廓。画文字也不能直接写,而是要先用 TextPainter 排版,再调用 textPainter.paint(canvas, offset)。Canvas 的坐标系向下为正,原点在左上角。
为了画出圆环或雷达图这类图形,经常需要根据角度计算坐标。Canvas 的 drawArc 接受 startAngle 和 sweepAngle 两个弧度参数;startAngle 默认从 x 轴正方向开始,如果希望从顶部起笔,就把 startAngle 设为 -π/2。
学会在画布上精准落笔之后,剩下的问题就是:怎么让这些图形自己动起来?轮到节拍器登场了。
- 圆心坐标cx 和 cy 是圆心在画布上的位置。
- 半径缩放r 决定点到圆心的距离,可以理解为圆规的开口大小。
- 三角函数定位cosθ 和 sinθ 把角度分解成水平和竖直方向的分量。
- 坐标合成把两个分量加上圆心,就得到弧上点的坐标。
动画节拍驱动
如果画家每次都是凭空想象上一帧画了什么,那画面一定会抖动。于是剧场里多了一只节拍器,它从 0 匀速走到 1,提醒所有人“现在是第几拍”。AnimationController 就是 Flutter 的节拍器。
AnimationController 的 value 默认在 0.0 到 1.0 之间变化。创建时需要传入 duration 和 vsync。forward() 让动画从当前值向 1 前进,reverse() 让它掉头回 0,repeat() 可以让动画循环播放。每次 tick 不仅更新 value,还会通知所有监听者。
vsync 是 TickerProvider,它能告诉引擎:如果这个动画当前不在屏幕中(比如页面被切走),就暂停打拍子,从而省电。这个细节是 Flutter 高性能动画体验的一部分。监听 value 通常配合 AnimatedBuilder 或 addListener,在回调里刷新绘制层。
节拍器给了均匀的节奏,但真实的动画需要一点个性:先快后慢,或先慢后快。这就要请出曲线插值艺术家。
- 流逝时间now 是当前时刻,start 是动画启动时刻,二者相减得到已过去的时间。
- 除以总时长把流逝时间归一化成 0~1 的线性进度。
- 夹取边界超过 1 就停在 1,避免动画提前结束或越界。
曲线插值艺术
节拍器只会均匀地滴答滴答,但舞台灯光如果是“啪”地一下全亮,会吓到观众;最好先慢,再突然亮起,最后缓缓收住。Curve 就是给灯光师准备的走位说明书。
CurvedAnimation 把 AnimationController 产生的线性 t 再映射成一个新的 t'。例如 Curves.easeOut 让开头快速、结尾缓慢;Curves.easeIn 相反;Curves.easeInOut 让两头慢中间快。你也可以实现 Curve 类的 transform 方法,定义自己的曲线。
为什么非线性更有质感?因为现实世界里的物体运动都有速度和加速度变化。匀速补间看起来像机器,缓动曲线则像真实的手、弹簧或风。图表入场动画常用 easeOutCubic,让柱子先拔地而起,再稳稳停住。
有了曲线,节拍器还是那个节拍器,但画家的笔已经有了感情。
- 剩余进度1 - t 代表还没走完的路。
- 立方放大三次方让剩余部分被压缩,导致前期变化快、后期变化慢。
- 反向取回用 1 减回去,得到修正后的进度。
重绘边界隔离
舞台很大,如果只挪动一盏灯,就让整个舞台包括天花板重新刷漆,那成本太高。Flutter 渲染层也有类似的苦恼:一个页面重绘时,默认会刷新受影响区域。RepaintBoundary 就像舞台上的防火隔板,把重绘限制在一个小隔间里。
在组件树中,RepaintBoundary 会创建一个独立的 layer。当用户拖动图表、滑动列表时,如果只有边界外的部分变化,边界内的图层不会被重新光栅化,而是直接复用上一帧的纹理。
但隔板也不是越多越好。每个 RepaintBoundary 都是一层额外的合成开销,如果每个 Text 都包一层,反而会比一次整页重绘更慢。常见实践是:把会高频更新的图表、动画图标、自定义绘制组件包起来,同时保证它不会频繁和父级发生交叠变换。
隔板放得聪明,观众只看到小范围变化,背后却省下了大量 CPU 和 GPU 的工作。
- 子区域面积被隔离的局部重绘区域。
- 整页面积没有隔离时可能要重绘的整个区域。
- 面积比比值越小,节省的绘制工作越多。
补间动画设计
节拍器只会从 0 数到 1,但画家需要知道柱子要长到多高、圆要从多大变到多大。Tween 就是剧本上的对照表:把节拍器的 0~1 翻译成任何你想用的数值区间。
Tween(begin: 0, end: 100).animate(controller) 会返回一个 Animation\<double>,当 controller.value 等于 0.5 时,动画值就是 50。Tween 不只支持 double,还可以给颜色、偏移甚至自定义对象做插值,方法是重写 lerp。
在复杂动画里,还会链式组合 Tween:先让图标淡入,再让图表上升,用 Interval 控制每个片段发生的时间段。反向播放时不需要新建 Tween,只要调用 controller.reverse() 或 repeat(reverse: true) 就能顺滑地下落。
Tween 让节拍器变成了真正的编舞老师,任何数值都能跟着节奏舞动。
- 区间跨度end - begin 计算出总共要变化的量。
- 乘进度用 t 表示完成了多少比例。
- 加起始值最后加上 begin,得到实际数值。
图表绘制实战
当画家、节拍器和隔板都各就各位,我们就可以做点正事了:把枯燥的数据变成会动的图表。假设手头有一周每天的访问量,我们希望打开页面时,柱子像小树苗一样从地面长出来。
折线图的核心是数据映射:把业务数值转到画布像素坐标。纵轴方向是向下的,所以越大的值视觉上越高,y 反而越小。柱状图则是把每条柱子的高度和 Tween 的进度相乘,再在每一帧重绘。AnimationController 的 forward 启动入场动画,Curves.easeOut 让柱子上生长度先快后慢。
性能方面,把图表区域包裹在 RepaintBoundary 中,这样动画结束后,即使父级有按钮点击或状态刷新,图表层也不会被反复重绘。如果还要支持数据订阅实时刷新,可以让 controller.repeat() 并以数据到达的时间戳作为下一帧目标的起点,实现滚动监控曲线。
从数据到视觉,舞台画家的最后一笔让数字有了生命力;这只节拍器与画笔的组合,从此成为数据可视化里最趁手的工具。
- 归一化先把数据值缩放到 0~1 之间。
- 翻转坐标由于 Canvas 的 y 轴向下,数据越大在视觉上越高,所以要取 1 减去结果。
- 缩放和偏移乘以绘图区高度,再加上顶部留白。
AI 应用场景
模型训练监控仪表盘
阶段:模型训练与可视化监控
解决的问题:传统方式用终端打印 loss 数值,用户很难快速看出训练是否收敛;AI 平台需要流畅更新的动态图表。使用 CustomPainter 绘制损失曲线,AnimationController 控制曲线滚动,RepaintBoundary 隔离高频刷新,整页不卡顿。
具体案例:训练一个推荐模型时,监控面板每秒接收多个 loss 值,用柱状图实时渲染新柱子,旧柱子通过 Tween 向左平滑位移。动画中的曲线像舞台灯光一样平滑推进,用户一眼就能判断损失是否还在下降。
AI 生成图表的动态渲染
阶段:AI 辅助前端开发
解决的问题:大模型根据自然语言生成 JSON 图表配置后,前端需要一套强大的渲染引擎把配置变成图形。CustomPainter 用一份代码同时在移动端和桌面上绘制折线图和柱状图,AnimationController 提供统一的入场动画,替代传统的 GIF 或 CSS 动画。
具体案例:用户说“画一个今年销售额的增长趋势”,大模型输出 {x: ['1月'...], y: [...]},Flutter 客户端解析 JSON 后交给 CustomPainter 绘制动态折线图,动画结束后自动停止控制器节省电量。
Python 演示
import mathimport time class AnimationController: """模拟 Flutter AnimationController,只负责产生 0~1 的进度""" def __init__(self, duration=1.0): self.duration = duration self.start_time = None self.progress = 0.0 def forward(self): self.start_time = time.time() def tick(self): if self.start_time is None: return self.progress elapsed = time.time() - self.start_time t = elapsed / self.duration if t > 1.0: t = 1.0 self.progress = t return self.progress class Tween: """模拟 Flutter Tween,把 0~1 进度映射到任意数值区间""" def __init__(self, begin, end): self.begin = begin self.end = end def lerp(self, t): return self.begin + (self.end - self.begin) * t def ease_out_cubic(t): return 1 - math.pow(1 - t, 3) class ChartPainter: """模拟 CustomPainter,按进度绘制一帧柱状图""" def __init__(self, data): self.data_list = data self.frames = [] def paint(self, progress): heights = [] for raw in self.data_list: height = int(raw * progress * 20) heights.append(height) bar_line = " ".join("#" * h + "-" * (20 - h) for h in heights) self.frames.append(bar_line) def run(self): controller = AnimationController(duration=2.0) tween = Tween(begin=0.0, end=1.0) controller.forward() while True: t = controller.tick() progress = ease_out_cubic(tween.lerp(t)) self.paint(progress) if t >= 1.0: break time.sleep(0.1) return self.frames if __name__ == "__main__": painter = ChartPainter([0.3, 0.7, 0.5]) for frame in painter.run(): print(frame)
controller = AnimationController(duration=2.0)创建节拍器,设定动画总时长 2 秒。tween = Tween(begin=0.0, end=1.0)创建从 0 到 1 的补间区间。t = controller.tick()询问节拍器当前走到哪个进度。progress = ease_out_cubic(tween.lerp(t))把线性进度套上缓动曲线,让柱子上长先快后慢。height = int(raw * progress * 20)每个数据点按进度映射为终端里柱子的高度。self.frames.append(bar_line)保存当前帧,之后统一打印形成动画效果。if __name__ == "__main__":保证脚本可以直接运行,输出 20 帧柱状图画面。
常见误区
误区我每次在 state 里更新数据后调用 setState,CustomPainter 的 paint 方法一定会重新执行。
正解setState 会重建 widget,但 Framework 会先比较新旧 CustomPaint 的 painter。只有 painter 不是同一个引用且 shouldRepaint 返回 true,才会真正调用 paint。就像观众起哄不会让舞台画家动笔,只有剧本确实被改了他才出手。
为何常见:大多数人把 setState 理解为“强制刷新一切”,忽略了 Flutter 的 diff 机制。
误区想做一个动画,就开一个 Timer 每隔 16ms setState 一次,视图就会动。
正解正确做法是用 AnimationController 驱动 value,再通过 AnimatedBuilder 或 addListener 更新绘制层。controller 会准确跟随屏幕刷新率,并且页面不可见时能自动暂停,Timer 则无法感知屏幕刷新,容易卡顿或浪费电量。
为何常见:初学者对状态管理理解不深,认为只有 setState 才能改 UI,其实动画是独立的时间轴系统。
误区既然 RepaintBoundary 可以隔离重绘,那把每个按钮、每段文字都包一层,页面性能一定起飞。
正解每个 RepaintBoundary 都会增加一个独立图层,图层越多,合成时的 GPU 压力越大。使用原则是:只给高频更新、且不跟周围频繁交叠的组件加。图表可以包,静态标题没必要。
为何常见:性能优化规则被极端化,忘了任何隔离都有开销。
误区Tween(begin: 0, end: 100) 好像只能让数字变,颜色、位置做不了。
正解Tween 是泛型类,Tween\<Color>、Tween\<Offset> 以及自定义对象都可以。需要为自定义类型提供 lerp 实现,或者使用 ColorTween 等现成子类。节拍器不在乎你翻译成什么,只负责给 0~1 的进度。
为何常见:示例代码大多用 double 演示,容易让人以为 Tween 只能处理数值。
误区CurvedAnimation 和 AnimationController 差不多,可以单独 forward/repeat。
正解CurvedAnimation 只是包装并映射 parent 的 progress,本身不能开启或暂停动画。它更像给节拍器的节拍设定“脚步落法”,而不是另一个节拍器。
为何常见:看到 CurvedAnimation 里也有 Animation 字样,误以为它是控制器。
面试题
Q1. 在 Flutter 中,想要自定义绘制形状,正确的做法是继承哪个类?
- A.继承 CustomPainter← 正确答案
- B.继承 StatefulWidget
- C.继承 StatelessWidget
- D.继承 ImageProvider
答案:A。CustomPainter 是 Flutter 提供的自定义绘制入口,必须实现 paint 和 shouldRepaint。StatefulWidget 管理状态,StatelessWidget 是静态组件,ImageProvider 负责图片加载,它们都不负责在画布上绘制。
Q2. 当 shouldRepaint 返回 false 时,CustomPainter 会发生什么?
- A.paint 会被跳过← 正确答案
- B.整个页面会崩溃
- C.动画会自动停止
- D.画笔颜色会失效
答案:A。shouldRepaint 返回 false 表示旧 painter 与新 painter 的绘制结果没有可见差异,Flutter 会跳过 paint 调用并复用旧渲染结果。这就像舞台画家确认剧本没变,便安心休息,页面不会崩溃,动画也不会因此停止。
Q3. 关于 AnimationController 的说法,正确的有哪些?
- A.默认 value 范围是 0 到 1← 正确答案
- B.需要 vsync 来防止离屏动画浪费电量← 正确答案
- C.forward 只会让动画永远循环
- D.duration 定义了动画的时长← 正确答案
答案:ABD。AnimationController 默认 value 范围是 0.0 到 1.0;vsync 用于绑定 TickerProvider,让动画离开屏幕时自动暂停;duration 控制动画总时长。forward() 默认播放到 1 就停止,只有 repeat() 才会循环,所以 C 错误。
Q4. CurvedAnimation 的主要作用是什么?
- A.替代 AnimationController 管理时间
- B.把线性进度映射为曲线进度← 正确答案
- C.让动画值自动变成负数
- D.绕过 Tween 直接修改颜色
答案:B。CurvedAnimation 包装一个 AnimationController,把线性的 t 变成曲线 t',让动画快慢更有层次。它不负责时间管理,也不改变数值方向或颜色,因此 A、C、D 都不正确。
Q5. RepaintBoundary 的核心作用是什么?
- A.强制整页刷新
- B.把重绘限制在局部图层← 正确答案
- C.拦截所有触摸事件
- D.缓存界面所有图片
答案:B。RepaintBoundary 会为子组件创建独立的渲染图层,父级重绘时不会连带着重绘内部。它不强制刷新、不处理触摸事件、也不缓存图片,核心就是“局部重绘隔离”。
Q6. 以下哪些属于 Canvas 绘制图形的常用 API?
- A.drawLine← 正确答案
- B.drawCircle← 正确答案
- C.drawImage← 正确答案
- D.drawTextView
答案:ABC。Canvas 提供 drawLine、drawCircle、drawImage 等基础绘制方法。Flutter 里文字绘制不是 drawTextView,而是用 TextPainter 排版后再调用 painter.paint(canvas, offset),因此 D 错误。
Q7. 执行 controller.repeat(reverse: true) 后,动画的行为是?
- A.正向循环无限次
- B.反向播放一次后停止
- C.正反交替无限循环← 正确答案
- D.repeat 不能传 reverse 参数
答案:C。repeat(reverse: true) 会让动画先正向走到 1,再反转回到 0,如此交替循环,形成类似钟摆的效果。它不会只播一次,也不会只正向循环,且 repeat 本身支持 reverse 参数。
Q8. 执行 Tween(begin: 0, end: 100).animate(controller) 后,controller.value 为 0.5 时动画值是多少?
- A.等于 0.5
- B.等于 50← 正确答案
- C.等于 100
- D.等于 25
答案:B。Tween 把 0~1 的进度线性映射到 begin~end。当进度为 0.5 时,动画值 = 0 + (100 - 0) × 0.5 = 50。这正是舞台剧本里“走到一半就该长到一半高”的含义。
Q9. CustomPainter 性能优化实践中,正确的做法有哪些?
- A.shouldRepaint 返回 false 可以跳过重复绘制← 正确答案
- B.RepaintBoundary 可以隔离局部重绘← 正确答案
- C.用 save 和 restore 管理坐标变换← 正确答案
- D.每帧都创建全新的 Paint 对象最佳
答案:ABC。shouldRepaint 精确返回 false 能避免无谓重绘;RepaintBoundary 隔离高频局部更新;save/restore 能安全还原坐标系,是标准做法。而每帧创建新 Paint 只会产生额外垃圾对象,维护复用更高效,所以 D 错误。
Q10. 在 Flutter 中,AnimationController 的 tick 回调会做什么?
- A.重建整个 Widget 树
- B.更新 value 并通知监听者← 正确答案
- C.重新执行 main 函数
- D.强制所有 RepaintBoundary 重绘
答案:B。每个 tick 到来时,AnimationController 根据时间轴更新自己的 value,然后通知所有注册的 Listener。Widget 树的重建由上层组件决定,main 函数不会重跑,RepaintBoundary 也不会被无条件强制重绘。节拍器只负责“报数”,至于谁关心这个数字,由监听者自己决定。