做Android开发几年之后,很多人会卡在同一个瓶颈上:代码不复杂,逻辑也清楚,但界面就是有卡顿、掉帧、黑屏,排查起来像无头苍蝇。反过来看那些资深工程师,不管遇到什么显示问题,脑子里都有一张“地图”,问题一到手就能定位到具体环节。这张地图,就是Android显示完整链路。
这篇文章要做的,就是把这条链路从App的View绘制,到屏幕像素被真正点亮,完整地串成一张图。你可能是刚接触系统原理的应用开发,也可能已经在做性能优化,甚至是在做系统定制,都能通过这张图找到自己的位置,也知道问题该往哪个环节去查。
这是“第1篇”,所以我先不讲某个具体控件的实现,也不纠结某一个函数的源码,而是把整条链路的骨架立起来。理解了骨架,后面那些细枝末节才有地方安放。
1. 为什么Android显示链路值得专门搞明白
1.1 卡顿的本质,是“一帧超预算了”
Android的屏幕刷新是周期性的。以最常见的60Hz屏幕为例,屏幕每秒钟刷新60次,也就是说每16.6毫秒就要输出一帧画面。这16.6毫秒不是一个抽象的数字,它是一条流水线的硬性预算——从App的UI线程开始干活,到画面真正显示出来,所有环节都必须在这16.6毫秒内完成。
我见过不少同学以为这16.6毫秒是给App代码用的,这是一个很常见的误解。实际情况是,这16.6毫秒要同时装下好几段工作:App端measure、layout、draw的View树遍历,RenderThread的渲染命令执行,GPU的像素绘制,SurfaceFlinger的图层合成,最后还要给显示控制器留出输出时间。
每段工作都有各自的耗时预算。App的绘制阶段一般要控制在6到8毫秒左右,合成阶段大约4到6毫秒,屏幕输出是硬实时,剩下的一点时间还要给调度、CPU频率切换留余量。任何一个环节超时,这一帧就赶不上下一个刷新周期,屏幕只能把上一帧再显示一遍,这就是掉帧,也就是我们肉眼看出来的“卡”。
也就是说,卡顿不是一种原因,而是一类结果。可能是App主线程太忙导致绘制来不及,可能是渲染线程被阻塞导致GPU没活干,也可能是SurfaceFlinger合成压力过大。没有链路图的话,你只能靠猜;有了链路图,你就知道每一帧都走同一条固定路线,按图索骥去量每一段耗时就行。
1.2 同样的“卡”,背后的根因可能完全不一样
随手举三个真实的场景。
第一个场景,列表快速滑动的时候明显掉帧。用工具一看,主线程的onBindViewHolder每次都做文件读取,把I/O操作放在了UI线程,卡顿的根因在App代码这一层。
第二个场景,页面本身很轻量,但每次动画启动的第一下必定卡一下,后面就流畅了。这种情况往往不是主线程的问题,而是着色器(Shader)编译在第一次绘制时触发了,RenderThread和GPU被打了个措手不及。卡顿发生在渲染管线这一层,不在UI线程。
第三个场景,界面层级很复杂,一个页面叠了三四个Surface,还开了多个悬浮窗。这时候就算App侧再合理,SurfaceFlinger合成这一层也可能超时,你的App代码再怎么优化也解决不了,只能通过减少图层数量、调整合成路径来解决。
这三个场景,表面现象都叫“卡”,但定位方向一个是主线程,一个是渲染线程,一个是系统合成器。如果没有全局视图,很容易把时间花在错误的地方。
1.3 谁最需要这张图
如果你只是照着官方文档写界面,可能暂时用不到这些。但只要你开始接触下面这些事情,这张图就绕不开:
- 做性能优化,想搞清楚掉帧到底掉在哪个阶段;
- 做自定义View或复杂动画,想知道硬件加速到底在加速什么;
- 做系统定制或者刷机相关的工作,需要理解SurfaceFlinger、HWC这些系统组件;
- 面试准备,Android显示体系是高级工程师绕不过去的核心知识块。
说白了,这条链路就是Android系统的“心脑血管”。你不需要背下来每一根血管的走向,但你必须知道血液是从心脏出来、经过哪些大动脉、最后怎么流回心脏的。
2. 一条链路上的三大阵营:App、系统服务、硬件
2.1 App阵营:View树、DisplayList与RenderThread
先看最左边,也就是你写的App代码。
一个Activity的布局,在内存里是一棵View树。UI线程会遍历这棵树,依次执行measure、layout、draw三大流程。measure算尺寸,layout定位置,draw画内容。
这里有一个很多人没真正理解的细节:draw阶段不是真的往屏幕上画像素,而是把“要画什么、画在哪里、用什么颜色”这些指令记录成渲染指令列表。在硬件加速开启的情况下,这个列表叫DisplayList。大家可以回忆一下小时候用复写纸描画,真正的工作不是一笔一笔涂,而是先把指令准备好,交给执行者去涂。
为什么要多这一层?因为指令可以被缓存、被复用。一个TextView的文本内容没变,只是位置变了,那GPU不需要重新生成整个文字的纹理,直接复用之前缓存的DisplayList就行。这就是为什么硬件加速之后,很多复杂界面的重绘成本大幅下降。
从Android 5.0开始,系统引入了RenderThread,也就是渲染线程。UI线程在生成DisplayList之后,就可以立刻去处理下一帧的输入事件和View树变化,渲染线程负责把这些指令转成GPU能执行的命令,并且真正提交给GPU。
UI线程和RenderThread的分离意义非常大。动画过程中的每一帧,UI线程只需要更新属性、生成新的DisplayList,真正耗时的像素填充在RenderThread上异步进行。如果两者不分离,UI线程就得干等GPU渲染完成,那下一帧什么都别想干了。
2.2 系统服务阵营:Surface、BufferQueue与SurfaceFlinger
App渲染完成后,画面并不会直接飞到屏幕上。所有窗口的画面,都要先汇总到系统全局的合成器——SurfaceFlinger那里。
每个窗口对应一个Surface。你可以把Surface理解成一块画布,App是拿画笔在画布上作画的人。但这块画布背后不是一个简单的内存块,而是一个叫BufferQueue的生产消费队列。
App这一端是生产者,负责往队列里填充渲染好的画面缓冲;SurfaceFlinger这一端是消费者,负责把缓冲取走、合成、送显。这个队列里流转的缓冲叫GraphicBuffer,是一块GPU可访问的图形内存,App渲染的时候直接画在这块内存上。
我把这套机制比作餐厅出餐:App是后厨,菜做好之后放在传菜口(BufferQueue),SurfaceFlinger是传菜员,把各个后厨做好的菜端到一起,装盘之后再上桌。传菜口的作用就是隔开后厨和上菜,后厨再慢,也不能让服务员一直杵在边上等;同理,服务员再忙,也不能让后厨把锅铲伸到餐桌上。
SurfaceFlinger合成的时候,会把所有可见窗口的图层按照Z轴顺序叠起来:状态栏、导航栏、壁纸、各个App窗口、悬浮窗,全部都是它的合成素材。最终合成出来的结果,才是用户真正在屏幕上看到的内容。
2.3 硬件阵营:HWC、显示控制器与屏幕
链路最右边是硬件。
SurfaceFlinger合成好的帧,并不是直接“写”到屏幕上的,中间还要经过硬件显示控制器和HWC(Hardware Composer,硬件合成器)。
HWC是Android 8.0之后重点重构过的HAL层。它的作用是判断哪些图层可以用硬件Overlay平面直接叠加,哪些必须交给GPU合成。如果图层数量少、又符合Overlay条件,那就完全不需要GPU参与,直接把各个图层的缓冲地址交给显示控制器去组合,这样既省电又高效。
显示控制器(Display Processing Unit,DPU)拿到最终画面之后,负责按屏幕的刷新时序把数据一行一行扫描到面板上。这块完全是硬实时,屏幕说16.6ms要刷新一次,它就必须在那个时间点把像素数据准备好。
一个容易被忽略的点是:屏幕本身有自己的固定工作节奏。哪怕App和SurfaceFlinger都提前完成了工作,屏幕也只能等下一个刷新周期再输出。这就是为什么后面要重点讲VSYNC——因为所有环节都需要对齐这个节拍。
3. 一张图读懂一帧画面的完整旅程
3.1 链路全景:从VSYNC到屏幕点亮的十个岗位
这一节是整篇文章的核心。我们把引擎盖打开,把一帧从左到右的完整路径平铺出来。这张“图”不用跑代码,只要你脑子里能记住这条路径,绝大多数显示问题你就能自己定位了。
一条完整的链路大致长这样:
VSYNC信号 → Choreographer回调 → View树measure/layout/draw → 生成DisplayList → RenderThread处理渲染命令 → GPU绘制到GraphicBuffer → queueBuffer入队 → BufferQueue(生产者消费者交接) → SurfaceFlinger合成 → HWC/GPU合成送显 → 屏幕刷新
每个环节都有明确的执行者和产出物,我把它整理成一份岗位清单:
| 环节 | 执行者 | 主要产出 |
|---|---|---|
| 产生心跳 | 硬件VSYNC/系统 | 垂直同步信号 |
| 应用接收心跳 | Choreographer | 帧回调 |
| 界面布局与绘制 | UI线程 | View树状态 + DisplayList |
| 渲染命令处理 | RenderThread | GPU命令流 |
| 像素填充 | GPU | 一帧GraphicBuffer图像 |
| 提交缓冲 | Surface(App侧) | 入队BufferQueue |
| 缓冲中转 | BufferQueue | 缓冲槽位流转 |
| 全局合成 | SurfaceFlinger | 最终合成帧 |
| 硬件叠加与送显 | HWC + DPU | 面板数据流 |
| 物理显示 | 屏幕面板 | 你的眼睛看见的画面 |
这张表的读法是从上往下,也是从左往右。上一步的产出是下一步的输入,任何一环断了或者超时,终端表现就是画面异常。
3.2 生产端:App是怎么用掉一帧预算的
先从VSYNC说起。
Android从4.1开始引入了Project Butter(黄油气)计划,核心之一就是VSYNC统一调度。硬件每16.6ms产生一次VSYNC信号,这个信号会同时送达App进程的Choreographer和系统的SurfaceFlinger。
App侧的Choreographer收到VSYNC之后,会回调doFrame,触发这一帧的工作:处理输入事件、执行动画、遍历View树做measure和layout、再执行draw生成DisplayList。请注意,doFrame里的逻辑全部跑在UI线程,所以UI线程一旦有别的耗时操作,doFrame就会推迟,帧预算立即被吃掉。
draw生成DisplayList之后,UI线程的工作就告一段落。RenderThread接管,把这些渲染指令发给GPU,GPU开始真正算像素。现代的GPU一次能并行处理大量绘制指令,一个100个View的界面,往往比你想的要快得多。
GPU渲染完成之后,这一帧画面被写进一个GraphicBuffer。App通过Surface把这个Buffer提交给BufferQueue,此时生产端的工作完成,一帧画面从“你代码里的状态”变成了“一块可以送给合成器使用的图像内存”。
这里我强调一句:App这一端的输出,永远只是“自己窗口的内容”,不是最终屏幕画面。你的App填充满屏,也只是填满自己的Surface,最终要由SurfaceFlinger把它和状态栏、导航栏等所有图层合在一起。
3.3 消费端:SurfaceFlinger怎么决定你看到什么
App一帧画完之后,BufferQueue里多了一个填充好的缓冲。接下来要看SurfaceFlinger的脸色。
SurfaceFlinger同样受VSYNC驱动。它会在每一个刷新周期检查所有可见图层的BufferQueue,把新到的缓冲取出来。取出来后做什么呢?做合成决策。
合成决策最核心的是两条路径:
第一条,硬件合成路径。如果当前图层的属性都满足HWC硬件Overlay的要求,比如没有复杂的混合模式、没有旋转、没有模糊,那么SurfaceFlinger就不做实际合成,而是把这些图层的Buffer直接交给HWC,让显示控制器自己去叠加。这条路非常快,GPU完全不用参与。
第二条,GPU合成路径。当图层太复杂、数量太多、或者有特殊效果,系统会把需要合成的部分通过GPU画到一块统一缓冲里,然后把这个缓冲交给显示控制器。这个路径灵活,但成本和功耗都更高。
两条路径会动态切换。比如同时打开两个悬浮窗、又有分屏、又有画中画,图层数一多,硬件Overlay平面不够用,系统就自动切到GPU合成,帧耗时可能从2毫秒变成5毫秒。
合成完成之后,最终画面被送到Display设备接口,DPU按屏幕刷新时序取出来,最终点亮屏幕上的像素。到这一步,一帧的完整旅程才算走完。
4. 链路里的四个关键机制
4.1 VSYNC:整条链路的节拍器
为什么Android要费这么大劲统一VSYNC?因为在VSYNC统一之前,各环节是各干各的,容易产生画面撕裂和掉帧。
我打个比方。两个人在同一个房间,一个人负责画画,一个人负责撕画,如果两个人没有统一节奏,画画的人刚画到一半,撕画的人就撕掉换新了,那画面就会撕裂。所以系统引入了一个统一的节拍器,所有动作都对齐这个节拍:VSYNC来一下,App开始画一帧;VSYNC来一下,合成器取走已经画好的缓冲。
这里还有一个非常精巧的设计:App的VSYNC和SurfaceFlinger的VSYNC并不是同一个时刻,而是错开的。App的VSYNC会稍微提前,让App先开始生产新一帧,SurfaceFlinger再在稍后的时刻消费已经准备好的帧。为什么要错开?因为如果两者同时工作,App在往BufferQueue里写缓冲的同时,SurfaceFlinger在往外取缓冲,同一个槽位就会冲突,反而互相等待。
Choreographer是App侧的节拍器接口。它不只是驱动View绘制,还负责动画帧回调。你写的Animator更新属性,也是靠Choreographer在VSYNC到来时通知的。理解了这一点,你就能明白为什么动画卡顿时,经常不是动画代码本身慢,而是整个UI线程的doFrame被耽误了。
4.2 BufferQueue:隐藏在生产消费之间的缓冲区
BufferQueue是整个链路里最不起眼、但最重要的一个部件。
它本质是一个固定数量槽位的环形队列。App生产者通过dequeueBuffer申请一块空闲缓冲,渲染完再通过queueBuffer入队;SurfaceFlinger消费者通过acquireBuffer取出缓冲,显示完成后再通过releaseBuffer释放回队列。
双缓冲是最基本的形式:App画前缓冲的时候,SurfaceFlinger在显示后缓冲,两者互不干扰。但如果App绘制超时,前缓冲还占着、后缓冲还没被消费,队列就没有空闲槽了,App的dequeueBuffer就会被阻塞。这种情况下,掉帧是必然的。
三缓冲就是再增加一个缓冲槽,App可以先画到第三块缓冲上,即使系统还没消费完上一块,也不至于立刻卡死。它的意义不是消除掉帧,而是当偶尔发生绘制超时的时候,给App多一个缓冲的余地,减少掉帧次数。
但三缓冲不是越多越好。缓冲区越多,每一帧从App画完到真正显示之间的延迟就越大。所以系统在两种策略之间做平衡:延迟优先的场景用双缓冲,流畅性优先的场景可能动态切换到三缓冲。这也是为什么在开发者选项里你会看到“强制启用三缓冲”之类的调试选项,它是以增加延迟换取流畅度。
4.3 HWC与GPU合成:省电和灵活性的取舍
HWC这个词很多人听说过,但不一定清楚它到底解决什么问题。
HWC是一层硬件抽象,它对接的是显示芯片提供的Overlay平面。在GPU合成模式下,所有图层都要被GPU画一遍,这个操作非常耗带宽、耗电,而且占用GPU时间。在HWC模式下,显示硬件本身就能同时输出多个图层内容,不需要GPU把所有东西揉成一团,既快又省。
举个实际的例子。播放视频的时候,视频画面本身是一个独立的Surface,字幕又是一个Surface。在硬件Overlay充足的情况下,这两个图层可以分别交给不同的Overlay平面,显示控制器把视频和字幕叠在一起输出,GPU全程不参与。
但是Overlay平面数量有限,一般也就几个。一旦图层数超出,或者图层带混合属性、圆角、模糊效果,硬件无法直接处理,系统就必须把部分图层合到GPU上。合成路径的切换对性能的影响是很直接的,这也是为什么在排查掉帧时,要看图层数量是否过多。
从Android 8.0开始,HWC2把合成决策权做了更细的划分,系统可以更频繁地在不同合成策略之间切换。开发者在性能优化里能做的事情,就是尽量减少不必要的图层、减少Surface的数量,帮助系统更容易走上硬件合成路径。
4.4 帧率与刷新率:为什么120Hz的屏幕也会卡
刷新率是屏幕的能力,帧率是内容的供给速度。两者经常被混为一谈,但它们的逻辑完全不同。
120Hz的屏幕每8.3毫秒刷新一次,它有能力显示120帧。但如果App每16.6毫秒才能产出一帧,那屏幕的刷新时序里有一半时间等不到新内容,只能重复显示旧帧。这种情况屏幕上看到的效果,可能跟你用60Hz手机没有区别,甚至因为刷新机制不同产生新的采样问题。
反过来还有一种情况:App能产出120Hz的内容,但屏幕是60Hz固定刷新率,那屏幕上只有60个画面能被显示出来,中间那些帧就被丢弃了,影响的是交互跟手度。
现代高端手机有可变刷新率技术,屏幕可以根据内容动态调整刷新率,比如看静态文字时降到10Hz省电,滑动时升到120Hz流畅。这本质上是让屏幕去适应内容的供给节奏,而不是站在固定节拍上干等。
理解了这一层,你就明白为什么一台120Hz屏幕的手机在低帧率游戏里也可能卡——不是屏幕不行,是内容端和显示端没对齐。这条链路的任何一段,都不可能绕过预算限制独自快起来。
5. 实战:常见显示问题的排查思路
5.1 卡顿掉帧:先拆段,再定位
遇到掉帧,我最忌讳的事情就是直接扎进代码里去“优化”。正确做法是先确认掉在哪一段。
第一步,先量App侧的总耗时。用开发者选项里的“GPU渲染模式分析”,或者更现代一点的Perfetto抓trace,先看UI线程、RenderThread和GPU分别花了多少时间。如果UI线程的柱状图明显超标,那就是主线程有耗时操作;如果RenderThread柱子高,那要去看渲染指令是否合理、是否存在Shader编译等问题。
第二步,查看合成侧的耗时。adb shell dumpsys SurfaceFlinger --latency是一个非常实用的命令,它可以拿到最近一段时间内每一帧的时间戳,包括期望呈现时间、帧准备完成时间、实际呈现时间。对比这三列数字,就能判断到底是App画慢了,还是SurfaceFlinger合成了太长时间。
第三步,再结合Perfetto看SurfaceFlinger的进程状态。如果SF线程长时间处于工作状态,而App侧的Buffer早就准备好了,那问题就在合成器这一侧,需要看图层数量、看合成路径、看HWC的决策结果。
这一步一步拆下来,你会发现在定位问题时,你是在跟一条链路上具体的工位对话,而不是在跟一团模糊的“卡顿”搏斗。
5.2 黑屏、闪屏、画面撕裂的常见路径
黑屏比卡顿要凶险得多,但它的排查思路反过来会更简单:看画面是断在哪一环。
最常见的一种黑屏,是App进程自己出了问题,比如崩溃或者被系统回收。进程没了,它的Surface也就不存在了,屏幕上那一块就显示不了内容。这种黑屏往往伴随Logcat里的崩溃日志,先查App进程的存活状态。
还有一种黑屏比较隐蔽,发生在SurfaceView或者TextureView的切换过程中。SurfaceView是独立于宿主View层级的一层Surface,它在创建和销毁时有自己的生命周期,如果切换时机不对,短则一帧黑屏、长则持续黑屏。SurfaceView的好处是可以在独立线程更新内容,不影响主线程,代价就是如果你不了解它的Surface生命周期,坑会很多。
闪屏的情况,多半和Shader编译、或者是Buffer提交时序有关。动画初次执行时闪一下,大概率是Shader编译导致的第一帧延迟。频繁闪烁,则要看是不是有图层在交替提交不成对的Buffer,导致合成器一会儿有内容一会儿没内容。
至于画面撕裂,现代Android里已经非常少见,因为VSYNC和BufferQueue双缓冲机制从机制上解决了乱序写屏的问题。如果你还看到撕裂,优先怀疑是不是某些特殊场景下绕过了标准链路,比如直接对硬件缓冲区做写入的播放器实现。
5.3 容易被误判的“伪显示问题”
做排查的时候,还有一个特别重要的经验:先确认问题到底在不在Android显示链路上。
我遇到过很多“显示不出来”的案例,最后发现根因根本不在显示链路。比如图片和资源加载不出来,第一反应不是去查SurfaceFlinger,而是应该先看文件路径有没有写对、权限有没有给够。Android 11之后,/storage/emulated/0/Android/data/包名/目录的访问权限大幅收紧,很多应用的数据目录在别的App里根本读不到,看起来像是“显示内容没了”,其实是文件存取被系统挡了。
再比如图形库在Python生态里画图显示不出中文、OLED模块接上去不亮、Windows设备管理器里驱动报错43,这些问题都叫“显示”,但和Android这条渲染链路完全不是一回事。排查之前先把问题归类到正确的技术栈里,能省下一大半冤枉时间。
我的习惯是,任何一个“画面异常”的问题,先问三句话:内容是哪来的?内容要送到哪里去?中间经过哪些组件?三句话问完,问题落在哪个环节基本就清楚了。
5.4 手工核对链路健康度的命令速查表
最后把我平时最常用的检查手段整理成一张表,供大家直接抄作业:
| 工具/命令 | 用途 | 主要看点 |
|---|---|---|
adb shell dumpsys gfxinfo <包名> | 查看某个App的帧耗时统计 | Draw、Process、Execute各阶段耗时,Janky帧数 |
adb shell dumpsys SurfaceFlinger --list | 列出当前所有图层 | 检查图层数量和图层名,确认是否存在不必要的Surface |
adb shell dumpsys SurfaceFlinger --latency | 导出最近帧的时间戳 | 对比期望呈现、帧准备、实际呈现,定位生产慢还是消费慢 |
| Perfetto trace | 全局抓取各线程状态 | 查看UI线程、RenderThread、SurfaceFlinger的CPU调度情况 |
| 开发者选项-GPU渲染模式分析 | 实时显示每帧渲染耗时 | 快速判断App侧和渲染侧哪一段柱状图异常 |
| Layout Inspector | 检查View层级 | 确认布局层级是否过深,是否存在无效节点 |
这些手段组合起来,已经能覆盖大多数日常显示问题的定位。不要一上来就用重型工具,先用简单命令确认问题的大致方向,再决定要不要深入抓trace。我自己的经验是,80%的问题在第二步就能定位到具体环节。
这篇先把显示链路的整体框架立起来:生产端是你写的App,消费端是SurfaceFlinger,节拍器是VSYNC,缓冲通道是BufferQueue,硬件收尾是HWC和屏幕。下一篇开始,我们会顺着这条链路往左走,先拆App端的View绘制细节,看看measure、layout、draw到底是怎么配合的,再专门讲硬件加速和RenderThread。建议你把这张链路图先存下来,排查问题的时候对着它走一遍,思路会清楚很多。