如何监听 Activity 的配置变更(如键盘弹出)而不重启?
解读
国内面试官问这道题,核心想确认三件事:
- 是否知道“配置变更”在 Android 里是一个广义概念,键盘可见性变化(keyboardHidden/keyboard)只是其中一种;
- 是否明白“不重启”必须同时解决“不重建 Activity”和“不丢失 UI 状态”两个痛点;
- 能否给出“官方做法”与“实战灰度”两套方案,并说清楚各自的坑与适配成本。
如果只答 android:configChanges="keyboardHidden" 而忽略 ViewModel + onConfigurationChanged 的联动,或者只字不提国内 ROM 对键盘事件的差异化处理,都会被判定为“半桶水”。
知识点
- 配置变更列表:keyboardHidden、keyboard、orientation、screenSize、smallestScreenSize、screenLayout、uiMode 等 20 余项;
- 重启流程:AMS 检测到变更 → 销毁当前 Activity → 重建新实例 → 重新走 onCreate;
- 阻断重启:AndroidManifest 中声明 android:configChanges="xxx" 并在 Activity 重写 onConfigurationChanged(Configuration);
- 生命周期差异:阻断后只会回调 onConfigurationChanged,不会走 onDestroy/onCreate,因此 Fragment、ViewModel、Compose 状态得以保留;
- 键盘可见性判定:Configuration.keyboardHidden 只有 HIDDEN/NOHIDDEN,无法区分“软键盘弹出”与“物理滑盖”;真正想监听“软键盘高度”,必须监听 ViewTreeObserver.OnGlobalLayoutListener 计算 rootView 可见高度差;
- 国内 ROM 坑:
- 小米/华为折叠屏机型把键盘事件拆成多次回调,需 debounce;
- 部分游戏手机把“键盘弹出”当成 orientation+screenSize 联合变更,需同时声明;
- 兼容策略:targetSdk≥30 时,若使用沉浸式/刘海/挖孔,还需处理 uiMode、screenLayout 变更,否则 onConfigurationChanged 可能收不到;
- Jetpack 侧:ViewModel 自带“配置变更存活”特性,无需额外处理;Compose 侧用 rememberSaveable + LocalConfiguration.current 即可;
- 暗黑模式特例:uiMode 变更若阻断,需手动调用 AppCompatDelegate.applyDayNight() 重新创建 Drawable 资源,否则出现“图标不刷新”客诉;
- 性能陷阱:在 onConfigurationChanged 里做重量操作(如重建 RecyclerView Adapter)会掉帧,国内厂商性能实验室会用 Systrace 抓 16 ms 红线,面试时主动提到“把耗时操作抛到 IdleHandler”是加分项。
答案
分三步给出生产级代码骨架,可直接背下来:
- AndroidManifest
<activity
android:name=".ui.MainActivity"
android:configChanges="keyboardHidden|keyboard|screenSize|orientation|screenLayout|uiMode"
android:exported="true"
android:windowSoftInputMode="adjustResize" />
说明:
- keyboardHidden 与 keyboard 必须同时写,少一个都会在部分 OPPO 机型上重启;
- screenSize 也要写,否则 API 24+ 横竖屏切换仍重启;
- windowSoftInputMode 用 adjustResize 而不用 adjustPan,保证 rootView 高度变化可测。
- Activity 端
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val keyboardDetector = KeyboardDetector()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
keyboardDetector.attach(binding.root) { isShow, height ->
// 这里回调主线程,做平移动画或刷新 Compose State
binding.composeView.setContent {
ChatPage(isKeyboardOpen = isShow, imeHeight = height)
}
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// 1. 刷新 Compose 本地配置,避免字体缩放失效
LocalConfiguration.current
// 2. 手动重新计算 insets,适配挖孔/刘海
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets ->
binding.composeView.setContent {
ChatPage(windowInsets = insets)
}
insets
}
// 3. 暗黑模式刷新
if (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK !=
resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
) {
AppCompatDelegate.applyDayNight()
}
}
override fun onDestroy() {
super.onDestroy()
keyboardDetector.detach()
}
}
- 软键盘高度探测器(kotlin 文件,单例可复用)
class KeyboardDetector {
private var listener: ((Boolean, Int) -> Unit)? = null
private var rootView: View? = null
private val detector = ViewTreeObserver.OnGlobalLayoutListener {
rootView ?: return@OnGlobalLayoutListener
val r = Rect()
rootView!!.getWindowVisibleDisplayFrame(r)
val screenHeight = rootView!!.rootView.height
val keypadHeight = screenHeight - r.bottom
val isOpen = keypadHeight > screenHeight * 0.15
listener?.invoke(isOpen, keypadHeight)
}
fun attach(view: View, callback: (Boolean, Int) -> Unit) {
this.rootView = view
this.listener = callback
view.viewTreeObserver.addOnGlobalLayoutListener(detector)
}
fun detach() {
rootView?.viewTreeObserver?.removeOnGlobalLayoutListener(detector)
listener = null
rootView = null
}
}
亮点:
- 用 0.15 系数兼容折叠屏、平板、分屏;
- 回调跑在主线程,但内部无内存泄漏,detach 时机放在 onDestroy;
- 与 onConfigurationChanged 解耦,无论系统是否重启都能拿到键盘高度。
拓展思考
- 如果业务场景是“短视频全屏播放”,不希望任何配置变更重启,但又想暗黑模式即时生效,可以把 uiMode 从 configChanges 里拿掉,改用 Activity.recreate() 手动重建,配合 ViewModel 存活,实现“无闪屏”的瞬时切换;
- 对于 Compose 场景,Google 官方在 WindowInsets 库已提供 WindowInsets.ime,但国内 ROM 对“导航条手势条”高度计算有魔改,仍需 OnGlobalLayoutListener 兜底;
- 车载项目(Android Automotive)没有软键盘,但物理键盘热插拔会触发 keyboard 变更,需在 onConfigurationChanged 里重新扫描外设列表,并更新 InputMethodService 缓存;
- 面试反向提问:如果老板要求“键盘弹出时禁止 RecyclerView 重新 layout”,你会怎么做?标准答案是:给 RecyclerView 设置 android:descendantFocusability="blocksDescendants",并在 onConfigurationChanged 里手动调用 adapter.notifyItemRangeChanged(0, itemCount, payload = "keyboard") 实现增量刷新,既不掉帧也不重启。