ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Android显示链路地图:从View绘制到SurfaceFlinger合成上屏

Android显示链路地图:从View绘制到SurfaceFlinger合成上屏 十几年前我第一次接触Android时习惯把显示相关的问题都甩给布局写得不好或者图片太大这类应用层原因。直到有次做一个视频类应用的掉帧优化我把dumpsys SurfaceFlinger的数据和Perfetto的时间线对到一起才发现卡顿的源头根本不在App里而是某个高优先级的系统Layer把合成带宽吃干净了。那次之后我意识到想真正定位Android显示问题脑子里必须有一张完整的Android显示完整链路图从应用的View树绘制到RenderThread渲染再到SurfaceFlinger合成最后落到屏幕硬件输出。这篇就是把这条链路从头到尾拆开给你一张可用的全局地图也附上我实际排查时常用的工具和判断逻辑。1. 链路总览一个像素从诞生到上屏要过几道门1.1 一帧画面的七段旅程先别急着看代码把整条链路从头到尾走一遍你会记住七个关键节点。第一段输入事件。用户在屏幕上滑动、点击触摸事件通过InputFlinger派发给当前窗口所在的App进程。这里看起来和显示没关系但一个事件如果分发得慢view的invalidate就来得晚后面所有环节都会顺延。第二段应用主线程完成measure、layout、draw。View树的测量布局决定每个控件的位置和大小draw阶段生成绘制指令。这时的内容还不是像素只是一堆操作指令所以叫DisplayList。第三段RenderThread消费这些指令调用Skia或OpenGL/Vulkan真正执行绘制渲染结果写入一个GraphicBuffer。第四段这个Buffer通过BufferQueue进入交付流程从App的Surface交给系统进程SurfaceFlinger。第五段SurfaceFlinger把所有App提交的Buffer——包括状态栏、导航栏、壁纸、你的页面——按照Z轴顺序合成一帧完整的画面。第六段合成结果交给HWCHardware Composer或直接通过GPU输出到显示控制器。第七段显示控制器按刷新率把这一帧扫到屏幕上驱动像素发光。这七段分布在三个进程/硬件层级里App进程负责前四段SystemServer进程里的SurfaceFlinger负责第五段HAL层和Kernel负责第六第七段。很多人只看第一、二段卡顿时只查主线程这就是视野盲区。1.2 数据与节拍一条流水线的两个维度理解显示链路我建议你用一条真实的生产流水线去打比方。流水线上传送的零件是GraphicBuffer每个零件就是一张完整帧的画面。App是生产零件的工位SurfaceFlinger是质检和组装工位屏幕是最终交付给用户的出口。流水线想高效运转不仅零件要合格传送带的节拍也得稳定。Android里这个节拍就是Vsync信号。系统屏幕每刷新一帧就产生一次Vsync从硬件显示控制器一路传到SurfaceFlinger再由SurfaceFlinger分发到各个App进程。App收到Vsync后开始准备下一帧SurfaceFlinger收到Vsync后把已经准备好的帧合成上屏。整个系统就是靠这个统一的节拍对齐所有参与者。这个类比能帮你理解很多现象缓冲队列的积压本质是零件到达速度和出口消化速度不匹配掉帧本质是某个工位在节拍到来时没交出合格的零件而SurfaceFlinger合成长本质是组装工位自己超时了。顺着数据和节拍两条线去看大多数卡顿问题都能快速划清责任范围。1.3 为什么必须用整条链路的视角看问题只盯局部的后果是你会在错误的地方反复优化。举个例子一个页面滑动卡你拿Profiler一看主线程每次draw只花了6ms不慢。于是你去优化图片解码把加载时间从80ms降到20ms但滑动还是卡。实际上问题可能出在第5段SurfaceFlinger因为另一个全屏半透明Layer做GPU合成把一帧预算吃掉大半App再快也补不上合成阶段的缺口。反过来也有。你以为SurfaceFlinger慢就一定砍动画、砍Layer实际上如果App的Buffer生产节奏太乱SF一直在等Buffer整体也会表现为合成慢。所以判断问题前先对每一段的职责和开销有个完整预期这就是这篇总览存在的意义。2. 应用侧绘制Choreographer、View系统与RenderThread的配合2.1 从界面结构到绘制指令应用侧的起点是ViewRootImpl。它连接了WindowManagerService和整个View树。当你调用setContentView本质上是在Window里建立一棵视图树而这棵树的根节点和系统窗口管理之间有一个ViewRootImpl作为桥梁。每次需要刷新界面时主线程会执行View的measure、layout、draw三步。measure计算每个View的尺寸layout确定位置draw生成DisplayList。这个阶段并没有真正画像素只是把怎么画记录下来并提交给RenderThread去执行。那Android事件分发机制在这里扮演什么角色事件分发决定了你点击的位置会命中哪个View而命中后会触发对应的状态变化比如Button的pressed状态。状态变化导致invalidateinvalidate又通过ViewRootImpl在下一个Vsync申请重绘。所以事件分发虽然不属于渲染管线却决定了渲染的触发时机和绘制范围。你可以在dispatchTouchEvent里打点观察事件分发耗时过长会直接影响下一帧的doFrame。2.2 Vsync节拍与16.6ms帧预算决定应用侧绘制节奏的核心是Choreographer。它内部有一个FrameDisplayEventReceiver专门监听Vsync信号收到后在主线程依次回调三类任务输入事件处理、动画更新、遍历与绘制。这些回调集合起来就是一次doFrame。一帧的时间预算是多少用1000ms除以屏幕刷新率。如果是60Hz屏那就16.6ms。注意这16.6ms不只是主线程的时间它要分摊给输入、动画、measure/layout/draw还要给RenderThread和SurfaceFlinger留时间。实际经验里App侧主线程最好控制在8ms以内超过10ms你就要警惕了。如果主线程doFrame消耗超过预算Choreographer会疯了一样去追下一帧结果表现为跳帧。注意这里有个反直觉的点系统并不是等主线程干完活才发下一帧VsyncVsync始终按固定节拍来。你超过节拍就只能等下一个拍子帧率就往下掉。所以Choreographer可以被理解为在节拍内尽量完成任务的协调者而不是干完活才催你的管家。2.3 RenderThread硬件加速后的幕后线程API 21之后系统引入了RenderThread它把渲染执行的工作从主线程搬到了独立线程重建了一个主线程负责业务和布局RenderThread负责执行绘制的职责划分。主线程draw阶段生成的DisplayList被封装成RenderNodeRenderThread统一去同步这些节点调用Skia或OpenGL/Vulkan真正执行GPU绘制。那为什么不是主线程直接画因为GPU绘制命令的提交经常是异步的如果放在主线程主线程会卡在驱动调用上。RenderThread的意义就是让主线程尽快回来处理下一帧的布局和事件硬件加速的工作在后台线程慢慢排队执行。实际分析时要注意RenderThread的耗时也要算进一帧的预算。很多开发者只看主线程忽略了RenderThread卡在GPU命令提交上同样会拖慢整帧。RenderThread耗时偏高通常意味着过度绘制、复杂Path、大量阴影或者GPU驱动负载过重。2.4 用Android Studio Profiler拆解应用侧耗时把App侧耗时看明白我最常用的工具还是Android Studio自带的CPU Profiler和Graphics Frame调试器。打开CPU Profiler记录一段滑动选择Flame chart能看到主线程名是main还有个后台线程叫RenderThread。如果火焰图里Choreographer.doFrame的栈出现就可以点进去看是measure还是layout占了大头。Graphics Frame旧版本叫GPU Profiler能告诉你每一帧draw阶段生成了多少个DrawCallRenderThread花了多少时间。常见信号是DrawCall数量上万或者flush命令耗时高。前者通常和布局层级、Path数量相关后者往往和GPU负载相关。这里提醒一个细节真机Profile时一定要关掉模拟器模拟器渲染路径用的是宿主机GPU数据完全不可信。另外尽量用Release包测试Debug模式下某些GC和检查逻辑会把帧时间拉高好几倍。3. 缓冲队列App与SurfaceFlinger之间的零件仓库3.1 BufferQueue的四个动作App画完的内容不能直接递给SurfaceFlinger去拼中间有一层缓冲区管理它就是BufferQueue。理解它只需要记四个动作dequeue、queue、acquire、release。App侧的Surface是生产者。它想画一帧先从BufferQueue里dequeue借出一个空闲Buffer画完后queue归还给队列。SurfaceFlinger是消费者它acquire领取这个Buffer去做合成合成完毕release释放Buffer让它回到空闲池。如此循环。这里需要注意Buffer的传递不是把像素数据拷贝到系统进程而是通过Binder传递Buffer句柄通常是共享内存fd或Gralloc分配的buffer handleSurfaceFlinger进程通过映射拿到同一块物理内存的访问权。真正的像素数据一直待在同一块显存/内存里谁也没有复制它。这既是性能设计也带来调试上的一些坑比如生命周期管理出错就会导致Surface泄漏。3.2 双缓冲与三缓冲为什么系统需要多个Buffer如果生产者和消费者的速度完全同步一个Buffer来回用就够了。但现实里两者经常互相等待。经典场景双缓冲模式下App画完Buffer ASF正在读Buffer B这时队列里没有空BufferApp必须等SF读完B才能继续画于是App空等帧率下降。为了减少这种等待系统会在双缓冲基础上再加一个Buffer形成三缓冲。三缓冲的本质是给生产者更多提前量。App可以在SF审核完一帧之前就开始画下一帧队列里多一个缓冲吸收抖动。代价是画面延迟略微升高因为Buffer从画完到上屏之间排队时间变长了。查看设备实际的缓冲数量可以执行adb shell dumpsys SurfaceFlinger看对应Layer的BufferQueue信息里面有maxDequeuedBufferCount、numBuffers等指标。有些机型的系统会在低负载时降为二缓冲来省内存这都正常。调试卡顿时不要一听缓冲由系统管理就不管它dequeue耗时和acquire耗时是判断生产消费瓶颈的重要证据。3.3 为什么不能直接复制一份像素给SurfaceFlinger很多人会问App画完直接拷贝给SF不就行了吗算一笔账一块1080p分辨率的屏幕RGBA8888格式一帧多少数据1920乘以1080再乘以4字节约8.3MB。60Hz下一秒就是约500MB。如果每次交付都走内存拷贝带宽会直接被吃掉功耗和延迟都不可接受。Android的Glow方案是让App和SF共享同一块GraphicBuffer。具体做法是App通过Gralloc模块分配缓冲区拿到fd句柄App里有对应的映射地址SurfaceFlinger通过匿名共享内存拿到同一Buffer的另一个映射。整条链路只传句柄不搬像素。理解这个机制后你再去看那些跨进程显示的方案比如SurfaceView、TextureView的差异本质都是Buffer所有权和合成路径的区别这个我后面详细讲。3.4 缓冲环节常见的掉帧源头缓冲环节最常见的掉帧是等待Buffer超时。比如App把Buffer全借出去还没还代码又要求再dequeue一个这时dequeue会阻塞等待直到SF用完返还。表现就是帧时间线里出现一个漫长的waiting for buffer间隙。另一种情况是SurfaceFlinger侧的同一Layer有多个Buffer都在等待合成时合成顺序不当会导致掉帧。我们可以adb shell dumpsys SurfaceFlinger看到Buffer的state字段常见有QUEUED、ACQUIRED、RELEASED。如果连续几帧都是QUEUED积压说明消费者速度跟不上生产者一般和合成策略或像素尺寸过大有关。我在源码里见过不少低层级纹理的问题比如VideoLayer的Buffer锁定了太长时间。排查思路很简单先确认App侧绘制是不是卡在dequeue再确认SF侧合成是不是卡在acquire。两边一对照就知道是生产端还是消费端的问题。4. 系统合成SurfaceFlinger怎么决定谁盖在谁上面4.1 SurfaceFlinger的合成工作循环SurfaceFlinger负责把系统里所有显示Layer合成一张最终的画面。它的工作循环同样由Vsync驱动。收到Vsync后SF会遍历当前所有的Layer判断哪些Layer的内容有更新、可见区域在哪里、是否需要重新合成然后根据合成策略调用GPU或HWC完成最终输出。关键点是SF并不总是每一帧重新把所有Layer 混合一遍。它会做Damage Region追踪只合成内容发生变化的区域。XDA社区经常讨论部分刷新这就是省电的核心。如果你看到某个Layer全屏都标记为damage那就会触发大量GPU合成。SF的运行节奏通常比App更难受因为它需要处理所有进程的Layer。如果设备上有太多可见Layer同时更新合成负担就会加大。所以很多手机系统会做动画层合并本质是减少需要独立合成的Layer数量。4.2 GPU合成与HWC硬件合成的取舍SF有两种合成路线。第一种是GPU合成ClientComposition把相关Layer按Z轴的顺序依次叠加绘制到目标buffer里。灵活、支持各种混合模式、任意效果但每一帧都需要GPU参与电量和带宽开销高。第二种是硬件合成DeviceComposition由显示控制器通过HWC硬件叠层直接完成多Layer混合CPU和GPU都不必做太多工作功耗低。问题是硬件叠层数量有限一般只有少数几个plane。实际系统会根据Layer的数量、大小、透明度、内容更新情况动态选择合成策略。你可以在dumpsys SurfaceFlinger里看到每个Layer的composition type。如果一个普通App页面里反复出现CLIENTGPU合成而不是DEVICE那就要检查是不是有全屏半透明Layer破坏了硬件叠层优化。4.3 Layer排序、遮挡与Damage区域系统里每个窗口、每个Surface最终都对应SF的一个Layer。Layer之间有一个Z轴顺序决定谁在前面谁在后面。普通App的Layer一般被安排在最上层之下的中间位置系统UI状态栏、导航栏在最上面。这就是为什么你在App里怎么调状态栏都盖在你内容上面的原因。合成的优化思路是能少画就少画。如果一个Layer是完全不透明且覆盖了另一个Layer的所有区域那么底层Layer完全可以跳过合成避免无谓的绘制这就是遮挡剔除。反过来如果顶层Layer是半透明甚至带圆角阴影底层Layer还是会参与合成GPU负载就上去了。常见的性能事故就是列表项带大面积阴影、圆角模糊叠加导致每一帧GPU合成范围变得巨大。4.4 从SF到屏幕面板HWC、Display与刷新率合成完的buffer最终要交给显示控制器。HWC通过validate和present两个阶段确认叠层方案然后把显示buffer提交给Display。显示控制器按固定的刷新率把buffer扫描到屏幕上。这里还要注意色彩管理、HDR转换、亮度调节这些都会作为显示链路里额外的时间开销存在。还有一个容易被忽略的点Vsync信号本身就是从显示控制器回读的。设备面板的刷新率变化会直接改变Vsync的频率进而影响Choreographer的帧预算。在高刷新率手机上如果你没有看清当前实际刷了多少Hz就会拿着60Hz的帧预算去分析120Hz的设备得出完全错误的结论。5. 拆开一帧的时间账本卡顿定位实战5.1 为什么主线程Profile不足以定位卡顿我最想纠正的习惯是主线程不卡就是没问题。一帧的耗时是主线程、RenderThread、BufferQueue、SurfaceFlinger、HWC几个环节的叠加。任何一个环节超出预算帧率都会掉但主线程Profile完全看不到后半段。举个例子你卡顿时主线程只用了5ms但SF合成用了20ms总时长远超16.6ms。如果你只看CPU Profiler会得出页面不卡的错误结论。正确做法是拿整帧的时间线而不是单线程的时间片。现在Android系统的FrameTimeline已经能把一帧在App、SF、HWC各段的开始和结束时间对齐展示这是做链路分析的利器。Perfetto和adb shell dumpsys gfxinfo输出的HISTOGRAM也都有类似信息。定位卡顿的第一步永远是先看整帧在哪一段花费超标。5.2 dumpsys SurfaceFlinger读懂关键指标在终端执行adb shell dumpsys SurfaceFlinger信息量很大我最常看的是下面几个字段用表格列出来方便你对照字段/段落作用常见异常信号refresh rate当前显示刷新率与设备标称不符说明有动态刷新策略Layer状态中的composition type该Layer使用GPU还是HWC合成普通页面大量CLIENT说明合层失效BufferQueue的Buffer State队列中各Buffer处于QUEUED还是ACQUIRED连续多帧QUEUED说明消费者慢mTimeStats/FrameTimeline帧各阶段耗时统计App/ SF/ HWC 各自耗时description中的visible标志Layer是否可见异常全屏Layer可见造成合成开销实际排查时我会把这些字段和Profiler时间线配合看。如果SF的合成策略从DEVICE变成CLIENT那基本可以确认某个Layer破坏了硬件合层接下来就去找那个Layer是谁。5.3 用Perfetto追踪一帧的完整生命周期Perfetto是目前最推荐的Trace工具能抓App、SurfaceFlinger、Kernel所有线程时间线。抓取命令很简单# 抓取10秒保存到设备指定目录 adb shell perfetto --time 10s -o /data/misc/perfetto-traces/case.perfetto-trace # 拉回电脑 adb pull /data/misc/perfetto-traces/case.perfetto-trace .把trace文件拖进 ui.perfetto.dev 就能看。找一帧的完整生命周期你可以搜索Choreographer找到主线程的doFrame段搜索RenderThread的syncFrameState与DrawFrames搜索SurfaceFlinger的doComposition和present。把这些slice的时间对齐就能看到一帧从App到SF再到HWC的完整账本。我通常会圈出掉帧前后相邻的几帧对比正常帧和掉帧帧在各段的耗时差异。注意trace中的时间戳都基于统一的clock如果不同进程的时基不一致直接对比会出偏差建议先开启统一的clock同步。5.4 一次卡顿排查实录从三个方向夹击以前调过一个锁屏界面滑动卡顿问题现象是偶尔掉到30帧左右。主线程耗时在9ms上下已经接近预算边缘但还没有到必然卡顿的程度。我于是调转方向去看SF侧。dumpsys SurfaceFlinger里发现一个全屏的模糊壁纸Layercomposition类型是CLIENT这意味着每一帧GPU都要对全屏做模糊采样。Perfetto里SF的doComposition那段耗时被拉高到13ms左右。再一看那个Layer是一个全天候动态模糊壁纸系统为了动画效果让它始终保持damage全屏。最终方案是把模糊效果改成静止帧缓存只在转场时重新渲染模糊层。改动后SF的合成耗时回到4ms以内锁屏滑动恢复60帧。整个过程App侧代码只改了一行真正的凶手在系统合成策略上。这类经验让我坚持一个原则先花10分钟看SF侧比在App侧盲目优化一晚上有效得多。6. 高刷与动态显示下的新问题6.1 关于掉帧的三个常见误区先说第一个误区掉帧一定等于主线程慢。实际上一帧可以因为GPU渲染慢、SF合成慢、HWC提交慢、BufferQueue排队慢而掉主线程只是其中一个环节。判断时不要在没有任何trace证据的情况下直接锁定主线程。第二个误区平均帧率能衡量流畅度。平均59帧和平均45帧中间过程完全不同。更科学的指标是帧时间分布HISTOGRAM和Jank次数。帧率平稳比平均数值更重要一次20ms的尖峰都可能被感知成卡顿。第三个误区卡顿只能通过App代码修。不少卡顿来自系统侧合成策略或窗口层的冲突。应用开发者能做的是提供更合理的Layer结构、控制遮罩层、避免无意义的全屏damage有些时候这比优化布局更立竿见影。6.2 SurfaceView、TextureView与普通View在链路中的位置差异同一个显示需求不同载体的链路位置完全不同我直接列个表载体Buffer来源合成方式典型场景普通ViewApp通过RenderThread绘制作为普通Layer参与SF合成常规UI页面SurfaceView独立Surface不参与View树绘制独立Layer可直接HWC叠加视频播放、相机预览TextureViewView树内的Texture内容作为纹理需要作为一个Texture被SF合成需要动画变换的视频流GLSurfaceViewApp自建GL上下文绘制到Surface类似SurfaceView游戏、复杂GL场景SurfaceView的优点是可以独立于UI层级直接把视频帧通过HWC叠加显示省去一次合成。缺点是它不在View树里做平移、旋转、淡入淡出等动画麻烦因为需要和所在View的Z轴同步。TextureView在View树内动画便利但内容必须作为一个纹理参与合成等于多一步处理和带宽消耗。做视频或相机业务时如果不需要动画建议优先SurfaceView。6.3 高刷新率适配帧预算从16.6ms变成8.3ms高刷机上一帧的时间预算从60Hz的16.6ms压缩到120Hz的8.3ms。这不是小数量的变化而是对整条链路每个环节都提出了更高要求。主线程、RenderThread、SF合成、HWC提交任何一段超过2ms都可能构成浪费。Android 12之后系统提供了Surface.setFrameRate以及刷新率切换机制。动态内容的页面申请高刷新率静态页面系统会自动降回低刷新率这样可以兼顾流畅和功耗。源码层面有一个DisplayManager和SurfaceFlinger的刷新率协商机制应用侧可以通过PhysicalDisplay请求具体的刷新率。开发时不要硬编码我的页面必须120Hz。对视频、滑动列表来说高刷有意义对纯静态文字页面强上高刷只会耗电还可能导致UI线程和SF之间的节拍频繁切换。另外注意部分手机上即使面板刷新率120Hz应用如果持续跑低耗时任务系统可能仍让App进入低刷新率模式帧预算分析要以dumpsys SurfaceFlinger中的实际refresh rate为准。6.4 后续学习路线从哪里继续深挖这条链路这篇文章是系列的第一篇先把图景立起来后续再逐个环节深入。如果你想继续深挖我给出几个方向。第一个方向是Framework应用层重点看ViewRootImpl、Choreographer、RenderNode的源码。第二个方向是系统服务层看SurfaceFlinger的doComposition和Layer管理逻辑。第三个方向是硬件抽象层看HWC的validate/present的调用流程以及DRM/KMS显示驱动。工具层面除了Perfetto还可以配合Simpleperf做CPU采样以及GPU厂商的Inspector看GPU占用。最后说点个人经验。我现在排查显示类问题习惯顺序是先确认掉帧是节拍问题还是处理问题也就是先打开Perfetto看一帧的时间线再看BufferQueue有没有blocked然后才去抠App代码。有一次线上问题同事一直以为是列表item缓存问题我第一眼看到SurfaceFlinger的Layer里有个全屏半透明遮罩Layer强行让所有合成从DeviceComposition退化成GPU合成一帧GPU耗时多出4ms。把遮罩改成HWC能处理的全视窗尺寸后卡顿直接消失。这类问题如果你心里没有整条链路图很可能会在App层空转很久。希望这张图能让后续阅读更顺也期待你在评论区分享新的案例。
返回列表