Android 干货分享:WebView 优化(1)—— 缓存管理、回收复用、网页秒开、白屏检测

8,021 阅读6分钟

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

目录

前言

来掘金的第一篇博客,分享下自己开发过程中对 WebView 的一些实践思路。后续也会随缘在掘金更新博客,方便自己回忆的同时也能够跟大家互相交流。

WebView 系列将从零开始构建一个 Demo,所以一些不重要的代码写的略为粗糙,重在分享思路,本次博客源码会放在本系列博客最后一篇里,如有设计不足,请大家多多指教 🙏 🙏 🙏

新建工程

image.png

用 AS 新建一个 Demo 工程,并且创建一个 Module 用于存放 WebView 相关代码,app 工程依赖 module_web。

WebView 基础封装

基础设置

WebSettings

新建一个工具类对 WebView 进行一些基础设置,后面用

object WebUtil {

    /**
     * 获取 WebView 缓存文件目录
     */
    fun getWebViewCachePath(context: Context): String{
        return context.filesDir.absolutePath + "/webCache"
    }

    fun defaultSettings(context: Context, webView: WebView) {
        // 白色背景
        webView.setBackgroundColor(Color.TRANSPARENT)
        webView.setBackgroundResource(R.color.white)

        webView.overScrollMode = View.OVER_SCROLL_NEVER
        webView.isNestedScrollingEnabled = false // 默认支持嵌套滑动

        // 设置自适应屏幕,两者合用
        webView.settings.useWideViewPort = true
        webView.settings.loadWithOverviewMode = true
        // 是否支持缩放,默认为true
        webView.settings.setSupportZoom(false)
        // 是否使用内置的缩放控件
        webView.settings.builtInZoomControls = false
        // 是否显示原生的缩放控件
        webView.settings.displayZoomControls = false
        // 设置文本缩放 默认 100
        webView.settings.textZoom = 100
        // 是否保存密码
        webView.settings.savePassword = false
        // 是否可以访问文件
        webView.settings.allowFileAccess = true
        // 是否支持通过js打开新窗口
        webView.settings.javaScriptCanOpenWindowsAutomatically = true
        // 是否支持自动加载图片
        webView.settings.loadsImagesAutomatically = true
        webView.settings.blockNetworkImage = false
        // 设置编码格式
        webView.settings.defaultTextEncodingName = "utf-8"
        webView.settings.layoutAlgorithm = WebSettings.LayoutAlgorithm.NORMAL
        // 是否启用 DOM storage API
        webView.settings.domStorageEnabled = true
        // 是否启用 database storage API 功能
        webView.settings.databaseEnabled = true
        // 配置当安全源试图从不安全源加载资源时WebView的行为
        webView.settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW

        // 设置缓存模式
        webView.settings.cacheMode = WebSettings.LOAD_DEFAULT
        // 开启 Application Caches 功能
        webView.settings.setAppCacheEnabled(true)
        // 设置 Application Caches 缓存目录
        val cachePath = getWebViewCachePath(context)
        val cacheDir = File(cachePath)
        // 设置缓存目录
        if (!cacheDir.exists() && !cacheDir.isDirectory) {
            cacheDir.mkdirs()
        }
        webView.settings.setAppCachePath(cachePath)
    }
}

WebChromeClient 封装

继承 WebChromeClient 主要实现了网页日志输出、js 弹窗拦截。

class BaseWebChromeClient : WebChromeClient() {

    private val TAG = "BaseWebChromeClient"

    /**
     * 网页控制台输入日志
     */
    override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
        Log.d(TAG, "onConsoleMessage() -> ${consoleMessage.message()}")
        return super.onConsoleMessage(consoleMessage)
    }

    /**
     * 网页警告弹框
     */
    override fun onJsAlert(
        view: WebView,
        url: String,
        message: String,
        result: JsResult
    ): Boolean {
        AlertDialog.Builder(view.context)
            .setTitle("警告")
            .setMessage(message)
            .setPositiveButton("确认") { dialog, which ->
                dialog?.dismiss()
                result.confirm()
            }
            .setNegativeButton("取消") { dialog, which ->
                dialog?.dismiss()
                result.cancel()
            }
            .create()
            .show()
        return true
    }

    /**
     * 网页弹出确认弹窗
     */
    override fun onJsConfirm(
        view: WebView,
        url: String,
        message: String,
        result: JsResult
    ): Boolean {
        AlertDialog.Builder(view.context)
            .setTitle("警告")
            .setMessage(message)
            .setPositiveButton("确认") { dialog, which ->
                dialog?.dismiss()
                result.confirm()
            }
            .setNegativeButton("取消") { dialog, which ->
                dialog?.dismiss()
                result.cancel()
            }
            .create()
            .show()
        return true
    }
}

WebViewClient 封装

WebViewClient 算是这一小节的重点了,在自定义 WebViewClient 中主要实现了两件事:

  1. 网页资源文件(js、css等)缓存管理
  2. 网页中的图片和原生App共用缓存(说简单点 App 用 Glide 加载图片,WebView 中的图片同样通过 Glide 加载
class BaseWebViewClient : WebViewClient() {

    private val fileApiService by lazy {
        Retrofit.Builder()
            .build()
            .create(FileApiService::class.java)
    }

    /**
     * 证书校验错误
     */
    @SuppressLint("WebViewClientOnReceivedSslError")
    override fun onReceivedSslError(
        view: WebView,
        handler: SslErrorHandler,
        error: SslError
    ) {
        AlertDialog.Builder(view.context)
            .setTitle("提示")
            .setMessage("当前网站安全证书已过期或不可信\n是否继续浏览?")
            .setPositiveButton("继续浏览") { dialog, which ->
                dialog?.dismiss()
                handler.proceed()
            }
            .setNegativeButton("返回上一页") { dialog, which ->
                dialog?.dismiss()
                handler.cancel()
            }
            .create()
            .show()
    }

    @RequiresApi(Build.VERSION_CODES.M)
    override fun onReceivedError(
        view: WebView,
        request: WebResourceRequest,
        error: WebResourceError
    ) {
        if (request.isForMainFrame) {
            onReceivedError(
                view,
                error.errorCode,
                error.description.toString(),
                request.url.toString()
            )
        }
    }

    override fun onReceivedError(
        view: WebView?,
        errorCode: Int,
        description: String?,
        failingUrl: String?
    ) {
        super.onReceivedError(view, errorCode, description, failingUrl)
    }

    override fun shouldOverrideUrlLoading(
        view: WebView,
        request: WebResourceRequest
    ): Boolean {
        return shouldOverrideUrlLoading(view, request.url.toString())
    }

    override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
        val scheme = Uri.parse(url).scheme ?: return false
        when (scheme) {
            "http", "https" -> view.loadUrl(url)
            // 处理其他协议
            //"tel" ->  {}
        }
        return true
    }

    override fun shouldInterceptRequest(
        view: WebView,
        request: WebResourceRequest
    ): WebResourceResponse? {
        var webResourceResponse: WebResourceResponse? = null

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

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

        if (webResourceResponse == null) {
            webResourceResponse = super.shouldInterceptRequest(view, request)
        }
        return 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
    }

    /**
     * 判断是否是可以被缓存等资源
     */
    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
            }
        }

        /**
         * 其他文件缓存逻辑
         * 1.寻找缓存文件,本地有缓存直接返回缓存文件
         * 2.无缓存,下载到本地后返回
         * 注意!!!
         * 一定要确保文件下载完整,我这里采用下载完成后给文件加 "success-" 前缀的方法
         */
        val webCachePath = WebUtil.getWebViewCachePath(context)
        val cacheFilePath =
            webCachePath + File.separator + "success-" + url.encodeUtf8().md5().hex() // 自定义文件命名规则
        val cacheFile = File(cacheFilePath)
        if (!cacheFile.exists() || !cacheFile.isFile) { // 本地不存在 则开始下载
            // 下载文件
            val sourceFilePath = webCachePath + File.separator + url.encodeUtf8().md5().hex()
            val sourceFile = File(sourceFilePath)
            runBlocking {
                try {
                    fileApiService.download(url, webRequest.requestHeaders).use {
                        it.byteStream().use { inputStream ->
                            sourceFile.writeBytes(inputStream.readBytes())
                        }
                    }
                    // 下载完成后增加 "success-" 前缀 代表文件无损 【防止io流被异常中断导致文件损坏 无法判断】
                    sourceFile.renameTo(cacheFile)
                } catch (e: Exception) {
                    e.printStackTrace()
                    // 发生异常删除文件
                    sourceFile.deleteOnExit()
                    cacheFile.deleteOnExit()
                }
            }
        }

        // 缓存文件存在则返回
        if (cacheFile.exists() && cacheFile.isFile) {
            val webResourceResponse =
                WebResourceResponse(mimeType, "UTF-8", cacheFile.inputStream())
            webResourceResponse.responseHeaders = mapOf("access-control-allow-origin" to "*")
            return webResourceResponse
        }
        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 "*/*"
    }

}

生命周期

引入 lifecycle 依赖

implementation 'androidx.lifecycle:lifecycle-livedata-core-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'

新建 BaseWebView 类继承自 WebView 并且实现 LifecycleEventObserver 接口

class BaseWebView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : WebView(context, attrs), LifecycleEventObserver {

    init {
        // WebView 调试模式开关
        setWebContentsDebuggingEnabled(true)
        // 不显示滚动条
        isVerticalScrollBarEnabled = false
        isHorizontalScrollBarEnabled = false
        // 初始化设置
        WebUtil.defaultSettings(context, this)
    }

    /**
     * 获取当前url
     */
    override fun getUrl(): String? {
        return super.getOriginalUrl() ?: return super.getUrl()
    }

    override fun canGoBack(): Boolean {
        val backForwardList = copyBackForwardList()
        val currentIndex = backForwardList.currentIndex - 1
        if (currentIndex >= 0) {
            val item = backForwardList.getItemAtIndex(currentIndex)
            if (item?.url == "about:blank") {
                return false
            }
        }
        return super.canGoBack()
    }

    /**
     * 设置 WebView 生命管控(自动回调生命周期方法)
     */
    fun setLifecycleOwner(owner: LifecycleOwner) {
        owner.lifecycle.addObserver(this)
    }

    /**
     * 生命周期回调
     */
    override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
        when (event) {
            Lifecycle.Event.ON_RESUME -> onResume()
            Lifecycle.Event.ON_STOP -> onPause()
            Lifecycle.Event.ON_DESTROY -> {
                source.lifecycle.removeObserver(this)
                onDestroy()
            }
        }
    }

    /**
     * 生命周期 onResume()
     */
    @SuppressLint("SetJavaScriptEnabled")
    override fun onResume() {
        super.onResume()
        settings.javaScriptEnabled = true
    }

    /**
     * 生命周期 onPause()
     */
    override fun onPause() {
        super.onPause()
    }

    /**
     * 生命周期 onDestroy()
     * 父类没有 需要自己写
     */
    fun onDestroy() {
        settings.javaScriptEnabled = false
    }

    /**
     * 释放资源操作
     */
    fun release() {
        (parent as ViewGroup?)?.removeView(this)
        removeAllViews()
        stopLoading()
        setCustomWebViewClient(null)
        setCustomWebChromeClient(null)
        loadUrl("about:blank")
        clearHistory()
    }

    fun setCustomWebViewClient(client: BaseWebViewClient?) {
        if (client == null) {
            super.setWebViewClient(WebViewClient())
        } else {
            super.setWebViewClient(client)
        }
    }

    fun setCustomWebChromeClient(client: BaseWebChromeClient?) {
        super.setWebChromeClient(client)
    }
}

WebView 缓存池

WebView 基础封装就搞定了,当然我写的 Demo 中的一些设置可能和实际项目中的逻辑不符,根据自己的需要修改即可,重在思路;

WebView 池化的优点:

  1. 缩短初次创建的时间
  2. 不用重复创建,复用 WebView 节省内存

缺点:

  1. 如放在 Application 中初始化会耗时,且官方并不推荐使用 Application 的 context 初始化 WebView
image.png
  1. 如放在 Activity 中利用 Activity 的 Context 进行初始化,会造成内存泄漏。

context 问题

对于 Context 问题可以通过 MutableContextWrapper 很好的解决,MutableContextWrapper 可以在初始化后随时修改上下文。 初始化操作放入 Application 中,临时将 WebView 的 Context 设置为 Application,当 Activity 需要获取 WebView 时替换成对应 Activity 的 Context,当 Activity 退出时为了防止内存泄漏再将 WebView 的 Context 替换回 ApplicationContext。

WebView 回收、复用

复用池

新建 WebViewPool 复用池类

class WebViewPool private constructor() {

    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 }
            }
        }
    }

    private val sPool = Stack<BaseWebView>()
    private val lock = byteArrayOf()
    private var maxSize = 1

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

    /**
     * 初始化webview 放在list中
     */
    fun init(context: Context, initSize: Int = maxSize) {
        for (i in 0 until initSize) {
            val view = BaseWebView(MutableContextWrapper(context))
            view.webChromeClient = BaseWebChromeClient()
            view.webViewClient = BaseWebViewClient()
            sPool.push(view)
        }
    }

    /**
     * 获取webview
     */
    fun getWebView(context: Context): BaseWebView {
        synchronized(lock) {
            val webView: BaseWebView
            if (sPool.size > 0) {
                webView = sPool.pop()
                Log.d(TAG, "getWebView from pool")
            } 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
        }
    }

    /**
     * 回收 WebView
     */
    fun recycle(webView: BaseWebView) {
        // 释放资源
        webView.release()

        // 根据池容量判断是否销毁 【也可以增加其他条件 如手机低内存等等】
        val contextWrapper = webView.context as MutableContextWrapper
        contextWrapper.baseContext = webView.context.applicationContext
        synchronized(lock) {
            if (sPool.size < maxSize) {
                sPool.push(webView)
            } else {
                webView.destroy()
            }
        }
    }
}

初始化

Application 中:

// 根据手机 CPU 核心数(或者手机内存等条件)设置缓存池容量
WebViewPool.getInstance().setMaxPoolSize(min(Runtime.getRuntime().availableProcessors(), 3))
WebViewPool.getInstance().init(applicationContext)

获取

在 Activity 中:

// 从缓存池获取
private val mWebView by lazy { WebViewPool.getInstance().getWebView(this) }
// 设置生命周期监听
mWebView.setLifecycleOwner(this)
// 添加到 RelativeLayout 容器中
mBinding.webContainer.addView(
    mWebView,
    RelativeLayout.LayoutParams(
        RelativeLayout.LayoutParams.MATCH_PARENT,
        RelativeLayout.LayoutParams.MATCH_PARENT
    )
)

回收

上面封装的 WebView 已经实现了生命周期回调,那么直接在 BaseWebView 的 onDestory 方法中进行回收:

fun onDestroy() {
    // 省略其他代码...
    WebViewPool.getInstance().recycle(this)
}

WebView 加载速度优化

预加载

上述的 BaseWebViewClient 中已经在 shouldInterceptRequest 方法中实现了资源拦截,拿到 url 由我们自己来进行下载或者读取本地缓存,那么在合适的时机将 Web 中的一些耗时的资源提前按照定义的规则下载到缓存目录,比如:公司首页的 Web 用 vue 实现,并且引入了 echarts 等等,将这些 js 文件提前下载后,当用户打开 WebView 时 shouldInterceptRequest 就直接返回本地缓存文件了。

由于 BaseWebViewClient 已经实现了下载、读取本地缓存文件的逻辑,这里就不再贴代码了。

本地模板

这是参考今日头条团队大佬分享的方案(文末有贴参考文献链接),适用的场景也比较有限,如果 Web 网页大部分都是相同的样式,比如头条的新闻详情页,大多数都是相同的样式,仅仅文字内容不同,那么可以在 assets 目录下内置一个 html 模板以及 css 样式,WebView 初始化后就可以直接加载这个 html 模板,当用户打开详情页时只需要请求数据,并且 set 到指定的 div 中。当用户关闭页面时不释放WebView 的资源仅清除 html 中加载的数据,直接回收到复用池中。这样加载详情页的速度会大大提升。

简单实现

下面实现一个新闻本地模板加载数据,为了效果更好加载本地模板的 WebView 新建一个新的复用池专门管理 TemplateWebView。

首先定义 TemplateWebView,很简单继承自 BaseWebView 重写释放资源和回收方法即可,释放资源时仅通过 js 调用清空模板数据:

class TemplateWebView(context: Context, attrs: AttributeSet? = null) : BaseWebView(context, attrs) {
    override fun release() {
        (parent as ViewGroup?)?.removeView(this)
        removeAllViews()
        evaluateJavascript("javascript:clearData()") {}
    }
    override fun onDestroy() {
        // 回收时用下面新定义的 TemplateWebViewPool 和 WebViewPool 区分开了
        TemplateWebViewPool.getInstance().recycle(this)        
    }
}

接着定义 TemplateWebViewPool 复用池,将上面的 WebViewPool 复制一份,将其中的 BaseWebView 类型改为 TemplateWebView,并且 init 初始化方法新增一句加载本地模板代码,为了节约篇幅仅贴出 init 方法:

class TemplateWebViewPool private constructor() {
    // 省略其他代码...
    fun init(context: Context, initSize: Int = maxSize) {
        for (i in 0 until initSize) {
            val view = TemplateWebView(MutableContextWrapper(context))
            // 初始化时就加载模板
            view.loadUrl("file:///android_asset/template_news.html")
            sPool.push(view)
        }
    }
}

同样在 Application 中做初始化和 WebViewPool 是一样的就不贴代码了。

下面轮到编写 hmtl 部分了,首先编写 css 样式,为了操作 html 元素方便 demo 中也引入了 jQuery,css 比较简单这里就不贴代码了可以从源码中获取

image.png

接着编写 template_news.html 模板文件

<!DOCTYPE html>
<html>

<head>
    <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport">
    <!-- 引入 js -->
    <script src='./js/jquery.min.js'></script>
    <!-- 引入 css -->
    <link rel="stylesheet" href="./css/news.css">
</head>

<body>
<div>
    <!-- 新闻标题 -->
    <h2 class="news_title"></h2>
    <!-- 新闻标签 -->
    <div class="news_tag"></div>
    <!-- 新闻内容 -->
    <div class="news_content"></div>
</div>
</body>

<script type='text/javascript'>
// 填充数据方法
function setNewsData(title, tag, content) {
   $('.news_title').html(title);
   $('.news_tag').html(tag);
   $('.news_content').html(content);
}

// 清除数据方法
function clearData(){
   $('.news_title').html(``);
   $('.news_tag').html(``);
   $('.news_content').html(``);
}
</script>

</html>

设置数据时就有两种选择了

  1. 获取到新闻数据后通过桥接调用 js 中的 setNewsData 方法填充数据
  2. 获取数据的方法也写入到 html 模板中加载 js 的同时获取到数据

这里仅演示第一种方法:

// 获取到新闻数据后 填充数据
mWebView.evaluateJavascript("javascript:setNewsData(`$title`, `$tag`, `$content`)") {}

效果图

4FFBC4BAAD9C749DB79B63A571DC65B8.gif

图片加载优化

上述 BaseWebClient 中已经实现了 Web 中的图片使用 Glide 加载,但是有些特殊情况,一些图片特别多的网页如果一次性把图片都加载出来那肯定会发生卡顿。 在网页开发中,有很多种图片懒加载的框架,这里以 jquery.lazyload.js 为例对本地模板进行图片懒加载优化。先说下思路:模板中先引入 jquery.lazyload.js,获取到要显示的 html 并且 set 到模板之后遍历 html,找出所有的 img 标签根据 jquery.lazyload.js 文档对其进行修改实现图片懒加载。

【延伸一下】遍历 html 标签拿到所有 img 后,也可以通过桥接将图片 url 传递给原生,实现网页图片浏览功能

首先引入 jquery.lazyload.js:

image.png

模板文件 template_news.html 中引入:

<script src='./js/jquery.lazyload.js'></script>

在 script 标签中增加方法:

<script type='text/javascript'>
// 设置图片懒加载
function setImageLazyload() {
    jQuery('img').each(function() {
      var url = jQuery(this).attr('src');
      jQuery(this).attr('data-original', url).removeAttr("src");
   })
   jQuery(document).ready(function() {
      jQuery('img').lazyload({
         placeholder: 'data:image/gif;base64,xxx', // 占位图 base64 太长了 就不贴了
         effect: 'fadeIn',
         skip_invisible: true
      });
   });
}


function setNewsData(title, tag, content) {
   // 省略其他代码...
   // 填充数据完成后 调用 setImageLazyload
   setImageLazyload()
}
</script>

效果图

qq_pic_merged_1662543954233.jpg

WebView 白屏检测

同样是今日头条团队大佬分享的思路,当 WebView 加载网页时遇到意外情况导致页面白屏时,需要引导用户刷新等等

原理

在合适时机获取 WebView 的截图,转为 Bitmap 遍历像素点,当非白色像素点达到一定阈值即为正常加载,反之则为白屏;以下是摘自今日头条团队的原文:

除了截图的性能损耗,像素点检测也是白屏检测中比较耗时的场景,经过实验,我们把 WebView 截图的图片进行缩小到原图的 1/6,遍历检测图片的像素点,当非白色的像素点大于 5% 的时候我们就认为是非白屏的情况,可以相对高效检测准确得出详情页是否发生了白屏

简单实现

首先在 BaseWebView 中定义一个白屏异常回调接口:

class BaseWebView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : WebView(context, attrs), LifecycleEventObserver {
    // 省略其他代码... 
    interface BlankMonitorCallback {
        fun onBlank()
    }

    private var mBlankMonitorCallback: BlankMonitorCallback? = null

    fun setBlankMonitorCallback(callback: BlankMonitorCallback){
        this.mBlankMonitorCallback = callback
    }
}

接着定一个内部类继承自 Runnable ,在 run 方法中实现白屏检测任务:

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()
        }
    }
}

继续在 BaseWebView 中定义 BlankMonitorRunnable 对象并且增加执行和取消白屏检测的方法:

class BaseWebView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : WebView(context, attrs), LifecycleEventObserver {
    // 省略其他代码... 
    private val mBlankMonitorRunnable by lazy { BlankMonitorRunnable() }
    
    /**
     * 调用后
     * 5s 后开始执行白屏检测任务 时间可以适当修改
     */
    fun postBlankMonitorRunnable() {
        Log.d(TAG, "白屏检测任务 5s 后执行")
        removeCallbacks(mBlankMonitorRunnable)
        postDelayed(mBlankMonitorRunnable, 5000)
    }
    
    /**
     * 取消白屏检测任务
     */
    fun removeBlankMonitorRunnable() {
        Log.d(TAG, "白屏检测任务取消执行")
        removeCallbacks(mBlankMonitorRunnable)
    }
}

现在只剩下在合适的时机调用方法即可,Demo 中给每个页面都加上了白屏检测,所以直接在 BaseWebViewClient 中实现:

class BaseWebViewClient : WebViewClient() {
    // 省略其他代码...
    override fun onPageStarted(view: WebView, url: String, favicon: Bitmap) {
        super.onPageStarted(view, url, favicon)
        if (view is BaseWebView){
            view.postBlankMonitorRunnable()
        }
    }

    override fun onPageFinished(view: WebView, url: String) {
        super.onPageFinished(view, url)
        // 页面加载完成后取消任务
        // 放在这里其实不是最佳 页面加载完后发生异常导致白屏的情况就检测不到了 这里只是个demo
        if (view is BaseWebView){
            view.removeBlankMonitorRunnable()
        }
    }
}

使用时在 Activity 中对 WebView 设置白屏监听即可:

mWebView.setBlankMonitorCallback(object : BaseWebView.BlankMonitorCallback {
    override fun onBlank() {
        AlertDialog.Builder(this@WebActivity)
            .setTitle("提示")
            .setMessage("检测到页面发生异常,是否重新加载?")
            .setPositiveButton("重新加载") { dialog, _ ->
                dialog.dismiss()
                mWebView.reload()
            }
            .setNegativeButton("返回上一页") { dialog, _ ->
                dialog.dismiss()
                onBackPressed()
            }
            .create()
            .show()
    }
})

最后

本篇博客主要实现了:

  1. WebView 缓存管理、和原生部分共用图片缓存
  2. WebView 生命周期回调
  3. WebView 复用池 回收 复用
  4. 网页秒开(主要是本地模板这种情况)、图片懒加载
  5. 白屏检测

这些东西也不难,很早网络上就有大佬分享这些思路,算是站在巨人的肩膀上总结实践一波吧。

由于篇幅原因下一篇再接着分享 WebView 独立进程以及跨进程通信的实现,文中的源码都是下班写博客的同时敲的,下一篇的更新需要点时间,相关源码也会贴在下一篇博客中。

如果我的博客分享对你有点帮助,不妨点个赞支持下!

参考文献

  1. 满满的WebView优化干货,让你的H5实现秒开体验
  2. Android WebView H5 秒开方案总结
  3. 今日头条品质优化 - 图文详情页秒开实践

参考源码

  1. fragmject
  2. AndroidProject-Kotlin