基于微信的Single Page App开发

wxspa-design

随着微信公众号功能逐渐的完善,基于微信的Web App将会成为一个热点。最近在做一个微信上的类游戏应用“大脑训练营”,由于之前没什么移动端web开发的经验,开发过程中踩了不少坑,在这里总结一下。

一、移动(基于微信的)Web App的特点

  • 微信可能运行在2G/3G网络环境下,要对页面的载入速度进行优化,也就是资源尽量小、请求尽量少
  • 不同手机的屏幕大小、像素密度不一样,需要在设计上做好适配
  • 微信的内置浏览器有三种运行环境Android(2.3~4.4)、iOS(5.0~8.0)、WP8,对应的三种浏览器环境是Android Stock Browser、Mobile Safari和IE Mobile(其中Android2.3问题最多,堪比PC的IE6)
  • 可以利用一些微信特有的js API,比如分享、图片预览和最近加入的扫码功能
  • 可以通过微信公众号发送消息或者推送来配合应用

二、技术选型

1. 基本想法

“大脑训练营”的设计类似一个App,每个页面都是全屏的,互相独立,很自然地开发和载入过程中每个页面都是一个单独的模块:开发过程中每个模块由若干html模板、js和css构成,上线的时候打包压缩成一个js文件;载入的时候,每个页面单独异步载入,只载入本页面需要的资源,不能像Hybrid App一样一次载入全部资源。

另外由于移动网络以及手机性能的限制,需要避免使用过于庞大的类库。MVVM框架比如angular、ember即使gzip后还有50k左右,而大脑训练营现在每个页面的所有资源也不过30~40k。

上面的需求其实是蛮直观的,但是由于我没有太多的前端工程经验,技术选型的时候花了很长时间(顺便吐槽一下,Web前端里每一个问题都有大量不同的工具尝试用不同的方法解决,没经验的新手会非常无所适从)。最后在丁总和肖雄同学的帮助下,花了一周时间终于捣鼓出来。

  1. 一个Single Page Application,避免整页刷新或跳转,每个页面是一个独立模块
  2. 使用Backbone作为应用框架(实际上只使用了router、history和view,没有使用BackBone的Model和Collection),利用Backbone的router管理页面切换和历史
  3. seajs作为模块管理工具,实现模块化开发和载入
  4. 引入autoprefixer处理css兼容性,autoprefixer可以非常好的处理flexbox、transform等css3的兼容性问题,极大提升了css开发效率
  5. 使用grunt进行打包发布,将每个模块合并成一个js文件
  6. 使用将js对象序列化成JSON存储到localStorage的存储方式,所有API和服务器的交互均在后台异步完成,不阻塞UI,以达到最佳体验。

2. 模块载入和划分

wxspa-dirtree

项目目录结构如图所示

  1. modules下每个目录是一个单独的模块,有html模板、js和css组成
  2. 公用业务代码放到单独的common模块中,包括初始化代码、工具函数、通用数据结构等
  3. 公用的js类库和css直接在index.html里通过script标签和link标签载入
  4. 每个模块相当于一个页面
    • 使用seajs-style的seajs.importStyle方法同步载入css
    • 使用seajs-text同步载入html模板和json
    • 使用require.async异步载入其他模块(从一个页面跳转到另一个页面)
    • 使用seajs的map功能自动给模块加版本号,这样就不需要上线后手动刷新CDN
  5. 模块内部的css和html通过同步载入,这些同步require依赖在发布的时候会经过依赖树分析整合到一个js中
  6. 两个页面里如果有一个相同的组件,可以把该组件做成一个单独的模块,并且异步载入,避免重复代码
  7. common模块需要一开始被载入,由index.html里的如下代码完成:
seajs.use('modules/common/common.js', function(run) { run() });

router

继承Backbone的Router,根据当前的url hash决定载入哪个模块,比如#list就去载入modules/list/list.js。页面跳转通过

router.navigate("#list", {trigger:true});

完成。router大致实现如下:

var router = Backbone.Router.extend({
    routes: {
        '': 'home',
        '*path': 'loadModule'
    },
    home: function() {
        this.loadModule('profile');
    },
    loadModule: function(path, param) {
        if (path && path.charAt(path.length - 1) == '/')
            path = path.substr(0, path.length - 1);
        var moduleName = path.substr(path.lastIndexOf("/") + 1);
        var jsPath = "modules/" + path + "/" + moduleName + ".js";
            require.async(jsPath, function(cb) {
            if (_.isFunction(cb)) {
                cb(param || {});
            }
        });
    },
});

3. 数据存储和同步

wxspa-datasync

存储

前面提到数据存储通过序列化object成json存到localStorage实现。大致的方法是,将要持久化的数据集中到一个全局变量比如Player里,比如

var Player = {
    name: 'bob',
    gender: 'male',
    brain: [123, 546, 123]
};

存储的时候通过JSON.stringify序列化成字符串直接存到localStorage里。每次应用启动的时候再从localStorage里读出,赋值到Player变量上。

Backbone有一个localStorage插件,基于Backbone的Model和Collection,重写他们的sync方法也,也实现了类似的功能。由于没有太多动态修改视图的需求,为了简洁和方便我放弃了Model和Collection。

同步

客户端的localStorage虽然可以实现持久化,但是并不是特别可靠,浏览器的localStorage可能会因为各种原因被清空(经过试验微信会不定期的清除localStorage,一般三天左右)。所以服务的数据存储还是要做的。

为了保证最佳的用户体验,使用后台定时同步的方法:每过一段时间检查一下数据是否有变化,如果有则向服务器发sync请求,这样不会阻塞任何UI,即使网络不好请求失败用户也不会察觉。

4. 打包发布

打包发布现在看起来是蛮简单的,但是做技术选型的时候在这个地方晕了很久。我花了两天在折腾spm、fis,尝试用它们来进行构建,但是发现spm和fis不是简单的构建工具,它们还各自包含了一套开发标准,而且过于复杂,和我想要的差太多,最后用肖雄同学给我的一个200行的打包脚本就全解决了。

打包的过程主要目的是把每个模块的js、模板和css打包成一个js文件并压缩,以减少http请求个数以及数据大小。通过grunt脚本自动化完成。大致过程是这样:每个模块的入口js都是$目录名.js,从这个js开始依次找到所有require函数调用,然后在递归解析require的模块的依赖,形成一颗依赖树,把依赖树中所有的文件合并到一个js中,相对于根的路径作为每个模块的唯一id,require中的相对路径将被替换 。 比如前面图中的calendar模块中的三个文件:calendar.js、calendar.css、calendar.html将被打包成如下:

define("modules/calendar/calendar.html", function() {
    return '
<div class="calendar">
  ....';
      });
      define("modules/calendar/calendar.css", function() {
          return " .calendar{display:none;position..."
      });
      define("modules/calendar/calendar.js", function(require, a, b) {
          var c = require("modules/calendar/calendar.html");
          seajs.importStyle(require("modules/calendar/calendar.css"));
          ...
      });

我后来知道这其实就是一个支持html模板的grunt-cmd-transport

打包完成后可以对模块内容算一个hash作为版本号,并把版本号填到index.html中seajs的map参数中,这样请求就会带上版本号,避免了手动刷新CDN。类似下面

seajs.config({
  map: [
    ["modules/calendar/calendar.js","modules/calendar/calendar.js?461147146"],
    ["modules/common/common.js","modules/common/common.js?10774761"],
    ["modules/intro/intro.js","modules/intro/intro.js?924585118"],
    ["modules/list/list.js","modules/list/list.js?1246129909"],
    ["modules/profile/profile.js","modules/profile/profile.js?262252239"],
    ["modules/result/result.js","modules/result/result.js?108248874"],
    ["games/engine.js","games/engine.js?1112487656"]
  ],
  base: ''
});

html压缩用的是html minfier,加上collapseWhitespace: true, conservativeCollapse: true这两个参数把html里的连续空格都压成一个(全部压掉会对inline元素的布局产生影响)。

sass的编译、autoprefixer和css的压缩使用gulp配合webstorm的File Wathcer来完成,因为开发过程中需要实时看到变化,gulp进行一次编译大概耗时10ms,grunt不但写起来麻烦,运行的也慢,编译压缩一个文件要2~4s。

三、调试方法

  1. 用chrome进行开发,通过chrome dev tool的emulation功能测试不同分辨率的适配,手边最好有Android2.3和4.0的设备以便做CSS兼容性测试
  2. Can I Use是神器,各种兼容性问题解释的非常清楚
  3. iOS上的问题可以通过Safari的远程调试功能来解决;Android里的Chrome也支持远程调试,但是需要注意的是微信是个webview,Chrome的特性支持要比Android自带浏览器/Webview好得多,所以只能用来调试js错误,css兼容性还得用自带浏览器或者微信来测试
  4. 使用神器weinre解决一切问题
  5. 需要注意的一个坑是微信的缓存。iOS下一般多刷新几次缓存就会失效,实在不行就杀进程重启微信。android的缓存就顽固多了,可以考虑加入如下的meta(只在Android下加,iOS上会触发safari请求304白屏的bug),
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">

另外有一种可以规避cache的方法就是在手机浏览器里而不是微信里调试,iOS和Android的自带浏览器都有手动清除缓存的选项。

四、遇到的问题

1. 屏幕适配

固定页宽

为了设计方便,大脑训练营的每个页面都是固定宽度(480px),然后根据屏幕宽高比通过css自适应高度,和传统的手机游戏的适配差不多。适配的手段有两种:
1. viewport meta
2. css transfrom scale

viewport meta在android上的实现非常令人费解,不同版本的系统表现不一样,非常坑,参照Stack Overflow的这个问题,拿着三台安卓设备试了一下午才解决(tested on iOS6/7/8,android 2.3/4.0.2/4.4.2)

var ua = navigator.userAgent.toLowerCase();
if (/iphone|ipad|ipod/.test(ua)) {
    document.write('
');
} else {
    document.write('
');
}

把这段代码放到head里的一个script标签里,就能实现页面固定480宽度、禁用缩放。

高度自适应

对于一个普通的网页来说上面的viewport就已经可以满足需求了,但是大脑训练营每个页面都是全屏的,也就是说需要自适应屏幕高度。屏幕最瘦长的基本是iphone5(1136/640=1.775,一些android设备稍微更瘦一点854/480=1.779),最扁的是ipad(1024/768=1.33),由于运行在微信中,高度还要去掉系统的通知栏+微信浏览器的工具栏(127px),所以得出要适配的高度范围是(1024-127) * (480/768)到854 – 127,也就是(560,737)

560到737是一个很大的变化区间,这会给设计工作带来很大的麻烦。我们的设计师是用iphone4(480×627)作为设计分辨率,如果页面摆的很满,在ipad里根本就放不下。这个问题也是移动游戏里非常常见的问题,解决方法也很固定,首先确定一个最小高度,对于小于最小高度情况有两种方法:
1. 容器还是最小高度,对容器做scaleY压扁整个UI
2. 在屏幕两侧留黑边,将容器做等比缩放

无论哪种方法,都需要前面提到的css transform scale出场了,比如等比缩放的实现:

var ua = navigator.userAgent.toLowerCase();
if (/ipad/.test(ua)) {
    var height = 640;
    var scale = window.innerHeight / height;
    $(document.body).css("height", height + "px");
    $(document.body).css("-webkit-transform", sprintf("scale(%f)", scale));
    $(document.body).css("-webkit-transform-origin", "top center");
}

到这里屏幕适配的问题就差不多都解决了。

2. CSS兼容性问题

To be continued.

This entry was posted in 技术学习 and tagged , , , . Bookmark the permalink.

One Response to 基于微信的Single Page App开发

  1. 能否给出示例代码?

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据