WMS 与渲染管线:从窗口到像素的完整旅程
一个 Activity 从启动到用户看到第一帧画面,究竟经历了什么?像素是如何从 Java 层的 onDraw() 一路传递到屏幕驱动的?本文将沿着这条链路,从 WindowManagerService(WMS)的窗口管理,到 VSync 绘制信号,再到 SurfaceFlinger 的最终合成,完整剖析 Android 图形系统的底层心脏。
WMS 的定位:系统的"窗口警察"
在 Android 中,屏幕上每一块可见区域都归属于某个窗口(Window)。Activity 的界面是一个窗口,弹出的 Dialog 是一个窗口,系统状态栏也是一个窗口。谁来管理这些窗口的位置、大小、层级、可见性?答案是 WindowManagerService(WMS)。
WMS 运行在 system_server 进程中,是 Android Framework 的核心服务之一。它不负责绘制内容(绘制由 App 自己完成),而是负责管理内容显示在哪里。把它想象成一个城市的建筑规划局:它规定每栋建筑(窗口)能建在哪、能有多高(Z-Order)、占地多大,但不关心建筑内部的装修。
WMS 的核心职责可以分为四类:
| 职责 | 说明 | 关键类/接口 |
|---|---|---|
| 窗口生命周期 | 处理 addView、removeView,管理窗口的显示与销毁 |
WindowState, Session |
| 窗口布局 | 计算每个窗口在屏幕上的位置和大小 | RootWindowContainer.performLayout() |
| Z-Order 管理 | 决定窗口的遮挡层级,状态栏 > 弹窗 > Activity | WindowState.mLayer |
| 动画调度 | 驱动窗口转场动画(进入/退出/切换) | WindowAnimator |
Activity 与 Window:一台戏,几块幕布?
在深入 WMS 内部之前,有一个问题必须先想清楚:一个 App 运行起来,WMS 里到底会出现几个窗口?
比喻:舞台与幕布
把整块屏幕想象成一个剧场的舞台。WMS 是舞台监督,负责决定每块幕布(Window)挂在哪里、挂多高、是否透明。不同的演员(App、系统服务)可以各自挂幕布,舞台监督统一协调,保证观众(用户)看到的是一幅完整的画面。
一个 App 可以同时挂多块幕布。Activity、Dialog、PopupWindow、输入法……每一种都可能是独立的幕布。
一个 Activity = 一个 Window
最常见的情形:启动一个 Activity,WMS 里就新增一个 WindowState。Activity 的整个界面(包括 DecorView 下的所有 View)都画在这一块 Window 上,而不是每个 View 各占一块。
Activity A 启动
│
▼
WMS 新增一个 WindowState(W_A)
│
└── 对应一块 Surface → SurfaceFlinger 中新增一个 Layer(L_A)
Activity A 的所有 View(TextView、Button、RecyclerView…)
└── 全部绘制在 L_A 这同一块 Surface 上
View 不是 Window。Layout 里有 100 个 View,WMS 里仍然只有 1 个窗口;View 树的 measure/layout/draw 全都在这一块 Surface 上完成。
启动新 Activity:新窗口还是替换?
每次 startActivity() 启动一个新 Activity,WMS 里就新增一个 Window,原来那个 Activity 的 Window 并不消失,而是被压入后台、挡在新窗口下面:
用户操作 WMS 窗口栈(Z-Order 从高到低)
───────────────── ─────────────────────────────────
打开 App → Activity A [W_A]
点击进入 Activity B [W_B] ← 新窗口,盖在 W_A 上
点击进入 Activity C [W_C]
[W_B]
[W_A] ← W_A 的 Surface 仍然存在,只是不可见
此时屏幕上只有 W_C 可见,但 W_A、W_B 的 WindowState 和对应的 Surface 依然在 WMS 和 SurfaceFlinger 中存活(SurfaceFlinger 会跳过不可见的 Layer,不做合成,不消耗 GPU)。
按返回键时,C 被销毁,W_C 被移除,B 的窗口重新可见——Activity 调度就是在调度这层窗口栈。ActivityTaskManagerService(ATMS)负责管理 Activity 的生命周期,而每次 Activity 的前台/后台切换,都会触发 WMS 对对应 Window 的可见性更新和 Z-Order 重排。
Dialog:寄生在 Activity 上的第二块幕布
弹出一个 AlertDialog,WMS 里确实会新增一个独立的 Window,而不是画在 Activity 的 Window 上:
// Dialog 的内部实现
class Dialog {
// Dialog 拥有独立的 Window 对象
private final Window mWindow;
public Dialog(Context context) {
// 创建一个新的 PhoneWindow
mWindow = new PhoneWindow(context);
// 类型为 TYPE_APPLICATION(应用子窗口)
mWindow.setWindowManager(..., TYPE_APPLICATION, ...);
}
public void show() {
// 向 WMS 注册这块新窗口
mWindowManager.addView(mDecor, mWindow.getAttributes());
}
}
Dialog 挂出后,WMS 的窗口栈变成:
[W_Dialog] ← Z-Order 高于宿主 Activity
[W_A] ← Activity A 依然在后面
Dialog 的 Window 类型是 TYPE_APPLICATION(子窗口),它通过 attrs.token 与宿主 Activity 的 WindowToken 绑定,这个绑定保证了 Dialog 必须在宿主 Activity 可见时才能存在——如果宿主 Activity 销毁,Dialog 也会被连带移除。这是 WMS 的安全机制:杜绝"孤儿弹窗"。
同理,PopupWindow 和 DropdownMenu 也是独立的 Window,本质与 Dialog 一样。
Toast:游离于 App 之外的系统窗口
Toast 与 Dialog 有本质区别:它不依附于任何一个 Activity,而是由系统服务直接向 WMS 注册,类型是 TYPE_TOAST(系统窗口):
App 进程 system_server 进程
───────────── ────────────────────────────────
Toast.show() ────► NotificationManagerService
│
▼ 向 WMS 注册 TYPE_TOAST 窗口
WMS 新增 W_Toast(高 Z-Order)
这也解释了为什么 Toast 能悬浮在所有 App 之上,并且与当前前台 App 无关——它是系统级窗口,不归任何一个 App 的 WindowToken 管。
切换到桌面:Launcher 的窗口浮现
按 Home 键切换到桌面时,发生了什么?
Launcher(桌面 App)本质上也是一个普通 Activity,它的 Window(W_Launcher)一直存在于 WMS 的窗口栈底部。当用户切回桌面时,ATMS 将之前的前台 Activity 压后台,Launcher 的 Window 重新浮现在最顶层:
按 Home 键前 按 Home 键后
────────────────── ──────────────────
[W_C] ← 前台可见 [W_Launcher] ← 浮现到最顶
[W_B] [W_C] ← 后台,不可见
[W_A] [W_B]
[W_Launcher] ← 垫底 [W_A]
[W_StatusBar] ← 系统窗口 [W_StatusBar]
[W_NavBar] [W_NavBar]
Launcher 窗口从来没有被销毁,只是 Z-Order 低于前台 App 而不可见。切回桌面的"动画",本质上是 WMS 的 WindowAnimator 驱动 W_C 执行退出动画(缩小/淡出),同时 W_Launcher 执行进入动画(放大/淡入)。
系统窗口:永远置顶的特权幕布
状态栏(StatusBar)、导航栏(NavigationBar)、输入法(IME)——这些系统 UI 组件也是 WMS 管理的 Window,但它们使用系统级窗口类型(TYPE_STATUS_BAR、TYPE_INPUT_METHOD),Z-Order 天然高于任何应用窗口:
WMS 全局窗口栈(Z-Order 从高到低)
────────────────────────────────────
[W_InputMethod] TYPE_INPUT_METHOD ← 软键盘
[W_StatusBar] TYPE_STATUS_BAR ← 状态栏
[W_NavBar] TYPE_NAVIGATION_BAR ← 导航栏
[W_Dialog] TYPE_APPLICATION ← 应用弹窗
[W_ForegroundApp] TYPE_BASE_APPLICATION ← 前台 Activity
[W_BackgroundApp] TYPE_BASE_APPLICATION ← 后台 Activity(不可见)
[W_Launcher] TYPE_BASE_APPLICATION ← 桌面(垫底)
这个层级结构是写死在 WindowManagerPolicy(PhoneWindowManager)中的,应用无法修改,也无法突破。这就是为什么任何 App 都无法遮挡系统状态栏的原因。
一个 App 的完整窗口清单
综合以上场景,一个典型的应用在使用过程中可能会产生以下 Window:
| 场景 | 对应 Window 数量 | 窗口类型 |
|---|---|---|
| 一个 Activity | 1 | TYPE_BASE_APPLICATION |
| Activity + AlertDialog | 2 | + TYPE_APPLICATION |
| Activity + PopupMenu | 2 | + TYPE_APPLICATION_PANEL |
| Activity + 软键盘弹出 | 2 | + TYPE_INPUT_METHOD(系统窗口,不归 App) |
| 多 Activity 返回栈(A→B→C) | 3 | 每个 Activity 1 个,后台不可见但仍存在 |
一句话总结:App 里每一个调用了 WindowManager.addView() 的组件,在 WMS 里都是一个独立的 WindowState;Activity 调度(前台/后台切换)的本质,就是 WMS 对这棵窗口树进行 Z-Order 重排和可见性更新。
窗口树:WMS 眼中的世界模型
WMS 内部以一棵树来组织所有窗口,称为窗口容器树(Window Container Tree)。这棵树的层次结构如下:
RootWindowContainer ← 根节点,整个显示系统的入口
└── DisplayContent ← 一块物理/虚拟屏幕
└── TaskDisplayArea ← 屏幕中可放置 Task 的区域
└── Task ← 对应一个 Activity 返回栈中的任务
└── ActivityRecord ← 对应一个 Activity
└── WindowState ← 对应一个具体的窗口
每个节点都是 WindowContainer 的子类,统一管理显示属性和生命周期。WindowState 是叶子节点,代表一个实际上屏的窗口,它存储了该窗口的全部状态:
// frameworks/base/services/core/java/com/android/server/wm/WindowState.java
class WindowState extends WindowContainer<WindowState> {
// 窗口在屏幕上的真实坐标(经 WMS 布局后确定)
final Rect mWindowFrames;
// 窗口类型(Activity/Dialog/Toast/StatusBar…)
final int mAttrs.type;
// 与 SurfaceFlinger 交互的控制句柄
SurfaceControl mSurfaceControl;
// Z 轴层级(决定窗口的遮挡顺序)
int mLayer;
// 指向客户端(App 进程)的 Binder 代理
IWindow mClient;
}
这棵树的根节点 RootWindowContainer 是整个系统的"鸟瞰图"——任何的布局计算、Z-Order 调整、焦点变更,都从这里出发,自顶向下穿透整棵树。
窗口添加:一次跨进程的窗口登记
当 App 调用 WindowManager.addView(view, params) 时(比如 Activity 的 setContentView 最终会触发这一步),一次跨进程的窗口登记流程就此启动。
客户端:ViewRootImpl 的诞生
WindowManager.addView() 的实际实现在 WindowManagerGlobal 中:
// frameworks/base/core/java/android/view/WindowManagerGlobal.java
public void addView(View view, ViewGroup.LayoutParams params, ...) {
// 1. 为该 View 创建一个 ViewRootImpl
// ViewRootImpl 是 View 树与 WMS 之间的"代理人"
ViewRootImpl root = new ViewRootImpl(view.getContext(), display);
// 2. 将三者绑定(View + Params + ViewRootImpl)
mViews.add(view);
mRoots.add(root);
mParams.add(wparams);
// 3. 触发向 WMS 的注册
root.setView(view, wparams, panelParentView);
}
ViewRootImpl 是整个客户端窗口管理的核心:它既是 View 树遍历的入口(performTraversals()),也是与 WMS 通信的代理。
跨进程握手:IWindowSession
ViewRootImpl 通过 AIDL 接口与 WMS 通信。这个接口叫 IWindowSession,每个 App 进程持有一个 Session 对象,所有窗口的添加/移除都通过这个 Session 中转:
为什么全进程只有一个 Session?
秘密在 WindowManagerGlobal 上。它是一个进程级单例(通过静态字段实现),持有一个静态的 IWindowSession 代理对象 sWindowSession:
// frameworks/base/core/java/android/view/WindowManagerGlobal.java
public final class WindowManagerGlobal {
// 整个进程共享的唯一 Session 代理
private static IWindowSession sWindowSession;
public static IWindowSession getWindowSession() {
synchronized (WindowManagerGlobal.class) {
if (sWindowSession == null) {
// 首次调用时,通过 Binder 拿到 WMS 的代理
IWindowManager windowManager = getWindowManagerService();
// 向 WMS 申请创建一个 Session,这是一次 Binder 跨进程调用
sWindowSession = windowManager.openSession(
new IWindowSessionCallback.Stub() { ... });
}
return sWindowSession; // 之后所有调用直接返回同一个对象
}
}
}
这个 sWindowSession 是 Java 的静态字段,在同一个进程地址空间里只会被创建一次。无论这个 App 打开了多少个 Activity、弹出了多少个 Dialog,它们底下的每一个 ViewRootImpl 拿到的都是同一个 IWindowSession。
可以把它想象成:一栋大楼(App 进程)只需要办理一张"营业执照"(Session)挂在门口,楼里的所有住户(Window)办理事务时都持这张执照去找建筑规划局(WMS),而不是每户各自申请一张。
Session 是什么时候初始化的?
sWindowSession 是懒加载的——进程启动时不会立即创建,只在进程中第一个 ViewRootImpl 被构造时才会触发。
具体时机链路如下:
App 进程启动
│ ActivityThread.main()
│
▼
第一个 Activity attach 到窗口
│ Activity.attach()
│ → mWindow = new PhoneWindow()
│ → mWindowManager = mWindow.getWindowManager()
│
▼
首次 setContentView() / addView() 调用
│ WindowManagerGlobal.addView()
│ → new ViewRootImpl() ← ViewRootImpl 构造函数
│
▼
ViewRootImpl 构造函数
│ mWindowSession = WindowManagerGlobal.getWindowSession()
│ │
│ └─ sWindowSession == null? 是 → 第一次 openSession()
│ 否 → 直接返回已有的
▼
WMS.openSession() [Binder 调用]
│ 在 system_server 进程中创建 Session 对象
└─ 返回 Session 的 Binder 代理给 App 进程
openSession() 是一次真正的 Binder 跨进程调用,会在 system_server 侧实例化一个 Session 对象,然后将其 Binder 句柄返回给 App 进程保存在 sWindowSession 中。此后这个 Session 对象会在 system_server 进程中存活至 App 进程死亡(Session 持有 App 进程的 DeathRecipient,进程死亡时自动清理)。
每个 ViewRootImpl 持有的是同一个 Session 代理
这意味着:
App 进程(WindowManagerGlobal.sWindowSession → 唯一的 IWindowSession Binder 代理)
│
├── ViewRootImpl_A mWindowSession = sWindowSession ← 同一个引用
├── ViewRootImpl_B mWindowSession = sWindowSession ← 同一个引用
└── ViewRootImpl_C mWindowSession = sWindowSession ← 同一个引用
↓ Binder IPC
system_server 进程(Session 对象,唯一实例)
│
└── WMS 的所有 addWindow / removeWindow 请求都经这里中转
WMS 在服务端通过 Session 的引用就能知道这些窗口请求来自哪个 App 进程,所以 Session 同时也是 WMS 识别客户端身份的凭证。
App 进程 system_server 进程
───────────────── ──────────────────────────────
ViewRootImpl Session(IWindowSession 实现)
│ │
│ mWindowSession.addToDisplayAsUser() │
│ ─────────── Binder IPC ──────────► │
│ │ WMS.addWindow(session, ...)
│ │ ──────────────────► WMS
│ │
│ ◄── 返回 SurfaceControl ────────── │
服务端:WMS.addWindow 的核心逻辑
// WindowManagerService.java(简化)
public int addWindow(Session session, IWindow client, ...,
WindowManager.LayoutParams attrs, ...) {
// 1. 权限校验:检查窗口类型和申请权限
// 系统级窗口(如 Toast)需要 SYSTEM_ALERT_WINDOW 权限
int res = mPolicy.checkAddPermission(attrs, ...);
if (res != ADD_OKAY) return res;
// 2. 查找或创建 WindowToken(窗口的"户口本")
// 同一个 Activity 的所有子窗口共享一个 Token
WindowToken token = displayContent.getWindowToken(attrs.token);
// 3. 创建 WindowState(窗口在 WMS 中的档案)
final WindowState win = new WindowState(this, session, client, token,
parentWindow, appOp[0], attrs, viewVisibility, displayContent);
// 4. 将 WindowState 插入窗口容器树,计算 Z-Order
win.attach();
displayContent.addWindowToDisplayOrderLocked(win);
// 5. 请求 SurfaceFlinger 分配一块绘图 Surface
win.openSurface();
// 6. 重新计算布局和焦点
mWindowPlacerLocked.requestTraversal();
return ADD_OKAY;
}
win.openSurface() 是关键一步:WMS 向 SurfaceFlinger 申请创建一个 SurfaceControl(绘图层句柄),并将其返回给 App 进程。此后 App 就可以向这块 Surface 提交像素了。
整个添加流程的时序如下:
sequenceDiagram
participant App as App (UI线程)
participant VRI as ViewRootImpl
participant WMS as WMS Session
participant SF as SurfaceFlinger
App->>VRI: WindowManager.addView()
VRI->>VRI: new ViewRootImpl()
VRI->>WMS: addToDisplayAsUser() [Binder IPC]
WMS->>WMS: 创建 WindowState
WMS->>SF: 申请 SurfaceControl
SF-->>WMS: 返回 SurfaceControl
WMS-->>VRI: 返回 Surface 句柄
VRI->>VRI: requestLayout() 触发首次绘制
VSync:渲染的心跳
有了窗口,App 怎么知道什么时候该往 Surface 上画东西呢?这就引出了 Android 图形系统最核心的节奏控制机制:VSync(垂直同步)。
比喻:乐队指挥的指挥棒
想象一支管弦乐队。乐手(App、SurfaceFlinger)各自演奏,如果没有节拍约束,小提琴拉到一半打击乐已经进入下一段,整首曲子就乱了。指挥(VSync)的指挥棒每隔固定时间挥一次,所有乐手必须在下一次挥棒前准备好自己的声部,棒落时同时演奏。
屏幕刷新就是这个「棒落」时刻——每 16.67ms(60Hz)扫描一次像素。如果 App 随意地在任意时刻写入像素,就可能发生画面撕裂(Tearing):屏幕上半段是上一帧的像素,下半段是新帧的像素,出现横向错位的视觉割裂。
无 VSync 时的撕裂现象
┌─────────────────┐
│ ████ 旧帧数据 │ ← 屏幕扫描到一半
│ ████ 旧帧数据 │
├─────────────────┤ ← App 恰好在这时写入新像素
│ ░░░░ 新帧数据 │
│ ░░░░ 新帧数据 │
└─────────────────┘
↑ 视觉撕裂线
「上一帧扫描完毕」是什么意思?
这里的「扫描」不是指 App 的绘制过程,而是显示器的物理硬件行为——屏幕每刷新一帧,并不是「突然全屏换一张图」,而是通过**逐行扫描(Line Scan)**从左到右、从上到下,一行一行地把 Framebuffer 里的像素点亮:
屏幕物理扫描过程(以 1080p 为例,共 1080 行)
时间→
─────────────────────────────────────────────────►
第 1 行: ████████████████████ ← 从 Framebuffer 读出第 1 行像素,驱动电流点亮
第 2 行: ████████████████████
第 3 行: ████████████████████
...(约 16ms 内逐行扫完 1080 行)
第1080行: ████████████████████ ← 最后一行扫完
[ Vertical Blanking Interval, VBI ] ← 消隐期,电子枪回扫到左上角
↑
这里发出 VSync 脉冲!
这个机制起源于 CRT 显像管时代:电子枪从左上角沿水平方向轰击荧光屏,扫完一行后快速回到下一行开头(水平消隐),扫完最后一行再回到左上角(垂直消隐,Vertical Blanking Interval,VBI)。VSync 信号就是在 VBI 期间触发的——此刻屏幕刚扫完最后一行,没有任何扫描在进行,是唯一安全的"换帧窗口"。
虽然现代 LCD/OLED 已经没有物理电子枪,但显示控制器(Display Controller)依然沿用这套逐行读取 Framebuffer 的时序逻辑,VBI 和 VSync 信号也作为硬件接口标准被完整保留了下来。
CRT 时代扫描路径(原理延续至今)
┌──────────────────────────┐
│ →→→→→→→→→→→→→ (第1行) │
│ →→→→→→→→→→→→→ (第2行) │
│ ··· │
│ →→→→→→→→→→→→→ (最后行) │
└──────────┬───────────────┘
│ 回扫(VBI 消隐期)
↓ 发出 VSync 脉冲
┌──────────────────────────┐
│ 回到左上角,开始新一帧 │
└──────────────────────────┘
所以「上一帧扫描完毕」的准确含义是:显示控制器刚刚扫完 Framebuffer 的最后一行,正在进入垂直消隐期,此刻发出 VSync 脉冲通知所有人——安全窗口开启,可以开始准备下一帧的内容了。
为什么需要 VSync?为什么要这样设计?
核心矛盾是:写入速度(CPU/GPU 随时可写)和读取速度(显示器按固定节奏扫描)不匹配。
如果没有同步机制,CPU/GPU 可能在显示器扫到一半时突然修改 Framebuffer,导致同一帧内上下两半的数据来自不同的渲染结果——这就是撕裂。
最朴素的解决思路是:用双缓冲(Double Buffer)+ 显式换帧时机。
双缓冲 + VSync 设计:
FrontBuffer(显示器正在读) BackBuffer(App 正在写)
────────────────────────── ──────────────────────
████████████████████ ░░░░░░░░░░░░░░░░░░░░
████████████████████ ░░░░░░░░░░░░░░░░░░░░
████████████████████ (读) ░░░░░░░░░░░░░░░░░░░░ (写)
VSync 到来时(VBI 期间,显示器没在读)
↓
swap_buffers():FrontBuffer ↔ BackBuffer
瞬间完成,不影响显示器读取
- VBI 期间换帧:此时显示器不在扫描,交换前后缓冲区的指针(不拷贝内存,只换地址)是安全的,绝不会被显示器读到中间态。
- App 在 VBI 后开始绘制:收到 VSync 脉冲就知道「换帧已完成,我现在可以向 BackBuffer 写下一帧了」。
这就是 VSync 为什么被设计成一个硬件信号而非软件定时器的原因:它必须精确对齐显示器的物理扫描节拍,任何软件定时器都无法做到这种级别的精确同步。
用一句话概括:VSync 是显示器在告诉系统「我刚扫完最后一行,趁我不在读的时候快去换 Buffer」。Android 的整套 Choreographer + BufferQueue 机制,都是在这一物理约束之上建起来的。
VSync 信号的作用就是给所有人一个统一的「开枪令」:上一帧扫描完毕(进入 VBI)→ 发出 VSync 脉冲 → App 开始绘制新帧 → 下一次脉冲时新帧已就绪 → 屏幕扫描新帧,全程无撕裂。
VSync 的双轨制:VSYNC-app 与 VSYNC-sf
Android 并非把一个原始 VSync 信号直接分发给所有消费者,而是通过 DispSync(软件锁相环)在软件层面模拟出两路独立的虚拟 VSync 信号:
时间轴(一帧 = 16.67ms)
────────────────────────────────────────────────────────►
硬件 VSync: ↑ ↑ ↑ ↑
0ms 16.67ms 33.33ms 50ms
VSYNC-app: ↑ ↑ ↑
0ms 16.67ms 33.33ms
├──App绘制──┤
VSYNC-sf: ↑ ↑ ↑
+4ms +20.67ms +37.33ms
├──SF合成──┤
两路信号错开一个相位偏移(Phase Offset),让 App 先画好 Buffer,SurfaceFlinger 稍后来取。这是一条异步流水线:App 在生产,SurfaceFlinger 在消费,两者并行不等待,端到端延迟只需 1 个 VSync 周期而非 2 个。
DispSync 的数学本质
DispSync 不是简单的定时器,它是一个锁相环(PLL,Phase-Locked Loop):
硬件 VSync(存在抖动)
↑ ↑ ↑ ↑ ↑ ↑ ← 到达时间不均匀
└──┴──┴───┴─┴──┘
│
▼
DispSync(PLL 滤波)
1. 采样:记录每次 HW-VSync 的时间戳
2. 建模:最小二乘法拟合出周期 T 和相位 φ
3. 预测:t_next = φ + n × T
│
▼
VSYNC-app / VSYNC-sf(平滑、稳定)
↑ ↑ ↑ ↑ ↑ ← 均匀分布,不受硬件抖动影响
Android 12 以后,DispSync 被
VsyncTracker+VsyncDispatch替代,但核心思想(PLL 锁相预测)完全一致。
Choreographer:App 侧的绘制调度员
Choreographer(编舞者)是 App 进程内的 VSync 消费者,负责将 VSYNC-app 信号翻译成具体的 UI 工作。每个应用主线程有且仅有一个 Choreographer 实例(通过 ThreadLocal 存储)。
信号接收链路
SurfaceFlinger 的 EventThread
│
│ 通过 BitTube(内部 Socket)发送 VSYNC-app
▼
FrameDisplayEventReceiver ← 继承自 DisplayEventReceiver
│
│ onVsync() 回调
▼
Choreographer.onVsync()
│
│ 发送 MSG_DO_FRAME 消息给主线程 Handler
▼
Choreographer.doFrame()
BitTube 是 SurfaceFlinger 与 App 进程通信 VSync 信号的隧道,本质是一对 Unix Socket fd,一端由 SurfaceFlinger 写入,另一端由 App 进程的 DisplayEventReceiver 监听(通过 Looper 的文件描述符事件机制)。
doFrame:四类回调的执行顺序
收到 VSync 信号后,Choreographer.doFrame() 按以下固定顺序执行四类回调:
// Choreographer.java
void doFrame(long frameTimeNanos, int frame) {
// 1. Input:处理触摸/按键事件(优先级最高)
doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
// 2. Animation:执行属性动画的插值计算
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
// 3. Insets Animation:处理软键盘等 insets 动画(API 30+)
doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos);
// 4. Traversal:触发 View 树的 measure/layout/draw
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
// 5. Commit:提交帧数据,通知 WMS 帧完成
doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
}
这个顺序非常关键:必须先处理输入事件,再执行动画(动画可能受输入影响),最后才遍历 View 树生成像素。把任何耗时操作塞进这个回调链里,都会导致该帧绘制超时,进而掉帧。
scheduleTraversals 与 VSync 按需触发
App 并不会在每一个 VSync 信号到来时都执行绘制,VSync 信号默认是关闭的。只有当调用 requestLayout() 或 invalidate() 时,才会通过 ViewRootImpl.scheduleTraversals() 主动"订阅"下一个 VSync:
// ViewRootImpl.java
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
// 插入一个同步屏障,阻断当前 Handler 队列中的普通 Message
// 保证 VSync 回调能及时到达,不被其他 Message 拖延
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
// 向 Choreographer 注册 CALLBACK_TRAVERSAL 回调
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
}
}
这里有一个重要细节:postSyncBarrier() 在 Handler 消息队列中插入了一个同步屏障。在这个屏障被移除之前,所有普通(同步)消息都会被阻塞,只有异步消息(Choreographer 的 VSync 回调是异步消息)能通过。这确保了 VSync 回调不会被队列中堆积的其他消息"插队"延迟。
Surface 与 BufferQueue:生产者-消费者模型
App 绘制完一帧后,像素数据存放在哪里?如何传递给 SurfaceFlinger?这里用到了 Android 图形系统的核心数据结构:BufferQueue。
三者的关系
App 进程 SurfaceFlinger 进程
───────────────── ──────────────────────────
Surface(生产者端) Layer(消费者)
│ ▲
│ dequeueBuffer() │
│ ─────────────► GraphicBuffer │
│ │ │
│ (App 在 Buffer 上绘制) │
│ │ │
│ queueBuffer() │ │
└──────────────────► BufferQueue ───►┘
(共享内存)
- Surface:App 持有的画笔入口。调用
lockCanvas()/unlockCanvasAndPost()或 OpenGL ES 的eglSwapBuffers(),本质上都是向BufferQueue的生产者端提交数据。 - GraphicBuffer:实际存储像素的内存块,通过
grallocHAL 分配在共享内存(通常是 GPU 可访问的内存)中。App 和 SurfaceFlinger 各自持有同一块内存的 FD,无需数据拷贝。 - BufferQueue:连接生产者(App)和消费者(SurfaceFlinger)的双端队列。通常维护 3 个 Buffer(三重缓冲)以避免等待。
三重缓冲的流水线
为什么是三个 Buffer?类比一条餐厅的传送带:
| Buffer 状态 | 说明 |
|---|---|
| Dequeued(正在生产) | App 持有,正在往上画像素 |
| Queued(等待消费) | App 画完提交,等待 SurfaceFlinger 取走 |
| Acquired(正在合成) | SurfaceFlinger 正在读取,用于本帧合成 |
三重缓冲让 App 绘制和 SurfaceFlinger 合成可以并行无阻塞地运行。双缓冲(2个Buffer)的场景下,如果 SurfaceFlinger 未及时消费,App 会因无空闲 Buffer 可写而阻塞,导致 Jank(掉帧)。
draw() 的底层实现:像素是怎么写进 Buffer 的
onDraw(Canvas canvas) 是每位 Android 开发者都熟悉的 API,但传入的 canvas 之下究竟发生了什么?像素是如何从 canvas.drawRect() 一步步写进 GraphicBuffer、最终被 SurfaceFlinger 读走的?
两条渲染路径
Android 存在两条完全不同的渲染实现路径,由是否开启硬件加速决定:
onDraw(Canvas canvas)
│
├── 软件渲染(Hardware Acceleration = OFF)
│ Canvas → Skia(CPU 光栅化)→ 直接写入 GraphicBuffer
│
└── 硬件加速(Hardware Acceleration = ON,API 14+ 默认)
Canvas → DisplayList 录制 → RenderThread → GPU 光栅化 → GraphicBuffer
路径一:软件渲染(Skia on CPU)
软件渲染时,Canvas 是 SkiaCanvas 的包装,所有 drawXxx() 调用最终转发给 Skia(Google 的 2D 图形库,C++ 实现)。Skia 用 CPU 执行光栅化(将矢量指令栅格化为像素),直接写入通过 Surface.lockCanvas() 锁定的 GraphicBuffer 内存:
callstack(软件渲染)
canvas.drawRect()
└── SkCanvas::drawRect() [Skia C++]
└── SkBitmapDevice::drawRect()
└── SkDraw::drawRect() [CPU 光栅化,计算每个像素颜色值]
└── 写入 SkBitmap.pixels
└── ← 这块内存就是 GraphicBuffer 的地址
Surface.unlockCanvasAndPost()
└── queueBuffer() → 提交给 BufferQueue → SurfaceFlinger 消费
软件渲染的致命弱点:所有计算在 CPU 上串行执行,invalidate() 会导致整个 dirty 区域重新光栅化,CPU 压力大,大量绘制时卡顿明显。
路径二:硬件加速(DisplayList + RenderThread + GPU)
硬件加速引入了一个关键的分层设计:录制与回放分离。
第一步:主线程「录制」DisplayList
开启硬件加速后,传入 onDraw() 的 Canvas 实际上是 RecordingCanvas,它不执行任何绘制,只是把所有操作录制成一个 RenderNode(显示列表):
// 主线程执行(UI Thread)
void onDraw(Canvas canvas) {
// canvas 实际是 RecordingCanvas
// 以下调用不产生任何像素,只是在内存中记录指令序列
canvas.drawRect(...) // → 录制: OP_DRAW_RECT
canvas.drawText(...) // → 录制: OP_DRAW_TEXT
canvas.drawBitmap(...) // → 录制: OP_DRAW_BITMAP
}
// onDraw 结束后,RenderNode 持有完整的操作序列
RenderNode(DisplayList)内存结构
┌──────────────────────────────────────┐
│ op[0]: DRAW_RECT (x,y,w,h,paint) │
│ op[1]: DRAW_TEXT (str,x,y,paint) │
│ op[2]: DRAW_BITMAP (bmp,src,dst) │
│ ... │
└──────────────────────────────────────┘
第二步:RenderThread「回放」到 GPU
主线程录制完成后,将 RenderNode 提交给独立的 RenderThread(渲染线程,Android 5.0 引入)。RenderThread 将 DisplayList 翻译成 OpenGL ES / Vulkan 指令,提交给 GPU 执行光栅化:
主线程(UI Thread) RenderThread GPU
───────────────── ────────────── ────
onDraw() 录制 RenderNode
│
│ syncAndDrawFrame() → 接收 RenderNode
│ 翻译为 GL/Vulkan 指令
│ eglSwapBuffers() → 光栅化,写入 GraphicBuffer
│ ← GPU fence 信号(渲染完成)
│ queueBuffer()
│ └─► BufferQueue → SurfaceFlinger
RenderThread 的核心价值:主线程录制完就可以继续处理下一帧的 Input/Animation,不需要等待 GPU 完成。GPU 渲染和主线程逻辑完全并行,这就是硬件加速在滚动场景下远比软件渲染顺滑的根本原因。
第三步:Fence 同步——GPU 完成的通知机制
GPU 的执行是异步的,CPU 提交 GL 指令后 GPU 并不立即完成。如何知道 GPU 已经把像素写进 GraphicBuffer 了?这通过 Fence(栅栏) 机制完成:
RenderThread GPU
│ │
│ glDrawXxx() │
│──────────────────────────►│ 开始渲染(异步)
│ │
│ eglSwapBuffers() │
│ → 获得 release_fence fd │
│ │
│ queueBuffer(fence_fd) │
│──── 把 Fence 一起提交 ───►BufferQueue
│ │ 渲染完成
│ │ → fence 变为 signaled
▼ │
SurfaceFlinger acquireBuffer() │
等待 fence signaled 后再读 Buffer ← 确保 GPU 已写完
Fence 是 Android 图形系统中 GPU 与 CPU 协同的核心同步原语。没有它,SurfaceFlinger 可能在 GPU 还没写完像素时就读取 Buffer,导致花屏。
两条路径对比
| 维度 | 软件渲染 | 硬件加速 |
|---|---|---|
| Canvas 实现 | SkiaCanvas(直接光栅化) |
RecordingCanvas(录制 DisplayList) |
| 执行线程 | UI Thread(串行) | UI Thread 录制 + RenderThread 执行(并行) |
| 光栅化单元 | CPU(Skia) | GPU(OpenGL ES / Vulkan) |
invalidate() 代价 |
重绘整个 dirty 区域 | 仅重放变化的 RenderNode 子树 |
| 适用场景 | 简单 2D 绘制、低端设备 | 绝大多数现代 Android 场景 |
SurfaceFlinger:最终的图像合成者
SurfaceFlinger 是独立于 App 和 WMS 的另一个系统服务进程,运行时拥有最高实时线程优先级(SCHED_FIFO)。它的职责是:在每个 VSYNC-sf 信号到来时,从所有 Layer 的 BufferQueue 中取出最新帧,按 Z-Order 合成为一张完整的屏幕图像,送往显示硬件。
比喻:印刷厂的最终装配线
把 SurfaceFlinger 想象成一家印刷厂的最终装配线。来料是各个车间(App)印好的图层(Layer Buffer),装配线(SurfaceFlinger)按照版面规划(Z-Order)把它们叠印在一张纸(Framebuffer)上,交给发行部门(显示器)。每台机器(Layer)的素材什么时候准备好,由传送带节拍(VSYNC-sf)决定;没准备好的就延用上一版内容,绝不停线等待单个 App。
Layer:SurfaceFlinger 眼中的世界
SurfaceFlinger 不认识「Activity」或「Window」,它只认识 Layer。每个 WMS 管理的 WindowState,在 SurfaceFlinger 内部都对应一个 Layer 对象:
WMS 视角 SurfaceFlinger 视角
─────────────────── ─────────────────────────────────
WindowState(软键盘) Layer_IME z=2050
WindowState(状态栏) Layer_StatusBar z=2100
WindowState(Dialog) Layer_Dialog z=21005
WindowState(前台 Activity) Layer_App z=21000
WindowState(桌面) Layer_Launcher z=11000
每个 Layer 背后都绑定一个 BufferQueue,App 是生产者,SurfaceFlinger 是消费者。
合成策略:GPU 合成 vs 硬件合成(HWC)
SurfaceFlinger 拿到所有 Layer 后,需要决定如何把它们合并成一张图。它不会无脑用 GPU,而是先把 Layer 列表交给 Hardware Composer(HWC) 做能力评估:
flowchart TD
A["VSYNC-sf 到来"] --> B["SurfaceFlinger 收集所有 Layer Buffer"]
B --> C["提交 Layer 列表给 HWC"]
C --> D{"HWC 能直接处理?"}
D -- "能(Device Composition)" --> E["硬件 Overlay 叠加\nDMA 零拷贝\n无 GPU 参与"]
D -- "不能(Client Composition)" --> F["SurfaceFlinger 用 GPU\nOpenGL ES 合成中间 Buffer"]
E --> G["提交 Framebuffer 给显示控制器"]
F --> G
G --> H["显示器扫描输出像素"]
Device Composition(硬件合成) 是首选路径。显示控制器内置 Overlay 硬件单元,直接把各 Layer 的 Buffer 地址告知硬件,DMA 在扫描时实时叠加,全程不用 GPU,极省电且零内存拷贝。
Client Composition(GPU 合成) 是兜底路径。当 Layer 含有复杂的圆角裁剪、模糊特效、非标准透明度混合等超出 HWC 硬件能力的操作时,SurfaceFlinger 退回用 GPU(OpenGL ES)将这些 Layer 先合成到一块中间 Buffer,再交给 HWC 处理剩余 Layer。
| 特性 | Device Composition | Client Composition |
|---|---|---|
| 执行单元 | 显示控制器硬件 | GPU(OpenGL ES) |
| 内存拷贝 | 零拷贝(DMA 直接读取) | 需写入中间 Framebuffer |
| 功耗 | 极低 | 较高 |
| 支持效果 | 简单叠加、旋转 | 任意图形效果 |
SurfaceFlinger 的完整合成周期
VSYNC-sf 脉冲
│
▼
① latchBuffer()(每个 Layer)
│ 从 BufferQueue.acquireBuffer() 取最新帧
│ 等待该 Buffer 的 release_fence(GPU 已写完才能读)
│
▼
② rebuildLayerStacks()
│ 按 Z-Order 对 Layer 排序
│ 剔除不可见的 Layer(后台 Window、被完全遮挡的 Layer)
│
▼
③ setUpHWComposer()
│ 将 Layer 列表发给 HWC
│ HWC 返回每个 Layer 的合成类型
│ (DEVICE / CLIENT / CURSOR / SIDEBAND…)
│
▼
④ doComposition()
├── CLIENT 类型 Layer → GPU 合成到中间 FrameBuffer
└── 将最终结果(含 DEVICE Layer 信息)提交给 HWC
│
▼
⑤ postComposition()
│ 调用 HWC.presentDisplay()→ 提交最终图像给显示控制器
│ 释放已消费的 Buffer(releaseBuffer → 归还 BufferQueue)
└── 更新帧统计(掉帧检测、帧率统计)
SurfaceFlinger 如何感知 App 的新帧
App 调用 queueBuffer() 后,BufferQueue 内部会触发 onFrameAvailable 回调通知 SurfaceFlinger「这个 Layer 有新帧了」。SurfaceFlinger 不会立即合成,而是把这个事件记录下来,等到下一个 VSYNC-sf 信号再统一处理——确保合成始终与屏幕刷新节奏对齐:
App queueBuffer()
│
▼
BufferQueue.onFrameAvailable() ← 回调
│
▼
SurfaceFlinger 记录「Layer_X 有新帧」
│
│ (等待 VSYNC-sf)
▼
VSYNC-sf 到来 → 统一合成所有就绪 Layer
这种攒批处理的设计避免了「App 每提交一帧就触发一次合成」的浪费——多个 App 在同一 VSync 周期内更新,只触发一次合成。
全链路全景:一帧的生命周期
将上面所有环节串联起来,一帧画面的生命周期如下:
时间轴 ─────────────────────────────────────────────────────────►
N-1帧 N帧
───── ──────────────────────────────
VSYNC-app VSYNC-app VSYNC-app
↓ ↓ ↓
App: [── 绘制N帧 ──]
scheduleTraversals()
Choreographer.doFrame()
measure/layout/draw
eglSwapBuffers() → queueBuffer()
VSYNC-sf VSYNC-sf
↓ ↓
SurfaceFlinger: [─ 合成N帧 ─]
acquireBuffer()
HWC 合成
postFrame()
[── 屏幕显示 N帧 ──]
└── 用户看到画面
整个流程的关键路径:
- App 收到
VSYNC-app→ 执行measure/layout/draw,提交 Buffer - SurfaceFlinger 收到
VSYNC-sf(相位偏移后) → 合成所有 Window 的 Buffer - 显示器 在下一个扫描周期,从 Framebuffer 读出合成结果并显示
一帧端到端的时延通常是 1~2 个 VSync 周期(16ms~33ms)。
WMS 如何感知绘制状态
WMS 不止于"安排位置",它还深度介入绘制流程的协调。有几个关键机制值得关注:
窗口属性变更的事务提交
当 WMS 需要修改窗口的位置、大小、透明度等属性时,不会逐条下发命令,而是通过 SurfaceControl.Transaction 批量提交:
// WMS 内部,批量修改窗口属性
SurfaceControl.Transaction t = mTransactionFactory.get();
t.setPosition(win.mSurfaceControl, x, y);
t.setAlpha(win.mSurfaceControl, alpha);
t.setLayer(win.mSurfaceControl, layer);
t.apply(); // 原子提交,保证同帧生效
Transaction.apply() 会将这批修改通过 Binder 发给 SurfaceFlinger,SurfaceFlinger 保证这些修改在同一帧内原子生效,避免出现"位置变了但透明度还没变"的中间态撕裂。
relayoutWindow:触发重新布局
当 App 的窗口发生大小变化时(如从竖屏旋转为横屏),ViewRootImpl 会调用 mWindowSession.relayout(),触发 WMS 重新计算该窗口的布局:
// ViewRootImpl.java(简化)
private void performTraversals() {
boolean windowShouldResize = // 窗口尺寸是否需要变化
if (windowShouldResize) {
// 向 WMS 申请重新布局,获取新的窗口尺寸
int relayoutResult = mWindowSession.relayout(
mWindow, mWindowAttributes, requestedWidth, requestedHeight, ...);
}
// 继续执行 measure/layout/draw
}
首帧绘制的同步机制
Activity 首次展示时,有一个微妙的同步问题:WMS 必须等到 App 真正提交了第一帧,才能显示窗口,否则用户会看到一个空白窗口。
这通过 WindowState.mWinAnimator.mDrawState 状态机来协调:
NO_SURFACE → DRAW_PENDING → HAS_DRAWN
↑ ↑
addView 完成 App 提交第一帧
WMS 为其分配 ViewRootImpl 通知 WMS
SurfaceControl finishDrawingWindow()
只有当状态到达 HAS_DRAWN,WMS 才允许 SurfaceFlinger 显示这个窗口。这就是为什么 Activity 启动时,在绘制第一帧之前,用户看到的是 windowBackground(系统默认背景)而不是空白。
Systrace 中的实证分析
理解了原理,不妨从 Systrace/Perfetto 中寻找实证。滑动一段 RecyclerView 时,在 Perfetto 中可以观察到以下模式:
Choreographer(App 主线程)
│
├─ [VSYNC-app] doFrame
│ ├─ input ← 处理手指滑动事件
│ ├─ animation ← 如果有 ItemAnimator 则在这里执行
│ └─ traversal ← RecyclerView.onLayout() 计算新位置
│ └─ draw ← 将新内容写入 Surface Buffer
│
└─ [next VSYNC-app] doFrame ...
SurfaceFlinger
│
├─ [VSYNC-sf] onMessageReceived
│ └─ handleMessageRefresh
│ └─ doComposition ← 合成 App 提交的 Buffer
│
└─ 提交显示
如果 traversal 耗时超过 16ms,下一帧的 VSYNC-app 来临时上一帧还没提交,SurfaceFlinger 就只能复用上一帧的 Buffer——这就是掉帧(Jank)。doFrame 开始时会检查 frameTimeNanos 与当前时间的差值,如果差值超过一帧时间,会打印 Skipped X frames! 日志。
渲染管线各层总结
| 层次 | 核心组件 | 所在进程 | 关键职责 |
|---|---|---|---|
| 应用层 | ViewRootImpl, View Tree |
App | measure/layout/draw,向 Surface 提交像素 |
| 调度层 | Choreographer, DisplayEventReceiver |
App | 订阅并响应 VSYNC-app,驱动绘制节奏 |
| 信号层 | DispSync (VsyncTracker), EventThread |
SurfaceFlinger | 生成/分发 VSYNC-app 和 VSYNC-sf |
| 缓冲层 | BufferQueue, GraphicBuffer |
跨进程共享内存 | App 与 SurfaceFlinger 之间的零拷贝数据管道 |
| 管理层 | WMS, WindowState, SurfaceControl |
system_server | 窗口元数据管理,属性变更原子下发 |
| 合成层 | SurfaceFlinger, HWC |
SurfaceFlinger | 按 Z-Order 合成所有 Layer,提交显示 |
| 硬件层 | 显示控制器, DMA | 内核/硬件 | 从 Framebuffer 扫描像素,驱动物理屏幕 |
这条链路跨越了七个层次、三个进程(App / system_server / SurfaceFlinger)、两种 IPC 机制(Binder + 共享内存)。每一帧画面的诞生,都是这些组件精密协作的结果——而任何一个环节出现阻塞或超时,就会以"掉帧"的形式被用户的眼睛捕捉到。