随着微信公众号功能逐渐的完善,基于微信的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前端里每一个问题都有大量不同的工具尝试用不同的方法解决,没经验的新手会非常无所适从)。最后在丁总和肖雄同学的帮助下,花了一周时间终于捣鼓出来。
- 一个Single Page Application,避免整页刷新或跳转,每个页面是一个独立模块
- 使用Backbone作为应用框架(实际上只使用了router、history和view,没有使用BackBone的Model和Collection),利用Backbone的router管理页面切换和历史
- seajs作为模块管理工具,实现模块化开发和载入
- 引入autoprefixer处理css兼容性,autoprefixer可以非常好的处理flexbox、transform等css3的兼容性问题,极大提升了css开发效率
- 使用grunt进行打包发布,将每个模块合并成一个js文件
- 使用将js对象序列化成JSON存储到localStorage的存储方式,所有API和服务器的交互均在后台异步完成,不阻塞UI,以达到最佳体验。
2. 模块载入和划分
项目目录结构如图所示
- modules下每个目录是一个单独的模块,有html模板、js和css组成
- 公用业务代码放到单独的common模块中,包括初始化代码、工具函数、通用数据结构等
- 公用的js类库和css直接在index.html里通过script标签和link标签载入
- 每个模块相当于一个页面
- 使用seajs-style的seajs.importStyle方法同步载入css
- 使用seajs-text同步载入html模板和json
- 使用require.async异步载入其他模块(从一个页面跳转到另一个页面)
- 使用seajs的map功能自动给模块加版本号,这样就不需要上线后手动刷新CDN
- 模块内部的css和html通过同步载入,这些同步require依赖在发布的时候会经过依赖树分析整合到一个js中
- 两个页面里如果有一个相同的组件,可以把该组件做成一个单独的模块,并且异步载入,避免重复代码
- 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. 数据存储和同步
存储
前面提到数据存储通过序列化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。
三、调试方法
- 用chrome进行开发,通过chrome dev tool的emulation功能测试不同分辨率的适配,手边最好有Android2.3和4.0的设备以便做CSS兼容性测试
- Can I Use是神器,各种兼容性问题解释的非常清楚
- iOS上的问题可以通过Safari的远程调试功能来解决;Android里的Chrome也支持远程调试,但是需要注意的是微信是个webview,Chrome的特性支持要比Android自带浏览器/Webview好得多,所以只能用来调试js错误,css兼容性还得用自带浏览器或者微信来测试
- 使用神器weinre解决一切问题
- 需要注意的一个坑是微信的缓存。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.
One Response to 基于微信的Single Page App开发