第138篇 自定义 View 进阶:onDraw 与 Paint 的进阶用法

简介: 本文深入解析自定义 View 进阶核心:Paint 三大能力(属性、Shader、Xfermode)、Canvas 变换与图层、属性动画驱动。强调效果实现原理而非死记 API,直击 onDraw 零分配、Xfermode 隔离、内存泄漏、RTL 支持等高频坑点,助你真正掌握“效果是怎么算出来的”。

先把结论放在前面:自定义 View 的进阶能力集中在三件事——Paint 的三大能力(画笔属性、Xfermode 合成、Shader 填充)、Canvas 的变换矩阵与图层、以及属性动画的驱动。 这三件事能组合出遮罩、渐变、橡皮擦、裁剪、保存恢复等效果。答不出"效果是怎么算出来的",就只是背 API。

自定义 View 进阶是高频考点,但答好的人不多。这篇带你从零理清它的原理、用法和坑。

Paint 的三个层次

// 1) 画笔属性:决定"画成什么样"
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
   
    style = Paint.Style.STROKE          // FILL / STROKE / FILL_AND_STROKE
    strokeWidth = 6f                     // dp 换算:dp * density
    strokeCap = Paint.Cap.ROUND
    strokeJoin = Paint.Join.ROUND
    color = 0xFF2196F3.toInt()
    textSize = 14f * resources.displayMetrics.scaledDensity
    typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
    letterSpacing = 0.02f               // 字间距
    isSubpixelText = true               // 允许亚像素,牺牲一点性能换清晰度
    setShadowLayer(8f, 0f, 2f, 0x30000000)
}

// 2) Shader:填充来源(渐变 / 位图 / 着色器)
paint.shader = LinearGradient(0f, 0f, width.toFloat(), 0f,
    intArrayOf(0xFF6200EE.toInt(), 0xFF03DAC5.toInt()), null, Shader.TileMode.CLAMP)
paint.shader = RadialGradient(cx, cy, radius,
    0xFFFFFFFF.toInt(), 0xFF000000.toInt(), Shader.TileMode.CLAMP)
paint.shader = BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)

// 3) Xfermode:像素如何合成(进设备 API 26+ 需开启硬件加速的层或用 offscreen)

Xfermode 的规则要讲清——它作用于"新画的内容"与"已画内容"的组合,用一张两圆叠加的图就能解释 SRC_IN(保留新画的、遮罩掉旧的)、DST_OUT(擦除已画区域)、PORTER_DUFF 那几个模式。Xfermode 在硬件加速层上需要通过 saveLayer 隔离,否则会影响该层之后的所有绘制内容——这是"用完 Xfermode 后后面的绘制都变了"这类诡异 bug 的根因。

// 正确的 Xfermode 用法:先开离屏层
val layer = saveLayer(0f, 0f, width.toFloat(), height.toFloat(), null)
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
// ... 绘制遮罩
restoreToCount(layer)   // 恢复,避免污染后续绘制

最常见的坑是

onDraw 里 new 对象与创建路径,GC 抖动引发掉帧。

onDraw 每帧调用一次,60Hz 下每秒 600 次。里面 new Paint()、new RectF()、new Path()、甚至 String.format() 都会分配对象,堆增长触发 GC,表现为"滑动时偶发掉帧,且帧率图上是一根一根的尖刺"。更隐蔽的是在 onDraw 里做 new Path() 后反复 path.reset() 复用是安全的,而每次重建 Path 对象则不是——Path 的内部缓存(PathVerb 优化)会在重建时丢失。

规范做法是把对象提到 onSizeChanged 或构造期缓存,并在缩放时统一更新:

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

    // 复用的对象,只在尺寸变化时更新
    private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val arcBounds = RectF()
    private val arcPath = Path()

    private var startAngle = -90f
    private var sweepAngle = 0f
    private var animated = 0f
    private var animator: ValueAnimator? = null

    init {
   
        trackPaint.style = Paint.Style.STROKE
        trackPaint.strokeCap = Paint.Cap.ROUND
        trackPaint.color = 0xFFE0E0E0.toInt()
        progressPaint.style = Paint.Style.STROKE
        progressPaint.strokeCap = Paint.Cap.ROUND
        progressPaint.color = 0xFF2196F3.toInt()
    }

    override fun onSizeChanged(w: Int, h: Int, ow: Int, oh: Int) {
   
        super.onSizeChanged(w, h, ow, oh)
        val d = min(w, h).toFloat() - strokeWidth * 2
        val left = (w - d) / 2
        val top = (h - d) / 2
        // 只改数值,不新建对象
        arcBounds.set(left, top, left + d, top + d)
        trackPaint.strokeWidth = strokeWidth
        progressPaint.strokeWidth = strokeWidth
    }

    override fun onDraw(canvas: Canvas) {
   
        // 每帧只做绘制,不分配任何对象
        canvas.drawArc(arcBounds, startAngle, 360f, false, trackPaint)
        canvas.drawArc(arcBounds, startAngle, sweepAngle, false, progressPaint)
    }
}

还有一个更隐蔽的坑:自定义 View 进阶的边界要进注释、进清单、进单测,三处都落到才算到位。具体清单:① 进注释——onMeasure 里写清支持哪几种 mode、为什么这么处理(覆盖"嵌套滚动容器"这类前提);② 进清单——这个 View 依赖什么(硬件加速?saveLayer?特定 API 级别?),以及哪些属性必须外部设置(setProgress(0f..1f)),写进 KDoc 与 README;③ 进单测——三模式测量用 makeMeasureSpec 参数化测试、动画终值断言、以及"不同尺寸下不崩溃"的健壮性测试。这三条能挡掉自绘 View 交付后最常见的三类问题:测量异常、误用、边界尺寸崩溃。

再补两个"工程现场"话题,它们比 API 细节更能体现做过。

话题一:文字测量与基线对齐。 Paint.measureText 给出的是"可见宽度"(不含尾部空格与部分字形溢出),getTextBounds 给出的是"墨迹范围"(上下含字体上下延伸)。做精确排版时要用 getTextBounds 或 FontMetrics,否则 descender(下伸部)会被裁掉。还有中英文混排的基线问题:Paint.FontMetrics 的 ascent/descent 描述相对基线,top/bottom 是字体实际墨迹边界——用 top 而不是 ascent 计算容器高度,才能保证不裁字。

val fm = paint.fontMetrics
val textHeight = fm.descent - fm.ascent
val textTop = fm.ascent            // 相对基线向上为负
canvas.drawText(text, x, baselineY - textTop, paint)   // 避免顶部被裁

话题二:硬件加速下的图层与 Paint 的等价路径。 硬件加速绘制时,Canvas 记录的是"显示列表"由 GPU 逐条执行,因此"绘制顺序 = 记录顺序"仍然成立,但某些操作(clipPath 复杂路径、drawBitmapMesh、部分 Xfermode)会触发离屏渲染,代价明显上升。所以在自定义 View 里要避开复杂 clipPath,用 canvas.clipRect 或 canvas.saveLayer 明确裁剪区域,性能差异很明显。判断某操作是否离屏,可以用 adb shell dumpsys gfxinfo <package> 看渲染耗时分布——这是排查绘制性能最有用的命令之一。

还有一个高频实战问题:自定义 View 怎么支持 RTL(从右到左)布局? 做法有两条——① 布局层面用 start/end 代替 left/right(View 在 onSizeChanged 里通过 layoutDirection 判断方向后镜像计算坐标);② 绘制层面用 canvas.scale(-1, 1, pivotX, pivotY) 做整体镜像,动画类控件(进度、仪表)通常这样做。更省事的方案是直接用 android:supportsRtl="true" + 系统提供的 getLayoutDirection(),不要自己判断屏幕坐标。

三条高频追问

问:Shader 和 ColorFilter 有什么区别?

Shader 决定"像素从哪来"(渐变、位图、图案),ColorFilter 决定"已有像素怎么变"(LightingColorFilter 调色、PorterDuffColorFilter 叠加、ColorMatrixColorFilter 做色彩矩阵)。用 ColorMatrix 做灰度/反色是最省事的方式:

val matrix = ColorMatrix().apply {
    setSaturation(0f) }   // 灰度
view.setColorFilter(ColorMatrixColorFilter(matrix))

问:saveLayer 什么时候必须用?

三类场景:① 用 Xfermode 时(否则污染整个图层);② BitmapShader 配合 PorterDuff 做遮罩时;③ 需要离屏渲染后再整体合成(比如截图、加噪、圆角裁剪)。代价是它会分配一张离屏位图,在 onDraw 里频繁 saveLayer 是卡顿的常见来源——能预生成 bitmap 缓存就别在每帧开图层。

问:怎么让自绘 View 支持点击?

override fun onTouchEvent(event: MotionEvent): Boolean {
   
    when (event.actionMasked) {
   
        MotionEvent.ACTION_DOWN -> {
   
            downX = event.x; downY = event.y
            isDragging = true
            parent.requestDisallowInterceptTouchEvent(true)  // 关键:防止父容器抢走
        }
        MotionEvent.ACTION_MOVE -> {
    /* 更新状态 + invalidate() */ }
        MotionEvent.ACTION_UP, ACTION_CANCEL -> isDragging = false
    }
    return true
}

要点:parent.requestDisallowInterceptTouchEvent(true) 放在 ACTION_DOWN 里,否则在 ScrollView 里拖动会被父容器截走;另外在移动超过 touchSlop 后主动 invalidate() 之外,还要注意"只在状态变化时刷新"——把每个 MOVE 都转成 invalidate 会在快速滑动时放大绘制成本。

问:ValueAnimator 驱动的自绘 View 如何避免内存泄漏?

ValueAnimator 持有回调,回调持有 View,View 持有 Context——链路完整就会泄漏。规范做法是在 onDetachedFromWindow 里 animator?.cancel(),并在 onAttachedToWindow 里按需重启;同时把 animator 的 updateListener 用 WeakReference 或在 detach 时置空。动画驱动的自绘 View 是泄漏高发区,这条要主动讲。

落到项目里怎么做

一条能直接落地的规则:自绘 View 的性能红线是"onDraw 里零分配"。用 AllocationTracker 或 StrictMode 在 debug 包里跑一遍,越界就当 bug 修。

配套实践三条:① 建 BaseCustomView 统一处理 Paint 初始化、dp 换算、动画生命周期、内存泄漏防护;② 复杂效果(阴影、多层叠加、模糊)优先预生成 bitmap 并缓存,只在尺寸或数据变化时重绘;③ 所有属性动画走 ValueAnimator + invalidate(),不自己起线程刷新。

给正在准备面试的你

这题的答法要落到"效果是怎么算出来的"。推荐这条线:先讲 Paint 三层次(属性 / Shader 填充来源 / Xfermode 合成规则)并强调 Xfermode 需要 saveLayer 隔离 → 讲 onDraw 零分配与对象复用(onSizeChanged 里只改数值)→ 补动画与内存泄漏(detach 时 cancel)→ 落到"边界进注释进清单进单测"。能被追问到"为什么 Xfermode 会影响后续绘制",说明真跑过;能讲出泄漏链路,说明交付过。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:MeasureSpec-与测量模式:三种模式的取舍

下一篇预告:事件分发机制:从-Activity-到-View-的完整链路

有任何问题欢迎在评论区留言交流。

相关文章
|
21天前
|
数据采集 人工智能 自然语言处理
企业业务型智能体的工程化落地:从平台能力到岗位交付
本文提出以岗位为交付单元的业务型智能体工程化路径,直击企业落地AI的核心痛点:数据分散、系统割裂、规则复杂、风控缺失。聚焦架构设计、多系统集成、知识工程、分级风控与渐进式实施,助力大模型真正扎根业务场景。
|
API Android开发 C++
第147篇 属性动画与估值器:插值曲线如何驱动画面
属性动画核心是三段流水线:`ValueAnimator` 推进时间(输出0~1进度)→ `TimeInterpolator` 重映射节奏(如先快后慢)→ `TypeEvaluator` 计算实际值(如float/integer插值)。三者正交、缺一不可,理清此链即破“插值器vs估值器”迷思。
74 0
|
缓存 API Android开发
第142篇 RecyclerView 缓存机制:四层缓存各自干什么
RecyclerView 缓存非单池,而是四层协同机制:`mAttachedScrap`(屏内重排免绑定)、`mCacheViews`(屏外按位置复用,免创建免绑定)、`mViewCacheExtension`(业务预创建扩展)、`mRecycledViewPool`(跨列表按 type 共享,免 inflate)。核心在于理解各层设计意图,而非死记字段。
66 0
|
安全 大数据 Android开发
第133篇 Intent 与 IntentFilter:显式隐式跳转与匹配规则
本文深入解析Android中Intent与IntentFilter的核心机制:Intent是通信信封,IntentFilter是收件规则;重点厘清隐式Intent必须匹配CATEGORY_DEFAULT、PendingIntent同一性由requestCode+filterEquals决定等高频误区,并涵盖exported声明、FLAG_IMMUTABLE强制要求、Deep Link实现等实战要点。
103 0
|
XML 安全 Java
第134篇 AndroidManifest 深度解析:组件声明的门道
AndroidManifest 不是普通配置文件,而是应用的**组件注册表+权限申请表+系统启动契约**。核心含 `package`、`application`、`component`、`uses-permission` 四部分;关键在**清单合并规则**:权限取并集、`application` 属性按优先级覆盖、组件安全属性需显式声明。掌握 `tools:` 标记干预与 `merged_manifest` 排查,方为真深度理解。
95 0
|
20天前
|
人工智能
FDE大师|AI越强,为什么这群“懂行的人”反而更值钱?
本文探讨AI时代下资深从业者的价值重构:当AI能写方案、做分析、生成代码,真正不可替代的,是深扎业务一线的“前线部署工程师”(FDE)——他们将隐性经验转化为可解释、可验证、可复用的规则与系统, bridging 业务理解与工程落地。经验不升值,除非它能被清晰表达、稳定复现、持续验证。(239字)
172 1
|
23天前
|
存储 弹性计算 固态存储
阿里云服务器租用费用价格:轻量和ECS收费标准,新用户最低38元一年
2026年阿里云服务器最新报价:轻量应用服务器低至38元/年(2核2G+200M带宽),ECS经济型99元/年起;含ECS实例规格、带宽(1–5Mbps月付23–125元)、ESSD云盘等详细计费说明,覆盖个人开发与企业级场景。
|
24天前
|
设计模式 缓存 安全
Agent Harness 深度拆解:从一个类比推导出 12 个组件
本文另辟蹊径,不罗列12个组件,而是以“裸LLM=无内存/硬盘/I/O的CPU”为心智模型,用第一性原理推导出长周期任务必然引发的5大问题(上下文腐化、错误累积、跨会话失忆、权限越界、无法自验),再逐一对映12个组件为何必然存在——讲清Harness不是设计模式,而是必须跑起来的运行时系统。(239字)
|
21天前
|
人工智能 自然语言处理 数据挖掘
阿里云Qwen3.8-Flash大模型介绍:模型能力、功能、适用场景、收费标准与便宜购买技巧
本文系统介绍阿里云通义千问体系的高性能旗舰大模型Qwen3.8-Flash:基于2.4万亿参数MoE架构,在保留旗舰能力的同时,针对速度、并发、成本与实时响应进行优化,定位为兼顾复杂能力与高吞吐响应的大模型。它具备编程与办公能力跃升、原生视觉理解贯穿规划执行验证、超长文档与长视频深度语义解析、长程任务自主规划与闭环迭代等能力,可胜任法律、金融、设计等数百种专业任务并端到端交付,适用于AI编程、智能办公、多模态分析与企业智能体等场景。收费按Token、多模态输入与上下文长度计费。
|
21天前
|
弹性计算 人工智能 安全
阿里云最新最便宜的云服务器多少钱?目前价格最低的云服务器配置、价格、购买资格及注意事项参考
本文旨在从价格结构、资源配比、适用业务场景、购买资格约束、续费机制与生命周期成本(TCO)等多个维度,对以上三类实例进行系统化的横向对比与深度解析,辅助采购决策者建立严谨的评估框架,在满足当前业务运行基线的同时,为未来架构演进预留合理的成本弹性空间。

热门文章

最新文章