前言
如果把 Android 比作一台车的话,传统 View 相当于手动档,而 Compose 则相当于自动档,用过 Compose 就再也回不去了。
今天便来探讨下在 Compose 中如何使用 WebView 及其优化。
最终效果图先行:
在 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
以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。
如果觉得本篇文章对您有帮助的话请点个赞让更多人看到吧,您的鼓励是我前进的动力。
谢谢~~