悲催的Android Webview——记新版广告墙开发

分享最近项目——点乐新版HTML5广告墙——中用到的技术、工具,踩过的坑。

前一阵很辛劳,所以荒废了博客。前几天终于完成了这项艰苦卓绝的工程:HTML5版广告墙,决定写篇文章,记录一下踩过的坑。

项目介绍

广告墙属于典型的列表式应用:打开后是无尽列表,通过滑动手指驱使列表滚动,上拉加载更多内容,下拉刷新列表(这次没做)。单击列表中的某项,打开详细页;单击上面的后退按钮,退回上一级页面。

考虑到目标平台是Android系统,内嵌WebKit内核,我采用了时下流行的HTML5+CSS3技术,并且做好了向下兼容的准备。为了减少代码量,我放弃了jQuery,也没有考虑半调子Zepto,全部使用原生开发。操作上,开始想用Hammer.js封装手势,后来处理滚动效果处理得不好,最终选择了iScroll 5。模板方面,前端使用Handlebars.js,后端使用Mustache,所以编译时还写了段代码做转换。

项目代码托管在github上,因为前端不涉及到业务逻辑,所以仓库是公开的,有兴趣的同学以clone下来看看。

技术选型大概聊这些,接下来开始进入正题。

性能vs兼容性

实际上,随着硬件的发展,对于列表式这种非性能敏感性应用来说,性能早已不是制约其发展的首要因素了。另一方面,有一定专业精神的前端如我,平时也会注重积累各种性能优化的点,并不断在项目中实验、实施——所以我就很搞不明白,技术实力强大如jQuery,为啥会弄出个jQuery Mobile这种完全不能用的东西出来……

相反,在这个项目中,由于平台分裂带来的兼容性问题成了最大的绊脚石。尤其是这些兼容性问题,极少找到现成的解决方案……下面举两个例子:

Vivo X909 和z-index

正常情况下,position:absolute;的dom节点在不显式设置z-index属性区分层级的时候,默认后面的节点会显示在前面的节点上方。大多数浏览器里都是这样,可是Vivo X909就不行。

列表大家都知道,点击某项,详情会从右边滚动进来。由于没有设置-webkit-tap-highlight-color(现在想想应该做的,下周回去加上吧),所以这个bug当时的的表现是“单击没有反应”。这种情况很难调试,我开始以为JS有错,试了很久,后来配置好Weinre(见上篇博客),才找到症结所在。最后把几个图层分别加上z-index,问题解决。

早期Android的animation-fill-mode

我们仍有20%的用户在使用2.3.x版本的Android,所以这块市场必然不能放弃。在小米2上调是好的动画,在老HTC G10上,滑出的图层在动画结束后会消失。这次好在有了上次的经验,直接上Weinre;加上caniuse.com也有明确的描述,所以很快解决了。


各种难以定位、缺少文档、找不到解决方案的兼容性问题层出不穷,让我不禁感慨:本以为买上了WebKit的康庄大道,谁知尼玛这豆腐渣工程下面全是坑啊……

滚屏

前面提到,开始时间比较富裕,我也比较自信,想借助Hammer.js,慢慢打磨出色的滚动体验。后来无尽的bug环伺左右,实在无法专心研究,便退而求其次,使用iScroll了。

iScroll如今已经发展到5,其作者自信的表示:“我尽全力提升了其在Android平台上的表现,我相信现在它已经达到了极致。”实验效果确实很理想,无论是高配置的小米,还是老旧的HTC G10,都表现出色。将来有空的时候得好好研究下他是怎么做的。

工程实践

随着时代发展,Web开发这种脚本语言也开始分裂成开发、部署两个阶段,这是个好事情。核心代码需要为维护、测试留下足够的空间,势必在性能表现上有所不足,部署时根据应用场景的不同输出不同的结果正好能解决这个问题。这个项目中我使用Grunt,花费了不少精力在编写Gruntfile.js上。

开发时,我将测试用的数据,按照设计好的数据结构写在define.js中,生产环境中用服务器生成真实数据来替代。不同的应用场景,也用其配置。如今,目标支持的6个场景都能稳定工作,效果不错。

Windows下不好配ruby,使用SASS是个问题,不过后来找到了Prepros,问题迎刃而解。

技术细节分享

简单构造选择器

jQuery功能确实强大,不过实在太重型了;备胎Zepto轻便一些,不过功能也弱一些,语法做不到全兼容,用起来不舒服。仔细考虑这个项目的需求,无非是:找到某个元素,给它添加事件侦听;或者判断它是否包含某个class,那么自己写一个直接到Dom节点的选择器就足够了。后来看到篇文章,里面详细测试了querySelectorgetElementById众的性能,发现还是后者大大领先——尤其在移动平台上,于是基本放弃了用前者的念头。

这个选择器实现起来很容易:

    var $ = window.$ = function (selector, root) {
      root = root || document;
      var dom;
      switch (selector.charAt(0)) {
        case '#':
          selector = selector.substr(1);
          dom = document.getElementById(selector);
          break;

        case '.':
          selector = selector.substr(1);
          dom = root.getElementsByClassName(selector)[0];
          break;

        default :
          dom = root.getElementsByTagName(selector)[0];
          break;
      }
      return dom;
    };

另外,$还可以用来当命名空间,保存一些需要全局使用的变量和方法。

使用Handlebars.js生成预编译模板

预编译模版可以降低运行时的运算量,对于移动开发这种能省则省的应用场景,实在是必备之物。另一方面,为了方便开发,把模版放在HTML里更好调整。好在有“编译输出”这个步骤,可以两全其美。

Handlebars.js的预编译是把模版转换成根据数据生成HTML的JS代码,可以使用uglify压缩。编译之前最好过滤掉多余的空格和换行符,这样生成的JS文件也会小很多。

当然,再预编译也不如直接渲染HTML来的快,所以在服务器端生成页面的时候,一定要包含实际内容,让用户尽快得到信息。而PHP没有现成的Handlebars实现(我也不打算写),好在有mustache.php,并且handlebars兼容mustache(其实不完全,也被坑了),所以我写了一小段代码将Handlebars模版转换成Mustache模版

使用attr()实现CSS中的多语言

除了传统的中文版,这次还要制作英文版。英文版与中文版的功能几乎完全一致,只是文字全部换成英文。最简单的做法就是搞个en.html——因为使用了模版,JS里不包含任何文字内容;但是“上拉加载更多”这个功能目前使用CSS的:after实现——初衷是不修改dom,不引起relayout——当然,反正我用着Sass,增加个语言包不难,不过我还是希望把修改集中在一个地方。

后来查了下CSS表达式的兼容性,发现Android 2.1起就支持,那就好办了,先把各阶段文案放到HTML的属性里:

    // 中文
    <div id="list" data-normal="继续上拉,加载更多广告" data-more="松开加载" data-loading="加载中..." data-no-more="没有其它广告了,再看看吧" data-error="加载失败,请稍候重试" data-over=""></div>
    // 英文
    <div id="list" data-normal="Pull up to load" data-more="Release to load" data-loading="Loading..." data-no-more="No more ads" data-error="Load failed" data-over=""></div>

然后在CSS里使用attr()访问即可

    #list.over
      &:after
        content: attr(data-over)

    #list.more
      &:after
        content: attr(data-more)

    #list.loading
      &:after
        content: attr(data-loading)

    #list.no-more
      &:after
        content: attr(data-no-more)

    #list.error
      &:after
        content: attr(data-error)

WebKit中z-indextranslateZ(0)混用,导致click事件半失灵的解决

  1. 要在浏览器里触发下载,只要链接到一个浏览器打不开的文件就行。链接跳转是在click触发的浏览器内建行为,换言之,链接跳转依赖于click的正常触发。
  2. clicktouch event晚200~300ms,所以为了保证用户体验,我们通常用taptouchstart + touchend)来替代click响应用户操作
  3. 所以一个“点击下载按钮”的操作,就会先响应tap事件,弹出详情浮层,告知用户获得积分的最终条件,然后等待浏览器开始下载
  4. CSS动画最好使用translateZ(0)促使浏览器启用GPU加速
  5. 这个bug在以上条件下产生,影响这个过程

之前我在PC版Chrome里也曾见识过这个bug(最新版没验证,大概20+的时候吧)。具体表现为,一个层,它的translateZ有赋值,它可能被移到某处(通过translateXtranslateY,或者动画过程中),不过还未到达那里,但是它仍然会拦截那里mousedown/mouseup事件,导致click事件无法正常触发。

这个bug非常难排查,因为它并不影响tap;视觉上看不到那个层,会想当然的认为不应该跟那个层有关系;并且,WTF,如果按得时间稍长,比如半秒,就可以触发click……因为触发条件比较复杂,我甚至很长时间没找到规律。最后只好请出“小黄鸭调试法”,试图向索隆解释发生了什么,然后发现无法正常下载的按钮都是被详情浮层遮盖着,继而回忆起以前解决过类似问题。

最后解决这个问题的方案我其实不很满意,不过确实能顺利工作:

  1. 层的当前位置不会覆盖按钮,先不给层增加动画
  2. tap后,给click留出足够的时间(我这里用400ms),再给层添加动画
  3. 确保click可以响应,通过setTimeout给层添加动画——本来300ms就测试通过,不过为了保险,我还是决定写成400

仍然悬而未决的问题

虽说项目已经快上线了,但仍有几个不解之谜……

  1. 某些手机仍然无法下载(启动<a>
  2. 快速拖动的时候可能把内容拖走(应该是触发了浏览器默认行为)

总结

做完项目回头看,系统分裂造成的兼容性问题比想象中多很多,原先以为最多是有些2.x版本不支持,做向下兼容就好,谁知还有各种实现细节问题。不过,在PC Chrome + 手机Chrome + Weinrealert之后,只要有耐心,大多数问题时可以解决的。

说完开发效率,另一方面再来看看产品表现。我们对性能不需要太乐观,也不用太悲观,敏感型应用,老老实实用原生开发,或编译成原生应用;非敏感型,跨平台高兼容性(咦,这眼泪哪里来的)的优势很明显。通过我实际看到移动端的表现,我们这些页面仔应该更有信心,将来移动大市场里,Web App的份额绝对不比Native App要少。

这项事业任重而道远,需要我们一起努力。

二维码小工具

二维码小工具,用来在页面里插入二维码。地址:http://meatqr.sinaapp.com/

这两天利用业余时间,做了个二维码的小工具,用来在页面里插入二维码。这个工具比较方便编辑同学在自己的文章或者专题里添加二维码,推广手机应用,或者手机网站。——要么说第一份工作很重要呢,离开媒体那么多年,想做点独立项目还是跟编辑工作有关。

工具地址是:http://meatqr.sinaapp.com/

代码也是完全开放的,采用MIT协议,托管在Github上,地址是:http://meathill.github.com/qrcode.js

有兴趣的同学试用下吧,记得给我反馈哟,我会不断更新维护的。


HTML5的File API应用

长文一篇,分享我最近使用HTML5 File API的经验。

HTML5新增了很多特性,其中File API是非常重要的部分。在肉大师中,我大量使用了HTML5的文件API,这样一来可以给予用户近乎桌面软件的体验,二来还能减少服务器和带宽的消耗。今天终于把最后几个问题解决了,在这里总结下HTML5 File API的使用。

随着用的越来越多,发现自己其实搞混了“File API和FileSystem API”两个东西。而且类写的也有问题。等到有空的时候把这篇文章重写一下好了。(2012-09-13)

用途

在W3C页面上,列出了File API可能用到的场合(以下为意译,可能有所偏颇,欢迎对比原文阅读):

  1. 断点续传
    • 上传时,先把目标文件复制到本地沙箱,然后分解逐块上传
    • 浏览器崩溃或者网络中断也没关系,因为恢复后可以续传
  2. 需要大量媒体素材的应用,比如视频游戏
    • 下载压缩包,在本地解压,就能恢复之前目录结构
    • 跨平台
    • 通过渐进式下载,进入新关卡或者开启新功能均无需等待,因为玩的时候所需素材已经通过后台下载完成了
    • 从本地缓存中直接读取素材,速度飞快
    • 二进制文件也不在话下
    • 使用压缩包可以大大减轻带宽和服务器消耗,也避免了频繁下载碎片文件带来的检索问题
  3. 离线图片/音频编辑器通
    • 不怕频繁读写大量数据
    • 只想重写文件的某些部分也能做到(比如修改ID3或者EXIF信息)
    • 创建目录组织项目后用起来舒服多了
    • 编辑完的文件还能被iTunes、Picasa之类的本地应用访问
  4. 离线视频播放器
    • 下载超过1G的大文件,将来想看再看
    • 可以在不同时间点间来回跳转播放
    • 能够给Video标签提供URL
    • 即便片子还没下完,也能把下载到的部分先睹为快
    • 还能任意截取一段视频交给Video标签播放
  5. 离线邮件客户端
    • 下载保存附件到本地自不必说
    • 断网的情况下,可以缓存用户要上传的附件,以后再上传
    • 需要时可以列出缓存里的附件,通过缩略图显示,预览后上传
    • 能像正常服务器那样触发标准的下载动作
    • 不仅能使用XHR一次性上传全部内容,还可以把邮件和附件拆解成小块依次发送

听起来都是些令人振奋的功能,实际用起来还是要踩点坑。下面就把我的经验分享一下。

继续阅读“HTML5的File API应用”

Backbone.js经验两则

最近使用backbone遇到的两个问题,分享下。

使用HTML5,实现从桌面拖拽到网页

使用HTML5新增加的API,可以很方便的实现拖拽, 包括从桌面拖拽到网页上(部分浏览器比如Chrome还可以把东西从网页拖拽到桌面),这个操作不在今天的讨论之内,可以参考:NATIVE HTML5 DRAG AND DROP这篇文章,讲得足够详细了。

当我试图在Backbone框架上使用这个功能的时候,问题出现了。开始我没多想,直接这么写的:

var myView = Backbone.View.extend({
  events: {
    "drop img": "img_dropHandler"
  },
  img_dropHandler: function (event) {
    var reader = new FileReader();
    var img = event.target;
    reader.onload = function (event) {
      $(img).attr('src', event.target.result);
    }
    result.readAsDataURL(event.dataTransfer.files[0]);
  }
});

结果运行时提示我,event对象没有dataTransfer属性。我用的是最新版本的Chrome,理应是对HTML5支持最好的,而且文中也说代码在Firefox和Chrome下运行通过。后来检查了一下event,发现是f.Event,似乎原本应该是MouseEvent或者Event什么的。于是我把属性展开,看到了originalEvent这个属性,是MouseEvent;再展开originalEvent,就看到了dataTransfer属性,里面有期望中的所有属性。看来是Backbone并没有直接使用原始事件,而是封装了一层再广播。(更正)事件代理是通过jQuery来做的,jQuery在这里把原始事件封装了一层再进行转播,导致原始事件的属性没有完全复制。解决方法很简单,多写几个字母就行了:

// 其它地方都一样
result.readAsDataURL(event.originalEvent.dataTransfer.files[0]);

Model中数组的处理

先看一段代码:

var ModelClass = Backbone.Model.extend({
  defaults: {
    contents: []
  }
});
var model1 = new ModelClass();
var arr = model1.get('contents');
arr[0] = 'haha';
var model2 = new ModelClass();
console.log(model2.get('contents'));

大家猜猜结果是什么?竟然是“[‘haha’]”!这个我只能认为是Backbone.js的bug了。解决方法是先复制一个数组,对数据操作后再赋值回去,如下:

var model1 = new ModelClass();
var arr = model1.get('contents').concat();
arr[0] = 'haha';
model1.set('contents', arr);