ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android显示链路全解析:从View绘制到屏幕像素的完整旅程

Android显示链路全解析:从View绘制到屏幕像素的完整旅程 做了几年Android开发被问得最多的其实不是“这个动画怎么写”而是“屏幕上的那一帧画面到底是怎么来的”。如果不系统地看一遍整条链路很多问题永远只能停留在“现象”层面为什么偶现掉帧、为什么SurfaceView有时候会闪黑、为什么折叠屏上UI会异常、为什么同一个布局在低端机上卡成PPT。这篇是“Android显示完整链路”系列的第1篇核心目标只有一个把从App写View到屏幕点亮像素这整条线串起来让你脑海里有那张完整的地图。适合想深入framework方向、想彻底弄懂卡顿原理、或者已经从UI进阶到系统源码阅读的开发者。我见过太多人在事件分发、Binder、Handler这些点上挖得特别深但一说到显示链路就只剩下一堆零散名词Vsync、Choreographer、SurfaceFlinger、HWUI、BufferQueue……知道每个词大概是什么却说不清它们之间的顺序和因果关系。这篇文章就是来填这个空白的先建立全局再逐层拆解。1. 一条帧的旅程从View树到屏幕像素1.1 显示链路到底是怎么一条线先说结论一条完整的Android显示链路可以切成五层。无论你用TextView还是自定义View无论你开不开硬件加速无论你是手机还是Android TV每一帧都逃不过这五层。第一层是App进程内的UI绘制层。你的Activity、View、WindowManager都在这里工作ViewRootImpl通过Choreographer接收系统节奏执行measure、layout、draw把View树变成可以被GPU执行的绘制指令。第二层是图形库层也就是HWUI里的Skia/OpenGL/Vulkan这一摊子事它把上层的绘制指令翻译成GPU能跑的任务。第三层是BufferQueue这是一个典型的生产者-消费者模型App作为生产者把渲染好的图像数据放进Buffer里SurfaceFlinger作为消费者取出来。第四层是SurfaceFlinger合成层它把所有App窗口的Buffer包括状态栏、导航栏、输入法窗口按照Z轴顺序合成一帧最终画面。第五层是Display输出层合成好的画面通过HWC提交给屏幕在Vsync信号到来时完成真正的像素刷新。这五层每一层都有自己独立的时钟和缓冲策略它们之间靠VSYNC信号同步靠Binder和共享内存传递数据。链路中任何一个环节掉了链子最终表现都是同一个词掉帧。1.2 把这张图拆成一张表为了让你快速建立画面感我用一张表把五层对应的核心角色和关键方法梳理出来对照着看比纯文字好记很多。层级所在进程核心对象关键动作对应的你熟悉的东西UI绘制层App进程ViewRootImpl / ChoreographerperformTraversals / doFrameonDraw、requestLayout图形绘制层App进程RenderNode / DisplayList / Skiadraw / RenderThread同步Canvas.drawXXX缓冲管理层跨进程共享BufferQueue / GraphicBufferdequeue / queue / acquireSurface.lockCanvas合成层SurfaceFlinger进程SurfaceFlinger / Layercomposite / HWC调用dumpsys SurfaceFlinger显示输出层系统进程/驱动HWC / Displaypresent / Vsync刷新屏幕刷新率、帧率这张表的价值在于你平时写代码时接触到的每一个UI概念最后都会落到表里某个具体环节。比如你给一个View设置背景色改的是第一层你开了硬件加速影响的是第二层你看到掉帧原因可能出现在第二层、第三层、第四层任何一处——只看Logcat根本定位不到。1.3 一条帧的完整旅程把这张表翻译成一条流水线就是这样的过程用户在屏幕上点击触摸事件先走Input系统最终分发给目标View。如果你对事件分发机制还不熟可以把这个过程理解为系统在问“这个坐标落在哪个View上”然后由ViewGroup的dispatchTouchEvent逐级传到最合适的子View。View接收到变化后调用requestLayout或invalidateViewRootImpl感知到需要重新绘制在下一个Vsync信号到来时执行doFrame。doFrame内部会依次处理输入、动画、measure、layout、draw。经过draw之后绘制指令被记录到RenderNode对应的DisplayList里RenderThread线程会把这些指令提交给GPU执行最终渲染结果写入BufferQueue中的某个Buffer。App在完成这一帧渲染后把Buffer通过BufferQueue的queueBuffer接口交给SurfaceFlinger同时通知SF“我这一帧好了你那边可以拿”。SurfaceFlinger收到Vsync信号后开始合成按照窗口Z轴顺序把所有可见Layer合成输出到一块屏幕Buffer上。如果硬件合成器支持它会直接把Layer序列交给HWC让显示硬件自己合成。屏幕在下一个Vsync信号到来时从Display Buffer中取出画面刷新完成用户肉眼看到的这一帧。整个过程大约16.6毫秒60Hz刷新率下就是这个数。这16.6毫秒里所有环节必须一气呵成任何一个环节超过预算你就会在屏幕上看到卡顿、掉帧或者画面撕裂。2. App侧从View到Surface的起点2.1 ViewRootImpl、Choreographer与Vsync的关系App侧的起点是ViewRootImpl它是View树和WindowManager之间的桥梁。你写的每一个Activity最终都会通过WindowManager.addView创建一个ViewRootImpl对象它负责整棵View树的测量、布局、绘制以及把绘制结果同步给WMS。很多人第一次读Framework源码时会困惑为什么onDraw不是立刻执行的为什么requestLayout之后要等很久答案就在Choreographer这个类里。Choreographer是App进程内负责编排帧的协调者它监视着系统派发的Vsync信号在信号的驱动下回调doFrame。ViewRootImpl把这个回调注册进去回调里依次处理三件事input处理这一帧内的输入事件animation处理动画帧traversal真正执行performTraversals也就是measure、layout、draw这就是为什么你调requestLayout后绘制不会立刻发生而是要等下一个Vsync。系统把所有UI操作都对齐到Vsync的节拍上是为了让每个窗口的画面在同一个节奏下输出避免出现一半窗口已经刷新、另一半还在等数据的错位感。这个机制你理解了就能明白为什么主线程卡顿会拖垮整个UI——因为doFrame必须在主线程执行主线程一旦忙于其他事情错过了这个Vsync回调那就只能等下一个信号白白丢掉一帧。Android 12之后系统有了一些调整比如引入了更强的frame pacing策略但整体结构没有本质变化。你在做Android 12适配时如果遇到掉帧比预期更严重的情况优先检查主线程有没有在doFrame前被别的长时间任务占住这是最常见的原因。2.2 硬件加速下的DisplayList与RenderThread从Android 3.0开始View的绘制默认走硬件加速。硬件加速的本质是把Canvas的API调用转换成一系列记录而不是直接往Bitmap上画像素。这些记录被存放在DisplayList里每个View的draw方法执行后产生的是一堆绘制命令比如“画一个圆”“贴一张纹理”这些。View对应的是RenderNode节点RenderNode内部持有DisplayList。当View发生局部更新时系统只需要更新这个RenderNode对应的DisplayList记录而不是整个View树全部重画。这也是为什么invalidate比requestLayout轻量得多——invalidate只标记需要重绘的节点下一帧同步数据时能按需提交。真正执行这些DisplayList的线程是RenderThread它与主线程分离在App进程后台独立运行。主线程做的是记录绘制命令RenderThread负责把这些命令翻译成OpenGL或者Vulkan的调用交给GPU去执行。这就带来一个关键收益主线程的重绘工作和GPU实际渲染工作可以流水线重叠。主线程在计算下一帧的记录时RenderThread同时在上传上一帧的纹理两者互不阻塞。我在实际处理布局性能问题时经常用这样一个判断标准如果掉帧时CPU占用很高主线程的Traversal耗时明显那是布局或绘制记录的问题如果主线程很空闲RenderThread却持续高负载那问题更可能在GPU渲染部分或者渲染指令太复杂。方向不一样查问题的路径就完全不一样。2.3 Surface与BufferQueue生产者与消费者的握手App端所有渲染结果最终都要写入Surface。这个Surface不是View树上的东西它是对BufferQueue中生产者端的封装。每个Window都对应一个Surface而Surface背后是一个BufferQueue。BufferQueue的模型非常简单但极其重要它定义了App和SurfaceFlinger之间怎么交接图像数据。过程是这样的App需要渲染时调用dequeueBuffer从BufferQueue中取出一块空闲BufferApp在Buffer上完成绘制App调用queueBuffer把这块Buffer放入队列并通知SurfaceFlingerSurfaceFlinger调用acquireBuffer取走一块Buffer进行合成合成完成且屏幕显示完毕之后SurfaceFlinger调用releaseBuffer释放这块Buffer把它重新还给BufferQueue这个模型的本质是把“生产”和“消费”解耦。App可以以自己的节奏渲染SurfaceFlinger可以以自己的节奏合成只要BufferQueue里有足够多的Buffer缓冲两者就不需要互相干等。这也是双缓冲和三缓冲的意义所在。双缓冲下BufferQueue里通常有2个Buffer一个被SurfaceFlinger持有用于合成显示另一个被App用来渲染。如果App渲染速度超过屏幕刷新速度就会出现两个Buffer都被占住、App等Buffer的情况这时候系统的窗口就会阻塞表现为掉帧。三缓冲则是给队列多准备一个Buffer让App在极端情况下也能提前开始下一帧的渲染。你说三缓冲是不是越多越好也不是Buffer太多会显著增加显示延迟触摸跟手性会变差这是一个需要平衡的问题。这里还要说一个我在Surface问题上踩过的坑改用Surface.lockHardwareCanvas时很多人以为它跟lockCanvas一样直接拿一块画布来画就行。实际上lockHardwareCanvas会走硬件加速路径渲染结果在GPU上画完还需要保证RenderThread把任务提交到GPU后才能queueBuffer。如果这两步没有同步好你拿到的Frame内容可能还是前一次的用户会看到画面“跳一下”。正确的做法是在lockCanvas后立刻完成绘制并unlockCanvasAndPost不要把Surface的长引用到处传。3. SurfaceFlinger真正决定“合成”的那一关3.1 App的Vsync与SF的Vsync联合调度在链路的另一端所有App的Buffer最终都汇聚到SurfaceFlinger进程。SurfaceFlinger是系统级服务负责对所有可见Layer做合成。它自己也有一套Vsync节奏由系统底层的Vsync控制器统一分发。Android的Vsync源自硬件显示控制器的VSYNC信号经过系统中转后分成两条线一条给App进程叫App Vsync驱动Choreographer一条给SurfaceFlinger叫SF Vsync驱动合成。这个“一朝两用”的设计非常巧妙。如果两个Vsync完全同时发出App先渲染SF再合成那会让App的帧延后一整个Vsync周期。所以系统会让SF Vsync略微晚于App Vsync留出App处理完一帧的时间。你可以用adb shell dumpsys SurfaceFlinger --latency看到这些Vsync的时间戳信息。理解了这点再看那些“App侧渲染只要8ms但屏幕却掉帧了”的诡异现象。问题根本不在App侧而在于SF合成这一环节。SF合成不及时、HWC处理Layer过多、合成策略选错都会让App已经准备好的帧多等一个周期。3.2 合成策略与HWC到底怎么选SurfaceFlinger拿到所有Layer的Buffer后要先决定怎么合成。两条路一是GPU合成也就是SurfaceFlinger自己用OpenGL把多个Layer画到一个输出Buffer上二是硬件合成由HWC接管让显示硬件用Overlay Plane直接合成。HWC的全称是Hardware Composer它是一套HAL接口不同的屏幕驱动实现不同。现代Android设备上几乎都是两种方式混合使用。HWC能处理的情况下优先走硬件合成因为硬件合成不用把每个Layer的Buffer读回GPU做一次全屏绘制省带宽、省CPU、省电。但如果Layer数量超过显示硬件Overlay平面的上限或者某个Layer做了特殊变换HWC处理不了SF就只能退回GPU合成把所有Buffer暴力合成一遍。看一个具体例子当你开着Game Mode游戏窗口、状态栏、导航栏、悬浮窗、还有输入法窗口同时可见这就是五六个Layer。老设备的Overlay平面一般就4个左右多出来的Layer必须由GPU合成。游戏本身是SurfaceView它自己的Buffer是独立的如果GPU合成压力大游戏的帧率也会被拖下来。这就是为什么有些手机上玩游戏时挂着悬浮窗帧率会明显下滑——悬浮窗增加了一个Layer把合成策略从硬件合成逼退到了GPU合成。3.3 Z轴顺序、Layer树与常见窗口异常的关系SurfaceFlinger用一个Layer树来管理所有可见窗口树的排序就是你在屏幕上看到的遮挡顺序。状态栏在最上层然后是应用窗口、输入法窗口、来电界面等等。每次窗口状态变化WMS都会通过Binder通知SF重建Layer树。这个顺序还牵扯到一个大家普遍忽略的问题为什么有些View是盖不住状态栏的为什么Dialog在Android 12上偶现动画异常。本质上都是Layer树的顺序或者外观属性Alpha、裁剪、圆角、阴影等被SF在合成阶段处理的方式不同。比如圆角裁剪如果窗口层设置了背景圆角HWC处理不了这类圆角裁剪就必须让GPU参与合成这是很多微圆角窗口在低端机上出现掉帧的元凶之一。另一个高频坑是SurfaceView。SurfaceView的Buffer是独立于View层的它不参与View树的常规绘制。SurfaceView的窗口在Layer树中是单独一层所以它可以实现低于1ms的延迟推送适用于视频播放、相机预览、游戏画面。但它也有对应的限制无法像View一样做常规的旋转、透明度叠加因为这个合成方式很特殊。你在SurfaceView上面盖一个半透明View经常出现显示顺序异常或者黑边原因就是SurfaceView的Z轴顺序不跟View树走而是由WindowManager和SurfaceFlinger单独协调。理解了这层很多“玄学问题”都有了答案。4. 帧的时序别让任何一环偷走你的16.6ms4.1 双缓冲、三缓冲和掉帧的那笔账在60Hz屏幕上一帧的预算大约是16.6ms。但这16.6ms并不是App独占的。实际时间线是App用一部分时间渲染SF用一部分时间合成最后屏幕用剩余时间刷新。任何一环超时都会连锁反应。我算过一笔账用数字说明为什么有的帧会白白丢掉假设当前是双缓冲。时间点在t0App通过dequeueBuffer拿到Buffer A开始渲染耗时16ms渲染完成后queueBuffer给SFSF在下一个Vsync拿到Buffer A开始合成耗时5ms屏幕刷新完成。这个流程很流畅两帧之间几乎没有间隔。但如果App渲染耗时变成20ms问题就出现了。App在t0开始渲染Buffer A20ms后在t1才完成queueBuffer此时离下一个Vsync只差一点点时间SF没来得及在本次Vsync里取走Buffer于是这一帧必须等到再下一个Vsync才能被合成并显示。结果就是屏幕上有一整段空窗期视觉上就是卡了一下。如果改成三缓冲App在t0渲染A的同时SF手里还有一个Buffer B在合成队列里还有一个空Buffer C。App即使晚了几毫秒也能在下一个Vsync开始前完成Queue从而避免丢帧。这个账算明白之后你就不会只在App侧找原因了有时候掉帧纯粹是缓冲策略和当前负载的博弈结果。4.2 RenderThread与GPU提交中的隐性时间在App进程内部主线程做的是measure、layout、draw也就是往DisplayList里记录命令这一步通常很快。真正耗时的是RenderThread同步DisplayList和向GPU提交渲染任务这一步。RenderThread会做三件事遍历DisplayList生成RenderNode树和Surface的绘制指令把纹理图上传到GPU显存执行OpenGL或Vulkan的draw call最终把图像画到Buffer上做完这些之后Buffer才会真正成为“可以被SF使用的数据”。这一步结束后App侧的SurfaceFlinger消费者才会收到queueBuffer通知。有一个我常用的小技巧判断瓶颈在哪一步开发者的“GPU呈现模式分析”柱状图里如果红柱明显说明GPU渲染时间超预算问题多半在RenderThread或者着色器过度使用如果绿柱正常但整体还是卡那问题可能在主线程那些不可见的隐性工作里。Perfetto里面可以直接看到SurfaceFlinger和RenderThread的task时长也可以区分这两类问题。4.3 冷启动首帧为什么慢冷启动首帧比普通帧慢很多这件事很能说明链路特性。启动时系统需要完成几乎一整条链路的初始化缺一不可创建进程并加载Application初始化各类系统服务创建Activity建立ViewRootImpl向WMS申请窗口并完成第一个Surface的创建第一帧绘制指令在主线程生成RenderThread子线程第一次初始化、创建GPU上下文SurfaceFlinger首次收到App的窗口Layer并经过WMS确认可见性屏幕完成第一次刷新整个链路中最容易被优化但更容易被忽略的是第一次GPU上下文的创建耗时。App启动前GPU还没准备好对应的渲染上下文第一次硬件加速要建立一堆资源很慢。你在优化冷启动时除了减少Application初始化工作量还会看到首帧延迟就要意识到不全是初始化代码的问题。链路每一环都有首次启动成本代码优化能解决一部分但不是全部。Android 12之后系统对首帧有更严格的上报逻辑会在Logcat里输出DisplayedTime。如果你遇到冷启动首帧特别慢的系统报告建议用Perfetto抓一下启动全程看SF的合成开始时间点是否比App绘制完成点还晚。如果SF侧合成在等App的Buffer那问题根因大概率在App侧的初始化任务上。5. 排查链路问题的两个方向5.1 从App侧定位卡顿源头排查显示链路问题我推荐你按链路顺序来不要一上来就看代码复现。第一步永远是确认“卡在App侧还是System侧”。最省事的办法是用Perfetto抓trace把一整段时间内的主线程、RenderThread、SF、HWC全部拉出来看。在Perfetto的图形界面里你能清楚地看到主线程和RenderThread的任务时间轴也能看到SurfaceFlinger的合成端点。只看App侧的话我用的最多的命令是adb shell dumpsys gfxinfo packageName framestats这个命令会输出最近一帧的详细时间戳包括app准备时间、GPU完成时间等。重点看IntendedVsync和FrameCompleted两个时间戳的差值这就是一帧真正从开始到结束所用的时间。如果App侧时间预算很健康比如12ms以内但用户感知仍然卡顿那就需要看合成侧。补充一个排查心法帧渲染里有个“掉帧的那一刻Logcat未必有异常”的现象。Choreographer掉帧超过阈值会打“Skipped X frames”这样的日志但很多掉帧其实是因为GPU异步完成的主线程完全没感知。所以不要只依赖Logcat用帧时间统计工具才能看清真相。5.2 从合成侧确认渲染瓶颈如果app侧时间看起来健康你就要把注意力转到SF合成这一步。一个常用的命令是adb shell dumpsys SurfaceFlinger --latency这条命令会输出窗口的帧时间信息重点看当前帧从acquire到present的时间差以及是否有acquire的时间和Vsync信号的时间存在错位。如果频繁出现acquire时间晚于合成时间点说明SF拿Buffer拿晚了问题大概率在App的渲染产出端如果acquire时间正常但present时间偏晚问题则在HWC或屏幕刷新端。我还真踩过一个印象很深的坑某台测试机上App侧的渲染时间全部在8ms以内但用户划动列表时依然有肉眼可见的掉帧。查了半天最后发现是状态栏Layer的Alpha动画导致SF每次合成都要全屏GPU混合整个合成耗时飙到了20ms。App侧完全没有问题但链路的尾端被拖死了。这类问题不看合成侧几乎不可能定位到。所以在排查显示问题时我的习惯是同时抓三条线的数据App主线程与RenderThread耗时、SF合成耗时、屏幕刷新率与Vsync相位。三个数据一交瓶颈在哪一层就非常清楚了。6. 关于“一张图”的实际经验和后续规划写到这里我把“那张图”完整拆给你了。这个系列的第1篇我建议你先只盯着图看不追求记住每个细节更不用去背代码。重点是让链路顺序和每个环节的交接点在脑子里留下框架。我自己的经验是看图和跟着源码跑通一遍之后再从某一个点比如SurfaceView、比如HWUI渲染深挖效率会高很多。不要一上来就扎进某一个类里那样容易在细节里迷失。画这张图我推荐用excalidraw这类白板工具比draw.io更适合画思路。别想着画一张完美的大图先按链路把五个层的方块画出来再把层之间的箭头和关键数据标上。等你跑通了某一段细节再回头在对应方块里补充这张图就变成了你的知识地图。有的同学喜欢把Vsync的相位关系用时间轴画也很有用尤其是排查掉帧时时间轴比结构图更能说明问题。第2篇我会把焦点放到BufferQueue和Triple Buffer这块把这套生产消费模型彻底讲透包括它和显示延迟、掉帧的关系以及怎么用代码去观测它。如果你在看Framework源码过程中有什么环节卡住了也欢迎在评论区说一下我后续会根据大家卡得最狠的地方调整选题。显示链路是个大坑但只要地图先画对了你自己也能顺着路走下去。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进