图像显示框架这个系列,写到第八篇才敢真正开始聊 BufferQueue。原因说起来有点惭愧:框架里几乎所有关键路径都被它串着,但想把它讲清楚,得先有前面几篇关于 vsync、渲染管线和 SurfaceFlinger 事务模型的认识。这篇我直接基于 Android 15 的 AOSP 源码,把 BufferQueue 和 BLASTBufferQueue 从头到尾拆一遍——类结构、状态机、一帧数据从 App 走到显示器的完整路径,以及 BLAST 到底改了什么、为什么现在的 Surface 默认都走 BLASTBufferQueue。适合已经对 SurfaceFlinger 有基本了解、想从源码层面理解帧数据流动的系统开发者;刚入门的同学建议先把前面几篇过一遍再回来。
1. 先建立全局认识:一帧图像在显示链路里流动的“三条通道”
很多文章上来就贴 BufferQueue 代码,但忽略了最根本的问题:这里要解决的到底是什么。我在看代码之前先想清楚了一件事——图形系统里,一帧画面从 App 手里到屏幕,不只是一块内存从 A 移到 B。
它实际上是三个独立信息的流动。第一个是 buffer 内存本身:这块由 gralloc/ION 分配的内存,通常用GraphicBuffer封装,里面是真正的像素数据。第二个是帧的描述信息:宽高、格式、裁剪区域 crop、旋转 transform、dataspace、背景色、圆角半径等等,这些决定了合成器该以什么姿态来对待这份像素。第三个是时序信息:帧号 frameNumber、时间戳 timestamp、FrameTimelineInfo、以及一帧生命周期里必不可少的一组 fence。
BufferQueue 这个名字给人的第一印象是“存放 buffer 的队列”,但它真正干的事,远比“队列”复杂:它是一套管理 buffer 使用权和生命周期状态机的分配器。GraphicBuffer 在生产者消费者之间共享,同一时刻只允许一方持有使用权,而 BufferQueue 负责裁决这把“使用权”。它既不搬运像素,也不负责合成,它只回答一个问题——当前这块 buffer,到底是谁能用。
这是图形栈里非常典型的解耦思路:生产者可能以 120Hz 渲染,消费者 SurfaceFlinger 则跟着 vsync 按 60Hz/90Hz 合成,两者节奏完全不同。如果没有一个中间层做缓冲,生产者一旦快于消费者,就只能丢帧;消费者一旦暂时落后,又只能干等。BufferQueue 用多个缓冲槽把这两个节奏隔开,让生产者和消费者各自按自己的时钟工作,谁也不用永远等着谁。
传统模型里,BufferQueue 管理的是第一通道(buffer 内存),而第二条和第三条通道是零散地跟着 queueBuffer 参数、独立的 SF Transaction 走的。这个割裂正是后面 BLASTBufferQueue 要解决的问题。所以在代码层面,我习惯把阅读入口分成三条线:libs/gui/BufferQueue*负责第一通道,libs/gui/Transaction*和SurfaceControl负责第二通道,FrameTimeline与 fence 负责第三通道。BLASTBufferQueue 恰好位于这三条线的交汇处,不理解这一点,后面看代码会越看越乱。
2. BufferQueue 源码实体:Core、Producer、Consumer 三件套与 64 个槽
2.1 三件套的分工
AOSP 里创建一对 BufferQueue 的入口是BufferQueue::createBufferQueue,调用方会拿到两个 binder 接口对象:sp<IGraphicBufferProducer>和sp<IGraphicBufferConsumer>。这套接口经过 AIDL 化之后,已经可以跨进程传递,但实现它们的对象仍然牢牢绑定在一起,靠同一个BufferQueueCore共享状态。
BufferQueueCore是整台机器的“发动机”:它保存了共享锁、mSlots槽位数组、mQueue待消费队列、mFrameCounter帧计数、mMaxBufferCount配额等一系列核心状态。BufferQueueProducer和BufferQueueConsumer则是两个方向的“遥控器”,分别实现生产端和消费端的 binder 服务。看到这里你会发现,BufferQueue 表面上是一对接口,实质上是一套三件套——一头生产、一头消费、中间一个共享账本。改任何一端的代码,都可能牵动另外两端。
值得一提的小细节:虽然接口叫 IGraphicBufferProducer / IGraphicBufferConsumer,Android 15 里这两个接口已经是完整 AIDL 定义了,生产者和消费者即使在不同进程也能通信。跨进程时,数据以BufferItem等方式在前端传递,但同一个 BufferQueue 内部的 Core 永远是同一进程里的实体,不会跨进程。
2.2 缓冲槽与状态机的真实面目
BufferQueue 最多有NUM_BUFFER_SLOTS个槽位,值是 64。这个数字不是随手拍的,是为了防止极端场景下生产者消费者因为错位导致内存无限膨胀——真正活跃的槽位通常只有两三个。每个槽是BufferSlot结构体,里面最重要的字段是mBufferState、mGraphicBuffer、mFence、mFrameNumber。
状态机是理解这套机制的第一把钥匙。Android 15 里 BufferSlot 状态本质是 uint32,枚举里有 FREE、DEQUEUED、QUEUED、ACQUIRED、ALLOCATED、PREALLOCATED 六种。老文章里经常只讲三态,新版本多出的 ALLOCATED 和 PREALLOCATED 是为了减少分配等待:生产者还没 dequeue 的时候,系统可能已经提前分配好 GraphicBuffer,状态标记为 ALLOCATED;一旦 dequeue 命中就能直接复用。状态迁移也不复杂,正规路径是下面这张表:
| 当前状态 | 操作 | 下一个状态 | 操作方 |
|---|---|---|---|
| FREE / ALLOCATED | dequeueBuffer | DEQUEUED | 生产者 |
| DEQUEUED | queueBuffer | QUEUED | 生产者 |
| QUEUED | acquireBuffer | ACQUIRED | 消费者 |
| ACQUIRED | releaseBuffer | FREE | 消费者 |
官方代码注释里还提到 PREALLOCATED:当生产者在 requestBuffer 阶段先把 buffer 分配好,放进槽位供随后 dequeue 使用,这是 BLAST 等现代路径下常见的优化。状态机保证了非常重要的约束:生产者不能同时持有过多 DEQUEUED 槽(除非显式允许),消费者不能把同一块 buffer 同时给两个图层,槽位的每一步流转都必须有明确的所有者。
2.3 配额参数:maxBufferCount、maxAcquiredBufferCount 和阻塞策略
在BufferQueueCore里,mMaxBufferCount是动态可调的配额,它决定了系统同时能容忍多少个“在路上”的 buffer。mMaxAcquiredBufferCount则限制消费者最多同时持有多少个 ACQUIRED 的槽。这两个配额和mDequeueBufferCannotBlock一起,构成了 BufferQueue 的背压机制。
mDequeueBufferCannotBlock这个标志很有意思。它表示生产者在无槽可用时是否允许阻塞。默认情况下,dequeue 会等消费者 release 释放槽位;但如果设为 true,没槽就直接返回错误码,由上层决定是丢帧还是改走其他路径。现代 BLAST 场景下,生产者往往不希望因为 dequeue 卡死整个渲染线程,所以这个标志的使用频率比老版本高得多。代码里有多少个分支、唤醒条件变量写在哪个函数,建议有空对着源码一行行看,今天先记住结论:dequeue 可能阻塞、也可能不阻塞,全看配额与标志位。
3. 生产与消费的源码级全链路:dequeue、queue、acquire、release 之间发生了什么
3.1 生产者视角:dequeueBuffer 不是“取个数组下标”那么简单
BufferQueueProducer::dequeueBuffer的函数签名里带着一大堆参数:宽、高、格式、usage、async、streamId……调用方是Surface::dequeueBuffer,更上层是ANativeWindow_dequeueBuffer,再往上才是eglSwapBuffers、unlockCanvasAndPost这些日常渲染 API。
进入函数后,第一步是检查 BufferQueue 是否已经被 abandon;然后进入BufferQueueCore::dequeueBuffer,它会在 64 个槽里寻找状态为 FREE 或 ALLOCATED、且宽高格式 usage 都满足请求的槽。这里有几个判定逻辑非常细:比如请求的 width/height 为 0 时表示“沿用 buffer 大小”,格式为 0 时表示“沿用已有格式”;再比如 usage 的位运算是按位比较,生产者请求增加的 usage 位必须全部命中。
如果找不到匹配的槽,有两种情况:要么等消费者释放,要么直接返回。等待是通过条件变量挂起生产者线程,等消费者releaseBuffer后唤醒;不等待则直接返回错误。找到之后,如果槽里还没有 GraphicBuffer,或者尺寸格式不匹配,就需要通过mCore->mAllocator重新分配一块内存。这个分配动作在低内存设备上可能肉眼可见地卡顿几十毫秒,所以ALLOCATED预分配槽的意义就在于此。
dequeue 完成时,生产者拿到的还有一个 fence。这个 fence 表示“前一个使用者(消费者)最后一次用完这块 buffer 的时刻”,生产者必须等它结束后才能往 buffer 里写像素。忽略这一步,就会在设备上看到画面撕裂甚至花屏。第一次用 dumpsys 看到 64 个槽位时往往会困惑:明明槽有这么多,为什么 dequeue 还会阻塞?因为状态不是 FREE 的槽不算可用,QUEUED 的在等待消费、ACQUIRED 的在合成器手里、DEQUEUED 的属于上一次渲染还没 release,真正能给你的 FREE 槽,往往只有两三个。
3.2 queueBuffer:帧不只是像素,还附带一个 BufferItem
生产者画完像素后调用queueBuffer。这个函数要做的事,是把这一帧的所有“身世信息”打包成一个BufferItem:生产者当前时间戳、宽高、crop、transform、scalingMode、surfaceDamage、dataSpace、acquireFence、frameNumber……然后把 BufferItem 推入 Core 的mQueue等待消费者取走。
BufferItem值得单独拿出来讲,因为它几乎定义了“一帧”的边界。在 Android 15 源码BufferItem.h里你能看到这个结构体被反复用在 queue、acquire、transaction 之间传递。它除了携带sp<GraphicBuffer>之外,还包含mTimestamp、mIsAutoTimestamp、mCrop、mTransform、mTransformToDisplayInverse、mScalingMode、mDataSpace、mFrameNumber、mSlot等成员。换句话说,它在 BufferQueue 时代就是“第二通道”的载体,只是一直没和 Transaction 真正合并到一条通路里。
queueBuffer 里还有一处关键操作:递增mFrameCounter并把新 frameNumber 写进 BufferItem。帧号是消费者判断新旧的关键凭据,也是 BLAST 里 Transaction 与 buffer 一一对应的序号。队列 push 完成后,状态改为 QUEUED,最后调用mCore->mConsumerListener->onFrameAvailable(item)通知消费者:有帧来了。监听器IConsumerListener是 SF 侧注册进来的,也可能是一段跨进程 proxy。这里注意一个细节:onFrameAvailable的触发是在调用线程上还是独立线程,取决于实现;主流场景是 BLAST 的回调在异步线程,后面会看到它如何影响时序。
3.3 消费者视角:acquire 取出、release 归还,以及 fence 的一生
消费者端入口是BufferQueueConsumer::acquireBuffer。它会从mQueue里取出一帧,返回BufferItem给调用方。如果队列为空,消费者会阻塞等待或返回状态;而一旦取出这一帧,对应槽位状态变为 ACQUIRED,表示这块 buffer 被消费者占用,谁都不能动。
接下来消费者要做的第一件事是等待 acquireFence。这个 fence 由生产者在 queue 时带入,信号触发时机是“生产者写入完成”。SurfaceFlinger 拿到帧后不会立刻读取,它要等这块 buffer 真正写完,才开始合成。合成完成后,消费者(或合成器)会写入 releaseFence 并调用releaseBuffer,通知 BufferQueue:这块 buffer 我不再需要了,你可以还回去。releaseBuffer 把 releaseFence 存进槽位的mFence,状态改回 FREE,并唤醒可能在 dequeue 上等着的生产者。
整个过程中有三类 fence 必须分清:
| Fence 名称 | 产生时机 | 等待者 | 含义 |
|---|---|---|---|
| acquireFence | 生产端 queue 时 | 消费者 | 生产者写入完成,可以读像素 |
| releaseFence | 消费端 release 时 | 下一任生产者 | 消费者/合成器读完,可以覆写 |
| presentFence | 合成器 present 时 | SurfaceFlinger | 帧真正送显完成 |
很多渲染时序问题,追到根上都是这几个 fence 没有正确传递。BLASTBufferQueue 的整个设计,也可以理解为“给每一帧的 acquire/release 和 Transaction 强行绑定在一起”。
3.4 传统模型下生产与事务的竞争:为什么要引入 BLAST
把上面三条链路串起来,你会看到传统模型里其实存在两条独立的通路:BufferQueue 负责 buffer + fence + timestamp,SurfaceFlinger 的 Transaction 负责图层属性。App 在 queueBuffer 的同时,可能还会发送一个 Transaction 去设置图层位置、裁剪、缩放。
这两条通路的到达顺序在 binder 调度下无法保证。可能 Transaction 先到 SF,帧后到;也可能帧先到,Transaction 后到。SF 在每个合成周期只是“取队列里最新的帧 + 取当前生效的图层属性”硬性拼在一起,那么就会出现我们常说的“事务和帧对不上账”:开发者明明先改了 transform 再画了新帧,结果屏幕上显示的却是旧帧叠在新属性上,或者新帧叠在旧属性上,表现为闪一下、错位、尺寸突变。这个问题在高刷新率设备上更加明显,因为帧间隔太短,任何时序偏差都会暴露。
同时,传统模型里 SF 判断“这一帧能不能被合成”要用时间戳做启发式猜测,比如“这帧太旧,跳过”。一旦遇到多窗口乱序提交、vsync 相位变化,启发式就会失灵或误判。于是 BLAST 应运而生。简单说,它把“帧的属性包”(crop、transform、dataspace、fence、frameNumber 等等)全部合并到一个 Transaction 里,和 buffer 同时提交、同时生效,从根本上消灭“说明书比货物后到”的老问题。
4. BLASTBufferQueue 的设计思路:把“帧”和“事务”焊死在同一条流水线上
4.1 老模型为什么不够用:三条通道脱节是万恶之源
上面已经把三条通道分开讲,这里再具体一点:老架构里,App 调用queueBuffer后,像素数据的生命周期由 BufferQueue 管;图层属性的生命周期由SurfaceControl.Transaction管;帧调度则由 FrameTimeline 与 vsync 调度器管。表面看各司其职,实际上是三个人各记各的账。
举一个非常典型的案例:你做自定义 SurfaceView,先调用setFixedSize改变显示尺寸,紧接着又开始渲染新一帧。setFixedSize 底层会发一个 Transaction,新一帧在下一秒才 queue 到 BufferQueue。SF 收到 Transaction 时,可能连 buffer 还没影子,它只能先把属性记下来,等到下一帧再来时套用。问题在于:如果新属性恰好在这帧的半途生效,合成器会怎么处理旧帧?假如你连续渲染了两帧,第一帧旧尺寸、第二帧新尺寸,而 Transaction 在两帧之间到达,那么第一帧可能被异常拉伸或者裁剪出界,屏幕闪一下。
BLAST 的核心设计哲学就一句话:**一个帧的所有属性,必须与 buffer 本身同步到达、同步生效。**Buffer Lifecycle And Streaming Transactions,名字里就写得很直白——把 buffer 的生命周期和事务绑定成同一种载体,用同一条通道传输,谁也别想超车。
4.2 BLAST 的演进:不是某个版本突然冒出来的怪物
BLAST 实际上经历了将近三年的演化。最初在 Android 11 上,SF 侧的 Consumer 开始用 BLAST 相关机制,SF 自己作为消费者时能拿到更完整的 buffer 状态信息;Android 12 把 BLAST 推进到客户端,Transaction的 buffer 载体开始支持携带帧数据和相关属性;Android 13 起,几乎所有SurfaceControl创建的 Surface 都默认使用 BLASTBufferQueue。到 Android 15,这套机制已经是绘制的唯一默认路径。
也就是说,你现在在 Android 15 设备上跑任何 App,不管它用的是 TextureView、SurfaceView 还是自定义 Surface,其底层IGraphicBufferProducer很可能就是BLASTBufferQueue。它名义上仍然遵守生产者消费者接口,但内部结构比传统 BufferQueue 复杂得多。
4.3 一个自然的疑问:BLAST 是不是抛弃了 BufferQueue
从我第一次读完 BLAST 代码时的感受来说:BLASTBufferQueue 并没有抛弃 BufferQueue,反而重度依赖 BufferQueue。它是套在 BufferQueue 外面的“外挂包装”。它自己仍然创建一对内部的 producer/consumer,用标准 BufferQueue 状态机管理缓冲槽,但消费端不再由 SurfaceFlinger 直接读取,而是由一个BLASTBufferItemConsumer在收到onFrameAvailable后,把 BufferItem 转成 Transaction 提交给 SF。
这个设计带来的直接变化是:传统模型里 SF 是唯一消费者,SF 要把 buffer 从 BufferQueue 的队列里“拉”出来;BLAST 模型里,客户端侧存在一个消费者,把 buffer “推”进 Transaction。前者是拉模式,后者是推模式,这是两者在架构层面最大的不同。其他诸如状态机、fence、slot 分配等基础机制,完全是复用 BufferQueue 的,没有另起炉灶。
5. Android 15 源码走读:一帧从 App 到屏幕的 BLAST 之旅
5.1 BLASTBufferQueue 的成员构成与角色
在 Android 15libs/gui/BLASTBufferQueue.cpp里,类声明的主要成员大致如下(我省略了部分琐碎字段):
class BLASTBufferQueue : public BnBufferQueue, public BnProducerListener, public BLASTBufferItemConsumer::FrameAvailableListener { sp<IGraphicBufferProducer> mProducer; sp<BLASTBufferItemConsumer> mConsumer; sp<SurfaceControl> mSurfaceControl; FrameInfo mFrameInfo; nsecs_t mNextTransactionId; bool mTransactionsApplied = false; // ... };BnBufferQueue让它能直接作为 IGraphicBufferProducer 对外服务;BnProducerListener让它能接收内部 BufferQueue 的 buffer 释放通知;FrameAvailableListener让它在内部消费者拿到新帧后收到回调。这三个继承,正好对应它同时扮演“生产者接口代理、缓冲区生命周期观察者、帧到达监听者”三重身份。
它对外的dequeueBuffer、queueBuffer会转发到内部的mProducer;真正被提交给 SF 的,不是 queueBuffer 返回的裸 buffer,而是onFrameAvailable之后被组建的 Transaction。这一步实际把“等待帧到达”和“发送帧属性”两件事合并到了同一个上下文里,你不再需要在代码两端手工做同步。
5.2 从 unlockCanvasAndPost 到 Transaction.apply 的完整链条
拿软件绘制场景举例。App 调用Surface.lockCanvas拿 Canvas 时,Java 底层创建一个Surface,其 native 对象对应的 producer 正是 BLASTBufferQueue。在 Canvas 上画完,调用Surface.unlockCanvasAndPost,会走到 native 的Surface::unlockAndPost,再调用queueBuffer。这里的 queueBuffer 是 BLASTBufferQueue 的版本,不是传统 producer 的 queueBuffer。
BLASTBufferQueue::queueBuffer 内部会在参数里填充相关的 frame info、transaction id 等,然后调用mProducer->queueBuffer,把帧真正推进内部 BufferQueue。紧接着,内部消费者BLASTBufferItemConsumer收到onFrameAvailable(item)回调。此刻 item 里已经带着完整的GraphicBuffer、acquire fence、crop、transform、frameNumber、timestamp 等字段。
onFrameAvailable里做的最重要的事,是构建一个Transaction对象,把 item 里的 buffer 和属性“搬”进去,再apply()。核心流程大致等价于:
Transaction t; t.addBuffer(mSurfaceControl->getHandle(), item.mGraphicBuffer, item.mCrop, item.mTransform, item.mTransformToDisplayInverse, item.mFence, item.mDataSpace, item.mCornerRadius, item.mAlpha, item.mFrameNumber, ...); t.setFrameTimelineInfo(item.mFrameTimelineInfo); status_t err = t.apply();注意这里 addBuffer 的第一个参数是 SurfaceControl 的句柄,Transaction 通过它去 SF 端定位目标图层。apply 之后,SF 的 binder 端会把这个 buffer 连同属性一起记录到目标 Layer 的 BufferData 里,作为下一个合成周期的候选帧。合成完成后,SF 写回 release fence,BLASTBufferQueue 通过内部 BufferQueue 的 release 回调把槽位归还给 App,App 才能继续 dequeue 下一帧。
这条链路的精髓,是靠 transaction id 和 frameNumber 把“App 生产帧”和“SF 消费帧”两端的生命周期串起来,从而在代码层面保证 buffer 的“使用权”不会二次发放——这就是为什么在测试机上你会看到 App 最多同时手里有两三个待画的 buffer,不会出现内存无限增长。
5.3 帧属性到底是从哪来的:BLAST 与 SurfaceControl 的合并逻辑
onFrameAvailable里拿到的 crop、transform 等属性,并不全来自 App 刚画的这帧。实际上,上层可能早就通过SurfaceControl.Transaction发送了“下一步将要生效”的属性集,这些 Transaction 会在 SF 端排队,但 BLAST 需要知道“哪一笔 Transaction 对应哪一帧”。
BLASTBufferQueue 的处理方式是:维护mFrameInfo和 transaction 队列,把未生效的 transaction 信息缓存在本地;当 buffer 到来时,把“最近一次还没 apply 的 Transaction 的属性”合并进这次 buffer 的 Transaction。这意味着,如果你在unlockCanvasAndPost之前先调用了setCrop/setTransform,只要没有提前 apply,BLAST 是能看到这些后备属性的;如果已经提前 apply 了,BLAST 也会在 SF 端按先后顺序合并。这个特性很好地解决了旧模型里“说明比货后到”的问题——所有内容都在同一笔事务中送达,不存在中间态。
这块逻辑在源码里非常琐碎,但核心思路就是事务与会话合并:一组 SurfaceControl 相关的属性变更,要落到哪个 layer、哪一帧,都要靠 transaction id 和 frameNumber 对账。代码里的getTransactionId、onTransactionCompleted、TransactionReady等回调,都是在为“对账”服务。
5.4 Android 15 里几个容易被忽略的 BLAST 细节
第一,setAutoRefresh。这个接口可以让 BLASTBufferQueue 在“没有新 buffer 产生”的情况下,持续用同一个 buffer 刷新图层。很多人第一次看到会觉得鸡肋,但它在系统动画、壁纸、始终显示等场景下非常实用:避免频繁分配 buffer,也避免图层显示在 vsync 之间被裁掉一半。
第二,update系列方法。当 Surface 尺寸或格式变化时,BLASTBufferQueue 会调用内部的 setDefaultBufferSize、setDefaultBufferFormat 等,这些变更本身也会被打包进随后的 Transaction。这让“尺寸变化”和“新帧”同样保证同步——这恰恰是旧模型最容易出 bug 的地方。
第三,帧时间戳的传递。Android 15 的 FrameTimelineInfo 已经跟随 BufferItem 一路走到 SF,并在addBuffer时挂进 Transaction。SF 的速度统计、App 端的帧统计(dumpsys gfxinfo)之所以能对得上,正是因为这条帧时间戳通道被打通。很多做性能优化的人一开始只查 CPU/GPU 时间,最后发现问题出在帧时间戳链路不完整,这一点我是踩过的。
6. 踩过的坑与调试建议:BLAST 场景下的常见翻车现场
6.1 黑屏与属性错乱:transaction 没等到 buffer 就 apply
我最初把一个老项目从传统模式迁移到 BLAST 时,遇到一个固定场景的黑屏:App 在启动后第一次 setFixedSize 改变尺寸,紧接着渲染第一帧,屏幕黑闪。一开始我以为是帧没画出来,后来dumpsys SurfaceFlinger看到 BufferQueue 里 slot 全在 QUEUED,才意识到是 transaction 先 apply、buffer 后到,SF 端在新的尺寸下收到旧的 buffer,合成器直接丢掉了。
修法也很简单:第一帧前不要提前 apply 和 buffer 强相关的属性,让它们跟着 BLAST 的 transaction 一起走,或者干脆在 dequeue 拿到 buffer 之后再 apply。这种现象在传统模型下极难排查,因为每次时序漂移都不一样;而 BLAST 模型下只要你保证“属性跟着帧走”,它天然是稳定的。
6.2 dequeue 卡死:release fence 没回来或回调线程被堵
另一个高频问题是 App 画到一半卡住,线程停在 dequeueBuffer 上,不是死锁,而是“槽位全被占住了”。拿到设备抓 trace,常见原因是 BLAST 的 FrameAvailable 回调线程太忙,或者内部 consumer 一直没收到 SF 返回的 release fence,导致 BufferQueue 的槽位迟迟不释放。
我的排查套路是:先看dumpsys SurfaceFlinger --layer,确认 buffer 槽位状态;再看 systrace 上 transaction 是否 apply 成功;最后查 BLASTBufferQueue 内部回调线程有没有被主线程抢跑。大多数时候问题出在“主线程频繁操作 SurfaceControl,把 binder 调用堵住”,把这类操作放到 BLAST 的提交线程能改善很多。
6.3 调试工具清单
开发 BLAST 相关问题时,下面的命令基本够用:
| 命令 | 观察点 |
|---|---|
dumpsys SurfaceFlinger --list | 当前图层列表 |
dumpsys SurfaceFlinger --latency | 每层 vsync 到 present 的耗时 |
dumpsys SurfaceFlinger --layer | 单个图层缓冲槽状态 |
dumpsys gfxinfo <pkg> | App 侧帧统计 |
systrace 抓WaitForBuffer/ fence 超时 | 确认是等待还是合成慢 |
排查的第一步一定是看 buffer 槽位属于哪个状态,而不是猜代码。状态机写得很清楚:QUEUED 多说明消费者没取,ACQUIRED 多说明合成器没释放,DEQUEUED 多说明生产者自己卡住了。把状态对应到代码路径,往往比反复加日志更快。
最后再分享一个朴素的体会:读 BLAST 源码这么久,最大的收获不是背住了某个函数,而是明白了“时序问题不能靠猜,要靠 fence 和帧号去对账”。传统渲染代码里常见的 usleep、nanosleep 缓解闪烁的做法,在 BLAST 下基本是自欺欺人。与其去调一个不稳定的“等一段时间”,不如把每一帧的生命周期和 fence 都理顺,让硬件告诉你什么时候真的写完、真的读完。这套框架最值得学习的地方,正在于此。