优化移动体验的HTML5技巧

jopen 11年前

简介

连轴转的刷新,不断变向的页面转换,以及tap事件的周期性的延迟仅仅是现在移动web环境令人头疼事情的一小部分。开发者正试图尽可能的靠近原生应用,但却经常被各种兼容问题,系统复位,和僵化的框架打乱步调。

在这篇文章中,我们将讨论创建一个移动HTML 5 web app需要的最低限度的东西。主要观点是去除现在移动框架试图隐藏的隐含复杂性。你会看到一个简约方法(使用核心的HTML 5APIs)和使你能够写出自己的框架或给你现在在用的框架贡献代码的基本原则。

硬件加速

通常情况下,GPUs处理精细的3D建模或者CAD图表,但这种情况下,我们想要原始的制图(divs, 背景,下落式阴影的文字,图像等等...) 能通过GPU平滑地展现出来并且有流畅的动画。不幸的是,大多数前端开发者没有考虑动画处理的机制并将其装载在第三方框架,但是这些核心的CSS3特性应该被掩盖吗?让我来给你们一些关于为什么关心这件事是十分重要的理由:

1. 内存分配和计算压力- 如果你将所有元素都合成在DOM里,仅仅是为了硬件加速,在你的代码基础上继续工作的另一个人可能会想狠狠揍你一顿。

2. 电源消耗- 显然地,当硬件开始工作,电源也随之开始消耗。当进行移动端开发时,开发者开发移动应用必须要考虑设备多样化的约束。普遍流行的情况是浏览器开发商开始使其产品能适应多样的设备硬件。

3. 冲突- 我曾经历过小故障:将硬件加速应用到一部分能够加速的页面。值得确信的是如果你有重复的加速区域是非常重要的。

为了尽可能地使户交互平滑并且接近真实,我们必须使浏览器为我们工作。理想的情况是,我们想要移动设备的CPU建立初始化动画,然后使GPU仅仅负责动画处理过程中合成不同的层。这就是translate3d, scale3d, translateZ做的事- 他们给了动画元素到他们各自的层,因此允许设备能平滑渲染。如果想要了解更多加速合成,WebKit工作原理,Ariya Hidayat 在他的博客里提供了许多信息

页面过渡

让我们看看开发移动WEB应用时最常用的三种用户交互方法:滑动、翻转、旋转效果。

你可以在这个链接查看代码的实际效果: http://slidfast.appspot.com/slide-flip-rotate.html (注意: 这个演示是为移动设备建立的,所以请启动模拟器,或者使用手机、平板电脑,或把你的浏览器窗口减小到约1024px或更小).

首先,我们将剖析滑动、翻转、旋转过渡,及如何使其加速。请注意每个动画是如何只需三、四行CSS和JavaScript即可实现的。

滑动

在这三种常用效果中最常用的是滑动,滑动页面变换模拟了移动应用的自然感觉。滑动转换用来向视图区域带来一个新的内容。

要实现滑动效果,首先我们要声明元素标签:

<div id="home-page" class="page">    <h1>Home Page</h1>  </div>    <div id="products-page" class="page stage-right">    <h1>Products Page</h1>  </div>    <div id="about-page" class="page stage-left">    <h1>About Page</h1>  </div>

注意我们是如何让页面向左或向右演出的。本质上,它可以是任何方向,但水平是最常见的。

我们现在只需几行CSS就可以产生有硬件加速的动画。我们交换页面上的div元素的class时,动画就会实际发生。

.page {    position: absolute;    width: 100%;    height: 100%;    /*activate the GPU for compositing each page */    -webkit-transform: translate3d(0, 0, 0);  }

translate3d(0,0,0)作为“银弹”方法而闻名。

当用户点击一个导航元素,我们执行下面的JavaScript来交换class。没有第三方框架被使用,这是纯JavaScript!

function getElement(id) {    return document.getElementById(id);  }    function slideTo(id) {    //1.) the page we are bringing into focus dictates how    // the current page will exit. So let's see what classes    // our incoming page is using. We know it will have stage[right|left|etc...]    var classes = getElement(id).className.split(' ');      //2.) decide if the incoming page is assigned to right or left    // (-1 if no match)    var stageType = classes.indexOf('stage-left');      //3.) on initial page load focusPage is null, so we need    // to set the default page which we're currently seeing.    if (FOCUS_PAGE == null) {      // use home page      FOCUS_PAGE = getElement('home-page');    }      //4.) decide how this focused page should exit.    if (stageType > 0) {      FOCUS_PAGE.className = 'page transition stage-right';    } else {      FOCUS_PAGE.className = 'page transition stage-left';    }      //5. refresh/set the global variable    FOCUS_PAGE = getElement(id);      //6. Bring in the new page.    FOCUS_PAGE.className = 'page transition stage-center';  }

stage-left或stage-right成为stage-center,会推动页面滑入视图中心。我们完全依靠CSS3完成繁重的工作。

.stage-left {    left: -480px;  }    .stage-right {    left: 480px;  }    .stage-center {    top: 0;    left: 0;  }

接下来,让我们看看处理移动设备检测与适应的CSS。我们可以定位每种设备和每种分辨率(参考 媒体查询解析)。我在演示中使用的只是几个简单的例子来覆盖移动设备上大多数的竖立和横放视图。这对应用每种设备本身的硬件加速功能也很有用。比如,因为Webkit的桌面版本加速了所有转换元素(不管是二维还是三维),所以在这个水平上建立媒体查询和排除加速很有意义。注意,在Android Froyo 2.2+以下,硬件加速技巧不会提供任何速度的改进。所有合成都是在软件内部实现的。

/* iOS/android phone landscape screen width*/  @media screen and (max-device-width: 480px) and (orientation:landscape) {    .stage-left {      left: -480px;    }      .stage-right {      left: 480px;    }      .page {      width: 480px;    }  }

翻转

在移动设备上,翻转实际上以把页面击飞(译者注:如果你熟悉棒球,很容易想像)而闻名。在这里我们用一些简单的 JavaScript 在iOS 和 Android (基于WebKit)设备上来处理这个事件。

在这个地址可查看实际执行效果http://slidfast.appspot.com/slide-flip-rotate.html.

当处理触摸事件和转换效果时,你要做的第一件事就是获得元素当前位置的句柄。在WebKitCSSMatrix上可以看到更多信息。

function pageMove(event) {    // get position after transform    var curTransform = new WebKitCSSMatrix(window.getComputedStyle(page).webkitTransform);    var pagePosition = curTransform.m41;  }

由于我们为页面翻转使用的是CSS3的ease-out转换,usualelement.offsetleft不会工作。

下一步我们要找出用户翻转的是哪个方向,并对事件(页面导航)设定一个发生的阈值。

if (pagePosition >= 0) {   //moving current page to the right   //so means we're flipping backwards     if ((pagePosition > pageFlipThreshold) || (swipeTime < swipeThreshold)) {       //user wants to go backward       slideDirection = 'right';     } else {       slideDirection = null;     }  } else {    //current page is sliding to the left    if ((swipeTime < swipeThreshold) || (pagePosition < pageFlipThreshold)) {      //user wants to go forward      slideDirection = 'left';    } else {      slideDirection = null;    }  }

你会注意到我们测量击打时间是毫秒级的。这允许导航事件在用户快速点击屏幕来翻页时也会发生。

为了定位页面和当手指正触摸屏幕时使动画看起来自然,我们在每次事件触发后都使用CSS3转换。

function positionPage(end) {    page.style.webkitTransform = 'translate3d('+ currentPos + 'px, 0, 0)';    if (end) {      page.style.WebkitTransition = 'all .4s ease-out';      //page.style.WebkitTransition = 'all .4s cubic-bezier(0,.58,.58,1)'    } else {      page.style.WebkitTransition = 'all .2s ease-out';    }    page.style.WebkitUserSelect = 'none';  }

我想玩弄一下三次曲线来让转换带有最好的自然感觉,但ease-out已经玩了这个花样。

最后,为让导航发生,我们必须调用我们之前在上一个演示里定义的slideTo()方法。

track.ontouchend = function(event) {    pageMove(event);    if (slideDirection == 'left') {      slideTo('products-page');    } else if (slideDirection == 'right') {      slideTo('home-page');    }  }

旋转

接下来,让我们来看看在本演示使用的旋转动画。在任何时候,你可以旋转页面将看到180度旋转后反面的“联系人”菜单选项。 同样的,只需要几行CSS和一些JavaScript指定一个点击时的transition class。注:旋转过渡则无法正确的在大多数版本的Android上呈现,因为它缺乏3D CSS transform 的支持。不幸的是,Android提供了“侧手翻”页面旋转特性,来替代翻转。我们建议在android得到支持之前使用transition来进行翻转

正面与背面的基本结构:

<div id="front" class="normal">  ...  </div>  <div id="back" class="flipped">      <div id="contact-page" class="page">          <h1>Contact Page</h1>      </div>  </div>

JavaScript:

function flip(id) {    // get a handle on the flippable region    var front = getElement('front');    var back = getElement('back');      // again, just a simple way to see what the state is    var classes = front.className.split(' ');    var flipped = classes.indexOf('flipped');      if (flipped >= 0) {      // already flipped, so return to original      front.className = 'normal';      back.className = 'flipped';      FLIPPED = false;    } else {      // do the flip      front.className = 'flipped';      back.className = 'normal';      FLIPPED = true;    }  }

CSS:

/*----------------------------flip transition */  #back,  #front {    position: absolute;    width: 100%;    height: 100%;    -webkit-backface-visibility: hidden;    -webkit-transition-duration: .5s;    -webkit-transform-style: preserve-3d;  }    .normal {    -webkit-transform: rotateY(0deg);  }    .flipped {    -webkit-user-select: element;    -webkit-transform: rotateY(180deg);  }

调试硬件加速能力

现在我们讲完基本变换的方法了,让我们看看它们是如何工作和合成的。

为了使这个奇妙的调试会话得以发生,让我们启动你喜欢的一个IDE和浏览器。我使用Mac,因此操作可能和你的操作系统的命令与方式都不同。首先我在命令行设置一些调试中使用的环境变量,然后启动Safari浏览器。打开Terminal,键入以下内容:

  • $> export CA_COLOR_OPAQUE=1
  • $> export CA_LOG_MEMORY_USAGE=1
  • $> /Applications/Safari.app/Contents/MacOS/Safari

这样就能开启Safari的两个调试助手功能。CA_COLOR_OPAQUE 会向我们展现哪个元素被实际合成和加速了。 CA_LOG_MEMORY_USAGE 会向我们展现当向backing store发送我们的绘制操作时使用了多少内存。这可以确切告诉你你给移动设备施加了多少压力,以及可能提示你你对GPU的使用会消耗目标设备多少电量。

现在让我们启动Chrome,这样我们可以很好地看到每秒多少帧(FPS)的信息:

  1. 打开Google Chrome web浏览器。
  2. 在地址栏输入about:flags.
  3. 向下滚动找到 FPS 计数器,激活它。

注意:不要在所有页选项中激活 GPU 合成。当浏览器检测到你标签中的合成项目,会只在左边角落显示FPS计数器,而这不是我们在本案例中想要的。

如果你在威力增强版的Chrome中查看本讲座效果页面,你会在左上方看到红色的 FPS 计数器。Chrome FPS

这就是我们怎样知道硬件加速功能被开启的方法。这也给了我们一个关于动画如何运行的和你是否有任何疏漏的想法(继续运行本应停止的动画)。

另一种让硬件加速变得实际可视化的方法是,如果你通过先设置我上面提到的环境变量来用Safari打开相同的页面。每个被加速的DOM元素都会有一个红色色调。这告诉了我们到底层合成了哪些元素。注意,白色的导航因为不能加速而没有变红。

Chrome在 about:flags中也有一个类似的设置 “Composited render layer borders”。

另一个看到合成层的好方式,是开启这个选项之后查看WebKit的落叶演示

最后,要真正了解我们的应用程序的图形硬件性能,让我们来看看内存是如何被消耗的。这里我们可以看到,我们正在把绘图指令产生的1.38MB数据推进到Mac OS上的CoreAnimation缓冲区。核心动画缓冲区是被OpenGL ES和GPU共享的,来创建你最终在屏幕上看到的像素。

当我们简单地调整一下浏览器窗口尺寸或把窗口最大化,我们会看到立即膨胀了。

这给你一个想法,内存是如何被消耗在移动设备上,只有当你调整浏览器的正确尺寸。如果你在调试或测试iPhone环境,请从320像素调整到480像素。我们现在明白了硬件加速究竟如何工作的,以及怎样来调试。这是一种用阅读数字来了解的方式,但也是真正看到GPU内存缓冲区可视化工作的方式,确实让事情变得透明了。

场景背后:提取和缓存

现在是时候把我们的页面和资源缓存提升到一个新水平了。就像jQuery Mobile及其类似框架所使用的方法,我们要用并发AJAX调用来预取和缓存我们的网页。

让我们来指出一些移动网络的核心问题和我们为什么需要这么做的原因:

  • 提取:预提取页面允许用户让应用程序离线,也能使导航行为之间无需等待。当然,我们不会希望当设备联机时堵塞设备带宽,所以我们需要有节制地使用此功能。
  • 缓存:接下来,我们要提取和缓存这些页面时,会使用并发或异步的方式。我们还需要使用localStorage(因为在设备之间它能被很好地支持),但不幸的是,它不是异步的。
  • AJAX和解析应答: 用 innerHTML() 把 AJAX 应答插入 DOM 是危险的 (而且 不可靠?)。作为替代,我们对插入 AJAX 应答信息和处理并发调用使用 可靠的机制。我们还利用了HTML5的一些新的特性来解析xhr.responseText。

滑动,翻转,和旋转演示 构建代码,我们开始先加上一些二级页面并链接到它们。然后我们将解析链接并飞速创建转换。

这里查看获取和缓存演示

如你所见,这里我们利用了语义标记。仅仅是到另一个页面的链接。子页面像它的父页面一样遵循相同的节点/类结构。我们可以更进一步的给"page"节点使用data-*属性,等等……这里是位于一个单独的html文件中(/demo2/home-detail.html)的详细页(子页面),它将被加载,缓存并在app加载时为页面转换预先建立。

<div id="home-page" class="page">    <h1>Home Page</h1>    <a href="demo2/home-detail.html" class="fetch">Find out more about the home page!</a>  </div>
现在让我们来看看JS。为简单起见,我没对代码添加助手或进行优化。我们在这里做的是遍历一个指定的DOM节点的数组,挖出要提取和缓存的链接。注意,对于本演示,fetchAndCache()方法在页面加载时被调用。我们在下一节中检测网络连接时会再次使用它,并决定它何时该被调用。
var fetchAndCache = function() {    // iterate through all nodes in this DOM to find all mobile pages we care about    var pages = document.getElementsByClassName('page');      for (var i = 0; i < pages.length; i++) {      // find all links      var pageLinks = pages[i].getElementsByTagName('a');        for (var j = 0; j < pageLinks.length; j++) {        var link = pageLinks[j];          if (link.hasAttribute('href') &&        //'#' in the href tells us that this page is already loaded in the DOM - and        // that it links to a mobile transition/page           !(/[\#]/g).test(link.href) &&          //check for an explicit class name setting to fetch this link          (link.className.indexOf('fetch') >= 0))  {           //fetch each url concurrently           var ai = new ajax(link,function(text,url){                //insert the new mobile page into the DOM               insertPages(text,url);           });           ai.doGet();        }      }    }  };

我们确保通过使用“ AJAX ”对象进行了适当的异步发送处理。在 Working Off the Grid with HTML5 Offline中调用的一个AJAX里有对使用localStorage的一个更高级的解释。在这个例子中,你会看到一个基本用法,用来缓存每个请求,并当服务器未返回成功的(200)响应时提供之前所缓存的对象。

function processRequest () {    if (req.readyState == 4) {      if (req.status == 200) {        if (supports_local_storage()) {          localStorage[url] = req.responseText;        }        if (callback) callback(req.responseText,url);      } else {        // There is an error of some kind, use our cached copy (if available).        if (!!localStorage[url]) {          // We have some data cached, return that to the callback.          callback(localStorage[url],url);          return;        }      }    }  }

不幸的是,由于本地存储使用UTF-16字符编码,每个字节被当作2个字节存储,将我们的存储限制从5MB降到 总共只有2.6MB。 在应用程序缓存范围之外提取和缓存这些页面/标记的整个原因在下一节中透露。

通过最近在HTML5中iframe元素的进展,我们现在有了一个简单而有效的方式来解析AJAX调用返回给我们的响应文本。有很多3000行脚本解析器和去除脚本标签的正则表达式之类的东西。但为何不让浏览器代为做它最擅长的?在这个例子中,我们要把响应文本写到一个暂时隐藏的iframe中。我们使用HTML5的“沙箱”属性,它禁用脚本并提供了许多安全特征…

从规范上来讲: 当设置了 sandbox 属性后, 在Iframe的内容上开启了一组额外的限制。 它的值应该是一组无序的、空格分隔的  token, 并且是大小写敏感的。 可以设置的值分别是 allow-forms, allow-same-origin, allow-scripts, 和 allow-top-navigation. 当属性设置了以后,  内容处理后,将被当作同源,forms 和 scripts 将被禁止,指向其他浏览上下文的 link 将被禁止,插件也被禁用。 为了防止危险的 HTML 内容造成破坏, 它应使用一个 text/html-sandboxed MIME 类型.

var insertPages = function(text, originalLink) {    var frame = getFrame();    //write the ajax response text to the frame and let    //the browser do the work    frame.write(text);      //now we have a DOM to work with    var incomingPages = frame.getElementsByClassName('page');      var pageCount = incomingPages.length;    for (var i = 0; i < pageCount; i++) {      //the new page will always be at index 0 because      //the last one just got popped off the stack with appendChild (below)      var newPage = incomingPages[0];        //stage the new pages to the left by default      newPage.className = 'page stage-left';        //find out where to insert      var location = newPage.parentNode.id == 'back' ? 'back' : 'front';        try {        // mobile safari will not allow nodes to be transferred from one DOM to another so        // we must use adoptNode()        document.getElementById(location).appendChild(document.adoptNode(newPage));      } catch(e) {        // todo graceful degradation?      }    }  };

Safari 正确的阻止了 Node 从一个 doc 到另一个的隐式移动。如果一个新的子节点在不同的 doc 上创建,将抛出一个错误。 那么这里我们使用adopt Node,一切都很好。

那么为什么还要用Iframe,而不仅仅用innerHTML?即便innerHtml如今已是html5规范的一部分,将服务器的响应直接插入未检查过的区域的做法也是有危害的。写作本文期间,我发现几乎所有人都是使用的innerHTML。如Jquery在其核心中使用,仅在发生异常时有一个回调函数来处理。而JQuery Mobile 也是这样使用的。当然我没有针对innerHTML的"随机停止工作"的状况做过任何严格的测试,但查看比较各个平台的对iframe和innerHTML的不同作用效果将十分有趣,更想知道那种方式的性能会好些...在这两种方式下其实我都已经听到了不少抱怨了。

网络类型,处理,性能分析

既然我们有能力来缓存(预测缓存)我们的web应用,我们必须提供更好的网络连接类型检测功能使得我们的应用更加智能。

这就是为什么移动应用的开发在 在线/离线模式和连接速度下变得十分敏感的原因。进入The Network Information API网络信息API. 每次我在演讲这个功能点的时,台下总有人会举起收提问"那我们使用它做什么呢?".那么肯定有种方式来开发一个超级智能的移动应用的。

第一烦人场景是...在高速列车上从移动设备访问一个Web站点,网络的连接在各个不同的时刻和不同的地理环境下很可能失去,因此导致各种不同的传输速度。 (如, HSPA 或 3G在一些城镇地区可以用, 但偏远地区可能只支持速度很慢的2G技术). 下面的代码解决了网络连接问题中的大部分场景。

接下来的代码演示的是:

  • 通过应用程序缓存进行脱机访问 。
  • 探测是否加为书签或脱机访问。
  • 探测是否 从 脱机 切换 至 在线 访问 。
  • 检测 低速连接 并 获取 基于 网络 类型 的 内容 。
并且 , 所有 这些 功能的实现 只需要 很 少 的 代码 。 首先 我们 探测 我们 的 事件 并获取 加载 情况 :
window.addEventListener('load', function(e) {   if (navigator.onLine) {    // new page load    processOnline();   } else {     // the app is probably already cached and (maybe) bookmarked...     processOffline();   }  }, false);    window.addEventListener("offline", function(e) {    // we just lost our connection and entered offline mode, disable eternal link    processOffline(e.type);  }, false);    window.addEventListener("online", function(e) {    // just came back online, enable links    processOnline(e.type);  }, false);

在 上述事件 的监听中 , 我们 必须 告诉 我们 的 代码是否被 事件 或 实际 页面 请求 刷新所 调用 。 主要 的 原因 是 因为 在 联机 和 脱机 模式 之间 切换 时 , 不会触发(fired) 关于页面正在 加载中的 事件 。

下一步 , 我们 做 一个 简单 的 检查是否存在 匿名 在线 或 加载 事件 。此代码需要禁用链接重置, 当 从 脱机 模式切换 为 联机状态 。这个应用需要 更 复杂 的功能, 你 可能 需要做一些逻辑插入来执行 恢复抓取内容和为间歇性连接而处理UX。

function processOnline(eventType) {      setupApp();    checkAppCache();      // reset our once disabled offline links    if (eventType) {      for (var i = 0; i < disabledLinks.length; i++) {        disabledLinks[i].onclick = null;      }    }  }
processOffine()函数也是同样的过程。假设你想让你的app到离线模式并且试图恢复之前场景所有的事务。下面的代码找出所有的外部链接并且让它们失效,永远在我们离线的应用中捕获用户,hoho
function processOffline() {    setupApp();      // disable external links until we come back - setting the bounds of app    disabledLinks = getUnconvertedLinks(document);      // helper for onlcick below    var onclickHelper = function(e) {      return function(f) {        alert('This app is currently offline and cannot access the hotness');return false;      }    };      for (var i = 0; i < disabledLinks.length; i++) {      if (disabledLinks[i].onclick == null) {        //alert user we're not online        disabledLinks[i].onclick = onclickHelper(disabledLinks[i].href);        }    }  }

好,这是多好的东东。现在我们的app知道处于何种连接状态,当它在线时,我们也可以检查连接类型,并且相应的调整它。我曾经监听典型的北美网络供应商下载并且潜在地给每种连接的添加了注释。

function setupApp(){    // create a custom object if navigator.connection isn't available    var connection = navigator.connection || {'type':'0'};    if (connection.type == 2 || connection.type == 1) {        //wifi/ethernet        //Coffee Wifi latency: ~75ms-200ms        //Home Wifi latency: ~25-35ms        //Coffee Wifi DL speed: ~550kbps-650kbps        //Home Wifi DL speed: ~1000kbps-2000kbps        fetchAndCache(true);    } else if (connection.type == 3) {    //edge        //ATT Edge latency: ~400-600ms        //ATT Edge DL speed: ~2-10kbps        fetchAndCache(false);    } else if (connection.type == 2) {        //3g        //ATT 3G latency: ~400ms        //Verizon 3G latency: ~150-250ms        //ATT 3G DL speed: ~60-100kbps        //Verizon 3G DL speed: ~20-70kbps        fetchAndCache(false);    } else {    //unknown        fetchAndCache(true);    }  }
fetchAndCache进程,有很多的设置参数,但是在此我仅仅让他执行同步(给参数false)的或者异步(给参数true)的去取给定连接的资源。

Edge (同步) 请求时间线

WIFI (异步) 请求时间线

这允许基于慢速或快速连接对用户体验的调整至少采取一些方法。这绝不是终结一切的解决方案。另一个要做的是,在慢速连接上,当应用程序仍在后台获取某个链接的页面时,如果点击这个链接,要抛出一个加载中模态。这个关键思想是减少延迟,同时用最新最棒的HTML5提供的对用户的连接充分利用其全部能量。点此查看检测网络的演示.

结论

移动HTML5应用程序刚刚上路。现在你可以看见非常简单且基础的完全围绕 HTML5 创建的移动“框架”以及配套技术。我认为对开发者来说,重要的是在核心中处理解决这些问题,且不要用封装掩盖它。