假如你给一个手偶小剧场当导演。幕后有一位舞台画家,他的任务是按照剧本在布景板上画房子、画月亮;你手里有一只节拍器,只要一拨,它就匀速地从 0 走到 1,让灯光渐渐亮起。Flutter 里的自定义绘制与动画,正是这位画家和这只节拍器的双人舞。CustomPainter 是那位画家,它在画布上自由作画;AnimationController 是那只节拍器,它只负责均匀地报出 0 到 1 的进度;而 Tween 和 Curve 是剧本里的“从矮到高”和“先快后慢”的走位说明。为了防止每次换背景都要搬空整个舞台,Flutter 还准备了 RepaintBoundary 隔板,把重绘挡在局部。今天我们就走进这个舞台,看一位画家如何跟着节拍,画出一张会动的数据图表。

岗位关联

对 AI 从业者来说,掌握这组工具意味着能在移动端与跨平台场景里,为模型指标、数据看板或 AI 生成内容打造流畅、有质感的可视化界面,让“数据会说话”这件事从后端走到用户眼前。

目录
  1. 可视化演示
  2. 画家角色定位
  3. 画布绘图手艺
  4. 动画节拍驱动
  5. 曲线插值艺术
  6. 重绘边界隔离
  7. 补间动画设计
  8. 图表绘制实战
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示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 本身不响应手势,也不会自动动画,它的世界只有静态的画面。

这种严格的审批机制,让舞台画家不会为每一次观众喧哗而浪费力气。

shouldRepaint(old)=(old.color≠color)∨(old.width≠width)\displaystyle \text{shouldRepaint}(\text{old}) = (\text{old.color} \neq \text{color}) \lor (\text{old.width} \neq \text{width})
换句话说,只要画笔的颜色或粗细有变化,舞台画家就会重新动笔;否则他就坐在旁边休息。
  1. 旧画家参数old 保存上一次的绘制配置,用来和当前 painter 对比。
    old\text{old}
  2. 颜色不等颜色变化意味着画面需要更新。
    old.color≠color\text{old.color} \neq \text{color}
  3. 线宽不等线条粗细变化同样是可见差异。
    old.width≠width\text{old.width} \neq \text{width}
  4. 或运算合并任意一个条件成立,都会触发重绘。
    ∨\lor

画布绘图手艺

画家的工具箱里有铅笔、圆规和曲线板;Canvas 就是 Flutter 发给这个工具箱。你可以在上面画直线、圆、矩形,也可以用 Path 把多段曲线缝合成一个复杂形状。

最常用的 API 有:drawLine 画两点之间的直线,drawCircle 画圆,drawRect 画矩形,drawOval 画椭圆,drawPath 则喂给一个 Path 对象,绘制任意轮廓。画文字也不能直接写,而是要先用 TextPainter 排版,再调用 textPainter.paint(canvas, offset)。Canvas 的坐标系向下为正,原点在左上角。

为了画出圆环或雷达图这类图形,经常需要根据角度计算坐标。Canvas 的 drawArc 接受 startAngle 和 sweepAngle 两个弧度参数;startAngle 默认从 x 轴正方向开始,如果希望从顶部起笔,就把 startAngle 设为 -π/2。

学会在画布上精准落笔之后,剩下的问题就是:怎么让这些图形自己动起来?轮到节拍器登场了。

x=cx+rcos⁡θ,y=cy+rsin⁡θ\displaystyle x = cx + r\cos\theta, \quad y = cy + r\sin\theta
这条公式告诉你:站在圆心,沿某个角度 θ 走半径 r 那么远,就能到达圆周上该角度的点。
  1. 圆心坐标cx 和 cy 是圆心在画布上的位置。
    cx,cycx, cy
  2. 半径缩放r 决定点到圆心的距离,可以理解为圆规的开口大小。
    rr
  3. 三角函数定位cosθ 和 sinθ 把角度分解成水平和竖直方向的分量。
    cos⁡θ, sin⁡θ\cos\theta, \ \sin\theta
  4. 坐标合成把两个分量加上圆心,就得到弧上点的坐标。
    x=cx+rcos⁡θ,y=cy+rsin⁡θx = cx + r\cos\theta, \quad y = cy + r\sin\theta

动画节拍驱动

如果画家每次都是凭空想象上一帧画了什么,那画面一定会抖动。于是剧场里多了一只节拍器,它从 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,在回调里刷新绘制层。

节拍器给了均匀的节奏,但真实的动画需要一点个性:先快后慢,或先慢后快。这就要请出曲线插值艺术家。

t=clamp(now−startduration, 0, 1)\displaystyle t = \text{clamp}\left(\frac{\text{now} - \text{start}}{\text{duration}},\ 0,\ 1\right)
节拍器把“已经过去了多久”除以“总时长”,再限制在 0 到 1 之间,得到当前拍子的进度。
  1. 流逝时间now 是当前时刻,start 是动画启动时刻,二者相减得到已过去的时间。
    now−start\text{now} - \text{start}
  2. 除以总时长把流逝时间归一化成 0~1 的线性进度。
    elapsedduration\frac{\text{elapsed}}{\text{duration}}
  3. 夹取边界超过 1 就停在 1,避免动画提前结束或越界。
    clamp(⋅, 0, 1)\text{clamp}(\cdot,\ 0,\ 1)

曲线插值艺术

节拍器只会均匀地滴答滴答,但舞台灯光如果是“啪”地一下全亮,会吓到观众;最好先慢,再突然亮起,最后缓缓收住。Curve 就是给灯光师准备的走位说明书。

CurvedAnimation 把 AnimationController 产生的线性 t 再映射成一个新的 t'。例如 Curves.easeOut 让开头快速、结尾缓慢;Curves.easeIn 相反;Curves.easeInOut 让两头慢中间快。你也可以实现 Curve 类的 transform 方法,定义自己的曲线。

为什么非线性更有质感?因为现实世界里的物体运动都有速度和加速度变化。匀速补间看起来像机器,缓动曲线则像真实的手、弹簧或风。图表入场动画常用 easeOutCubic,让柱子先拔地而起,再稳稳停住。

有了曲线,节拍器还是那个节拍器,但画家的笔已经有了感情。

t′=1−(1−t)3\displaystyle t' = 1 - (1 - t)^3
这是 easeOutCubic 的写法:t 越接近 1,t' 增长越缓慢,所以柱子会在后半程优雅地减速。
  1. 剩余进度1 - t 代表还没走完的路。
    1−t1 - t
  2. 立方放大三次方让剩余部分被压缩,导致前期变化快、后期变化慢。
    (1−t)3(1 - t)^3
  3. 反向取回用 1 减回去,得到修正后的进度。
    1−(1−t)31 - (1 - t)^3

重绘边界隔离

舞台很大,如果只挪动一盏灯,就让整个舞台包括天花板重新刷漆,那成本太高。Flutter 渲染层也有类似的苦恼:一个页面重绘时,默认会刷新受影响区域。RepaintBoundary 就像舞台上的防火隔板,把重绘限制在一个小隔间里。

在组件树中,RepaintBoundary 会创建一个独立的 layer。当用户拖动图表、滑动列表时,如果只有边界外的部分变化,边界内的图层不会被重新光栅化,而是直接复用上一帧的纹理。

但隔板也不是越多越好。每个 RepaintBoundary 都是一层额外的合成开销,如果每个 Text 都包一层,反而会比一次整页重绘更慢。常见实践是:把会高频更新的图表、动画图标、自定义绘制组件包起来,同时保证它不会频繁和父级发生交叠变换。

隔板放得聪明,观众只看到小范围变化,背后却省下了大量 CPU 和 GPU 的工作。

ratio=areachildarearoot\displaystyle \text{ratio} = \frac{\text{area}_{\text{child}}}{\text{area}_{\text{root}}}
子区域面积与整页面积的比值越小,RepaintBoundary 的收益越明显。
  1. 子区域面积被隔离的局部重绘区域。
    areachild\text{area}_{\text{child}}
  2. 整页面积没有隔离时可能要重绘的整个区域。
    arearoot\text{area}_{\text{root}}
  3. 面积比比值越小,节省的绘制工作越多。
    ⋅⋅\frac{\cdot}{\cdot}

补间动画设计

节拍器只会从 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 让节拍器变成了真正的编舞老师,任何数值都能跟着节奏舞动。

v=begin+(end−begin)×t\displaystyle v = \text{begin} + (\text{end} - \text{begin}) \times t
把进度 t 当成百分比,begin 加上“总跨度乘以百分比”,就是当前帧的动画值。
  1. 区间跨度end - begin 计算出总共要变化的量。
    end−begin\text{end} - \text{begin}
  2. 乘进度用 t 表示完成了多少比例。
    (end−begin)×t(\text{end} - \text{begin}) \times t
  3. 加起始值最后加上 begin,得到实际数值。
    begin+⋯\text{begin} + \cdots

图表绘制实战

当画家、节拍器和隔板都各就各位,我们就可以做点正事了:把枯燥的数据变成会动的图表。假设手头有一周每天的访问量,我们希望打开页面时,柱子像小树苗一样从地面长出来。

折线图的核心是数据映射:把业务数值转到画布像素坐标。纵轴方向是向下的,所以越大的值视觉上越高,y 反而越小。柱状图则是把每条柱子的高度和 Tween 的进度相乘,再在每一帧重绘。AnimationController 的 forward 启动入场动画,Curves.easeOut 让柱子上生长度先快后慢。

性能方面,把图表区域包裹在 RepaintBoundary 中,这样动画结束后,即使父级有按钮点击或状态刷新,图表层也不会被反复重绘。如果还要支持数据订阅实时刷新,可以让 controller.repeat() 并以数据到达的时间戳作为下一帧目标的起点,实现滚动监控曲线。

从数据到视觉,舞台画家的最后一笔让数字有了生命力;这只节拍器与画笔的组合,从此成为数据可视化里最趁手的工具。

y=paddingTop+(1−v−vmin⁡vmax⁡−vmin⁡)×plotHeight\displaystyle y = \text{paddingTop} + \left(1 - \frac{v - v_{\min}}{v_{\max} - v_{\min}}\right) \times \text{plotHeight}
这是最常见的数值到画布纵坐标的映射:先归一化,翻转坐标,最后缩放和平移。
  1. 归一化先把数据值缩放到 0~1 之间。
    v−vmin⁡vmax⁡−vmin⁡\frac{v - v_{\min}}{v_{\max} - v_{\min}}
  2. 翻转坐标由于 Canvas 的 y 轴向下,数据越大在视觉上越高,所以要取 1 减去结果。
    1−(⋅⋅)1 - \left(\frac{\cdot}{\cdot}\right)
  3. 缩放和偏移乘以绘图区高度,再加上顶部留白。
    paddingTop+⋯×plotHeight\text{paddingTop} + \cdots \times \text{plotHeight}

AI 应用场景

模型训练监控仪表盘

阶段:模型训练与可视化监控

解决的问题:传统方式用终端打印 loss 数值,用户很难快速看出训练是否收敛;AI 平台需要流畅更新的动态图表。使用 CustomPainter 绘制损失曲线,AnimationController 控制曲线滚动,RepaintBoundary 隔离高频刷新,整页不卡顿。

具体案例:训练一个推荐模型时,监控面板每秒接收多个 loss 值,用柱状图实时渲染新柱子,旧柱子通过 Tween 向左平滑位移。动画中的曲线像舞台灯光一样平滑推进,用户一眼就能判断损失是否还在下降。

AI 生成图表的动态渲染

阶段:AI 辅助前端开发

解决的问题:大模型根据自然语言生成 JSON 图表配置后,前端需要一套强大的渲染引擎把配置变成图形。CustomPainter 用一份代码同时在移动端和桌面上绘制折线图和柱状图,AnimationController 提供统一的入场动画,替代传统的 GIF 或 CSS 动画。

具体案例:用户说“画一个今年销售额的增长趋势”,大模型输出 {x: ['1月'...], y: [...]},Flutter 客户端解析 JSON 后交给 CustomPainter 绘制动态折线图,动画结束后自动停止控制器节省电量。

Python 演示

python70 行
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)
目的:用纯 Python 模拟 Flutter 的 AnimationController、Tween 与 CustomPainter,让读者看清“节拍器驱动进度、补间映射数值、画家按帧绘制柱状图”的完整链路。
  1. controller = AnimationController(duration=2.0)创建节拍器,设定动画总时长 2 秒。
  2. tween = Tween(begin=0.0, end=1.0)创建从 0 到 1 的补间区间。
  3. t = controller.tick()询问节拍器当前走到哪个进度。
  4. progress = ease_out_cubic(tween.lerp(t))把线性进度套上缓动曲线,让柱子上长先快后慢。
  5. height = int(raw * progress * 20)每个数据点按进度映射为终端里柱子的高度。
  6. self.frames.append(bar_line)保存当前帧,之后统一打印形成动画效果。
  7. 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 中,想要自定义绘制形状,正确的做法是继承哪个类?

单选题 · easy

  • A.继承 CustomPainter← 正确答案
  • B.继承 StatefulWidget
  • C.继承 StatelessWidget
  • D.继承 ImageProvider

答案:A。CustomPainter 是 Flutter 提供的自定义绘制入口,必须实现 paint 和 shouldRepaint。StatefulWidget 管理状态,StatelessWidget 是静态组件,ImageProvider 负责图片加载,它们都不负责在画布上绘制。

Q2. 当 shouldRepaint 返回 false 时,CustomPainter 会发生什么?

单选题 · mid

  • A.paint 会被跳过← 正确答案
  • B.整个页面会崩溃
  • C.动画会自动停止
  • D.画笔颜色会失效

答案:A。shouldRepaint 返回 false 表示旧 painter 与新 painter 的绘制结果没有可见差异,Flutter 会跳过 paint 调用并复用旧渲染结果。这就像舞台画家确认剧本没变,便安心休息,页面不会崩溃,动画也不会因此停止。

Q3. 关于 AnimationController 的说法,正确的有哪些?

多选题 · mid

  • 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 的主要作用是什么?

单选题 · mid

  • A.替代 AnimationController 管理时间
  • B.把线性进度映射为曲线进度← 正确答案
  • C.让动画值自动变成负数
  • D.绕过 Tween 直接修改颜色

答案:B。CurvedAnimation 包装一个 AnimationController,把线性的 t 变成曲线 t',让动画快慢更有层次。它不负责时间管理,也不改变数值方向或颜色,因此 A、C、D 都不正确。

Q5. RepaintBoundary 的核心作用是什么?

单选题 · easy

  • A.强制整页刷新
  • B.把重绘限制在局部图层← 正确答案
  • C.拦截所有触摸事件
  • D.缓存界面所有图片

答案:B。RepaintBoundary 会为子组件创建独立的渲染图层,父级重绘时不会连带着重绘内部。它不强制刷新、不处理触摸事件、也不缓存图片,核心就是“局部重绘隔离”。

Q6. 以下哪些属于 Canvas 绘制图形的常用 API?

多选题 · mid

  • 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) 后,动画的行为是?

单选题 · hard

  • 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 时动画值是多少?

单选题 · mid

  • 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 性能优化实践中,正确的做法有哪些?

多选题 · hard

  • A.shouldRepaint 返回 false 可以跳过重复绘制← 正确答案
  • B.RepaintBoundary 可以隔离局部重绘← 正确答案
  • C.用 save 和 restore 管理坐标变换← 正确答案
  • D.每帧都创建全新的 Paint 对象最佳

答案:ABC。shouldRepaint 精确返回 false 能避免无谓重绘;RepaintBoundary 隔离高频局部更新;save/restore 能安全还原坐标系,是标准做法。而每帧创建新 Paint 只会产生额外垃圾对象,维护复用更高效,所以 D 错误。

Q10. 在 Flutter 中,AnimationController 的 tick 回调会做什么?

单选题 · extreme

  • A.重建整个 Widget 树
  • B.更新 value 并通知监听者← 正确答案
  • C.重新执行 main 函数
  • D.强制所有 RepaintBoundary 重绘

答案:B。每个 tick 到来时,AnimationController 根据时间轴更新自己的 value,然后通知所有注册的 Listener。Widget 树的重建由上层组件决定,main 函数不会重跑,RepaintBoundary 也不会被无条件强制重绘。节拍器只负责“报数”,至于谁关心这个数字,由监听者自己决定。