ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

深入Android WMS:窗口布局原理与运行时管理实战指南

2026/9/8 0:52:09 拓冰建站 浏览量
深入Android WMS:窗口布局原理与运行时管理实战指南 搞Android底层有一点时间的人应该都遇到过这种场景某个Activity跑得好好的一进分屏弹窗就跑到屏幕外面或者横竖屏切换瞬间闪一下黑屏又或者明明只是改了一个TextView的可见性dumpsys window里却能看到某一窗口在一秒内被反复relayout了十几次。这些问题表面上是应用层写了个Bug但真正深挖下去最后都会落到同一个核心服务身上WMSWindowManagerService。WMS在Android系统里管的事情非常聚焦就是窗口的添加、移除、布局计算、层级管理以及和显示流程的对接。AMS负责Activity生命周期WMS负责窗口的布局与运行时管理两者通过ActivityRecord和WindowToken互相咬合。这一篇我想重点聊聊窗口布局的计算链路以及窗口在运行过程中是怎么被WMS调度、刷新和管理的。这些内容虽然偏Framework但弄懂之后对排查分屏、折叠屏、弹窗错位、闪黑闪白这类问题会特别有帮助。顺带先纠正一个容易搜错方向的点网上搜“WMS”经常蹦出来仓储物流管理系统做Android的人第一次接触这个词很容易懵。Android里的WMS就是WindowManagerService和ERP、SRM、OSAT那套企业软件没有任何关系。接下来讲的都是窗口管理别被热词带偏。1. 开始之前先理清WMS在窗口布局中扮演的角色1.1 布局这件事到底谁说了算很多人有个误解觉得窗口的位置和大小是View系统自己算完再画出来。实际上App内部的那套measure、layout只是完成了“视图树内部”的尺寸分布最终窗口落在屏幕哪个位置、有多大、是否被状态栏遮挡、和别的窗口怎么叠放这些决定权在WMS手里。一个App窗口从创建到显示大致要经历这样的分工App进程里的ViewRootImpl负责把DecorView的测量、布局、绘制走完然后通过Binder向系统进程的WMS提交一个布局请求带上WindowManager.LayoutParamsWMS根据当前显示区域的边界、窗口类型、重力、系统窗口的位置等因素算出这个窗口最终的Frame再把结果返回给App进程。App拿到这个结果后才能确定Surface的尺寸和绘制区域。所以我的看法是理解WMS窗口布局重点不是去看App里那一大坨measure逻辑而是搞清楚“App提交的尺寸期望”和“WMS最终给出的Frame”之间的换算关系。这就像租房子App是房客提交自己的需求WMS是房东兼物业它不光要满足你的需求还要考虑整栋楼其他住户的位置最后敲定你实际分到哪一间。1.2 WMS管什么、不管什么为了后面不绕晕先把边界划清楚。WMS在布局这个环节里主要管以下几件事维护每个窗口的WindowState这是WMS侧对窗口的抽象描述窗口的requested尺寸、最终frame、可见性、window type都挂在这里。计算窗口的Frame也就是窗口在屏幕上的实际矩形区域。维护窗口层级决定谁在谁上面触摸事件往哪投。触发Surface的创建、重绘和销毁并把BufferQueue和SurfaceFlinger对接起来。在系统窗口状态栏、导航栏、输入法出现或消失时重新安排普通应用窗口的可用区域。WMS不管的事情也很明确它不负责具体的View绘制不关心Button的颜色、RecyclerView的复用逻辑也不主动替App做业务状态保存。它更像一个全局调度者只关心“窗口这个容器本身的几何和变换”。理解这条边界遇到问题就不会什么都往WMS上甩。2. 一次窗口布局请求的完整旅程2.1 从requestLayout到performTraversals窗口的布局变更起点几乎都是某段业务代码调用了View.requestLayout()。这个方法会沿着ViewTree向上冒泡到ViewRootImplViewRootImpl会执行scheduleTraversals()向Choreographer注册一个CALLBACK_TRAVERSAL类型的回调等下一个VSYNC到来时执行。很多刚接触的人以为scheduleTraversals之后马上就会布局其实这里已经埋了一个机制ViewRootImpl有个mTraversalScheduled标志位如果上一帧的traversal还没执行完再次scheduleTraversals会被直接忽略。也就是说一个VSYNC周期内多次requestLayout会被合并成一次遍历这个机制是保证UI不被打爆的重要基础。等VSYNC回调真正触发后performTraversals就开始跑。它内部至少会做三件事重新测量View树performMeasure、重新布局View树performLayout、触发绘制performDraw。但到这里窗口本身的位置和Surface的尺寸还没有最终确定。真正和WMS打交道的是performTraversals中段调用IWindowSession.relayout()的那一刻。2.2 跨进程布局请求relayoutWindow到底做了什么relayout()是一个同步Binder调用指向WMS的Session.relayoutWindow()。注意“同步”这个词很重要App进程会阻塞等待WMS返回结果。之所以必须同步是因为App需要从WMS拿到最终的Surface、窗口Frame以及可见区域Insets之后才能继续后面的绘制和BufferQueue提交。App传给WMS的信息包括WindowManager.LayoutParams、当前View可见性、已经准备好的SurfaceControl等。WMS在relayoutWindow里主要做这几件事更新WindowState里记录的requestedWidth、requestedHeight、gravity、flags等信息。根据当前DisplayContent的内容重新计算窗口Frame并更新窗口的Insets状态。如果Surface需要重建或尺寸变化会给App返回一个新SurfaceControl。把窗口是否已准备好显示、是否真的可见等信息打包进relayoutResult返回给App。这个环节最容易出现的一个坑是App端自己先算出期望尺寸WMS再按显示约束修正最后App拿回的Frame如果和App内部算的不一致绘制可能错位。典型的例子就是App没有正确处理WindowInsets在弹出输入法时只重算了View树却没有主动触发relayout导致窗口顶部被键盘顶起来底部又少了一块。2.3 WMS侧集中调度WindowSurfacePlacerWMS收到relayoutWindow之后会把布局请求交给WindowSurfacePlacer去执行最终的surface placement。记住这个类名它是WMS布局计算的调度中枢职责是遍历所有可见窗口把每个窗口的最终Frame和Surface状态落到DisplayContent的窗口列表里。WindowSurfacePlacer内部维护了一个mPendingLayoutChanges执行performSurfacePlacement时会循环检查这个变量直到所有待处理的变化都被消费完毕才退出。为什么要循环因为布局一个窗口可能会引起另一个窗口的变化比如系统状态栏切换为全屏模式后原本在它下面的普通应用窗口需要重新调整Frame。这种连锁反应必须一轮一轮地消化掉直到整个系统窗口布局达到稳态。一个很重要的经验是当你在System UI或普通App里频繁调用addView、updateViewLayout、控制状态栏显隐这类操作时会导致WMS侧的mPendingLayoutChanges持续不为0布局循环被反复触发CPU和主线程负载肉眼可见地上升。后面我会单独讲一个这种问题怎么排查。3. 窗口位置与尺寸是怎么被算出来的3.1 窗口frame的计算源DisplayFrames与WindowStateWMS计算窗口frame最底层的依据是当前DisplayContent里维护的DisplayFrames信息。DisplayFrames会记录当前显示区域里各种“边界”mUnrestrictedFrame全屏区域没有任何系统窗口遮挡限制。mSystemFrame扣除状态栏、导航栏之后普通应用窗口能用的区域。mStableFrame稳定区域横竖屏切换时也不变的区域。mDisplayCutout刘海屏的挖孔区域。普通Activity窗口默认落在mSystemFrame里。如果App设置了FLAG_LAYOUT_IN_SCREEN代码里叫layoutInScreen窗口就可以扩展到mUnrestrictedFrame此时内容延伸到全屏自己通过WindowInsets去避开状态栏。如果再加上layoutInDisplayCutoutMode设置为SHORT_EDGES或ALWAYS窗口内容就可以延伸到挖孔区域。WMS侧每个窗口都用WindowState来描述WindowState里除了对外暴露的Frame还保存着mRequestedWidth、mRequestedHeight、mAttrs等。在computeFrameLw这样的方法里WMS会把requested尺寸和当前选定的显示区域做合成最终写死窗口实际的mFrame。注意这里有一个关键点WindowState的mFrame一旦确定不仅影响窗口绘制区域还影响触摸事件命中测试。触摸点如果不在mFrame内可能被直接判给下层窗口。3.2 尺寸约束与gravity如何共同决定最终位置具体计算窗口位置时WMS会依据WindowManager.LayoutParams里的gravity字段。gravity指示窗口相对显示区域的哪个位置对齐常见的有Gravity.TOP、Gravity.CENTER、Gravity.BOTTOM等。坐标算法其实不复杂先算出窗口宽高在相应显示区域内的剩余空间再按gravity指示的方向把窗口放在左上角、居中或右下角之类的位置。举个实际例子。默认Dialog窗口的LayoutParams一般设置了Gravity.CENTERWMS计算时会先取出mSystemFrame作为可用区域再把Dialog请求的宽度和高度放进来用(可用宽 - 窗口宽) / 2这类方式算出x和y偏移。如果你在分屏环境里创建了一个Dialog却没有把它依附到分屏内的某个Activity而是用application context创建那么窗口可用区域会被WMS认为整个display的frame结果弹窗就可能出现在另一个分屏区域视觉上就是跑到屏幕外了。尺寸方面LayoutParams里宽高可以填WRAP_CONTENT、MATCH_PARENT或者具体数值。MATCH_PARENT在WMS侧会被解析为可用区域的对应尺寸WRAP_CONTENT则由App侧的View树自己测量。这两种模式的差异在横竖屏切换时特别容易暴露如果窗口用了具体像素宽高而不监听配置变化旋转后窗口frame依然按旧尺寸计算内容就会变形或出现黑边。3.3 层级(z序)与显示区域谁在上层、谁能显示所有窗口在WMS的DisplayContent中会按z序排列。WMS判断窗口层级的依据主要有两类一类是窗口类型比如系统错误窗口、IME窗口、应用窗口、壁纸窗口优先级从高到低另一类是WindowToken内维护的应用进程token顺序同一个App内多个Activity窗口再按启动顺序决定前后。z序决定的不只是谁盖住谁还有触摸事件的遍历顺序。WMS在派发触摸事件时会从最上层窗口开始往下找能接收事件的WindowState。如果一个窗口不可见但还挂在窗口列表里它可能会挡住底下可点窗口的事件这类问题是运行时管理里比较隐蔽的Bug来源。另外z序在分屏和多窗口模式下的含义会稍微变化。WMS会给每个DisplayContent设置对应的WindowingMode分屏主副窗口属于不同的Task窗口的Frame其实已经被限制在各自Task的Bounds里。此时如果你在代码里强行修改窗口的gravity或x/y偏移可能产生的结果就是窗口跑到TaskBounds之外直接被SurfaceFlinger裁剪掉表现为窗口“消失”或只显示一部分。3.4 特殊窗口多窗口、分屏与刘海屏多窗口环境下窗口布局最大的不同在于“可用区域不是全屏”。每个窗口的Frame被约束到TaskBounds内同时窗口的Insets状态会随着分屏线拖动实时变化。实测中常见的现象是分屏时弹出DialogDialog默认使用创建者Activity的TaskBounds但如果Dialog是单独起了一个Window且没有正确绑定DisplayArea就可能在错误的位置计算Frame。刘海屏对布局的影响更直接。默认手机竖屏时如果App没开layoutInDisplayCutoutModeWMS会自动把应用窗口限制在安全区域内避免内容被挖孔遮挡。但如果App做了沉浸式全屏又想铺满整个屏幕就必须显式设置LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS否则WMS仍然会按挖孔区域避开内容。这个参数一旦在运行时动态变化比如从普通模式切到Always模式WMS需要重新执行一次布局窗口Frame会变化通常这时候你会看到画面有一个明显的位置跳变属于正常现象。还有一种常见情况是折叠屏或平板切换时窗口从竖屏变成横屏或者从全屏变成分屏。系统会为窗口下发配置变更正常情况下ViewRootImpl会走一遍完整的relayout。但如果你在某层逻辑里拦截了配置变更同时又修改了窗口尺寸WMS侧就会陷入一种“Frame想变、App却拒绝变”的状态最终表现就是窗口显示异常或黑屏。这个问题在Android 12L的大屏适配中尤其多见。4. 运行时布局的管理与刷新4.1 添加、更新、移除窗口的运行时链路我们在应用层最常调用的是WindowManager.addView、updateViewLayout和removeView。这三个方法最终都会通过IWindowSession进入WMS。addView的过程大体是App进程创建一个ViewRootImpl把View和LayoutParams绑定然后调用session.addToDisplayWMS在这里会创建一个WindowState并挂到DisplayContent的窗口列表上同时分配SurfaceControl。这里要特别注意addView并不能立刻让窗口显示出来窗口真正的第一次绘制还要等下一次VSYNC触发App侧完成绘制并把BufferQueue里的Buffer交给SurfaceFlinger合成。所以“窗口创建了”和“窗口画出来了”之间是有时间差的。updateViewLayout则是把新的LayoutParams传给WMS更新WindowState里的attrs和requested尺寸并请求重新布局。开发者经常犯的错误是频繁调用updateViewLayout去改同一个flag比如切换全屏、切换状态栏颜色完全没有合并请求。实测中这种操作一方面会导致WMS侧反复计算布局另一方面也会打断Choreographer的绘制流水线造成肉眼可见的掉帧。removeView的链路同样走Binder到WMSWMS会先把WindowState从显示列表中移除再销毁对应的SurfaceControl。有一个经典问题如果在removeView之后还保留着对这个Window里的View的引用并试图修改它ViewRootImpl已被detach行为不可预期。另一种情况是removeView被调用两次第二次会抛异常。这些运行时生命周期问题看着是App的Bug但定位时往往要借助WMS的dumpsys日志来判断窗口是否真的被移除了。4.2 VSYNC、Choreographer与布局刷新的踩坑点窗口布局刷新和VSYNC是绑定的。ViewRootImpl每次执行scheduleTraversals都会向Choreographer注册回调Choreographer等下一个VSYNC信号到来时统一触发。这里有一个隐蔽问题如果某一帧的主线程任务过多Choreographer的回调被挤到很晚才执行VSYNC已经过去了这一帧就会被标记为掉帧。用户感知就是动画卡、窗口跳动。很多开发者在遇到这类问题时喜欢盯着SurfaceFlinger看但真正源头可能在主线程的relayout、measure这些任务上SurfaceFlinger只是没有拿到新Buffer而已。布局刷新过程中善用同步屏障和帧回调可以辅助排查。ViewRootImpl里会通过Choreographer.postCallback来安排绘制任务你可以在App侧通过Choreographer.FrameCallback自己记录每帧的时间戳和WMS侧的relayout时间做个对比。如果发现relayout一次耗时超过十几毫秒那就是窗口布局本身太重了优先查WindowState数量、布局层数和是否触发了不必要的Surface重建。4.3 运行时性能频繁relayout与卡顿定位WMS布局性能问题最典型的表现是“明明UI很简单但就是卡”。如果没有在代码里做过统计建议先用系统自带的FrameMetrics或者Perfetto抓一下。True Duty的比例一旦偏高可以先看relayoutWindow的调用频率。我遇到过一个实际案例某页面有一个透明浮层逻辑里根据网络状态不断updateViewLayout调整它的尺寸每次调整都触发WMS relayout。网络状态又频繁变化导致窗口在一秒内重算了十几次Frame。表面上看是动画卡本质上是布局请求被当成高频事件处理了。解决办法是把布局变化合并只在状态稳定后做一次更新或者在短时间内做节流。另一个值得注意的点是WMS relayout是同步Binder调用调用频率过高会直接拉长主线程阻塞时间。App侧调用一次relayout整个过程要经历一次完整Binder往返期间用户的所有触摸事件都会被延迟处理。所以对布局参数的更新宁可攒一攒也不要每次改一个标志就relayout一次。这个体验和数据库批量提交是一个道理。5. 三个实战问题排查记录从表象到根因5.1 分屏下弹窗跑到屏幕外的根因现象在折叠屏展开后的分屏模式里某个页面弹出了一个确认框弹窗没有出现在当前分屏区域内而是出现在了另一个分屏区域旁边甚至部分跑到屏幕外。排查过程先用dumpsys window windows看这个弹窗的WindowState重点看mFrame和mAttrs信息。结果发现它的requestedWidth、requestedHeight正常但mFrame已经超出了当前TaskBounds的范围。再看它的token对应的WindowToken类型发现它是用一个application context创建的没有绑定到当前activity的token。WMS在布局时找不到正确的ActivityRecord只能按全局display边界来布局于是弹窗跑偏。解决办法创建弹窗时不要用application context至少传入当前Activity的Context或者给LayoutParams设置正确的token。如果必须用application context创建系统级浮窗则要显式指定DisplayId并处理好bounds否则布局就无法对准目标显示区域。这个案例的教训是很多窗口布局问题表面上像是WMS计算错误实际是上层没有给WMS足够的定位信息。WMS再强也猜不到你想要的“上下文”。5.2 折叠屏切换后闪黑/闪白现象折叠屏从手机态展开成平板态某些页面会先闪一下黑屏再恢复到正常画面。这个闪现不是每次必现但频率挺高。排查过程这种问题先抓SurfaceFlinger的Buffer和WMS的窗口状态。打开Perfetto之后发现配置变更发生时WMS把旧SurfaceControl销毁了新SurfaceControl的BufferQueue还没有及时提交第一帧BufferSurfaceFlinger输出窗口上有一段时间是“无Buffer可显示”的状态于是用户看到了黑屏。这个问题的根因通常有两类。一类是App在配置变更时重建了Surface但没有做到像素级缓存导致第一帧内容非常晚。另一类是系统侧的SurfaceControl销毁和重建并不是原子操作存在间隙。App层面能做的是在窗口重建阶段尽量快速绘制第一帧或者用硬件缓存的Bitmap先顶住一帧系统侧则是要在WindowState切换Surface时做好Frame的承接避免中间出现空窗。闪白的情况本质上也是类似只是当时合成的Surface上还没有内容SurfaceFlinger默认填充了白色背景。比如Toast这种窗口App首帧没有及时来系统就会拿一个白色背景先顶着。如果你做的自定义浮窗出场时经常闪白可以考虑在addView时提前准备一个占位帧把首帧绘制时间提前。5.3 系统窗口反复触发布局导致CPU飙升现象某定制ROM上用户反馈系统桌面滑动时CPU占用明显偏高用Systrace看到WindowSurfacePlacer.force/performLayout频繁被调用一秒内出现十几次布局循环。排查过程直接看dumpsys window windows的输出会发现多个窗口都处于“请求布局”状态。进一步检索触发源发现有个SystemUI的窗口持续修改WindowInsets比如输入法状态、导航栏显隐状态被某状态栏插件反复设置。每一次设置都会使WMS重算所有窗口Frame连锁导致桌面、通知栏全部重新布局。解决办法分两层应用层避免无意义的系统UI标志切换系统层可以在WMS里对同一窗口短期内频繁的layout请求做节流或者配合DisplayPolicy的mSystemBooted状态判断在系统启动早期不要频繁做布局重置。日常开发中遇到这类问题先不要急着改WMS用dumpsys window看看是哪个窗口在不停“变脏”定位到源头才是最有效的。这个案例说明窗口布局的运行时管理很多时候不是“某一次布局算错了”而是“布局被不必要的重复触发”。优化思路也从“改对”变成了“减少重复”。6. 排查WMS窗口布局问题的常用手段6.1 命令行三板斧wm、dumpsys window、SurfaceFlinger查窗口布局和运行时状态我日常工作里最依赖的还是下面几条命令其实穿起来就是三板斧。# 查看当前显示尺寸与密度信息 adb shell wm size adb shell wm density # 查看窗口相关完整信息 adb shell dumpsys window windows adb shell dumpsys window displays adb shell dumpsys window policywm命令主要用于确认系统认为的屏幕尺寸和density。窗口frame计算错误时第一步基本就是用它确认逻辑分辨率有没有被某个配置改掉。比如有些设备误设置了overridesize导致明明屏幕是1080宽系统却以为有1440宽窗口布局自然就乱套。dumpsys window windows是所有窗口布局排查的核心。输出内容里重点看这几个字段WindowState里mRequestedWidth、mRequestedHeight是App侧的期望尺寸。mFrame是WMS最终算出的窗口frame。mDisplayFrame是显示区域。mViewVisibility和mWindowRemovalAllowed代表窗口可见与可移除状态。如果mFrame和mDisplayFrame有明显出入基本可以断定是窗口布局边界出了问题。dumpsys window policy则可以看到系统窗口、InputMethod窗口等全局策略相关的状态排查输入法或导航栏引起的布局变化会用到。# 查看SurfaceFlinger合成与Buffer队列状态 adb shell dumpsys SurfaceFlinger --latency这条命令能看SurfaceFlinger侧的帧延迟数据配合dumpsys gfxinfo可以判断丢帧是发生在App渲染阶段还是发生在合成阶段。大部分闪黑闪白问题都可以用它确认SurfaceFlinger是否长时间没有收到合格Buffer。6.2 Android Studio工具怎么配合使用Android Studio里的Layout Inspector可以看View树和每个View的bound主要用于确认App内部视图树布局是否正确。但它看不了WMS侧的Frame边界所以当App内部的layout全部正常、窗口却显示错乱时不要指望Layout Inspector能救你赶紧去和dumpsys window windows对照。Profiler里的CPU和UI渲染数据可以用来观察主线程的layout和draw耗时。如果你怀疑某个布局变化导致频繁relayout先在Profiler里看Main Thread的ActivityThread relaunchActivity或ViewRootImpl relayout相关方法调用频率。不过Profiler对系统Framework方法的采样频率有限定位深层问题还是要靠Perfetto或Systrace。如果是纯应用层验证也可以自己在代码里用FrameMetrics打点window.addOnFrameMetricsAvailableListener({ _, frameMetrics, _ - val duration frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) // 记录duration超过16ms就是卡顿候选 }, mainExecutor)这样能拿到App侧每一帧的耗时统计再把耗时高和WMS dumpsys里布局变化的高峰时间对一下基本就能锁定是不是布局触发的卡顿。6.3 一个实用技巧在关键节点打窗口debug信息很多窗口布局Bug是偶现的今天复现明天复现不了。此时单纯靠外置工具抓不到现场需要在业务代码里加上关键观测点。我习惯在三个位置加日志addView成功后立刻读一次ViewRootImpl的mWidth和mHeight看App侧期望的窗口大小。在View的onGlobalLayout回调里读取DecorView在屏幕上的实际位置和WMS最终Frame做比对。在onWindowFocusChanged里记录窗口获得焦点的时间点结合relayout日志判断窗口创建的耗时。这些日志并不需要长期保留但偶现问题爆发时它们能帮你在第一时间定位出窗口布局是哪个环节出的错。实测下来只要在这三处打了点90%的窗口错乱问题都能快速缩小范围剩下10%再去翻dumpsys SurfaceFlinger。另外对于一个已经稳定运行的业务我也建议在重大版本里接入一次FrameMetrics的卡顿上报把卡顿发生的页面和卡顿时段内的relayout次数一起上报。很多时候WMS层面其实没问题是应用自己发出了过多的布局请求这种统计数据是最好的说服材料。7. 写在最后窗口布局和运行时管理说到底是理解一套规则加一套时序。规则是WMS怎么把App的LayoutParams换算成最终Frame时序是布局请求什么时候到WMS、WMS什么时候回结果、SurfaceFlinger什么时候合成。这套机制平时不出问题一出问题就是那种“看起来是View的问题但View代码怎么改都没用”的硬骨头。我个人在实际排查中的体会是不要一上来就扎进源码先按dumpsys window windows、wm size、SurfaceFlinger --latency这个顺序把现场证据拿全。窗口frame、显示区域、Buffer状态这几个数值一对上问题原因基本就浮出水面了。剩下的才是去WMS源码里确认具体计算分支。如果你正在做多窗口、折叠屏适配或者被某个顽固的闪黑/错位问题折磨希望这篇能给你一个相对完整的排查路径。踩过几次坑之后再回头看你会发现WMS并没有那么神秘它只是在严格执行一套全局布局规则而我们要做的是学会在这套规则下正确表达“我想让窗口出现在哪里”。