安卓自定义 View 绘制性能差:别急着甩锅给硬件,先看看这几刀切没切对
做安卓开发这几年,自定义 View 算是最能体现功底的一块试金石。兄弟部门拿它炫技,我们拿它填坑,但说句实在话,onDraw()里那几行代码要是没伺候好,掉帧、卡顿、GC 频繁,一套组合拳下来,旗舰机也扛不住。之前做过一个实时数据监控的仪表盘项目,自绘了一个复杂的刻度盘组件,刚接入业务方的时候,滑动页面明显能感觉到“肉”,后来用 Profile GPU Rendering 一查,绘制耗时直接干到了 12ms 朝上,妥妥的掉帧重灾区。这篇文章不整虚的,从问题根源到优化手段,再到我当时排查的完整路径,一次性说透。适合那些自定义 View 已经能跑起来、但性能一塌糊涂,想搞清楚到底哪里在偷偷浪费时间的开发者。
1. 从根上定位性能瓶颈:绘制流程不是你以为的只是“画画”
很多人一提到自定义 View 性能差,第一反应就是onDraw()里画的东西太多。这个方向没错,但太片面了。要真正解决问题,你得先弄明白一次界面刷新到底走了多少路,否则你连优化谁都搞不清楚,属于典型的“病急乱投医”。
1.1 一次刷新背后的完整链路:measure / layout / draw 一个都不能少
View 的绘制是分阶段的。当系统收到一个invalidate()请求,它并不是直接重画,而是先走ViewRootImpl.performTraversals()这条主线,里面依次包含了performMeasure、performLayout、performDraw三个阶段。你以为你改了个onDraw里的坐标,因为调用了invalidate()所以只会触发 draw 阶段?错。如果这次变更影响到了宽高或者位置信息,Android 会强制把 measure 和 layout 一起给你跑一遍。我曾经优化过一个自适应的折线图 View,它的高度依赖数据条数的动态变化。最初在数据更新时我习惯性调用requestLayout(),结果发现每次数据刷新,整个 View 树的测量和布局都会从根节点重新来一遍。当页面里还有其他重量级兄弟 View 的时候,那可不是只重绘你自己,是连坐全页面。所以优化绘制性能的第一步,不是盯着画笔优化,而是先确认你的刷新策略是不是最轻量的那一种。能用invalidate()就不碰requestLayout(),这不仅仅是习惯问题,是性能意识的核心分水岭。
1.2 硬件加速与软件绘制的两套逻辑:你的代码可能走了最慢的路
很多开发者对“硬件加速”的理解停留在概念层。实际上,硬件加速开启后,View 的绘制会在 RenderThread 上生成 DisplayList,再由 GPU 栅格化输出;而如果关闭硬件加速,所有操作全部压在 CPU 上,通过 Skia 软件绘制逐像素计算。两者对 API 的支持也有差异。比如Canvas.clipPath(),在软件绘制模式下一切正常,但硬件加速下 API 18 之前直接不支持,18 之后也仅支持折线区域的 clip。我的建议是,新项目一律默认开启硬件加速,除非遇到某些Canvas绘制怪癖(比如文字描边变形、特定混合模式异常),否则不要随便在 Manifest 里给 application 或 activity 关掉它。另外,如果你在自定义 View 里用到了setLayerType(View.LAYER_TYPE_HARDWARE, null)试图生成离屏缓冲,务必在不再需要时及时调用setLayerType(View.LAYER_TYPE_NONE, null)释放。我自己踩过坑:一个时钟表盘 View 在onDraw里给表针部分用了硬件层,拖动页面时流畅了,但长时间驻留后内存占用被 GG 回收好几次,反复重建离屏缓冲反而把性能拖垮。硬件层不是免费的午餐,它是拿额外内存换 GPU 合成效率,滥用必然出事。
1.3 绘制时间到底花在哪:从 Trace 里读出三大隐形杀手
想要知道绘制慢在哪,我一般直接用Systrace或 Perfetto 抓一段操作录屏,然后盯着Draw阶段和RenderThread的耗时看。通常问题就三类:第一,DisplayList录制过程中执行了大量复杂绘制指令,比如反复创建Path、频繁移动画布、使用高消耗的drawText相关方法;第二,触发过度绘制,同层级的区域被绘制了多次,GPU 片段着色器压力增大;第三,绘制过程中把主线程卡死了,比如在onDraw里读写文件、解析 JSON、甚至做了网络请求。这个我见得特别多,有些同学把业务逻辑硬塞进绘制方法,真出现问题的时候都是自己埋的雷。
2. 绘制性能优化的关键刀法:每个操作都要有它的战术意图
弄明白链路和瓶颈之后,就到了真正动刀的环节。这里的核心原则是“减、省、缓存”。减是减少绘制指令和刷新频率,省是尽量避免创建临时对象和复杂计算,缓存是让能复用的东西绝对不做第二次。每一刀都有讲究,砍错了反而会适得其反。
2.1 向onDraw()里的 new 对象宣战: GC 就是掉帧的隐形帮凶
这是老生常谈,但每次看代码评审还是会发现漏网之鱼。onDraw()每一帧都会执行,如果里面的代码有new Paint()、new Path()、new RectF(),意味着每秒钟要创建数十个临时对象,这些对象会在短时间内迅速变成垃圾,触发 GC。GC 一跑,主线程就得停下来等它。我见过一个极端案例,一个同事画一个不断转动的雷达扫描圈,每次刷帧新建一个Paint和一个RectF,一秒钟刷 30 帧,几秒钟内就产生了上百个临时对象,然后页面就开始规律性卡顿,卡顿节奏跟 GC 频率几乎一致。优化方式很简单,把这些对象全部提为 View 的成员变量,在构造器里初始化好,onDraw()里只负责改数值和画。这里还有一个小细节:Paint的属性直接设置没问题,但如果频繁使用Paint.setColor()或Paint.setStrokeWidth(),在某些设备上会导致绘制缓存失效,触发重新录制。所以如果你的 View 有多种状态切换,建议给每个状态建一个对应的Paint实例,而不是用一个 Paint 反复切属性。
2.2 invalidate 的边界与 rect 的使用:别再让全 View 重画了
invalidate()不带参数,就是让整个 View 区域重绘。但如果你的自定义 View 很大,而且只有一小块区域发生了变化,比如一个展示多段文本的组件,其中只有一个数字在变,那带参数的invalidate(Rect)就是专门针对这种场景的优化。调用后系统只会把脏矩形区域加入绘制列表,结合硬件加速的局部更新特性,可以有效减少 GPU 的栅格化区域。不过它也有局限性:如果你绘制的元素超出了矩形边界,或者绘制内容有阴影、发光效果,局部更新会导致残影或绘制不全。我自己的经验是,对画面元素非常规则、更新区域明确的组件才采用局部刷新,比如进度条的滑块、数字跑马灯。如果内容比较凌乱、绘制元素交叉,就直接采用全量刷新,因为此时invalidate(Rect)省下的 GPU 开销还不够弥补逻辑复杂度和潜在的 bug 排查成本。
2.3 快速拒绝与 clipRect:卡掉看不见的绘制才是王道
过度绘制最典型的场景就是互相遮挡的区域被画了好几遍。针对这种情况,最常用的手段是Canvas.clipRect()。比如一个复杂的表盘,表盘底纹和其他装饰层如果绘制范围很大,可以在绘制之前预先裁剪出真正需要绘制的区域,把被遮挡的绘制指令直接排除掉。另一个相关技巧是重写onDraw()前用canvas.quickReject()判断某个 Path 或 Rect 是否与当前裁剪区域相交,如果完全不可见,直接跳过绘制。特别是 Map 类的自定义控件在缩放时,这个方法能把计算量降一个量级。有人觉得重复创建一个 Path 然后在quickReject里传进去也算开销,这里就能看出缓存 Path 的又一层好处了:你不仅节省了对象创建,还让裁剪判断逻辑在每帧复用同一个几何体,配合 RecordingCanvas 的指令级优化,效果非常可观。
2.4setWillNotDraw与默认背景:别把时间浪费在画空背景上
自定义 View 的根布局如果设置了背景,那么它的子 View 只要没有设置背景,绘制时系统会自动跳过绘制背景这一层,这是优化点。但是很多人不知道setWillNotDraw(false)的姊妹方法是View.setWillNotDraw(true),这个方法专门告诉 ViewGroup 优化系统:“这个 View 不需要绘制任何内容”。比如一个纯布局容器 FrameLayout,如果不做任何绘制,默认情况下系统其实还是会为它执行绘制过程,虽然开销小,但积少成多。给它设置setWillNotDraw(true)后,系统直接从绘制流程里把它剔除,减少遍历耗时。另外,自定义 View 如果不小心在代码里设置了背景色,却画了完全不透明的业务内容,这个背景就属于白画的,每一帧都白白增加了一次覆盖绘制。检查一下自定义 View 的 XML 布局和代码,凡是内容完全不透底的控件,背景能删就删。删掉之后,不管是 GPU 合成还是 CPU 绘制,都会轻快一截。
3. 从实战里长出来的调优方案:以仪表盘组件的翻车与修复为例
理论说得再多,不落到代码里都是空中楼阁。这里我拿之前那个仪表盘项目完整复盘一遍,从最初的性能数据采集,到逐步定位并修复问题,争取还原当时每一步的思考。这个组件本身不算复杂,一个半圆弧背景、一圈动态刻度线、一个大数字和若干辅助文本,但实际跑起来时问题却五花八门。
3.1 初版实现的典型性能雷区复盘
最初版本的代码结构大概是这样的:onMeasure里根据传入的width和height动态计算圆弧半径,onDraw里每帧根据当前实时数值计算角度,然后逐条绘制刻度线,数字部分则直接调canvas.drawText()展示。看起来没毛病,但实际跑起来后我做了三件事:打开开发者选项里的“调试 GPU 过度绘制”,画面呈现明显粉红色;打开 Profile GPU Rendering 的柱状图,发现垂直条形图经常超过绿线;再用 Profile 工具抓主线程,发现 GC 频率非常高。问题定位很明确,代码里每帧创建了 56 个RectF对象用来存放每一条刻度线的位置,每帧创建了 1 个Paint对象设置字体大小和颜色来画文本,同时视图在刷新数据时调用了requestLayout(),导致测量和布局流程被反复触发。这三板斧下来,性能不掉才怪。
3.2 优化过程中的具体代码级改动
我优化的思路很直接,分几步走。第一,把所有Paint对象全部提为成员变量,在构造器里初始化,并为主要颜色和描边样式建立不同的 Paint 实例,比如bgPaint、scalePaint、textPaint,这样省去每帧切换画笔属性的开销。第二,把刻度线的几何坐标全部在onMeasure()里计算并缓存到一个Path对象中,之后onDraw()直接canvas.drawPath()一次成型,不再逐条绘制,这一步直接把 56 次绘制调用变成了 1 次。第三,数字文本部分不再每帧调用drawText,而是用一个String缓存上一帧的值,只有数值变化时才更新TextPaint的测量结果并调用drawText,否则直接跳过。第四,数据更新时只调用invalidate(),只有尺寸发生真正变化时才触发requestLayout(),这样切断了刷新时的连坐效应。就这四步,没有涉及复杂算法,纯粹是代码组织方式的调整,但效果非常明显。
3.3 优化前后性能数据对比与收益量化
优化完成后我又用同一台机器、同样的操作路径测了一遍。GC 频率从原来每几秒触发一次下降到几乎没有明显 GC 发生;Profile GPU Rendering 的柱状图从经常过线变成稳定压在绿线以下,绘制耗时从峰值 12ms 以上降到平均 2~3ms 左右;用 Systrace 抓渲染线程,DisplayList 的录制耗时明显缩短,UI 线程的doFrame过程也不再被 GC 卡顿干扰。说句不夸张的话,整个页面的滑动流畅度从肉眼可见的卡顿变成了完全顺滑。这里我最想强调的是,这些优化完全没有引入难懂的黑科技,它靠的就是“不要做无用功”这个朴素原则。在自定义 View 的世界里,少即是多,几乎永远成立。
4. 效率工具与排查手段:没有数据支撑的优化都是“自我感动”
聊完实战案例,我再单独拉一节出来专门讲性能排查工具的使用方法。这些东西你要是用得熟练,性能问题基本就能做到“手里有粮,心里不慌”。现在 Android Studio 自带的工具链已经非常成熟了,你不需要记特别多,但核心的那几个一定要会用。
4.1 GPU 过度绘制检测与 Profile GPU Rendering:怎么看、怎么读
开发者选项里的“调试 GPU 过度绘制”会把界面渲染成不同颜色,蓝、绿、淡红、深红分别代表不同程度的过度绘制。你打开它,在 App 页面里滑动一圈,如果发现大面积粉红甚至深红,那基本就是绘制层次铺了太多层。我给自己定了一个标准:主体内容区域不允许出现大面积淡红,更别提深红。Profile GPU Rendering 的柱状图则展示了每帧的 UI 绘制耗时、渲染线程耗时以及 vsync 延迟情况,如果柱状图频繁越过底部绿色基准线,就说明当前绘制耗时过高,需要优化。注意,这个柱状图的测量结果是包含系统自身上下文切换在内的,所以要对比不同版本的代码性能,要在同一台设备、同一个系统版本、同样操作强度下测,否则没有可比性。
4.2 Layout Inspector 与自定义 View 层级检查:看一眼就知道是不是“堆”出来的
有时候自定义 View 性能差,并不全是绘制代码的问题,还可能是 View 层级嵌套过深导致测量和布局耗时过多。Layout Inspector 是 AS 自带工具,可以查看当前界面的 View 树层级,非常直观。如果发现自定义 View 内部嵌套了多层 LinearLayout 或 RelativeLayout,并且某些层级没有任何实际绘制内容,那就是典型的“可以用自定义 View 合并,却硬生生用布局代码堆出来”的反模式。合理的做法是检查每个子 View 的实际作用,把不必要的中间层删掉,或者直接用自定义的绘制方式替代多层布局。这里也引出一个通用法则:一个成熟的工程里,View 树越浅越好,绘制内容越集中越优,这是所有 UI 性能优化的底线。
4.3 Perfetto 与 Systrace:深入到系统渲染管线的最后一公里
如果常规的 Layout Inspector 和 GPU 渲染分析找不出问题,就得动用 Perfetto 或者旧一点的 Systrace 了。它们可以记录系统级的事件,包括 vsync 信号、Choreographer 的 doFrame 事件、RenderThread 的 draw 阶段、GPU 的合成操作等。通过看火焰图,你能确定耗时是发生在你的应用进程里还是系统渲染进程里。我遇到过一种情况,明明自己的onDraw耗时已经压到很低,但帧率就是不稳定。后来抓 Perfetto 发现,问题根本不在我的 View,而是系统另一个重量级 App 的 Notification 频繁触发了整机渲染负担。这种问题靠应用层优化是无法解决的,只能用 Perfetto 定位到真正的原因,再针对性调整自己的页面刷新频率,比如减少动画帧数、在低性能模式下降级绘制效果。
4.4 开发者选项里必开的三件套:把性能数据实时摆在眼前
最后分享一个我的日常配置:开发者选项里,“不保留活动”和“后台进程限制”我是不开的,因为会影响 App 的正常逻辑,但“显示 Surface 更新”、“显示 GPU 视图更新”和“Profile HWUI 渲染”这三项是排查自定义 View 性能时的常驻选项。“显示 Surface 更新”可以让你看到哪些区域在持续刷新,“Profile HWUI 渲染”则会在屏幕上直接绘制出每帧的渲染耗时,配合 Logcat 还能输出更详细的 HWUI 性能数据。这套组合非常适合开发阶段实时感受页面流畅度,比写一堆日志再分析要直观得多。当然,记得在正式测试时把这些选项全部关掉,因为打开状态本身会带来额外开销,影响测试数据的准确性。
5. 踩坑记录与进阶心得:有些弯路,你能不走就不走
前面讲的大部分是相对标准的方法论,现在聊几个我在实际项目里踩过的坑和摸索出来的心得。这些内容不在官方文档里,但哪一个都是真金白银换来的。
5.1 硬件加速下的drawText性能陷阱:字体渲染不是免费的
很多自定义 View 会大量使用drawText,比如信息面板、仪表盘数值、状态列表等。在硬件加速模式下,文本的抗锯齿和渲染交给了 GPU 的字体渲染管线,看似比 CPU 快,但如果你频繁调用drawText且文本内容变化很大,GPU 的纹理上传和缓存更新会成为新的瓶颈。我做过一个测试,同一个 View 里用 30 条文本每帧刷新内容和用 30 条静态文本每帧只重绘,前者的耗时几乎是后者的三倍。原因是每帧都要重新 layout 并上传新的纹理到 GPU。解决方案是尽量把静态文本合并到同一张 Bitmap 里,利用StaticLayout创建一次文本布局后复用;如果必须动态刷新,则尽量限制文本变化的频率,比如在数据变化阈值超过一定值时再刷新,而不是每帧都刷。还有一个细节,TextPaint的setTextSize()也会触发内部缓存重置,能避免就避免,需要大小时可以直接在构造时定义好。
5.2 动画与 View 更新频率的耦合:别再让ValueAnimator无脑跑 60 帧
有时候性能问题不是绘制本身,而是动画驱动了过度刷新。很多人在做自定义 View 动画时,默认给ValueAnimator设置了 60fps,每 16ms 刷新一次,其实很多场景根本不需要这么高的刷新率,比如一个缓慢呼吸的光晕效果,30fps 甚至 24fps 就足够平滑。把动画刷新率降下来,绘制次数直接减半,性能自然好一大截。另外,一定要在动画结束时调用animator.cancel()或animator.end(),避免动画余烬还在持续触发invalidate()。之前项目里出现过一个问题:页面已经切走了,但 View 的ValueAnimator还在后台跑,每帧刷新导致 CPU 空转。虽然不至于崩,但耗电和 CPU 占用率都上去了,这种隐形浪费最容易被忽略。
5.3 自定义 View 的“假扁平化”:能复用系统能力就别自嗨
最后一个心得可能有点反直觉:有些自定义 View 的问题不是画得太复杂,而是不该自定义的地方过度自定义了。比如一个简单的圆形进度按钮,其实完全可以用ProgressBar加自定义 drawable 实现,半路出家写一个 View 反而要考虑 measure、touch 事件、状态保存、无障碍等一堆额外逻辑,性能还未必比系统组件好。我见过太多初级开发者为了炫技,把TextView能搞定的事用Canvas.drawText重写一遍,结果文本换行、超长省略、字间距处理全都要自己搞,代码量翻倍不说,还容易出 bug。优化自定义 View 性能的前提是:这个 View 确实有存在的必要。如果系统组件够了,别自找麻烦。
5.4 一段可复用的绘制优化自查清单
写代码时心里默念这份自查清单,能帮你少走很多弯路。第一,onDraw()里有没有new对象?有就全部提为成员变量。第二,有没有在onDraw()里做耗时计算,比如String.format()、正则匹配、集合遍历?能提前算的绝不留到绘制阶段。第三,刷新时是调用了invalidate()还是requestLayout()?如果尺寸没变,永远用前者。第四,是否存在过度绘制的区域?有则通过裁剪或调整层级剔除。第五,文本绘制是否每帧都变了?能缓存文本布局就缓存,能降低刷新频率就降。第六,动画真的需要 60fps 吗?非必要的话可以降到 30fps。第七,如果 View 内容完全不透明,背景设置了吗?没设置的话要不要考虑加上,让 GPU 知道不用绘制后面层级;设置的话再看看内容是否已经覆盖住背景,能删则删。这一套下来,90% 的自定义 View 性能问题都能找到答案。
从最初那个 12ms 的仪表盘组件,到现在压到 2ms 级别的流畅体验,我最大的感触是:安卓自定义 View 的性能优化,本质上是一场“用最少做最多”的思维训练。它逼着你不断审视每一行代码、每一次刷新、每一个对象的生命周期。这份习惯一旦养成,不只在 View 上受益,整个 App 的流畅度和稳定性都会跟着上一个台阶。希望这篇文章里的思路和方法,能帮你下次再遇到“自带 View 卡成 PPT”的时候,多几分底气和章法。