在 Compose 中封装 WebView

如需在 Jetpack Compose 中使用 WebView,您必须将其封装在 AndroidView 中。本指南介绍了常见的使用场景,以及如何在 Compose 中支持这些场景。

使用 AndroidView 封装 WebView

如需在 Compose 中使用 WebView,请使用 AndroidView 将其封装起来:

@Composable
fun SimpleWebView(
    initialUrl: String,
    modifier: Modifier = Modifier
) {
    AndroidView(
        modifier = modifier.fillMaxSize(),
        factory = { context ->
            WebView(context).apply {
                webViewClient = WebViewClient()
                settings.javaScriptEnabled = true
                loadUrl(initialUrl)			
            }
        }
    )
}

这适用于在应用内显示简单网址。不过,WebView 处理的是与 Android View 生命周期和 Compose 生命周期分开的复杂状态生命周期。集成 Compose 可能会引入复杂的 WebView 场景,从而导致难以解决的 bug。以下部分介绍了可能需要特殊处理才能支持这些功能的用例。

保留 WebView 状态

在 Compose 中处理配置更改和导航是一项具有挑战性的任务,因为 WebView 是绑定到其宿主 Activity 的旧版 View,并且不建议其实例的生命周期长于 Activity 的生命周期。

因此,持久保存 WebView 状态的标准方法是允许 WebView 实例与 Activity 一起销毁和重新创建。您可以使用 Bundle 手动持久保存其内部导航历史记录和滚动状态。

@Composable
fun PersistentWebView(url: String) {
    val webViewStateBundle = rememberSaveable { Bundle() }

    AndroidView(
        factory = { context ->
            WebView(context).apply {
                webViewClient = WebViewClient()
                settings.javaScriptEnabled = true

                // Restore the state and history
                if (webViewStateBundle.containsKey("WEBVIEW_STATE")) {
                    restoreState(webViewStateBundle.getBundle("WEBVIEW_STATE")!!)
                } else {
                    loadUrl(url)
                }
            }
        },
        onRelease = { releasedWebView ->
            // Save navigation history before the instance is destroyed
            val bundle = Bundle()
            releasedWebView.saveState(bundle)
            webViewStateBundle.putBundle("WEBVIEW_STATE", bundle)
        },
        modifier = Modifier.fillMaxSize()
    )
}

处理返回导航

WebView 具有导航历史记录时,系统返回手势应在 WebView 内向后导航,而不是退出屏幕。

使用 Compose BackHandler API 拦截系统返回事件,并调用 WebView goBack() 函数:

// ...
@Composable
fun BackNavigationDemoScreen(onBack: () -> Unit) {
    // Hold a reference to the WebView to check its history state
    var webViewReference by remember { mutableStateOf<WebView?>(null) }

    // Intercept the system back press if the WebView has history
    BackHandler(enabled = true) {
        val webView = webViewReference
        if (webView != null && webView.canGoBack()) {
            webView.goBack() // Go back in history
        } else {
            onBack() // Exit screen
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Back Navigation Demo") },
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
                    }
                }
            )
        }
    ) { padding ->
        Column(modifier = Modifier.fillMaxSize().padding(padding)) {
            AndroidView(
                modifier = Modifier.fillMaxSize(),
                factory = { context ->
                    WebView(context).apply {
                        settings.javaScriptEnabled = true

                        // Keeps link navigations internal to the WebView instead of opening Chrome
                        webViewClient = WebViewClient() 

                        loadUrl("https://developer.android.com")
                        webViewReference = this
                    }
                },
                onRelease = {
                    webViewReference = null
                }
            )
        }
    }
}

此实现方式提供浏览器样式的导航行为。

嵌套滚动

在 Compose 中使用 WebView 时,不容易支持嵌套滚动。将 WebView 放置在可滚动的 Compose 容器(例如 LazyColumn)中时,WebView 可能会消耗所有滚动手势。由于 WebView 依赖于其自身的内部渲染引擎,因此目前无法正常地将其与 LazyColumn 嵌套使用。

如需跟踪 WebView 的官方嵌套滚动支持的进度,请参阅此问题

无边框布局和窗口边衬区

使用全屏布局时,WebView 内容可能会显示在状态栏等系统栏下方。您可以使用 windowInsetsPadding 修饰符将整个 WebView 推送到安全区域:

@Composable
fun EdgeToEdgeDemo(url: String) {
    AndroidView(
        modifier = Modifier
            .fillMaxSize()
            .windowInsetsPadding(WindowInsets.systemBars),
        factory = { context ->
            WebView(context).apply {
                loadUrl(url)
            }
        }
    )
}

如需详细了解边衬区,请参阅了解 WebView 中的窗口边衬区

将应用主题与 WebView 内容同步

当应用在浅色模式和深色模式之间切换时,如果处理得当,WebView 内容可以自动更新,而无需重新加载页面。

如果您拥有网页内容,则为了使颜色与应用的主题保持同步,请处理媒体查询 prefers-color-scheme,确保网页能够适应所选的主题。

如需让下拉菜单和弹出式窗口等原生元素能够检测并匹配应用主题,请将 DayNight 样式主题应用到 Activity.

<resources>

    <!-- ...
    <!-- Use a DayNight theme in your manifest to handle both modes automatically -->
    <style name="Theme.Webviewdemo.DayNight" parent="Theme.AppCompat.DayNight.NoActionBar" />
</resources>

@Composable
fun ThemeSyncDemo(onBack: () -> Unit) {
    val context = LocalContext.current
    AndroidView(
        modifier = Modifier.fillMaxSize(),
        factory = { _ ->
            WebView(context).apply {
                settings.javaScriptEnabled = true
                webViewClient = WebViewClient()
                val html = """
                            <html>
                            <head>
                                // ...


                                    @media (prefers-color-scheme: dark) {
                                        body {
                                            background-color: #212121;
                                            color: #ffffff;
                                        }
                                        select {
                                            border-color: #BB86FC;
                                            background: #212121;
                                            color: #ffffff;
                                        }
                                    }
                                </style>
                            </head>
                            // ...
                            </html>
                        """.trimIndent()
                loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
            }
        }
    )
} 

如果网页没有深色主题,或者您不拥有网页内容,算法调暗功能可能有助于强制应用深色主题。已具有深色模式的现代网站会忽略此算法,而改用自己的内置样式。

在 Compose 中处理 Web 权限

当网页请求访问硬件或数据访问权限(例如摄像头、麦克风或位置信息)时,WebView会在其 WebChromeClient 中触发特定的回调。您必须处理这些回调,并确保授予相应的 Android 运行时权限。

处理摄像头和麦克风权限

当网页请求摄像头或麦克风访问权限(例如 WebRTC 或视频录制)时,WebView 会调用 WebChromeClient.onPermissionRequest

不过,在调用 grant() 之前,您必须请求以下 Android 运行时权限:

  • Manifest.permission.CAMERA
  • Manifest.permission.RECORD_AUDIO

首先,为 WebView 定义一个权限处理程序,用于跟踪从 WebView 请求的 PermissionRequest

class WebViewPermissionHandler(
    private val launcher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
) {
    var pendingRequest by mutableStateOf<PermissionRequest?>(null)
        private set

    fun handleRequest(request: PermissionRequest) {
        val isTrustedOrigin = request.origin.host == "www.trusted-domain.com" || request.origin.host == "app.local" // Always verify the origin before granting request


        if (!isTrustedOrigin) {
            Log.w("WebViewPermission", "Blocked and denied permission request from untrusted origin: ${request.origin.host}")
            request.deny()
            return
        }

        val androidPermissions = mutableListOf<String>()
        request.resources.forEach { resource ->
            when (resource) {
                PermissionRequest.RESOURCE_VIDEO_CAPTURE -> androidPermissions.add(Manifest.permission.CAMERA)
                PermissionRequest.RESOURCE_AUDIO_CAPTURE -> androidPermissions.add(Manifest.permission.RECORD_AUDIO)
            }
        }

        // Save the request and launch the Android system dialog
        pendingRequest = request
        launcher.launch(androidPermissions.toTypedArray())
    }

    fun onResult(results: Map<String, Boolean>) {
        val allGranted = results.values.all { it }
        Log.d("WebViewPermission", "Kotlin: All permissions granted? $allGranted")

        if (allGranted) {
            pendingRequest?.grant(arrayOf("/* list of permissions */"))
        } else {
            pendingRequest?.deny()
        }
        pendingRequest = null
    }
}

接下来,创建一个可记住 WebViewPermissionHandler 的可组合项。使用 rememberLauncherForActivityResult 请求权限:

@Composable
fun rememberWebViewPermissionHandler(): WebViewPermissionHandler {
    val handlerState = remember { mutableStateOf<WebViewPermissionHandler?>(null) }
    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { results ->
        handlerState.value?.onResult(results)
    }
    return remember {
        WebViewPermissionHandler(launcher).also { handlerState.value = it }
    }
}

处理 onPermissionRequest 回调中的权限。这会启动权限启动器:

@Composable
fun WebViewPermissionScreen() {
    val permissionHandler = rememberWebViewPermissionHandler()

    AndroidView(
        factory = { context ->
            WebView(context).apply {
                settings.javaScriptEnabled = true

                webChromeClient = object : WebChromeClient() {
                    override fun onPermissionRequest(request: PermissionRequest) {
                        // Simply delegate to the handler
                        permissionHandler.handleRequest(request)
                    }
                }

		   // load a web page that needs permissions
            }
        },
        modifier = Modifier.fillMaxSize()
    )
}

嵌入式 WebView 的替代方案

如果您不想嵌入 WebView,Android 还提供了其他用于显示 Web 内容的选项,例如 Chrome 自定义标签页。请参阅在 Android 应用中使用 Web 内容,了解如何为您的使用情形(例如浏览或身份验证)选择正确的方法。