Jetpack Compose : 优雅的使用WebView

7,209 阅读4分钟

前言

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

最终效果图先行:

ezgif-3-3ae2bbcc04.gif

在 Compose 中使用 WebView

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

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

之前写过 满满的WebView优化干货,让你的H5实现秒开体验。 效果还不错,所以在此基础上扩展 WebViewManager实现前进和后退功能。
我们在 WebViewManager 中定义 backStackforwardStack 来保存后退页和前进页。

  • 当用户打开新 WebView 时将旧 WebView 保存到后退栈中。
  • 当用户按下后退时从 backStack 取出后退 WebView ,并将旧 WebView 保存到前进栈中。
  • 当用户按下前进时从 forwardStack 取出后退 WebView ,并将旧 WebView 保存到后退栈中。

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

class WebViewManager private constructor() {

	...

    private val webViewMap = mutableMapOf<String, WebView>()
    private val webViewQueue: ArrayDeque<WebView> = ArrayDeque()
    private val backStack: ArrayDeque<String> = ArrayDeque()
    private val forwardStack: ArrayDeque<String> = ArrayDeque()
    private var lastBackWebView: WeakReference<WebView?> = WeakReference(null)

    private fun obtain(context: Context, url: String): WebView {
        val webView = webViewMap.getOrPut(url) {
            getWebView(MutableContextWrapper(context))
        }
        if (webView.parent != null) {
            (webView.parent as ViewGroup).removeView(webView)
        }
        val contextWrapper = webView.context as MutableContextWrapper
        contextWrapper.baseContext = context
        return webView
    }

    private fun back(webView: WebView): Boolean {
        return try {
            backStack.removeLast()//通过NoSuchElementException判断是否处在第一页
            forwardStack.add(webView.originalUrl.toString())
            true
        } catch (e: Exception) {
            lastBackWebView = WeakReference(webView)
            false
        }
    }

    private fun forward(webView: WebView): Boolean {
        return try {
            val forwardLastUrl = forwardStack.removeLast()
            backStack.add(webView.originalUrl.toString())
            forwardLastUrl
        } catch (e: Exception) {
            Log.e(this.javaClass.name, e.message.toString())
            null
        }
    }

    private fun recycle(webView: WebView) {
         try {
            webView.removeParentView()
            val originalUrl = webView.originalUrl.toString()
            if (lastBackWebView.get() != webView) {
                if (!forwardStack.contains(originalUrl)) {
                    backStack.add(originalUrl)
                }
            } else {
                destroy()
                //重新缓存一个webView
                prepare(webView.context)
            }
        } catch (e: Exception) {
            Log.e(this.javaClass.name, e.message.toString())
        }
    }
    
    ...

}

先定义供外部调用 WebView 可组合项的 WebViewNavigator

原理也简单即通过 SharedFlow 来发送和接受命令

@Stable
class WebViewNavigator(
    private val coroutineScope: CoroutineScope
) {
    private sealed interface NavigationEvent {
        data object Back : NavigationEvent
        data object Forward : NavigationEvent
        data object Reload : NavigationEvent
    }

    private val navigationEvents: MutableSharedFlow<NavigationEvent> = MutableSharedFlow()

    var lastLoadedUrl: String? by mutableStateOf(null)
        internal set
    var progress: Float by mutableFloatStateOf(0f)
        internal set

    @OptIn(FlowPreview::class)
    internal suspend fun handleNavigationEvents(
        onBack: () -> Unit = {},
        onForward: () -> Unit = {},
        reload: () -> Unit = {},
    ) = withContext(Dispatchers.Main) {
        // 设置350(切换动画时间)的防抖,防止WebView回收未完成导致的崩溃
        navigationEvents.debounce(350).collect { event ->
            when (event) {
                is NavigationEvent.Back -> onBack()
                is NavigationEvent.Forward -> onForward()
                is NavigationEvent.Reload -> reload()
            }
        }
    }

    fun navigateBack() {
        coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) }
    }

    fun navigateForward() {
        coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) }
    }

    fun reload() {
        coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) }
    }

}

通过 AndroidView 来自定义 WebView 可组合

@Composable
fun WebView(
    url: String,
    navigator: WebViewNavigator,
    modifier: Modifier = Modifier,
    goBack: () -> Unit = {},
    goForward: (url: String?) -> Unit = {},
    shouldOverrideUrl: (url: String) -> Unit = {},
    onNavigateUp: () -> Unit = {},
) {
    var webView by remember { mutableStateOf<WebView?>(null) }
    BackHandler(true) {
        navigator.navigateBack()
    }
    webView?.let {
        LaunchedEffect(it, navigator) {
            with(navigator) {
                handleNavigationEvents(
                    onBack = {
                        if (WebViewManager.back(it)) {
                            goBack()
                        } else {
                            onNavigateUp()
                        }
                    },
                    onForward = {
                        goForward(WebViewManager.forward(it))
                    },
                    reload = {
                        it.reload()
                    }
                )
            }
        }
    }
    AndroidView(
        factory = { context ->
            WebViewManager.obtain(context, url).apply {
                webViewClient = object : WebViewClient() {

                     override fun shouldOverrideUrlLoading(
                        view: WebView?,
                        request: WebResourceRequest?
                    ): Boolean {
                        if (view == null || request == null) {
                            return false
                        }
                        val requestUrl = request.url.toString()
                        if (!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
                    }

                }
                if (url.isValidURL() && !this.url.isValidURL()) {
                    this.loadUrl(url)
                }
            }.also { webView = it }
        },
        modifier = modifier,
        onRelease = {
            WebViewManager.recycle(it)
        }
    )
}
@Composable
fun rememberWebViewNavigator(
    coroutineScope: CoroutineScope = rememberCoroutineScope()
): WebViewNavigator =
    remember(coroutineScope) { WebViewNavigator(coroutineScope) }

通过 Navigation 实现跳转和切换动画

WebView 的跳转和切换动画通过 Navigation 实现。
Compose 中使用 Navigation 写起来跟 when 表达式一样,所以没有过的童鞋也无需担心,看下代码就差不多能理解了。

@Composable
fun WebViewNavGraph(
    url: String,
    navigator: WebViewNavigator,
    modifier: Modifier = Modifier,
    shouldOverrideUrl: (url: String) -> Unit = {},
    onNavigateUp: () -> Unit = {},
) {
    val navController = rememberNavController()
    val navActions = remember(navController) { WebViewNavActions(navController) }
    NavHost(
        navController = navController,
        startDestination = WebViewDestinations.WEB_VIEW_ROUTE + "/${Uri.encode(url)}",
        modifier = modifier,
        enterTransition = {
            slideIntoContainer(
                AnimatedContentTransitionScope.SlideDirection.Left,
                animationSpec = tween(350)
            )
        },
        exitTransition = {
            slideOutOfContainer(
                AnimatedContentTransitionScope.SlideDirection.Left,
                animationSpec = tween(350)
            )
        },
        popEnterTransition = {
            slideIntoContainer(
                AnimatedContentTransitionScope.SlideDirection.Right,
                animationSpec = tween(350)
            )
        },
        popExitTransition = {
            slideOutOfContainer(
                AnimatedContentTransitionScope.SlideDirection.Right,
                animationSpec = tween(350)
            )
        },
    ) {
        composable("${WebViewDestinations.WEB_VIEW_ROUTE}/{url}") { backStackEntry ->
            WebView(
                url = backStackEntry.arguments?.getString("url") ?: url,
                navigator = navigator,
                goBack = {
                    if (navActions.canBack()) {
                        navActions.navigateBack()
                    } else {
                        onNavigateUp()
                    }
                },
                goForward = {
                    if (it != null) {
                        navActions.navigateToWebView(it)
                    }
                },
                shouldOverrideUrl = {
                    shouldOverrideUrl(it)
                    navActions.navigateToWebView(it)
                },
                onNavigateUp = onNavigateUp
            )
        }
    }
}

class WebViewNavActions(
    private val navController: NavHostController
) {
    val canBack: () -> Boolean = {
        navController.previousBackStackEntry != null
    }
    val navigateBack: () -> Unit = {
        navController.navigateUp()
    }
    val navigateToWebView: (url: String) -> Unit = { url ->
        navController.navigate(
            WebViewDestinations.WEB_VIEW_ROUTE + "/${Uri.encode(url)}",
            navOptions { launchSingleTop = false }
        )
    }
}

object WebViewDestinations {
    const val WEB_VIEW_ROUTE = "web_view_route"
}

完整的 WebScreen 代码

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun WebScreen(
    url: String,
    webBookmarkList: List<String>,
    onWebBookmark: (isAdd: Boolean, text: String) -> Unit = { _, _ -> },
    onWebHistory: (isAdd: Boolean, text: String) -> Unit = { _, _ -> },
    onNavigateToBookmarkHistory: () -> Unit = {},
    onNavigateUp: () -> Unit = {},
) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    val sheetState = rememberModalBottomSheetState(
        initialValue = ModalBottomSheetValue.Hidden,
    )
    val wvNavigator = rememberWebViewNavigator()
    Column(
        modifier = Modifier
            .background(colorResource(R.color.white))
            .fillMaxSize()
            .systemBarsPadding()
    ) {
        ModalBottomSheetLayout(
            sheetState = sheetState,
            modifier = Modifier.weight(1f),
            sheetContent = {
                Row(
                    modifier = Modifier
                        .background(colorResource(R.color.white))
                        .height(50.dp)
                ) {
                    Button(
                        onClick = {
                            try {
                                val uri = Uri.parse(wvNavigator.lastLoadedUrl)
                                val intent = Intent(Intent.ACTION_VIEW, uri)
                                intent.addCategory(Intent.CATEGORY_BROWSABLE)
                                context.startActivity(intent)
                                scope.launch { sheetState.hide() }
                            } catch (e: Exception) {
                                e.printStackTrace()
                            }
                        },
                        elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
                        shape = RoundedCornerShape(0),
                        colors = ButtonDefaults.buttonColors(
                            backgroundColor = colorResource(R.color.white),
                            contentColor = colorResource(R.color.theme)
                        ),
                        contentPadding = PaddingValues(16.dp),
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxHeight()
                    ) {
                        Icon(
                            painter = painterResource(R.mipmap.ic_web_browse),
                            contentDescription = null,
                            tint = colorResource(R.color.theme)
                        )
                    }
                    Button(
                        onClick = {
                            onNavigateToBookmarkHistory()
                            scope.launch { sheetState.hide() }
                        },
                        elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
                        shape = RoundedCornerShape(0),
                        colors = ButtonDefaults.buttonColors(
                            backgroundColor = colorResource(R.color.white),
                            contentColor = colorResource(R.color.theme)
                        ),
                        contentPadding = PaddingValues(16.dp),
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxHeight()
                    ) {
                        Icon(
                            painter = painterResource(R.mipmap.ic_web_history),
                            contentDescription = null,
                            tint = colorResource(R.color.theme)
                        )
                    }
                    Button(
                        onClick = {
                            onWebBookmark(
                                !webBookmarkList.contains(wvNavigator.lastLoadedUrl),
                                wvNavigator.lastLoadedUrl.toString()
                            )
                        },
                        elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
                        shape = RoundedCornerShape(0),
                        colors = ButtonDefaults.buttonColors(
                            backgroundColor = colorResource(R.color.white),
                            contentColor = colorResource(R.color.theme)
                        ),
                        contentPadding = PaddingValues(16.dp),
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxHeight()
                    ) {
                        Icon(
                            painter = painterResource(R.mipmap.ic_web_bookmark),
                            contentDescription = null,
                            tint = colorResource(
                                if (webBookmarkList.contains(wvNavigator.lastLoadedUrl)) {
                                    R.color.theme_orange
                                } else {
                                    R.color.theme
                                }
                            )
                        )
                    }
                    Button(
                        onClick = {
                            wvNavigator.injectVConsole()
                            scope.launch { sheetState.hide() }
                        },
                        elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
                        shape = RoundedCornerShape(0),
                        colors = ButtonDefaults.buttonColors(
                            backgroundColor = colorResource(R.color.white),
                            contentColor = colorResource(R.color.theme)
                        ),
                        contentPadding = PaddingValues(16.dp),
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxHeight()
                    ) {
                        Icon(
                            painter = painterResource(R.mipmap.ic_web_debug),
                            contentDescription = null,
                            tint = colorResource(
                                if (wvNavigator.injectVConsole) {
                                    R.color.theme_orange
                                } else {
                                    R.color.theme
                                }
                            )
                        )
                    }
                }
            }
        ) {
            WebViewNavGraph(
                url = url,
                navigator = navigator,
                modifier = Modifier.fillMaxSize(),
                shouldOverrideUrl = {
                    onWebHistory(true, it)
                },
                onNavigateUp = onNavigateUp
            )
        }
        AnimatedVisibility(visible = (wvNavigator.progress > 0f && wvNavigator.progress < 1f)) {
            LinearProgressIndicator(
                progress = wvNavigator.progress,
                modifier = Modifier.fillMaxWidth(),
                color = colorResource(R.color.theme_orange),
                backgroundColor = colorResource(R.color.white)
            )
        }
        Row(
            modifier = Modifier
                .background(colorResource(R.color.white))
                .height(50.dp)
        ) {
            Button(
                onClick = {
                    wvNavigator.navigateBack()
                },
                elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
                shape = RoundedCornerShape(0),
                colors = ButtonDefaults.buttonColors(
                    backgroundColor = colorResource(R.color.white),
                    contentColor = colorResource(R.color.theme)
                ),
                contentPadding = PaddingValues(17.dp),
                modifier = Modifier
                    .weight(1f)
                    .fillMaxHeight()
            ) {
                Icon(
                    painter = painterResource(R.mipmap.ic_web_back),
                    contentDescription = null,
                    tint = colorResource(R.color.theme)
                )
            }
            Button(
                onClick = {
                    wvNavigator.navigateForward()
                },
                elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
                shape = RoundedCornerShape(0),
                colors = ButtonDefaults.buttonColors(
                    backgroundColor = colorResource(R.color.white),
                    contentColor = colorResource(R.color.theme)
                ),
                contentPadding = PaddingValues(17.dp),
                modifier = Modifier
                    .weight(1f)
                    .fillMaxHeight()
            ) {
                Icon(
                    painter = painterResource(R.mipmap.ic_web_forward),
                    contentDescription = null,
                    tint = colorResource(R.color.theme)
                )
            }
            Button(
                onClick = {
                    wvNavigator.reload()
                },
                elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
                shape = RoundedCornerShape(0),
                colors = ButtonDefaults.buttonColors(
                    backgroundColor = colorResource(R.color.white),
                    contentColor = colorResource(R.color.theme)
                ),
                contentPadding = PaddingValues(15.dp),
                modifier = Modifier
                    .weight(1f)
                    .fillMaxHeight()
            ) {
                Icon(
                    painter = painterResource(R.mipmap.ic_web_refresh),
                    contentDescription = null,
                    tint = colorResource(R.color.theme)
                )
            }
            Button(
                onClick = {
                    scope.launch {
                        if (sheetState.isVisible) {
                            sheetState.hide()
                        } else {
                            sheetState.show()
                        }
                    }
                },
                elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp),
                shape = RoundedCornerShape(0),
                colors = ButtonDefaults.buttonColors(
                    backgroundColor = colorResource(R.color.white),
                    contentColor = colorResource(R.color.theme)
                ),
                contentPadding = PaddingValues(16.dp),
                modifier = Modifier
                    .weight(1f)
                    .fillMaxHeight()
            ) {
                Icon(
                    painter = painterResource(R.mipmap.ic_web_more),
                    contentDescription = null,
                    tint = colorResource(R.color.theme)
                )
            }
        }
    }
}

Thanks

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

源代码地址

推荐阅读