深入理解 Android WebView 的 onShowCustomView 回调

135 阅读7分钟

深度解析 Android WebView 全屏机制:onShowCustomView 与沉浸式适配方案

在开发 Android WebView 应用时,实现网页视频的全屏播放是一个常见且具有挑战性的需求。其核心难点不仅在于处理全屏视图的挂载,还涉及原生布局属性(如 fitsSystemWindows)带来的适配残留问题,以及沉浸式状态栏、导航栏、横竖屏切换等复杂场景的综合协调。

本文将从 机制原理 → 适配陷阱 → 解决方案 → 进阶实践​ 四个维度,全面剖析 WebView 全屏机制,并提供一套稳定可靠的沉浸式适配方案。


一、核心机制:什么是 onShowCustomView

1. 方法定义与本质

onShowCustomView(View view, CustomViewCallback callback)是 WebChromeClient类中的一个重要回调方法。

  • 本质:它是一个 “委托回调” 。当网页内部请求进入全屏模式时,WebView 并不直接改变自身大小或层级,而是将代表全屏内容的视图(view)交给宿主 Activity。Activity 必须负责将该视图添加到一个能够覆盖整个窗口的容器中,并管理其生命周期。
  • 设计意图:将全屏控制权从 WebView 移交至原生层,使开发者可以灵活控制全屏视图的展示方式(如动画、横屏、隐藏系统UI等)。

2. 触发场景(何时被调用?)

该方法仅在网页主动发起“全屏请求”时触发,典型场景包括:

场景说明
✅ HTML5 视频全屏用户点击 <video>控件中的全屏按钮(如 YouTube、Bilibili H5 播放器)。
✅ JavaScript Fullscreen API网页脚本调用 element.requestFullscreen(),常见于 H5 游戏、地图(如高德地图全屏)、自定义播放器等。
✅ WebXR / VR 模式网页进入虚拟现实或全景模式(如 A-Frame、Three.js 的 VR 场景)。

⚠️ 注意:以下行为不会触发 onShowCustomView

  • 普通页面滚动或缩放
  • 软键盘弹出
  • 原生 Dialog、PopupWindow 显示
  • 页面跳转或加载

二、适配陷阱:fitsSystemWindows的静态冲突

为了实现沉浸式状态栏(如透明状态栏、内容延伸到状态栏下方),开发者常在根布局设置:

android:fitsSystemWindows="true"

但在 onShowCustomView中,即使动态设置 rootView.setFitsSystemWindows(false),全屏视图顶部仍可能残留状态栏高度的空白或黑边,无法实现真正的全屏覆盖

1. 现象复现

  • 进入全屏后,视频上方出现一条固定高度的空白区域(通常为 24dp~72dp,取决于设备状态栏高度)。
  • 背景为黑色或主题色,破坏沉浸感。
  • setFitsSystemWindows(false)无效,Padding 未被清除。

2. 冲突根源深度解析

(1)fitsSystemWindows的工作机制
  • 当 android:fitsSystemWindows="true"时,系统在 布局分发阶段(通过 View.dispatchApplyWindowInsets())会监听 WindowInsets(如状态栏、导航栏高度)。
  • 系统会自动为当前 View 设置 paddingToppaddingBottom等值,以避免内容被系统栏遮挡。
  • 此过程是一次性计算并应用的,且默认不会随 fitsSystemWindows标志位变化而自动撤销。
(2)setFitsSystemWindows(false)的局限性
  • 调用 setFitsSystemWindows(false)仅修改了 View 内部的 mFitSystemWindows标志位。
  • 它告诉系统:“后续不再为我分发 WindowInsets”,但不会清除已设置的 Padding
  • 因此,即使标志位设为 false,原有的 paddingTop依然存在,导致全屏视图被“顶起”。
(3)视觉残留的本质
  • 全屏视图(customView)被添加到 video_container中,而 video_container又位于 rootLayout之下。
  • 若 rootLayout保留了 paddingTop,其所有子 View(包括全屏容器)都会继承该偏移量,造成视觉上的“缩进”。

三、解决方案:最佳实践与代码实现

1. 布局结构设计

建议在 Activity 布局中预留一个顶层全屏容器,用于承载网页传入的全屏视图。

<!-- activity_main.xml -->
<RelativeLayout 
    android:id="@+id/root_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

    <!-- 正常业务布局 -->
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <include layout="@layout/layout_title_bar" />

        <WebView
            android:id="@+id/web_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </LinearLayout>

    <!-- 全屏容器:初始隐藏,置于最上层 -->
    <FrameLayout
        android:id="@+id/video_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_alignParentTop="true"
        android:layout_alignParentStart="true"
        android:visibility="gone"
        android:background="#000000" />

</RelativeLayout>

💡 提示:video_container必须是 RelativeLayout的直接子 View,且初始不可见,避免遮挡主界面。


2. 核心代码实现(Java)

public class WebViewFullScreenActivity extends AppCompatActivity {

    private WebView webView;
    private RelativeLayout rootLayout;
    private FrameLayout videoContainer;
    private View titleBar;
    private View customView;
    private WebChromeClient.CustomViewCallback customViewCallback;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 初始化视图
        rootLayout = findViewById(R.id.root_layout);
        webView = findViewById(R.id.web_view);
        videoContainer = findViewById(R.id.video_container);
        titleBar = findViewById(R.id.title_bar);

        setupWebView();
    }

    private void setupWebView() {
        WebSettings settings = webView.getSettings();
        settings.setJavaScriptEnabled(true);
        settings.setDomStorageEnabled(true);
        settings.setMediaPlaybackRequiresUserGesture(false); // 允许自动播放

        webView.setWebChromeClient(new WebChromeClient() {
            @Override
            public void onShowCustomView(View view, CustomViewCallback callback) {
                // 防止重复进入全屏
                if (customView != null) {
                    callback.onCustomViewHidden();
                    return;
                }

                customView = view;
                customViewCallback = callback;

                // === 关键适配逻辑:清除 fitsSystemWindows 残留 Padding ===
                if (rootLayout != null) {
                    // 1. 关闭 fitsSystemWindows 处理
                    rootLayout.setFitsSystemWindows(false);
                    // 2. 手动清除已设置的 Padding(核心!)
                    rootLayout.setPadding(0, 0, 0, 0);
                    // 3. 强制重绘布局(确保立即生效)
                    rootLayout.requestLayout();
                }

                // 隐藏标题栏
                if (titleBar != null) titleBar.setVisibility(View.GONE);

                // 隐藏系统 UI(状态栏、导航栏)
                hideSystemUI();

                // 添加全屏视图并展示
                videoContainer.addView(view, new FrameLayout.LayoutParams(
                        ViewGroup.LayoutParams.MATCH_PARENT,
                        ViewGroup.LayoutParams.MATCH_PARENT));
                videoContainer.setVisibility(View.VISIBLE);

                // 可选:锁定横屏
                setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);
            }

            @Override
            public void onHideCustomView() {
                if (customView == null) return;

                // === 恢复布局属性 ===
                if (rootLayout != null) {
                    rootLayout.setFitsSystemWindows(true);
                    // 系统将在下一次 Insets 分发时重新计算 Padding
                }

                // 显示标题栏
                if (titleBar != null) titleBar.setVisibility(View.VISIBLE);

                // 恢复系统 UI
                showSystemUI();

                // 移除全屏视图
                videoContainer.setVisibility(View.GONE);
                videoContainer.removeView(customView);
                customViewCallback.onCustomViewHidden();
                customView = null;
                customViewCallback = null;

                // 恢复竖屏
                setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
            }
        });

        // 加载测试页面
        webView.loadUrl("https://example.com/fullscreen-video.html");
    }

    // 隐藏系统状态栏和导航栏
    private void hideSystemUI() {
        View decorView = getWindow().getDecorView();
        decorView.setSystemUiVisibility(
                View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
                        | View.SYSTEM_UI_FLAG_FULLSCREEN
                        | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                        | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                        | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                        | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
        );
    }

    // 恢复系统 UI
    private void showSystemUI() {
        View decorView = getWindow().getDecorView();
        decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
    }

    // 拦截返回键:优先退出全屏
    @Override
    public void onBackPressed() {
        if (customView != null) {
            webView.getWebChromeClient().onHideCustomView();
        } else {
            super.onBackPressed();
        }
    }

    @Override
    protected void onDestroy() {
        if (webView != null) {
            webView.destroy();
        }
        super.onDestroy();
    }
}

四、进阶注意事项与优化策略

1. 沉浸式状态栏库的协同(如 ImmersionBar)

若使用第三方库(如 ImmersionBar),需在 onShowCustomView中调用其 API 统一管理状态栏显隐:

// 进入全屏
ImmersionBar.with(this)
    .fitsSystemWindows(false)
    .init();

// 退出全屏
ImmersionBar.with(this)
    .fitsSystemWindows(true)
    .statusBarDarkFont(true) // 根据主题调整
    .init();

✅ 优势:避免手动操作 WindowInsets的复杂性,兼容更多 ROM。


2. 横竖屏与屏幕旋转管理

  • 强制横屏:在 onShowCustomView中调用 setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
  • 恢复方向:在 onHideCustomView中恢复为 SCREEN_ORIENTATION_PORTRAIT或 SCREEN_ORIENTATION_UNSPECIFIED
  • 配置支持:在 AndroidManifest.xml中为 Activity 添加:
android:configChanges="orientation|screenSize|keyboardHidden"

避免旋转时重建 Activity。


3. 硬件加速与渲染性能

  • 确保 Activity 启用硬件加速(默认开启):
<application android:hardwareAccelerated="true" ... >
  • 若遇到黑屏或卡顿,可尝试为 videoContainer或其父布局设置:
videoContainer.setLayerType(View.LAYER_TYPE_HARDWARE, null);

4. 多窗口与分屏适配(API 24+)

在支持分屏的设备上,需额外处理 onShowCustomView在分屏模式下的行为:

  • 检查 isInMultiWindowMode(),决定是否允许全屏。
  • 可考虑禁用全屏或提示用户“请退出分屏以使用全屏”。

5. 安全性与权限控制

  • 若网页内容来自不可信源,建议限制全屏权限或使用 WebViewAssetLoader加载本地资源。
  • 避免在 onShowCustomView中执行耗时操作,防止 ANR。

五、总结:构建“手动干预 + 成对恢复”的适配思维

onShowCustomView是 WebView 与原生系统交互的关键桥梁,尤其在处理 H5 视频、游戏、VR 等富媒体场景时不可或缺。

面对 fitsSystemWindows引发的适配残留问题,开发者应摒弃“系统会自动清理”的幻想,建立以下核心意识:

原则说明
🔧 手动干预 Padding进入全屏时必须显式调用 setPadding(0,0,0,0)清除历史残留。
🔁 成对恢复属性fitsSystemWindows的开关必须成对出现(进入关、退出开)。
🎯 生命周期绑定全屏视图的添加/移除、UI 显隐、屏幕方向应与 customView生命周期严格同步。
🧩 库与系统协同结合沉浸式库、WindowInsets 监听器,构建更健壮的适配体系。

唯有如此,才能实现 H5 全屏体验与原生沉浸式 UI 的无缝融合,为用户提供流畅、沉浸、一致的跨平台交互体验。


✅ 推荐阅读扩展