Android WebView 性能轻量优化

0. 前言

前面有被用户投诉 APP 流量消耗厉害:

[2017-08-08 07:34:40 utc0000] [SettingActivity-null]  严选APP流量消耗太大啦,每次启动都更新,下面流量很大。建议优化流量的消耗,可以对加载画质进行选择。想必淘宝APP,消耗流量可是大多了。
[2017-06-01 21:43:36 utc0000] 怎么没用有流量节约模式,一会用了我200M
[2017-03-28 18:38:09 utc0000] 流量占用太大代码
[2017-03-28 18:38:09 utc0000] 流量占用太大
[2017-06-12 08:32:25 utc0000] 严选app太费流量了

于是乎考虑了流量方面的问题。暂时 APP 中涉及流量的几个方面:

  1. 普通 https 请求,wzp 请求

    文本传输,请求已经做了 gzip,流量占比小

  2. 配置文件下载,应用内升级下载包

    触发的时机较少,每次升级版本才触发。另外整个 APP 全量包才 12M+,而我们实施增量更新,因此流量占比小

  3. 网络图片下载

    图片下载消耗的流量较多,然而本地使用 fresco 加载图片资源,已经实现了内存缓存(已编码和未编码)和本地缓存(大图缓存和小图缓存),此外 APP 中已经使用 nos 的参数设置为 webp 格式,根据实际图片控件大小设置请求的图片大小,同时设置了 quality。在保证图片清晰度的前提下,尽可能的减少了流量的消耗。

    由于产品要求,并没有做根据手机内存和网络环境的图片清晰度设置。

  4. h5 页面展示

    h5页面消耗的流量较多,由于h5页面是交由前端处理显示,客户端开发关注的少些,而此处消耗了大量的流量

使用 TrafficStats 记录 APP 几个页面的流量消耗:

image

image

image

启动页,首页,专题 H5 页

页面 接收的流量 接收的包数量 发送的流量 发送的包数量
启动页 196.7K 168 11.8K 182
首页 632K 677 58K 584
专题页A(h5) 3.0M 2288 110.9K 1626
专题页A 33.5K 98 50.6K 150
浏览多个 h5 页面后进入专题页A 3.0M 2412 150.6K 1829

由上表可以看出:第一次进入 APP 的专题页消耗了大量的流量;返回首页再一次进入,流量消耗明显小了很多;浏览多个 h5 页面后进入专题页,还是消耗了大量的流量。从上面的流量数据中,就有几个疑问了:

  1. 专题 H5 页的这么多流量消耗主要哪里?

    通过抓包可以发现,基本上的情况是消耗在图片的请求上(后续分析)

  2. 如何降低 H5 页面首次加载的流量消耗

    前端 H5 页面根据设备屏幕大小动态设置图片大小,保证清晰度的前提下,降低流量消耗。这部分由前端开发保证,对于移动客户端开发透明,因此这里并不具体讨论。

  3. 第 1 次进入专题页和第 2 次进入专题页,为什么会有流量差异?

    很容易会想到专题页的 WebView 使用了缓存,而我们代码中也确实是做了相关的设置。而至于这里的代码是如何影响缓存策略的,后面会进一步说明

    webSettings.setCacheMode(WebSettings.LOAD_DEFAULT);
    webSettings.setDatabaseEnabled(true);
    webSettings.setDomStorageEnabled(true);
    webSettings.setAppCacheEnabled(true);
    
  4. 第 2 次进入专题页和第 3 次进入专题页(已经浏览很多其他页面),为什么会有流量差异?

    很容易会想到是该页面的缓存失效了,而至于为何失效和如何避免,后面会进一步说明

  5. 如何降低非首次加载的流量消耗,提升 H5 页面首次加载显示的速度?

    很容易发现,在有本地缓存情况下,加载 H5 页面的速度会大幅提升,加载 33.5K 的资源相比加载 3.0M 的资源会提升更大的页面打开速度

1. Android WebView 系统缓存策略

1.1 CacheMode

WebSettings 能设置的 CacheMode 主要有:

  • LOAD_DEFAULT

    默认设置,如果有本地缓存,且缓存有效未过期,则直接使用本地缓存,否则加载网络数据

  • LOAD_NORMAL

    已经废弃

  • LOAD_CACHE_ELSE_NETWORK

    如果有本地缓存则直接使用本地缓存,而不管缓存数据是否过期失效,否则加载网络数据

  • LOAD_NO_CACHE

    加载网络数据,不使用缓存

  • LOAD_CACHE_ONLY

    有本地缓存,加载数据,否则加载失败

这里的缓存策略比较好理解,但是仍有几个疑问:

  1. 我们的 APP 是不是使用 LOAD_CACHE_ELSE_NETWORK 就可以完成缓存的使用和加载问题?

    类似 FrescoUniversalImageLoader 等第三方图片库,都设计了三级缓存,内存 → 磁盘 → 网络,分级取数据,只要取到就不再访问下一级。而 h5 页面,加载的内容不仅仅有图片,也有 html,js,css 等内容,而这部分内容我们认为经常会发生变化,直接使用 LOAD_CACHE_ELSE_NETWORK 是不可接受的

  2. 本地缓存是存储在哪里?

    见 1.2 本地缓存目录

  3. LOAD_DEFAULT 模式下如何判定缓存有效?

    见 1.3 缓存有效性判断

1.2 本地缓存目录

在手机 (nexus5,Android 6.0.1) 本地目录 /data/data/${packagename}/cache/org.chromium.android_webview/

image

1.3 H5 缓存机制

1.3.1 Dom Storage 存储机制

H5DOM Storage 机制提供了一种方式让程序员能够把信息存储到本地的计算机上,在需要时获取。相比 cookie 的存储读取,DOM Storage 提供了更大容量的存储空间。H5 建议每个网站的 Storage 空间是 5M,而 cookie 的大小上限为 4K。

Dom Storage 存储方式为键值对存储,K/V 的数据格式为字符串类型,如果需要保存非字符串,需要在读写的时候进行类型转换或使用 JSON 序列化。为此,Dom Storage 不适合存储复杂或者存储空间要求大的数据(如图片数据等),一般用于存储一些服务器或者本地的临时数据,和 Android SharePreference 机制类似。

参见接口定义:

interface Storage { 
    readonly attribute unsigned long length; 
    // 返回列表中第 n 个键的名字。Index 从 0 开始
    getter DOMString key(in unsigned long index); 
    // 返回指定键对应的值
    getter any getItem(in DOMString key); 
    // 存入一个键值对
    setter creator void setItem(in DOMString key, in any data); 
    // 删除指定的键值对
    deleter void removeItem(in DOMString key); 
    // 删除 Storage 对象中的所有键值对
    void clear(); 
};

Dom Storage 可分为 SessionStorageLocalStorage。两者使用方法基本相同,区别在于作用的范围不同。SessionStorage 用来存储与页面相关的数据,页面关闭后就无法使用,而 LocalStorage 则持久存在,在页面关闭后也可以使用。

在 Android 中,我们通过 WebSetting 的接口来开启或关闭 Dom Storage。

WebSettings webSettings = webView.getSettings();
webSettings.setDomStorageEnabled(true);

1.3.2 Web 数据库存储机制

H5 也提供了基于 SQL 的数据库存储机制,用于存储一些结构化数据,Web SQL Database 存储机制提供了一组 API 供 Web App 创建、存储、查询数据库。相比 Dom Storage 适合存储结构复杂的数据。

在 Android WebView 中,可以通过 WebSettings 设置是否启用 SQL Database,和设置数据库文件的存储路径。

WebSettings webSettings = webView.getSettings();
webSettings.setDatabaseEnabled(true);
webSettings.setDatabasePath(dbPath);

1.3.3 HTML 应用程序缓存机制

应用程序缓存(简称 AppCache) 为 web 应用的离线访问提供了支持,其原理是基于 manifest 属性和 manifest 文件。manifest 缓存会一直保存,直到缓存被清除,manifest 文件被修改,或浏览器更新 AppCache。

  • manifest 属性

    每个指定了 manifest 的页面在用户对其访问时都会被缓存。如果未指定 manifest 属性,则页面不会被缓存(除非在 manifest 文件中直接指定了该页面)。

    <!DOCTYPE HTML>
    <html manifest="demo.appcache">
        <body>
        The content of the document......
        </body>
    </html>
    

    manifest 属性

  • manifest 文件

    manifest 文件是简单的文本文件,它告知浏览器被缓存的内容(以及不缓存的内容)。
    manifest 文件可分为三个部分:

    1. CACHE MANIFEST - 在此标题下列出的文件将在首次下载后进行缓存
    2. NETWORK - 在此标题下列出的文件需要与服务器的连接,且不会被缓存
    3. FALLBACK - 在此标题下列出的文件规定当页面无法访问时的回退页面(比如 404 页面)
    CACHE MANIFEST
    /theme.css
    /logo.gif
    /main.js
    

    CACHE MANIFEST 示例

在 Android WebView 中,可以通过 WebSettings 设置是否启用 AppCache、缓存文件存储路径、缓存上限。

WebSettings webSettings = webView.getSettings();
webSettings.setAppCacheEnabled(true);
webSettings.setAppCachePath(cachePath);
webSettings.setAppCacheMaxSize(cacheSize);

1.3.4 IndexedDB

IndexedDB 也是一种数据库的存储机制,但不同于 Web SQL Database,归于 NoSQL 数据库。IndexedDB 存储数据是 key-value 的形式。Key 是必需,且要唯一;Key 可以自己定义,也可由系统自动生成。Value 也是必需的,但 Value 非常灵活,可以是任何类型的对象。一般 Value 都是通过 Key 来存取的,相比 Dom Storage 的 K/V 存储方式,功能更强大,存储空间更大。IndexedDB 支持 index(索引),可对 Value 对象中任何属性生成索引,然后可以基于索引进行 Value 对象的快速查询。

Android 4.4 引入 IndexDB 支持,是否开启只需打开允许 JS 执行的开关

WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true);

1.3.5 File System API

Android 系统的 Webview 还不支持 File System API,这里也不在介绍。

1.3.6 浏览器缓存机制

浏览器缓存机制是指通过 HTTP Header 部分的 Cache-Control(或 Expires)和 Last-Modified(或 Etag)等字段来控制文件缓存的机制。

  1. Last-Modified

    标识文件在服务器的最新更新修改时间。请求资源时,浏览器通过 If-Modified-Since 字段带上本地缓存的最新修改时间,服务器通过比较缓存文件的修改时间是否一致,来判断文件是否有修改。如果没有修改,则服务器返回 304 告知浏览器继续使用缓存;否则返回 200,同时返回最新的文件。

    // 服务器返回
    Last-Modified: Tue, 12 Jan 2016 09:31:27 GMT
    // 客户端再次发送请求
    If-Modified-Since: Tue, 12 Jan 2016 09:31:27 GMT
    
  2. Cache-Control

    相对值,单位是秒,指定某个文件从发出请求开始起的有效时长,在这个有效时长之内,浏览器直接使用缓存,而不发送请求。Cache-Control 不用要求服务器与客户端的时间同步,也不用服务器时刻同步修改配置 Expired 中的绝对时间,从而可以避免额外的网络请求。优先级比 Expires 更高。

    Cache-Control: max-age=600, public
    

    Cache-Control 通常与 Last-Modified 一起使用。一个用于控制缓存有效时间,一个在缓存失效后,向服务查询是否有更新。

  3. Expires

    表示到期时间,一般用在 response 报文中,当超过此时间后响应将被认为是无效的而需要网络连接,反之而是直接使用缓存

    Expires: Thu, 12 Jan 2017 11:01:33 GMT
    
  4. ETag

    是对文件进行标识的特征字符串。浏览器向服务器请求文件时,通过 If-None-Match 字段把特征字串发送给服务器,服务器通过 Etag 比对来判断文件是否更新。Etag 一致,则返回 304;否则返回 200 和最新的文件。

    // 服务器返回
    ETag: 248b11be4d6c7db6b2a699988a6603a5
    // 客户端再次发送请求
    If-None-Match: 248b11be4d6c7db6b2a699988a6603a5
    

    ETagLast-Modified 一同使用,是要其中一个判断缓存有效,则浏览器使用缓存数据

  5. Cache-Control:no-cache (Pragma:no-cache)

    浏览器忽略本地缓存,请求 HEADER 中代码带上改字段,请求服务器获取最新的数据

整个流程图如下

image

我们 APP 中设置的 CacheMode 为 WebSettings.LOAD_DEFAULT,即支持浏览器缓存机制。

另外,Cache-Control 与 Last-Modified 是浏览器内核的机制,缓存容量是 12MB,不分 HOST,过期的缓存会最先被清除。如果都没过期,应该优先清最早的缓存或最快到期的或文件大小最大的;过期缓存也有可能还是有效的,清除缓存会导致资源文件的重新拉取

2 客户端 H5 缓存机制考虑

和流量关系比较多的主要是浏览器缓存机制(manifest 文件的更新也遵守浏览器缓存机制),如何缓存 html、js、css、图片等文件。通过缓存机制,对于移动 APP 提高资源文件的加载速度、节省流浪有着很大的意义。而具体该针对哪种资源使用哪个缓存字段,以及缓存时长设置就比较重要。若时长设置的太短,则缓存效果受影响,若时长设置过长,则不能及时获取服务器最新数据。

  • html、js、css 等资源
    考虑这些文件会随着业务需求,经常发生变化。甚至我们的很多页面都有推荐功能,页面内容会随着用户的足迹发生变化,为此这些文件,可以使用 Last-Modified(Etag) 来控制缓存。

  • 图片资源
    图片也可以通过 Last-Modified(Etag) 来控制缓存。但使用 Last-Modified 需要每次都向服务器发起查询请求请求。考虑到图片文件是长时间不变的,在珍贵的移动网络下,推荐使用 Cache-Control 设置一个较长的时间来缓存。

    如果 h5 页面中需要更新一张图片的话,我们也是通过新增一张图片,替换 url 去实现。如果不改变 url,直接替换服务端图片,会由于 dns 服务器的缓存(nos 图片缓存时间大概是1个月),导致客户端无法显示最新的图片。

    除此之外,也可以使用 AppCache 机制,由前端控制缓存文件,客户端设置缓存路径和大小。

使用浏览器的缓存策略或者 AppCache 机制,看起来已经能很好的解决问题了,但为何我们的 APP h5 页面流量消耗严重,加载还不够快。通过抓包查看我们的 APP H5 页面的资源加载情况:

  1. 专题页 H5 展示

    image
  2. 第一次进入数据请求,可以看到图片数据都是从服务器拉取,累计消耗缓存较大

    image
  3. 退出马上重新进入

    image

    图片请求显示的为 [no content],即使用了缓存数据

    image

    查看具体请求 Request,图片资源请求同时使用了 Last-ModifiedEtag

    image

    查看具体请求 Response,服务器判断客户端缓存有效,不在返回图片数据

  4. 浏览多个 H5 页面后,再次进入专题页

    image

    图片数据又重新从服务器拉取

    image

    查看具体请求 Response,可以并没有看到 If-Modified-SinceIf-None-Match 字段

    image

    查看具体请求 Response,返回了 PNG image 数据

由上,可以总结为以下 2 点:

  1. Cache-Control 与 Last-Modified 缓存容量过小

    根据一开始的流量分析,可以发现,一个专题页(H5页面)就已经消耗了 2MB~3MB 的流量,其他大量的商品详情页类似,而缓存容量仅为 12MB,很明显在用户稍微多浏览几个页面之后,最开始的页面缓存就已经被清理了,于是重新进入还是会继续发请求。

  2. 图片资源使用 ETagLast-Modified 控制缓存

    抓包发现,我们 H5 页面图片资源依旧使用 Etag 控制缓存,为此每个图片需要请求服务器文件是否更新。这样子也导致了页面加载过慢,依旧有微量流量被消耗掉。当然 web 端开发可以直接改成 Cache-Control 方式,然而并不保证全部页面都使用了一致的缓存方式,毕竟 APP 前端还分了多个活动组,多个开发。

  3. AppCache

    需要各个 h5 页面提供设置 manifest 属性和 manifest 文件,h5 页面的展示涉及主站、各个活动组等,完全推广需要制定跨团队的规范。

综上考虑,如果有一套移动端开发能控制的缓存策略,就能很好的突破缓存容量过小和图片缓存策略统一的问题(流量中,图片占了绝大部分,其他 js、css 等资源可以前端自行控制,暂时不考虑视频流数据)。

3 资源文件预置

一个网页从加载到显示,需要下载大量的文件,不仅消耗时间也消耗流量;如果不想依赖于浏览器缓存,客户端接管缓存的话,挺多人想到可以将需要的资源文件预置在 app 中,当加载 h5 页面的时候,直接从本地加载,进而达到节省流量和提高 WebView 的性能
这里的关键问题是如何让 WebView 去加载本地文件,而不是去网络加载?

3.1 替换 HTML 图片标签

  1. 加载 url 前,设置不加载图片资源

    WebSettings webSettings = mWebView.getSettings();
    webSettings.setLoadsImagesAutomatically(false);
    
  2. 注入 js 方法

    mWebView.addJavascriptInterface(new InJavaScriptLocalObj(), "local_obj");
    private final class InJavaScriptLocalObj {
    
        @JavascriptInterface
        public String getLocalSrc(String src) {
            return "file://storage/emulated/0/YanXuan/file/a.jpeg";
        }
    }
    
  3. 页面加载完成时,修改图片标签

    private class MyWebViewClient extends WebViewClient {
    
        @Override
        public void onPageFinished(WebView view, String url) {
            super.onPageFinished(view, url);
            view.loadUrl("javascript:(function(){var objs = document.getElementsByTagName('img');\nfor(var i=0;i<objs.length;i++) {\n  var imgUrl = objs[i].getAttribute('src'); var localUrl = window.local_obj.getLocalSrc(imgUrl); if(localUrl) {objs[i].setAttribute('src', localUrl);}\n}})()");
        }
    }
    
  4. 重新触发加载图片

    webSettings.setLoadsImagesAutomatically(true);
    
  5. 结果

    image
    image

    I/chromium: [INFO:CONSOLE(0)] "Not allowed to load local resource: file://storage/emulated/0/YanXuan/file/a.jpeg">

    及时替换了,本地图片也无法加载

3.2 请求拦截

除了使用 js 注入的方式之外,还可以使用 WebViewClient 的方法去修改加载对象。

webView.setWebViewClient(new WebViewClient() {
    @Override
    public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
        File file = getLocalImage(url);
        if (file == null || !file.exists()) {
            return null;
        }
        ...
        return new WebResourceResponse(mimeType, "UTF-8", new FileInputStream(file);
    }
});

此外,客户端需要预置资源,并维护网络图片 url 和本地图片之间的关联。

若预置资源不限于图片资源,由于 html、js、css 等资源容易发生变化,因此还需要实现一套机制实现本地资源和服务端数据及时的更新,即服务端需要支持版本控制和资源增量下发等功能。

CandyWebCache 是杭研团队在 Web 缓存方面做的一个思考和尝试,其功能非常强大:

其设计框架图如下:


image

截图自 移动端静态资源缓存方案 - CandyWebCache

  1. 提供 Gradle 插件,支持将 H5 资源提前打入 APP 中
  2. 支持设置内存缓存大小设置,磁盘缓存路径设置等
  3. 拦截 WebView 加载请求,在本地缓存存在的情况下,使用本地缓存,而不再发送网络请求
  4. APP 启动时,提供多种策略更新 webapp 的静态资源包,并支持全量更新和增量更新
  5. 静态资源更新使用机制并不局限于 WebView,同样支持 HotFix 和 ReactNative 等

4 本地轻量缓存策略

4.1 设计考虑

采用资源预置的方式,已经能很好的解决 H5 的流量问题和加载速度问题,然后还是有几个问题需要考虑

  1. 资源预置的方式,会导致 APK 包大小变大

    我们的 APK 现在也只有 12M+,然而预置的资源很容易让我们的 APK 包大小翻倍。

    解决方案:有团队采用资源预置的方案,但并没有将资源预置到 APK 中,而是在 APP 启动页或其他空闲时间段且在 wifi 状态下后台默默下载 H5 资源。

  2. 需要后端配合,需要后端提供接口支持全量更新和增量更新

    根据我们团队的现状:后端开发业务繁重,客户端开发这边压力比较轻,为此需要尽量降低后端的工作,甚至直接对后端开发透明

  3. 个别 H5 页面的更新展示,会不会不够及时?

    当 html、js、css 的内容发生改变的时候(url 链接不发生变化的情况下),需要经过 APP 端经过资源更新后,客户端的展示才会显示新内容。当然,只要保证 H5 资源发生变化,采用新的资源链接的方式就能避免。

  4. 个别 H5 页面不被流量,这部分流量消耗是否能避免?

    针对一款电商产品,H5 页面会很多(包括大量的专题页、每个商品的详情页等),因此用户极可能不会浏览全部的页面。然而我们开发并不清楚每个用户大概率会浏览哪些页面,如果全部资源预置或者预下载,那部分流量是没有必要的。
    相反的,如果采用资源预下载的方式,因为是 wifi 环境下,这部分流量消耗并没有多少影响。

综上,考虑点 1、3、4 并不是问题,而 2 却是我们客户端开发需要真正考虑的,如何对后端、前端开发完全透明?

我们期望能有一个轻量的缓存机制,客户端能接管浏览器的部分缓存和使用,进而分担浏览器缓存的消耗,同时也不期望增大 APP 包大小,并不依赖服务器(暂时未考虑动态化,不需要考虑脚本的打包下载)。考虑到我们目前的需求仅针对 H5 页面,同时影响流量和触发浏览器缓存清除的主要因素是图片资源,而图片资源基本不会变(相同 url 对应的图片数据,如果发生变化就会因为 DNS 缓存原因造成问题)。为此我们只需要能接管 H5 中的图片资源,同时也不需要将资源预置。共享浏览器图片缓存和本地图片缓存。

image

整体流程图

那么如何实现这一功能,需要确认几个问题:

  1. 如何拦截 H5 中的资源请求?
  2. 如何识别请求的资源是否是图片?
  3. 如何在 H5 加载中构建自己的缓存?
  4. 如何共享 H5 缓存与本地图片缓存?
  5. 如何使用已经构建的缓存?

4.2 拦截 H5 资源请求

查看 [Android Developer WebViewClient](https://developer.android.com/reference/android/webkit/WebViewClient.html#shouldInterceptRequest(android.webkit.WebView, java.lang.String)) 可以发现确实有接口可以拦截 H5 中的资源文件请求。

// added in API level 21
WebResourceResponse shouldInterceptRequest (WebView view,
                    WebResourceRequest request);

// added in API level 11
WebResourceResponse shouldInterceptRequest (WebView view,
                    String url)

Notify the host application of a resource request and allow the application to return the data. If the return value is null, the WebView will continue to load the resource as usual. Otherwise, the return response and data will be used. NOTE: This method is called on a thread other than the UI thread so clients should exercise caution when accessing private data or the view system.

2 个方法在后台线程执行,若返回值为 null,则 WebView 会继续从网络加载数据,若不为 null,则从返回的值中获取。

查看源码可以发现确实如此:

private class BackgroundThreadClientImpl extends AwContentsBackgroundThreadClient {
    // All methods are called on the background thread.

    @Override
    public AwWebResourceResponse shouldInterceptRequest(
            AwContentsClient.AwWebResourceRequest request) {
        String url = request.url;
        AwWebResourceResponse awWebResourceResponse;
        // Return the response directly if the url is default video poster url.
        awWebResourceResponse = mDefaultVideoPosterRequestHandler.shouldInterceptRequest(url);
        if (awWebResourceResponse != null) return awWebResourceResponse;

        awWebResourceResponse = mContentsClient.shouldInterceptRequest(request);

        if (awWebResourceResponse == null) {
            mContentsClient.getCallbackHelper().postOnLoadResource(url);
        }

        if (awWebResourceResponse != null && awWebResourceResponse.getData() == null) {
            // In this case the intercepted URLRequest job will simulate an empty response
            // which doesn't trigger the onReceivedError callback. For WebViewClassic
            // compatibility we synthesize that callback.  http://crbug.com/180950
            mContentsClient.getCallbackHelper().postOnReceivedError(
                    request,
                    /* error description filled in by the glue layer */
                    new AwContentsClient.AwWebResourceError());
        }
        return awWebResourceResponse;
    }
}
  1. WebView 加载资源 url 时,首先交由 DefaultVideoPosterRequestHandler 尝试拦截
  2. 否则,交由 mContentsClient 尝试拦截,这里就会调用 WebViewClient#shouldInterceptRequest 尝试拦截
  3. 否则,发送消息,交由其他组件进行网络加载
  4. 若前面拦截成功,返回结果 awWebResourceResponse 无数据,则直接回调错误

4.3 识别请求资源为图片

  1. 根据下载文件识别(不可取)

    如何识别下载的资源是图片,很容易想到在第一次网络下载之后,去查看本地文件。然而这种方法,并不可行,不同 Android 版本,图片保存的缓存目录并不一致,同时文件命名规则并不可见(还未查到源码)。另外,自行编码根据 url 对文件进行一次下载,而这样子就会触发一个 url 的 2 次下载,浏览器一次和自发的一次,虽然达到了目的,但是用户的流量却被我们浪费了,不可取。

  2. 根据请求 header 判断

    当 Android SDK >= 21 时,资源拦截接口为:

    WebResourceResponse shouldInterceptRequest (WebView view,
            WebResourceRequest request);
    

    我们检查 request 的数据可以看到,Header 中显示了 image/webp,指明了文件类型

    image
  3. 根据 url 判断

    当 Android SDK < 21 时,资源拦截接口为:

    WebResourceResponse shouldInterceptRequest (WebView view,
                    String url)
    

    此时并没有请求 Header,无法从 Header 中获取文件类型,所幸从 url 的后缀还是能识别出文件类型

    https://m.you.163.com/act/pub/c5AEmHDqQf.html
    https://yanxuan.nosdn.127.net/14956956090752977.png
    

4.4 在 H5 加载中数据引流

为了避免相同 url 的 2 次加载(WebView 的一次加载,我们编码实现的一次加载)而导致消耗用户流量。我们需要一次请求加载到我们制定的磁盘缓存路径,同时还能让 WebView 读取到数据。

查看 WebResourceResponse 的定义,可以看出资源拦截之后 WebView 加载数据,是从 mInputStream 中尝试加载。

public class WebResourceResponse {
    private boolean mImmutable;
    private String mMimeType;
    private String mEncoding;
    private int mStatusCode;
    private String mReasonPhrase;
    private Map<String, String> mResponseHeaders;
    private InputStream mInputStream;
    
    ....
}

那问题就好解决了,我们构建一个代理模式,将 InputStreamWrapper 返回。在 WebView 从 InputStreamWrapper 中读取数据(从输入流中获取数据时)时,我们同时将数据导流到指定的缓存路径,进而达到引流的效果,而这个过程对于 WebView 是完全透明的。

class InputStreamWrapper extends InputStream {
    private InputStream mInnerIs;
    ...
    
    @Override
    public int read(byte[] buffer) throws IOException {
        int count = mInnerIs.read(buffer);
        writeOutputStream(buffer, 0, buffer.length, count);
        return count;
    }

    @Override
    public int read(byte[] buffer, int byteOffset, int byteCount) throws IOException {
        int count = mInnerIs.read(buffer, byteOffset, byteCount);
        writeOutputStream(buffer, byteOffset, byteCount, count);
        return count;
    }
}

4.5 本地缓存构建

前面得到了数据引流,而利用这个引流出来的数据流,我们可以将这部分字节流输出到本地文件,即当图片资源下载完成,本地文件也就构建完成。那么得到这个本地文件后,要如何构建本地缓存呢?这里需要满足以下几个设计需求:

  1. 缓存上限可以设置
  2. 当达到上限,缓存优先保存最近使用的文件,删除最早使用的文件
  3. 缓存路径可以设置
  4. 应用层可以删除缓存

4.5.1 DiskLruCache

很容易想到 DiskLruCache,因为上述需求均已经满足,我们只需简单封装就能构建整个图片本地缓存。


// 创建 DiskLruCache 时可以设置缓存上限和缓存路径
public WebDiskLruCache(Context app, long maxSize, String dirPath) {
    try {
        File e = new File(dirPath);
        e.mkdirs();
        this.mDiskLruCache = DiskLruCache.open(e, this.getAppVersionCode(app), 1, maxSize);
    } catch (IOException var6) {
        Log.e("WebDiskLruCache", var6.toString());
        var6.printStackTrace();
    }
}
// 删除缓存
public void clear() {
    try {
        if(this.mDiskLruCache != null) {
            this.mDiskLruCache.delete();
        }
    } catch (IOException var2) {
        Log.e("WebDiskLruCache", var2.toString());
        var2.printStackTrace();
    }
}

4.5.2 共用图片 SDK 本地存储

虽然使用 LruDiskCache 能简单完美的实现需求,然而考虑到工程中基本上会使用相关图片库,这些图片库的都有各自的缓存策略机制。

  • UniversalImageLoaderUnlimitedDiskCache
  • FrescoDiskStorageCache
  • Picasso:借助 HTTP 缓存
  • GlideDiskLruCache

因为我们自身的 APP 工程使用的是 Fresco,这里就分析下如何共享 Fresco 的磁盘缓存。首先查看 Fresco 加载网络图片的的流程:

image

流程看似没有什么问题,然而 Fresco 的磁盘缓存并不只有唯一一个,查看 DiskCacheProducer.java 可以发现有 2 个磁盘缓存,分别为 mSmallImageBufferedDiskCachemDefaultBufferedDiskCache。至于会使用哪个缓存,和应用层 ImageRequest 的创建有关。

public void produceResults(
      final Consumer<EncodedImage> consumer,
      final ProducerContext producerContext) {
    ImageRequest imageRequest = producerContext.getImageRequest();

    ...

    final CacheKey cacheKey = mCacheKeyFactory.getEncodedCacheKey(imageRequest);
    final BufferedDiskCache cache =
        imageRequest.getImageType() == ImageRequest.ImageType.SMALL
            ? mSmallImageBufferedDiskCache
            : mDefaultBufferedDiskCache;
    ...
}

虽然基本上我们可以通过反射或者其他方式获取 mDefaultBufferedDiskCache 并往里面塞 WebView 的图片数据,但是如果以后 Fresco SDK 升级,可能导致反射失败或底层磁盘缓存变化,为此这里通过替换 HttpUrlConnectionNetworkFetcher 方式设置图片数据。

ImagePipelineConfig.Builder configBuilder = ImagePipelineConfig.newBuilder(context)
    .setNetworkFetcher(WebFrescoNetworkFetcher.getInstance())
    ...
    ;
Fresco.initialize(context, configBuilder.build());

并修改图片预获取逻辑如下:

image

由于 CacheKey 同样由 url 计算得到,为此我们已经打通了非 H5 页面的图片资源缓存和 WebView 中的图片资源缓存。这样带来的好处是:

  1. 只有一份图片磁盘缓存,更容易业务层设置缓存上限
  2. WebView 和其他页面的相同 url 的图片不会存在重复缓存,增加磁盘利用率

4.6 使用已构建的文件缓存

如何使用缓存,其实就比较简单了,就是如何根据 url 利用 Fresco 的 api 依次读取内存中的缓存和磁盘缓存得到 InputStream 并构建 WebResourceResponse 即可。

FrescoReadFromCache.png

针对 Fresco 磁盘缓存

4.7 其他主流图片 SDK 缓存共享

  • glide:3.7.0

    通过反射,获取到最终的 DiskCache 对象,并进行 put 和 get 操作

    Glide → Engine → diskCacheProvider → DiskCache
    
  • universal-image-loader:1.9.5

    直接利用接口获取 DiskCache 对象

    ImageLoader.getInstance().getDiskCache()
    
  • picasso:2.5.2

    打开 HttpUrlConnection cache 开关

    HttpURLConnection conn = (HttpURLConnection) httpUrl.openConnection();
    conn.setRequestMethod("GET");
    conn.setUseCaches(false);
    

4.8 默认浏览器缓存使用对比

由上我们拦截了 WebView 图片请求,并设计了一套自己的缓存策略,然而浏览器缓存中是否会有重复图片缓存,需要进一步确认。

新安装 APP,并打开专题页如下,滚动确保加载全部资源,检查默认浏览器缓存情况。

image
  1. 未使用本地资源拦截:

    打开 data/data/${packagename}/cache/org.chromium.android_webview/,本地有 113 个文件(排除 index-dir 文件夹),占用空间大小 7.26M

    image
  2. 使用本地资源拦截:

    打开缓存目录,本地有 26 个文件(排除 index-dir 文件夹),占用空间大小 1.13M

    image

由上可以对比得出,使用本地资源拦截,图片缓存已经不再默认浏览器缓存目录,确保缓存不存在重复的 2 份

4.9 有缓存情况下的性能测试

WebView 加载速率对比

网络环境 无缓存 浏览器缓存 LightWebCache
2G
gif
[图片上传失败...(image-9ed1be-1548903975485)] [图片上传失败...(image-96c41b-1548903975485)]
2G \ 27.4s 6.8s
3G
gif
gif
gif
3G \ 22.6s 3.7s
4G
gif
gif
gif
4G \ 1.1s 0.9s

WebView 请求数对比

无缓存 浏览器缓存 LightWebCache
请求数 86 (23 个 304 图片请求) 90 21

WebView 流量使用对比

  • 初次安装,1 个 H5 页面进入一次
单次进入 无缓存 浏览器缓存 LightWebCache
接收 2.38M 80.7K 21.6K
发送 146.0K 77.7K 12.4K
总计 2.52M 158.5K 34.0K
  • 初次安装,3个 H5 页面依次遍历 3 次
浏览器缓存 LightWebCache
接收 8.88M 8.76M
发送 714.7K 586.1K
总计 9.58M 9.3M

5 首次加载速度优化

5.1 无缓存情况下的性能测试

在无缓存情况下(新安装 APP),对比正常情况和使用资源拦截的情况下的页面加载速度

红米 Note4,Android 6.0 (chrome 内核)

网络环境 正常情况 资源拦截
wifi
gif
gif
2.1s 2.6s
wifi
gif
gif
1.5s 1.4s

酷派 8729,Android 4.3 (非 chrome 内核,云测平台 wifi 状态弱)

网络环境 正常情况 资源拦截
wifi
gif
gif
5.8s 14.9s

5.2 无缓存情况下的性能结果解析

容易发现,非 chrome 内核的 WebView 加载对比很明显,无任何资源缓存情况下,使用资源拦截加载相比正常使用,加载速度慢了很多。

WebResourceResponse.mInputStream 的 read 方法增加日志,并得到日志结果。

@Override
public int read(@NonNull byte[] buffer) throws IOException {
    LogUtil.i("WebCache_Thread", "this.hashcode = " + this.hashCode() + " thread = " + Thread.currentThread());
    ...
}

@Override
public int read(@NonNull byte[] buffer, int byteOffset, int byteCount) throws IOException {
    LogUtil.i("WebCache_Thread", "this.hashcode = " + this.hashCode() + " thread = " + Thread.currentThread());
    ...
}
  1. 酷派 8729,Android 4.3 (非 chrome 内核)

    image

    各个图片的 read 方法是单线程执行

  2. 红米 Note4,Android 6.0 (chrome 内核)

    image

    各个图片的 read 方法是多线程执行

  • 问题分析:

    由于在 Android 4.4 及以上,拦截的资源是多线程加载,因此无缓存且资源拦截情况下,H5 的加载速度和正常的 WebView 加载速度差别不大。而在 Android 4.4 以下,拦截的资源是单线程加载的,因此无缓存且资源拦截情况下,H5 的加载速度就要慢很多。

    这里并不考虑,有缓存情况下的加载,因为即便是本地 IO 操作,比起网络操作速度也要快上很多,即便是单线程加载,也并不会导致加载体验问题。

5.3 拦截资源并行加载优化

针对 Android 4.4 以下单线程加载速度慢的问题,可以在图片资源下载的中间添加一个下载缓冲区,模拟并发下载。

image
  • 性能对比:
网络环境 资源拦截 资源拦截+预加载
wifi
gif
gif
14.9s 9.6s

由数据可得,使用预加载的方式,加载速度提升了 36% 左右,但比起不使用资源拦截的方式却是还是慢了不少,而其原因是前端页面已经支持了图片懒加载,而资源拦截的方式,进度条的展现收到了图片资源下载的影响,因此看起来加载速度更慢。另一方面,现在 Android 4.3 及以下的手机已经占比很少了,因此这部分机型的性能降低影响并没有这么大。

image

引用自 android develop

5.4 其他优化方式

  1. 图片资源懒加载

    客户端加载 H5 页面时设置不加载图片,在非图片资源加载完毕时,重新触发加载图片,加快页面展示。

    • 在 WebView 加载页面之前先设置 webView.getSettings().setBlockNetworkImage(true); 将图片下载阻塞
    • 在浏览器 OnPageFinished事件中设置 webView.getSettings().setBlockNetworkImage(false);

    特点:

    • 仅对非拦截的图片资源生效
    • 视觉展示效果需要进一步处理
    gif
  2. Chrome Custom Tabs

    gif

    提供了接口支持 chrome 后台加载

    特点:

    • 需要用户有安装 chrome,并且被设置为默认浏览器
    • 系统的要求是4.3(API 18)以上,Chrome 版本45+
    • 非内置 WebView
  3. VasSonic

    特点:

    • 提供预加载接口,预加载 html 内容至内存
    • 仅提升 html 文件加载,js、css、图片等资源走正常浏览器流程
    • 预加载不支持重定向
    image
  4. 后台 Service 预加载 WebView

    • 独立进程预加载 html、js、css 等资源
    • 其他资源拦截直接拦截返回空字节流

    特点:

    • 下次加载也仅保证预加载资源请求 304,速度提升有限

6 总结

使用 WebView 轻量缓存策略,给我们带来的好处如下:

  1. 相比资源预加载方案,保持了 APK 包大小,无需服务端配合更新本地资源;资源更新和用户浏览的页面有关,不存在用户无浏览而预下载导致的流量消耗
  2. html、js、css 等变化可能性较大的文件,通过浏览器缓存策略,确保了文件的及时更新
  3. 对前端、后端和移动端(业务层)透明
  4. 接管浏览器缓存中的图片资源缓存,减少了流量器缓存增长速度,避免 html、js、css 等文本文件因缓存上限被删除的可能,增大了缓存周期变长,为此减少了这部分数据的流量和加载速度
  5. 扩大 WebView 图片缓存上限,极大的减少了图片重复下载的流量耗损
  6. 拦截 WebView 图片请求,相比浏览器缓存,减少了 304 的请求的流量和性能消耗
  7. 打通了本地图片缓存和 WebView 的图片缓存,避免了 H5 和其他页面图片的重复请求导致的流量消耗和磁盘占用。
  8. 缓存策略简单,可维护性好

除此之外,还有一些完善的地方需要我们做进一步处理:

  1. 使用缓存的情况下,相比全资源预加载方案,页面打开速度要慢,多出了 html、js、css 等文件的请求,不过这部分的开销较小
  2. 资源预加载策略需要根据产品需求选择方案
  3. 缓存策略仅适用于 WebView,对于动态化脚本,热补丁包的下发等流量消耗,并不支持优化,这方面可以参考 ht-candywebcache-android ,后续做优化。

参考文档

  1. ht-candywebcache-android
  2. WebView 图片离线缓存(含图片)
  3. 移动端静态资源缓存方案 - CandyWebCache
  4. VasSonic:轻量级高性能Hybrid框架
  5. Chrome Custom Tabs
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容