Android 异形屏与横屏全屏沉浸式适配技术方案

8 阅读5分钟

1. 背景与业务痛点

随着刘海屏、水滴屏、打孔屏等异形屏(Display Cutout)的普及,以及全模态全面屏的演进,传统的固定状态栏高度适配方案已无法满足现代 App 的视觉需求。

特别是在横屏高沉浸式场景(如视频播放器、游戏、全景相机等)中,若适配不当,通常会面临以下两个痛点:

  1. 黑边问题:系统为了防止内容被刘海遮挡,默认会在刘海侧强行填充一条黑色遮罩,导致画面无法真正全屏。
  2. UI 元素遮挡:在强制全屏后,如果未动态计算安全区域,关键交互组件(如返回键、控制面板、悬浮按钮)会被刘海物理遮挡,引发用户误触或无法点击的故障。

为了规范异形屏适配流程,保证多终端视觉一致性与高可靠性,特制定本技术方案。

2. 核心适配策略

异形屏适配的核心逻辑可解耦为两个步骤:

  • Window 级别配置(解禁限制) :修改窗口属性,声明允许应用内容延伸至屏幕短边的物理异形区域。
  • View 级别适配(精准避让) :通过底层事件分发机制,动态获取物理遮挡边界,对交互组件(非背景图)施加绝对/相对偏移。
+-------------------------------------------------------------+
|  [ 状态栏 / 左刘海区域 ] <--- Window 声明 SHORT_EDGES 延伸到此 |
|  +-------------------------------------------------------+  |
|  | (返回键) <--- View 监听到 Insets,通过 Padding 避让    |  |
|  |                                                       |  |
|  |             视频/游戏等背景画面 (全面延伸占满)           |  |
|  +-------------------------------------------------------+  |
+-------------------------------------------------------------+

3. 技术实现方案 (基于 API 28+)

自 Android 9.0 (API 28) 起,Google 推出了官方标准 API。针对横竖屏沉浸式场景,方案实现细则如下:

3.1 属性配置:开启边缘绘制 (Edge-to-Edge)

必须在 Activity 启动时(setContentView 之前),通过设置 layoutInDisplayCutoutMode 告诉系统不再保留黑边。

Java

private void setupFullscreenAndCutout(Window window) {
    // 1. 允许内容延伸到短边刘海区(横屏沉浸式核心)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        WindowManager.LayoutParams lp = window.getAttributes();
        lp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
        window.setAttributes(lp);
    }

    // 2. 隐藏系统状态栏和导航栏
    WindowInsetsControllerCompat windowInsetsController =
            WindowCompat.getInsetsController(window, window.getDecorView());
    
    windowInsetsController.setSystemBarsBehavior(
            WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
    windowInsetsController.hide(WindowInsetsCompat.Type.systemBars());
    
    // 3. 核心:允许内容绘制在系统栏区域,解除传统布局限制
    WindowCompat.setDecorFitsSystemWindows(window, false);
}

注意: 必须选用 LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES。若使用 DEFAULT 模式,应用在横屏时依然会被系统强制切出黑边。

3.2 动态避让:安全区域边界获取与消费

获取边界时,切忌对全屏背景 View 设置 Padding,否则全屏沉浸将失效。正确的解法是:将监听器绑定在承载交互 UI 的容器 View 上。

核心实现代码:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    
    // 初始化窗口属性
    setupFullscreenAndCutout(getWindow());
    setContentView(R.layout.activity_media_player);

    // 承载全屏画面的背景 View(如 TextureView 或 ImageView)
    View videoRenderView = findViewById(R.id.video_render_view);
    // 包裹了返回按钮、进度条等交互组件的容器 View
    View uiControlContainer = findViewById(R.id.ui_control_container);

    // 注册 WindowInsets 监听
    ViewCompat.setOnApplyWindowInsetsListener(uiControlContainer, (v, insets) -> {
        DisplayCutoutCompat cutout = insets.getDisplayCutout();
        
        if (cutout != null) {
            // 当设备发生 90° 或 270° 旋转时,系统会自动回调此处
            // 此时 safeLeft 与 safeRight 会动态互换,无须手动判断旋转角度
            int safeLeft = cutout.getSafeInsetLeft();
            int safeRight = cutout.getSafeInsetRight();
            int safeTop = cutout.getSafeInsetTop();
            int safeBottom = cutout.getSafeInsetBottom();
            
            // 动态对交互容器施加 Padding,确保内部组件由于物理刘海被挤压到安全区内
            v.setPadding(safeLeft, safeTop, safeRight, safeBottom);
        } else {
            // 无刘海设备,重置边距
            v.setPadding(0, 0, 0, 0);
        }
        
        // 返回 CONSUMED 意味着此 Insets 已被完全处理,不再向下游子 View 分发
        return WindowInsetsCompat.CONSUMED;
    });

    // 主动触发一次 Insets 分发,确保 attached 时回调能立刻执行
    uiControlContainer.requestApplyInsets();
}

4. 架构专项适配:Jetpack Compose 方案

若项目已向现代声明式 UI 架构(Jetpack Compose)迁移,则不再需要手动注册 Listener 并计算像素值,可直接利用 Compose 的 WindowInsets 扩展。

// 1. 在 ComponentActivity 的 onCreate 中开启全屏幕适配
WindowCompat.setDecorFitsSystemWindows(window, false)

// 2. 在 Composable 中进行生命式布局
@Composable
fun LandscapePlayerScreen() {
    Box(modifier = Modifier.fillMaxSize()) {
        // 背景渲染层:强制填满整个物理屏幕,包含刘海区
        VideoPlayerComponent(modifier = Modifier.fillMaxSize())

        // 交互控制层:利用 windowInsetsPadding 自动避开所有异形屏物理遮挡及系统栏
        Column(
            modifier = Modifier
                .fillMaxSize()
                .windowInsetsPadding(WindowInsets.safeDrawing) // 核心:自动处理多端异形屏与旋转
        ) {
            ControlTopBar(onBackClick = { /*...*/ })
            Spacer(modifier = Modifier.weight(1f))
            ControlBottomBar()
        }
    }
}

5. 历史机型兼容性设计 (Android 8.0 - 8.1)

虽然低版本系统目前市场份额较低,但在特定基线或出海项目中若仍需向下兼容,需要针对头部厂商进行私有配置兜底:

厂商Manifest 配置动态判断与尺寸获取方式
华为/荣耀android.notch_support = true通过反射调用 com.huawei.android.util.HwNotchSizeUtil 内的 hasNotchInScreengetNotchSize
小米 (MIUI)notch.config = 1通过反射读取系统属性 ro.miui.notch 判断;通过系统资源 id 间接获取宽度与高度。
OPPO / vivo无须特定配置,全屏模式下默认延伸。OPPO: 反射 com.oppo.feature.screen.heteromorphismvivo: 反射 android.util.FtFeatureisFeatureSupport(0x00000020)

注:在当前时间节点,若无强特定老旧机型业务指标,技术侧建议不再为 Android 8.X 编写繁重的反射兼容代码,统一采用 Android 9.0+ 官方标准方案作为基线标准。

6. 方案落地与上线核对清单 (Checklist)

为确保技术方案高质量交付,上线前需在测试阶段核对以下用例:

  • 横屏 90° 翻转测试:确认刘海在左侧时,左侧交互组件正常缩进,右侧无异常留白。
  • 横屏 270° 翻转测试:确认刘海在右侧时,UI 自动切换,右侧交互组件正常缩进,左侧无异常留白。
  • 折叠屏/大屏展开态适配:在内屏及打孔外屏间切换时,检查 Insets 刷新是否会引起界面闪烁或控件跳变。
  • 系统手势冲突测试:开启全面屏手势(如边缘右滑返回)后,检查边缘按钮在施加安全 Padding 后是否仍存在点击冲突。