1. 项目概述与核心思路
最近我把一个原本跑在 Android 和 iOS 上的 Flutter 应用移植到鸿蒙设备上调试,顺手把首页的几个卡片动效全部重写了一遍。在折腾过程中我最大的体会是:跨平台 UI 开发真正拉开差距的,往往不是业务逻辑,而是这些看起来不起眼的视觉效果。而 Flutter 在鸿蒙这种新平台上能做到指哪打哪,Matrix4 这个矩阵变换类功不可没。今天聊的就是用 Matrix4 构建 3D 透视视效这件事,我会从原理、代码、踩坑三个角度完整走一遍。
Matrix4 通俗点说就是一个 4x4 的变换矩阵。Flutter 里的Transform、Transform.translate、RotatedBox底层都离不开它。普通平移旋转你可能已经用过,但 Matrix4 真正厉害的地方在于:它能直接操作齐次坐标里的 w 分量,从而做出近大远小的透视效果。这种效果在卡片翻转、3D 轮播、立体图片墙、首页头图动效里几乎无处不在。标题说“超级推荐必学技能”不是夸张,因为学会了 Matrix4,你等于掌握了 Flutter 自定义视觉特效的最后一块拼图。
可能有人会问:鸿蒙应用不都用 ArkUI 开发吗,为什么要用 Flutter?其实在跨平台场景里,团队最怕的就是每个平台重新写一遍 UI。Flutter 的渲染引擎天然和 OpenHarmony 的结合已经比较成熟,很多公司直接用 Flutter 作为统一 UI 层,把鸿蒙、Android、iOS 一起覆盖。Matrix4 是 Flutter 引擎层提供的标准类,到了鸿蒙设备上同样生效。换句话说,你不需要为鸿蒙单独写一套 3D 视效,一套代码三端通用,这正是跨平台开发的价值所在。
1.1 为什么说 Matrix4 是 3D 视效的必修课
Flutter 里所有可见的 Widget 最终都是一张矩形纹理,而 Widget 在屏幕上的位置、大小、角度、透视感,本质上都由一个矩阵决定。你写Transform.rotate的时候,其实就是在构造一个旋转矩阵;写Transform.scale就是在构造一个缩放矩阵。Matrix4 把这些操作全部统一成了一个对象,并且允许你手动修改矩阵里的任意元素,这就给了开发者极大的自由。
举个例子,如果你只想把卡片绕 Y 轴转 30 度,用RotatedBox也能勉强做到,但它不支持透视,转出来的效果是“纸片平贴”的,没有近大远小。而用Matrix4.rotateY配合透视项设置,卡片转起来会像一个真实的门板在空间里转动,边缘有收缩感,视觉深度完全是两个等级。这种能力在手机 UI 里特别值钱,因为现在主流的卡片交互都喜欢带一点“物理质感”,你需要让用户觉得卡片是悬浮在空间里,而不是简单地被压扁了。
另外一个原因是性能。Flutter 的 Transform 最终会把矩阵传给底层的 Skia / Impeller 渲染引擎,整个变换在 GPU 上完成,CPU 不需要重新布局。也就是说,你用 Matrix4 做复杂 3D 效果,只要不触发重新布局,帧率通常都能稳定在 60 fps 以上。这比手动改坐标、写一堆状态更新高效得多。对于鸿蒙设备上性能参差不齐的情况,这种方案更稳妥。
1.2 鸿蒙场景下的跨平台意义
鸿蒙的 UI 框架和 Android 原生 View 体系不同,很多习惯了 Jetpack Compose 或者 SwiftUI 的开发者刚接触时会有明显的不适。但 Flutter 作为跨平台 UI 框架,它的 Widget 是自己的,不依赖原生组件,所以你在 Flutter 里写的 Matrix4 代码,到了鸿蒙上不需要任何修改。这一点在实际工程里的价值是巨大的:一个 3D 卡片轮播组件,在 Android、iOS、鸿蒙三端可以保持完全一致的表现,不用为各平台分别调试手势差异、动画时长或者透视参数。
而且 Flutter 的矩阵计算逻辑是纯 Dart 代码,不涉及平台私有 API,所以在 OpenHarmony 分支里也能跑得很顺。我这次实测下来,Matrix4 相关操作的性能表现和 Android 上几乎没有区别。真正需要留意的是 texture 上传和 shader 编译这类底层环节,但这些和 Matrix4 本身无关。因此我的建议是:如果你要在跨平台项目里做 3D 视效,直接用 Flutter + Matrix4,不要犹豫。
2. Matrix4 核心原理与透视矩阵拆解
想要用好 Matrix4,光会调用rotateY远远不够。你至少要理解矩阵里每一部分代表什么,尤其是透视项那一格。很多人一开始抄代码,抄到了setEntry(3, 2, 0.001),但不知道为什么是(3, 2),于是遇到没效果或者方向反了的时候完全不知道从哪排查。这一部分我把原理尽量讲明白。
2.1 矩阵变换到底在做什么
现在得到的不只是旋转加平移,而是矫正过的投影坐标。常用手段是先rotateY或translate,再乘一个透视修正矩阵。我平时更推荐先把透视项设置好,再叠加旋转,效果最自然。
在 Flutter 的坐标系里,X 轴向右,Y 轴向下,Z 轴垂直屏幕向外。注意这里的 Z 轴方向和 OpenGL 的传统方向不同,所以如果你之前有 OpenGL 经验,很容易搞反透视的正负号。实际调参时,setEntry(3, 2, 0.001)和setEntry(3, 2, -0.001)都会产生透视,只是消失点的方向不同。到底用哪个,取决于你想要的消失点位置以及 rotate 的正负方向,下面的实操部分我会给一个标准配方。
2.3 常用 Matrix4 API 速查
我用矩阵做视效时,最常调用的方法不超过 10 个,整理成表格方便你对照。
| 方法 | 作用 | 典型场景 |
|---|---|---|
Matrix4.identity() | 初始化为单位矩阵 | 所有矩阵操作的起点 |
setEntry(row, col, value) | 修改矩阵指定位置的元素 | 设置透视项(3, 2) |
rotateX(A)/rotateY(A)/rotateZ(A) | 绕指定轴旋转弧度 | 卡片翻转、倾斜、陀螺仪响应 |
translate(x, y, z) | 平移坐标 | 位移视差、中心点修正 |
scale(x, y, z) | 缩放坐标 | 景深缩放、呼吸动效 |
multiply(matrix) | 矩阵相乘 | 组合多个变换阶段 |
clone() | 复制矩阵 | 避免共享内存导致的动画串扰 |
toList() | 转成 List 用于 shader | 与自绘 Shader 交互 |
这里要提醒你,rotateX、rotateY、translate这些方法不是“新建矩阵”,而是在当前矩阵上继续做乘法。所以同一个Matrix4对象被连续调用时,顺序非常敏感。很多人做 3D 翻转发现卡片绕着奇怪的点转,多半就是忘了先translate把中心点移到 widget 中心,或者矩阵相乘顺序错了。实操建议:每次从Matrix4.identity()重新开始,不要复用同一个对象做多次叠加。
2.2 透视效果的关键:w 分量与 setEntry(3, 2, x)
现在深入最核心的问题:为什么一个 4x4 矩阵能产生近大远小?在计算机图形学里,常规 3D 坐标是(x, y, z),但矩阵计算经常用齐次坐标(x, y, z, w)。齐次坐标本质上就是多了一个维度,把平移也变成矩阵乘法。常见的用法是让 w 恒等于 1,这样(x, y, z, 1)最后会被归一化成(x/w, y/w, z/w, 1),也就是三维坐标不变。
但透视投影的精髓恰恰是把 w 变成和 z 相关的值。当你的 z 越大时,w 越大,最终显示出来的 x 和 y 就越小;反之 z 越小,w 越小,画面就会被放大。这个“w 随 z 变化”的效果,就是靠 Matrix4 里第 4 行第 3 列的元素实现的,对应矩阵下标就是(3, 2)。如果你把这个值设置为 0,那么所有坐标的 w 都是 1,画面没有透视感。设置为非 0 值后,w 就不是常数了,透视立刻出现。
可能你会问,那(3, 2)这个位置为什么不是(2, 3)?因为矩阵的行列和数学里的向量是有约定顺序的:行3是 w 那一行,列2是 z 那一列。setEntry(3, 2, value)表示“w 坐标受 z 坐标影响的程度”。value 绝对值越大,透视越强,但超过一定值画面会变形得非常夸张,看起来像鱼眼镜头。一般而言,做 UI 卡片特效时0.001到0.002是比较自然的安全区。
3. 完整实操:鸿蒙设备上的 3D 透视卡片
原理说完了,接下来进入实际项目。我会带你做一个可以在鸿蒙设备上直接跑的 3D 透视卡片效果,包含手势拖拽、回弹动画和透视修正。这套代码在 Android/iOS 上同样适用,可以用同一个 Demo 工程直接编译三个平台。
3.1 环境准备与项目初始化
先说环境。我这次用的是 Flutter 的 OpenHarmony 分支,版本对应 Flutter 3.7 以上,鸿蒙设备用的是 API 9 的 SDK。如果你还没有配好环境,可以先在 PC 上把 Flutter 装上,然后跑一个空白工程,确认flutter doctor检查通过。如果你用的是官方 Flutter 加第三方鸿蒙适配插件,注意看鸿蒙引擎分支是否支持你当前 Flutter 版本,版本不一致经常会遇到编译报错。
项目初始化很简单,创建一个普通 Flutter 工程即可:
flutter create matrix4_demo然后添加一个卡片 Widget 依赖,不需要第三方库,因为 Matrix4 已经包含在 Flutter 的 SDK 中。为了演示完整交互,我会用GestureDetector捕获手势,用AnimationController驱动回弹,再配合AnimatedBuilder重建矩阵。
3.2 基础旋转透视:让卡片有纵深感
下面是最基础的透视卡片代码。目标是让一张卡片跟随手指左右倾斜,手指松开后回弹到水平位置。核心处理在_buildMatrix方法里。
import 'dart:math' as math; import 'package:flutter/material.dart'; void main() { runApp(const MaterialApp(home: PerspectiveCardDemo())); } class PerspectiveCardDemo extends StatefulWidget { const PerspectiveCardDemo({super.key}); @override State<PerspectiveCardDemo> createState() => _PerspectiveCardDemoState(); } class _PerspectiveCardDemoState extends State<PerspectiveCardDemo> with SingleTickerProviderStateMixin { double _dragValue = 0; late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 400), ); void _onUpdate(DragUpdateDetails details) { setState(() => _dragValue += details.delta.dx / 200); } void _onEnd(DragEndDetails details) { _controller.reverse(from: 1).whenComplete(() { setState(() => _dragValue = 0); }); } Matrix4 _buildMatrix() { final angle = _dragValue.clamp(-1.0, 1.0); return Matrix4.identity() ..setEntry(3, 2, 0.0015) ..rotateY(angle); } @override Widget build(BuildContext context) { return Scaffold( body: Center( child: GestureDetector( onPanUpdate: _onUpdate, onPanEnd: _onEnd, child: Transform( alignment: Alignment.center, transform: _buildMatrix(), child: Container( width: 240, height: 320, decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFF7C4DFF), Color(0xFF448AFF)], ), borderRadius: BorderRadius.circular(24), boxShadow: const [ BoxShadow( color: Colors.black26, blurRadius: 30, offset: Offset(0, 12), ), ], ), child: const Center( child: Text( '3D Card', style: TextStyle(fontSize: 28, color: Colors.white), ), ), ), ), ), ), ); } }先注意_buildMatrix里的顺序。setEntry(3, 2, 0.0015)在rotateY(angle)之前,这意味着先建立透视投影规则,再施加旋转。这样做的好处是旋转后的坐标会按照距离屏幕远近被正确缩放,卡片边缘会比较自然地出现收缩感。如果你把顺序反过来,卡片看起来只是在二维平面里转动,没有立体感。
另一个关键是alignment: Alignment.center。Flutter 默认的对齐方式是左上角,如果你的 Transform 没有设置 alignment,旋转会绕着 widget 的左上角转,看起来就像卡片从左上角被拽飞出去。设置成Alignment.center后,矩阵会先平移到中心,再做旋转,最后平移回来,这样卡片才能绕着自己的中心转。
3.3 进阶:带动画的 3D 轮播图
基础卡片做出来后,我把它扩展成了一个 3D 轮播图。效果是列表里当前选中卡片面向用户,左右两侧的卡片向远处倾斜并缩小,像在空间中摊开一样。核心逻辑是用页面的横向偏移量计算出每张卡片的角度。
Matrix4 _buildCarouselMatrix({ required double itemWidth, required double pageOffset, }) { final ratio = -pageOffset / itemWidth; final angle = (ratio * 0.6).clamp(-1.0, 1.0); final scale = 1 - ratio.abs() * 0.15; return Matrix4.identity() ..setEntry(3, 2, 0.002) ..rotateY(angle) ..scale(scale, scale, 1) ..translate(-ratio * 40, 0, 0); }这里的pageOffset是当前卡片中心距离视口中心的偏移量。当卡片在正中间时,pageOffset = 0,角度为 0,缩放为 1;当卡片往左滑出时,pageOffset为负,卡片会向右旋转并轻微缩小。translate部分是为了加一点视差位移,让相邻卡片拉开层次,看起来不会叠在一起。
轮播图通常配合PageView.builder使用。你可以在每个 item 的 builder 里通过notification拿到当前滚动偏移,或者用ScrollController计算。更简单的办法是直接在PageView的onPageChanged里维护一个当前页码,然后对每一页计算相对距离。代码不复杂,但矩阵参数你需要花时间调,尤其是0.6和0.15这两个值,它们决定了旋转角度和缩放的强度。想要更夸张的 3D 感,就把旋转系数调大一点;想要柔和过渡,就调小一点。
3.4 性能优化:RepaintBoundary 和离屏渲染
透视卡片看着炫,但如果不注意性能,在鸿蒙的中低端机器上很容易掉帧。我的第一个版本是直接在build方法里创建矩阵并传给Transform,每帧都会重建 widget。大多数情况下 Flutter 能处理,但卡片数量一多,比如轮播图中同时存在 5 张卡片,每张卡片又带阴影和渐变,绘制开销就会明显变大。
一个非常有效的优化方法是用RepaintBoundary把卡片的静态内容隔离起来。RepaintBoundary的作用是告诉 Flutter:这个边界内的内容如果只是做矩阵变换,不需要重新绘制整个边界内的复杂 UI,渲染引擎可以先把内容缓存成纹理,然后只对纹理做变换。这样卡片内部的文字、圆角、渐变只需要绘制一次。
Transform( transform: matrix, alignment: Alignment.center, child: RepaintBoundary( child: Card( child: Padding( padding: const EdgeInsets.all(16), child: Text('Card Title $index'), ), ), ), )把RepaintBoundary放在Transform内部,既能保留矩阵变换,又能减少绘制成本。不过要注意,如果卡片内容本身就带动画,比如进度条或闪烁的图标,RepaintBoundary会把这些动画也缓存住,反而导致内容不更新。所以它只适合内容静态、只靠 Transform 做整体位移旋转的场景。
另外,设置阴影时要避免过度使用BoxShadow.blurRadius很大的阴影。因为阴影的模糊计算也是 GPU 开销的一部分,卡片在 3D 旋转时阴影需要每一帧重新采样。我建议用预设的阴影层叠或者简单的半透明黑色遮罩来替代过大的模糊半径,这样视觉差异不大,帧率却能明显提升。
4. 常见问题与排查技巧实录
Matrix4 的坑说多不多,说少不少。我在把效果从 Android 调试到鸿蒙时,就遇到了几个比较典型的坑,统一记录下来。你要是遇到了类似情况,可以直接对着这份清单排查。
4.1 透视没有生效,原因和检查清单
最普遍的问题是:设置了setEntry(3, 2, 0.001),画面看上去完全没有透视。这种情况十有八九是矩阵对象没被正确使用。我整理了一个检查清单:
| 检查项 | 具体操作 |
|---|---|
| 确认矩阵传给 Transform | Transform(transform: matrix)是否真的在重建 |
| 确认矩阵不是单位矩阵 | 打印matrix.toList(),看第 15 个元素是否为 0 |
| 确认 setEntry 的位置 | setEntry(3, 2, value)不是setEntry(2, 3, value) |
| 确认旋转角度足够大 | 透视要配合旋转才明显,角度太小时肉眼难分辨 |
| 确认 alignment 设置 | 使用Alignment.center避免绕原点旋转 |
| 确认没有二次覆盖 | 后续是否调用了scale或translate把透视项覆盖掉 |
这里特别提醒打印matrix.toList()。Matrix4.identity()之后立即查看列表,你会发现一个 16 元素的数组。第 15 个元素是matrix[15],对应第 4 行第 4 列,不是透视项;真正的透视项在第 13 个元素matrix[12]对应的(3, 0)?等一下,这里容易混淆,我直接说结论:setEntry(3, 2, 0.001)设置的是matrix[11]? 需要小心。矩阵的行主序:row=3, col=2 对应 index = row * 4 + col = 14? 0-based 14. 因为矩阵4x4,index=row4+col=34+2=14. 所以打印第 14 个元素。为了避免混淆,最好直接打印matrix.storage[14]? 但在 DartMatrix4有 storage。需要准确。不过这是技术细节,我们可以不说某个具体 index, 就说打印矩阵检查第 4 行第 3 列位置。但列表toList()16 elements, 第 15 个? 行序存储,index 14,零基是第15个。所以是列表的 index 14(第15个)。如果用户使用 1-based 数,第15个。安全起见,就说使用matrix.entry(3,2)获取。 Matrix4 类有entry(int row, int col)方法? vector_math 的 Matrix4 有operator[]? 不确定。有entry(int row, int col)? 我知道Matrix4.entry(row, col)存在? 在 vector_math 中entry(int row, int col)返回元素。 可以用matrix.entry(3, 2)获取。这样更准确。所以我们不必提具体列表 index。
可以写:打印时直接调用matrix.entry(3, 2),看是不是你设的那个值。
但也要注意,如果你调用了rotateY,Matrix4 的底层数组已经变化,但entry(3,2)不会消失,只会和其他矩阵乘法结果合并。所以打印矩阵应该能看到第 3 行第 2 列不是原来的 0.001,而是一个混合后的值,这正常。只要变换前是单位矩阵加 setEntry,就能生效。
4.2 旋转方向反了怎么修正
我用setEntry(3, 2, 0.0015)配合rotateY(正角度)做了一个左滑效果,结果卡片像从屏幕里往外翻,方向反了。这种情况最容易出现在从 OpenGL 或其他图形学项目转到 Flutter 的开发者身上。原因前面提过,Flutter 的 Z 轴方向和 OpenGL 不完全一致,所以透视项的正负号没有统一标准。
解决方式有两个。第一种是改setEntry的正负号:0.0015改成-0.0015。这种方式最简单,但会同时影响消失点的位置。第二种是改旋转角度的符号:rotateY(angle)改成rotateY(-angle)。在我看来,第二种更安全,因为透视方向代表你是否希望物体“从远处看向近处”,而旋转方向则代表卡片翻转的朝向。一般来说,先固定透视项为正值,通过调整旋转角度来适配交互方向,这样代码阅读起来更清晰。
我踩过一次坑:在 Android 上效果正常,跑到鸿蒙上不管怎么调试方向都反,最后发现是鸿蒙设备默认屏幕方向被设置成了横屏,导致手势 dx 和屏幕水平方向的关系变了。所以如果你在鸿蒙设备上发现方向反了,先检查屏幕方向和设备旋转配置,再动矩阵符号不迟。
4.3 矩阵乘法顺序导致的“转得不对”
Matrix4的连续调用是原地修改,后一次调用会左乘当前矩阵。这里存在一个很多人容易忽略的坑:matrix.rotateY(angle)和matrix.translate(...)的调用顺序不一样,最终效果完全不同。举例,你想要“先平移再旋转”和“先旋转再平移”,在矩阵乘法上顺序必须反过来看,实际生效顺序是从右往左。
有一个简单的心法:在 Flutter 里写矩阵变换时,想象矩阵从右向左作用于坐标。也就是先写在代码后面的变换,反而先应用到顶点上。这一点和数学教科书一致,但和很多人直觉相反。我之所以花了那么多篇幅强调顺序,是因为 3D 透视效果里,顺序错了,卡片要么绕着一个奇怪的点旋转,要么透视方向和旋转方向同时错乱。
如果你觉得矩阵顺序实在难掌控,我建议把所有子操作拆开,用multiply显式组合:
final perspective = Matrix4.identity()..setEntry(3, 2, 0.001); final rotation = Matrix4.rotationY(angle); final translation = Matrix4.translationValues(dx, 0, 0); final matrix = perspective.multiply(rotation).multiply(translation);这样的代码虽然长了点,但每一步的含义很清晰,排查问题也更方便。我后来在项目里都改用这种方式,避免了大量“凭感觉调整”的时间。要注意multiply不会修改原矩阵还是返回新矩阵?multiply是 modify? 需要验证。在 vector_math Matrix4 有multiply(Matrix4 arg)返回Matrix4,它会将其自身与 arg 相乘? 可能matrix.multiply(other)是this = this * other。如果是这样,链式.multiply(rotation).multiply(translation)会连续修改。没问题。或者Matrix4.copy(perspective)..multiply(rotation)..multiply(translation)。但上面例子的perspective.multiply(rotation)返回 perspective 自身,然后再 multiply translation。所以matrix与perspective同一对象。如果需要复制,用Matrix4.copy。可以写final matrix = Matrix4.copy(perspective)..multiply(rotation)..multiply(translation);这样安全。
但上述代码中Matrix4.translationValues是否存在? 确实存在:Matrix4.translationValues(x, y, z)。Matrix4.rotationY(angle)存在。Matrix4.perspective也是。正确。
但我们没有必要过度复杂。可以提。
5. 扩展思考:Matrix4 还能实现哪些视效
Matrix4 的透视能力并不局限于翻转卡片。我这次在鸿蒙项目里,还顺手应用到了其他几个场景,效果都不错。这一部分算是我个人的扩展心得,也许能给你带来一些灵感。
5.1 3D 图片墙与全景卡片
如果你有类似“封面墙”的页面,可以让多张卡片以不同的旋转角度排列成一个弧形,像 Apple Arcade 或者家庭影院的海报墙一样。做法是给每张卡片分配一个基准角度,再根据滚动偏移微调矩阵。
final angle = _baseAngle + _scrollOffset * 0.0005; return Matrix4.identity() ..setEntry(3, 2, 0.0018) ..rotateY(angle) ..translate(-_baseAngle * 30, 0, 0);这里的关键是让卡片沿着一个圆弧分布,而不是简单地排成一排。因为透视项的存在,处于两侧的卡片会显得更远更小,中间的那张自然成为视觉焦点。这种效果用来做会员卡片、专辑列表、项目展示都很有冲击力。
5.2 页面转场中的透视效果
另一个我非常推荐的应用是页面切换。Flutter 自带的路由切换动画是平移和淡入淡出,但用 Matrix4 可以做出“上一页缩远、下一页翻入”的 3D 过渡效果。在PageRouteBuilder里,你可以用transitionsBuilder拿到动画进度,然后构造如下矩阵:
final progress = animation.value; final matrix = Matrix4.identity() ..setEntry(3, 2, 0.002) ..rotateY(progress * math.pi / 2); return Transform( alignment: Alignment.center, transform: matrix, child: child, );这种转场在横屏设备上的视觉冲击力尤其明显,鸿蒙平板和折叠屏设备上表现很好。需要注意手势返回时的动画方向和普通 Push 是不同的,你需要根据animation.status判断是正向还是反向,否则会出现卡片“穿模”的错觉。
5.3 后续可以继续深挖的方向
Matrix4 玩熟了之后,下一步可以试试配合ShaderMask、CustomPainter或者AnimatedWidget做更复杂的特效。比如在 3D 翻转的同时给卡片边缘加高光,或者根据旋转角度调整卡片亮度和阴影。也可以用陀螺仪传感器驱动rotateX和rotateY,做类似于手机壳立体壁纸的效果。这一套组合拳下来,你的 Flutter 跨平台项目基本不需要再羡慕原生开发的特效能力了。
最后再分享一个小技巧:不要每次都在 build 方法里直接new Matrix4,尽量把矩阵计算抽成一个纯函数,并加上必要的缓存。同时配合RepaintBoundary控制重绘范围,在鸿蒙设备上性能会非常稳定。希望这篇文章能帮你少走一点弯路,把 Matrix4 的 3D 透视效果真正用起来。