一篇文章搞定《WebView的优化及封装》

前言

上篇对WebView大家肯定都有了一个基本的认知,和入门。
本篇继续对他进行一些工程上的优化。
主要原因:WebView加载过慢,影响用户体验,毕竟原生是秒开的。

WebView的过程分析

我们只有知道他的加载Web页面的过程了,之后对每步的耗时进行分析,才能去确定我们要优化的方向,和优化点。
下面我们就来有意识的去分析这个加载Web页面的过程。
整体的话主要分为下面三个阶段

  • 初始化阶段:也就是创建WebView
  • 网络阶段:也就是请求资源的过程
  • 渲染阶段:页面的DOM渲染(文字、图片、等等)
    在这里插入图片描述
    详细过程的时间(引用百度APP统计的详细时间)
    他将整个过程分为4个阶段,因为图片加载时发生在正文、整体页面渲染之后,再去JS请求网络的。
    在这里插入图片描述
  • 整体过程:初始化 Webview -> 请求页面 -> 下载数据 -> 解析HTML -> 请求 JS/CSS资源 -> DOM 渲染 -> 解析 JS 执行 -> JS 请求数据 -> 解析渲染 -> 下载渲染图片
  • 总时长:初始化组件260ms + 请求HTML数据加HTML解析270ms + 请求JS/CSS资源解析加正文数据1400ms + 图片下载加渲染600ms。共计2530ms,2.5秒那对用户来说可真是一个《漫长的季节》

确定优化方案

  • 初始化:首先初始化肯定是比较好优化的,毕竟可以通过预加载,缓存,复用等多种手段。
  • HTML处理过程:可以将一些公共内容,在本地加载(预置模版,静默更新)
  • JS/CSS资源和正文数据:可以将一些公共内容,在本地加载(预置模版,静默更新),提前获取(比如列表请求,页面数据请求等其他原生的数据请求)
  • 图片资源:可以拦截JS的请求、用缓存、本地、原生请求。

OK,那么就按照这几步挨个看看吧。
(PS:简单实现、主讲思路。根据具体的业务场景,添油加醋)

一、预加载,复用缓冲池(初始化优化)

我们可以选择在合适的时机 预加载 WebView 并存入 缓存池 中,等要用到时再直接从缓存池中取,从而缩短显示首屏页面的时间

优化的解析说明

目的:缩短完成首屏页面的时间。
原理:用空间换时间的做法,采用缓存池。

三个需要注意的问题

  • 触发时机如何选
    • IdleHandler 来解决。通过 IdleHandler 提交的任务只有在当前线程关联的 MessageQueue 为空的情况下才会被执行,因此通过 IdleHandler 来执行预创建可以保证不会影响到当前主线程任务
  • Context 如何选
    • MutableContextWrapper 是系统提供的 Context 包装类,其内部包含一个 baseContext。MutableContextWrapper 所有的内部方法都会交由 baseContext 来实现,且 MutableContextWrapper 允许外部替换它的 baseContext。
    • 因此我们可以在一开始的时候使用 Application 作为 baseContext,等到 WebView 和 Activity或者Fragment绑定的时候,切换到当前组件的Context。当组件销毁之后再切换回Application。
  • 复用的WebView模版(结合下面的预置模版有奇效)
    • 可以把已经加载解析过HTML、CSS、JS的webview缓存起来,后面用

具体的实现

  • 双重检验锁,的单例实现
  • 利用IdleHandler来初始化
  • 获取和回收方法
/**
 * Author: mql
 * Date: 2023/8/30
 * Describe : WebView的缓存复用池
 */
class WebViewPool private constructor(){
    
    
    //1、使用双重检验锁,进行单例的实现
    companion object{
    
    
        private const val TAG = "WebViewPool"

        @Volatile
        private var instance: WebViewPool? = null

        fun getInstance() : WebViewPool{
    
    
            return instance ?: synchronized(this) {
    
    
                instance ?: WebViewPool().also {
    
     instance = it }
            }
        }
    }

    //2、初始化
    //采用Stack进行存储复用
    private val webViewPool = Stack<BaseWebView>()
    //保证线程同步的安全
    private val lock = byteArrayOf()
    private var poolMaxSize = 1

    /**
     * 设置 webview 池容量
     */
    fun setMaxPoolSize(size: Int) {
    
    
        synchronized(lock) {
    
     poolMaxSize = size }
    }

    /**
     * 初始化webview 放在list中
     */
    fun init(context: Context, initSize: Int = poolMaxSize) {
    
    
        Looper.myQueue().addIdleHandler{
    
    
            Log.d(TAG, "init WebViewPool WebViewCacheStack Size = " + webViewPool.size)
            if(webViewPool.size < poolMaxSize){
    
    
                val view = BaseWebView(MutableContextWrapper(context))
                view.webViewClient = BaseWebViewClient() //自定义的webViewClient
                view.webChromeClient = BaseWebChromeClient() //自定义的webChromeClient
                webViewPool.push(view)
            }
            false
        }
    }

    //3、提供从pool中获取WebView
    fun getWebView(context: Context): BaseWebView {
    
    
        synchronized(lock) {
    
    
            val webView: BaseWebView
            if (webViewPool.size > 0) {
    
    
                webView = webViewPool.pop()
                Log.d(TAG, "getWebView from webViewPool")
            } else {
    
    
                webView = BaseWebView(MutableContextWrapper(context))
                Log.d(TAG, "getWebView from create")
            }

            val contextWrapper = webView.context as MutableContextWrapper
            contextWrapper.baseContext = context

            // 默认设置
            webView.webChromeClient = BaseWebChromeClient()
            webView.webViewClient = BaseWebViewClient()
            return webView
        }
    }

    //4、使用结束的回收WebView
    fun recycle(webView: BaseWebView) {
    
    
        // 释放资源
        webView.release()

        // 根据池容量判断是否销毁
        val contextWrapper = webView.context as MutableContextWrapper
        contextWrapper.baseContext = webView.context.applicationContext
        synchronized(lock) {
    
    
            if (webViewPool.size < poolMaxSize) {
    
    
                webViewPool.push(webView)
            } else {
    
    
                webView.destroy()
            }
        }
    }
}

使用的示例

//初始化 Application
WebViewPool.getInstance().setMaxPoolSize(min(Runtime.getRuntime().availableProcessors(), 3))
WebViewPool.getInstance().init(applicationContext)

//获取WebView
private val mWebView by lazy {
    
     WebViewPool.getInstance().getWebView(this) }

//回收Webview
fun onDestroy() {
    
    
    WebViewPool.getInstance().recycle(this)
}

二、预置模版(请求、渲染优化)

我们旨在减少网络请求HTML、CSS、JS、数据内容的时间。还有减少解析HTML的时间。所以需要用缓存 or 保存的思想去做。

优化的解析说明

目的:减少网络请求时间
原理:空间换时间
三个需要注意的问题:

  • 离线包:离线具有固定模版特性的HTML、JS、CSS
    • 每次打包时均预置最新的模板文件到客户端中,每套模板文件均有特定的版本号
    • App 在后台定时去静默更新(时机:可以在启动App和前后台切换的时候去检查的)
  • 预获取数据、JS内容注入:提前获取数据内容,利用JS进行注入
    • 列表页接口返回列表数据的时候带上JS的数据内容下发
    • 利用JS的方法调用进行数据的注入,展示。
    • ps:虽然减少了JS请求数据的时间,但是在前一步请求列表的时候,会消耗一些流量
  • 如果完全离线内联好HTML、JS、CSS文件。再加载WebView
    • 正常来说,WebView 需要在加载完主 HTML 之后再去加载 HTML 中的 JS 和 CSS,需要多次 IO 操作
    • 我们提前把他内联在一起,能减少这多次IO

其实我们在正常的业务中只实现离线包就可以了。后面的内容如果追求极致的话。倒是可以去尝试一下。

扫描二维码关注公众号,回复: 16478039 查看本文章

具体的实现

1、离线包

这个说一下具体的流程,就不附带代码了,代码涉及到业务。因为离线包的一个静默更新会涉及到后端的小伙伴的配合。
具体使用倒是可以给大家模拟一个简单代码去看看
具体流程的流程图如下
在这里插入图片描述
那么在有了离线包之后,我们怎么去使用呢?或者说怎么拦截请求,去使用本地的资源?

  • 判断URL并解析,看看是不是本地资源有的
  • 加载本地资源
  • 包装WebResourceResponse返回资源内容
override fun shouldInterceptRequest(
    view: WebView,
    request: WebResourceRequest
): WebResourceResponse? {
    
    
    val url = request.url.toString()
    // 判断请求的URL是否为本地资源,如果是则加载本地资源
    if (isLocalResource(url)) {
    
    
        try {
    
    
            // 加载本地的HTML、JS、CSS资源
            val inputStream = getLocalResource(url)
            val mimeType = getMimeType(url)
            return WebResourceResponse(mimeType, "UTF-8", inputStream)
        } catch (e: IOException) {
    
    
            e.printStackTrace()
        }
    }
    return super.shouldInterceptRequest(view, request)
}

private fun isLocalResource(url: String): Boolean {
    
    
    // 判断URL是否为本地资源的逻辑
    // 例如判断URL是否以特定的路径开头等
    //比如:
    val url = webRequest.url.toString()
    return url.startsWith("file:///android_asset/")
}

private fun getLocalResource(url: String): InputStream {
    
    
    // 加载本地资源的逻辑
    // 根据URL读取本地的文件或输入流并返回
}

private fun getMimeType(url: String): String {
    
    
    // 获取资源的MIME类型
    // 根据URL的后缀或其他信息判断MIME类型并返回
}

2、预获取数据、JS内容注入

假设你的HTML模版是:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>title</title>
        <link rel="stylesheet" type="text/css" href="xxx.css">
    <script>
        function changeContent(data){
    
    
            document.getElementById('content').innerHTML=data;
        }
    </script>
</head>
<body>
    <div id="content"></div>
</body>
</html>

假设你提前在获取列表的时候,下发的JSON数据。

json data = 
{
    
    
    "id" : 1,
    "webview_data" : "哈哈哈"
}

之后你就可以去把“哈哈哈”通过JS进行注入

webView.evaluateJavascript("javascript:changeContent('${
      
      data.webview_data}')") {
    
    }

3、内联离线资源文件

在 Android 端内联 HTML、JS 和 CSS 文件的一种常见方法是使用 WebView 的 loadDataWithBaseURL 方法。这个方法允许你加载一个包含 HTML 内容的字符串,并指定一个基本的 URL,以便 WebView 使用该 URL 加载相关的资源。

WebView webView = findViewById(R.id.webView);

// 读取 HTML 文件内容
String htmlContent = readFileAsString("main.html");

// 读取 JS 文件内容
String jsContent = readFileAsString("main.js");

// 读取 CSS 文件内容
String cssContent = readFileAsString("main.css");

// 构建完整的 HTML 内容
String fullHtmlContent = "<html><head><style>" + cssContent + "</style></head><body>" + htmlContent + "</body><script>" + jsContent + "</script></html>";

// 设置基本的 URL,用于 WebView 加载相关资源
String baseUrl = "file:///android_asset/";

// 加载内联的 HTML 内容
webView.loadDataWithBaseURL(baseUrl, fullHtmlContent, "text/html", "UTF-8", null);

这个示例假设 HTML、JS 和 CSS 文件在 assets 目录下,通过 readFileAsString 方法来读取文件内容。你需要自行实现这个方法。

请注意,这种内联方式适用于较小的文件,当文件内容较大时,可能会导致 WebView 初始化过程较慢。

三、拦截请求与共享缓存(请求、渲染优化)

旨在减少JS加载图片和其他可缓存数据的请求时间。
如今的 WebView 页面往往是图文混排的,图片是资讯类应用的重要表现形式。

优化的解析说明

目的:减少请求图片资源带来的时间延迟。
原理:空间换时间
三个需要注意的问题:

  • 拦截请求:WebViewClient 提供了一个 shouldInterceptRequest 方法用于支持外部去拦截请求,WebView 每次在请求网络资源时都会回调该方法,方法入参就包含了 Url,Header 等请求参数。
  • 缓存资源:我们可以通过该方法来主动拦截并完成图片的加载操作,这样我们既可以使得两端的资源文件得以共享,也避免了多次 JS 调用带来的效率问题,还将图片资源加入到了缓存当中。
  • 缓存资源形式:
    • 移动端已经预置了离线包(已经缓存了图片到本地)
    • 通过 OkHttp 本身的 Cache 功能来实现资源缓存 (之后通过拦截器去自定义缓存,达到两端统一缓存)
    • 通过Glide统一去加载图片。实现资源缓存共享

总结来说就是:拒绝JS的复杂图片请求,想象从本地加载、或者利用Android原生加载。享受缓存并共享缓存。
最后包装WebResourceResponse返回资源。

具体代码的实现

  • shouldInterceptRequest拦截
    • 本地缓存的内容去assets找
    • 可缓存的内容,主要就是图片资源。通过Glide去加载,顺便用上Glide的缓存(岂不是比你自己实现强啊)
override fun shouldInterceptRequest(
    view: WebView,
    request: WebResourceRequest
): WebResourceResponse? {
    
    
    var webResourceResponse: WebResourceResponse? = null

    // 1、如果是 assets 目录下的文件
    if (isAssetsResource(request)) {
    
    
        webResourceResponse = assetsResourceRequest(view.context, request)
    }

    // 2、如果是可以缓存的文件
    if (isCacheResource(request)) {
    
    
        webResourceResponse = cacheResourceRequest(view.context, request)
    }

    if (webResourceResponse == null) {
    
    
        webResourceResponse = super.shouldInterceptRequest(view, request)
    }
    return webResourceResponse
}
  • 解析URL
    • 判断是否是本地资源
    • 获取本地资源包装webResourceResponse
/**
 * 判断是否是本地资源
 */
private fun isAssetsResource(webRequest: WebResourceRequest): Boolean {
    
    
    val url = webRequest.url.toString()
    return url.startsWith("file:///android_asset/")
}

/**
 * assets 文件请求
 */
private fun assetsResourceRequest(
    context: Context,
    webRequest: WebResourceRequest
): WebResourceResponse? {
    
    
    val url = webRequest.url.toString()
    try {
    
    
        val filenameIndex = url.lastIndexOf("/") + 1
        val filename = url.substring(filenameIndex)
        val suffixIndex = url.lastIndexOf(".")
        val suffix = url.substring(suffixIndex + 1)
        val webResourceResponse = WebResourceResponse(
            getMimeTypeFromUrl(url),
            "UTF-8",
            context.assets.open("$suffix/$filename")
        )
        webResourceResponse.responseHeaders = mapOf("access-control-allow-origin" to "*")
        return webResourceResponse
    } catch (e: Exception) {
    
    
        e.printStackTrace()
    }
    return null
}
  • 是可以缓存的内容
    • 判断是否是缓存内容
    • 利用Glide进行缓存
/**
 * 判断是否是可以被缓存等资源
 */
private fun isCacheResource(webRequest: WebResourceRequest): Boolean {
    
    
    val url = webRequest.url.toString()
    val extension = MimeTypeMap.getFileExtensionFromUrl(url)
    return extension == "ico" || extension == "bmp" || extension == "gif"
            || extension == "jpeg" || extension == "jpg" || extension == "png"
            || extension == "svg" || extension == "webp" || extension == "css"
            || extension == "js" || extension == "json" || extension == "eot"
            || extension == "otf" || extension == "ttf" || extension == "woff"
}

private fun cacheResourceRequest(
    context: Context,
    webRequest: WebResourceRequest
): WebResourceResponse? {
    
    
    var url = webRequest.url.toString()
    var mimeType = getMimeTypeFromUrl(url)

    // WebView 中的图片利用 Glide 加载(能够和App其他页面共用缓存)
    if (isImageResource(webRequest)) {
    
    
        return try {
    
    
            val file = Glide.with(context).download(url).submit().get()
            val webResourceResponse = WebResourceResponse(mimeType, "UTF-8", file.inputStream())
            webResourceResponse.responseHeaders = mapOf("access-control-allow-origin" to "*")
            webResourceResponse
        } catch (e: Exception) {
    
    
            e.printStackTrace()
            null
        }
    }
    // 其他文件的缓存,根据需求去看吧,可以利用文件IO去存其他的资源。
    
    return null
}

/**
 * 判断是否是图片
 * 有些文件存储没有后缀,也可以根据自家服务器域名等等
 */
private fun isImageResource(webRequest: WebResourceRequest): Boolean {
    
    
    val url = webRequest.url.toString()
    val extension = MimeTypeMap.getFileExtensionFromUrl(url)
    return extension == "ico" || extension == "bmp" || extension == "gif"
            || extension == "jpeg" || extension == "jpg" || extension == "png"
            || extension == "svg" || extension == "webp"
}

/**
 * 根据 url 获取文件类型
 */
private fun getMimeTypeFromUrl(url: String): String {
    
    
    try {
    
    
        val extension = MimeTypeMap.getFileExtensionFromUrl(url)
        if (extension.isNotBlank() && extension != "null") {
    
    
            if (extension == "json") {
    
    
                return "application/json"
            }
            return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "*/*"
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
    return "*/*"
}

其他问题的处理

DNS、CDN优化(请求优化)

  • DNS 优化
    • DNS 也即域名解析,指代的是将域名转换为具体的 IP 地址的过程。
    • 如果 WebView 访问的主域名和客户端的不一致,那么 WebView 在首次访问线上资源时,就需要先完成域名解析才能开始资源请求,这个过程就需要多耗费几十毫秒的时间。因此最好就是保持客户端整体 API 地址、资源文件地址、WebView 线上地址的主域名都是一致的。
  • CDN 加速
    • 通过将 JS、CSS、图片、视频等静态类型文件托管到 CDN,当用户加载网页时,就可以从地理位置上最接近它们的服务器接收这些文件,解决了远距离访问和不同网络带宽线路访问造成的网络延迟情况

白屏检测(异常处理)

问题的解析说明

用户的网络环境和系统环境千差万别,甚至 WebView 也可能发生内部崩溃。当发生问题时,用户看到的可能就直接只是一个白屏页面了,所以进一步的优化手段就是需要去检测是否发生白屏以及相应的应对措施。

问题的解析思路

  • 对 WebView 进行截图,遍历截图的像素点的颜色值,如果非白屏颜色的颜色点超过一定的阈值,就可以认为不是白屏。
  • 字节跳动技术团队的做法是:通过 View.getDrawingCache()方法去获取包含 WebView 视图的 Bitmap 对象,然后把截图缩小到原图的 1/6,遍历检测图片的像素点,当非白色的像素点大于 5% 的时候就可以认为是非白屏的情况,可以相对高效且准确地判断出是否发生了白屏。

发现问题后的对策

  • 重试
  • 降级、不走缓存、预知、直接请求线上的内容页
  • 给出相应的提示、放弃展示

检测到之后,就可以进行重试、放弃优化重试、给出相应提示。具体的策略的话,看业务来定吧。

代码实现

class BaseWebView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : WebView(context, attrs), LifecycleEventObserver {
    
    
    // 省略其他代码... 
    inner class BlankMonitorRunnable : Runnable {
    
    
    
        override fun run() {
    
    
            val task = Thread {
    
    
                // 根据宽高的 1/6 创建 bitmap
                val dstWidth = measuredWidth / 6
                val dstHeight = measuredHeight / 6
                val snapshot = Bitmap.createBitmap(dstWidth, dstHeight, Bitmap.Config.ARGB_8888)
                // 绘制 view 到 bitmap
                val canvas = Canvas(snapshot)
                draw(canvas)
    
                // 像素点总数
                val pixelCount = (snapshot.width * snapshot.height).toFloat()
                var whitePixelCount = 0 // 白色像素点计数
                var otherPixelCount = 0 // 其他颜色像素点计数
                // 遍历 bitmap 像素点
                for (x in 0 until snapshot.width) {
    
    
                    for (y in 0 until snapshot.height) {
    
    
                        // 计数 其实记录一种就可以
                        if (snapshot.getPixel(x, y) == -1) {
    
    
                            whitePixelCount++
                        }else{
    
    
                            otherPixelCount++
                        }
                    }
                }
                // 回收 bitmap
                snapshot.recycle()
    
                if (whitePixelCount == 0) {
    
    
                    return@Thread
                }
    
                // 计算白色像素点占比 (计算其他颜色也一样)
                val percentage: Float = whitePixelCount / pixelCount * 100
                // 如果超过阈值 触发白屏提醒
                if (percentage > 95) {
    
    
                    post {
    
    
                        mBlankMonitorCallback?.onBlank()
                    }
                }
            }
            task.start()
        }
    }
}

版本问题带来的白屏(异常处理)

系统版本大于等于 4.3,小于等于 6.0 之间,ViewRootImpl 在处理 View 绘制的时候,会通过一个布尔变量 mDrawDuringWindowsAnimating 来控制 Window 在执行动画的过程中是否允许进行绘制,该字段默认为 false,我们可以利用反射的方式去手动修改这个属性,避免这个白屏效果。

/**
 * 让 activity transition 动画过程中可以正常渲染页面
 */
fun setDrawDuringWindowsAnimating(view: View) {
    
    
    if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M
        || Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1
    ) {
    
    
        //小于 4.3 和大于 6.0 时不存在此问题,无须处理
        return
    }
    try {
    
    
        val rootParent: ViewParent = view.rootView.parent
        val method: Method = rootParent.javaClass
            .getDeclaredMethod("setDrawDuringWindowsAnimating", Boolean::class.javaPrimitiveType)
        method.isAccessible = true
        method.invoke(rootParent, true)
    } catch (e: Throwable) {
    
    
        e.printStackTrace()
    }
}

总结

上面说的都是每一步的优化,这些优化是可以进行结合的。
比如:

  • 提前预加载一个WebView模版,利用本地的H5资源。
  • 之后加入到WebView的缓冲池中。比如去定义一个TemplateWebView去专门处理一些常用的比较固定的WebView页面。
  • 之后在WebViewPool中去专门添加这种比较固定的WebView页面去缓存。
  • 在每次只需要去注入新的正文数据进行复用就OK了。

多的不BB!!!
加油!!!!奥利给。

猜你喜欢

转载自blog.csdn.net/weixin_45112340/article/details/132591479