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

韦斯利·黑尔斯
Wesley Hales

简介

旋转刷新、网页切换不连贯和点按事件时常发生延迟等都是当今移动网络环境中令人头疼的问题之一。开发者试图尽可能接近原生,但经常被黑客攻击、重置和死板框架打断。

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

硬件加速

通常,GPU 会处理详细的 3D 建模或 CAD 图,但在本例中,我们希望原始绘图(div、背景、带阴影的文本、图片等)看起来平滑,并通过 GPU 流畅地添加动画效果。 遗憾的是,大多数前端开发者将此动画流程交给第三方框架处理,而无需担心语义,但是否应该将这些核心的 CSS3 功能掩盖?我来列举几个原因,说明为何关注此产品很重要:

  1. 内存分配和计算负担 - 如果您只是为了硬件加速而合成 DOM 中的每个元素,那么下一个负责您代码的人可能会追赶您,并大力打败您。

  2. 功耗 - 显然,当硬件出现问题时,电池也会起作用。在针对移动设备开发应用时,开发者不得不在编写移动网络应用时考虑各种设备限制。随着浏览器制造商开始支持使用越来越多的设备硬件,这种情况会更为普遍。

  3. 冲突 - 我在对网页中已加速的部分应用硬件加速时遇到了小故障。因此,了解是否存在重叠的加速非常重要

为使用户互动尽可能流畅并尽可能接近原生,我们必须让浏览器为我们服务。理想情况下,我们希望移动设备 CPU 设置初始动画,然后让 GPU 在动画过程中仅负责合成不同的层。这就是 translate3d、scale3d 和 translateZ 的作用:它们为动画元素提供了自己的层,从而使设备能够顺畅地一起渲染所有内容。为了进一步了解加速合成以及 WebKit 的工作原理,Ariya Hidayat 在他的博客上提供了很多有用的信息

页面转换

我们来看看开发移动网络应用时三种最常见的用户互动方法:滑动、翻转和旋转效果。

您可以在以下网址查看代码的实际运行情况:http://slidfast.appspot.com/slide-flip-rotate.html(注意:此演示是专为移动设备设计的,因此,请启动模拟器,使用您的手机或平板电脑,或将浏览器窗口缩小到约 1024 像素或更小)。

首先,我们将剖析滑动、翻转和旋转过渡及其加速方式。请注意,每个动画只需要三到四行 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 元素上的类时,会展示实际的动画。

.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 会加速所有转换元素(无论是 2-D 还是 3-D),因此有必要创建媒体查询并排除该级别的加速。 请注意,在 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';
}

我试着尝试过三次方贝塞尔曲线,为过渡效果提供最佳的原生感,但缓和确实解决了这个问题。

最后,为了实现导航,我们必须调用在上一个演示中使用的之前定义的 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,因此这些命令可能会因您的操作系统而异。 打开终端并输入以下内容:

  • $> 导出 CA_COLOR_OPAQUE=1
  • $> 导出 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. 向下滚动几项内容,然后点击 FPS 计数器对应的“启用”。

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

Chrome FPS

这就是我们启用硬件加速的方式。此外,它还能让我们了解动画的运行方式,以及是否有任何泄漏(本应停止的持续播放的动画)。

另一种实际可视化硬件加速的方法是在 Safari 中打开同一网页(使用我前面提到的环境变量)。每个加速的 DOM 元素都会显示红色。这准确地显示了按层合成的内容。请注意,白色导航不是红色的,因为它没有加速。

组合联系人

about:flags“合成渲染层边框”中也提供了适用于 Chrome 的类似设置。

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

综合叶子

最后,为了真正了解应用的图形硬件性能,我们来了解一下内存的使用情况。在这里,我们看到我们正在将 1.38MB 的绘制指令推送到 Mac OS 上的 CoreAnimation 缓冲区。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 的更高级说明,请参阅利用 HTML5 离线处理网格。在此示例中,您会看到针对每个请求进行缓存以及在服务器返回成功 (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 UTF-16 进行字符编码,因此每个单个字节会存储为 2 个字节,从而使我们的存储空间上限从 5MB 提高到总计 2.6MB。在应用缓存范围之外提取和缓存这些网页/标记的完整原因将在下一部分揭示。

随着采用 HTML5 的 iframe 元素的最新进展,我们现在能够简单而有效地解析从 AJAX 调用中返回的 responseText。有大量的 3,000 行 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 仅在异常时将其用于附加回退机制。JQuery Mobile 也使用它。不过,我尚未对 innerHTML “停止工作”方面进行任何繁重的测试,但看到受此影响的所有平台将会非常有意思。此外,了解哪种方法的性能更高,也会很有意思...我也听到了双方对此的说法。

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

现在,我们能够缓冲(或预测缓存)Web 应用,接下来必须提供适当的连接检测功能,使我们的应用更智能。这时,移动应用开发对在线/离线模式和连接速度极为敏感。输入 Network Information API。每次我在演示中展示此功能时,观众都会举手并问:“我要用它做什么?”。这样就可以设置一款极为智能的移动网络应用。

首先来看一个很无聊的常识场景... 在高速列车上通过移动设备与网页交互时,网络很可能会在不同时刻消失,而不同的地理位置可能支持不同的传输速度(例如,部分城市地区可能可以使用 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);

在上面的 EventListener 中,我们必须告知代码是从事件还是实际网页请求或刷新调用的。主要原因是,在线和离线模式之间切换时不会触发正文 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) 提取资源。

边缘(同步)请求时间轴

边缘同步

Wi-Fi(异步)请求时间轴

WLAN 异步

这样至少允许采用某种方法基于慢速或快速连接调整用户体验。这绝不是一劳永逸的解决方案。另一个需要处理的事项是,在用户点击链接时(在慢速连接下)抛出加载模态,而应用可能仍在在后台提取该链接的页面。此处的要点是缩短延迟时间,同时充分利用用户连接的功能,使之能够提供最新最出色的 HTML5 广告素材。 点击此处查看网络检测演示

总结

移动 HTML5 应用的发展之旅才刚刚开始。现在,您看到了完全围绕 HTML5 及其支持技术构建的移动“框架”的非常简单和基础的基础基础。我认为,开发者务必要在核心层面使用并解决这些功能,而不是被封装容器遮盖。