Android H5秒开方案调研—今日头条H5秒开方案详解

本文对Android H5秒开方案进行了调研,对今日头条App的秒开方案进行了详细分析。

本文首发:http://yuweiguocn.github.io/

《子夜吴歌·秋歌》
长安一片月,万户捣衣声。
秋风吹不尽,总是玉关情。
何日平胡虏,良人罢远征。
—唐,李白

背景

在回家的地铁上使用自家应用H5相关功能时,可能由于网络原因导致体验较差,在使用微信、今日头条App时,感觉很流畅,基本做到了秒开,然后就想了解下业内H5秒开方案。

问题原因

  • 文件下载耗时:包括html、css、js、图片等
  • 页面渲染耗时:页面渲染,解析js、css文件等
  • WebView创建耗时:首次创建WebView耗时大约需要500ms左右,第二次创建耗时大约需要20ms左右

常见解决方案

WebView缓存相关

  • 浏览器缓存机制,通过请求头控制缓存
  • Dom Storgage(Web Storage)存储机制
  • Web SQL Database 存储机制
  • Application Cache(AppCache)机制
  • Indexed Database (IndexedDB)

可通过以下代码实现:

WebSettings webSettings = myWebView.getSettings();

webSettings.setCacheMode(WebSettings.LOAD_DEFAULT);

webSettings.setDomStorageEnabled(true);

webSettings.setDatabaseEnabled(true);
final String dbPath = getApplicationContext().getDir("db", Context.MODE_PRIVATE).getPath();
webSettings.setDatabasePath(dbPath); 

webSettings.setAppCacheEnabled(true);
final String cachePath = getApplicationContext().getDir("cache", Context.MODE_PRIVATE).getPath();
webSettings.setAppCachePath(cachePath);
webSettings.setAppCacheMaxSize(5*1024*1024);

webSettings.setJavaScriptEnabled(true);

开源方案

  • CacheWebView: 通过拦截shouldInterceptRequest方法使用okhttp的缓存功能实现,使用简单可配置。
  • VasSonic:腾讯出品的一个轻量级的高性能的Hybrid框架,专注于提升页面首屏加载速度,完美支持静态直出页面和动态直出页面,支持预加载兼容离线包等方案。优点是性能好,速度快,大厂出品,缺点是配置复杂, 同时需要前后端接入。

今日头条方案

先来看下今日头条的效果,第二次断网打开页面做到了秒开的效果:


今日头条针对自己平台的文章详情页做了很多优化,具体包括以下几点:

  • 内置文章详情页所需的css、js等文件,并可以控制版本
  • WebView预创建
  • 预加载包含文章详情页所需的css、js的空html
  • 在列表页预加载文章详情所需的内容使用LRU内存缓存并保存到本地数据库
  • 在文章详情页获取预创建的WebView(预加载了html),直接调用js设置页面内容
  • 通过js控制图片的显示,图片懒加载(当图片在可见区域或即将可见才会加载图片),点击加载图片等
  • Html中的图片通过ContentProvider获取使用Fresco下载的图片

内置所需文件

WebView预创建,资源预加载

首次创建WebView要比第二次创建耗时慢很多,原因估计是WebView首次创建需要初始化一些静态资源,第二次创建时不需要初始化,所以第二次创建耗时要少很多。

使用Context包装类MutableContextWrapper传入Application预创建WebView对象,然后预加载一个使用java代码拼接的html,提前对js、css资源进行解析。等获取预创建的WebView时再替换为Activity的context。

public class PreloadWebView {
    private PreloadWebView(){}

    private static final int CACHED_WEBVIEW_MAX_NUM = 2;
    private static final Stack<WebView> mCachedWebViewStack = new Stack<>();


    public static PreloadWebView getInstance(){
        return Holder.INSTANCE;
    }

    private static class Holder{
        private static final PreloadWebView INSTANCE = new PreloadWebView();
    }

    /**
     * 创建WebView实例
     * 用了applicationContext
     */
    public void preload() {
        L.d("webview preload");
        Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
            @Override
            public boolean queueIdle() {
                if (mCachedWebViewStack.size() < CACHED_WEBVIEW_MAX_NUM) {
                    mCachedWebViewStack.push(createWebView());
                }
                return false;
            }
        });
    }

    private WebView createWebView() {
        WebView webview = new WebView(new MutableContextWrapper(App.getApp()));
        webview.getSettings().setJavaScriptEnabled(true);
        webview.loadDataWithBaseURL("file:///android_asset/article/?item_id=0&token=0",getHtml(),"text/html","utf-8","file:///android_asset/article/?item_id=0&token=0");
        return webview;
    }


    private static String getHtml() {
        StringBuilder builder = new StringBuilder();
        builder.append("<!DOCTYPE html>\n");
        builder.append("<html>\n");
        builder.append("<head>\n");
        builder.append("<meta charset=\"utf-8\">\n");
        builder.append("<meta name=\"viewport\" content=\"initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n");
        builder.append("<link rel=\"stylesheet\" type=\"text/css\" href=\"");
        builder.append("file:///android_asset/article/css/android.css");
        builder.append("\">\n</head>\n");
        builder.append("<body class=\"font_m\"><header></header><article></article><footer></footer>");
        builder.append("<script type=\"text/javascript\" src=\"");
        builder.append("file:///android_asset/article/js/lib.js");
        builder.append("\"></script>");
        builder.append("<script type=\"text/javascript\" src=\"");
        builder.append("file:///android_asset/article/js/android.js");
        builder.append("\" ></script>\n");
        builder.append("</body>\n");
        builder.append("</html>\n");
        return builder.toString();
    }

    /**
     * 从缓存池中获取合适的WebView
     *
     * @param context activity context
     * @return WebView
     */
    public WebView getWebView(Context context) {
        // 为空,直接返回新实例
        if (mCachedWebViewStack == null || mCachedWebViewStack.isEmpty()) {
            WebView web = createWebView();
            MutableContextWrapper contextWrapper = (MutableContextWrapper) web.getContext();
            contextWrapper.setBaseContext(context);
            return web;
        }
        WebView webView = mCachedWebViewStack.pop();
        // webView不为空,则开始使用预创建的WebView,并且替换Context
        MutableContextWrapper contextWrapper = (MutableContextWrapper) webView.getContext();
        contextWrapper.setBaseContext(context);
        return webView;
    }


}

本地数据库缓存

使用数据库进行持久化。


图片资源的显示

使用ContentProvider获取图片资源:

content://com.xposed.toutiao.provider.ImageProvider/getimage/origin/eJy1ku0KwiAUhm8l_F3qvuduJSJ0mRO2JtupiNi9Z4MoWiOa65cinMeX57xXVDda6QPKFld0bLQ9UckbJYlR-UpX3N5Smfi5x3JJ934YxWlKWZhEgbeLhBB-QNFyYUfL1s6uUQFgMkKMtwLA4gJSVwrndUWmUP8CC5xhm87izlKY7VDeTgLXZUtOlJzjkP6AxXfiR5eMYdMCB9PHneGHBzh-VzEje7AzV3ZvHYpjJV599w-uZWXvWadQR_vlAhtY_Bn2LKuzu_GGOscc1MfZ4veyTyNuuu4G1giVqQ==/6694469396007485965/3

上面的ContentProvider的uri会调用对应ContentProvider的openFile方法,别忘了在清单文件中注册。

public class ImageProvider extends ContentProvider {
  ...
  public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
    File file = getFile(uri);
    return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) ;
  }
  ...
}

中间字符串使用zip压缩,使用下面的代码解压zip数据的代码:

static final byte[] buffer = new byte[4096];
public static final String unzip(String str) {
    try {
        Inflater inflater = new Inflater();
        inflater.setInput(Base64.decode(str, 8));
        int size = inflater.inflate(buffer);
        inflater.end();
        String temp = new String(buffer, 0, size, "UTF-8");
        return temp;
    } catch (Exception e) {
        e.printStackTrace();
    }
    return "";
}

解压后的数据如下:

{
    "origin": {
        "uri": "large/pgc-image/8e72c19ce0f2456880947531d5bbb230",
        "urls": ["http://p1-tt.byteimg.com/large/pgc-image/8e72c19ce0f2456880947531d5bbb230", "http://p1-tt.byteimg.com/large/pgc-image/8e72c19ce0f2456880947531d5bbb230", "http://p3-tt.byteimg.com/large/pgc-image/8e72c19ce0f2456880947531d5bbb230"]
    },
    "webp_origin": {
        "uri": "details/v0/w640/pgc-image/8e72c19ce0f2456880947531d5bbb230.webp",
        "urls": ["http://p99.pstatp.com/details/v0/w640/pgc-image/8e72c19ce0f2456880947531d5bbb230.webp", "http://p6-tt.byteimg.com/details/v0/w640/pgc-image/8e72c19ce0f2456880947531d5bbb230.webp", "http://p1-tt.byteimg.com/details/v0/w640/pgc-image/8e72c19ce0f2456880947531d5bbb230.webp"]
    },
    "thumb": {
        "uri": "thumb/pgc-image/8e72c19ce0f2456880947531d5bbb230",
        "urls": ["http://p9-tt.byteimg.com/thumb/pgc-image/8e72c19ce0f2456880947531d5bbb230", "http://p3-tt.byteimg.com/thumb/pgc-image/8e72c19ce0f2456880947531d5bbb230", "http://p1-tt.byteimg.com/thumb/pgc-image/8e72c19ce0f2456880947531d5bbb230"]
    },
    "webp_thumb": {
        "uri": "thumb/pgc-image/8e72c19ce0f2456880947531d5bbb230.webp",
        "urls": ["http://p1-tt.byteimg.com/thumb/pgc-image/8e72c19ce0f2456880947531d5bbb230.webp", "http://p3-tt.byteimg.com/thumb/pgc-image/8e72c19ce0f2456880947531d5bbb230.webp", "http://p6-tt.byteimg.com/thumb/pgc-image/8e72c19ce0f2456880947531d5bbb230.webp"]
    }
}

uri的最后两个片段表示文章id及图片索引,用于通过js通知页面图片加载完成。通过解析content的uri中的数据获取Fresco下载的缓存文件,返回一个ParcelFileDescriptor对象即可。效果如下图所示:

通过以上优化点实现的最终效果如下:

传送门:https://github.com/yuweiguocn/TouTiaoH5

总结

通过对今日头条app的分析,针对平台特有文章可以采用类似头条方案对数据预加载以提升用户体验。由于时间关系,就不再对微信进行分析,猜测也是采用了类似方案实现。通过WebView提供的缓存功能和拦截资源方法进行缓存体验上还是不尽如人意。也许等5G普及会好许多。

参考

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

推荐阅读更多精彩内容