用于优化移动广告效果的 HTML5 技术

Wesley Hales
Wesley Hales

简介

在当今的移动 Web 环境中,旋转刷新、页面过渡不流畅以及点按事件的周期性延迟只是众多令人头疼的问题中的几个。开发者正努力尽可能接近原生体验,但常常因黑客攻击、重置和僵化的框架而受阻。

在本文中,我们将讨论创建移动 HTML5 Web 应用所需的最低限度。重点是揭示当今移动框架试图隐藏的复杂性。您将看到一种极简方法(使用核心 HTML5 API)和基本原理,这些知识将帮助您编写自己的框架或为当前使用的框架做出贡献。

硬件加速

通常,GPU 会处理详细的 3D 模型或 CAD 图,但在这种情况下,我们希望通过 GPU 使原始绘图(div、背景、带阴影的文字、图片等)看起来平滑,并实现平滑的动画效果。 遗憾的是,大多数前端开发者在将此动画流程交给第三方框架时,并不关心语义,但这些核心 CSS3 功能是否应该被屏蔽?下面我将列举几个原因,说明关注这些内容的重要性:

  1. 内存分配和计算负担 - 如果您为了实现硬件加速而对 DOM 中的每个元素进行合成,那么下一个使用您代码的人可能会追上您并狠狠地揍您一顿。

  2. 耗电量 - 显然,当硬件开始工作时,电池也会开始消耗电量。在开发移动应用时,开发者在编写移动 Web 应用时必须考虑各种各样的设备限制。随着浏览器制造商开始支持访问越来越多的设备硬件,这种情况将变得更加普遍。

  3. 冲突 - 将硬件加速应用于已加速的网页部分时,我遇到了故障行为。因此,了解您是否使用了重叠的加速器非常重要。

为了让用户互动尽可能流畅且接近原生体验,我们必须让浏览器为我们所用。理想情况下,我们希望移动设备 CPU 设置初始动画,然后让 GPU 仅负责在动画过程中合成不同的层。这正是 translate3d、scale3d 和 translateZ 的作用,它们为动画元素提供自己的层,从而使设备能够顺畅地一起渲染所有内容。如需详细了解加速合成以及 WebKit 的运作方式,请访问 Ariya Hidayat 的博客,其中包含大量实用信息

页面转换

下面我们来看看开发移动 Web 应用时最常用的三种用户互动方式:滑动、翻转和旋转效果。

您可以点击此处查看此代码的实际效果:http://slidfast.appspot.com/slide-flip-rotate.html(注意:此演示是为移动设备构建的,因此请启动模拟器、使用手机或平板电脑,或者将浏览器窗口的大小调整为大约 1024 像素或更小)。

首先,我们将剖析滑动、翻转和旋转过渡效果及其加速方式。请注意,每个动画仅需三到四行 CSS 和 JavaScript 代码。

滑动

在三种过渡方式中,滑动页面过渡是最常见的一种,它模仿了移动应用的本地感觉。调用幻灯片过渡效果,将新的内容区域带入视口。

For the slide effect, first we declare our markup:

<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 元素上的类时,就会发生实际的动画效果。

.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 来交换类。未使用任何第三方框架,这就是纯 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-leftstage-right 变为 stage-center,并强制页面滑动到中心视口。我们完全依赖 CSS3 来完成繁重的工作。

.stage-left {
  left: -480px;
}

.stage-right {
  left: 480px;
}

.stage-center {
  top: 0;
  left: 0;
}

接下来,我们来看看用于处理移动设备检测和方向的 CSS。 我们可以针对每种设备和每种分辨率进行调整(请参阅媒体查询分辨率)。在此演示中,我仅使用了几个简单的示例来涵盖移动设备上的大多数竖屏和横屏视图。这对于按设备应用硬件加速也很有用。例如,由于桌面版 WebKit 会加速所有已转换的元素(无论它是 2D 还是 3D),因此在该级别创建媒体查询并排除加速是有意义的。 请注意,在 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 淡出过渡效果来实现页面翻转,因此常规的 element.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;
  }
}

您还会注意到,我们也在以毫秒为单位衡量 swipeTime。这样一来,如果用户快速滑动屏幕来翻页,系统便会触发导航事件。

为了在手指触摸屏幕时定位网页并使动画看起来像原生动画,我们在每次触发事件后使用 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';
}

我尝试使用 cubic-bezier 来为过渡效果提供最佳原生体验,但 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 代码即可分配过渡类 onclick。 注意:在大多数 Android 版本上,旋转过渡效果无法正确呈现,因为这些版本缺少 3D CSS 转换功能。遗憾的是,Android 并没有忽略翻转,而是通过旋转使网页“翻滚”消失,而不是翻转。我们建议您谨慎使用此过渡效果,直到支持得到改进为止。

加购(正面和背面的基本概念):

<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。 首先,从命令行启动 Safari,以使用一些调试环境变量。我使用的是 Mac,因此命令可能会因您的操作系统而异。 打开终端并输入以下内容:

  • $> 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 可显示我们在将绘制操作发送到后备存储区时使用的内存量。这会准确显示您对移动设备的压力,并可能提示 GPU 使用情况如何消耗目标设备的电池电量。

现在,我们启动 Chrome,以便查看一些不错的每秒帧数 (FPS) 信息:

  1. 打开 Google Chrome 网络浏览器。
  2. 在网址栏中,输入 about:flags
  3. 向下滚动几项,然后点击“Enable”(启用)FPS 计数器。

如果您在增强版 Chrome 中查看此页面,则会在左上角看到红色 FPS 计数器。

Chrome FPS

这样我们就知道硬件加速已开启。它还可以帮助我们了解动画的运行方式,以及是否存在任何泄漏(应停止的持续运行动画)。

另一种直观了解硬件加速的方法是在 Safari 中打开同一网页(使用我上面提到的环境变量)。每个加速的 DOM 元素都带有红色调。这会显示按层合成的内容。 请注意,白色导航不是红色,因为它未加速。

合成联系人

Chrome 中也提供类似设置,即 about:flags 中的“合成渲染层边框”。

查看合成图层的另一种好方法是在应用此 mod 的情况下查看 WebKit 落叶演示

复合叶

最后,为了真正了解应用的图形硬件性能,我们来看看内存消耗情况。 从上图可以看出,我们正在向 Mac OS 上的 CoreAnimation 缓冲区推送 1.38 MB 的绘制指令。Core Animation 内存缓冲区在 OpenGL ES 和 GPU 之间共享,以创建您在屏幕上看到的最终像素。

Coreanimation 1

当我们只是调整浏览器窗口的大小或将其最大化时,也会看到内存随之增加。

Coreanimation 2

只有将浏览器调整为正确的尺寸,您才能了解移动设备上的内存消耗情况。如果您要调试或测试 iPhone 环境,请将尺寸调整为 480x320 像素。 现在,我们已经确切了解硬件加速的工作原理以及调试所需的条件。阅读相关内容是一回事,但实际看到 GPU 内存缓冲区以直观的方式工作,确实能让人对相关概念有更清晰的认识。

幕后揭秘:提取和缓存

现在,我们来进一步提升网页和资源缓存。与 JQuery Mobile 和类似框架所用的方法非常相似,我们将通过并发 AJAX 调用预提取和缓存网页。

下面我们来探讨几个核心移动网站问题,以及我们为何需要解决这些问题:

  • 提取:预提取网页可让用户离线使用应用,还可让用户在导航操作之间无需等待。当然,我们不希望在设备上线时限制设备的带宽,因此需要谨慎使用此功能。
  • 缓存:接下来,我们希望在提取和缓存这些网页时采用并发或异步方法。我们还需要使用 localStorage(因为该功能在设备中得到广泛支持),但遗憾的是,它不是异步的。
  • AJAX 和解析响应:使用 innerHTML() 将 AJAX 响应插入 DOM 是危险的(并且不可靠?)。我们改用可靠的机制来插入 AJAX 响应和处理并发调用。我们还利用 HTML5 的一些新功能来解析 xhr.responseText

滑动、翻转和旋转演示中的代码基础上,我们首先添加一些辅助页面并链接到这些页面。然后,我们会解析链接并即时创建过渡效果。

iPhone 首页

点击此处查看“提取和缓存”演示。

如您所见,我们在此处使用了语义标记。只是指向另一个网页的链接。子网页遵循与其父网页相同的节点/类结构。我们可以进一步使用“页面”节点等的 data-* 属性。以下是位于单独 HTML 文件 (/demo2/home-detail.html) 中的详情页面(子页面),该页面将在应用加载时加载、缓存并设置为可过渡。

<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>

现在,我们来看看 JavaScript。为简单起见,我将所有辅助程序或优化都排除在代码之外。我们在这里所做的只是遍历指定的 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') &amp;&amp;
      //'#' 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) &amp;&amp;
        //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”对象来确保适当的异步后处理。如需更深入地了解如何在 AJAX 调用中使用 localStorage,请参阅 Working Off the Grid with HTML5 Offline。在此示例中,您可以看到在每个请求上缓存的基本用法,以及当服务器返回除成功 (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;
      }
    }
  }
}

遗憾的是,由于 localStorage 使用 UTF-16 进行字符编码,因此每个字节都以 2 个字节的形式存储,从而将存储空间限制从 5MB 降至 2.6MB 总空间。下一部分将介绍在应用缓存范围之外提取和缓存这些网页/标记的全部原因。

随着 HTML5 中 iframe 元素的最新进展,我们现在有了一种简单有效的方法来解析从 AJAX 调用返回的 responseText。有许多 3000 行的 JavaScript 解析器和正则表达式可以移除脚本标记等。但为什么不让浏览器发挥其最大优势呢?在此示例中,我们将 responseText 写入临时隐藏的 iframe 中。我们使用的是 HTML5“沙盒”属性,该属性可停用脚本并提供多项安全功能…

根据规范:沙盒属性(如果指定)可对 iframe 托管的任何内容启用一组额外的限制。其值必须是一个无序的集合,其中包含以空格分隔的唯一令牌,这些令牌不区分 ASCII 大小写。允许的值包括 allow-forms、allow-same-origin、allow-scripts 和 allow-top-navigation。设置该属性后,内容会被视为来自唯一来源,表单和脚本会被停用,链接无法定位到其他浏览上下文,并且插件会被停用。

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 正确拒绝了将节点从一个文档隐式移动到另一个文档。如果新子节点是在其他文档中创建的,则会引发错误。因此,我们在这里使用 adoptNode,一切正常。

那么,为什么选择 iframe?为什么不直接使用 innerHTML?尽管 innerHTML 现在是 HTML5 规范的一部分,但将服务器(恶意或正常)的响应插入到未经检查的区域是一种危险的做法。在撰写本文期间,我发现没有人使用 innerHTML 以外的任何内容。我知道 JQuery 在其核心中使用它,仅在出现异常时才使用 append 作为后备。JQuery Mobile 也使用它。不过,我尚未针对 innerHTML “随机停止工作”进行任何深入测试,但如果能了解此问题影响的所有平台,那将非常有趣。此外,哪种方法的效果更好也值得关注…我也听到了双方对此的说法。

网络类型检测、处理和分析

现在,我们能够缓冲(或预测性缓存)Web 应用,因此必须提供适当的连接检测功能,使应用更加智能。在这种情况下,移动应用开发对在线/离线模式和连接速度非常敏感。输入 Network Information API。每次我在演示中展示此功能时,总会有观众举手问:“我可以用它做什么?”因此,下面介绍一种设置极其智能的移动 Web 应用的方法。

首先,我们来看一个枯燥的常识性场景… 当您在高速列车上使用移动设备与网络互动时,网络可能会在不同时刻中断,并且不同地理位置可能支持不同的传输速度(例如,HSPA 或 3G 可能在某些城市地区可用,但偏远地区可能仅支持速度慢得多的 2G 技术)。以下代码可应对大多数连接场景。

以下代码提供了:

  • 通过 applicationCache 进行离线访问。
  • 检测是否已添加书签且处于离线状态。
  • 检测从离线状态切换到在线状态以及从在线状态切换到离线状态的情况。
  • 检测连接速度缓慢的情况,并根据网络类型提取内容。

同样,所有这些功能只需要很少的代码。首先,我们检测事件和加载场景:

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);

在上面的 EventListeners 中,我们必须告知代码它是从事件还是实际的网页请求或刷新中调用的。主要原因是,在在线模式和离线模式之间切换时,不会触发正文 onload 事件。

接下来,我们简单检查一下是否存在 ononlineonload 事件。此代码会在从离线切换到在线时重置已停用的链接,但如果此应用更复杂,您可能会插入逻辑来恢复提取内容或处理间歇性连接的界面。

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;
    }
  }
}

processOffline() 也是如此。在此处,您将操作应用以进入离线模式,并尝试恢复在后台进行的任何交易。以下代码会找出我们所有的外部链接并将其停用,从而将用户永远困在我们的离线应用中,哈哈哈!

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);

    }
  }
}

好了,接下来我们来聊点有意思的。现在,我们的应用知道自己处于哪种连接状态,我们还可以在应用处于在线状态时检查连接类型,并相应地进行调整。我在每种连接的注释中列出了典型的北美提供商的下载速度和延迟时间。

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 流程进行多项调整,但我在这里所做的只是告知它针对给定连接异步 (true) 或同步 (false) 获取资源。

Edge(同步)请求时间轴

Edge Sync

WIFI(异步)请求时间轴

WIFI Async

这样一来,您至少可以根据连接速度的快慢来调整用户体验。这绝不是万能的解决方案。另一个待办事项是在点击链接时(在连接速度较慢的情况下)显示加载模态框,同时应用可能仍在后台提取该链接的网页。 这里的重点是,在充分利用最新最出色的 HTML5 所提供的功能的同时,尽可能缩短延迟时间。 点击此处查看网络检测演示

总结

移动 HTML5 应用的未来之路才刚刚开始。现在,您可以看到完全基于 HTML5 及其支持技术构建的移动“框架”的简单基本原理。我认为,开发者应在核心层面使用并解决这些功能,而不是通过封装器来掩盖它们。