说实话,窗口这套东西在 Android 里属于“平时用不到、出事就头大”的典型。应用开发只知道调WindowManager.addView,系统开发天天对着WindowManagerService(也就是俗称的 WMS)的源码发愁。我前一阵排查一个“窗口偶尔白屏、打开慢半拍”的问题,把从应用进程到系统进程、再到 SurfaceFlinger 的整条链路重新读了一遍,发现很多代码流程如果不画出来,光靠看源码真的很绕。这篇文章就把窗口打开全流程的代码路径拆开讲清楚,从addView开始,到 WMS 登记窗口、分配 Surface、计算布局,最后到 SurfaceFlinger 合成上屏,跟着代码走一遍。我这里统一以 Android 11/12 附近的源码为准,版本差异比较大的地方我会单独说明。
1. 从一次 addView 请求开始说起:窗口到底是怎么打开的
1.1 真正发起 addView 的地方不在 WindowManager 本身
大多数开发者的认知是:我调了WindowManager.addView(view, params),窗口就创建了。这话对也不对。WindowManager只是个接口,真正的实现是WindowManagerImpl,而它内部又会把请求转给进程级的单例WindowManagerGlobal。WindowManagerGlobal.addView做的第一件事不是通知系统,而是先给这个 View 配一个<span >ViewRootImpl</span>。
// WindowManagerGlobal.addView 的简化逻辑 public void addView(View view, ViewGroup.LayoutParams params, Display display, Window parentWindow, int userId) { // 线程检查:必须在主线程 if (mLooper != Looper.getMainLooper()) { throw new CalledFromWrongThreadException( "Only the original thread that created a view hierarchy can touch its views."); } ViewRootImpl root; synchronized (mLock) { root = new ViewRootImpl(view.getContext(), display); view.setLayoutParams(wparams); root.setView(view, wparams, panelParentView); } }所以一次addView调用,真正做的是“在应用进程里建立一棵视图树和它的控制器”。ViewRootImpl是整个窗口消息驱动的心脏,你说的窗口测量、布局、绘制、和系统交互,全在里面。
这里有个容易误解的点:WindowManagerGlobal是进程级单例,也就是说一个应用进程里所有窗口都共用这一个对象。系统进程里对应的 Session 也是复用的,后面讲 Binder 通道时会看到这个设计的影响。
1.2 ViewRootImpl 建立与线程约束
new ViewRootImpl(context, display)这个构造函数里,最重要的工作是拿到与 WMS 通信的IWindowSession。代码里是这么调的:
mWindowSession = WindowManagerGlobal.getWindowSession();这个getWindowSession()内部会通过WindowManagerService.openSession()拿一个 Binder 代理。第一次调用时建立连接,之后整个进程所有窗口都复用同一个 Session 对象。Session 在系统进程那一端保存了调用者的pid、uid、mProcessName等信息,所以 WMS 做权限判断时,能知道“是哪个进程在请求窗口”。
线程约束也必须在这里提。窗口操作绝大多数要求主线程,原因不是 Binder 调用本身需要,而是ViewRootImpl里的 View 树不是线程安全的,它的scheduleTraversals、performTraversals都依赖 Choreographer 的帧回调,这套机制只允许主线程驱动。你在子线程里直接addView,基本都会在WindowManagerGlobal.addView的线程检查处被拦下来。
1.3 setView 中的跨进程调用与 IWindow
ViewRootImpl.setView是真正开始跟系统交互的地方。它的流程可以压缩成三步:
- 把 View 挂到
ViewRootImpl的 View 层级里; - 创建
ViewRootImpl.W这个IWindow.StubBinder 对象,它代表“应用进程里的这个窗口”的远端接口; - 通过
mWindowSession.addToDisplay把这个窗口注册到 WMS。
关键调用是这样的:
// ViewRootImpl.setView 中(伪代码,省略了大量字段) mWindowSession.addToDisplay(mWindow, mSeq, mWindowAttributes, getHostVisibility(), mView.getDisplay().getDisplayId(), mTmpFrame, mTmpContentInsets, mTmpStableInsets, mTmpOutsets, mTmpDisplayedContentInfo, mInputChannel);mWindow是IWindow类型的 Binder 对象,WMS 后续要通知应用“你的窗口位置变了”“你的窗口可见性变了”时,就通过它回调。这也是个容易忽略的地方:从这套设计一开始,应用和系统就不是简单的“一问一答”,而是双向 Binder 通信。addToDisplay是一次同步调用,应用进程会阻塞等 WMS 返回窗口的初始位置信息。如果 WMS 那边锁竞争严重,这个同步调用就会直接变成“应用主线程卡顿”的元凶之一。
2. Session.addToDisplay 与 WMS.addWindow 的校验逻辑
2.1 IWindowSession 这条 Binder 通道传了什么
IWindowSession是定义在android.view包下的 AIDL 接口,实现在系统进程的Session类里。它管的不只是添加窗口,relayout、addWindow、remove、setInsets这些窗口生命周期操作都走它。
一次addToDisplay调用,传递的数据可以分成几类:
| 数据 | 作用 |
|---|---|
IWindow client | 系统进程回调应用侧窗口事件的 Binder |
LayoutParams attrs | 窗口类型 type、token、flags、坐标、软键盘模式等 |
displayId | 目标显示设备 |
viewVisibility | 当前 View 的可见状态 |
一系列out参数 | WMS 计算后返回的窗口 Frame、Insets 区域 |
InputChannel | 输入事件通道,首次添加时建立 |
注意这里传的LayoutParams是一次 Parcel 拷贝。你在应用侧设置 token、type、flag,到 WMS 那边就已经是另一个对象了。很多“为什么改了 params 没生效”的排查,最后都要回到这里确认数据有没有真的传过去。
2.2 addWindow 的三段校验
Session.addToDisplay收到应用请求后,会转到WindowManagerService.addWindow。这个名字听起来像是“添加一个窗口”,实际含义是“在 WMS 的窗口表里登记一个窗口状态对象”。真正的 View 和绘制内容根本不在这里。
addWindow开头的校验分三块:
- token 校验:
type为应用窗口时,LayoutParams.token必须是有效的IApplicationToken;type是子窗口(1000-1999)时,必须有合法的父窗口。这里最容易挂的就是BadTokenException。 - type 校验:窗口 type 必须落在应用窗口(1-99)、子窗口(1000-1999)、系统窗口(2000+)的合理区间内。
- 权限校验:一些特殊 type 需要权限,比如
TYPE_APPLICATION_OVERLAY需要SYSTEM_ALERT_WINDOW,注入事件窗口需要INJECT_EVENTS权限。
| 校验失败 | 典型原因 | 对应异常 |
|---|---|---|
| token 无效 | Activity 已销毁,但还拿 token 去加子窗口 | BadTokenException |
| 父窗口不存在 | 子窗口先于父窗口 addView | BadTokenException |
| 权限不足 | 未声明SYSTEM_ALERT_WINDOW就弹悬浮窗 | SecurityException |
| Display 不存在 | displayId 无效或已移除 | IllegalArgumentException |
还有一个细节:addWindow的返回值是一堆ADD_*错误码,比如ADD_OKAY、ADD_BAD_APP_TOKEN、ADD_PERMISSION_DENIED。应用侧ViewRootImpl拿到非ADD_OKAY后,才会转换成各种异常往外抛。所以你在应用层看到的BadTokenException,其实是系统进程跨进程返回错误码之后,应用进程自己抛的。
2.3 服务端户口:WindowState 与窗口层级
校验通过后,WMS 会创建WindowState对象,这是系统进程里最重要的窗口数据结构。WindowState持有:
- 对应的
IWindowBinder 代理; LayoutParams的副本;- 所属的
WindowToken; - 所在
DisplayContent; - 和 Surface 强相关的
SurfaceControl。
WindowState创建后会放进mWindowMap,同时加到所属DisplayContent的窗口列表里。到了这一步,“窗口”在系统侧才真正有了户口。
老版本源码里还有个WindowStateAnimator,负责和 Surface 动画有关的状态管理。Android 12 前后这部分被合并进了WindowState,职责是一样的。如果你看的是老代码,看到这个类不用慌,它跟“动画”关系不大,更多是管理 Surface 生命周期。
同一时刻的窗口层级也在这里计算。应用窗口的基础层级由窗口 type 决定,同一个 Activity 里的子窗口再根据WindowManager.LayoutParams里的x、y、gravity等算出 sub layer。层级计算放在后面讲 SurfaceFlinger 时会继续展开。
3. relayoutWindow:布局参数回流与 Surface 的第一次亮相
3.1 为什么要两次往返
addToDisplay只是登记户口,窗口有多大、位置在哪、Surface 是什么,都没确定。应用的 View 树要测量布局,必须知道窗口最终会占据多大区域。这一步就是relayout的职责。
ViewRootImpl.setView执行完addToDisplay后会主动requestLayout(),随即scheduleTraversals()触发一次完整的 View 遍历。在performTraversals方法里,代码会先判断是否需要跟 WMS 做relayout,第一次遍历时必然要。这就是为什么窗口打开会有两次典型的 Binder 往返:
addToDisplay:注册窗口,返回初始 Frame;relayout:根据应用请求宽高和系统策略,计算最终布局结果,并返回 Surface。
这两次调用缺一不可。addToDisplay相当于“我要开窗”,relayout相当于“窗开多大、窗玻璃在哪”。
3.2 SurfaceControl 的创建与 Surface 的绑定
relayout在 WMS 端对应relayoutWindow。这里最核心的事情是:如果这个窗口还没有 Surface,就创建SurfaceControl。
// WMS.relayoutWindow 中关于 Surface 的部分(示意,非完整源码) if (win.getSurfaceControl() == null) { win.createSurfaceControl(); // 内部等价于: // mSurfaceControl = new SurfaceControl.Builder(mSurfaceControlFactory) // .setName(win.getName()) // .setFlags(...) // .build(); }SurfaceControl是系统进程持有的、对应一个显示层的句柄。应用侧拿到的是Surface,它和SurfaceControl共享同一个底层BufferQueue的生产者端。这个Surface通过relayoutWindow的outSurface参数传递回应用进程。
有个细节要注意:relayout不是每次都重建 Surface。只有第一次 relayout 时窗口还没有 Surface,才会走创建流程。后面如果窗口尺寸、位置变化,通常只是修改SurfaceControl的属性,而不是重建整个 Surface。真正触发 Surface 重建的场景很有限,大多是 Surface 相关崩溃或 GPU 上下文重置。
3.3 performTraversals 的测量布局绘制节奏
ViewRootImpl.performTraversals是 View 遍历的总调度,顺序很讲究:
- 先 relayout,拿到
frame和 Insets; - 再
performMeasure,用窗口真实尺寸约束测量; - 然后
performLayout; - 最后
performDraw。
其中第 1 步和第 2 步之间有个强依赖:没有frame就没法确定 MeasureSpec。这也是首次窗口打开无法跳步的原因。
绘制阶段,现代的 Android 基本都是硬件加速。performDraw内部会走HardwareRenderer,它把 View 的DisplayList转换成 GPU 指令,再通过Surface提交到BufferQueue。此时 buffer 还没上屏,只是提交给了系统合成端。很多开发者看到“draw 完成”就以为显示出来了,其实中间还隔着 SurfaceFlinger 这一层,这就是后面章节要讲的。
首次窗口打开时,relayout 返回 Surface 后,View 树才真正开始测量布局,所以状态栏、桌面那些动画能看到一个“窗口从空白到有内容”的过渡。如果 Surface 创建成功但应用迟迟不画第一帧,那就是白屏。
4. 从 WMS 到 SurfaceFlinger:窗口几何信息如何上屏
4.1 Transaction 提交位置和层级
WMS 本身不画任何像素,它只负责两件事:算窗口的几何属性,然后把几何属性通过SurfaceControl.Transaction提交给 SurfaceFlinger。这个“几何属性”包括层位置、层大小、裁剪区域、层级顺序、透明度等。
// WMS 计算完成后给窗口层提交几何信息(示意) SurfaceControl.Transaction t = new SurfaceControl.Transaction(); t.setLayer(surfaceControl, layer); t.setPosition(surfaceControl, frame.left, frame.top); t.setWindowCrop(surfaceControl, frame.width(), frame.height()); t.setAlpha(surfaceControl, alpha); t.apply();Transaction是一次原子提交。WMS 不会把WindowState的每个字段都直接同步给 SF,而是把“这一帧窗口应该长什么样”打包成一个事务。SF 拿到事务后统一更新合成状态,避免多个窗口状态不一致导致闪烁。
窗口层级计算通常在assignWindowLayers里完成,各类型窗口的 base layer 按 type 区分,同类型窗口内部再按 WindowState 的mSubLayer排序。层级的最终表现就是 Z 轴顺序:谁盖在谁上面。
4.2 应用侧绘制与 BufferQueue 的关系
应用进程和 SurfaceFlinger 之间,真正的数据通道是BufferQueue。应用侧Surface是生产者,SF 侧Layer里包含消费者。应用每画完一帧,就把 buffer 通过queueBuffer交给消费者。
这里的 vSync 驱动链路是:
Choreographer收到 vsync 信号;- 回调
ViewRootImpl.TraversalRunnable; - 执行
performTraversals和performDraw; HardwareRenderer渲染完成后syncAndDrawFrame;Surface提交 buffer 到 BufferQueue;- SurfaceFlinger 在下一次合成时读取 buffer。
注意:WMS 和 SF 的沟通是事务,应用和 BufferQueue 的沟通是 buffer。两套机制相互独立又耦合在一起:buffer 里是内容,transaction 里是内容如何呈现。窗口打开慢,经常就是这两套机制没对齐。
4.3 “窗口打开完成”到底指什么
从代码流程看,“窗口打开完成”至少有两个层面的标准:
- WMS 层面:
WindowState已经注册,Surface 已创建,状态走到READY_TO_SHOW或HAS_DRAWN; - 用户体验层面:第一帧真正被 SurfaceFlinger 合成并输出到屏幕。
这两个标准之间可能隔着一帧甚至多帧。系统判断窗口可以显示,通常要等应用的第一帧 buffer 提交到 BufferQueue 后才能确定。WindowState里mDrawState字段就是干这个的,状态机大致是:
| mDrawState | 含义 |
|---|---|
NO_SURFACE | 窗口还没创建 Surface |
DRAW_PENDING | Surface 已创建,等待应用提交第一帧 |
COMMIT_DRAW_PENDING | 第一帧已提交,等待 SF 事务确认 |
READY_TO_SHOW | 可以显示 |
HAS_DRAWN | 已经显示过至少一帧 |
白屏、启动窗口秒关、窗口一闪而过,很多都和这个状态机的跳转有关。应用层觉得“我画完了”,WMS 那边可能还在等 buffer 真正到达 SF。
5. 实战排错:窗口没出来的问题,怎么按代码流程定位
5.1 先看 dumpsys window 的窗口状态
窗口相关的问题,我排查时第一件事永远是dumpsys window windows。不要瞎猜,先看系统侧认为这个窗口存在不存在。
adb shell dumpsys window windows输出里重点看几个字段:
mWindowInfo或Window #...:窗口是否存在;mViewVisibility:应用侧视图是否可见;mHasSurface:Surface 是否已创建;mDrawState:当前处在哪个绘制状态;mFrame:窗口最终位置大小。
如果WindowState根本没有,说明addWindow就没通过校验。如果mHasSurface=false,问题在 relayout 之前的链路。如果mHasSurface=true但mDrawState一直停在DRAW_PENDING,那就是应用提交第一帧慢了,问题不在 WMS,而在应用绘制本身。
这一步能把问题范围从“整个窗口链路”直接缩小到具体阶段,非常省时间。
5.2 卡顿、延迟和白屏时的链路检查
窗口打开慢,一般有两种形态:一种是整体延迟,比如点了按钮后几百毫秒才看到窗口;另一种是白屏,窗口出现了但内容迟迟不来。
整体延迟优先怀疑 Binder 同步阻塞。addToDisplay和relayout都是同步调用,如果 WMS 那边mGlobalLock被某个耗时任务占住,所有窗口的请求都会排队。这时候用 systrace 抓取能看到系统进程里 WMS 的 Binder 调用耗时异常长。
白屏优先怀疑绘制链路。Surface 已经是有效的,但应用侧迟迟没有 buffer 提交。可以通过 systrace 或 Perfetto 看:
- 应用进程有没有执行
draw; HardwareRenderer有没有提交 buffer;- SurfaceFlinger 有没有收到新 buffer 并合成。
我自己遇到的一次典型问题,是应用在onCreate里做了大量耗时初始化,导致第一帧提交前主线程一直被占用。从流程上,WMS 那边窗口已经DRAW_PENDING等了两百多毫秒,体验上就是白屏。
5.3 BadTokenException 与权限拒绝的源头理解
BadTokenException 是窗口开发里最常见的异常,但很多人只会在应用层打日志,不知道真正判断在系统进程的addWindow里。如果你用LayoutParams设置了一个已经失效的 token,WMS 在校验阶段就直接返回ADD_BAD_APP_TOKEN。
排查方法很简单:日志里搜WindowManagerService关键字,能看到类似:
WindowManager: addWindow: window already exists WindowManager: addWindow: Bad token, window token=...权限问题也是同一套路。TYPE_APPLICATION_OVERLAY这类窗口在 Android 8.0 以后要求弹窗权限,WMS 在addWindow里会检查mContext.checkCallingPermission。如果权限没有授予,返回ADD_PERMISSION_DENIED,应用层再抛SecurityException。
这类问题的定位诀窍是:不要只看异常堆栈,先确认 Binder 调用是否真正到达了 WMS。很多时候应用主动做了 try-catch,异常被吞掉,但dumpsys window里窗口列表始终没有目标窗口。从这段代码流程来看,窗口没出现在dumpsys里,说明问题在addWindow之前的任何一步,而不是在绘制和合成阶段。
窗口打开的代码流程,说复杂也复杂,说简单也简单:应用提交一个 View 树,WMS 负责登记和算位置,SF 负责把内容合成出去。中间每一层都有各自的校验和状态,排错时只要能确定“问题发生在哪一层”,就成功了一大半。每次遇到窗口问题,我建议先把dumpsys window的状态和异常堆栈对应起来看,再决定是抓 systrace 还是查权限。这套方法我用了很多年,比对着源码瞎猜要靠谱得多。