背景
随着智能手机的普及,现代生活中,我们渐渐摆脱不了对手机的依赖。出行、购物、医疗、住房、社交等社会各个层面的需求都离不开借助智能手机去实现更高效、便捷的目的。短短十几年,依托于智能手机的发展也出现了层出不穷的互联网公司、手机品牌厂商以及数不清的应用程序,而随着相关互联网产业市场渐渐达到人口瓶颈,基于存量市场的争夺也迎来了白热化的阶段。产业内卷的一角,是各大手机品牌的竞争,除了以“换壳为主”作为主要的出新策略,各大厂商也纷纷绞尽脑汁推陈出新,诸如折叠屏、高刷新、快充、亿级相机像素等软硬件迭代也屡见于各种新机发布会上。据不完全统计,仅 2021年,Android 阵营的品牌厂商在全球范围内就发布了 502 款机型设备(数据来源:gsmarena)。先不讨论内卷的背后是对现实的焦虑,国产手机品牌对于新技术、新硬件的探索以及略显激进的将其应用,个人认为已经很“上进”了(此处不得不 cue 一下现在连壳都懒得换的 Apple【手动狗头.jpg】)。
回归本文的主题,作为一名 Android 开发者,除了对各类五花八门碎片化严重的机型和系统进行适配以外,还有一块重点工作是让自己开发的应用程序能用上品牌厂商们主打的新特性来增强应用的用户体验。像对折叠屏的适配,利用多屏小窗口的特性来增加用户多任务的互动感。
(网络图,侵删)
对相机超高像素的适配来丰富用户拍照的体验以及针对支持高刷新率的手机屏幕,对应用和游戏进行适配,增加用户的流畅体验。本文从这些新特性中选取了一点,来重点讲述基于 Android 系统的刷新机制,如何来适配目前市面上的一些高刷新率手机,以获得更好的用户体验。
(网络图,侵删)
众所周知,现在市面上大部分主流的机型的屏幕刷新率还停留在 60Hz,即屏幕以 1000ms/60 约为 16.6ms 的速度刷新一次。而现在一些高配手机已经可以达到 90Hz 甚至是 120Hz,从上个动图也可以看出,不同的刷新率,频率越高,给用户的感官体验也更加顺滑。这里穿插一个小知识,大家知道电影也是由一帧帧连续画面制作而成,那么电影的刷新帧率是多少?24fps!也就是说,只要用每秒 24 个画面的速度去播放连续单个画面片段,大脑就会自动联想成是一个连续的画面。那么为什么李安导演还要尝试拍摄 120fps 的《比利·林恩的中场战事》(《Billy Lynn's Long Halftime Walk》)?从受众角度出发,120fps 电影带来的感受远比 24fps 来的震撼和深入人心,特别对于一些宏大的战争场面或者叙事布景,24fps 的高速画面在过渡的时候会出现模糊(动态模糊,motion blur)现象,但 120fps 能让这些模糊过渡以更清晰的画面呈现在观众面前,观众能更有代入感。对于一些支持高刷新的游戏也是同理。
屏幕刷新机制
在 Android 系统中,针对屏幕的 UI 渲染刷新流水线(rendering pipeline)可以大致分为 5 个阶段:
- 阶段1:应用的 UI 线程处理输入事件、调起属于应用的相关回调以及更新记录了相关绘画指令的 View 层次结构列表;
- 阶段2:应用的渲染线程(RenderThread)将处理后指令发送给 GPU;
- 阶段3:GPU 绘制该帧数据;
- 阶段4:SurfaceFlinger 是负责在屏幕上显示不同应用窗口的系统服务,它会组合出屏幕应该最终显示出的内容,并将帧数据提交给屏幕的硬件抽象层 (HAL);
- 阶段5:屏幕显示该帧内容。
Android 采用的是双缓冲策略,通过垂直同步信号 vsync 来保证前后缓存的最佳交换时机。前面有提到两个概念,一个是帧率,一个是刷新频率。一种比较理想的状态是,帧率和刷新频率保持一致,GPU 刚处理完一帧,刷到屏幕上,下一帧就准备好了,这时候前后帧数据的连续完整的。但数据从 CPU 传递给 GPU 过程不可控,当屏幕刷新时,如果取到的帧 buffer 并非是完整 ready 的状态,就会出现很早之前非智能手机或者老旧电视机常出现的屏幕画面撕裂的问题(比如屏幕中上一半是之前的画面,下一半是新的画面)。双缓冲的策略解决的就是这个问题,通过维护两个缓冲区,让前缓冲区负责将帧数据运送到屏幕上的同时,在后缓冲区准备下一帧的渲染对象,通过 vsync 信号来开关是否将后缓冲区数据交换到前缓冲区。
我们再来看一下 Android 的源码实现
(Choreographer$FrameDisplayEventReceiver
):
private final class FrameDisplayEventReceiver extends DisplayEventReceiver implements Runnable {
private boolean mHavePendingVsync;
private long mTimestampNanos;
private int mFrame;
public FrameDisplayEventReceiver(Looper looper, int vsyncSource) {
super(looper, vsyncSource);
}
@Override
public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {
// 将 vsync 事件通过 handler 发送
long now = System.nanoTime();
if (timestampNanos > now) {
Log.w(TAG, "Frame time is " + ((timestampNanos - now) * 0.000001f)
+ " ms in the future! Check that graphics HAL is generating vsync "
+ "timestamps using the correct timebase.");
timestampNanos = now;
}
// 判断是否还有挂起的信号未被处理
if (mHavePendingVsync) {
Log.w(TAG, "Already have a pending vsync event. There should only be "
+ "one at a time.");
} else {
mHavePendingVsync = true;
}
mTimestampNanos = timestampNanos;
// 替换新的帧数据
mFrame = frame;
Message msg = Message.obtain(mHandler, this);
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
}
@Override
public void run() {
mHavePendingVsync = false;
// 帧数据处理
doFrame(mTimestampNanos, mFrame);
}
}
我们再从应用的角度通过 systrace 工具再直观的感受一下 Android 的刷新机制:
上图中,每一个垂直灰度区为一次 vsync 信号的同步,每个灰度区为 16.6ms。换句话说,只要 UI thread 以及 RenderThread 的一系列方法引用和执行在一个灰度区内执行完成了,那么这一帧的数据渲染就可以在 16.6ms 内完成。我们再看下图将这些信号放大后的一段异常渲染逻辑,红框中的渲染明显已经超过了一个 vsync 信号的边界,也就是说这帧数据的渲染耗时过长,在一个信号周期内没有执行完成,那么这一帧的执行反映到代码执行就是有问题的。Android 一般会通过分析相关函数的堆栈来定位问题出现的地方,如红框中的问题是由 RPDetectCoreView
这个自定义视图引起。如果应用运行一段时间内,这种情况频繁出现,造成的用户观感就是应用的卡顿和掉帧现象。
高刷的适配
了解了 Android 的基本刷新机制,我们再看一下如何来适配目前带了高刷属性的机型。应用或者游戏可以通过 Android 官方的 SDK/NDK API 来影响屏幕的刷新率,为什么说是“影响”而不是“决定”?前面也提到了 CPU/GPU 的写入速度是不可控的,所以这些方法也只能是去影响屏幕的帧率。
使用 Android 自带的 SDK
适配高刷的时候,我们会像一般注册设备传感器那样,先知道设备本身支持哪些传感器,再进行具体的采集动作。同样,对于屏幕刷新率,我们也得先知道屏幕支持的刷新率以及当前的刷新率,再去可控地调节成应用想要的刷新率。<br /
那么获取刷新率的方法有如下两种,两种都是通过注册监听器实现:
DisplayManager.DisplayListener
通过 Display.getRefreshRate() 查询刷新率
// 注册屏幕变化监听器
public void registerDisplayListener(){
DisplayManager displayManager = (DisplayManager) mContext.getSystemService(Context.DISPLAY_SERVICE);
displayManager.registerDisplayListener(new DisplayManager.DisplayListener() {
@Override
public void onDisplayAdded(int displayId) {
}
@Override
public void onDisplayRemoved(int displayId) {
}
@Override
public void onDisplayChanged(int displayId) {
}
}, dealingHandler);
}
// 获取当前刷新率
public double getRefreshRate() {
return ((WindowManager) mContext
.getSystemService(Context.WINDOW_SERVICE))
.getDefaultDisplay()
.getRefreshRate();
}
- 还可以通过 NDK 的
AChoreographer_registerRefreshRateCallback
API
void AChoreographer_registerRefreshRateCallback(
AChoreographer *choreographer,
AChoreographer_refreshRateCallback,
void *data
)
void AChoreographer_unregisterRefreshRateCallback(
AChoreographer *choreographer,
AChoreographer_refreshRateCallback,
void *data
)
在获取了屏幕可用刷新率之后,就可以尝试根据业务需求去设置刷新率,方法都很简单,这里不再做 sample code说明:
使用 SDK 的
setFrameRate()
方法- Surface.setFrameRate
- SurfaceControl.Transaction.setFrameRate
使用 NDK 的
_setFrameRate
函数- ANativeWindow_setFrameRate
ASurfaceTransaction_setFrameRate
FramePacingLibrary(FPL,帧同步库)
这里再介绍一下 FramePacingLibrary (别名 Swappy)。Swappy 可以帮助基于 OpenGL 以及 Vulkan 渲染 API 的游戏进行流畅的渲染和帧同步。这里又有一个帧同步的概念需要说明一下。帧同步,上文我们提到,Android 的整个渲染管道是 CPU 到 GPU 再到 HAL 屏幕显示硬件,这里的帧同步就是指 CPU 的逻辑运算和 GPU 的渲染与操作系统的显示子系统和底层显示硬件之间的同步。
为什么 Swappy 适用于游戏,因为游戏包含大量的 CPU 计算以及渲染工作,这些计算策略通过从 0 到 1 的开发显然是成本很高的一个工作,所以 Swappy 像市面上大多数的游戏引擎那样(Unity、Unreal 等等)提供了现成的策略机制来帮助游戏更好更容易的进行开发。
它可以做到:
- 给每帧渲染添加呈现的时间戳,按时来显示帧画面,补偿由于游戏帧较短而出现的卡顿现象
- 将锁机制注入到程序中,让显示的流水线能跟上进度,不会积累过多导致长帧的卡顿和延迟(同步栅栏)
- 提供了帧统计信息来调试和剖析程序
通过一些图片来简单描述一下它的原理:下图是一个理想的在 60Hz 设备上运行 30Hz 的帧同步,这里每一帧(A\B\C\D)都“恰如其分”地正常渲染到了屏幕上。但现实中并不都是这种情况,对与一些短的游戏帧,如下图中的 C 帧,由于耗时更短,导致 B 帧还没有完全显示应有的帧数就被 C 帧抢先,同时 B 帧的 NB 信号又触发了 C 帧的再次显示,就像跑步的人被路上的小石子绊倒,以一个固定的姿势摔出几米一样,后续都会显示 C 帧,导致卡顿。
而 Swappy 库通过增加呈现时间戳解决了这个问题,就像给每一帧数据设置了一个闹钟,闹钟不响就不允许显示在屏幕上:
总结
虽然适配高刷的特性不需要做太多的代码适配,但是还是必须要仔细考虑下面几方面问题:
- 在通过
setFrameRate()
方法设置屏幕刷新率时,还是会存在设置无法生效的情况,像有更高优先级的 Surface 有不同的帧率设置,或者设备处于省电模式等,因此我们在开发程序时也必须考虑到如果设置失效的情况下,程序也能正常运行才可以; - 避免频繁调用
setFrameRate()
方法,在每帧过渡的时候,如果频繁调用会引起掉帧问题,我们需要提前通过 API 获取应有的信息来一次性调整到正确的帧率; - 不要固定写死特定的帧率,而应该根据实际的业务场景来调整帧率的设置策略,目标是无缝地在高低屏幕刷新率之间过渡。