第4章 Canvas进阶操作:画布变换与裁剪机制

说实话,Canvas的变换操作是我刚学自定义View时最头疼的部分。那时候我总觉得,画个圆就画圆,干嘛还要平移旋转?直到我在项目中做一个地图标注功能,需要把图标旋转到特定角度...嗯,那次我彻底明白了变换的重要性。

4.1 画布变换:让绘图更灵活

Canvas的变换操作,说白了就是改变画布坐标系。你想想看,默认坐标系是左上角为原点,X轴向右,Y轴向下。但实际开发中,我们经常需要换个角度、换个位置来画东西。

4.1.1 平移(translate)

平移是最基础的变换。它把坐标系的原点移动到新的位置。

// 把原点移到 (100, 100)
canvas.translate(100f, 100f)
// 现在画一个圆,圆心在 (0, 0),实际显示在 (100, 100)
canvas.drawCircle(0f, 0f, 50f, paint)

我个人习惯用平移来做相对定位。比如画一个仪表盘,所有刻度都相对于中心点来画,这样代码清晰多了。

4.1.2 旋转(rotate)

旋转操作让画布绕某个点转动。注意,旋转会影响后续所有绘制。

// 绕原点旋转45度
canvas.rotate(45f)
// 或者绕指定点旋转
canvas.rotate(45f, centerX, centerY)
小技巧: 我在做时钟控件时,用旋转来画刻度线。每画一条线就旋转6度,12条刻度线轻松搞定。

4.1.3 缩放(scale)

缩放可以改变坐标轴的单位长度。参数是X轴和Y轴的缩放比例。

// X轴放大2倍,Y轴不变
canvas.scale(2f, 1f)
// 或者整体缩放
canvas.scale(1.5f, 1.5f)
注意: 缩放也会影响画笔的宽度!我曾经在项目中用缩放画一个放大镜效果,结果边框也跟着变粗了,看起来很奇怪。后来我改用Path的变换来避免这个问题。

4.1.4 错切(skew)

错切可能用得少一些,但做倾斜效果时很管用。它让图形沿着某个方向拉伸。

// X轴错切0.5,Y轴不变
canvas.skew(0.5f, 0f)

错切的效果有点像把正方形拉成平行四边形。我在做3D翻转动画时用过它,配合旋转能模拟出透视效果。

4.2 save与restore机制:状态栈管理

这里我要重点说一下save和restore。很多初学者不理解为什么要用它们,觉得麻烦。其实它们解决了一个核心问题:变换的隔离

你想想看,如果你在画一个复杂图形时做了平移和旋转,画完之后坐标系就变了。后面的绘制全都会受影响。这时候就需要save和restore来保存和恢复画布状态。

// 保存当前状态
canvas.save()

// 做一些变换
canvas.translate(100f, 100f)
canvas.rotate(45f)
// 画一个旋转后的矩形
canvas.drawRect(0f, 0f, 200f, 100f, paint)

// 恢复之前的状态
canvas.restore()

// 这里的坐标系还是原来的,不受影响
canvas.drawCircle(0f, 0f, 50f, paint)
核心理解: save和restore就像是一个栈。每次save压入一个状态,每次restore弹出一个状态。你可以嵌套使用,但一定要成对出现。

我曾经犯过一个错误:在循环里调用了save但忘了restore,结果画布状态越来越乱,图形完全变形了。排查了半天才发现是状态栈不平衡。

4.3 Clip裁剪与Region

裁剪是Canvas另一个强大的功能。它让你只绘制指定区域内的内容,区域外的部分会被自动裁剪掉。

4.3.1 基本裁剪

// 裁剪一个矩形区域
canvas.clipRect(100f, 100f, 300f, 300f)
// 后续绘制只在这个区域内显示
canvas.drawBitmap(bitmap, 0f, 0f, paint)

4.3.2 路径裁剪

更灵活的是用Path来裁剪,可以裁剪出任意形状。

val path = Path()
path.addCircle(200f, 200f, 150f, Path.Direction.CW)
canvas.clipPath(path)
// 现在只有圆形区域内的内容会显示
实用场景: 我在做头像裁剪功能时,用clipPath裁剪出一个圆形区域,然后让用户拖动图片,只显示圆形内的部分。效果很自然。

4.3.3 Region操作

Region是Android中表示区域的高级类。它支持各种集合运算:交集、并集、差集等。

val region1 = Region(100, 100, 300, 300)
val region2 = Region(200, 200, 400, 400)

// 计算交集
val intersectRegion = Region()
intersectRegion.op(region1, region2, Region.Op.INTERSECT)

// 应用到Canvas
canvas.clipRegion(intersectRegion)

Region操作在复杂裁剪场景下特别有用。比如你要裁剪出一个圆环形状,就可以用两个圆的Region做差集。

性能提醒: clipPath和clipRegion在硬件加速下可能不支持。如果你发现裁剪没效果,检查一下是否关闭了硬件加速。我一般在View的构造器里调用 setLayerType(LAYER_TYPE_SOFTWARE, null)

4.4 实战:组合变换实现复杂效果

好了,理论说完了。我们来个实战例子:画一个旋转的齿轮。

class GearView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.STROKE
        strokeWidth = 4f
    }
    
    private var rotationAngle = 0f
    
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        
        val centerX = width / 2f
        val centerY = height / 2f
        val radius = minOf(centerX, centerY) - 20f
        
        // 保存初始状态
        canvas.save()
        
        // 平移到中心
        canvas.translate(centerX, centerY)
        // 旋转
        canvas.rotate(rotationAngle)
        
        // 画齿轮外圈
        canvas.drawCircle(0f, 0f, radius, paint)
        
        // 画齿轮齿
        for (i in 0 until 12) {
            canvas.save()
            canvas.rotate(i * 30f)
            canvas.drawRect(
                -8f, radius - 20f,
                8f, radius + 10f,
                paint
            )
            canvas.restore()
        }
        
        // 画内圈
        canvas.drawCircle(0f, 0f, radius * 0.6f, paint)
        
        // 恢复初始状态
        canvas.restore()
        
        // 更新旋转角度
        rotationAngle += 2f
        if (rotationAngle >= 360f) {
            rotationAngle = 0f
        }
        invalidate()
    }
}

这个例子用到了平移、旋转、save/restore的嵌套使用。每个齿都是独立旋转后绘制的,互不影响。这就是状态栈管理的精髓。

4.5 避坑指南

最后,分享几个我踩过的坑:

  • 变换顺序很重要:先平移再旋转和先旋转再平移,结果完全不同。我建议先平移再旋转,这样更符合直觉。
  • save和restore要配对:我习惯在onDraw开头调用save,结尾调用restore。如果中间有嵌套,用try-finally保证配对。
  • 裁剪后记得恢复:裁剪会影响后续所有绘制。如果只想裁剪一部分,一定要用save/restore包裹。
  • 硬件加速限制:clipPath在硬件加速下可能失效。如果遇到问题,先试试关闭硬件加速。

嗯,Canvas的变换和裁剪就讲到这里。这些操作看起来简单,但组合起来能实现非常丰富的效果。下一章我们会讲Path的高级用法,到时候这些变换技巧会派上大用场。