Bao bọc WebView trong Compose

Để sử dụng WebView trong Jetpack Compose, bạn phải gói thành phần này trong AndroidView. Hướng dẫn này giải thích các trường hợp sử dụng phổ biến và cách hỗ trợ các trường hợp đó trong Compose.

Gói WebView bằng AndroidView

Để sử dụng WebView trong Compose, hãy gói thành phần này bằng 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)			
            }
        }
    )
}

Cách này phù hợp để hiển thị một URL đơn giản trong ứng dụng của bạn. Tuy nhiên, WebView xử lý các vòng đời trạng thái phức tạp tách biệt với vòng đời của Chế độ xem Android và vòng đời của Compose. Việc tích hợp Compose có thể gây ra các tình huống phức tạp WebView dẫn đến lỗi khó xử lý. Các phần sau đây mô tả những trường hợp sử dụng có thể cần xử lý cụ thể để hỗ trợ các tính năng đó.

Giữ nguyên trạng thái WebView

Việc xử lý các thay đổi về cấu hình và điều hướng trong Compose là một thách thức vì WebView là một View cũ liên kết với Activity lưu trữ và không nên để thực thể của nó tồn tại lâu hơn vòng đời của Activity.

Do đó, cách tiêu chuẩn để duy trì trạng thái của WebView là cho phép huỷ và tạo lại các thực thể WebView cùng với Activity. Bạn có thể duy trì nhật ký điều hướng nội bộ và trạng thái cuộn theo cách thủ công bằng cách sử dụng 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()
    )
}

Xử lý thao tác điều hướng quay lại

Khi WebView có nhật ký điều hướng, cử chỉ quay lại của hệ thống sẽ điều hướng quay lại trong WebView thay vì thoát khỏi màn hình.

Sử dụng API BackHandler của Compose để chặn sự kiện quay lại của hệ thống và gọi hàm 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
                }
            )
        }
    }
}

Cách triển khai này cung cấp hành vi điều hướng theo kiểu trình duyệt.

Cuộn lồng

Tính năng cuộn lồng nhau không được hỗ trợ dễ dàng khi sử dụng WebView trong Compose. Khi đặt WebView bên trong vùng chứa Compose có thể cuộn, chẳng hạn như LazyColumn, WebView có thể sử dụng tất cả cử chỉ cuộn. Vì WebView dựa vào công cụ kết xuất nội bộ riêng, nên việc lồng ghép thành phần này với LazyColumn hiện không hoạt động đúng cách.

Để theo dõi tiến trình hỗ trợ chính thức cho tính năng cuộn lồng nhau cho WebView, hãy xem vấn đề này.

Bố cục tràn viền và phần lồng ghép cửa sổ

Khi sử dụng bố cục tràn viền, WebView nội dung có thể xuất hiện bên dưới các thanh hệ thống, chẳng hạn như thanh trạng thái. Bạn có thể sử dụng đối tượng sửa đổi windowInsetsPadding để đẩy toàn bộ WebView vào vùng an toàn:

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

Để biết thêm thông tin về phần lồng ghép, hãy xem bài viết Tìm hiểu về phần lồng ghép cửa sổ trong WebView.

Đồng bộ hoá giao diện ứng dụng với nội dung WebView

Khi ứng dụng chuyển đổi giữa chế độ sáng và tối, nội dung WebView có thể tự động cập nhật mà không cần tải lại trang nếu được xử lý đúng cách.

Nếu bạn sở hữu nội dung trang web, để đồng bộ hoá màu sắc với giao diện của ứng dụng, hãy xử lý truy vấn phương tiện prefers-color-scheme để đảm bảo trang web của bạn thích ứng với giao diện đã chọn.

Để cho phép các phần tử gốc như trình đơn thả xuống và cửa sổ bật lên phát hiện và khớp với giao diện ứng dụng, hãy áp dụng giao diện kiểu DayNight cho Activity. của bạn

<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)
            }
        }
    )
} 

Nếu trang web không có giao diện tối hoặc nếu bạn không sở hữu nội dung trên web, thì thuật toán làm tối có thể giúp buộc áp dụng giao diện tối. Các trang web hiện đại đã có chế độ tối sẽ bỏ qua thuật toán này và sử dụng kiểu tích hợp sẵn của riêng chúng.

Xử lý quyền truy cập web trong Compose

Khi một trang web yêu cầu quyền truy cập vào phần cứng hoặc dữ liệu (ví dụ: máy ảnh, micrô hoặc vị trí), WebView sẽ kích hoạt các lệnh gọi lại cụ thể trong WebChromeClient. Bạn phải xử lý các lệnh gọi lại này và đảm bảo cấp các quyền tương ứng khi bắt đầu chạy Android.

Xử lý quyền truy cập vào camera và micrô

Khi một trang web yêu cầu quyền truy cập vào camera hoặc micrô (ví dụ: WebRTC hoặc quay video), WebView sẽ gọi WebChromeClient.onPermissionRequest.

Tuy nhiên, trước khi gọi grant(), bạn phải yêu cầu các quyền khi bắt đầu chạy sau đây của Android:

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

Trước tiên, hãy xác định một trình xử lý quyền cho WebView để theo dõi PermissionRequest được yêu cầu từ WebView:

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
    }
}

Tiếp theo, hãy tạo một thành phần kết hợp ghi nhớ WebViewPermissionHandler. Sử dụng rememberLauncherForActivityResult để yêu cầu cấp quyền:

@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 }
    }
}

Xử lý quyền từ lệnh gọi lại onPermissionRequest. Thao tác này sẽ khởi chạy trình chạy quyền:

@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()
    )
}

Giải pháp thay thế cho WebView được nhúng

Nếu bạn không muốn nhúng WebView, Android sẽ cung cấp các lựa chọn khác để hiển thị nội dung trên web, chẳng hạn như Thẻ tuỳ chỉnh của Chrome. Hãy xem bài viết Sử dụng nội dung trên web trong ứng dụng Android để tìm hiểu cách chọn phương pháp phù hợp cho các trường hợp sử dụng của bạn (chẳng hạn như duyệt web hoặc xác thực).

Tài nguyên khác