Android性能优化(一)——卡顿优化
本文转载自努比亚技术团队公众号。
在分析任何问题之前,我们都需要先弄清楚其基本原理,也就是要掌握了这个“道”,才能真正着手去分析问题,否者只能是弄得一头雾水,也没法真正的理解和解决问题。所以要想分析并解决界面掉帧卡顿问题,我们就先需要知道在Android
系统上应用UI
线程到底是如何完成一帧画面的上帧显示动作的(本文讲解的内容主要基于Android
原生应用的绘制渲染流程,对于游戏应用和Flutter
开发的应用流程会不太一样,由于篇幅所限,本文暂不涉及,可以关注团队后续其它文章的内容)。由于大部分应用界面的上帧更新画面动作都是由用户手指触摸屏幕而触发,所以本文以手指上下滑动应用界面的操作场景为例,结合Systrace
分析一下Android
应用上帧显示的原理。
1 Input事件处理机制
1.1 系统机制分析
Android
系统是由事件驱动的,而 Input
是最常见的事件之一,用户的点击、滑动、长按等操作,都属于 Input
事件驱动,其中的核心就是 InputReader
和 InputDispatcher
。InputReader
和 InputDispatcher
是跑在 system_server
进程中的两个 Native
循环线程,负责读取和分发 Input
事件。整个处理过程大致流程如下:
- 触摸屏会按照屏幕硬件的触控采样率周期,每隔几毫秒扫描一次,如果有触控事件就会上报到对应的设备驱动;系统封装了一个叫
EventHub
的对象,它利用inotify
和epoll
机制监听/dev/input
目录下的input
设备驱动节点,通过EventHub
的getEvents
接口就可以监听并获取到Input
事件; InputReader
负责从EventHub
里面把Input
事件读取出来,然后交给InputDispatcher
进行事件分发;InputDispatcher
在拿到InputReader
获取的事件之后,对事件进行包装后,寻找并分发到目标窗口;InboundQueue
队列(“iq”
)中放着InputDispatcher
从InputReader
中拿到的input
事件;OutboundQueue
(“oq”
)队列里面放的是即将要被派发给各个目标窗口App
的事件;WaitQueue
队列里面记录的是已经派发给App
(“wq”
),但是App
还在处理没有返回处理成功的事件;PendingInputEventQueue
队列(“aq”)中记录的是应用需要处理的Input
事件,这里可以看到input
事件已经传递到了应用进程;deliverInputEvent
标识App
UI Thread
被Input
事件唤醒;InputResponse
标识Input
事件区域,这里可以看到一个Input_Down
事件 + 若干个Input_Move
事件 + 一个Input_Up
事件的处理阶段都被算到了这里;App
响应处理Input
事件,内部会在其界面View
树中逐层分发和处理。
用一张图描述整个过程大致如下(关于这部分详细的Android
系统源码实现流程可以参考这篇文章):
1.2 结合Systrace分析
从上面的系统机制的分析可以看出,整个Input
触控事件的分发与处理主要涉及到两个进程:一个是system_server
系统进程,另一个是当前焦点窗口所属的Setting
应用进程。
一、system_server
进程的处理过程:
当用户手指在
Setting
应用界面滑动时,系统system_server
进程中的native
线程InputReader
会从EventHub
中读取其利用linux
的epoll
机制监听到的屏幕驱动上报的Input
触控事件,然后唤醒另外一条native线程InputDispatcher
负责进行事件的进一步分发处理。InputDispatcher
被唤醒后会先将事件放到InboundQueue
队列(也就是Systrace
上看到的“iq”
队列)中,然后找到具体处理此input
事件的应用目标窗口,并将Input
事件放入对应的应用目标窗口的OutboundQueue
队列(也就是Systrace
上看到的“oq”
队列)中,等待进一步通过SocketPair
双工信道发送input
事件到应用目标窗口中;最后当事件发送给具体的应用目标窗口后,会将事件移动到
WaitQueue
队列中(也就是Systrace
上看到的“wq”
队列)并一直等待收到到目标应用处理Input
事件完成后的反馈后再从队列中移除,如果5秒内没有收到目标应用窗口处理完成此次Input
事件的反馈,就会报该应用ANR异常事件。以上整个过程在Android
系统AOSP
源码中都加有相应的Systrace tag
,如下Systrace
截图所示:
二、应用进程的处理过程:
当Input
触控事件通过socket
传递到Settings
应用进程这边后,会唤醒应用的UI
线程在ViewRootImpl#deliverInputEvent
的流程中进行Input
事件的具体分发与处理。具体的处理流程:
先交给之前在添加应用
PhoneWindow
窗口时的ViewRootImpl#setView
流程中创建的多个不同类型的InputUsage
中依次进行处理(比如对输入法处理逻辑的封装ImeInputUsage
,某些key
类型的Input
事件会由它先交给输入法进程处理完后再交给应用窗口的InputUsage
处理),整个处理流程是按照责任链的设计模式进行;最后会交给负责应用窗口
Input
事件分发处理的ViewPostImeInputUsage
中具体处理,这里面会从View
布局树的根节点DecorView
开始遍历整个View
树上的每一个子View
或ViewGroup
控件执行事件的分发、拦截、处理的逻辑;最后触控事件处理完成后会调用
finishInputEvent
结束应用对触控事件处理逻辑,这里面会通过JNI
调用到native
层InputConsumer
的sendFinishedSignal
函数中通过socket消息通知系统框架中的InputDispatcher
该Input
事件处理完成,触发从"wq"
队列中及时移除待处理事件以免报ANR
异常。一次滑动过程的触控交互的
InputResponse
区域中一般会包含一个Input
的ACTION_DOWN
事件+多个ACTION_MOVE
事件+一个ACTION_UP
事件,Settings
应用界面中的相关View
控件在收到多个ACTION_MOVE
触控事件后,经过判断为用户手指滑动行为,一般会调用View#invalidate
等相关接口触发UI
线程的绘制上帧更新画面的操作,具体流程后文会继续详细分析。以上过程如下Systrace
截图所示:
2 应用UI线程消息循环机制
App
应用启动时,在Fork
创建进程后会通过反射创建代表应用主线程的ActivityThread
对象并执行其main
函数,进行UI
主线程的初始化工作:
1 |
|
主线程初始化完成后,主线程就有了完整的 Looper
、MessageQueue
、Handler
,此时 ActivityThread
的 Handler
就可以开始处理 Message
,包括 Application
、Activity
、ContentProvider
、Service
、Broadcast
等组件的生命周期函数,都会以 Message
的形式,在主线程按照顺序处理,这就是 App
主线程的初始化和运行原理,部分处理的 Message
如下:
1 |
|
主线程初始化完成后,主线程就进入阻塞状态(进入epoll_wait
状态,并释放CPU
运行资源),等待 Message
,一旦有 Message
发过来,主线程就会被唤醒,处理 Message
,处理完成之后,如果没有其他的 Message
需要处理,那么主线程就会进入休眠阻塞状态继续等待。可以说Android
系统的运行是受消息机制驱动的,而整个消息机制是由上面所说的四个关键角色相互配合实现的(Handler
、Looper
、MessageQueue
、Message
),其运行原理如下图所示:
3 Android屏幕刷新机制
3.1 双缓存+Vsync
在一个典型的显示系统中,一般包括CPU
、GPU
、Display
三个部分:CPU
负责计算帧数据,把计算好的数据交给GPU
,GPU
会对图形数据进行渲染,渲染好后放到buffer
(图像缓冲区)里存起来,然后Display
(屏幕或显示器)负责把Buffer
里的数据呈现到屏幕上。
屏幕上显示的内容,是从Buffer
图像帧缓冲区中读取的,大致读取过程为:从Buffer
的起始地址开始,从上往下,从左往右扫描整个Buffer
,将内容映射到显示屏上。如下图所示:
当然,屏幕上显示的内容需要不断的更新,如果在同一个Buffer
进行读取和写入操作,将会导致屏幕显示多帧内容而出现显示错乱。所以硬件层除了提供一个Buffer
用于屏幕显示,还会提供了一个Buffer
用于后台的CPU/GPU
图形绘制与合成,也就是我们常说的双缓冲:让绘制和显示器拥有各自的Buffer
:CPU/GPU
始终将完成的一帧图像数据写入到 后缓存区(Back Buffer
),而显示器使用前缓存区( Front Buffer
),当屏幕刷新时,Front Buffer
并不会发生变化,当Back Buffer
准备就绪后,它们才进行交换。如下图所示:
理想情况下假设前一帧显示完成,后一帧数据就准备好了,屏幕开始读取下一帧内容进行显示,也就是开始读取上图中的后缓冲区的内容:
此时,前后缓冲区进行一次角色交换,之前的后缓冲区变为前缓冲区,进行图形的显示,之前的前缓冲区则变为后缓冲区,进行图形的绘制合成。然而,理想很丰满,现实很骨感,上面假设“当前一帧显示完毕,后一帧准备好了”的情况,在现实中这两个事件并非同时完成。那么,屏幕读取缓冲区的速度和系统绘制合成帧的速度之间有什么关系呢,带着这个疑惑我们看看下面两个基本概念:
- 屏幕刷新率(
Screen Refresh Rate
): 屏幕刷新率是一个硬件的概念,单位是Hz
(赫兹),是说屏幕这个硬件刷新画面的频率:举例来说,60Hz
刷新率意思是:这个屏幕在 1 秒内,会刷新显示内容60 次;那么对应的,90Hz
是说在 1 秒内刷新显示内容 90 次。 - 帧率(
Frame Rate
): 与屏幕刷新率对应的,帧率是一个软件的概念,单位是FPS
(Frame Per Second
),表示CPU/GPU
在一秒内绘制合成产生的帧数,意思是每秒产生画面的个数,FPS
的值是由软件系统决定的。举例来说,60FPS
指的是每秒产生 60 个画面;90FPS
指的是每秒产生 90 个画面。
我们用以下两个假设来分析两者的关系:
屏幕刷新率比系统帧率快:
此时,在前缓冲区内容全部映射到屏幕上之后,后缓冲区尚未准备好下一帧,屏幕将无法读取下一帧,所以只能继续显示当前一帧的图形,造成一帧显示多次,也就是卡顿。系统帧率比屏幕刷新率快:
此时,屏幕未完全把前缓冲区的一帧映射到屏幕,而系统已经在后缓冲区准备好了下一帧,并要求读取下一帧到屏幕,将会导致屏幕上半部分是上一帧的图形,而下半部分是下一帧的图形,造成屏幕上显示多帧,也就是屏幕撕裂现象,如下图所示:
所以上面两种情况,都会导致问题,根本原因就是两个缓冲区的操作速率不一致。解决办法就是:让屏幕控制前后缓冲区的切换时机,让系统帧速率配合屏幕刷新率的节奏。那么屏幕是如何控制这个节奏的呢?
答案就是垂直同步(VSync):当屏幕从缓冲区扫描完一帧到屏幕上之后,开始扫描下一帧之前,中间会有一个时间间隙,称为Vetrical Blanking Interval (VBI)
,这个时间点其实就是进行前后缓存区交换的最佳时机,此时屏幕并没有在刷新,也就避免了屏幕撕裂现象的产生,所以在此时发出的一个同步Vsync
信号,该信号用来切换前缓冲区和后缓冲区(本质就是内存地址的交换,瞬间即可完成),即可达到最佳效果。
通过上面的分析可以看出:屏幕的显示节奏是由屏幕刷新率的硬件参数决定且固定的,软件操作系统需要配合屏幕的显示,在固定的时间内准备好下一帧,以供屏幕进行显示,两者通过VSync
信号来实现同步(VSync
这个概念并不是Google
首创的,它在早年的PC
机领域就已经出现了)。
3.2 Drawing with Vsync
在Android 4.1
之前,屏幕刷新也遵循上面介绍的 双缓存+VSync
机制,整个流程与架构借用2012
年Google I/O
大会上展示的一张图如下所示:
上图中:
一、纵轴表示Buffer
的使用者,由如下三个角色构成:
CPU
:代表利用CPU
对界面View
的Measure
尺寸测量、Layout
位置布局、Draw
绘制并最终生成纹理的操作;GPU
:代表使用OpenGl
库指令操作GPU
硬件对CPU
生成的纹理数据进行渲染和栅格化以及合成等操作;Display
:代表底层的显示屏幕;
二、横轴表示时间,每个长方形表示Buffer
的使用,长方形的宽度代表使用时长,VSync
代表垂直同步信号。
我们以时间为顺序来看看这种设计存在的潜在缺陷:
Display
上显示第0
帧数据,此时CPU
和GPU
正在处理准备第1
帧的画面,且在Display
显示下一帧前完成;- 因为
CPU
和GPU
的处理及时,Display
在第0
帧显示完成后,也就是第1
个VSync
后,缓存进行交换,然后正常显示第1
帧; - 接着第
2
帧开始处理,但是CPU
并没有立刻开始准备第2
帧的数据,而是直到第2
个VSync
快来前才开始处理的; - 第
2
个VSync
来时,由于第2
帧数据还没有准备就绪,缓存没有交换,屏幕显示的还是第1
帧画面,即产生了丢帧卡顿问题; - 当第
2
帧数据准备完成后,它并不能立马被显示,而是要等到下一个VSync
带来后,进行前后缓存交换才能显示到屏幕上。
出现此掉帧卡顿问题的根本原因是:上层的CPU
和GPU
并不知道Vsync
信号的到来,所以在底层屏幕的Vsync
信号发出后并没有及时收到并开始下一帧画面的操作处理。根据前面的分析我们知道:双缓存的交换是在Vsyn
信号到来时进行,交换后屏幕会读取Front Buffer
内的新数据更新显示到屏幕上,而此时的Back Buffer
就可以供GPU
准备下一帧数据了。如果 Vsyn
到来时 CPU/GPU
就开始操作的话,是有完整的Vsync
周期时长来处理一帧数据,以避免卡顿的出现。那如何让 CPU/GPU
的处理在 Vsyn
信号到来时就开始进行呢?
为了优化系统显示性能,Google
在Android 4.1
系统中对Android Display
系统进行了重构,引入了Project Butter
(黄油计划),其中很重要的一点修改就是实现了:在系统收到VSync
信号后,上层CPU
和GPU
马上开始进行下一帧画面数据的处理,完成后及时将数据写入到Buffer
中,Google
称之为Drawing with Vsync
。如下图所示:
3.3 Choreographer
上一节中讲到的,为了优化显示系统性能,Google
在Android 4.1
系统中对Android Display
系统进行了重构,引入了Project Butter
(黄油计划),其中很重要的一点修改就是实现了:在系统收到VSync
信号后,上层CPU
和GPU
马上开始进行下一帧画面数据的处理,完成后及时将数据写入到Buffer
中。为了实现这个效果,控制上层CPU
和GPU
在收到Vsync
信号后马上开始一帧数据的处理,谷歌为此专门设计了一个名为Choreographer
(中文翻译为“编舞者”)的类,来控制上层绘制的节奏。
Choreographer
的引入,主要是为了配合系统Vsync
垂直同步机制,给上层 App 的渲染提供一个稳定的 Message
处理的时机,也就是 Vsync
到来的时候 ,系统通过对 Vsync
信号周期的调整,来控制每一帧绘制操作的时机。Choreographer
扮演 Android 渲染链路中承上启下的角色:
- 承上:负责接收和处理
App
的各种更新消息和回调,等到Vsync
到来的时候统一处理。比如集中处理Input
(主要是Input
事件的处理) 、Animation
(动画相关)、Traversal
(包括measure、layout、draw
等操作) ,判断卡顿掉帧情况,记录CallBack
耗时等; - 启下:负责请求和接收
Vsync
信号。接收Vsync
信号到来的事件后回调(通过FrameDisplayEventReceiver
.onVsync
),并请求Vsync
(FrameDisplayEventReceiver
.scheduleVsync
) 。
一般应用App
有界面UI
的变化时,最终都会调用走到ViewRootImpl#scheduleTraversals()
方法中,该方法中会往Choreographer
中放入一个CALLBACK_TRAVERSAL
类型的绘制任务,如下代码所示:
1 |
|
Choreographer
在收到的绘制任务后,其内部的工作流程如下图所示:
从以上流程图可以看出上层一般App
应用UI
中View
的绘制流程(包含SurfaceView的游戏应用的绘制流程会有一些差异,篇幅有限此处不再展开分析):
View#invalidate
触发更新视图请求,此动作会调用ViewRootImpl#scheduleTraversals
函数;ViewRootImpl#scheduleTraversals
中会向Choreographer
中postCallback
放入一个CALLBACK_TRAVERSAL
类型绘制待执行任务;Choreographer
通过DisplayEventReceiver
向系统SurfaceFlinger
注册下一个VSync
信号;- 当底层产生下一个
VSync
消息时,将该信号发送给DisplayEventReceiver
,最后传递给Choreographer
; Choreographer
收到VSync
信号之后,向主线程MessageQueue
发送了一个异步消息;- 最后,异步消息的执行者是跑在主线程中的
ViewRootImpl#doTraversal
,也就是真正开始绘制一帧的操作(包含measure、layout、draw
三个过程);
至此,底层的VSync
控制上层绘制的逻辑就解释完了。
4 UI 线程绘制流程
在前几节中分析了应用UI
线程的消息循环机制和Android
屏幕刷新机制之后,我们接着1
小节中关于Input
触控事件的处理流程继续往下分析。在1
小节的分析中我们了解到:用户手指在应用界面上下滑动时,应用的UI
线程中会收到system_server
系统进程发送来的一系列Input
事件(包含一个ACTION_DOWN
、多个ACTION_MOVE
和一个ACTION_UP
事件),应用界面布局中的相关View
控件在收到多个ACTION_MOVE
触控事件后,判断为用户手指的滑动行为后,一般会调用View#invalidate
等接口触发UI
线程的绘制上帧更新画面的操作。
在开始分析之前,我们先来看看Android
系统的GUI
显示系统在APP
应用进程侧的核心架构,其整体架构如下图所示:
Window
是一个抽象类,通过控制DecorView
提供了一些标准的UI方案,比如背景、标题、虚拟按键等
,而PhoneWindow
是Window
的唯一实现类,在Activity
创建后的attach流程中创建,应用启动显示的内容装载到其内部的mDecor
(DecorView
);DecorView
是整个界面布局View控件树的根节点,通过它可以遍历访问到整个View
控件树上的任意节点;WindowManager
是一个接口,继承自ViewManager
接口,提供了View
的基本操作方法;WindowManagerImp
实现了WindowManager
接口,内部通过组合
方式持有WindowManagerGlobal
,用来操作View
;WindowManagerGlobal
是一个全局单例,内部通过ViewRootImpl
将View
添加至窗口中;ViewRootImpl
是所有View
的Parent
,用来总体管理View
的绘制以及与系统WMS
窗口管理服务的IPC
交互从而实现窗口的开辟;ViewRootImpl
是应用进程运转的发动机,可以看到ViewRootImpl
内部包含mView
(就是DecorView
)、mSurface
、Choregrapher
:mView
代表整个控件树,mSurfacce
代表画布,应用的UI渲染会直接放到mSurface
中,Choregorapher
使得应用请求vsync
信号,接收信号后开始绘制流程。
我们从ViewRootImpl
的invalidate
流程继续往下分析:
1 |
|
从以上分析可以看出,应用UI线程的绘制最终是通过往Choreographer
中放入一个CALLBACK_TRAVERSAL
类型的绘制任务而触发,下面的流程就和3.3.3
小节中的分析的一致,Choreographer
会先向系统申请Vsync
信号,待Vsync
信号到来后,向应用主线程MessageQueue
发送一个异步消息,触发在主线程中执行ViewRootImpl#doTraversal
绘制任务动作。我们接着看看ViewRootImpl
的doTraversal
函数执行绘制流程的简化代码流程:
1 |
|
从上面的代码流程可以看出,ViewRootImpl
中负责的整个应用界面绘制的主要流程如下:
- 从界面View控件树的根节点
DecorView
出发,递归遍历整个View
控件树,完成对整个View
控件树的measure
测量操作,由于篇幅所限,本文就不展开分析这块的详细流程; - 界面第一次执行绘制任务时,会通过
Binder
IPC
访问系统窗口管理服务WMS的relayout接口,实现窗口尺寸的计算并向系统申请用于本地绘制渲染的Surface
“画布”的操作(具体由SurfaceFlinger
负责创建应用界面对应的Layer
对象,并通过内存共享的方式通过Binder
将地址引用透过WMS回传给应用进程这边); - 从界面View控件树的根节点
DecorView
出发,递归遍历整个View
控件树,完成对整个View
控件树的layout
布局操作; - 从界面View控件树的根节点
DecorView
出发,递归遍历整个View
控件树,完成对整个View
控件树的draw
绘制操作,如果开启并支持硬件绘制加速(从Android 4.X开始谷歌已经默认开启硬件加速),则走GPU
硬件绘制的流程,否则走CPU
软件绘制的流程;
以上绘制过程从systrace
上看如下图所示:
5 RenderThread 线程渲染流程
截止到目前,在ViewRootImpl
中完成了对界面的measure
、layout
和draw
等绘制流程后,用户依然还是看不到屏幕上显示的应用界面内容,因为整个Android
系统的显示流程除了前面讲到的UI线程的绘制外,界面还需要经过RenderThread
线程的渲染处理,渲染完成后,还需要通过Binder
调用“上帧”交给surfaceflinger
进程中进行合成后送显才能最终显示到屏幕上。本小节中,我们将接上一节中ViewRootImpl
中最后draw的流程继续往下分析开启硬件加速情况下,RenderThread
渲染线程的工作流程。由于目前Android 4.X之后系统默认界面是开启硬件加速的,所以本文我们重点分析硬件加速条件下的界面渲染流程,我们先分析一下简化的代码流程:
1 |
|
从上面的代码可以看出,硬件加速绘制主要包括两个阶段:
- 从
DecorView
根节点出发,递归遍历View
控件树,记录每个View
节点的drawOp
绘制操作命令,完成绘制操作命令树的构建; JNI
调用同步Java
层构建的绘制命令树到Native
层的RenderThread
渲染线程,并唤醒渲染线程利用OpenGL
执行渲染任务;
5.1 构建绘制命令树
我们先来看看第一阶段构建绘制命令树的代码简化流程:
1 |
|
从以上代码可以看出,构建绘制命令树的过程是从View
控件树的根节点DecorView
触发,递归调用每个子View
节点的updateDisplayListIfDirty
函数,最终完成绘制树的创建,简述流程如下:
- 利用
View
对象构造时创建的RenderNode
获取一个SkiaRecordingCanvas
“画布”; - 利用
SkiaRecordingCanvas
,在每个子View
控件的onDraw
绘制函数中调用drawLine
、drawRect
等绘制操作时,创建对应的DisplayListOp
绘制命令,并缓存记录到其内部的SkiaDisplayList
持有的DisplayListData
中; - 将包含有
DisplayListOp
绘制命令缓存的SkiaDisplayList
对象设置填充到RenderNode
中; - 最后将根
View
的缓存DisplayListOp
设置到RootRenderNode
中,完成构建。
以上整个构建绘制命令树的过程可以用如下流程图表示:
硬件加速下的整个界面的View
树的结构如下图所示:
最后从Systrace
上看这个过程如下图所示:
5.2 执行渲染绘制任务
经过上一小节中的分析,应用在UI
线程中从根节点DecorView
出发,递归遍历每个子View
节点,搜集其drawXXX
绘制动作并转换成DisplayListOp
命令,将其记录到DisplayListData
并填充到RenderNode
中,最终完成整个View
绘制命令树的构建。从此UI线程的绘制任务就完成了。下一步UI
线程将唤醒RenderThread
渲染线程,触发其利用OpenGL
执行界面的渲染任务,本小节中我们将重点分析这个流程。我们还是先看看这块代码的简化流程:
1 |
|
从以上代码可以看出:UI
线程利用RenderProxy
向RenderThread
线程发送一个DrawFrameTask
任务请求,RenderThread
被唤醒,开始渲染,大致流程如下:
syncFrameState
中遍历View
树上每一个RenderNode
,执行prepareTreeImpl
函数,实现同步绘制命令树的操作;- 调用
OpenGL
库API
使用GPU
硬件,按照构建好的绘制命令完成界面的渲染(具体过程,由于本文篇幅所限,暂不展开分析); - 将前面已经绘制渲染好的图形缓冲区
Binder
上帧给SurfaceFlinger
合成和显示;
整个过程可以用如下流程图表示:
从Systrace
上这个过程如下图所示:
6 SurfaceFlinger图形合成
SurfaceFlinger
合成显示部分属于Android
系统GUI
中图形显示的内容,简单的说SurfaceFlinger
作为系统中独立运行的一个Native
进程,借用Android
官网的描述,其职责就是负责接受来自多个来源的数据缓冲区,对它们进行合成,然后发送到显示设备。如下图所示:
从上图可以看出,其实SurfaceFlinger
在Android
系统的整个图形显示系统中是起到一个承上启下的作用:
- 对上:通过
Surface
与不同的应用进程建立联系,接收它们写入Surface
中的绘制缓冲数据,对它们进行统一合成。 - 对下:通过屏幕的后缓存区与屏幕建立联系,发送合成好的数据到屏幕显示设备。
图形的传递是通过Buffer
作为载体,Surface
是对Buffer
的进一步封装,也就是说Surface
内部具有多个Buffer
供上层使用,如何管理这些Buffer
呢?答案就是BufferQueue
,下面我们来看看BufferQueue
的工作原理。
6.1 BufferQueue机制
借用一张经典的图来描述BufferQueue
的工作原理:
BufferQueue
是一个典型的生产者-消费者模型中的数据结构。在Android
应用的渲染流程中,应用扮演的就是“生产者”的角色,而SurfaceFlinger
扮演的则是“消费者”的角色,其配合工作的流程如下:
- 应用进程中在开始界面的绘制渲染之前,需要通过
Binder
调用dequeueBuffer
接口从SurfaceFlinger
进程中管理的BufferQueue
中申请一张处于free
状态的可用Buffer
,如果此时没有可用Buffer
则阻塞等待; - 应用进程中拿到这张可用的
Buffer
之后,选择使用CPU
软件绘制渲染或GPU
硬件加速绘制渲染,渲染完成后再通过Binder
调用queueBuffer
接口将缓存数据返回给应用进程对应的BufferQueue
(如果是GPU
渲染的话,这里还有个GPU
处理的过程,所以这个Buffer
不会马上可用,需要等GPU
渲染完成的Fence
信号),并申请sf
类型的Vsync
以便唤醒“消费者”SurfaceFlinger
进行消费; SurfaceFlinger
在收到Vsync
信号之后,开始准备合成,使用acquireBuffer
获取应用对应的BufferQueue
中的Buffer
并进行合成操作;- 合成结束后,
SurfaceFlinger
将通过调用releaseBuffer
将Buffer
置为可用的free
状态,返回到应用对应的BufferQueue
中。
6.2 Vsync同步机制
在之前3.3
小节关于Android
系统屏幕刷新机制中我们分析了Vsync
机制的来龙去脉。其实Android
系统中的Vsync
信号的产生与管理都是由SurfaceFlinger
模块统一负责的,Vysnc
信号一般分为两种类型:
app
类型的Vsync
:app
类型的Vysnc
信号由上层应用中的Choreographer
根据绘制需求进行注册和接收,用于控制应用UI绘制上帧的生产节奏。根据3.4
小结中的分析:应用在UI线程中调用invalidate
刷新界面绘制时,需要先透过Choreographer
向系统申请注册app
类型的Vsync
信号,待Vsync
信号到来后,才能往主线程的消息队列放入待绘制任务进行真正UI
的绘制动作;sf
类型的Vsync
:sf
类型的Vsync
是用于控制SurfaceFlinger
的合成消费节奏。应用完成界面的绘制渲染后,通过Binder
调用queueBuffer
接口将缓存数据返还给应用对应的BufferQueue
时,会申请sf
类型的Vsync
,待SurfaceFlinger
在其UI线程中收到Vsync
信号之后,便开始进行界面的合成操作。
Vsync
信号的生成是参考屏幕硬件的刷新周期的,其架构如下图所示:
6.3 帧数据的提交消费过程
我们接着3.5.2
小节中的分析,应用进程的RenderThread
渲染线程在执行完一帧画面的渲染操作的最后,会通过Binder
调用queueBuffer
接口将一帧数据提交给SurfaceFlinger
进程进行消费合成显示。我们结合相关简化的源码流程(这里基于Android 11
源代码分析)来看看SurfaceFlinger
中是如何处理应用的请求的。
1 |
|
上面的frameAvailableListener
是BufferQueueLayer
:
1 |
|
以上过程从Systrace
上看如下图所示:
由上面分析可知,只要有layer
上帧,那么就会申请下一次的Vsync-sf
信号, 当Vsync-sf
信号来时会调用onMessageReceived
函数来处理帧数据:
1 |
|
在handleMessageInvalidate
里一个比较重要的函数是handlePageFlip
():
1 |
|
这里可以看出来,handlePageFlip
里一个重要的工作是检查所有的Layer
是否有新Buffer
提交,如果有则调用其latchBuffer
来处理:
1 |
|
这里调用到了BufferLayerConsumer
的基类ConsumerBase
里:
1 |
|
到这里onMessageInvalidate
中的主要工作结束,在这个函数的处理中:SurfaceFlinger
主要是检查每个Layer
是否有新提交的Buffer
, 如果有则调用latchBuffer
将每个BufferQueue
中的Slot
通过acquireBuffer
拿走。此过程从Systrace
上看如下图有所示:
之后acquireBuffer
拿走的Buffer
(Slot
对应的状态是ACQUIRED
状态)会被交由HWC``Service
处理,这部分是在onMessageRefresh
中处理的:
1 |
|
以上过程从systrace
上看如下图所示:
最后总结一下应用调用queueBuffer
将一帧Buffer
数据提到SurfaceFlinger
后SurfaceFlinger
的主要处理流程,:
- 首先
Binder
线程会通过BufferQueue
机制把应用上帧的Slot
状态改为QUEUED
, 然后把这个Slot
放入mQueue
队列, 然后通过onFrameAvailable
回调通知到BufferQueueLayer
, 在处理函数里会请求下一次的Vsync-sf
信号; - 在
Vsync-sf
信号到来后,SurfaceFlinger
主线程要执行两次onMessageReceived
, 第一次要检查所有的Layer
看是否有上帧, 如果有Layer
上帧就调用它的latchBuffer
把它的Buffer
acquireBuffer
取走;并发送一个消息到主消息队列,让主线程再次走进onMessageReceived
,; - 第二次走进来时,主要执行
present
方法,在这些方法里会和HWC service
沟通,调用它的跨进程接口通知它去做图层的合成后送显示器显示。
后续HWC service
的合成以及屏幕的详细显示原理由于篇幅有限就不展开说明,感兴趣的读者可以参考系列文章。
7 流程总结与卡顿定义
7.1 应用绘制上帧流程总结
在本节中我们以用户手指上下滑动应用界面的操作场景为例,结合系统源码和Systrace
工具,按照执行顺序分析了Android
应用绘制上帧显示的系统运行机制与总体流程,我们以一张图描述如下:
最后总结整个流程大致如下:
- 用户手指触摸屏幕后,屏幕驱动产生
Input
触控事件;框架system_server
进程中的EventHub
通过epoll
机制监听到驱动产生的Input
触控事件上报,由InputReader
读取到Input
事件后,唤醒InputDispatcher
找到当前触控焦点应用窗口,并通过事先建立的socket
通道发送Input
事件到对应的应用进程; - 应用进程收到
Input
触控事件后UI
线程被唤醒进行事件的分发,相关View
控件中根据多个ACTION_MOVE
类型的Input
事件判断为用户手指滑动行为后,通过Choreographer
向系统注册申请app
类型的Vsync
信号,并等待Vsync
信号到来后触发绘制操作; app
类型的Vsync
信号到来后,唤醒应用UI
线程并向其消息队列中放入一个待执行的绘制任务,在UI
线程中先后遍历执行View
控件树的测量、布局和绘制(硬件加速默认开启的状态下会遍历并记录每个View
的draw
操作生成对应的绘制命令树)操作;View
控件树的绘制任务执行完成后会唤醒应用的RenderThread
渲染线程执行界面渲染任务;整个渲染任务中会先同步UI
线程中构建好的绘制命令树,然后通过dequeueBuffer
申请一张处于free
状态的可用Buffer
,然后调用SkiaOpenGLPipeline
渲染管道中使用GPU
进行渲染操作,渲染完成后swapBuffer
触发queueBuffer
动作进行上帧;- 应用渲染线程最后的
queueBuffer
上帧动作,会唤醒对端SurfaceFlinger
进程中的Binder
处理线程,其中将对应用BufferQuque
中的Buffer
标记为Queued
状态,然后注册申请sf
类型的Vsync
信号; - 待
sf
类型的Vsync
信号到来后会唤醒SurfaceFlinger
的主线程执行一帧的合成任务,其中会先通过handlePageFlip
操作遍历所有的应用Layer
找到有上帧操作的处于Queued
状态的Buffer
进行AcquireBuffer
获取标记锁定,然后执行persent
动作调用唤醒HWC service
进程的工作线程执行具体的图层的合成送显操作; HWC service
中最终会收到SurfaceFlinger
的请求后,进行图层合成操作,最终通过调用libDrm
库相关接口Commit
提交Buffer
数据到Kernel内核中的屏幕驱动,并最终送到屏幕硬件上显示。
7.2 卡顿的定义
根据本节中我们对Android
应用上帧显示的原理分析,我们初步可以判断:如果在一个Vsync
周期内(60HZ
的屏幕上就是16.6ms
),按照整个上帧显示的执行的顺序来看,应用UI
线程的绘制、RenderThread
线程的渲染、SurfaceFlinger/HWC
的图层合成以及最终屏幕上的显示这些动作没有全部都执行完成的话,屏幕上就会显示上一帧画面的内容,也就是掉帧,而人的肉眼就可能会感觉到画面卡顿(由于 Triple Buffer
的存在,这里也有可能不掉帧)。
这里借用高爷的一段经典描述从三个方面定义卡顿:
- 从现象上来说,在
App
连续的动画播放或者手指滑动列表时(关键是连续),如果连续2
帧或者2
帧以上,应用的画面都没有变化,那么我们认为这里发生了卡顿; - 从
SurfaceFlinger
的角度来说,在App
连续的动画播放或者手指滑动列表时(关键是连续),如果有一个Vsync
到来的时候 ,App
没有可以用来合成的Buffer
,那么这个Vsync
周期SurfaceFlinger
就不会走合成的逻辑(或者是去合成其他的Layer
),那么这一帧就会显示App
的上一帧的画面,我们认为这里发生了卡顿; - 从
App
的角度来看,如果渲染线程在一个Vsync
周期内没有queueBuffer
到SurfaceFlinger
中App
对应的BufferQueue
中,那么我们认为这里发生了卡顿。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!