搞懂变换!精通 Compose 绘制(二)

上一篇讲解绘制的文章中,我们聊了 DrawScope 的基础:怎么进入绘制阶段,怎么用 drawBehinddrawWithContentdrawWithCache,以及为什么像素对齐和 Path 缓存会影响实际性能。

这篇文章,我们继续往下走。

只会 drawCircle()drawRect() 肯定是还不够的。如果你想做旋转指针、环形进度、轨道加载、缩放呼吸效果等,就一定会碰到这几个 API:

kotlin 复制代码
translate(...)
rotate(...)
scale(...)
withTransform { ... }
clipRect { ... }
clipPath { ... }

它们看起来都很简单,但第一次用的时候很容易懵。

有一点需要先记住:你以为自己是在移动某个图形,实际上你是在改变坐标系。

踩坑

比如我们想画一个指针。

需求很简单:指针从中心出发,随着角度旋转,末端再画一个小圆。

很多人第一反应会这么写:

kotlin 复制代码
Modifier.drawBehind {
    val handLength = 58.dp.toPx()
    drawDialBackground() // 绘制背景
    rotate(degrees = angle) {
        drawLine(
            color = Color(0xFFE53935),
            start = Offset.Zero,
            end = Offset(0f, -handLength),
            strokeWidth = 4.dp.toPx()
        )
        drawCircle(
            color = Color(0xFF1E88E5),
            radius = 6.dp.toPx(),
            center = Offset(0f, -handLength)
        )
    }
}

看起来没问题:先旋转,再画线,最后在末端画圆。

但实际效果大概率不对。原因有两个:

  1. Offset.Zero 还是组件左上角,不是表盘中心。
  2. rotate 默认围绕当前 DrawScope 的中心旋转坐标系,而 drawLinedrawCircle 里的坐标,都会被这个旋转后的坐标系重新解释。

如果不信,直接看效果:

换句话说,你写下的每一个 Offset 都不是一个永远不变的屏幕绝对位置。它是当前坐标系里的一个点。坐标系一变,这个点的含义也跟着变。

把原点挪到中心

写绘制代码时,一个很好用的习惯是:把复杂图形的"局部原点"放到你真正关心的位置。

对于指针来说,我们真正关心的是表盘中心。那么就先 translate 到中心,再把后续绘制都写成相对坐标。

kotlin 复制代码
Modifier.drawBehind {
    val center = Offset(size.width / 2f, size.height / 2f)
    val handLength = 58.dp.toPx()
    drawDialBackground()
    translate(left = center.x, top = center.y) {
        rotate(degrees = angle, pivot = Offset.Zero) {
            drawLine(
                color = Color(0xFFE53935),
                start = Offset.Zero,
                end = Offset(0f, -handLength),
                strokeWidth = 4.dp.toPx()
            )
            drawCircle(
                color = Color(0xFF1E88E5),
                radius = 6.dp.toPx(),
                center = Offset(0f, -handLength)
            )
        }
    }
}

这段代码读起来就清楚多了:

  1. 先把坐标系原点移动到组件中心。
  2. 再围绕这个新原点旋转,所以这里要显式写 pivot = Offset.Zero
  3. 最后在局部坐标系里画指针和末端圆点。

这里的 Offset.Zero 就是表盘中心,Offset(0f, -handLength) 就是指针末端。

这就是 DrawScope 变换里最重要的一个概念:你不是在移动图形,你是在移动后面那几行绘制代码所处的坐标系。

再看修正后的效果:

translate

先从最简单的 translate 开始。

kotlin 复制代码
Modifier.drawBehind {
    drawCircle(
        color = Color.LightGray,
        radius = 30.dp.toPx(),
        center = Offset.Zero
    )

    translate(left = 100.dp.toPx(), top = 100.dp.toPx()) {
        drawCircle(
            color = Color.Blue,
            radius = 30.dp.toPx(),
            center = Offset.Zero
        )
    }
}

第一个圆画在左上角。

第二个圆看起来像是画在 (100dp, 100dp),但更准确地说,是坐标系的原点被移动到了 (100dp, 100dp),然后你又在新的 Offset.Zero 上画了一个圆。

背景是为了显示绘制区域,让大家更容易看清当前的相对位置。

这个区别很重要。因为一旦你接受"原点变了",也就是"坐标系变了",后面的 rotatescale 就好理解了。

rotate

kotlin 复制代码
Modifier.drawBehind {
    drawRect(Color.LightGray)

    rotate(degrees = 45f) {
        drawRect(Color.Red)
    }
}

这里不是"红色矩形自己转了 45 度"。

如果你理解了上面的说法,就会明白:在 rotate 代码块里,坐标系已经旋转了 45 度。你在里面调用 drawRect(),这个矩形当然就会出现在旋转后的坐标系里。

在代码块结束后,Compose 会帮你恢复之前的绘制状态。你不用像传统 Canvas 那样手动 save() / restore(),也就不容易出现状态没有恢复、导致后续绘制全部歪掉的问题。

还有一个细节要记住:DrawScope.rotate 默认使用当前 DrawScope 的中心作为 pivot。如果你已经用 translate 把局部原点挪到了目标位置,并且希望围绕这个局部原点旋转,就要显式传 pivot = Offset.Zero

scale

scale 也是一样。

kotlin 复制代码
Modifier.drawBehind {
    scale(scale = 1.5f) {
        drawCircle(
            color = Color.Green,
            radius = 30.dp.toPx(),
            center = Offset.Zero
        )
    }
}

你可以把它理解成:坐标系里的"单位"变大了。

所以不只是圆的半径会被放大,圆心位置、线宽、后续所有绘制命令都会受到影响。

如果你只是想让某个半径变大,直接改 radius 就行。如果你想让一整组图形一起放大缩小,用 scale 会是更自然的写法。

顺序很重要

一看到"顺序很重要"这句话,我又想起了 Modifier 的调用顺序,不过,Modifier 的调用顺序可能以后不再重要了。

变换最容易写错的地方,不是 API 名字,而是顺序。

一句话记住:每一次变换,都会改变下一行绘制代码所处的世界。

看两个场景。

原地旋转

要让图形原地旋转,通常是先 translate,再 rotate。更准确地说,是先把局部原点移到目标位置,再围绕这个局部原点旋转。

kotlin 复制代码
Modifier.drawBehind {
    val center = Offset(size.width / 2f, size.height / 2f)

    translate(center.x, center.y) {
        rotate(degrees = angle, pivot = Offset.Zero) {
            drawRect(
                color = Color.Red,
                topLeft = Offset(-20.dp.toPx(), -20.dp.toPx()),
                size = Size(40.dp.toPx(), 40.dp.toPx())
            )
        }
    }
}

这段代码的效果是:矩形围绕自己的局部中心旋转。

因为你先把原点挪到了中心,再围绕这个新原点旋转。

公转

rotate,再 translate,则是公转效果。

kotlin 复制代码
Modifier.drawBehind {
    val center = Offset(size.width / 2f, size.height / 2f)
    val orbitRadius = 80.dp.toPx()

    translate(center.x, center.y) { // 先把局部原点移动到公转中心
        rotate(degrees = angle, pivot = Offset.Zero) {
            translate(left = orbitRadius, top = 0f) { // 再沿旋转后的 X 轴移动半径距离
                drawCircle(
                    color = Color.Cyan,
                    radius = 10.dp.toPx(),
                    center = Offset.Zero
                )
            }
        }
    }
}

这段代码的效果是:小圆围绕中心转。

逻辑是这样的:

  1. 先把原点移到画布中心。
  2. 再把坐标系旋转 angle
  3. 然后沿着旋转后的 X 轴移动 orbitRadius
  4. 最后在新的 Offset.Zero 上画圆。

这就是公转效果的核心:先旋转,再平移。

pivot

pivot 有个坑:默认值别想当然。

如果你只是想让一个图形围绕某个点旋转,不用手写 translate + rotate

DrawScope.rotate 本身就支持 pivot

kotlin 复制代码
Modifier.drawBehind {
    val center = Offset(size.width / 2f, size.height / 2f)

    rotate(
        degrees = angle,
        pivot = center
    ) {
        drawRect(
            color = Color.Magenta,
            topLeft = center - Offset(40.dp.toPx(), 40.dp.toPx()),
            size = Size(80.dp.toPx(), 80.dp.toPx())
        )
    }
}

pivot 可以理解成"围绕哪个点旋转"。

这个效果和上面的 translate + rotate 很接近。

不过需要注意,DrawScope.rotate 的默认 pivot 是当前绘制区域的中心,不是 Offset.Zero(左上角)。这点和很多人直觉不一样。

所以我还是建议你先理解 translate + rotate(pivot = Offset.Zero) 的写法。因为一旦效果复杂起来,比如一个行星带着卫星转动,局部坐标系会比到处算绝对坐标清楚得多。

withTransform

如果你需要同时做多次变换,可以用 withTransform

kotlin 复制代码
Modifier.drawBehind {
    val center = Offset(size.width / 2f, size.height / 2f)

    withTransform({
        translate(center.x, center.y)
        rotate(degrees = angle, pivot = Offset.Zero)
        scale(scaleX = 1.2f, scaleY = 1.2f, pivot = Offset.Zero)
    }) {
        drawCircle(
            color = Color.Yellow,
            radius = 30.dp.toPx(),
            center = Offset.Zero
        )
    }
}

好处是结构更集中:上面描述坐标系怎么变,下面描述在这个坐标系里画什么。

复杂动画里我更喜欢这种写法,尤其是你要把几层变换嵌套起来的时候。

嵌套变换:实战行星和卫星

现在回到一个稍微复杂一点的场景:一个小球绕中心转,小球旁边还有一个卫星绕它转。

如果你用绝对坐标算,会很快变成三角函数大杂烩。

但用局部坐标系就很顺:

kotlin 复制代码
Modifier.drawBehind {
    val center = Offset(size.width / 2f, size.height / 2f)
    val orbitRadius = 90.dp.toPx()
    val moonRadius = 24.dp.toPx()

    translate(center.x, center.y) {
        rotate(degrees = planetAngle, pivot = Offset.Zero) {
            translate(orbitRadius, 0f) {
                drawCircle(
                    color = Color.Cyan,
                    radius = 12.dp.toPx(),
                    center = Offset.Zero
                )

                rotate(degrees = moonAngle, pivot = Offset.Zero) {
                    translate(moonRadius, 0f) {
                        drawCircle(
                            color = Color.White,
                            radius = 4.dp.toPx(),
                            center = Offset.Zero
                        )
                    }
                }
            }
        }
    }
}

这段代码里有三个坐标系:

  1. 画布坐标系:原点在组件左上角。
  2. 中心坐标系:原点在画布中心。
  3. 行星坐标系:原点在行星当前位置。

卫星的绘制完全不用知道"太阳中心"在哪里。它只要知道自己相对行星的位置。

这就是嵌套变换的价值:你不用把所有东西都换算成全局坐标。

一点点数学震撼

如果只是为了写 Compose 代码,你不需要天天手算矩阵。但知道一点底层模型,会让你更容易判断为什么顺序会影响结果。

默认情况下,画布坐标系可以理解成两个方向,也就是两个基向量:

  • X 轴:向右,写成 (1, 0)
  • Y 轴:向下,写成 (0, 1)

这两个方向组成了一个最普通的坐标系。用矩阵写出来就是单位矩阵:

text 复制代码
[1  0]
[0  1]

它的意思是:X 方向还是 X 方向,Y 方向还是 Y 方向,什么都没变。

所以当你画一个点 Offset(3f, 2f) 时,可以粗略理解成:

text 复制代码
(3, 2) × 单位矩阵 = (3, 2)

结果还是 (3, 2)

scale 改的是单位长度

如果调用 scale(2f),可以理解成坐标系变成了这样:

text 复制代码
[2  0]
[0  2]

这时 X 方向的一个单位变成 2px,Y 方向的一个单位也变成 2px

所以你在里面画 radius = 30f 的圆,看起来会变成 60px。不是圆这个对象自己偷偷改了半径,而是当前坐标系里的单位长度变了。

如果只想横向拉伸,可以理解成:

text 复制代码
[2  0]
[0  1]

X 方向被放大,Y 方向不变。矩形就会变宽,但高度不变。

rotate 改的是方向

旋转稍微绕一点,但核心还是同一件事:它改变了 X/Y 两个基向量的方向。

在 Android / Compose 的画布坐标系里,Y 轴向下,所以顺时针旋转 θ 可以写成:

text 复制代码
[cosθ  -sinθ]
[sinθ   cosθ]

假设旋转 60 度:

text 复制代码
cos60° = 0.5
sin60° ≈ 0.866

[0.5   -0.866]
[0.866  0.5  ]

这意味着:

  • 原来的 X 方向 (1, 0),会变成 (0.5, 0.866),也就是右下方。
  • 原来的 Y 方向 (0, 1),会变成 (-0.866, 0.5),在画布坐标里是左下方。

所以当你在 rotate 代码块里继续写:

kotlin 复制代码
drawCircle(center = Offset(100f, 0f), radius = 10f)

这个 Offset(100f, 0f) 的含义已经不是"往屏幕右边走 100px"了,而是"沿着旋转后的 X 轴走 100px"。

这就是为什么很多图形会"飞到奇怪的位置"。不是它飞了,是你还在用旧坐标系的直觉理解新坐标系里的点。

为什么 2x2 矩阵不够

缩放和旋转都可以用上面的 2x2 矩阵表示,因为它们改变的是方向和长度。

但平移不一样。

translate(100f, 50f) 的意思是:不管原来的点在哪里,都额外加上 (100, 50)

text 复制代码
x' = x + 100
y' = y + 50

单纯的 2x2 线性变换做不到这件事,因为它只能表达"把 x/y 乘一下、混一下",不能凭空加一个固定偏移量。

所以图形系统通常会用齐次坐标,把二维点扩展成三维形式:

text 复制代码
[a  b  tx]   [x]     [a·x + b·y + tx]
[c  d  ty] × [y]  =  [c·x + d·y + ty]
[0  0   1]   [1]     [        1       ]

这里的 a, b, c, d 负责旋转、缩放、错切,tx, ty 负责平移。

你不需要在业务代码里手写这个矩阵,但知道它的存在,就能理解为什么 translaterotatescale 最后可以被 Compose 放进同一套变换状态里处理。

如果你以前在 View 里写过变换(还记得 Matrix 吗?),这些内容应该不会陌生。

为什么顺序会影响结果

矩阵乘法有一个很烦但很重要的特点:顺序不能随便换。

text 复制代码
A × B  不一定等于  B × A

这就是为什么:

kotlin 复制代码
translate(center.x, center.y) {
    rotate(degrees = angle, pivot = Offset.Zero) {
        drawCircle(...)
    }
}

和:

kotlin 复制代码
rotate(degrees = angle) {
    translate(center.x, center.y) {
        drawCircle(...)
    }
}

不是一回事。

前者是先把局部原点挪到中心,再围绕这个局部原点旋转。后者是先把整个坐标系旋转,再沿着旋转后的坐标轴去平移。

写动画的时候,很多 bug 都来自这里。你以为自己只是把两行代码换了个顺序,但在矩阵层面,整个变换链已经变了。

回到 Compose:真正要记住什么

前面的矩阵不是为了让你手算,而是为了支撑几个判断:

  • translate 会改变 Offset.Zero 的位置。
  • rotate 会改变"向右"和"向下"的方向。
  • scale 会改变单位长度。
  • 调用顺序不同,结果就不同。

Compose 会维护当前绘制状态,后续绘制命令都会受这个状态影响。

裁剪

clipRectclipPath 也经常跟变换一起出现,但它们做的事情不一样。

translaterotatescale 改变的是"画在哪里"。

clipRectclipPath 改变的是"哪些地方允许画出来"。

kotlin 复制代码
Modifier.drawBehind {
    clipRect(
        left = 0f,
        top = 0f,
        right = size.width / 2f,
        bottom = size.height
    ) {
        drawCircle(
            color = Color.Red,
            radius = size.minDimension / 2f,
            center = Offset(size.width / 2f, size.height / 2f)
        )
    }
}

这段代码里,圆还是按原来的位置绘制,只是右半部分会被裁掉。

clipPath 可以做更复杂的裁剪,比如星形、圆角头像、自定义遮罩。但它的成本也更高,尤其是路径复杂、边缘还需要抗锯齿的时候。

所以,能用 clipRect 就别上来就用 clipPath。只有当形状本身确实需要复杂路径时,再考虑 clipPath

性能上要注意什么

变换通常比你在 Kotlin 层手动计算一堆点要划算。

比如你要画 16 个拖尾圆,让它们沿轨道排开。你可以每个点都手算 sin/cos,也可以复用同一套旋转和平移逻辑:

kotlin 复制代码
repeat(16) { index ->
    val trailAngle = angle - index * 8f
    val alpha = 1f - index / 16f

    translate(center.x, center.y) {
        rotate(degrees = trailAngle, pivot = Offset.Zero) {
            translate(orbitRadius, 0f) {
                drawCircle(
                    color = Color.Cyan.copy(alpha = alpha),
                    radius = 8.dp.toPx(),
                    center = Offset.Zero
                )
            }
        }
    }
}

这类写法的好处是:绘制逻辑更稳定,位置关系也更容易看懂。

不过也别把"变换很便宜"理解成"随便写都没成本"。真正影响性能的还有:

  1. 每帧创建多少对象。
  2. 绘制命令数量有多少。
  3. PathBrushShader 这类对象有没有缓存。
  4. 裁剪路径是不是过于复杂。
  5. 动画是否导致不必要的重组和布局。

如果绘制里需要复用 Path 或渐变,还是上一篇说的老规矩:优先考虑 drawWithCache

一点想法

上一篇文章我们解决的是"为什么要用 DrawScope":当一个效果本质上只是绘制问题时,直接进入绘制阶段,往往比堆一层又一层 Composable 更直接,也更容易避开不必要的重组和布局开销。

这一篇解决的是"进入 DrawScope 之后怎么把图形动起来":translaterotatescale 不是在单独移动某个图形,而是在改变后续绘制命令所在的坐标系。只要把这个模型想明白,很多看起来玄乎的动画,其实都可以拆成几层局部坐标系:先把原点放到合适的位置,再决定旋转、平移、缩放的顺序。

所以,DrawScope 真正有价值的地方不只是"能画",而是它给了你一种更低成本组织视觉效果的方式:静态部分用路径和缓存控制对象分配,动态部分用变换控制位置关系。前者解决性能底线,后者解决表达能力。

写到这里,Compose 自定义绘制的基本框架就比较完整了:知道什么时候该画,知道在哪一层画,也知道怎么通过坐标系变换把图形组织起来。后面再遇到进度条、仪表盘、加载动画、粒子轨迹这类效果,就不用一上来堆布局节点了,先想想:这个效果是不是可以直接在 DrawScope 里画出来。

相关推荐
美狐美颜SDK开放平台12 小时前
美颜SDK开发详解:如何优化美颜SDK在低端安卓机上的性能?
android·ios·音视频·直播美颜sdk·视频美颜sdk
Gary Studio12 小时前
深入MTK Android BSP:如何确定编译目标与查找项目设备树
android
casual_clover13 小时前
【Android】实现状态栏背景透明,系统时间/图标直接显示在页面背景上
android·透明状态栏
blackorbird13 小时前
Android Pixel 10 零点击漏洞利用链
android
_kerneler13 小时前
[qemu+kvm] vfio-platform irq 注入过程
android
亚空间仓鼠13 小时前
Docker容器化高可用架构部署方案(十一)
android·docker·架构
我命由我1234513 小时前
Android 开发问题:TextView 内容超过宽度时,默认不会换行
android·开发语言·java-ee·android studio·android jetpack·android-studio·android runtime
shandianchengzi14 小时前
【科普】安卓|安卓手机上如何简便实现Ctrl+Z(需要键盘或一台Windows电脑)
android·windows·智能手机·计算机外设·安卓·科普·记录
赏金术士21 小时前
Compose 教学项目
android·kotlin·compose