悲催的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要少。

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

本地部署weinre帮助移动开发

如何在本地电脑上搭建weinre服务,帮助我们调试远程页面(比如手机上)。

weinre是个开源项目,用来在Web开发中做远程调试的工作,相当给力。后来也捐给了Apache基金会,并且从ruby移植到了JavaScript,现在可以直接通过npm安装。在最近的广告墙大重构中,这个工具帮了我很大的忙。

安装weinre首先要装node.js,后者在Windows和Mac上直接下包就能装,并且自动配置环境,很方便;在Linux上需要自己编译一次,也不复杂,我之前有篇日志写了,现在还好使,可以看看。

node.js环境搞定后,直接用npm就可以安装weinre了,直接装在全局中最好:

sudo npm install weinre -g

接下来启动weinre,我在这里卡了一阵,大概因为不太了解端口侦听的缘故,我以为直接启动就好,结果从外面连不上(手机连不上连点反应都没有,很难排查),因为侦听的是localhost的ip,也就是127.0.0.1。后来尝试绑定内网ip,才算解决问题:

# 8081是想找一个不常用的端口,后面的ip是我在内网的ip
weinre --httpPort 8081 --boundHost 192.168.10.54

这次无论是本机还是手机都可以正常访问了。然后Mac这里可能还会遇到点小问题。虽然我关闭了防火墙,但是Unix自带的ipfw还在工作,会阻止从外面过来的访问(不知道为啥80没问题),所以要给8081端口专门的许可:

sudo ipfw add 8081 allow from any to any

之后就万事大吉了,在HTML里添加 <script src="http://192.168.10.54:8081/target/target-script-min.js#meathill"></script> (域名根据具体情况修改),然后打开 http://192.168.10.54:8081/client/#meathill 就可以查看了,如果有多台终端在调试的话,还可以点选目标机器。

好了,享受下weinre带来的方便快捷的移动端调试吧。(不自己截图了,借用了weinre官网上的图片)

weinre使用截图
weinre使用截图