Android—WebView加载速度优化工程实践

一、混合开发的优势与缺陷

在混合开发大行其道的今天,很多页面和功能都转由前端实现,客户端只要在APP中嵌入一个WebView即可,同时前端开发的页面对于Android和iOS端的效果是统一的,省去了适配的困扰。

适合前端开发的界面主要有以下两种:
1、新闻咨询类页面,这类页面布局比较复杂,通过前端实现相对原生更为简单。
2、运营活动类界面,这类页面更新较为频繁,前端迭代后可以直接上线,跳过了客户端的发版流程。

前端开发的优势显而易见:开发敏捷、发版灵活。但是它的缺点同样明显,我认为主要有以下3点。
第一个问题就是性能问题,冷启APP后第一次新建WebView的耗时会让用户感受到明显的卡顿,而WebView.loadUrl(String url)的耗时更为严重,很容易出现白屏的现象。
第二个问题是浪费流量,虽然WebView内核有缓存机制(例如两次打开相同的页面,第二次会快很多),但是对于新闻咨询页面来说,很多css、js甚至图片资源都是重复的,但是内核的缓存机制不一定能准确地复用这些资源。
第三个问题就是安全问题,部分前端页面需要通过JSBridge调用原生API,如果JavaScriptInterface不对调用方的域名进行限制,某些恶意网址就可能调用原生的方法进行入侵。

安全问题是依赖业务去解决的,如果没有暴露相应的方法,恶意网址也就无从下手。而性能问题与流量问题则会影响WebView的加载速度,比较影响用户体验,下面从WebView的加载流程来看我们应该如何解决这两个问题来提高加载速度。

二、WebView加载页面的流程

假设WebView嵌入在Activity中,从Activity启动到显示出前端页面的第一帧,大致要经历以下阶段。
① Activity启动
② WebView新建与初始化
③ WebView.loadUrl(String url)
而当调用WebView.loadUrl(String url)之后,所有的资源请求都会经过WebViewClient的shouldInterceptRequest(...)方法,这些资源请求包括但不局限于主html内容、css、js和图片资源等,应用可以在该方法拦截资源请求并加载别的内容。

可以看到加载页面的流程是串行的,主要分为两个部分,一是WebView的新建与初始化,二是WebView.loadUrl(String url),那么只要我们缩短其中任一阶段的耗时,就可以达到缩短耗时的目的。

三、WebView加载速度优化方案

这里以新闻资讯类页面为例,目前这类页面加载最快的就是头条系APP,例如“懂车帝”,在信息流内点击咨询跳转后直接展示咨询详情,不会像其他APP那样出现过渡界面或白屏。

那么懂车帝究竟做了什么才能让WebView加载地如此之快?
首先对于新闻咨询页面来说,不同的页面之间有很多重复的资源,这些资源可以直接保存在本地复用。反编译懂车帝APK之后会发现Assets中有很多的css、js和图片文件,应用可以拦截这部分的请求转而加载这些文件。
其次,WebView的新建与初始化也是比较耗时的,因此可以使用WebView缓存池进行复用。
当然还有最重要的一点就是预加载,在懂车帝信息流内,即使你断网后点开一条咨询,你会发现文字内容还是正常加载。也就是说,在信息流内时,url的主html内容已经被下载到了内存中,可以直接通过WebView.loadDataWithBaseUrl(...)加载。

接下来介绍这3种方案的具体实现以及对加载速度的影响,我在懂车帝中挑选了7篇咨询,编写Demo并统计其加载时间(这里统计的是点击item到WebView加载进度为100所需的时间),在没有使用任何优化的情况下,平均加载时间为1203ms。

3.1 资源缓存

上面提到,WebView 请求任何的资源时都会回调shouldInterceptRequest()方法,此时可以将 WebView 需要请求的资源替换为本地资源以提升加载速度。
本地资源可以保存在Assets或文件系统中,如果保存在Assets中,文件的安全性会得到保证,没有出错或者被修改的风险,但是无法实时更新,只能依赖客户端发版更新文件;如果保存在文件系统中,可以做到实时更新,但是下载文件时可能出错,使用时需要对文件进行校验。

这里以保存在Assets中为例,当WebView回调shouldInterceptRequest(...)时,如果发现当前的文件可以使用Assets中的缓存,即可将其包装成WebResourceResponse,如下所示。

mWebView.setWebViewClient(new WebViewClient() {
    @Override
    public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
        // 根据url得到文件名
        String fileName = getFileNameByUrl(request.getUrl().toString());
        if (!fileInCache(fileName)) {
            // 如果当前文件不在缓存列表中, 不使用缓存
            return super.shouldInterceptRequest(view, request);
        } else {
            // 当前文件可以使用缓存, 根据后缀判断 mimetype
            InputStream inputStream = null;
            String mimeType = null;
            if (fileName.endsWith("css")) {
                mimeType = "text/css";
            } else if (fileName.endsWith("js")) {
                mimeType = "text/javascript";
            } else if (fileName.endsWith("png")) {
                mimeType = "image/png";
            }
            if (mimeType == null) {
                return null;
            }
            try {
                inputStream = App.getContext().getAssets().open(fileName);
            } catch (IOException e) {
                Log.e(LOG_TAG, "read file IOException: " + e.getMessage());
            }
            if (inputStream != null) {
                WebResourceResponse response = new WebResourceResponse(
                            mimeType, "utf-8", inputStream);
                // 解决css、js的跨域问题
                Map<String, String> headers = new HashMap<>();
                headers.put("Access-Control-Allow-Origin", "*");
                headers.put("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT");
                headers.put("Access-Control-Max-Age", "3600");
                headers.put("Access-Control-Allow-Headers", "Content-Type,Access-Token,Authorization");
                response.setResponseHeaders(headers);
                return response;
            }
            return null;
        }
    }
});

使用资源缓存后,平均的加载时间为1001ms,相较于初始时提升了200ms左右。

3.2 WebView缓存池

平时使用WebView的时候我们都是动态新建并添加到ViewGroup中,新建时就传入了Context,那如果使用缓存池,Context该怎么传呢?

一种方案是直接用ApplicationContext新建WebView;另一种方案是使用MutableContextWrapper,如果在某个Activity中被使用了就改为该Activity的Context,回收时改为ApplicationContext。一个简易版的WebView缓存池实现如下。

注意在APP启动时应该就要调用WebViewPool的初始化方法新建一个WebView,不然启动第一个包含WebView的页面时耗时也会很久。

public class WebViewPool {

    private List<DetailWebView> mIdleWebViewList;
    private List<DetailWebView> mUsingWebViewList;

    private static class Holder {
        private static WebViewPool sInstance = new WebViewPool();
    }

    public static WebViewPool getInstance() {
        return Holder.sInstance;
    }

    private WebViewPool() {
    }

    /**
     * 在 APP 启动时调用, 直接新建一个备用的WebView
     */
    public void init() {
        mIdleWebViewList = new CopyOnWriteArrayList<>();
        mUsingWebViewList = new ArrayList<>();
        MutableContextWrapper contextWrapper = new MutableContextWrapper(App.getContext());
        DetailWebView detailWebView = new DetailWebView(contextWrapper);
        mIdleWebViewList.add(detailWebView);
    }

    public DetailWebView acquireWebView(Context context) {
        if (mIdleWebViewList != null && mIdleWebViewList.size() > 0) {
            DetailWebView webView = mIdleWebViewList.remove(0);
            MutableContextWrapper contextWrapper = (MutableContextWrapper) webView.getContext();
            contextWrapper.setBaseContext(context);
            mUsingWebViewList.add(webView);
            return webView;
        } else {
            MutableContextWrapper contextWrapper = new MutableContextWrapper(context);
            DetailWebView webView = new DetailWebView(contextWrapper);
            mUsingWebViewList.add(webView);
            return webView;
        }
    }

    public void recycleWebView(DetailWebView webView) {
        if (webView == null) {
            return;
        }
        ViewGroup viewParent = (ViewGroup) webView.getParent();
        if (viewParent != null) {
            viewParent.removeView(webView);
        }
        webView.loadUrl("about:blank");
        if (mUsingWebViewList != null && mUsingWebViewList.contains(webView)) {
            mUsingWebViewList.remove(webView);
            MutableContextWrapper contextWrapper = (MutableContextWrapper) webView.getContext();
            contextWrapper.setBaseContext(App.getContext());
            webView.setWebViewClient(null);
            webView.setWebChromeClient(null);
            mIdleWebViewList.add(webView);
        } else {
            webView.clearHistory();
            webView.destroy();
        }
    }
}

在使用资源缓存的基础上再使用WebView缓存池,平均的加载时间为918ms。

3.3 预加载

预加载主体的实现比较简单,在信息流的RecyclerView进入IDLE状态时,判断当前有多少个条目曝光,然后启动对应数量的子线程开始下载数据为String,用户进入某个条目时,如果数据下载完毕就可以直接通过WebView.loadDataWithBaseURL(mUrl, data, "text/html", "utf-8", null)展示数据。
应用对预加载下载的数据是需要统一管理的,可以考虑使用LRU缓存。不过由于是多线程下载数据,需要对在对LRU缓存读取时加锁保证线程安全,这里的锁可以选择读写锁。

通过实验,使用预加载之后的平均加载时间为698ms。

3.4 总结

这里的Demo对于加载时间的统计其实是存在误差的,首先选取的咨询较少;其次前端页面的加载时间受网速影响是最大的。所以这个统计仅供参考,但是也能看出来每个优化方法都是有作用的,在实际项目中可以根据项目实际情况使用。

四、其余优化方案

腾讯有一个WebView加载优化的方案VasSonic,将WebView初始化和WebView加载数据这两个操作由原本的串行改为并行,缩短整体的加载时间。

VasSonic大概的流程为:WebView开始初始化时启动一个子线程去下载html数据,当WebView初始化完的时候通知子线程已经初始化完毕,此时数据下载有3种情况:1、子线程还没开始下载数据;2、子线程下载了一部分数据;3、子线程已经下载完了数据。收到WebView初始化完的消息后,子线程将已经下载的数据和没有下载的数据拼接为桥接流返回给内核渲染。

当然这个框架还有很多功能,例如将html内容分为模板和数据,并且为模板和数据分别提供更新的功能,适合用于更新频繁的运营类界面,具体可见参考3。

参考

  1. 常用文件的mime和mimetype的对应关系
  2. 跨域详解
  3. 腾讯祭出大招VasSonic,让你的H5页面首屏秒开