webview

265 阅读6分钟

一、Android中调用JS方法

1.1、loadUrl

首先在Android中新增一个按钮,并设置其点击事件

//Android中调用JS方法
Button button = findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
    	callJs();
    }
});

点击事件中调用了callJs方法,然后再activity中实现该方法

public void callJs(){
    webView.loadUrl("javascript:toAndroidCall()");
}

然后在JS中实现该方法

//Android中调用JS方法
function toAndroidCall(){
    alert("Android中调用JS方法成功");
}

如果要传递参数,最简单就是拼接字符串(也可以拼接json):

public void callJs(){
    String message = "公众号:霸道的程序猿";
    webView.loadUrl("javascript:toAndroidCallWithParam('"+message+"')");
}

然后在JS端

//Android中调用JS方法-带参数
function toAndroidCallWithParam(message){
    alert("Android中调用JS方法成功,收到参数:"+message);
}

缺点:

  • 如果js方法返回数据,这里会发生重定向。解决办法是用evaluateJavascript
  • 会使页面刷新。

1.2、evaluateJavascript

Android 4.4 后才可使用。

public void callJs(){
    webView.evaluateJavascript("javascript:toAndroidCall()"new ValueCallback<String>() {
        @Override
        public void onReceiveValue(String value) {
            Log.i("qcl0228", "js返回的数据" + value);
        }
    });
}

传参也和上面一样,拼接字符串。

二、JS中调用Android的方法

2.1、addJavascriptInterface对象映射

通过对象映射将Android中的本地对象和JS中的对象进行关联,从而实现JS调用Android的对象和方法。

在Android中实现方法

//JS调用Android方法
@JavascriptInterface
public void jsCallAndroid(){
    Toast.makeText(this,"JS调用Android方法成功",Toast.LENGTH_LONG).show();
}

要加注解,然后还需要给webView进行配置

//增加JS接口
// 参数1:Android的本地对象
// 参数2:JS的对象
webView.addJavascriptInterface(this,"android");

增加一个JS接口

然后在JS中新增一个按钮

<button id="button" onclick="toCallAndroid()">JS调用Android方法</button>

并设置点击事件

//JS中调用Android方法
function toCallAndroid(){
    android.jsCallAndroid();
}

如果要传递参数,一般都是传递json:

var json = {"name":"XJY","age":25",company":"CSII"};

//直接将json作为参数传递:
window.name.jsToClient(json);

//Android获取的参数是不可用的,打印出来的是undefinded。
//JS要这样处理,再作为参数传递给原生:

var jsonStr = JSON.stringify(json);

window.name.jsToClient(jsonStr);

//这样Android才能接受到json的字符串。

缺点:

  • 当JS拿到Android这个对象后,就可以调用这个Android对象中所有的方法,包括系统类(java.lang.Runtime 类),从而进行任意代码执行。

    如可以执行命令获取本地设备的SD卡中的文件等信息从而造成信息泄露。

解决:

  • Android 4.2版本之后

Google 在Android 4.2 版本中规定对被调用的函数以 @JavascriptInterface进行注解从而避免漏洞攻击。

  • Android 4.2版本之前

    在Android 4.2版本之前采用**拦截prompt()**进行漏洞修复。

2.2、shouldOverrideUrlLoading拦截

  • JS和Android约定所需要的Url协议。
  • 当JS通过Android的mWebView.loadUrl()加载后,就会回调shouldOverrideUrlLoading()拦截URL。

优点:

  • 不存在方式1的漏洞;

缺点:

  • JS获取Android方法的返回值复杂。

如果JS想要得到Android方法的返回值,只能通过 WebView 的 loadUrl()去执行 JS 方法把返回值传递回去。

2.3、onJsAlert()、onJsConfirm()、onJsPrompt()拦截

Android通过 WebChromeClientonJsAlert()onJsConfirm()onJsPrompt()方法回调拦截JS对话框alert()confirm()prompt() 消息。

  1. 常用的拦截是:拦截 JS的输入框(即prompt()方法)
  2. 因为只有prompt()可以返回任意类型的值,操作最全面方便、更加灵活;而alert()对话框没有返回值;confirm()对话框只能返回两种状态(确定 / 取消)两个值。。

三、Webview基础封装及优化

3.1、WebSettings

基础设置

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

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

3.3、WebViewClient

网页资源文件(js、css等)缓存管理

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 "*/*"
    }

}

3.4、BaseWebView

生命周期管理

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

3.5、优化

3.5.1、初始化-MutableContextWrapper

1、如放在 Application 中初始化会耗时,且官方并不推荐使用 Application 的 context 初始化 WebView。

2、如放在 Activity 中利用 Activity 的 Context 进行初始化,会造成内存泄漏。

对于 Context 问题可以通过 MutableContextWrapper 很好的解决,MutableContextWrapper 可以在初始化后随时修改上下文。

  • 初始化操作放入 Application 中,临时将 WebView 的 Context 设置为 Application;
  • 当 Activity 需要获取 WebView 时替换成对应 Activity 的 Context;
  • 当 Activity 退出时为了防止内存泄漏再将 WebView 的 Context 替换回 ApplicationContext。
3.5.2、复用池-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)
}
3.5.3、预加载

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

3.5.4、本地模板

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

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

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

这里仅演示第一种方法:

// 获取到新闻数据后 填充数据
mWebView.evaluateJavascript("javascript:setNewsData(`$title`, `$tag`, `$content`)") {}
3.5.5、图片加载优化

上述 BaseWebClient 中已经实现了 Web 中的图片使用 Glide 加载,但是有些特殊情况,一些图片特别多的网页如果一次性把图片都加载出来那肯定会发生卡顿。 在网页开发中,有很多种图片懒加载的框架,这里以 jquery.lazyload.js 为例对本地模板进行图片懒加载优化

3.5.6、白屏检测

在合适时机获取 WebView 的截图,转为 Bitmap 遍历像素点,当非白色像素点达到一定阈值即为正常加载,反之则为白屏。

简单实现:

首先在 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)
    }
}

现在只剩下在合适的时机调用方法即可,所以直接在 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()
    }
})