最近在基于 Android 模拟器相关的工作中,解决了一个录屏时屏幕闪烁的问题,在这里做一个记录。
现象
项目中用到了 Google Android Emulator 的开源代码,在 Windows、Linux 和 macOS 上使用 qemu 运行一个定制版的 Android 镜像。模拟器自带一个录屏的功能,可以录制屏幕上内容,保存成视频文件。
但在 macOS 平台上录制的视频上总有闪烁的现象——即有的视频帧是完全的黑屏。而且这个问题在 Windows & Linux 上不会复现。
原理
Android Emulator 的屏幕显示,分为 compose 和 post 两个阶段,即合成和上屏。前者主要在一块 off-screen 的 framebuffer 纹理上,合并 layer,绘制屏幕内容;后者主要在绘制完成后,将 off-screen framebuffer 中的内容,真正显示到屏幕上。
而且在代码实现中,在 compose 阶段的一开始,会调用一次 glClear()
清理上一帧的内容,然后再开始绘制和上屏,即 glClear()->compose->post 的顺序。
而录屏的时机,发生在 post 调用发生后——因为这个时候,屏幕已经绘制完成了。
录屏线程会调用 glReadPixels()
,读取绘制后 off-screen framebuffer 中的内容,并下发到后端的 encode 接口(例如 ffmpeg),进行视频压缩。
根因
为什么闪屏?
根因在于,macOS 上 qemu 读取屏幕的逻辑与 compose & post 的逻辑是异步的。
在 macOS 上,在 post 调用下发后,qemu 会异步地在另一个线程上读取屏幕纹理,由于它和屏幕的绘制线程(compose/post)相互独立,如果读取时机不恰当,就会出问题。
如果读取纹理的时机刚好发生在 glClear()
后,compose 实际合成之前,即 glClear()->glReadPixels()->compose->post 的顺序。那么这一笔 glReadPixels()
就会读到屏幕清理后的内容,即黑屏。
为什么 Windows & Linux 平台不会闪屏?
因为 macOS 平台与其他平台的绘制、上屏逻辑有区别。
macOS 上,compose 、post 操作,都必须在 UI thread 内完成(m_mainThreadPostingOnly)。
而 Windows & Linux 下都没有这个要求,而且会等待 post 命令执行返回:
if (!postOnlyOnMainThread) {
m_postThread.waitQueuedItems();
所以 Windows 与 Linux 上,读屏的逻辑始终与屏幕绘制是同步的,不存在 race condition。
怎么修复?
那么该怎么修复呢?
第一个想法是加锁。加一把互斥锁后,保证 glClear()->compose->post 的过程中不会有 glReadPixels()
操作发生,这样也就不会读到黑屏的画面。经过实验,虽然这样修复行之有效,但由于加了把大锁,很难保证不会引入死锁的情况,风险有点大。
第二个想法是参照 Windows & Linux 上的逻辑,也在 post 指令下发后等待执行结束,以实现同步。经过实验,虽然也能避免读到黑屏,但却在结束录屏时,有概率引起死锁。
经过排查,原因是,由于在点击按钮结束录屏时,结束逻辑也跑在 UI thread 上,但这时另一个线程一边拿着一个锁,一边在等待 post 指令在 UI thread 上结束——但这时 UI thread 已经被结束线程占用了,所以引起了死锁:
- ui thread – 在执行 ~ScreenRecorder(),在等 stop thread 结束
- stop thread – 在执行 FrameBuffer::setPostCallback, 等待 frame buffer lock
- frame buffer compose – 拿着 frame buffer lock,在等待 post worker 结束
- post worker – 在等待 composev2Impl 代码在 ui thread 上执行结束
- composev2Impl 代码 – 没有办法执行,进不了 ui thread
可见试图把 UI thread 的绘制逻辑与读屏操作进行同步,很容易引入各种无法预料的死锁。
所以最后采用了一个最直观而稳妥的修复方法:直接把读屏操作也放在 UI thread 中,保证以 glClear()->compose->post->glReadPixels() 的顺序执行。
发表回复