做系统定制这些年我遇到过最多的需求就是“让第三方 App 的布局按我们的规矩来”。比如某个视频 App 横屏时弹幕被系统手势条挡住或者某个工具类 App 在折叠屏上被拉伸得比例失调。常规做法是一个一个加反射、写 Hook累得半死还挡不住 App 升级。直到我把目光放到 Android Framework 的 FrameLayout.onMeasure 上事情才变得简单起来。这篇文章适合正在做 ROM 定制、SystemUI 适配、大屏/折叠屏兼容的开发者也适合想彻底搞懂 View 测量原理的 Android 应用开发。核心思路不复杂大部分第三方页面的根布局都是 FrameLayout 或它的子类而根布局的测量规格最终决定整棵视图树的尺寸约束改这一个入口就能控制第三方 App 的全局布局特征。我会把方案选型、源码机制、实操步骤和踩坑记录都写出来尽量让没有 Framework 经验的读者也能照着动手。1. 为什么把突破口选在 FrameLayout.onMeasure1.1 第三方 App 布局问题到底发生在哪一环一个 View 从创建到显示要经历 measure、layout、draw 三个阶段。错误可能发生在任意一环但我做过的第三方 App 布局适配里九成以上都出在 measure 阶段。原因很简单measure 决定的是“这个 View 应该有多大”后续 layout 只能在这个结果上摆位置draw 只能在这个结果上画内容。尺寸一旦错了后面再怎么调 gravity、调 margin 都是治标不治本。举个例子。某第三方 App 在 21:9 全面屏上横屏播放时底部控制条会伸进手势导航区域。定位到最后发现是它的根 FrameLayout 在测量阶段拿到的可用高度包含了整个屏幕完全没有考虑系统导航栏占用的空间。这个时候你去改它的 XML 布局没有任何意义因为布局文件里写的是 match_parent真正决定 match_parent 到底匹配多少像素的是父容器传递下来的 MeasureSpec。父容器给的规格不对子 View 怎么适配都是白搭。所以我的思路是不跟具体某个控件较劲直接盯着 measure 阶段的入口做文章。第三方 App 的视图树再复杂也逃不过根节点那一次测量。1.2 为什么是 FrameLayout从 DecorView 到普通页面的结构事实先看一个很容易被忽略的结构事实每个 Activity 的窗口根视图 DecorView它的父类就是 FrameLayout。也就是说不管第三方 App 页面内部用的是 LinearLayout、ConstraintLayout 还是别的什么窗口第一层一定是 FrameLayout。这意味着在 Framework 层修改 FrameLayout 的测量逻辑等于给所有 Activity 加了一道统一的“关卡”。再看页面内部。很多第三方 App 为了性能和简单页面根布局就是 FrameLayout或者在 Fragment 的容器、Dialog 的 contentView 里大量使用 FrameLayout。就算页面换成其他 ViewGroup作为根节点的 DecorView 那一层 FrameLayout 始终存在我们依然有机会在最外层做控制。为什么偏偏是 FrameLayout 而不是别的 ViewGroup 作为默认窗口容器因为它足够简单。FrameLayout 的子 View 之间没有复杂的相对约束关系测量逻辑就是遍历子 View、找出最大宽高、再按 padding 和 spec 约束出最终尺寸。简单意味着稳定也意味着修改它的时候不容易碰坏框架自身的逻辑。如果换成 RelativeLayout光是想清楚它的测量依赖关系就够写一篇论文了。1.3 定制 onMeasure 和其他方案的取舍很多人第一反应是“直接 Hook 掉这个 App 的 LayoutParams”或者用反射改它的根布局 gravity。这些方案不是不能用而是非常脆。App 一发新版本混淆规则一变反射字段可能就找不到了Hook 框架也只是在某个进程里生效做不到系统级统一。WindowManager.LayoutParams 这条路我也试过。它能改窗口级别的大小、位置和 gravity但它管不到窗口内部的布局。比如你想让某个 App 的内容从左边缩进 20dp只调 LayoutParams 的 width 是做不到的它只会把整个窗口缩小窗口里面的内容还是按照自己的布局规则来。想精确控制“窗口内部”的测量行为还是得回到 ViewGroup 的测量逻辑上。还有一个常见方案是给第三方 App 做 overlay 或者用辅助功能去移动窗口这种治标不治本的办法我基本不用。对比下来修改 FrameLayout.onMeasure 的优势在于生效范围广、不依赖 App 内部实现、不会被版本更新击穿、还能按包名做精细化控制。下面这张表是我在实际项目里的横向对比。方案生效范围抗版本更新能力实现成本可控制粒度反射改 LayoutParams单窗口差混淆后易失效中窗口级Hook 框架单进程中依赖框架维护高任意 View 级WindowManager 参数单窗口较好低窗口级修改 FrameLayout.onMeasure系统全局极好源码层生效中高ViewGroup 级选 onMeasure 本质上是在“通用性”和“可控性”之间找一个平衡点。它比反射稳比 Hook 简单虽然粒度不如单 View 级那么精细但对付“全局布局偏了”“内容越界了”“宽高比例不对”这类问题已经足够。2. 动手前必须吃透的 FrameLayout.onMeasure 机制2.1 onMeasure 的参数本质MeasureSpec 三件套onMeasure 的两个参数看起来是两个 int实际上是压缩包高 2 位是模式低 30 位是尺寸。这个设计叫 MeasureSpec是 Android 视图系统里最核心、也最容易被新手忽略的概念。三种模式要刻在脑子里。EXACTLY 表示父容器给定了精确尺寸子 View 的 match_parent 和具体 dp 值都会换算成这种模式AT_MOST 表示子 View 最大不能超过某个值wrap_content 就是这种模式UNSPECIFIED 表示父容器不做任何限制常见于 ScrollView、RecyclerView 测量子 View 的时候。拿生活场景类比EXACTLY 像是学校分配的固定座位长宽都定死了AT_MOST 像是“最多坐 50 人”你想坐 30 人没问题坐 55 人就不行UNSPECIFIED 像是空教室你想怎么摆就怎么摆。理解这个之后你再看 onMeasure 的定制空间就清晰了FrameLayout 在 super.onMeasure 里会把收到的这两个 spec 拆开、解析、再传递给每一个子 View 的 measure 方法。所以最优雅的修改方式是在调用 super.onMeasure 之前把 widthMeasureSpec 和 heightMeasureSpec 这两个“总闸门”先调整到我们想要的状态。2.2 FrameLayout.onMeasure 源码执行流程不同 Android 版本的 FrameLayout.onMeasure 源码细节略有差异但核心流程非常稳定大致是这样Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int count getChildCount(); int maxHeight 0; int maxWidth 0; int childState 0; for (int i 0; i count; i) { final View child getChildAt(i); if (mMeasureAllChildren || child.getVisibility() ! GONE) { measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0); final LayoutParams lp (LayoutParams) child.getLayoutParams(); maxWidth Math.max(maxWidth, child.getMeasuredWidth() lp.leftMargin lp.rightMargin); maxHeight Math.max(maxHeight, child.getMeasuredHeight() lp.topMargin lp.bottomMargin); childState combineMeasuredStates(childState, child.getMeasuredState()); } } maxWidth getPaddingLeftWithForeground() getPaddingRightWithForeground(); maxHeight getPaddingTopWithForeground() getPaddingBottomWithForeground(); maxWidth Math.max(maxWidth, getSuggestedMinimumWidth()); maxHeight Math.max(maxHeight, getSuggestedMinimumHeight()); setMeasuredDimension( resolveSizeAndState(maxWidth, widthMeasureSpec, childState), resolveSizeAndState(maxHeight, heightMeasureSpec, childState MEASURED_HEIGHT_STATE_SHIFT)); }注意几个关键点。第一FrameLayout 对每个子 View 调用的是 measureChildWithMargins它会用 getChildMeasureSpec 把父 spec 和子 View 的 LayoutParams 合并成子 View 自己的 spec。第二FrameLayout 最终的测量尺寸是“所有子 View 中最大的那个 padding”然后经过 resolveSizeAndState 与父 spec 求交集。第三如果子 View 是 GONE默认不参与测量除非设置了 mMeasureAllChildren。这段源码给我们的启示是只要在 super.onMeasure 之前修改传入的 spec就能影响“所有子 View 的可用尺寸”只要在 super.onMeasure 之后、返回之前修改 setMeasuredDimension 的值就能直接篡改 FrameLayout 自身的测量结果。这两种手段对应完全不同的适用场景后面实战部分我会分别演示。2.3 修改 FrameLayout.onMeasure 的三个“下手位置”根据我测试下来的经验对 onMeasure 的修改有三个位置各有各的适用范围。第一个位置是“入口改 spec”也就是在 super.onMeasure 调用之前替换 widthMeasureSpec 和 heightMeasureSpec。这个位置最适合做“全局限宽”“全局加内边距”“限制可用高度”这类操作。优点是改动最小、最不容易出错因为 FrameLayout 原有遍历和状态合并逻辑都保留了缺点是你只能影响 FrameLayout 的子 View如果 FrameLayout 自己是根布局且需要改变自身在窗口中的尺寸这个位置还不够。第二个位置是“循环里改子 View 的测量方式”比如不调用 measureChildWithMargins而是手动给某个子 View 构造一个特殊的 MeasureSpec 再调用 child.measure。这个位置适合针对“特定子 View”做定制比如识别到某个广告容器或者某个弹窗根布局给它单独设置最大尺寸。实现时要小心别漏了合并 MeasuredState 和保存 measuredWidth/measuredHeight否则容易出诡异问题。第三个位置是“改最终测量结果”也就是在 super.onMeasure 之后调用 setMeasuredDimension 传入我们自己算好的宽高。这个位置适合强制改变 FrameLayout 自身的大小比如把弹窗内容的根布局强制限制成屏幕的一半。风险也最明显如果 FrameLayout 是 DecorView 这种直接受 ViewRootImpl 管理的根视图强行改变测量尺寸可能导致窗口尺寸和 WMS 记录不一致后面 layout、触摸事件都会出问题。我的建议是优先用第一个位置实在不行再用第三个位置。能用“改变约束”解决的问题就不要用“打破结果”的方式硬来这个原则能帮你少踩很多坑。3. 实战改源码、编模块、看效果3.1 准备 AOSP 编译环境与定位文件实战的第一步是准备一个能编译 Framework 的环境。正规做法是在 Ubuntu 上搭 AOSP 源码编译环境或者直接拉厂商提供的 ROM 源码。如果你手头只有一个已发布的镜像也可以尝试用 apktool 回编译 framework 文件但流程复杂、兼容性差不太适合做系统性布局适配。源码里需要修改的文件路径是固定的frameworks/base/core/java/android/widget/FrameLayout.java。这个文件从 Android 2.x 到 14 都在目录一直没变过倒是省了不少定位功夫。编译目标用 make framework 就行不过要提醒一句Android 9 之后 framework 代码会被打进 boot image只替换 framework.jar 不一定会生效不同版本会有些差异以你当前源码版本的构建说明为准。如果只是想在本地快速验证修改逻辑完全不需要先刷机。我一般会在 Android Studio 里建一个普通 App 工程自定义一个 MyFrameLayout extends FrameLayout然后把要验证的 onMeasure 改动写进去用测试页面跑一遍。逻辑验证没问题了再移植到 Framework 源码里编译。这套流程能省掉大量刷机重启的时间。3.2 实战需求把第三方弹窗限制在安全宽度内我要演示的第一个需求是某个第三方 App 的广告弹窗在平板上会拉伸到几乎满屏看起来非常突兀我们希望把它限制在最大 480dp 宽度内。这类弹窗的内部容器十有八九是 FrameLayout而且它作为 Dialog 的 contentView尺寸是跟随内容测量结果的非常适合用入口改 spec 的方式处理。第一步先在自定义 FrameLayout 里验证核心逻辑public class CompatFrameLayout extends FrameLayout { private int mMaxWidthPx; public CompatFrameLayout(Context context, AttributeSet attrs) { super(context, attrs); mMaxWidthPx (int) (480 * getResources().getDisplayMetrics().density); } Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode MeasureSpec.getMode(widthMeasureSpec); int widthSize MeasureSpec.getSize(widthMeasureSpec); int maxWidth Math.min(widthSize, mMaxWidthPx); if (widthMode MeasureSpec.EXACTLY widthSize maxWidth) { widthMeasureSpec MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.EXACTLY); } else if (widthMode MeasureSpec.AT_MOST) { widthMeasureSpec MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST); } super.onMeasure(widthMeasureSpec, heightMeasureSpec); } }这段代码的关键在于重新构造了 widthMeasureSpec。原来的 spec 是平板的真实宽度比如 1280px代表父容器要求 FrameLayout 撑满现在改成 480dp 对应的像素值模式保持 EXACTLY。super.onMeasure 内部会把新的 spec 传给所有子 View那些 match_parent 的子 View 就会跟着变成 480dp 宽。为什么这里要保留 EXACTLY因为弹窗根布局通常被 Dialog 设置为 match_parent如果改成 AT_MOST某些子 View 的 wrap_content 行为会变得奇怪。保留 EXACTLY 可以确保宽度是被“精确限制”的而不是“建议限制”行为更可预测。验证没问题后移植到 Framework 源码时我会在 FrameLayout.java 里加一个静态配置入口再读取 SystemProperties 控制开关和数值。这样即使系统里所有 FrameLayout 都会走到这段逻辑也只有在属性开关打开时才生效不会影响默认行为。3.3 实战需求给所有第三方页面加全局内边距第二个需求更常见某第三方 App 在刘海屏上顶部标题栏会被刘海区域裁掉或者在某些特殊屏幕上页面内容的边缘总是贴到屏幕边上我们希望全局给它加一个安全内边距。这个需求同样可以用入口改 spec 的方式做但思路稍微不一样。这次不是限制最大宽度而是“同时压缩左右上下的可用空间”实现后子 View 的可用区域自动往中间收缩。Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (isThirdPartyCompatEnabled()) { int horizontalInset getSafeHorizontalInset(); int verticalInset getSafeVerticalInset(); int widthSize MeasureSpec.getSize(widthMeasureSpec); int widthMode MeasureSpec.getMode(widthMeasureSpec); int heightSize MeasureSpec.getSize(heightMeasureSpec); int heightMode MeasureSpec.getMode(heightMeasureSpec); int newWidth Math.max(0, widthSize - horizontalInset * 2); int newHeight Math.max(0, heightSize - verticalInset * 2); widthMeasureSpec MeasureSpec.makeMeasureSpec(newWidth, widthMode); heightMeasureSpec MeasureSpec.makeMeasureSpec(newHeight, heightMode); } super.onMeasure(widthMeasureSpec, heightMeasureSpec); }我实际测试下来这种方案对绝大多数第三方页面都能生效因为页面内容通常是从根布局开始的根布局的可用区域一缩小内部所有控件都会跟着内缩。但有一个例外如果第三方 App 自己又写了强制匹配屏幕的代码比如在 onResume 里手动设置 LayoutParams 的宽高为屏幕尺寸那它会在页面加载后把测量结果覆盖掉。这种情况属于 App 主动对抗系统单靠改 onMeasure 是压不住的只能在其他环节配合处理。这个方案在应用到 Framework 层时有两个细节需要注意。第一安全内边距的数值要按 density 换算不同屏幕密度下同样 dp 对应的像素不一样。第二如果同时处理了宽和高要小心 Dialog 类型窗口的高度计算因为 Dialog 的高度还受 WindowManager 参数和软键盘状态的共同影响强压高度容易导致弹窗位置异常。3.4 编译、替换与验证的完整路径在本地验证好修改逻辑后就可以走正式流程了。AOSP 环境下先执行 source build/envsetup.sh然后 lunch 你设备的 target最后 make framework -j$(nproc)。编译产物在 out/target/product/ /system/framework/ 下。真机替换环节调试阶段我建议用可解锁 bootloader 的设备配合 adb root adb remount直接把新的 framework 文件 push 进去然后重启 system_server 来验证。这样不用完整刷机效率高很多。切记操作前备份原始文件Framework 出了问题还能 fastboot 救回来。验证方式不能只看“是不是不越界了”要分三层看。第一层看测量日志在 onMeasure 里临时加一句 Log.d 输出收到的 MeasureSpec 和最终 setMeasuredDimension 的值确认数字符合预期。第二层看布局边界打开开发者选项里的“显示布局边界”能直观看到实际生效的布局轮廓。第三层看交互改完之后不仅要看外观还要实际点一点弹窗按钮确认触摸热区没有跟着跑偏。触摸热区是很多人会漏掉的一个点只管视觉效果、不管点击区域改完布局后按钮点不动那才是真的灾难。4. 常见问题与排查技巧实录4.1 改了 onMeasure 之后第三方 App 白屏或布局错乱这是我在 Framework 层改布局时遇到最多的故障九成原因是把 DecorView 层级的测量结果强行改掉了。DecorView 是窗口根视图ViewRootImpl 会拿它测量后的尺寸去跟 WindowManager 同步窗口大小。如果你在 setMeasuredDimension 里塞进去一个和窗口不一致的宽高整个窗口的布局都会变得诡异起来严重时直接白屏。解决办法是分清“改 FrameLayout 自身尺寸”和“改 FrameLayout 子 View 尺寸”。对 DecorView 这种根视图尽量只改传入的 spec让子 View 跟着缩但 DecorView 自身保持 match_parent 的行为。如果确实需要把整个窗口缩小不要走 setMeasuredDimension应该去改 ViewRootImpl 的 performMeasure 入口或者直接改 WindowManager.LayoutParams。这是另一个话题改对了位置才不会被反噬。4.2 只对部分 App 生效或完全不生效这种问题排查思路很简单先确认目标页面里到底有没有 FrameLayout 在起作用。有些第三方 App 的页面根布局是 LinearLayout 或 ConstraintLayout尽管窗口根视图 DecorView 是 FrameLayout但如果我们的修改逻辑是对“所有 FrameLayout 子 View”生效通常还是能覆盖到的。真正的例外是页面整体放到了一个自定义 ViewGroup 里且这个自定义 ViewGroup 自己重写了 onMeasure完全无视父容器传递的 spec。发生这种情况时我们只能去这个自定义 ViewGroup 的层面做处理。还有一个容易被忽略的原因是Android 不同版本的 FrameLayout.onMeasure 源码有差异尤其是一些大版本里加入了对 foreground 和 layout_mode 的处理。如果你的修改是直接复制旧的整段 onMeasure 代码在新版本上很可能会丢掉新增逻辑。每次升级系统版本都要重新 diff 一下官方的 FrameLayout 源码不要抱着旧代码不放手。4.3 测量改了但状态栏、刘海屏区域还是被内容遮挡这个问题说明你只改了测量没处理 WindowInsets。onMeasure 阶段执行顺序是早于 insets 分发的当我们把子 View 的可用区域缩小时FrameLayout 并不知道刘海屏和系统栏具体占了多少空间。如果目标 App 没有主动适配 insets内容依然会顶到屏幕边缘。常规做法有两条路。一条是在修改 spec 时直接按固定值预留空间比如从 DisplayCutout 的 safeInset 里读取安全区数据把这个值当作内边距的一部分。另一条是在 dispatchApplyWindowInsets 里给子 View 统一 setPadding这在某些场景下比改 measure 更精确。两条路可以配合用onMeasure 管总宽度和总高度insets 负责减去真实系统栏区域。4.4 全局修改误伤系统应用和系统 UI这是 Framework 层开发最容易翻车的地方。FrameLayout 在系统里的使用量极其恐怖通知栏、最近任务、设置界面到处都是。如果我们不加区分地给所有 FrameLayout 套用第三方适配逻辑最先坏掉的可能不是第三方 App而是系统自己。我处理这类问题的标准做法是加包名白名单。在 onMeasure 里通过 ActivityThread.currentActivityThread().getApplication() 拿到当前进程的包名判断是否命中适配列表。不过这种判断在极端场景下会有性能开销所以我会把匹配结果缓存到进程级静态变量里避免每个 FrameLayout 每次测量都去查一遍。场景白名单匹配方案缓存策略普通 Activity 页面缓存当前应用包名进程启动时初始化多进程 App每个进程独立判断按进程名缓存系统 UI不加入白名单只读不缓存这个方法并不能解决所有问题。比如某些第三方 App 用了多进程白名单判断可能要分进程处理再比如有些 App 的首页和支付页逻辑不一样可能需要按 Activity 名做二次过滤。这些问题没有银弹只能在实际适配过程中慢慢积累经验。5. 最后再分享两个小技巧第一个技巧是“先在本地工程验证再上源码”。很多同学一上来就改 AOSP编译一次要半小时起步调试一次要重启一遍设备效率非常低。我现在的习惯是先在 Android Studio 里用自定义 ViewGroup 把逻辑写清楚确认测量数值、边界情况都符合预期再迁移到 Framework。这样把“逻辑调试”和“系统集成”两个阶段彻底分开每次编译 Framework 都是在验证集成问题而不是验证逻辑问题。第二个技巧是“改完一定要看测量日志而不是只看视觉效果”。视图树在屏幕上呈现出来的样子是 measure、layout、draw 三个阶段叠加的结果很多时候你凭肉眼觉得“差不多了”实际上测量出来的数值和你设计的目标差了十万八千里。只要在 onMeasure 里加一行日志把每个关键 FrameLayout 的 spec 和最终尺寸打出来很多问题立刻就能定位。我个人在做这类系统级修改时最大的体会是动手前先想清楚要定制的是“尺寸”还是“位置”。只改 onMeasure 能解决的是尺寸问题位置问题得配合 onLayout、gravity 和 WindowInsets 一起想。很多人栽跟头就是因为在一个环节里硬扛所有需求结果越写越复杂最后自己都收不了场。想清楚边界你的布局定制方案才能真正扛得住第三方 App 一轮又一轮的更新。