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

相关推荐
IT乐手35 分钟前
java 对比分析对象是否有变化
android·java
做时间的朋友。1 小时前
MySQL 8.0 窗口函数
android·数据库·mysql
举儿1 小时前
通过TRAE工具实现贪吃蛇游戏的全过程
android
守月满空山雪照窗1 小时前
深入理解 MTK FPSGO:Android 游戏帧率治理框架的架构与实现
android·游戏·架构
阿凤211 小时前
uniapp运行到app端怎么打开文件
android·前端·javascript·uni-app
学习使我健康2 小时前
Android 事件分发机制
android·java·前端
贵沫末2 小时前
Claude Code For VS Code安装以及跳过认证
android
00后程序员张2 小时前
完整教程:如何将iOS应用程序提交到App Store审核和上架
android·macos·ios·小程序·uni-app·cocoa·iphone
aq55356002 小时前
ThinkPHP5.x核心特性全解析
android·数据库·oracle·php·laravel
Fᴏʀ ʏ꯭ᴏ꯭ᴜ꯭.3 小时前
MySQL高可用集群实战:MHA搭建全攻略
android·mysql·adb