WebView白屏检测与处理

8,301 阅读7分钟

前言

一直有用户反馈APP中WebView页面白屏,包括自己也遇到了几次。关于如何检测拍屏,去网上浏览一番,自己整理了一套方案。大体分为一下几步:

  1. 截取当前屏幕的内容,获得Bitmap
  2. 判断Bitmap是否为白色图片
  3. 针对白屏做相应的处理

WebView截屏

截屏的时机

WebView有以下函数:

public void setWebViewClient(WebViewClient client) {}

入参 android.webkit.WebViewClient 中有以下函数:

   /**
     * Notify the host application that a page has finished loading. This method
     * is called only for main frame. Receiving an {@code onPageFinished()} callback does not
     * guarantee that the next frame drawn by WebView will reflect the state of the DOM at this
     * point. In order to be notified that the current DOM state is ready to be rendered, request a
     * visual state callback with {@link WebView#postVisualStateCallback} and wait for the supplied
     * callback to be triggered.
     *
     * @param view The WebView that is initiating the callback.
     * @param url The url of the page.
     */
    public void onPageFinished(WebView view, String url) {}

我们在 onPageFinished 里准备截图即可,代码如下:

   //应该是x5 SDK有bug,导致onPageFinished 进度100 时会回调两次,这里做个缓存
   private final ArrayList<String> completedPageCache = new ArrayList<>();

   @Override
   public void onPageFinished(WebView webView, String url) {
       //会出现多次进入onPageFinished的情况,最后一次进度为100
       if (completedPageCache.contains(url)) return;
       
       //进度为100
       if (webView.getProgress() > 99) {
           completedPageCache.add(url);
           //开始截图
           new WebWhiteChecker(WebActivity.this, webView, url).startCheck();
       }
       super.onPageFinished(webView, url);
    }

onPageFinished 实际会调用多次,每次进度都不一样,但是最后进度为100。webView.getProgress() > 99 这里保证进度达到100才开始截图。

completedPageCache 用来存储已经做过截图的网页地址。因为我们使用的是腾讯X5 SDK,实际中会遇到进度为100的情况调用两次。这里做个去重,真是坑啊!

X5 WebView的类名跟原生得几乎一致,上面的相关的api都是通用的。

开始截图

直接上代码:

  fun startCheck() {
      webView?.postDelayed({
          try {
              //Activity不处于被销毁的状态
              if (!activity.isDestroyed && !activity.isFinishing) {
                  webView.x5WebViewExtension?.let {
                      //这里取一半大小,不然可能OOM
                      val bitmap = Bitmap.createBitmap(webView.width / 2, webView.height / 2, Bitmap.Config.ARGB_8888)
                      //这里必须设置0.5f, 跟上方bitmap的缩放比例一致,不然无法截图
                      it.snapshotVisible(bitmap, false, false, false, false, 0.5f, 0.5f) {
                            //开始检测
                            checkOnSubThread(bitmap)
                        }
                    }
                }
            } catch (e: Exception) {
                L.e(e)
            }
        }, 1000)
    }

postDelayed设置了1秒的延时,是因为APP有的网页全是图片,图片没加载出来时,背景是白色的,没有延时会导致截取白色背景(1秒是自己实验出来的,实际可以设置大一点)。

createBitmap时宽高选择了WebView的一半,因为之前使用原始大小产生了OOM,尴尬!另外也可以提升接下来的扫描效率,毕竟量小了嘛。

snapshotVisible是X5 SDK提供的截图方法,简单好用!原生WebView怎么截图,大家可以自行搜索!我还没试过~

Bitmap检测

Bitmap有个getPixel函数:

    /**
     * Returns the {@link Color} at the specified location. Throws an exception
     * if x or y are out of bounds (negative or >= to the width or height
     * respectively). The returned color is a non-premultiplied ARGB value in
     * the {@link ColorSpace.Named#SRGB sRGB} color space.
     *
     * @param x    The x coordinate (0...width-1) of the pixel to return
     * @param y    The y coordinate (0...height-1) of the pixel to return
     * @return     The argb {@link Color} at the specified coordinate
     * @throws IllegalArgumentException if x, y exceed the bitmap's bounds
     * @throws IllegalStateException if the bitmap's config is {@link Config#HARDWARE}
     */
    @ColorInt
    public int getPixel(int x, int y) {
        checkRecycled("Can't call getPixel() on a recycled bitmap");
        checkHardware("unable to getPixel(), "
                + "pixel access is not supported on Config#HARDWARE bitmaps");
        checkPixelAccess(x, y);
        return nativeGetPixel(mNativePtr, x, y);
    }

这个方法返回了 a non-premultiplied ARGB value, 恕我能力有限,不知道这个是啥,我有猜测但是我不能瞎说呀!有知道的大神可以指导一下。

throws IllegalStateException if the bitmap's config is {@link Config#HARDWARE}如果Bitmap是HARDWARE类型的会抛异常。HARDWARE类型可自行搜索了解一下。

有关x, y传参,直接看下面调用:

   //白点计数
   private var whitePixelCount = 0

   private fun checkOnSubThread(bitmap: Bitmap) {
       //异步线程执行
       RxSchedulers.scheduleWorkerIo {
           val width = bitmap.width
           val height = bitmap.height

           for (x in 0 until width) {
               for (y in 0 until height) {
                   if (bitmap.getPixel(x, y) == -1) {//表示是白色
                       whitePixelCount++
                   }
               }
           }

           if (whitePixelCount > 0) {
               val rate = whitePixelCount * 100f / width / height
               //这里可以对比设定的上限,然后做处理
            }
            bitmap.recycle()
        }
    }

首先要保证检测过程要在子线程执行,不然你懂的!

我们把Bitmap的宽高理解成二维数组,直接两层循环往getPixel传入下x(with),y(height).这样就可以取出每个像素点的色值了。

bitmap.getPixel(x, y) == -1//表示是白色 为啥-1表示白色,这个我真不知道😓。。。专业知识不够,只能剑走偏锋,自己搞个啥都没有的网页测试出来的(举一反三,其他色值也可以测试出来具体的值)。

其实你把 Color.WHITE 打印出来也是 -1。当然严格来说,这并不能证明 getPixel获取的-1就代表白色!

每判定一次白色,whitePixelCount就加一,最后除以Bitmap的宽*高,得到白色的占比。

咱们搞个微软的必应 https://cn.bing.com/ 来试试 :

bi.jpeg

检测结果如下:

WebWhiteChecker: white rate = 4.73251

实际应用中,我们APP设置的阈值时95%,也就是说白色占比超过阈值就认为是白屏。

深色模式下的情况有待研究,实际测试下来虽然背景是黑色的,但是白色占比竟然跟正常模式是一样的!

补充

其实Bitmap中还有一个函数:

    @NonNull
    public Color getColor(int x, int y) {
        checkRecycled("Can't call getColor() on a recycled bitmap");
        checkHardware("unable to getColor(), "
                + "pixel access is not supported on Config#HARDWARE bitmaps");
        checkPixelAccess(x, y);

        final ColorSpace cs = getColorSpace();
        if (cs.equals(ColorSpace.get(ColorSpace.Named.SRGB))) {
            return Color.valueOf(nativeGetPixel(mNativePtr, x, y));
        }
        // The returned value is in kRGBA_F16_SkColorType, which is packed as
        // four half-floats, r,g,b,a.
        long rgba = nativeGetColor(mNativePtr, x, y);
        float r = Half.toFloat((short) ((rgba >>  0) & 0xffff));
        float g = Half.toFloat((short) ((rgba >> 16) & 0xffff));
        float b = Half.toFloat((short) ((rgba >> 32) & 0xffff));
        float a = Half.toFloat((short) ((rgba >> 48) & 0xffff));

        // Skia may draw outside of the numerical range of the colorSpace.
        // Clamp to get an expected value.
        return Color.valueOf(clamp(r, cs, 0), clamp(g, cs, 1), clamp(b, cs, 2), a, cs);
    }

直接返回一个android.graphics.Color对象,为什么不使用这个函数呢?

  1. 因为RequiresApi限制,看下图:

api.png

em...需要Android Q才行

  1. 因为它比getPixel多了一层将色值转化成Color对象的过程,涉及到全图二维数组的遍历,效率上肯定差了!

好奇的朋友肯定会打印getColor在白色情况下获得的Color对象,我这里先贴出来:

Color(1.0, 1.0, 1.0, 1.0, sRGB IEC61966-2.1)

em...看不太懂,应该是白色吧

白屏处理

在检测到白屏之后怎么做呢?

根据实际遇到的情况,我们从运维入手,排查了白屏时WebView的请求情况,发现资源接口返回了204--No Content 没有新文档,浏览器应该继续显示原来的文档。

而我们WebView设置使用的是默认缓存策略:

// 设置缓存模式
WebSettings.setCacheMode(WebSettings.LOAD_DEFAULT);

也就说204的时候我们使用了本地缓存,猜测使用缓存的时候出现了问题。

所以我们APP在包含WebView的页面退出之后,执行了清除WebView缓存的工作:

 public static void clearDataAndCache(@NonNull WebView webView) {
      //清除缓存
      webView.clearCache(true);
      webView.clearFormData();
      webView.clearHistory();
 }

经证实清除缓存的操作在大部分情况下都是有效的。

那么关于白屏的原因到底是什么,可以参考这篇今日头条品质优化 - 图文详情页秒开实践中所说:

而在 Android 中,我们采用的是自研内核 WebView,也会遇到一些奇奇怪怪的坑。

多线程读模板文件问题,WebView 在运行中会读取的文件模板,如果此时另外一个线程同时更新模板文件时,就出现了模板加载问题,所以需要保证模板加载的原子性 Render 卡死问题,内核是一个比较复杂的逻辑,内部渲染极少数情况也会出现 Render 卡死问题,但是在详情页整体用户的量级下,即使只有十万分之一的可能,对用户来说也是一个比较大的问题,此时我们会从业务上做白屏监控进行重试

当然不管是 iOS 和 Android, WebView 加载的逻辑都比较复杂,有时候怎么重试也无法成功,这个时候我们会直接降级到加载线上的详情页,优先保证用户的体验。

我们使用的腾讯的X5,头条是他们自研,可能没有原生的WebView成熟,出现白屏的时候我们还可以降级操作。

最后,如果你知道还有什么白屏原因,希望可以分享一下!