WebAppCache

智能手机的发展

伴随着智能手机在国内的普及及其换代速度,HTML5技术在移动领域的发展终究比PC端来的更迅速些。

根据移动互联网第三方数据挖掘和整合营销机构艾媒咨询(iiMedia Research)发布的《2012中国智能手机市场年度研究报告》数据显示,截止2012Q4季度,中国智能手机用户数达到了3.8亿,同比增长72.7%;

2012中国智能手机用户规模发展状况2012中国智能手机用户规模发展状况

报告指出,在操作系统占有率方面,Android份额到达68.6%,iOS有所下滑,占12.8%,Symbian则难抑下滑趋势,占12.4%,Windows Phone作为后起之秀,份额只占3.8%,但后续占有率会持续扩大;

2012年中国智能手机市场操作系统分布状况2012年中国智能手机市场操作系统分布状况

在系统平台分布方面,Android上V2.x是主流版本,iOS上V5是主流版本

2012年中国智能手机市场Android、iOS平台版本分布2012年中国智能手机市场Android、iOS平台版本分布

(以上数据来自iiMedia Research《2012中国智能手机市场年度研究报告》)

Application Cache的问题

回到正题,HTML5伴随着智能手机的发展,在手机应用开发方面的优势越发凸显,尤其是其跨平台、版本更新等优势;在一些对性能要求稍低及项目人员紧张的产品中,使用WebApp的形式(大多为Native App+WebApp的混搭方式)不失为一种好的解决方案。

排除WIFI,对于国内那昂贵的流量费用,而且是极不稳定的GPRS来说,WebApp的开发不得不考虑一个问题:缓存。

开始我很兴奋,知道HTML5给我们提供了Application Cache离线储存接口,通过manifest文件,我终于可以翱翔在离线数据的大海中。

理想很丰满,现实却总是很骨感;Application Cache这货可真不好管理,如果你还不清楚它的实际情况,可以参考下这篇文章《Application Cache is a Douchebag》,内容我就不翻译了,但标题我得翻译下:《Application Cache就是个人渣》。

关于Application Cache,有一个致命的缺点,那就是你不能选择更新哪些资源。你的manifest文件更新了,所有指定的资源都会给下载,对于流量是金的移动互联网来说,这不就是坑爹嘛。

localStorage

但上帝总在关了一扇门之后,给我们开启另一扇门,而这一扇门就是:localStorage。

localStorage的存储空间是按域名来计算的,不同平台容量不同,即使相同平台相同版本但由于手机厂商调教不一,造成实际使用中的大小也是 不一样的。就拿笔者的MX2(Android 4.1)自带的浏览器来说,测试出来的结果是64M。虽然不同平台及版本存在差异,但对于大部分WebApp来说,这样的存储空间已经可以派上大用场了。

UserAgent Megabytes
Android 2.3 8
Android 4 58
iPhone 5 5
iPhone 6 26

(以上数据来自Browserscope,测试地址点这里

在使用localStorage前,我们还需要清楚webview对localStorage的影响,特别是对于那些嵌套在不同客户端的 WebApp来说,webview对localStorage的支持与否也是不可忽视的一点。同时,对于业务较多的根域来说,不同WebApp之间可能会 出现空间上的使用管理混乱问题,这需要在前期规划时对存储做好队列管理工作。

解决方案:WebAppCache

考虑到Application Cache的维护麻烦问题,在我最近的项目中就基本放弃了manifest的方式,转而使用类MVC的方式(估且这么叫吧)。

WebAppCache方案由app.json配置应用的每个资源信息,app.html进行整个应用的调度,包括版本对比、更新以及缓存队列管理。由于使用了Ajax来拉取文件,所以受同源访问限制,对跨域请求有要求的同学,可以使用withCredentials,这里就不仔细展开了,只是提供其中一种简单的实现方式。

假设我们的目录结构如下:

app/--
  |---app.manifest
  |---app.html
  |---WebAppCache.js
  |---page/
    |---index.html
    |---inner/
      |---demo.html
  |---js/
    |---zepto.min.js
    |---touch.min.js
    |---app.js
    |---demo.js
  |---css/
    |---global.css
    |---inner/
      |---demo.css

WebAppCache约定app.html与app.json处于相同的目录层级,其他的资源不作要求。

则实际请求地址如下:
index.html  -> /app/app.html 或 /app/app.html?v=index
inner/demo.html -> /app/app.html?v=inner.demo

app.json应用配置文件

{
  // 配置app.json文件过期时间(分钟)
  "expire": "30",
  // 核心加载的js文件
  "jsCore": ["zepto", "touch"],
  // 核心加载的css文件
  "cssCore": ["global"],
  // js配置
  "jsConfig": {
    // js基准路径
    "path": "/app/js/",
    // js缺省后缀
    "suffix": ".js"
  },
  // css配置
  "cssConfig": {
    // css基准路径
    "path": "/app/css/",
    // css缺省后缀
    "suffix": ".css"
  },
  // 页面配置
  "pageConfig": {
    // 页面基准路径
    "path": "/app/page/",
    // 页面缺省后缀
    "suffix": ".html"
  },
  // 声明应用js资源
  "js": {
    "zepto": {
      // 指定拉取路径,url为空时以"基准路径+模块名+缺省后缀"拉取
      "url": "/app/js/zepto.min.js",
      // 版本号,-1时不作缓存
      "v": "1.0.0"
    },
    "touch": {
      "url": "/app/js/touch.min.js",
      "v": "1.0.0"
    },
    "app": {
      "v": "1.0.0"
    },
    "demo": {
      "v": "1.0.0"
    }
  },
  // 声明应用css资源
  "css": {
    "global": {
      "url": "/app/css/global.css",
      "v": "1.0.0"
    },
    "app": {
      "v": "1.0.1"
    },
    "inner.demo": {
      "v": "1.0.0"
    }
  },
  // 声明应用页面
  "page": {
    "index": {
      "v": "20130415",
      // 声明除去核心加载外需要加载的资源
      "js": ["app"],
      "css": ["app"]
    },
    "inner.demo": {
      "v": "20130415",
      "js": ["app", "demo"],
      "css": ["app", "inner.demo"]
    }
  }
}


app.html缓存调度

<!DOCTYPE html>
<html manifest="/app/app.manifest">
<head>
<title></title>
<meta charset="utf-8">
<script type="text/javascript" src="/app/WebAppCache.js"></script>
</head>
<body>
</body>
</html>

app.html我使用了Application Cache,这在不使用SPA方式对页面进行documwnt.write输出时,可以加快页面载入速度。当所有的资源处理完毕之后,会将内容渲染到当前页面输出。

WebAppCache.js之队列管理

为了兼容同一根域下多WebApp的场景,WebAppCache.js以应用为单位进行缓存管理,每次进行写操作时,都会缓存当前的key到队列 里;同时资源队列以”资源缓存时间先后 + css核心资源(按依赖权重由低到高排列)+ js核心资源(按依赖权重由低到高排列)“进行排列;
在溢出时,按App使用时间先后进行队列淘汰;当所有非当前App淘汰完毕后,再对当前App资源进行资源队列淘汰;在淘汰当前App资源队列后仍无法存储时,最后尝试清空当前App缓存再试。

/**
 * 优化的缓存设置, 溢出捕获以及队列管理
 */
function cache(n, v, prefix) {
  prefix = (getType(prefix) == 'string') ? prefix : _appName;
  if (getType(v) == 'undefined') {
    var r = _storage.getItem(prefix + n);

    if (r === null) {
      return r;
    }

    try {
      return JSON.parse(r);
    } catch (e) {
      return r;
    }
  }

  // 缓存当前应用的写操作key值(无序)
  if (prefix == _appName) {
    var cacheKey = cache('CacheKey') || [];
    cacheKey.push(n);
    cacheKey = uniq(cacheKey);
    _storage.setItem(_appName + 'CacheKey', JSON.stringify(cacheKey));
  }

  if (getType(v) != 'string') {
    v = JSON.stringify(v);
  }

  try {
    _storage.setItem(prefix + n, v);
  } catch (e) {
    var appName = shiftAppCache();

    if (appName !== false) { // 重新尝试缓存
      cache(n, v);
    } else { // 没有应用缓存可供删除时, 淘汰当前应用队列
      var cq = cache('Core') || [],
        sq = sourceQueue();

      // 将Core与Source资源合并进行队列管理
      sq = sq.concat(cq);

      // 缓存区不足时,淘汰当前应用缓存重新发起请求
      if (sq.length < 1) {
        clearAppCache(_appName);
        window.location.reload(false);
        return;
      }

      var item = sq.shift(),
        key = _appName + item;

      // 删除最早的缓存
      _storage.removeItem(key);
      _storage.removeItem(key + '.Version');
      // 更新队列
      sourceQueue(sq);
      // 重新尝试缓存
      cache(n, v);
    }
  }
} 

/**
 * 清空应用缓存
 */
function clearAppCache(appName) {
  var cacheKey = cache('CacheKey', undefined, appName) || [];
  each(cacheKey, function(k, v) {
    _storage.removeItem(appName + v);
  });
  _storage.removeItem(appName + 'CacheKey');
}
/**
 * 按应用缓存队列清空应用缓存(跳过当前应用缓存)
 */
function shiftAppCache() {
  var appQueue = cache('App.Queue', undefined, '') || [];
  appQueue = arrDel(_appName, appQueue); // 跳过当前应用缓存
  if (appQueue.length > 0) {
    var appName = appQueue.shift();
    clearAppCache(appName);
    cache('App.Queue', appQueue, '');
    return appName
  }
  return false;
}

对于同一根域下多WebApp的场景,当用户同时开启多个应用造成空间不足时,当前的解决方案在localStorage支持的情况下可能会出现数据缓存不久就被淘汰的情况,这种情况可以通过转换为sessionStorage来进行优化。

有一点需要注意,在使用document.write输出文档流时,要在window.onload触发后方可进行页面渲染,否则原文档流不会被覆盖。

最后附上GitHub地址:WebAppCache