Jetpack Compose : 优雅的使用WebView

8,993 阅读5分钟

前言

如果把 Android 比作一台车的话,传统 View 相当于手动档,而 Compose 则相当于自动档,用过 Compose 就再也回不去了。
今天便来探讨下在 Compose 中如何使用 WebView 及其优化。

最终效果图先行:

ezgif-3-3ae2bbcc04.gif

在 Compose 中使用 WebView

那么回到今天的主题,在 Compose 如何使用 WebView 呢?
目前为止 Compose 还没有提供 WebView 的可组合项,因此我们要通过 AndroidView 来自定义实现WebView

改造 WebViewManager 实现前进和后退功能

之前写过 满满的WebView优化干货,让你的H5实现秒开体验。 效果还不错,所以在此基础上优化WebViewManager,由于官方对webview的优化(手机性能和用户网络等一系列的提升)预加载的收益已经不太明显,故移除相关代码而把重点放在更多的缓存实现上。

代码如下,为方便阅读,本文只贴关键代码,完整代码移步 fragmject · github

class WebViewManager private constructor() {

    companion object {
        @Volatile
        private var INSTANCE: WebViewManager? = null

        private fun getInstance() = INSTANCE ?: synchronized(WebViewManager::class.java) {
            INSTANCE ?: WebViewManager().also { INSTANCE = it }
        }

        fun prepare(context: Context) {
            getInstance().prepare(context)
        }

        fun destroy() {
            getInstance().destroy()
        }

        fun obtain(context: Context, url: String): WebView {
            return getInstance().obtain(context, url)
        }

        fun recycle(webView: WebView) {
            getInstance().recycle(webView)
        }

        fun isAssetsResource(request: WebResourceRequest): Boolean {
            return getInstance().isAssetsResource(request)
        }

        fun isCacheResource(request: WebResourceRequest): Boolean {
            return getInstance().isCacheResource(request)
        }

        fun assetsResourceRequest(
            context: Context,
            request: WebResourceRequest
        ): WebResourceResponse? {
            return getInstance().assetsResourceRequest(context, request)
        }

        fun cacheResourceRequest(
            context: Context,
            request: WebResourceRequest
        ): WebResourceResponse? {
            return getInstance().cacheResourceRequest(context, request)
        }
    }

    private val webViewMap = mutableMapOf<String, WebView>()
    private val backStack: ArrayDeque<String> = ArrayDeque()
    private val lruCache: LRUCache<String, String> = LRUCache(1000)
    private val acceptImage = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
    
    private fun create(context: Context): WebView {
        val webView = WebView(context)
        webView.setBackgroundColor(Color.TRANSPARENT)
        webView.overScrollMode = WebView.OVER_SCROLL_NEVER
        webView.isVerticalScrollBarEnabled = false
        val webSettings = webView.settings
        webSettings.setSupportZoom(true)
        webSettings.allowFileAccess = true
        webSettings.cacheMode = WebSettings.LOAD_DEFAULT
        webSettings.domStorageEnabled = true
        webSettings.javaScriptEnabled = true
        webSettings.loadWithOverviewMode = true
        webSettings.displayZoomControls = false
        webSettings.useWideViewPort = true
        webSettings.mediaPlaybackRequiresUserGesture = true
        webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
        CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)
        return webView
    }

    private fun prepare(context: Context) {
        Looper.myQueue().addIdleHandler {
            val cachePath = CacheUtils.getDirPath(context, "web_cache")
            File(cachePath).takeIf { it.isDirectory }?.listFiles()?.sortedWith(compareByDescending {
                //文件创建时间越久说明使用频率越高
                //通过时间倒序排序防止高频文件初始化时位于队首易被淘汰
                val attrs = Files.readAttributes(it.toPath(), BasicFileAttributes::class.java)
                attrs.creationTime().toMillis()
            })?.forEach {
                val absolutePath = it.absolutePath
                //put会返回被被淘汰的元素
                lruCache.put(absolutePath, absolutePath)?.let { path ->
                    //删除被淘汰的文件
                    File(path).delete()
                }
            }
            false
        }
    }

    private fun obtain(context: Context, url: String): WebView {
        val webView = webViewMap.getOrPut(url) {
            backStack.add(url)
            create(context)
        }
        if (webView.parent != null) {
            (webView.parent as ViewGroup).removeView(webView)
        }
        if (webViewMap.size > 50) {
            try {
                webViewMap.remove(backStack.removeFirst())?.let {
                    it.removeParentView()
                    it.removeAllViews()
                    it.destroy()
                }
            } catch (e: Exception) {
                Log.e(this.javaClass.name, e.message.toString())
            }
        }
        return webView
    }

    private fun recycle(webView: WebView) {
        try {
            webView.removeParentView()
        } catch (e: Exception) {
            Log.e(this.javaClass.name, e.message.toString())
        }
    }
    
    private fun destroy() {
        try {
            webViewMap.destroyWebView()
        } catch (e: Exception) {
            Log.e(this.javaClass.name, e.message.toString())
        }
    }

    private fun MutableMap<String, WebView>.destroyWebView() {
        values.toList().forEach {
            it.removeParentView()
            it.removeAllViews()
            it.destroy()
        }
        clear()
    }

    private fun WebView.removeParentView(): WebView {
        if (parent != null) {
            (parent as ViewGroup).removeView(this)
        }
        val contextWrapper = context as MutableContextWrapper
        contextWrapper.baseContext = context.applicationContext
        return this
    }

    fun isAssetsResource(request: WebResourceRequest): Boolean {
        val url = request.url.toString()
        return url.startsWith("file:///android_asset/")
    }

    fun isCacheResource(request: WebResourceRequest): Boolean {
        val url = request.url.toString()
        //忽略掉百度统计
        if (url.contains("hm.baidu.com/hm.gif")) return false
        val extension = request.getExtensionFromUrl()
        if (extension == "text/html") return true
        if (extension.isBlank()) {
            val accept = request.requestHeaders["Accept"] ?: return false
            if (accept == acceptImage && request.method.equals("GET", true)) {
                return true
            }
        }
        return extension in listOf(
            "ico",
            "bmp",
            "gif",
            "jpeg",
            "jpg",
            "png",
            "svg",
            "webp",
            "css",
            "js",
            "json",
            "eot",
            "otf",
            "ttf",
            "woff"
        )
    }

    fun assetsResourceRequest(context: Context, request: WebResourceRequest): WebResourceResponse? {
        try {
            val url = request.url.toString()
            val filename = url.substringAfterLast("/")
            val suffix = url.substringAfterLast(".")
            val mimeType = request.getMimeTypeFromUrl()
            val encoding = context.assets.open(suffix + File.separator + filename)
            return WebResourceResponse(mimeType, null, encoding).apply {
                responseHeaders = mapOf("access-control-allow-origin" to "*")
            }
        } catch (e: Exception) {
            Log.e(this.javaClass.name, e.message.toString())
        }
        return null
    }

    fun cacheResourceRequest(context: Context, request: WebResourceRequest): WebResourceResponse? {
        try {
            val url = request.url.toString()
            val cachePath = CacheUtils.getDirPath(context, "web_cache")
            val fileName = url.encodeUtf8().md5().hex()
            val key = cachePath + File.separator + fileName
            val file = File(key)
            if (!file.exists() || !file.isFile) {
                runBlocking {
                    download(cachePath, fileName) {
                        setUrl(url)
                        putHeader(request.requestHeaders)
                    }
                    lruCache.put(key, key)?.let { path ->
                        File(path).delete()
                    }
                }
            }
            if (file.exists() && file.isFile) {
                val mimeType = request.getMimeTypeFromUrl()
                return WebResourceResponse(mimeType, null, file.inputStream()).apply {
                    responseHeaders = mapOf("access-control-allow-origin" to "*")
                }
            }
        } catch (e: Exception) {
            Log.e(this.javaClass.name, e.message.toString())
        }
        return null
    }

    private fun WebResourceRequest.getExtensionFromUrl(): String {
        return try {
            MimeTypeMap.getFileExtensionFromUrl(url.toString())
        } catch (e: Exception) {
            Log.e(this.javaClass.name, e.message.toString())
            "*/*"
        }
    }

    private fun WebResourceRequest.getMimeTypeFromUrl(): String {
        return try {
            when (val extension = getExtensionFromUrl()) {
                "", "null", "*/*" -> "*/*"
                "json" -> "application/json"
                "text/html" -> extension
                else -> MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "*/*"
            }
        } catch (e: Exception) {
            Log.e(this.javaClass.name, e.message.toString())
            "*/*"
        }
    }
}

通过 AndroidView 来自定义 WebView 可组合

@Composable
fun WebView(
    url: String,
    navigator: WebViewNavigator,
    modifier: Modifier = Modifier,
    onReceivedTitle: (title: String?) -> Unit = {},
    onCustomView: (view: View?) -> Unit = {},
    shouldOverrideUrl: (url: String) -> Unit = {},
) {
    var webView by remember { mutableStateOf<WebView?>(null) }
    var injectState by remember { mutableStateOf(false) }
    var showDialog by remember { mutableStateOf(false) }
    var extra by remember { mutableStateOf<String?>(null) }
    LaunchedEffect(webView, navigator) {
        webView?.let {
            with(navigator) {
                handleNavigationEvents(
                    reload = { it.reload() }
                )
            }
        }
    }
    val resourceToPermissionMap = mapOf(
        "android.webkit.resource.VIDEO_CAPTURE" to Manifest.permission.CAMERA,
        "android.webkit.resource.AUDIO_CAPTURE" to Manifest.permission.RECORD_AUDIO
    )
    var permissionRequest by remember { mutableStateOf<PermissionRequest?>(null) }
    val contract = ActivityResultContracts.RequestMultiplePermissions()
    val requestPermissions = rememberLauncherForActivityResult(contract) { result ->
        permissionRequest?.apply {
            var isGranted = true
            result.entries.forEach { entry ->
                if (!entry.value) {
                    isGranted = false
                }
            }
            if (isGranted) {
                grant(resources)
            }
        }
    }
    LaunchedEffect(permissionRequest) {
        val permissions = mutableListOf<String>()
        permissionRequest?.resources?.forEach { resource ->
            resourceToPermissionMap[resource]?.let { permission ->
                permissions.add(permission)
            }
        }
        permissions.toTypedArray().apply {
            requestPermissions.launch(this)
        }
    }
    AndroidView(
        factory = { context ->
            WebViewManager.obtain(context, url).apply {
                this.layoutParams = FrameLayout.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT
                )
                setDownloadListener { url, _, _, _, _ ->
                    try {
                        val intent = Intent(Intent.ACTION_VIEW, url.toUri())
                        intent.addCategory(Intent.CATEGORY_BROWSABLE)
                        context.startActivity(intent)
                    } catch (e: Exception) {
                        Log.e(this.javaClass.name, e.message.toString())
                    }
                }
                setOnLongClickListener {
                    val result = hitTestResult
                    when (result.type) {
                        WebView.HitTestResult.IMAGE_TYPE, WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE -> {
                            extra = result.extra
                            showDialog = true
                            true
                        }

                        else -> false
                    }
                }
                webChromeClient = object : WebChromeClient() {

                    override fun onProgressChanged(view: WebView, newProgress: Int) {
                        super.onProgressChanged(view, newProgress)
                        navigator.progress = (newProgress / 100f).coerceIn(0f, 1f)
                        if (newProgress > 80 && navigator.injectVConsole && !injectState) {
                            evaluateJavascript(context.injectVConsoleJs()) {}
                            injectState = true
                        }
                    }

                    override fun onReceivedTitle(view: WebView?, title: String?) {
                        super.onReceivedTitle(view, title)
                        onReceivedTitle(title)
                        view?.tag = title
                    }

                    override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
                        super.onShowCustomView(view, callback)
                        onCustomView(view)
                    }

                    override fun onHideCustomView() {
                        super.onHideCustomView()
                        onCustomView(null)
                    }

                    override fun onPermissionRequest(request: PermissionRequest?) {
                        permissionRequest = request
                    }
                }
                webViewClient = object : WebViewClient() {

                    override fun shouldInterceptRequest(
                        view: WebView?,
                        request: WebResourceRequest?
                    ): WebResourceResponse? {
                        if (view != null && request != null) {
                            when {
                                WebViewManager.isCacheResource(request) -> {
                                    return WebViewManager.cacheResourceRequest(context, request)
                                }

                                WebViewManager.isAssetsResource(request) -> {
                                    return WebViewManager.assetsResourceRequest(context, request)
                                }
                            }
                        }
                        return super.shouldInterceptRequest(view, request)
                    }

                    override fun shouldOverrideUrlLoading(
                        view: WebView?,
                        request: WebResourceRequest?
                    ): Boolean {
                        if (view == null || request == null) {
                            return false
                        }
                        val requestUrl = request.url.toString()
                        if (request.hasGesture()
                            && !request.isRedirect
                            && URLUtil.isNetworkUrl(requestUrl)
                            && requestUrl != url
                        ) {
                            shouldOverrideUrl(requestUrl)
                            return true
                        }
                        if (!URLUtil.isValidUrl(requestUrl)) {
                            try {
                                view.context.startActivity(Intent(Intent.ACTION_VIEW, request.url))
                            } catch (e: Exception) {
                                Log.e(this.javaClass.name, e.message.toString())
                            }
                            return true
                        }
                        return false
                    }

                    override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
                        super.onPageStarted(view, url, favicon)
                        injectState = false
                    }

                    override fun onPageFinished(view: WebView, url: String?) {
                        super.onPageFinished(view, url)
                        injectState = false
                    }
                }
                if (URLUtil.isValidUrl(url) && !URLUtil.isValidUrl(this.url)) {
                    this.loadUrl(url)
                }
                tag?.let { title ->
                    onReceivedTitle(title.toString())
                }
                webView = this
            }
        },
        modifier = modifier,
        onRelease = {
            WebViewManager.recycle(it)
        }
    )
    val context = LocalContext.current
    StandardDialog(
        show = showDialog,
        title = "提示",
        text = "你希望保存该图片吗?",
        onConfirm = {
            extra?.let {
                if (URLUtil.isValidUrl(it)) {
                    context.saveImagesToAlbum(it) { _, _ ->
                        Toast.makeText(context, "保存图片成功", Toast.LENGTH_SHORT).show()
                        showDialog = false
                    }
                } else {
                    var str = it
                    if (str.contains(",")) {
                        str = str.split(",")[1]
                    }
                    val array = Base64.decode(str, Base64.NO_WRAP)
                    val bitmap = BitmapFactory.decodeByteArray(array, 0, array.size)
                    context.saveImagesToAlbum(bitmap) { _, _ ->
                        Toast.makeText(context, "保存图片成功", Toast.LENGTH_SHORT).show()
                        showDialog = false
                    }
                }
            }
        },
        onDismiss = { showDialog = false },
    )
}
@Composable
fun rememberWebViewNavigator(
    coroutineScope: CoroutineScope = rememberCoroutineScope()
): WebViewNavigator =
    remember(coroutineScope) { WebViewNavigator(coroutineScope) }

完整的 WebScreen 代码

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun WebScreen(
    url: String,
    onNavigateToBookmarkHistory: () -> Unit = {},
    onNavigateUp: () -> Unit = {},
    shouldOverrideUrl: (url: String) -> Unit = {},
) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    var customView by remember { mutableStateOf<View?>(null) }
    var sheetValue by rememberSaveable { mutableStateOf(SheetValue.PartiallyExpanded) }
    val bottomSheetState = rememberStandardBottomSheetState(
        initialValue = sheetValue,
        confirmValueChange = {
            sheetValue = it
            true
        },
        skipHiddenState = false
    )
    val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState)
    val navigator = rememberWebViewNavigator()
    var title by remember { mutableStateOf<String?>("") }
    var bookmark by remember { mutableStateOf<History?>(null) }
    LaunchedEffect(url) {
        WanHelper.getBookmark().collect {
            WanHelper.getBookmark().collect { bk ->
                bookmark = bk.firstOrNull { it.url == url }
            }
        }
    }
    DisposableEffect(customView) {
        val activity = context as ComponentActivity
        val window = activity.window
        val insetsController = WindowCompat.getInsetsController(window, window.decorView)
        if (customView != null) {
            insetsController.hide(WindowInsetsCompat.Type.systemBars())
        }
        onDispose {
            insetsController.show(WindowInsetsCompat.Type.systemBars())
        }
    }
    Box {
        Scaffold(
            topBar = {
                TitleBar(
                    title = title.toString(),
                    navigationIcon = {
                        IconButton(
                            onClick = {
                                onNavigateUp()
                            },
                            modifier = Modifier.height(45.dp)
                        ) {
                            Icon(
                                Icons.AutoMirrored.Filled.ArrowBack,
                                contentDescription = null,
                                tint = MaterialTheme.colorScheme.onPrimaryContainer
                            )
                        }
                    },
                    actions = {
                        IconButton(
                            onClick = {
                                scope.launch {
                                    if (sheetValue == SheetValue.PartiallyExpanded) {
                                        bottomSheetState.expand()
                                    } else {
                                        bottomSheetState.partialExpand()
                                    }
                                }
                            }
                        ) {
                            Icon(
                                painter = painterResource(R.mipmap.ic_more_v),
                                contentDescription = null,
                                modifier = Modifier.padding(8.dp),
                                tint = MaterialTheme.colorScheme.onPrimaryContainer
                            )
                        }
                    })
            },
            contentWindowInsets = WindowInsets.statusBars
        ) { innerPadding ->
            BottomSheetScaffold(
                sheetContent = {
                    Row(modifier = Modifier.height(64.dp)) {
                        Button(
                            onClick = {
                                navigator.reload()
                                scope.launch { bottomSheetState.partialExpand() }
                            },
                            modifier = Modifier
                                .weight(1f)
                                .fillMaxHeight(),
                            shape = RoundedCornerShape(0),
                            colors = ButtonDefaults.buttonColors(
                                containerColor = MaterialTheme.colorScheme.surfaceContainer,
                                contentColor = MaterialTheme.colorScheme.onSurfaceVariant
                            ),
                            elevation = ButtonDefaults.buttonElevation(0.dp, 0.dp, 0.dp),
                            contentPadding = PaddingValues(
                                horizontal = 28.dp,
                                vertical = 18.dp
                            ),
                        ) {
                            Icon(
                                painter = painterResource(R.mipmap.ic_web_refresh),
                                contentDescription = null,
                                tint = MaterialTheme.colorScheme.onSurfaceVariant
                            )
                        }
                        Button(
                            onClick = {
                                try {
                                    val uri = url.toUri()
                                    val intent = Intent(Intent.ACTION_VIEW, uri)
                                    intent.addCategory(Intent.CATEGORY_BROWSABLE)
                                    context.startActivity(intent)
                                    scope.launch { bottomSheetState.partialExpand() }
                                } catch (e: Exception) {
                                    Log.e(this.javaClass.name, e.message.toString())
                                }
                            },
                            modifier = Modifier
                                .weight(1f)
                                .fillMaxHeight(),
                            shape = RoundedCornerShape(0),
                            colors = ButtonDefaults.buttonColors(
                                containerColor = MaterialTheme.colorScheme.surfaceContainer,
                                contentColor = MaterialTheme.colorScheme.onSurfaceVariant
                            ),
                            elevation = ButtonDefaults.buttonElevation(0.dp, 0.dp, 0.dp),
                            contentPadding = PaddingValues(
                                horizontal = 28.dp,
                                vertical = 18.dp
                            ),
                        ) {
                            Icon(
                                painter = painterResource(R.mipmap.ic_web_browse),
                                contentDescription = null,
                                tint = MaterialTheme.colorScheme.onSurfaceVariant
                            )
                        }
                        Button(
                            onClick = {
                                onNavigateToBookmarkHistory()
                                scope.launch { bottomSheetState.partialExpand() }
                            },
                            modifier = Modifier
                                .weight(1f)
                                .fillMaxHeight(),
                            shape = RoundedCornerShape(0),
                            colors = ButtonDefaults.buttonColors(
                                containerColor = MaterialTheme.colorScheme.surfaceContainer,
                                contentColor = MaterialTheme.colorScheme.onSurfaceVariant
                            ),
                            elevation = ButtonDefaults.buttonElevation(0.dp, 0.dp, 0.dp),
                            contentPadding = PaddingValues(
                                horizontal = 28.dp,
                                vertical = 18.dp
                            ),
                        ) {
                            Icon(
                                painter = painterResource(R.mipmap.ic_web_history),
                                contentDescription = null,
                                tint = MaterialTheme.colorScheme.onSurfaceVariant
                            )
                        }
                        Button(
                            onClick = {
                                scope.launch {
                                    if (bookmark != null) {
                                        WanHelper.deleteHistory(bookmark!!)
                                    } else {
                                        WanHelper.setBookmark(title.toString(), url)
                                    }
                                    bottomSheetState.partialExpand()
                                }
                            },
                            modifier = Modifier
                                .weight(1f)
                                .fillMaxHeight(),
                            shape = RoundedCornerShape(0),
                            colors = ButtonDefaults.buttonColors(
                                containerColor = MaterialTheme.colorScheme.surfaceContainer,
                                contentColor = MaterialTheme.colorScheme.onSurfaceVariant
                            ),
                            elevation = ButtonDefaults.buttonElevation(0.dp, 0.dp, 0.dp),
                            contentPadding = PaddingValues(
                                horizontal = 28.dp,
                                vertical = 18.dp
                            ),
                        ) {
                            Icon(
                                painter = painterResource(R.mipmap.ic_web_bookmark),
                                contentDescription = null,
                                tint = if (bookmark != null) {
                                    MaterialTheme.colorScheme.onSurface
                                } else {
                                    MaterialTheme.colorScheme.onSurfaceVariant
                                }
                            )
                        }
                    }
                },
                modifier = Modifier.padding(innerPadding),
                scaffoldState = scaffoldState,
                sheetPeekHeight = 0.dp,
                sheetShape = RoundedCornerShape(0.dp),
                sheetShadowElevation = 10.dp,
                sheetDragHandle = null,
                sheetSwipeEnabled = false
            ) { padding ->
                WebView(
                    url = url,
                    navigator = navigator,
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(padding),
                    onReceivedTitle = {
                        title = it
                        scope.launch {
                            WanHelper.setBrowseHistory(it.toString(), url)
                        }
                    },
                    onCustomView = { customView = it },
                    shouldOverrideUrl = shouldOverrideUrl,
                )
            }
        }
        customView?.let {
            AndroidView(factory = { _ -> it })
        }
    }
}

Thanks

以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。
如果觉得本篇文章对您有帮助的话请点个赞让更多人看到吧,您的鼓励是我前进的动力。
谢谢~~

源代码地址