在分屏模式下,如何确保 UI 元素不会被遮挡或错位?

解读

国内主流 ROM(MIUI、ColorOS、EMUI、OriginOS、Flyme 等)对 AOSP 分屏做了深度定制:

  1. 比例范围从 16:9 到 1:1,甚至支持“小窗+分屏”三应用同屏;
  2. 状态栏/导航栏高度、挖孔、水滴、圆角、折叠屏折痕区域各厂差异大;
  3. 面试时如果只回答“用 dp 适配”会被认为没做过真机验证,必须给出可落地的 WindowInsets、Configuration 变更监听、ConstraintLayout+Guideline 组合拳,并提到国产机折痕避让 API。

面试官真正想听的是:

  • 你能不能在生命周期回调里实时拿到最新窗口边界;
  • 能不能用 Jetpack WindowManager 1.x 的 WindowMetrics 避开已弃用的 Display.getSize();
  • 知不知道国内 ROM 强制“全屏显示”开关会导致 Application 的 DisplayMetrics 被缓存错,需要重写 Resources 走 getSystemService(WindowManager).currentWindowMetrics;
  • 有没有在折叠屏机型上验证过当用户从 8:7.1 大屏切到 22.5:18 分屏时,你的底部导航会不会被折痕遮挡。

知识点

  1. 分屏生命周期:onMultiWindowModeChanged / onConfigurationChanged
  2. 窗口边界获取:
    • SDK 30 以前:Display.getSize()(已废弃,国内面试答这个直接扣分)
    • SDK 30+:WindowMetrics#getBounds()、WindowMetrics#getWindowInsets()
  3. 系统边衬避让:WindowInsets.getInsetsIgnoringVisibility()、WindowInsetsCompat.Type.systemBars() | displayCutout()
  4. 国产折痕/圆角避让:小米 MiuiFoldableHelper、华为 FoldableScreenManager、OPPO ScreenCutoutManager(均为厂商私有 SDK,需 Maven 私服集成)
  5. 布局策略:ConstraintLayout + Guideline percent、WindowSizeClass 计算、Compose 的 WindowInsetsPadding、NavigationBarPadding
  6. 强制全屏开关陷阱:Settings.Secure.getInt("display_fullscreen_apps") 导致 Activity 重启后 Resources 缓存旧高宽,需 recreate() 或 updateResources()
  7. 测试命令:adb shell am split 0.5 + adb shell wm size 折叠屏分辨率快速模拟

答案

分屏模式下保证 UI 不遮挡要分三步:实时监听、准确取数、动态避让。

  1. 实时监听
    在 Activity 与 Fragment 中同时重写:

    • onMultiWindowModeChanged(isInMultiWindowMode: Boolean)
    • onConfigurationChanged(newConfig: Configuration)
      并在 AndroidManifest 对应 Activity 声明 android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden",避免重启。
  2. 准确取数
    使用 Jetpack WindowManager 1.2+:
    val metrics = windowManager.currentWindowMetrics
    val bounds = metrics.bounds
    val insets = metrics.windowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout())
    计算实际可用矩形:
    val usableWidth = bounds.width() - insets.left - insets.right
    val usableHeight = bounds.height() - insets.top - insets.bottom
    切勿用 DisplayMetrics.widthPixels,它在国产机强制全屏开关开启时会被缓存成错误值。

  3. 动态避让
    布局层:

    • XML 采用 ConstraintLayout,底部关键按钮用 app:layout_constraintBottom_toTopOf="@id/guideline_nav",guideline 百分比通过代码 setGuidelinePercent(0.9) 动态调整;
    • Compose 侧用 Modifier.windowInsetsPadding(WindowInsets.navigationBars + WindowInsets.displayCutout),并监听 LocalConfiguration.current.screenWidthDp 变化;
    • 折叠屏机型在 onCreate 中调用厂商 SDK 获取折痕区域,换算成 px 后额外增加一个 Guideline 或 Spacer,确保 BottomSheet 不会被折痕遮挡。

    代码示例(Kotlin):
    override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean) {
    super.onMultiWindowModeChanged(isInMultiWindowMode)
    val windowMetrics = windowManager.currentWindowMetrics
    val insets = windowMetrics.windowInsets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout())
    val density = resources.displayMetrics.density
    val bottomMarginPx = insets.bottom + getFoldCreaseHeight() // 厂商 SDK
    guidelineNav.setGuidelineBegin((windowMetrics.bounds.height() - bottomMarginPx) / density)
    }

    最后,在 UI 测试用例里用 Espresso 的 OrientationChangeAction 和 UiAutomator 的 UiDevice.getInstance().setOrientationLeft() 组合分屏命令,断言关键按钮底部距屏幕实际距离 ≥ insets.bottom + foldCreaseHeight,即可覆盖 90% 国产机场景。

拓展思考

  1. 三应用同屏(小窗+分屏)场景下,你的 Activity 可能只占屏幕 30%,此时如果弹出 Dialog 使用默认 WRAP_CONTENT 会被系统强制截断,需要自定义 Dialog 的 Window 类型为 TYPE_APPLICATION_PANEL 并手动设置 Gravity.BOTTOM|Gravity.CENTER_HORIZONTAL,同时限制最大高度为可用窗口高度的一半。
  2. 国内银行、支付类 App 为了安全会禁止分屏,做法是在 onCreate 里直接判断 isInMultiWindowMode 并 finish(),但 Android 12L 开始企业管理员可以强制分屏,此时需要弹合规提示而非直接退出,否则会被应用商店审核驳回。
  3. 折叠屏从展开到半折(Posture HALF_OPENED)时,系统会连续触发两次 configurationChange,第一次比例突变、第二次折痕区域更新,如果两次回调共用同一个 coroutine 做动画,会出现闪屏;正确做法是使用 lifecycleScope + debounce(200ms) 合并事件,等 WindowMetrics 稳定后再刷新布局。