WebView 静态页面秒加载方案要点

Github: github.com/oOJohn6Oo/W...

效果

以静态 html 渲染流式返回的数据为例,当前方案可实现:

  1. WebView 复用省去创建 WebView 的耗时
  2. 避免重复 loadUrl 实现数据秒渲染

掘金必须用自己的图床并且必须带水印,原图可以去 Github 看。

具体实现

缓存池实现

  • 这里使用 host + path 作为 key,WebView 作为 value,构建 WebView 缓存池。
  • 使用 MutableContextWrapper 处理复用时 Context 变化的情况。
    > 极简 WebViewCache.kt
kt 复制代码
object WebViewCache {
    private const val MAX_POOL_SIZE = 8

    private val mPools: MutableMap<String, Pools.Pool<WebView>> = mutableMapOf()
    private val emptyWebViewClient = object : WebViewClient() {}

    fun acquireOrCreate(context: Context, url: Uri? = null): WebView {
        val hostAndPath = (url?.host ?: "") + (url?.path ?: "")
        if (!mPools.contains(hostAndPath)) {
            mPools[hostAndPath] = Pools.SynchronizedPool(MAX_POOL_SIZE)
        }
        var webView = mPools[hostAndPath]?.acquire()

        if (webView == null) {
            val wrapper = MutableContextWrapper(context)
            webView = WebView(wrapper)
        } else {
            (webView.context as? MutableContextWrapper)?.baseContext = context
        }
        return webView
    }

    @MainThread
    fun releaseSafe(webView: WebView) = webView.apply {
        Log.d(TAG, "releaseWebView $webView")
        (webView.parent as? ViewGroup)?.removeView(webView)
        webView.stopLoading()
        (webView.context as? MutableContextWrapper)?.baseContext = appContext
        webView.webViewClient = emptyWebViewClient
        val uri = webView.url?.toUri()
        val hostAndPath = (uri?.host ?: "") + (uri?.path ?: "")
        mPools[hostAndPath]?.release(webView)
    }
}

具体使用

  • 使用时根据 url 是否为空判断应该 loadUrl 还是直接调用 JS 方法
  • 如果是全新创建的 WebView,则在 onPageFinished 回调中调用
    > JsWithCacheWebFragment.kt
kt 复制代码
class JsWithCacheWebFragment: BaseWebFragment() {

    private var currentWebView: WebView? = null

    override fun getWebView(): WebView {
        val desireUrl = "file:///android_asset/demo_js_interface.html"

        return WebViewCache.acquireOrCreate(requireContext(), desireUrl.toUri()).apply {
            this@JsWithCacheWebFragment.currentWebView = this
            WebViewCache.setupWebView(this)
            webViewClient = object: WebViewClient(){
                override fun onPageFinished(view: WebView?, url: String?) {
                    super.onPageFinished(view, url)
                    printMsg.add("${SystemClock.uptimeMillis() - startTime}ms pageFinished, progress:${view?.progress}")
                    if(view?.progress == 100){
                        startCollectData(view)
                    }
                }
            }
            if (this.url.isNullOrBlank()) {
                loadUrl(desireUrl)
            } else {
                startCollectData(this)
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        currentWebView?.also { WebViewCache.releaseSafe(it) }
        currentWebView = null
    }
}

关键问题点及解决方案

1. WebView 创建耗时

WebView 的创建及其相关操作必须在主线程,这是 Android 强制的,我们只能将创建提前,放到缓存池中,使用时从缓存池中取,不用时及时释放到缓存池。目前没有其他办法优化。

2. loadUrl 耗时

其实创建只是初次耗时会久一些,而 loadUrl稳定地慢,测试本地 html 文件最快都要差不多 50 ms,所以避免调用这个方法是优化耗时的关键。

3. onPageFinished 回调多次

Android Webview 内部机制决定的,可以通过 WebView.progress == 100 来判断

4. 进入页面但缓存池空白

不提前"预热",首次使用肯定还是避免不了 WebView 创建和 laodUrl,只能提前加载。 提前加载需要确定合适的时机,一般是推荐通过 idleHandler 完成。

kt 复制代码
val preloadIdleHandler = object : MessageQueue.IdleHandler {
    override fun queueIdle(): Boolean {
        if (mPools.isNotEmpty()) return false
        val desireUrl = "file:///android_asset/demo_js_interface.html"
        preloadWebView(desireUrl)
        return false
    }
}
fun preloadWebView(desireUrl: String) {
    acquireOrCreate(appContext, desireUrl.toUri()).apply {
        setupWebView(this)
        webViewClient = object : WebViewClient() {
            override fun onPageFinished(view: WebView?, url: String?) {
                super.onPageFinished(view, url)
                view?.let { releaseSafe(it) }
            }
        }
        loadUrl(desireUrl)
    }
}

然后在合适的时机调用

kt 复制代码
Looper.getMainLooper().queue.addIdleHandler(WebViewCache.preloadIdleHandler)

5. 内存泄漏

主要是 context 和 WebViewClient,我们在 release 的时候重置下就可以了。

7. 内存占用可能太高

主要还是跟渲染的页面内容相关,WebView 本身其实占用内存不是很大,多个实例也不会导致内存占用线性增长。

可以通过优化代码逻辑及调整缓存池大小来降低内存。

8. 可能更快的 WEB 通信方式

一直听说 WebMessagePort 相较于 JSInterface 性能更优,但是 Demo 上这种极简场景没看出有性能差别,可能交互频繁程度还是不够,ChatGPT 说适合传输音视频流的场景。

3. Demo 效果中 ViewPager2 + BottomSheet 下的一些关键问题点及其解决方案

1. 实现嵌套滑动,解决可能滑不动的问题

ViewPager2 内部其实是 RecyclerView 实现的,需要先禁用它的嵌套滑动,然后内容页面因为要与 BottomSheet 联动,需要使用 NestedScrollView

kt 复制代码
(mBinding.viewpagerFgWeb.getChildAt(0) as RecyclerView).apply {
    isNestedScrollingEnabled = false
}

另外可能会遇到切换 Tab 后无法嵌套滑动的情况,这是 BottomSheet 的已知问题,在 这篇分析文章 中有详细说明,但,Demo 中没有遇到这个问题。

1. 如何实现预加载下一页、最多同时存在三个 Tab

强烈推荐阅读这篇 ViewPager2 分析文章,简单来说:

  • offscreenPageLimit = 1 实现预加载下一页
  • isItemPrefetchEnabled = false + setItemViewCacheSize(0) 实现最多同时存在三个 Tab

2. 点击序号快速切换 Tab 卡顿

因为 ViewPager2 内部是 RecyclerView,而快速切换默认使用 smoothScrollTo,这会导致沿途经过的 Tab 可能会被快速加载,然后快速销毁,当逻辑一复杂,同时重建再销毁五六个 Tab 性能当然会变差。

为了避免这个问题,同时又支持左右切换,可以:

  1. 在点击时判断 currentPosition 和 targetPosition 的距离来决定是 scrollTo 还是 smoothScrollTo
  2. 点击时统一直接 scrollTo

3. 默认 BottomSheetDialog 的 BottomSheet 不会全屏

BottomSheetDialog 的 BottomSheetBehavior 是加在 FrameLayout 上的,而它的默认测量行为是根据 childView 的最大值决定自身高度,而且会忽略上层传过来的 heightMeasureSpec。

所以 Demo 中是自定义了 FrameLayoutonMeasure 方法,高度固定取最大值,然后将 ViewPager2 塞进这个 FrameLayout 中就可以了。

相关推荐
阿巴斯甜12 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker12 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq952713 小时前
Andorid Google 登录接入文档
android
黄林晴15 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿1 天前
Android MediaPlayer 笔记
android
Jony_1 天前
Android 启动优化方案
android
阿巴斯甜1 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇1 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_1 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android