在 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 檢視區塊生命週期和 Compose 生命週期不同。整合 Compose 可能會導致複雜的WebView情境,進而產生難以解決的錯誤。以下各節說明可能需要特定處理方式的應用實例,才能支援這些功能。

保留 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 時,系統不容易支援巢狀捲動功能。在可捲動的 Compose 容器 (例如 LazyColumn) 中放置 WebView 時,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 中處理網路權限

網頁要求存取硬體或資料 (例如相機、麥克風或位置資訊) 時,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 提供其他顯示網頁內容的選項,例如 Chrome 自訂分頁。請參閱「在 Android 應用程式中使用網頁內容」,瞭解如何為您的用途 (例如瀏覽或驗證) 選擇正確方法。