美文网首页
Jetpack Compose : 让你的WebView丝滑流畅

Jetpack Compose : 让你的WebView丝滑流畅

作者: miaowmiaow | 来源:发表于2023-10-12 09:14 被阅读0次

前言

如果把 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 webViewCache: MutableList<WebView> = ArrayList(1)
    private val backStack: ArrayDeque<WebView> = ArrayDeque() //后退栈,用来保存后退页
    private val forwardStack: ArrayDeque<WebView> = ArrayDeque() //前进栈,用来保存前进页
    private var lastBackWebView: WeakReference<WebView?> = WeakReference(null)

    fun obtain(context: Context): WebView {
        if (webViewCache.isEmpty()) {
            webViewCache.add(create(MutableContextWrapper(context)))
        }
        val webView = webViewCache.removeFirst()
        val contextWrapper = webView.context as MutableContextWrapper
        contextWrapper.baseContext = context
        return webView
    }

    fun back(webView: WebView): Boolean {
        return try {
            webViewCache.add(0, backStack.removeLast())
            forwardStack.add(webView)
            true
        } catch (e: Exception) {
            lastBackWebView = WeakReference(webView)
            false
        }
    }

    fun forward(webView: WebView): Boolean {
        return try {
            webViewCache.add(0, forwardStack.removeLast())
            backStack.add(webView)
            true
        } catch (e: Exception) {
            false
        }
    }

    fun recycle(webView: WebView, canRecycle: Boolean) {
        try {
            removeParentView(webView)
            //在WebScreen中跳转到其它页面则直接添加到webViewCache中
            if (canRecycle) {
                //需要回收的webView不是后退栈底的webView则添加到后退栈中
                //否则将其重置后添加到webViewCache中
                if (lastBackWebView.get() != webView) {
                    if (!backStack.contains(webView) && !forwardStack.contains(webView)) {
                        backStack.addLast(webView)
                    }
                } else {
                    lastBackWebView.clear()
                    backStack.clear()
                    forwardStack.clear()
                    webView.stopLoading()
                    webView.clearHistory()
                    webView.loadDataWithBaseURL("about:blank", "", "text/html", "utf-8", null)
                    webViewCache.add(0, webView)
                }
            } else {
                webViewCache.add(0, webView)
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

}

先定义供外部调用 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(
    originalUrl: String,
    navigator: WebViewNavigator,
    modifier: Modifier = Modifier,
    canRecycle: Boolean = true,
    goBack: () -> Unit = {},
    goForward: () -> Unit = {},
    shouldOverrideUrl: (url: String) -> Unit = {},
    onNavigateUp: () -> Unit = {},
) {
    val url by remember { mutableStateOf(originalUrl) }
    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 = {
                        if (WebViewManager.forward(it)) {
                            goForward()
                        }
                    },
                    reload = {
                        it.reload()
                    }
                )
            }
        }
    }
    AndroidView(
        factory = { context ->
            WebViewManager.obtain(context).apply {
                webViewClient = object : WebViewClient() {

                     override fun shouldOverrideUrlLoading(
                        view: WebView?,
                        request: WebResourceRequest?
                    ): Boolean {
                        // request.isRedirect == true为重定向,则交给WebView处理,解决重定向白屏的关键
                        if (view != null && request != null && request.url != null && !request.isRedirect) {
                            if ("http" != request.url.scheme && "https" != request.url.scheme) {
                                try {
                                    view.context.startActivity(
                                        Intent(Intent.ACTION_VIEW, request.url)
                                    )
                                } catch (e: Exception) {
                                    e.printStackTrace()
                                }
                            } else {
                                shouldOverrideUrl(request.url.toString())
                            }
                            return true
                        }
                        return false
                    }

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

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

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

@Composable
fun WebNavGraph(
    originalUrl: String,
    webViewNavigator: WebViewNavigator,
    modifier: Modifier = Modifier,
    canRecycle: Boolean = true,
    onWebHistory: (isAdd: Boolean, text: String) -> Unit = { _, _ -> },
    onNavigateUp: () -> Unit = {},
) {
    val navController = rememberNavController()
    val navActions = remember(navController) { WebNavActions(navController) }
    NavHost(
        navController = navController,
        startDestination = WebDestinations.WEB_VIEW_ROUTE + "/${Uri.encode(originalUrl)}",
        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("${WebDestinations.WEB_VIEW_ROUTE}/{url}") { backStackEntry ->
            WebView(
                originalUrl = backStackEntry.arguments?.getString("url") ?: originalUrl,
                navigator = webViewNavigator,
                canRecycle = canRecycle,
                goBack = {
                    navActions.navigateUp()
                },
                goForward = {
                    navActions.navigateToWebView("about:blank")
                },
                shouldOverrideUrl = {
                    onWebHistory(true, it)
                    navActions.navigateToWebView(it)
                },
                onNavigateUp = onNavigateUp
            )
        }
    }
}

class WebNavActions(
    private val navController: NavHostController
) {
    val navigateToWebView: (url: String) -> Unit = {
        navController.navigate(
            WebDestinations.WEB_VIEW_ROUTE + "/${Uri.encode(it)}",
            navOptions { launchSingleTop = false }
        )
    }
    val navigateUp: () -> Unit = {
        navController.navigateUp()
    }
}

object WebDestinations {
    const val WEB_VIEW_ROUTE = "web_view_route"
}

完整的 WebScreen 代码

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun WebScreen(
    originalUrl: 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()
    var canRecycle by remember { mutableStateOf(true) } //当切换到其它页面时需要禁止回收
    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 = {
                            canRecycle = false
                            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
                                }
                            )
                        )
                    }
                }
            }
        ) {
            WebNavGraph(
                originalUrl = originalUrl,
                webViewNavigator = wvNavigator,
                modifier = Modifier.fillMaxSize(),
                canRecycle = canRecycle,
                onWebHistory = onWebHistory,
                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)
                )
            }
        }
    }
}

待实现优化

当前方案每次新页面都会新建 WebView ,下一步将实现复用机制。
目前的想法是创建前检查 backStackforwardStack 的数量,大于某个阈值则不在创建 WebView 直接从栈里取。
现在的问题是这个阈值如何确定,如果设置较大感觉没有达到复用的意义,较小用户体验又很差所以还在纠结中...

以上方案是针对个人项目框架和需求实现,可能有未考虑到的场景,希望大家能积极交流一起完善。

Thanks

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

源代码地址

相关文章

网友评论

      本文标题:Jetpack Compose : 让你的WebView丝滑流畅

      本文链接:https://www.haomeiwen.com/subject/sctnbdtx.html