上周和同事一起讨论关于 Compose 推广问题的时候,同事提了一个点:感觉 Compose 用多了,就会依赖重组来做特效,以前复杂一点的效果愿意用 View 的 onDraw 去处理,到了 Compose,就很少直接去绘制了。
我回答道:那是你现在用得少,还不知道怎么用 Compose 的 draw。
你等我下一篇文章,我给你讲得明明白白!
这就来了

如果你是一位 Compose 开发者,那么迟早会碰到这么一个瞬间------在你第三次嵌套 Box、第五次用 Modifier.background() 的时候,突然反应过来:这也太绕了吧。
又或者,你只是刷到了一篇讲 background 的重组陷阱 的文章,觉得挺有意思,就顺着往下挖了挖。
不管怎样,最后你都会得出同一个结论:你需要的不是又一个布局节点,而是 Canvas。
说得更直白一点:我不要布局,不要重组,只要绘制效果。
这篇文章不只是教你画几个形状,而是要带你深入 Compose 绘制管线(Drawing Pipeline)的底层,搞明白怎么做出既好看、架构又靠谱的 UI。
性能是要收税的
官方文档不太会跟你强调的一点是:Compose 里的标准 Composable 其实都是"万金油"。Box、Column、Row 被设计用来应对各种布局需求,但代价是它们身上背着所有场景的开销。
灵活性这东西,是要拿性能来换的。
拿一个带动画分段的自定义进度条来说吧。
你当然可以用一堆 Box 加旋转修饰符去叠,一个小小的视觉元素就能搞出 8-12 个 UI 树节点。每个节点都要走"测量---布局---绘制"这套流程,每个都可能触发重组。
但如果你换成一个 Modifier.drawBehind 呢?
直接往 Canvas 上画,没有多余的节点、没有布局开销。同样的视觉效果,只在绘制阶段就搞定了,而且当只有视觉属性变化时,重组和布局可以直接跳过。
Compose 中的 DrawScope 就是为这个场景而生的。
它提供了一个干净的环境,让你能直接操作 Canvas,同时在底层帮你处理好密度转换、坐标系和硬件加速这些杂事。
学会了 DrawScope,你就有了"压平"UI 树的能力。
与其加三个 Spacer 和一个 Box 去画个装饰背景,不如直接在现有组件的绘制阶段插一脚,在硬件加速的 Canvas 上把图形画出来。
DrawScope 的意义
一聊到 DrawScope,就说明我们从"What"(声明式 UI,Text(name))转向了"How"(命令式编程,textView.setText(name))。
读完这篇文章,你就会搞清楚 DrawScope 到底是什么环境,为什么说 Modifier.drawBehind 是个性能"外挂",还会通过手搓一个精确调试工具,让你感受一下手动路径渲染的威力。
如果你想从"写 UI 的人"变成"懂渲染系统的人",理解绘制层主要给你三样东西:
- 性能:跳过那些没必要的重组和布局阶段。
- 精准 :做到像素级别的精确控制,不受
LayoutNode系统的限制。 - 效率:让 Compose 运行时少跟踪一些对象,内存占用自然就下来了。
先搞清楚在哪画、怎么画
在动笔画线之前,得先弄明白两件事:"在哪里画"和"怎么画"。以前用 View 系统的时候,入口是 onDraw(canvas: Canvas);到了 Compose,入口换成了 DrawScope。
DrawScope 不是一堆绘图命令的简单集合,它更像是一个带约束的舞台------在底下帮你接好了硬件加速的 Canvas,还自动处理了 dpi 适配。
开始绘制
在 Compose 里,你不用重写什么方法,通过 Modifier 就能插到绘制阶段。主要有三个入口,各有各的用处。
1. drawBehind
这是最常用的入口,让你在 Composable 的主要内容"后面"画画。
- 适合场景: 自定义背景、阴影、装饰性的点缀。
- 性能优势: 非常轻量,不会给 UI 树多加任何节点。
kotlin
Text(
text="别忘了关注 RockByte 哦",
color = Color.Black,
textAlign = TextAlign.Center,
modifier = Modifier
.size(120.dp)
.drawBehind { // DrawScope 接收者
drawCircle(Color.Gray, radius = size.minDimension / 2)
}
)

2. drawWithContent
这个可以理解成"拦截器",由你来控制渲染顺序。
- 适合场景: 覆盖层、毛玻璃效果,或者任何需要在 UI 内容上下做手脚的场景。
- 关键特性: 你得自己调用
drawContent(),不然实际的 Composable 内容不会被渲染出来。
kotlin
Text(
"Text with semi-transparent overlay",
modifier = Modifier.drawWithContent { // ContentDrawScope 接收者
drawContent() // 先绘制文本,此处理解为绘制本来应该绘制的内容
// 在上面叠一层着色
drawRect(Color.Red, size = this.size.copy(width = this.size.width / 2f))
}
)
为了更直观地演示效果,我使用了这种覆盖的颜色,这样便可以看出,长方形是盖在文字上面的。

3. drawWithCache
性能优化首选。
如果绘制操作涉及到一些比较"贵"的对象分配(比如 Path 或 Shader),drawWithCache 的好处是------只在尺寸变化或者你读取的状态值变了的时候才重新创建这些对象,而不是每帧都 new 一遍。
- 适合场景: 那些不需要每帧都变的复杂形状或渐变。
kotlin
Box(
modifier = Modifier
.size(120.dp)
.drawWithCache {
// 分配 Path
val path = Path().apply {
moveTo(0f, 0f)
lineTo(size.width, size.height)
moveTo(size.width, 0f)
lineTo(0f, size.height)
addOval(
androidx.compose.ui.geometry.Rect(
size.width * 0.1f,
size.height * 0.1f,
size.width * 0.9f,
size.height * 0.9f
)
)
}
onDrawBehind { // 具体的绘制逻辑
drawPath(path, Color.Black, style = Stroke(width = 3f))
}
}
)

命令式编程
你会发现,此处有个概念转变,就像编写 View 的 onDraw 逻辑一样,很多 Compose 开发者刚接触到此处时都会愣一下:等等,那我的声明式呢?
一旦进入 DrawScope,你就从声明式编程切换到了命令式编程。
平时写 Compose,你只管描述 UI 长什么样,框架自己去琢磨怎么实现------比对状态、跳过没变的 Composable、为了效率调整执行顺序。你想的是"结果"。
但到了 DrawScope 里面,你说的就是命令了。
drawRect()、drawLine()、drawPath() 按顺序一个接一个执行,就像在画布上一层一层地刷油画颜料。顺序就是一切------先画的在下面,后画的在上面。框架不会帮你优化执行顺序,也不会跳过重复的调用,你写什么它就老老实实执行什么。
kotlin
Modifier.drawBehind {
drawRect(Color.Blue) // 第一层:填充整个边界
drawCircle(Color.Red) // 第二层:在蓝色之上
}
这就是画家算法(Painter's Algorithm)------比现代 UI 框架早了几十年的渲染方式。
DrawScope 用起来感觉不一样,原因就在这儿:你不是在描述意图,而是在亲自指挥每一步操作。
那 Compose 为什么要暴露这么一个命令式的东西出来?
因为有些效果你没法用声明式去"描述",只能靠一笔一笔画出来:比如沿着自定义曲线走的渐变、对物理做出反应的粒子系统、或者每一帧都因为实时数据而长得不一样的可视化。
这些东西需要你直接控制渲染顺序,而不是让一个抽象层去猜你想干嘛。
你可以把 DrawScope 想象成一个你控制的遥控器:Compose 管着你的绘制代码什么时候跑(在绘制阶段),但里面具体发生了什么,全由你说了算。
前置知识
上面只是一点前菜,我们得搞出一点真家伙才行。
不过在这之前,先弄清楚绘制世界的几条基本规则。
1. 原点在左上角
现实生活中我们习惯了坐标从中心或者左下角开始,但在图形世界(包括 DrawScope)里,原点 (0,0) 永远是组件边界的左上角。
- X 轴: 往右越来越大。
- Y 轴: 往下越来越大。
- size 对象: 在
DrawScope里可以直接拿到size。它不是"我想要多大"的请求,而是布局阶段确定下来的、实实在在的最终像素尺寸。
kotlin
Modifier.drawBehind {
drawCircle(
color = Color.Red,
radius = 50f,
center = Offset.Zero // 在左上角绘制
)
}
2. dp 与 px
开发者转到自定义绘制时最常踩的坑:DrawScope 里几乎全是像素(px)在操作,但设计稿得适配不同屏幕密度的手机。
好在 Compose 在 DrawScope 里已经帮你接好了密度适配的接口。你不用自己去拿屏幕密度,直接用 toPx() 和 toDp() 这些辅助函数就行,因为 DrawScope 本身实现了 Density 接口。
小贴士: 千万别硬编码像素值。比如一条线要 2dp 粗,就写 2.dp.toPx()。
这样不管是在 2015 年的低端机还是 2025 年的旗舰机上,你画出来的东西看着都一样。
如果你写 4.px,那就糟糕了。。。在好的机器上,这条线会特别细!
kotlin
drawCircle(
color = Color.Cyan,
radius = 12.dp.toPx(), // Good
center = Offset(size.width / 2, size.height / 2)
)
3. 移动
在 DrawScope 里调用 rotate()、translate() 或 scale() 的时候,你动的不是画笔,而是整个坐标系。
kotlin
Box(
modifier = Modifier
.size(160.dp)
.background(Color.Red.copy(alpha = 0.2f))
.drawBehind {
drawCircle(Color.LightGray, radius = 60f, center = Offset.Zero)
translate(left = 100f, top = 100f) { // 原点 (0,0) 现在位于父级空间的 (100, 100)
drawCircle(Color.Blue, radius = 50f, center = Offset.Zero)
// 这个圆出现在 (100, 100),而不是 (0, 0)
}
}
)

想象一下:你拿一张描图纸盖在画布上,然后去移动那张描图纸。你在上面画画的时候手感是正常的,但底下画布上看到的结果是移动过后的。
所以变换调用要把绘图代码包在里面:
kotlin
Box(
modifier = Modifier
.padding(start = 100.dp)
.size(160.dp)
.drawBehind {
drawRect(Color.LightGray)
rotate(degrees = 45f) {
drawRect(Color.Magenta) // 旋转了
}
// 变换结束------坐标系恢复原样
scale(0.4f) {
drawRect(Color.Green) // 缩小了
}
}
) {
Text("RockByte 公众号", Modifier.align(Alignment.Center))
}

这种代码,写多了就会感觉,妙不可言!
Compose 会自动维护状态栈,每次变换会自动压栈,代码块一退出就出栈。这能防止"变换漂移"的 bug------就是旋转和平移在各帧之间不知不觉地越攒越多。
性能小知识: 变换在 GPU 层面就是矩阵运算。分别旋转 100 个元素再绘制,和旋转一次坐标系后再画 100 个元素,后者要高效得多------所以直接进行坐标系变换这种方式非常值得推荐。
搞一个 UI 网格工具
光说不练假把式。为了让你对命令式绘制有更直观的感受,我们来做一个开发者多数情况下会用到的工具:精度网格叠加层。
我之前开发车机 App 的时候,盒子厂商是拿着尺子去量 UI 的,当时最让我头疼的就是字体!
为啥要做网格?
因为就算设计师再靠谱,偶尔也会出现这种事------设计稿上标的 8dp,到了实现里不知道怎么就变成了 7.5dp。
做这么一个能叠像素级精确网格的自定义 Modifier,就能直接在设备上验证对齐和间距,省得产品经理说:"你做的好像歪了"。
朴素实现
kotlin
fun Modifier.precisionGrid(color: Color) = drawWithContent {
drawContent()
val stepSize = 10.dp.toPx().roundToInt().toFloat()
var x = stepSize
// 绘制竖线
while (x < size.width) {
drawLine(
color = color,
start = Offset(x = x, y = 0f),
end = Offset(x = x, y = size.height),
strokeWidth = 1f,
)
x += stepSize
}
var y = stepSize
// 绘制横线
while (y < size.height) {
drawLine(
color = color,
start = Offset(x = 0f, y = y),
end = Offset(x = size.width, y = y),
strokeWidth = 1f,
)
y += stepSize
}
}

如果你在真机上测试,你可能会碰到一个挺烦人的现象------"模糊线"。
我给你的截图实际上也有点虚。
这其实碰到了数字图形学里一个绕不开的东西------抗锯齿(Anti-Aliasing)。
当你告诉 DrawScope 在 x = 10.0 这个位置画一条 strokeWidth 为 1px 的线时,渲染器会把描边居中放在这个坐标上。半个像素在 9.5,半个像素在 10.5。但屏幕没法点亮"半个"像素,硬件只好把颜色混合到相邻两个像素上来凑合。
怎么修: 加一个 0.5f 的偏移量,把坐标"卡"到像素中心上,这样 1px 的线刚好占满一个物理像素行/列。
kotlin
// 在循环内部,调整偏移量:
start = Offset(x = x + 0.5f, y = 0f)

对比上面那一张图,能看出区别吗?
Path 优化
while 循环的方式能用,但写法上确实啰嗦了点。
在 4K 屏上画密集网格的话,每帧可能要发好几百个 drawLine 命令,每个命令都是对底层图形 API 的一次独立调用,这就有点浪费了。
想要更高性能,用 Path 就对了。
Path 可以用一个对象描述一整套复杂的几何形状,然后一次操作就丢给 GPU,大大减少了 Kotlin 代码和硬件之间的来回沟通开销。
kotlin
fun Modifier.optimizedGrid(gridColor: Color): Modifier = drawWithCache {
val stepSizePx = 10.dp.toPx().roundToInt().toFloat()
val strokeWidth = 1f
val offset = 0.5f
val gridPath = Path().apply {
// 竖线
var x = 0f
while (x <= size.width) {
moveTo(x + offset, 0f)
lineTo(x + offset, size.height)
x += stepSizePx
}
// 横线
var y = 0f
while (y <= size.height) {
moveTo(0f, y + offset)
lineTo(size.width, y + offset)
y += stepSizePx
}
}
onDrawWithContent {
drawContent()
drawPath(
path = gridPath,
color = gridColor,
style = Stroke(width = strokeWidth)
)
}
}

为啥这种写法好呢:
- drawWithCache :把
Path的创建和计算从即时绘制循环里挪出来了。组件重新绘制的时候(比如动画期间),只要容器尺寸没变,网格的几何数据就不会被重新算一遍。 - GPU 效率 :用
drawPath给显卡发的是单条指令,而不是一堆断断续续的小命令。 - 视觉精度:考虑了子像素渲染的问题,确保你做的诊断工具跟它量的 UI 一样精确。
顺便说说性能
DrawScope 里的代码每秒可能被调用 60 到 120 次(你想想你搞了个渐变的动画)。你在里面创建的任何对象------不管是 Path、Paint、Shader 还是最简单的 Offset------最后都得被垃圾回收。
GC 跑起来的时候可能会造成卡顿(jank),因为系统会暂停下来去回收内存。
所以,如果你发现自己在 drawBehind 里写了 val path = Path(),赶紧打住。把它挪到 drawWithCache 或者 remember 里去,这样 UI 才能保持丝滑。
总结
我们看到了 Modifier.drawBehind 和 drawWithContent 怎么帮我们插进渲染管线,再加上像开发者网格这样的精确工具。同时也明白了,能力越大责任越大------像素对齐和对象分配这些细节管好了,UI 才能跑得顺畅。
下一步
会画网格只是个开头。你现在可以用像素级精度画静态形状了,但 UI 很少有完全不动的。
下一篇文章,我们会深入状态变换(Stateful Transformations)------看看怎么在不重新画坐标系的情况下移动、旋转和缩放图形,揭开 withTransform 的面纱,这可是做出复杂、高性能动画的关键。
期待下一篇文章吧!