Canvas与绘图:从零开始画点东西

说实话,Jetpack Compose 里的 Canvas 是我最喜欢的功能之一。为什么?因为它让你能完全掌控屏幕上的每一个像素。你想想看,在 XML 时代,想画个自定义 View 得继承、重写 onDraw、处理测量... 一堆模板代码。现在呢?一个 Canvas composable 就搞定了。

我个人习惯把 Canvas 看作是「一张无限大的画布」。你可以在上面画线、画圆、画矩形,甚至用 Path 画出任意形状。这节课,我们就从最基础的绘图 API 开始,一步步掌握 Canvas 的核心用法。

Canvas composable 的基本用法

先看一个最简单的例子:

@Composable
fun SimpleCanvas() {
    Canvas(
        modifier = Modifier
            .size(200.dp)
            .background(Color.LightGray)
    ) {
        // 在这里绘制内容
        drawCircle(Color.Red, radius = 50.dp.toPx())
    }
}

嗯,这里要注意:Canvas 内部是一个 DrawScope 上下文。所有绘图操作都在这个作用域里执行。坐标原点在 Canvas 的左上角,x 轴向右,y 轴向下。

💡 我的习惯: 每次用 Canvas 之前,先想清楚坐标系。尤其是做动画时,坐标系搞反了,画面会飞出去。

drawLine:画一条线

画线是最基础的操作。我在项目中经常用它来做分隔线、网格线或者图表中的趋势线。

Canvas(modifier = Modifier.size(200.dp)) {
    val canvasWidth = size.width
    val canvasHeight = size.height
    
    drawLine(
        color = Color.Blue,
        start = Offset(0f, canvasHeight / 2),
        end = Offset(canvasWidth, canvasHeight / 2),
        strokeWidth = 4f,
        cap = StrokeCap.Round  // 线帽样式
    )
}

参数不多,但有几个坑:

  • strokeWidth 的单位是 px,不是 dp。记得用 dp.toPx() 转换
  • cap 控制线两端的形状,Round 会让端点变圆润
  • pathEffect 可以画虚线,比如 PathEffect.dashPathEffect(floatArrayOf(10f, 10f))
⚠️ 我曾经踩过的坑: 在 drawLine 里传了 dp 值给 strokeWidth,结果线细得几乎看不见。后来才意识到,所有绘图 API 的参数都是 px。养成习惯:2.dp.toPx()

drawCircle:画一个圆

画圆比画线稍微复杂一点,但也不难。核心参数是圆心坐标和半径。

Canvas(modifier = Modifier.size(200.dp)) {
    val centerX = size.width / 2
    val centerY = size.height / 2
    
    // 实心圆
    drawCircle(
        color = Color(0xFFFF6B35),
        radius = 60.dp.toPx(),
        center = Offset(centerX, centerY)
    )
    
    // 空心圆(只画边框)
    drawCircle(
        color = Color.Black,
        radius = 60.dp.toPx(),
        center = Offset(centerX, centerY),
        style = Stroke(width = 3.dp.toPx())
    )
}

这里有个小技巧:用 style = Stroke(...) 画边框,用默认的 Fill 画实心。两者可以叠加,做出描边效果。

drawRect:画矩形

矩形在 UI 中太常见了。进度条、卡片背景、按钮... 本质上都是矩形。

Canvas(modifier = Modifier.size(200.dp)) {
    // 普通矩形
    drawRect(
        color = Color(0xFF4CAF50),
        topLeft = Offset(20.dp.toPx(), 20.dp.toPx()),
        size = Size(160.dp.toPx(), 80.dp.toPx())
    )
    
    // 圆角矩形
    drawRoundRect(
        color = Color(0xFF2196F3),
        topLeft = Offset(20.dp.toPx(), 120.dp.toPx()),
        size = Size(160.dp.toPx(), 60.dp.toPx()),
        cornerRadius = CornerRadius(12.dp.toPx())
    )
}

你想想看,如果不用 Canvas,要实现圆角矩形得用 Modifier.clip(RoundedCornerShape(...))。但 Canvas 给了你更底层的控制权,比如可以只画左上角圆角、其他角直角。

drawPath:画出任意形状

Path 才是 Canvas 的灵魂。它能画出任何你想要的形状——曲线、贝塞尔、多边形...

Canvas(modifier = Modifier.size(200.dp)) {
    val path = Path().apply {
        // 移动到起点
        moveTo(100.dp.toPx(), 20.dp.toPx())
        // 画一条线到右下
        lineTo(180.dp.toPx(), 160.dp.toPx())
        // 画一条线到左下
        lineTo(20.dp.toPx(), 160.dp.toPx())
        // 闭合路径
        close()
    }
    
    drawPath(
        path = path,
        color = Color(0xFFE91E63),
        style = Stroke(width = 4.dp.toPx())
    )
}

Path 的 API 很丰富:

  • moveTo(x, y):移动画笔,不画线
  • lineTo(x, y):从当前位置画直线到目标点
  • quadraticBezierTo():二次贝塞尔曲线
  • cubicTo():三次贝塞尔曲线
  • addCircle()addRect():添加子形状
🔑 核心思路: Path 就像一支笔,你告诉它「走到这里」、「画到那里」、「拐个弯」。所有复杂的图形,都是这些基本动作的组合。

Path 动画:让图形动起来

静态的 Path 已经很强了,但加上动画才真正酷。我记得在做一个「加载中」动画时,用 Path 动画实现了路径描边效果,用户反馈说「这动画很丝滑」。

核心思路是用 AnimatableanimateFloatAsState 控制一个 0f 到 1f 的进度值,然后通过 PathMeasure 截取路径的一部分来绘制。

@Composable
fun PathAnimation() {
    val progress = remember { Animatable(0f) }
    
    LaunchedEffect(Unit) {
        progress.animateTo(
            targetValue = 1f,
            animationSpec = tween(durationMillis = 2000, easing = LinearEasing)
        )
    }
    
    val path = remember {
        Path().apply {
            moveTo(50f, 150f)
            cubicTo(50f, 50f, 150f, 50f, 150f, 150f)
        }
    }
    
    val pathMeasure = remember { PathMeasure(path, false) }
    val pathLength = pathMeasure.length
    
    Canvas(modifier = Modifier.size(200.dp)) {
        val currentProgress = progress.value
        val segmentPath = Path()
        
        // 从路径起点截取到当前进度位置
        pathMeasure.getSegment(
            startDistance = 0f,
            stopDistance = pathLength * currentProgress,
            destination = segmentPath,
            startWithMoveTo = true
        )
        
        drawPath(
            path = segmentPath,
            color = Color(0xFF6200EE),
            style = Stroke(
                width = 6.dp.toPx(),
                cap = StrokeCap.Round
            )
        )
    }
}

这段代码做了三件事:

  1. Animatable 驱动一个 0→1 的动画
  2. PathMeasure 测量路径总长度
  3. 每帧根据进度截取路径的一部分来绘制

效果就是:一条曲线从起点开始「生长」到终点。很像你用手指在屏幕上画线,线会慢慢出现。

💡 优化建议: 如果路径很复杂,记得把 PathMeasurepathremember 缓存起来。否则每帧重建对象,性能会崩。我刚开始没注意,动画一跑就掉帧。

知识体系总览

下面这张图总结了 Canvas 绘图的整体脉络:

Canvas 绘图知识体系 Canvas composable drawLine drawCircle drawRect drawPath start / end / strokeWidth center / radius / style topLeft / size / cornerRadius PathMeasure + 动画

总结一下

Canvas 绘图的核心就四个 API:

API 用途 关键参数
drawLine 画直线 start, end, strokeWidth, cap
drawCircle 画圆 center, radius, style
drawRect 画矩形 topLeft, size, cornerRadius
drawPath 画任意形状 path, style, PathMeasure

我个人觉得,Path 动画是这里面最有意思的部分。它把「静态绘图」和「动态交互」连在了一起。你想想看,一个 App 里那些流畅的加载动画、手势轨迹、图表绘制... 底层都是 Path + 动画的组合。

嗯,这一节的内容就到这里。代码不多,但每个 API 都值得你亲手敲一遍。尤其是 Path 动画,多调调参数,感受一下 PathMeasure.getSegment() 的截取逻辑。下次遇到自定义绘图需求,你就知道该从哪里下手了。


公众号:蓝海资料掘金营,微信deep3321