如何监听 Activity 的配置变更(如键盘弹出)而不重启?

解读

国内面试官问这道题,核心想确认三件事:

  1. 是否知道“配置变更”在 Android 里是一个广义概念,键盘可见性变化(keyboardHidden/keyboard)只是其中一种;
  2. 是否明白“不重启”必须同时解决“不重建 Activity”和“不丢失 UI 状态”两个痛点;
  3. 能否给出“官方做法”与“实战灰度”两套方案,并说清楚各自的坑与适配成本。
    如果只答 android:configChanges="keyboardHidden" 而忽略 ViewModel + onConfigurationChanged 的联动,或者只字不提国内 ROM 对键盘事件的差异化处理,都会被判定为“半桶水”。

知识点

  1. 配置变更列表:keyboardHidden、keyboard、orientation、screenSize、smallestScreenSize、screenLayout、uiMode 等 20 余项;
  2. 重启流程:AMS 检测到变更 → 销毁当前 Activity → 重建新实例 → 重新走 onCreate;
  3. 阻断重启:AndroidManifest 中声明 android:configChanges="xxx" 并在 Activity 重写 onConfigurationChanged(Configuration);
  4. 生命周期差异:阻断后只会回调 onConfigurationChanged,不会走 onDestroy/onCreate,因此 Fragment、ViewModel、Compose 状态得以保留;
  5. 键盘可见性判定:Configuration.keyboardHidden 只有 HIDDEN/NOHIDDEN,无法区分“软键盘弹出”与“物理滑盖”;真正想监听“软键盘高度”,必须监听 ViewTreeObserver.OnGlobalLayoutListener 计算 rootView 可见高度差;
  6. 国内 ROM 坑:
    • 小米/华为折叠屏机型把键盘事件拆成多次回调,需 debounce;
    • 部分游戏手机把“键盘弹出”当成 orientation+screenSize 联合变更,需同时声明;
  7. 兼容策略:targetSdk≥30 时,若使用沉浸式/刘海/挖孔,还需处理 uiMode、screenLayout 变更,否则 onConfigurationChanged 可能收不到;
  8. Jetpack 侧:ViewModel 自带“配置变更存活”特性,无需额外处理;Compose 侧用 rememberSaveable + LocalConfiguration.current 即可;
  9. 暗黑模式特例:uiMode 变更若阻断,需手动调用 AppCompatDelegate.applyDayNight() 重新创建 Drawable 资源,否则出现“图标不刷新”客诉;
  10. 性能陷阱:在 onConfigurationChanged 里做重量操作(如重建 RecyclerView Adapter)会掉帧,国内厂商性能实验室会用 Systrace 抓 16 ms 红线,面试时主动提到“把耗时操作抛到 IdleHandler”是加分项。

答案

分三步给出生产级代码骨架,可直接背下来:

  1. 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 高度变化可测。
  1. 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()
    }
}
  1. 软键盘高度探测器(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 解耦,无论系统是否重启都能拿到键盘高度。

拓展思考

  1. 如果业务场景是“短视频全屏播放”,不希望任何配置变更重启,但又想暗黑模式即时生效,可以把 uiMode 从 configChanges 里拿掉,改用 Activity.recreate() 手动重建,配合 ViewModel 存活,实现“无闪屏”的瞬时切换;
  2. 对于 Compose 场景,Google 官方在 WindowInsets 库已提供 WindowInsets.ime,但国内 ROM 对“导航条手势条”高度计算有魔改,仍需 OnGlobalLayoutListener 兜底;
  3. 车载项目(Android Automotive)没有软键盘,但物理键盘热插拔会触发 keyboard 变更,需在 onConfigurationChanged 里重新扫描外设列表,并更新 InputMethodService 缓存;
  4. 面试反向提问:如果老板要求“键盘弹出时禁止 RecyclerView 重新 layout”,你会怎么做?标准答案是:给 RecyclerView 设置 android:descendantFocusability="blocksDescendants",并在 onConfigurationChanged 里手动调用 adapter.notifyItemRangeChanged(0, itemCount, payload = "keyboard") 实现增量刷新,既不掉帧也不重启。