前言
如果把 Android
比作一台车的话,传统 View
相当于手动档,而 Compose
则相当于自动档,用过 Compose
就再也回不去了。
今天便来探讨下在 Compose
中如何使用 WebView 及其优化。
最终效果图先行:
![](https://img.haomeiwen.com/i26537659/8e60c86fdf91910b.gif)
在 Compose 中使用 WebView
那么回到今天的主题,在 Compose
如何使用 WebView
呢?
目前为止 Compose
还没有提供 WebView
的可组合项,因此我们要通过 AndroidView
来自定义实现WebView
。
改造 WebViewManager 实现前进和后退功能
之前写过 满满的WebView优化干货,让你的H5实现秒开体验。 效果还不错,所以在此基础上扩展 WebViewManager
实现前进和后退功能。
我们在 WebViewManager
中定义 backStack
和 forwardStack
来保存后退页和前进页。
- 当用户打开新
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
,下一步将实现复用机制。
目前的想法是创建前检查 backStack
和 forwardStack
的数量,大于某个阈值则不在创建 WebView
直接从栈里取。
现在的问题是这个阈值如何确定,如果设置较大感觉没有达到复用的意义,较小用户体验又很差所以还在纠结中...
以上方案是针对个人项目框架和需求实现,可能有未考虑到的场景,希望大家能积极交流一起完善。
Thanks
以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。
如果觉得本篇文章对您有帮助的话请点个赞让更多人看到吧,您的鼓励是我前进的动力。
谢谢~~
网友评论