扩展函数与高阶函数在自定义View中的应用

自定义 View 是 Android 开发中绕不开的话题。说实话,早期我做自定义 View 时,代码总是又臭又长——Canvas 操作一堆重复代码,Paint 配置散落在各处,触摸事件处理更是让人头疼。直到我把扩展函数和高阶函数用进去,整个代码风格才变得清爽起来。

今天我们就聊聊这三样东西怎么组合使用:Canvas 的扩展函数、Paint 的 DSL 配置、触摸事件的简化。我保证,看完你也会爱上这种写法。

Canvas 扩展函数:告别重复的 save/restore

先看一个最常见的场景。每次用 Canvas 做平移、旋转、裁剪时,我们都要手动调用 save()restore()。写多了真的很烦。

// 传统写法
canvas.save()
canvas.translate(100f, 100f)
canvas.rotate(45f)
// 画点什么
canvas.restore()

我个人习惯用扩展函数把这个过程封装起来。核心思路就是:把 save/restore 的逻辑交给高阶函数去处理

// 定义一个 Canvas 的扩展函数
fun Canvas.withSave(block: Canvas.() -> Unit) {
    save()
    block()
    restore()
}

// 使用
canvas.withSave {
    translate(100f, 100f)
    rotate(45f)
    drawCircle(0f, 0f, 50f, paint)
}

你看,withSave 接收一个 Canvas.() -> Unit 类型的高阶函数。在这个 lambda 内部,this 就是 Canvas 本身,所以可以直接调用 translaterotatedrawCircle 这些方法。save 和 restore 完全被隐藏了。

我的经验:我在项目中遇到过一种情况——嵌套了多层 save/restore,结果忘记 restore,导致绘制错乱。用 withSave 之后,这种问题再也没出现过。闭包天然保证了成对调用。

还可以继续扩展。比如加上裁剪功能:

fun Canvas.withClip(
    left: Float, top: Float, right: Float, bottom: Float,
    block: Canvas.() -> Unit
) {
    save()
    clipRect(left, top, right, bottom)
    block()
    restore()
}

说白了,这就是把「固定流程」抽象成扩展函数,把「变化的部分」用高阶函数暴露出去。你想想看,是不是很优雅?

Paint 的 DSL 配置:让属性设置更直观

Paint 的配置项很多——颜色、粗细、抗锯齿、样式、字体……传统写法是这样的:

val paint = Paint().apply {
    color = Color.RED
    strokeWidth = 5f
    isAntiAlias = true
    style = Paint.Style.STROKE
    strokeCap = Paint.Cap.ROUND
}

嗯,apply 已经比直接 setter 好很多了。但如果我们想更进一步,可以自己写一个 DSL 构建器:

fun paint(block: Paint.() -> Unit): Paint {
    return Paint().apply(block)
}

// 使用
val myPaint = paint {
    color = Color.BLUE
    strokeWidth = 3f
    isAntiAlias = true
    style = Paint.Style.FILL
}

这其实就是一个简单的 DSL。但真正的妙用在于——我们可以把 Paint 的配置和 Canvas 的绘制结合起来

fun Canvas.drawCircle(
    cx: Float, cy: Float, radius: Float,
    paintBlock: Paint.() -> Unit
) {
    val p = Paint().apply(paintBlock)
    drawCircle(cx, cy, radius, p)
}

// 使用
canvas.drawCircle(200f, 200f, 100f) {
    color = Color.RED
    style = Paint.Style.STROKE
    strokeWidth = 8f
}

这样做的好处是:Paint 的配置和绘制逻辑写在一起,可读性大大提升。我曾经在一个复杂图表项目中大量使用这种模式,每个图元的 Paint 配置都内联在绘制调用中,代码量减少了将近 40%。

注意:每次调用都会 new 一个 Paint 对象。如果是在 onDraw 中高频调用,建议把 Paint 缓存起来,或者用对象池。我踩过这个坑——动画卡顿就是因为频繁创建 Paint。

触摸事件的简化:用高阶函数解耦

自定义 View 的触摸事件处理,往往是代码最乱的地方。各种 when 分支、状态判断、坐标计算混在一起。我们可以用高阶函数把「事件处理逻辑」和「事件分发逻辑」拆开。

先定义一个触摸事件的 DSL 构建器:

class TouchHandler {
    var onDown: ((MotionEvent) -> Boolean)? = null
    var onMove: ((MotionEvent) -> Boolean)? = null
    var onUp: ((MotionEvent) -> Boolean)? = null

    fun handle(event: MotionEvent): Boolean {
        return when (event.actionMasked) {
            MotionEvent.ACTION_DOWN -> onDown?.invoke(event) ?: false
            MotionEvent.ACTION_MOVE -> onMove?.invoke(event) ?: false
            MotionEvent.ACTION_UP -> onUp?.invoke(event) ?: false
            else -> false
        }
    }
}

fun View.onTouch(block: TouchHandler.() -> Unit) {
    val handler = TouchHandler().apply(block)
    setOnTouchListener { _, event -> handler.handle(event) }
}

然后在 Activity 或 Fragment 中使用:

myView.onTouch {
    onDown = { event ->
        // 处理按下
        true
    }
    onMove = { event ->
        // 处理移动
        true
    }
    onUp = { event ->
        // 处理抬起
        true
    }
}

你可能会问:这跟直接写 setOnTouchListener 有什么区别?区别在于——我们把事件类型和对应的处理逻辑显式地对应起来了。代码结构更清晰,也更容易复用。

还可以更进一步,加入手势识别:

class TouchHandler {
    private var startX = 0f
    private var startY = 0f

    var onDown: ((MotionEvent) -> Boolean)? = null
    var onMove: ((MotionEvent) -> Boolean)? = null
    var onUp: ((MotionEvent) -> Boolean)? = null
    var onSwipe: ((direction: SwipeDirection) -> Boolean)? = null

    fun handle(event: MotionEvent): Boolean {
        return when (event.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                startX = event.x
                startY = event.y
                onDown?.invoke(event) ?: false
            }
            MotionEvent.ACTION_MOVE -> {
                onMove?.invoke(event) ?: false
            }
            MotionEvent.ACTION_UP -> {
                val dx = event.x - startX
                val dy = event.y - startY
                if (Math.abs(dx) > 100) {
                    onSwipe?.invoke(
                        if (dx > 0) SwipeDirection.RIGHT else SwipeDirection.LEFT
                    )
                }
                onUp?.invoke(event) ?: false
            }
            else -> false
        }
    }
}

enum class SwipeDirection { LEFT, RIGHT, UP, DOWN }

我曾经在一个自定义轮播图组件中用了这个模式。触摸滑动、点击跳转、边界回弹——所有逻辑都通过 DSL 配置,主类代码非常干净。

三者结合:一个完整的例子

把 Canvas 扩展、Paint DSL、触摸 DSL 组合起来,写一个可拖动的圆形控件:

class DragCircleView(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    private var cx = 200f
    private var cy = 200f
    private val radius = 80f

    init {
        onTouch {
            onDown = { true }
            onMove = { event ->
                cx = event.x
                cy = event.y
                invalidate()
                true
            }
        }
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.withSave {
            canvas.drawCircle(cx, cy, radius) {
                color = Color.parseColor("#4A90D9")
                style = Paint.Style.FILL
                isAntiAlias = true
            }
            canvas.drawCircle(cx, cy, radius) {
                color = Color.WHITE
                style = Paint.Style.STROKE
                strokeWidth = 4f
            }
        }
    }
}

你看,整个 View 的代码非常紧凑。绘制部分用 withSavedrawCircle DSL 配置,触摸部分用 onTouch DSL 配置。没有冗余的 save/restore,没有散落的 Paint 配置,没有杂乱的触摸分支。

核心思想:扩展函数负责「扩展能力」,高阶函数负责「注入逻辑」。两者结合,就能写出声明式、可读性强的自定义 View 代码。

知识体系总览

下面这张图展示了本章的核心逻辑:

自定义View 三件套 Canvas 扩展函数 withSave { ... } withClip { ... } withRotate { ... } Paint DSL 配置 paint { color = ... } drawCircle(x, y, r) { ... } 内联配置,减少临时变量 触摸事件简化 onTouch { onDown = ... } 手势识别 DSL 事件与逻辑解耦 扩展函数 + 高阶函数 = 声明式、可复用的自定义 View

从图中可以看出,三个分支都围绕同一个核心——用扩展函数和高阶函数简化代码结构。Canvas 扩展解决的是「流程封装」问题,Paint DSL 解决的是「配置内联」问题,触摸 DSL 解决的是「事件解耦」问题。三者各司其职,组合起来威力巨大。

嗯,这一章的内容就到这里。代码不多,但思路值得反复品味。下次写自定义 View 时,不妨试试这种风格——你会发现,原来 Kotlin 的扩展函数和高阶函数,真的能让 Android 开发变得更有趣。


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