关于WebView的秒开实战

2 阅读8分钟

通过这篇文章你可以了解到

  • 性能优化的思维方式

  • WebView进阶知识

写在前面

最近组里做新的Web容器的,一次承载多个H5页面,以实现左右切换,默认展示主会场页,并要达到提升打开率的目标。要达到这个目标,那势必要从加载优化入手,缩短页面的打开时间。优化的点包括但不限于,Activity初始化、ViewPager和Fragment的初始化、WebView的初始化等等。

上一片文章给大家分享了我在ViewPager上面做的优化,本篇文章再接着给大家分享下WebView秒开的尝试。

优化效果

我们以提升打开率为目标,口径是资源位点击WebView的onPageFinished

使用新的容器之后,打开率提升了大约10%-20%65%—>85%),在低端机上的提升较为明显。为了让各位同学更加直观的感受到优化后的效果,这里用两张图简化的流程图来表示:

ago.png

以上是我们的容器简略的加载过程需经过6个步骤,加载时长从Activity的onCreate开始计算到WebView的onPageFinished大约需要3000ms(低端机)。很显然,在如今这个快节奏的社会,用户是不会等待这么长时间的。为此我们对它进行了一场手术,把它整成了下边的样子:

after.png

???what's the ****?玩俄罗斯方块吗?

同学憋急,你现在只需要关注的是:它由6个冗长的步骤,变成了两个步骤(Na组件放在了WebView初始化完成后加载),大大缩减了我们首页的加载时间。关于你的疑问,我会在下边的章节解释。

过程分析

上一节我们讲到,一次完整的打开过程需要经过6个步骤,经过了我们大刀阔斧的改造后,只需要两个步骤。这节接着给大家剖析我们这么做的底层逻辑。

Native优化

资源预加载

WebView组件加载过程有三处网络耗时分别是主文档HTML的加载、JS/CSS的加载和内容数据的加载,串行的流程是效率及其低下的。那么我们是不是改成并行的?当然不能!

  • 主文档HTML其实就是一个H5的框架,一个页面内所有的资源都是先通过主文档来触发加载,在主文档被加载之前我们是不能知道有哪些JS和CSS文件的。

  • 内容数据(包括图片)是由我们的业务方决定的,涵盖了各个营销场景,不像新闻浏览类的页面有固定的排版样式。由于页面不统一,单独对它进行下载再注入的改造成本有点大。(后续的离线化方案可实现

基于上述两点,可取的做法是:先把主文档数据预取后缓存,待WebView loadUrl之后,通过WebViewClient的监听去拦截主文档的请求

@Nullable
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
    for (RequestInterceptor interceptor : Interceptors) {
        //拦截请求,去缓存中取出主文档数据
        WebResourceResponse response = interceptor.intercept(view, request);
            if (response != null) {
                return response;
            }
    }
    return super.shouldInterceptRequest(view, request);
}

预加载时机

预加载放在点击资源位(资源位在首页区域)之前是最理想的,这也是我们的初步设想。但是涉及到首页模块的改造,需要对应组件方的配合支持,会导致开发周期的延长。所以我们决定在第一版以HOOK Instumentation的方式,在点击资源位之后,Activity的onCreate之前去开启子线程对主文档进行预加载

wait4.png

同时跟首页组件方协调方案:在首页的T2阶段(不影响其它优先级更高的任务)对资源位进行预加载,点击后如果首页预加载成功则直接打开Activity,否则继续Instrumentation加载逻辑。

JS/CSS预加载

主文档加载完成了之后,可以对缓存的数据进行识别查找到需要加载的JS/CSS文件,紧接着开始进行JS/CSS的预加载。

下面时查找JS文件的伪代码:

private static final String JS_PATTERN = "<script\\s+[^>]*?src=[\"']([^\"']+)[\"'][^>]*>(?:<\\/script>)?";

/**
 *@param htmlData 将主文档的二进制文件转换成的String类型
 *@param JSPattern 用于从主文档内匹配JS文件的正则表达式
 */
private void recognitionJS(String htmlData, String JSPattern) {
    try {
        Pattern scriptPattern = Pattern.compile(JSPattern, Pattern.CASE_INSENSITIVE);
        Matcher scriptMatcher = scriptPattern.matcher(htmlData);
        while (scriptMatcher.find()) {
            String link = scriptMatcher.group(1) + "";
            if (TextUtils.isEmpty(link)) {
                continue;
            }
 
            mResSet.add(link);
        }
     } catch (Exception e) {
     }
}

这样一来我们的流程在第二版就变成了:

wait6.png

到这里我们在数据请求这一块所做的优化就结束了,那么我们的矛头接下来该指向哪里?

WebView预热

首次创建耗时较长

我从埋点的数据中发现,容器冷启打开的时间比热启要长的多,从Activity onCreate到WebView loadUrl之前的耗时比起热启大约慢了200多ms。这个过程中初始化的组件除了WebView还有有ViewPager和Fragment,通过再次细分阶段的埋点统计耗时发现,启动方式对这两者的初始化时间影响不大,WebView初始化时间自然就成了我们攻克的对象

我们找来了其它几个机型重复上述的步骤,高端机上表现并不明显,但也存在差异(大约80ms)。进一步确定了是WebView自身的原因,可以得出结论:WebView第一次初始化的时间会比后续创建的时间长,具体差异取决于机型性能

WebView Pool

利用前面得出的结论,可以在App启动时开始WebView的第一次初始化再销毁,以减少后续使用过程的创建时间。但还是避免不了往后创建带来的时间开销,这个时候池化技术就呼之欲出了。

我们可以将创建好的WebView放入容器中,可以一个也可以多个,取决于业务。由于创建WebView需要和Context绑定,而预创建WebView是无法提前获知所需要挂载的Activity的,为此我们找到了MutableContextWrappe。引用官方对它的介绍:

Special version of ContextWrapper that allows the base context to be modified after it is initially set. Change the base context for this ContextWrapper. All calls will then be delegated to the base context. Unlike ContextWrapper, the base context can be changed even after one is already set.

翻译成人话:它允许在运行时动态地更改 Context。这在某些特定场景下非常有用,例如,当您需要在不同的 Context 之间灵活切换或修改 Context 时。真是完美的解决了我们预创建绑定Context的问题!

//预创建WebView,存入缓存池
MutableContextWrapper contextWrapper = new MutableContextWrapper(getAppContext());
mWebViewPool.push(new WebView(contextWrapper));

//取出WebView,替换我们所需要的Context
WebView webView = mWebViewPool.pop();
MutableContextWrapper contextWrapper = (MutableContextWrapper) webView.getContext();
contextWrapper.setBaseContext(activityContext);

看到这里,如果你是不是以为WebView的池化就这样结束了?

那是不可能滴

那是不可能滴

那是不可能滴

子进程承接

众所周知,一个亿级DAU的商业化App是非常庞杂的。在App启动时,有许多的任务需要初始化,势必会带来很大的性能开销。如果在这个阶段进行WebView的创建和池化的操作。前者可能会引出ANR,后者则是会面临内存溢出的风险。一波刚平,一波又起!

怎么办?再开个线程?WebView不能在子线程初始化,即使可以也解决不了内存开销的问题。PASS!

线程不行,进程呢?Bingo!

我们可以在App启动时开启一个子进程,在子进程进行WebView的初始化和池化的任务。系统会为子进程重新开辟内存空间,同时在子进程创建WebView也不会阻塞主进程的主线程,顺带也可以提高我们主进程的稳定性,可谓是一举多得。整个加载流程也就变成了三个大步骤。

wait67.png

组件懒加载

上一篇文章里有讲到我们容器的页面结构,没看过的请点击这里。在开始WebView加载之前会经过ViewPager和Fragment的初始化,经过线下实验统计,省去这两玩意儿大约可以提升67%(口径:Activity onCreate到WebView loadUrl,也就是说ViewPager和Fragment占这个过程的67%)。这不,优化点又来了。

打开容器的第一阶段,只需加载一个页面。因此我们可以将WebView直接放在Activity上显示,无需ViewPager和Fragment的介入,等到首页加载完成后再初始化这两组件,并开始缓存其它页面。

到这里我们的加载流程就变成了开头的样子了:

wait68.png

不要抬杠:你开头画的也不是这个样子的啊?

咱这不是为了更方便的理解,所以在开头小小的抽象了一下吗。手动狗头

其它优化

剩下还有一些前端的通用优化方式、网络通用优化方式在网上有同学总结的很清楚,在这里我就不一一列举。感兴趣的可以跳转对应文章进行查阅

今日头条品质优化 - 图文详情页秒开实践