先把结论放在前面:软键盘适配的正确解法是"监听 IME 的 insets,按需消费",而不是猜键盘高度或依赖全局的 windowSoftInputMode。 键盘不是一个固定高度的对话框,它的高度由输入法类型、候选栏、方向、安全区共同决定,任何"按固定值上移布局"的方案在某些输入场景下都会错位。
这题面试里的分水岭:答"用 adjustResize,然后 getWindowVisibleDisplayFrame 减一下"属于旧做法;能答出"为什么 adjustResize 在全屏/沉浸式下失效、insets 动画要自己跟、Compose 用 imePadding 而不是 padding",就是机制层的答案。
为什么旧方案会失效
| 方案 | 失效场景 | 根因 |
|---|---|---|
adjustResize |
全屏主题、FLAG_LAYOUT_NO_LIMITS、Compose 透明容器 |
系统不裁剪窗口,无可调整空间 |
getWindowVisibleDisplayFrame |
沉浸式、多窗口、动画过程中 | 拿到的是窗口可见区,不是键盘高度 |
| 固定键盘高度(如 260dp) | 不同输入法、候选栏、横屏 | 键盘高度不固定 |
translationY = -keyboardHeight |
动画跳变、焦点跳转错位 | 无动画跟随,无焦点联动 |
adjustResize 的本质是"系统帮你在窗口层面腾出空间"——它会调整窗口的可见区域并触发一次布局。但全屏主题下窗口已经占满屏幕,系统无从裁剪,于是这个开关形同虚设。 这条是这题的核心机制。
现代解法:insets 分发与消费
Insets 是一套"系统告诉应用某个区域被系统 UI 占用了多少"的机制。键盘的占位是其中一类:
// View 体系:监听 insets,按需消费
ViewCompat.setOnApplyWindowInsetsListener(binding.root) {
v, insets ->
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val navInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val initial = insets.getInsets(WindowInsetsCompat.Type.systemBars())
// 只关心"键盘相对底部露出的高度"
val imeBottom = imeInsets.bottom
// 消费掉,防止继续向下分发(例如让 WebView 不再重复处理)
ViewCompat.onApplyWindowInsets(
v, WindowInsetsCompat.CONSUMED
)
binding.inputBar.updatePadding(bottom = imeBottom)
v.layoutParams.height = initial.top + imeBottom
insets
}
// Compose:imePadding 才是对的 Modifier
Column(
Modifier.fillMaxSize()
.consumeWindowInsets(WindowInsets.ime) // 消费,避免子组件重复处理
) {
LazyColumn(Modifier.weight(1f)) {
/* 内容 */ }
BottomBar(
Modifier.imePadding() // 键盘弹出时自动加底部内边距
) {
submit() }
}
imePadding() 与 padding(bottom = imeHeight) 的差别是"是否消费 insets":padding 只是加了一段固定内边距,insets 仍会继续分发给子组件,导致子组件(如列表)也重复避让,出现"内容被顶两次"。imePadding 内部会消费掉这部分 inset,保证同层组件不会重复响应。 这条是这题最实用的细节。
注意 navigationBars() 与 ime() 的叠加关系:键盘弹出时导航栏可能仍在,两者在部分机型上不重叠、直接相加会多留一段空白。规范是用 WindowInsets.ime.union(navigationBars) 或按版本分别处理,不要盲目相加。
动画跟随:Insets 动画与预测性返回
键盘弹出/收起是有动画的,如果布局直接跳到最终高度,用户会看到明显的"跳一下"。两条处理路径:
// 路径一:给 insets 动画时长,让系统补间(API 30+)
WindowCompat.setDecorFitsSystemWindows(window, false)
// 侧栏滑入等场景用动画时长
ViewCompat.setWindowInsetsAnimationCallback(
binding.root,
WindowInsetsAnimation.Callback().apply {
override fun onPrepare(animation: WindowInsetsAnimation) {
val ib = animation.typeInsets(WindowInsetsCompat.Type.ime())
binding.inputBar.updateLayoutHeight(ib.bottom)
}
}
)
// 路径二:自己驱动进度(Compose)
val imeBottom by animateDpAsState(
targetValue = if (imeVisible) imeHeight else 0.dp,
animationSpec = tween(durationMillis = 250)
)
规范是目标高度直接用 insets 值(保证最终位置精确),动画只负责过渡过程;不要用动画去"猜"键盘高度。能提到预测性返回(predictive back)与手势返回动画需要同一套 insets 动画回调,说明跟上了新版本变化。
焦点:滚动到可见与自动弹出
// 键盘弹出后把焦点输入框滚到可见区域
binding.editText.doOnFocusChanged {
_, hasFocus ->
if (hasFocus) binding.scrollView.postDelayed({
binding.scrollView.smoothScrollTo(0, binding.editText.bottom)
}, 300) // 等键盘动画基本完成再滚
}
焦点相关的三个必答点:① 首次进入页面自动弹键盘要主动 requestFocus() + showSoftInput,不能指望框架自动弹;② 滚动时机要在键盘动画之后,否则滚动位置基于旧布局计算,表现为"输入框被键盘挡住一半";③ 输入框在 ScrollView 里,要保证它所在的祖先链是 NestedScrollView 或 ScrollView,否则 requestChildRectangleOnScreen 不会生效。
Compose 侧的对应做法:Modifier.focusRequester(fr) 配合 LaunchedEffect 调 fr.requestFocus(),滚动用 bringIntoViewRequester。注意 Compose 的自动滚动行为由 bringIntoView 策略控制,不同版本默认行为不同,实战中常需要显式指定。
最常见的坑是
第一层坑:adjustResize 在全屏主题下失效,布局被键盘遮挡。
根因是系统没有可裁剪的窗口空间。规范是用 insets 监听替代 windowSoftInputMode 的猜测;windowSoftInputMode 只保留 adjustNothing 之类的辅助语义。
第二层坑:键盘动画期间布局跳变,未跟随 insets 动画。
表现是"键盘弹出的瞬间内容先跳一下再停稳"。规范是用 setWindowInsetsAnimationCallback(View 侧)或 animateDpAsState(Compose 侧)跟随动画过程。
第三层坑:内容底部被顶两次(列表与底栏各避让一次)。
根因是同一份 insets 被多层组件重复消费。规范是在容器层 consumeWindowInsets(WindowInsets.ime),内层用 imePadding()。
这题在面试里怎么问、怎么答
问:adjustResize 是怎么工作的?什么情况下会失效?
答:机制是系统在键盘弹出时调整窗口的可用区域并触发重新布局,App 侧再通过监听配置变化或可见区域变化来调整布局。失效场景有三类:全屏/沉浸式主题(窗口已占满屏幕,无可调整空间)、FLAG_LAYOUT_NO_LIMITS、以及 Compose 的透明容器。根因是"没有可裁剪的窗口空间",所以正确的解法不是继续调 windowSoftInputMode,而是改用 insets 机制。
问:insets 机制是什么?有哪些类型?
答:Insets 是系统把"某块区域被系统 UI 占用多少"分发给应用的机制。常见类型有 systemBars(状态栏与导航栏)、ime(键盘)、displayCutout(刘海)、systemGestures(手势条)、mandatorySystemGestures。应用按类型读取,并用 consume 决定是否继续向下分发。能说出"消费"这个概念,就说明理解了这套机制的设计意图——避免多层重复避让。
问:Compose 里 imePadding() 和 padding(bottom = ime) 有什么区别?
答:imePadding() 会消费对应的 insets,保证同层子组件不会重复避让;padding() 只是加固定内边距,insets 继续下发,可能出现内容被顶两次。另外 imePadding 的值随键盘动画自动变化,无需自己算。再补一条:两者不要叠加使用。
问:键盘弹出后输入框被挡住了,你怎么处理?
答:处理链路是"焦点变化 → 键盘动画 → 滚动到可见"。三个要点:① 滚动时机在键盘动画基本完成之后(或用 WindowInsetsAnimationCallback 在 onPrepare 里按最终 insets 调整);② 祖先链必须是可滚动的 NestedScrollView/ScrollView,否则自动滚动不生效;③ Compose 侧用 bringIntoViewRequester 时要显式指定滚动策略,因为不同版本默认值不同。
问:全屏页面怎么做键盘适配?
答:不用 adjustResize,改为 setDecorFitsSystemWindows(window, false) + 监听 ime insets + 自行消费。全屏场景下系统不替你腾空间,所有避让都要自己算,这也是为什么 insets 机制在这类场景里是唯一可靠解法。
落到项目里怎么做
一条能写进规范的红线:所有带输入框的页面必须用 insets 机制(View 侧 setOnApplyWindowInsetsListener、Compose 侧 imePadding() + 容器层 consumeWindowInsets)做键盘避让,禁止用固定键盘高度或 getWindowVisibleDisplayFrame 猜测。 这一条能覆盖本篇三类主要问题。
配套实践三条:① 把键盘适配收敛到统一基类或容器组件,把消费策略固定下来,业务页面不重复写 inset 逻辑;② 在测试里加入"键盘弹出后当前输入框完全可见"这条断言(Compose 侧可用 onNodeWithTag(...).assertIsDisplayed());③ 覆盖横屏、分屏、深色、放大字体四种组合做一遍验证,因为这几种情况下的 insets 来源不同。
给正在准备面试的你
这题的答法要落到"键盘适配的本质是消费 insets,不是猜高度;adjustResize 失效的根因是没有可裁剪的窗口空间"这条主线。推荐这条线:先给旧方案失效表 → 讲 insets 类型与消费语义 → 讲 imePadding 与 padding 的差别 → 讲动画跟随与焦点滚动时机 → 补三个坑 → 收在"统一容器消费"。能被追问"为什么全屏下 adjustResize 失效"并答出"窗口已占满屏幕,系统无可裁剪空间",说明理解到机制层;能主动提到"同层组件会重复避让"并给出 consumeWindowInsets 解法,说明处理过真实布局问题。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Span-与富文本:TextView-的高级渲染
下一篇预告:图片选择与相机调用:Photo-Picker-与-SAF
有任何问题欢迎在评论区留言交流。