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 中就可以了。

相关推荐
雨白25 分钟前
手写 MaterialEditText:实现浮动标签(Floating Label)效果
android
CYRUS_STUDIO2 小时前
使用 readelf 分析 so 文件:ELF 结构解析全攻略
android·linux·逆向
纽马约4 小时前
Android Room的使用详解
android
游戏开发爱好者85 小时前
基于uni-app的iOS应用上架,从打包到分发的全流程
android·ios·小程序·https·uni-app·iphone·webview
深盾科技5 小时前
Android Keystore签名文件详解与安全防护
android·安全·gitee
安卓开发者5 小时前
Android Glide插件化开发实战:模块化加载与自定义扩展
android·glide
夏天的味道٥9 小时前
MySQL explain命令的作用
android·mysql·adb
鹏多多9 小时前
flutter-使用confetti制作炫酷纸屑爆炸粒子动画
android·前端·flutter