去年我在做鸿蒙端适配的时候,碰到一个挺尴尬的需求:设计稿里放了一张带透视旋转的商品卡片,转起来要像真纸片在桌上翻滚一样。我第一反应是“完了,这得上 3D 引擎,或者干脆让原生那边写个控件嵌入 Flutter”。后来仔细翻了一遍 Flutter 的能力边界,发现根本不用那么折腾——Transform 组件背后那个 Matrix4,就是干这个的。而且这套东西写完之后,Android、iOS、鸿蒙三端跑出来的效果几乎完全一致,不需要为了某一个平台单独写原生逻辑。
如果你平时写 Flutter 页面,主要停留在 Container、Row、Stack 这些布局层面,那 Matrix4 可能是你离“高级动效”最近的一扇门。它本质就是一串 16 个 float 数字,但通过这一串数字,你能让控件平移、旋转、缩放、倾斜,甚至实现真正的 3D 透视——近大远小、卡片翻转、视差跟随,全都能在纯 Dart 层面搞定。这篇文章我准备把 Matrix4 从原理到实战拆开讲一遍,重点放在 3D 透视视效上,顺便把鸿蒙适配时的几个隐藏坑也一起说了。适合已经会写基本 Flutter 页面、但还没系统接触过矩阵变换的开发者阅读。
1. 别被 16 个数字吓住:Matrix4 就是在描述“坐标怎么走”
1.1 为什么 UI 变换要扯上线性代数
很多人一听到矩阵就头皮发麻,觉得这是三维图形学才需要的东西。实际上 Flutter 里你对控件做的所有变换——移动、旋转、缩放、倾斜——都可以统一表达成一个坐标映射:输入一个点 (x, y, z),经过某个规则,输出一个新的点 (x', y', z')。矩阵就是这套规则最紧凑的写法。
那为什么偏偏是 4x4,而不是 3x3?因为 3x3 矩阵虽然能表达三维空间里的线性变换,但表达不了平移——平移本质上是加法,不是乘法。三维图形学里有个非常经典的处理方式,就是给坐标增加一个额外维度,把三维坐标写成 (x, y, z, 1),用 4x4 矩阵同时把旋转、缩放、平移、透视全部装进一次乘法里。这个技术叫齐次坐标,Matrix4 里的 4,就是齐次坐标里那个多余的维度。
你可以把 Matrix4 想象成一张“坐标改写的说明表”:告诉 Flutter,屏幕上的每个像素点应该从原来的位置挪到哪里去。理解了这一点,后面所有操作都围绕一件事——怎么修改这 16 个数字,让像素按你想要的方式乖乖移动。
1.2 storage 数组的排列顺序:最容易踩的第一个坑
Matrix4 内部的数据存储在一个长度为 16 的 Float64List 里,通过storage属性可以访问。很多人拿到这 16 个数字之后,第一个困惑是:到底哪个数字控制 x 方向移动,哪个数字控制旋转?
这里有个特别容易搞混的点:storage数组是列主序排列的。也就是说storage[0]、storage[1]、storage[2]、storage[3]是矩阵的第一列,而不是第一行。我见过不少同事直接把数学课上“行主序”的习惯带进来,结果矩阵写出来效果完全不对,还没法排查。
Matrix4 identity = Matrix4.identity(); print(identity.storage); // [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]单位矩阵的 storage 看上去就是对角线上是 1,其余全是 0。在列主序里,前三列的前三行分别对应 x 轴、y 轴、z 轴方向的变换基向量,第四列的前三个数字则直接控制平移量。这个位置关系理解了,看代码时才有方向感。
另外要注意,Matrix4.setEntry(row, col, value)的参数顺序是行在前、列在后,和storage的索引关系是:storageIndex = col * 4 + row。很多教程直接让你修改storage[11]来实现透视,其实storage[11]对应的就是第 2 列第 3 行。如果你用setEntry(3, 2, value),那对应的是第 2 列第 3 行,索引同样是 11,但语义完全不同。我在下文的透视章节会专门说这个位置,建议你两种写法都试一下,亲眼看看效果差异。
1.3 单位矩阵:什么都不做,才是一切变换的起点
Matrix4.identity() 返回单位矩阵,它的意义是“对坐标不做任何修改”。所有变换操作都应该从它开始,然后逐步叠加,最终赋值给 Transform 的 transform 属性。这就像画画前先铺一张白纸,不能一上来就东一笔西一笔。
Transform( transform: Matrix4.identity(), child: Container(width: 100, height: 100, color: Colors.blue), )上面这段代码运行后,蓝色方块一动不动。接下来我们要做的所有事情,都是在这个“白纸”上不断加规则,让方块动起来。
2. 平移、旋转、缩放:先熟练三件套,再谈透视
2.1 三行代码看懂基础变换
Matrix4 最常用的三个方法就是 translate、rotate、scale,对应三类最基础的几何变换。下面这段代码一次性展示了它们的写法:
// 平移:让控件在 x 方向移动 50,y 方向移动 30 Matrix4 translateMatrix = Matrix4.identity()..translate(50.0, 30.0); // 旋转:绕 z 轴旋转 0.5 弧度,大约 28.6 度 Matrix4 rotateMatrix = Matrix4.identity()..rotateZ(0.5); // 缩放:x 方向放大 1.5 倍,y 方向缩小到 0.8 Matrix4 scaleMatrix = Matrix4.identity()..scale(1.5, 0.8);注意这里的单位是弧度,不是角度。我见过很多新手直接写rotateZ(90),然后发现控件几乎没怎么转——因为 90 弧度已经转了好几圈了。要在心里记住:要做 90 度旋转,你得写rotateZ(math.pi / 2)。
rotateX、rotateY、rotateZ 三兄弟分别绕三条轴旋转。在 2D UI 里,我们通常只用 rotateZ——它是你熟悉的“平面内旋转”;一旦你开始用 rotateX 或 rotateY,画面里就会出现明显的纵深感,这正是 3D 视效的开始。
2.2 组合变换的顺序问题:这是一个隐藏的翻车点
真正开始做复杂动效时,你不会只用单个变换,更多时候是“先移过去,再转一下”,或者“边旋转边缩放”。这时候就必须面对矩阵乘法的一个重要性质:不满足交换律。
什么意思?平移后旋转,和旋转后平移,结果往往完全不一样。你可以用下面这段代码自己验证:
import 'package:vector_math/vector_math_64.dart'; void main() { // 先平移,再旋转 final m1 = Matrix4.identity() ..translate(100.0, 0.0) ..rotateZ(0.785398); // 45度 // 先旋转,再平移 final m2 = Matrix4.identity() ..rotateZ(0.785398) ..translate(100.0, 0.0); final point = Vector3(50.0, 0.0, 0.0); print(m1.transform3(point)); print(m2.transform3(point)); }你可以实际跑一下,两个输出结果完全不同。而且如果你拿一个带旋转内容的容器去做这个实验,视觉差异会更明显:一种是方块先平移再绕着自身中心转,另一种是方块先旋转、再沿着旋转之后的 x 轴方向滑出去。
这里我建议你不要死记硬背“哪个先执行”,因为链式调用和最终效果之间并不像读代码那样直观,受方法内部实现影响很大。我的习惯是:先用一个带颜色的小方块写个验证 demo,把顺序换来换去看效果,确认了再写进正式代码。一条经验是:如果希望物体“先绕自身轴旋转,再整体移动”,通常先写 rotate 后写 translate 就能得到直观效果;反之想让物体“先移动到目标位置,再在目标位置原地旋转”,则需要反向调整。多试两次,手感自然就有。
3. 透视矩阵的核心秘密:setEntry(3, 2) 才是 3D 视效的灵魂
3.1 为什么普通旋转看起来不像 3D
你可能会发现一个现象:明明用了 rotateY,控件旋转起来了,但看起来还是像一张平面图在转,没有那种“从屏幕里探出来”的立体感。
问题出在一个关键差异上:正交投影与透视投影。Flutter 默认的 Transform 矩阵是正交投影——物体离你多远,大小都不变。现实中你看到的世界是近大远小的,一个物体转过来离你近的一边应该更大、离你远的一边应该更小。缺少了这个“近大远小”,旋转就只是形状压扁的平面动画,而不是真正的 3D。
这就是为什么你光用 rotateY 会觉得效果“差点意思”。要做出真正的 3D 透视视效,必须手动给矩阵加上透视分量。
3.2 用 setEntry(3, 2) 注入“近大远小”
透视的原理,简单说就是让齐次坐标里的 w 分量根据 z 轴深度发生变化。落在 Matrix4 上,关键位置就在第 4 行第 3 列,也就是 row = 3、col = 2 的那个元素。
Matrix4 perspectiveMatrix = Matrix4.identity() ..setEntry(3, 2, 0.0015);设置了这个元素之后,顶点坐标在最终除以 w 的值时,远处的点会被缩小,近处的点会被放大,视觉上就产生了透视效果。0.0015 这个数值代表透视强度,需要根据你内容的实际尺寸去调,并不是一个绝对标准。它背后的含义约等于“视距倒数”:数值越大,视距越短,透视越夸张;数值越小,越接近正交投影,立体感越弱。
通常我先把 0.001 作为起步值,如果效果太“平”就往上加到 0.002、0.003,如果画面出现边缘扭曲,说明太夸张了,往回压一压。
3.3 透视与旋转的正确组合姿势
透视本身不会让画面动起来,它只提供一个“镜头参数”。你必须把透视和旋转组合在一起,让物体在旋转过程中呈现出明显的近大远小变化,这才算是真正的 3D 视效。
Transform( alignment: Alignment.center, transform: Matrix4.identity() ..setEntry(3, 2, 0.0015) ..rotateY(angle), child: Container(...), )这里有个细节值得强调:alignment一定要设置,它决定了变换的基准点。默认情况下变换围绕左上角,你旋转起来会像一张纸被捏住一个角甩动,而不是围绕卡片中心翻转。把 alignment 设为 Alignment.center,才能得到那种“卡片在指尖翻转”的效果。
3.4 透视强度调优的实操经验
透视参数看起来简单,实际调优很考验经验。我总结过几个规律,对你肯定有用:
| 现象 | 原因 | 调整方向 |
|---|---|---|
| 旋转时边缘像被“扯破”,扭曲感强 | 透视太强 | 从 0.002 以上降到 0.001 附近 |
| 旋转有立体感,但纵深不明显 | 视距偏大 | 从 0.0005 升到 0.001~0.0015 |
| 卡片旋转到侧面时出现抖动 | 动画帧率不足或矩阵数值过小 | 检查动画曲线,透视参数不低于 0.0008 |
| 大卡片远角处发虚 | 超出可见范围 | 必要时用 ClipRect 裁剪,但先检查 z 值 |
还有一个很容易忽略的点:如果你的卡片宽度很大(比如接近屏幕宽度),同样的 0.0015 在小卡片上看起来刚好,在大卡片上就会显得非常夸张。这跟 FOV(视场角)是同一个道理,窄视距适合小物体,宽画幅需要更小的透视系数。我的做法是让透视强度跟卡片尺寸成反比估算:宽度 200 左右用 0.0015,宽度 400 以上用 0.001,这样出来的效果基本不会翻车。
4. 实战一:海报墙的 3D 翻转入场动画
4.1 需求拆解与整体思路
很多人学 Matrix4 是冲着现成效果来的,不是为了学线性代数。所以我用一个常见的交互场景来讲透:海报墙翻转入场。具体效果是:页面打开后,一张张海报从底部旋转着飞入屏幕,每一张翻转入场时都带明显的 3D 纵深,而不是生硬的淡入淡出。
拆开来看,这个需求包含三个技术点:
- 每张海报围绕 x 轴从垂直状态旋转到水平显示;
- 旋转过程中必须带透视,不然就像平面压扁;
- 入场要有错落感,不能所有海报同一节奏。
4.2 核心代码实现
我先把最基础的单张翻转动画写好,再考虑错落的入场节奏。
import 'dart:math' as math; import 'package:flutter/material.dart'; class FlipCard extends StatefulWidget { const FlipCard({super.key, required this.color}); final Color color; @override State<FlipCard> createState() => _FlipCardState(); } class _FlipCardState extends State<FlipCard> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), )..forward(); @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { final angle = Curves.easeOutBack.transform(_controller.value) * math.pi; return Transform( alignment: Alignment.center, transform: Matrix4.identity() ..setEntry(3, 2, 0.0015) ..rotateX(angle), child: child, ); }, child: Container( width: 160, height: 220, color: widget.color, alignment: Alignment.center, child: const Text('POSTER', style: TextStyle(color: Colors.white)), ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } }这里我选Curves.easeOutBack,是为了让翻转到最后带一点回弹,视觉上更“跳”一些。实际跑起来你会发现,因为有setEntry(3, 2, 0.0015)的存在,海报在旋转到接近 90 度时边缘会有明显的放大收缩,这就是透视带来的立体感。如果去掉这一行,整个动画立刻会变得比较平淡。
4.3 背面镜像问题的处理
上面代码只处理了正面。如果海报有背面,比如“卡片翻转查看详情”场景,你就得处理一个典型问题:旋转超过 90 度以后,内容会左右镜像。这是因为我们的视角从正面跑到了背面,而背面内容的显示逻辑和正面完全不一样。
处理方式有两种。简单粗暴的办法是在角度超过math.pi / 2时切换显示内容,正面和背面分开布局。细腻一点的办法是给背面内容再包一层Transform(transform: Matrix4.rotationY(math.pi), ...),让内容在背面翻转回来,这样用户看到的背面文字方向是正常的。后者在连续翻转场景里观感更好。
4.4 错落入场与列表结合的细节
单张卡片搞定后,要把多张海报组织成墙,还需要给每一张加上延迟启动的动画。我习惯用Interval来控制每张卡片在同一个AnimationController里的起始时机。比如三张卡片分别在 0%、30%、60% 位置开始入场:
final curved = CurvedAnimation( parent: controller, curve: Interval( index * 0.25, index * 0.25 + 0.8, curve: Curves.easeOut, ), );这样三张海报会依次翻转入场,视觉上很自然地形成“瀑布流式”的进场层次。整个过程中,每张卡片都只消耗一个 Transform 和一个 AnimatedBuilder,没有引入任何第三方动画库,性能压力很小。
4.5 这一版实现里我踩过的坑
这个方案我做了很多次后,有一个特别想提醒你的点:FlipCard 的宽度和高度比例,会直接决定旋转方向的选择。宽扁型卡片用 rotateX 会非常奇怪,更像是翻跟头;只有高宽比大于 1:1 的卡片,rotateX 才有“翻牌”的感觉。如果你要做的是横版海报,最好改用 rotateY,视觉效果才自然。
另外,如果海报有圆角,记得在 Transform 的外层放一个 ClipRRect。原因是在透视旋转过程中,卡片边缘的锯齿会非常明显,别问我怎么知道的,都是肉眼盯出来的教训。
5. 实战二:手势驱动的 3D 悬浮视差卡片
5.1 从固定动画到人机交互
如果说上一节是“播放动画”,那这一节要做的就更有意思了:让用户的触摸直接控制 3D 透视的角度。效果很像一些音乐 App 的专辑封面——手指按住卡片左右滑动,卡片随之绕 y 轴旋转,上下滑动则绕 x 轴旋转,松手后卡片缓慢弹回原位。这个效果一旦用上,页面的“高级感”立刻不一样,而且原理跟前端流行的 tilt 视差卡片完全一致。
5.2 数据处理:把手势位移映射成旋转角
核心思路是先通过 GestureDetector 拿到位移增量,再把位移增量换算成旋转角度和透视偏移。我给出了一个可以直接跑起来看效果的简化版本:
class TiltCard extends StatefulWidget { const TiltCard({super.key, required this.child}); final Widget child; @override State<TiltCard> createState() => _TiltCardState(); } class _TiltCardState extends State<TiltCard> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 400), ); double _dx = 0.0; double _dy = 0.0; void _onPanUpdate(DragUpdateDetails details) { setState(() { _dx += details.delta.dx * 0.3; _dy += details.delta.dy * 0.3; // 控制最大值,避免旋转角度过大导致卡片“翻过去” _dx = _dx.clamp(-0.6, 0.6); _dy = _dy.clamp(-0.6, 0.6); }); } void _onPanEnd(DragEndDetails details) { _controller.forward(from: 1.0).whenComplete(() { setState(() { _dx = 0.0; _dy = 0.0; }); }); } @override Widget build(BuildContext context) { return GestureDetector( onPanUpdate: _onPanUpdate, onPanEnd: _onPanEnd, child: AnimatedBuilder( animation: _controller, builder: (context, child) { final tx = _dx * (1 - _controller.value); final ty = _dy * (1 - _controller.value); return Transform( alignment: Alignment.center, transform: Matrix4.identity() ..setEntry(3, 2, 0.0012) ..rotateX(-ty) ..rotateY(tx), child: child, ); }, child: widget.child, ), ); } }注意_dx用clamp(-0.6, 0.6)限制最大旋转角大约 34 度。超过这个角度,卡片边缘会出现明显的透视变形,看起来不仅不酷,反而像是被“掰弯”了。0.6 弧度是我试下来比较安全的上限。
5.3 为什么松手回弹不用 setState 而是用 AnimationController
这里的回弹逻辑是我刻意设计的:_controller.forward(from: 1.0)让控制器从 1.0 执行到 0,过程中tx和ty从当前值平滑衰减为 0,形成“松手后自动回平”的效果。之所以不用 setState 手动改位移量,是因为你要的是连续的 400 毫秒过渡,而不是一帧跳变。AnimationController 天然支持这种插值,并且可以跟 Transform 的矩阵更新无缝配合。
这套方案里还有个细节值得提:AnimatedBuilder 的 child 参数被设成了widget.child,这样卡片内容本身不会每次动画帧都重建,只重建最外层的矩阵变换。这一点在卡片内容复杂时尤其重要,能省下大量不必要的 build 开销。
5.4 手势+矩阵耦合时最常见的三类异常
这类交互写多了之后,我总结出三个高频异常,你遇到时可以少走弯路:
第一类是卡片抖动。原因通常是 setState 和 AnimationController 同时在改矩阵参数,互相覆盖。解决办法是统一由 AnimatedBuilder 消费动画参数,手势更新时只更新_dx、_dy,不要直接拿矩阵值去做递增。
第二类是角度回弹后旋转方向反了。大多是因为符号问题,Flutter 的 y 轴方向是向下的,所以手指上滑时_dy是负值,为了让卡片朝上仰,要用rotateX(-ty)。符号不对的话,卡片跟手势方向永远是反的,体验非常别扭。
第三类是嵌套在列表里导致手势冲突。如果 TiltCard 放在 ListView 里,onPanUpdate 会和列表滚动冲突。解决办法是外层用 VerticalDragGestureRecognizer 做手势竞技场配置,或者在 ListView 的physics里根据场景关闭纵向滚动。这个需要结合具体页面结构决定,没有万能写法。
6. 鸿蒙平台上跑 Matrix4 动画:性能、状态与实际适配细节
6.1 鸿蒙端渲染引擎对矩阵动画的影响
Matrix4 的代码在 Android、iOS、鸿蒙上是一致的,但渲染底层却有差异。鸿蒙版 Flutter 走的是自研容器加渲染引擎适配路线,整体动画性能跟 Flutter 官方实现之间会有一些细微差别。根据我的实际测试,Matrix4 透视动画在鸿蒙设备上的第一帧会比 Android 慢一点,尤其在低端鸿蒙机型上,如果一帧里同时跑三四张翻转卡片,能明显感觉到掉帧。
解决办法不是去优化矩阵计算本身——那本来就是纯数学,很快——而是减少同一时间参与重绘的图层数量。Flutter 的 Transform 组件默认会触发图层合成,如果一个页面里多个卡片同时持续旋转,合成的压力会集中在 GPU。这时候给每张卡片包一层RepaintBoundary,让 Flutter 把每张卡片当成独立的合成层,反而能减轻全局重绘的压力。
RepaintBoundary( child: Transform( transform: Matrix4.identity()..setEntry(3, 2, 0.0015)..rotateY(angle), child: cardChild, ), )6.2 状态管理:动画过程中别让矩阵被“打断”
鸿蒙设备上还有一个特别容易踩的问题:页面级状态变化会把进行中的动画打断。比如你用 Provider 或者 Cubit 管理页面数据,某个异步数据到了之后 notifyListeners 触发页面 rebuild,Transform 拿到的 Matrix4 如果是在 builder 里临时构建的,动画就会瞬间跳回初始状态——看起来就像卡片“闪了一下”。
我现在的习惯是:把 Matrix4 的构建结果缓存下来,或者拆成独立的 StatefulWidget,让动画只在它自己的局部范围内 rebuild。用 Cubit 管理业务状态没问题,但动画状态一定要和业务状态分开,不要让同一个 rebuild 同时影响两者。曾经有一个鸿蒙项目里,我因为没注意这个,卡片翻转动画被下拉刷新触发的 rebuild 反复打断,排查了很久才定位到是状态耦合的问题。
6.3 页面切换后状态丢失的问题
和 Matrix4 相关的另一个常见问题是页面切换。Flutter 的 Navigator 切走页面时,页面状态默认会保留,但如果你在页面上用了with SingleTickerProviderStateMixin的 AnimationController,且没有处理页面返回时的状态恢复,卡片可能会停在旋转一半的怪异角度上。
解决方案有两个思路:一是用PageStorageKey给卡片列表加存储标识,让页面重建后能恢复到切换前的位置;二是在页面重新可见时主动重置动画,在RouteAware的didPopNext回调里重新触发_controller.forward(from: 0)。具体用哪个,取决于你希望用户回来时看到的是“继续之前的旋转”还是“重新播放入场动画”,我倾向于后者,因为前者的矩阵状态通常没有保存完整,回来反而容易显得卡壳。
6.4 把矩阵工具做成公共库
鸿蒙项目里多个页面都会用到透视卡片和视差效果时,别再到处复制 Matrix4 的构建代码了。我建议单独抽一个矩阵工具文件,把常用的透视矩阵封装成方法,方便统一调整参数。
import 'package:vector_math/vector_math_64.dart'; Matrix4 perspectiveRotateY(double angle, {double perspective = 0.0012}) { return Matrix4.identity() ..setEntry(3, 2, perspective) ..rotateY(angle); }如果你愿意,还可以用 Dart 的 part/part of 机制,把矩阵工具类按业务模块拆成多文件,这样卡片翻转、视差跟随、入场动画各自的矩阵构建逻辑互不干扰,维护成本低很多。我在实际项目里就吃过“全局搜索 setEntry 却不知道哪个页面在用”的亏,后来统一封装之后,调参效率高了一倍不止。
写在最后的一些体会
矩阵变换这个东西,刚开始接触容易卡在“数学感”上,一旦跑通几个 demo,你会觉得它比想象中简单得多。我个人的经验是,不要沉迷于记住每个矩阵元素的含义,先记住三个结论:translate/rotate/scale对应三件套基础变换;setEntry(3, 2, value)是透视的总开关;链式调用顺序会影响结果,记不清就写 demo 验证。等你真的用 Matrix4 做过一套完整的 3D 卡片交互,再回头看 4x4 矩阵的数学原理,会顺畅很多。
最后送你一个小技巧:调试透视参数时,别光用眼睛看,可以在动画运行中临时加一个 Slider,动态调整 setEntry 的第三个参数,实时对比不同透视强度下的画面效果。等找到了满意的数值,再把 Slider 删掉,把参数写死进代码里。这个做法比一遍遍改代码热重载要高效得多,尤其在做鸿蒙适配时,能快速排除“是不是透视参数不适配这个屏幕尺寸”的干扰项。