简介
在当今的移动网络环境中,旋转刷新、页面过渡不流畅以及点按事件周期性延迟只是众多令人头疼的问题中的一小部分。开发者们正努力追求极致的原生体验,但往往会受到黑客攻击、重置和僵化框架的干扰。
在本文中,我们将讨论创建移动 HTML5 Web 应用所需的最低要求。重点是揭示当今移动框架试图隐藏的复杂性。您将看到一种极简方法(使用核心 HTML5 API)和基本原理,这些知识将使您能够编写自己的框架或为您当前使用的框架做出贡献。
硬件加速
通常,GPU 负责处理详细的 3D 建模或 CAD 图表,但在这种情况下,我们希望通过 GPU 让原始绘图(div、背景、带阴影的文本、图片等)看起来流畅且动画流畅。 不幸的是,大多数前端开发者都将此动画过程交给第三方框架,而不关心语义,但这些核心 CSS3 功能是否应该被掩盖?以下是关注这些内容的重要原因:
内存分配和计算负担 - 如果您只是为了硬件加速而对 DOM 中的每个元素进行合成,那么下一个处理您代码的人可能会追着您并狠狠地揍您一顿。
功耗 - 显然,硬件启动时,电池也会启动。在开发移动应用时,开发者在编写移动 Web 应用时不得不考虑各种设备限制。随着浏览器制造商开始允许访问越来越多的设备硬件,这种情况将更加普遍。
冲突 - 当我将硬件加速应用于页面中已经加速的部分时,遇到了故障行为。因此,了解是否存在重叠加速非常重要。
为了让用户互动尽可能顺畅且接近原生体验,我们必须让浏览器为我们服务。理想情况下,我们希望移动设备 CPU 设置初始动画,然后让 GPU 仅负责在动画过程中合成不同的层。这就是 translate3d、scale3d 和 translateZ 的作用:它们为动画元素提供自己的层,从而使设备能够顺畅地将所有内容渲染在一起。如需详细了解加速合成和 WebKit 的工作原理,请参阅 Ariya Hidayat 的博客,其中包含大量实用信息。
页面过渡
下面我们来看看开发移动 Web 应用时最常见的三种用户互动方式:滑动、翻转和旋转效果。
您可以在此处查看此代码的实际效果: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-left 或 stage-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) 信息:
- 打开 Google Chrome 网络浏览器。
- 在网址栏中,输入 about:flags。
- 向下滚动几项,然后点击“FPS 计数器”对应的“启用”。
如果您在升级后的 Chrome 版本中查看此页面,您会在左上角看到红色的 FPS 计数器。
这样我们就能知道硬件加速功能已开启。它还能让我们了解动画的运行方式,以及是否存在任何泄漏(应停止但仍在持续运行的动画)。
另一种直观呈现硬件加速的方法是在 Safari 中打开同一页面(使用我上面提到的环境变量)。每个加速的 DOM 元素都带有红色色调。这会向我们确切显示按层合成的内容。 请注意,白色导航栏不是红色,因为它没有加速。
Chrome 中也有类似的设置,即 about:flags 中的“合成渲染层边框”。
查看合成层的另一种好方法是在应用此模式时查看 WebKit 落叶演示。
最后,为了真正了解应用的图形硬件性能,我们来看看内存是如何消耗的。 在这里,我们可以看到,我们正在将 1.38MB 的绘制指令推送到 Mac OS 上的 CoreAnimation 缓冲区。Core Animation 内存缓冲区在 OpenGL ES 和 GPU 之间共享,以创建您在屏幕上看到的最终像素。
当我们只是调整浏览器窗口大小或将其最大化时,也会看到内存占用增加。
只有在您将浏览器调整到正确的尺寸后,此功能才能让您了解移动设备上的内存消耗情况。如果您要针对 iPhone 环境进行调试或测试,请将尺寸调整为 480 像素 x 320 像素。 现在,我们已经确切了解了硬件加速的工作原理以及调试所需的操作。光看文字是一回事,但亲眼看到 GPU 内存缓冲区的工作过程,才真正让人豁然开朗。
幕后故事:提取和缓存
现在,是时候进一步提升页面和资源缓存水平了。与 JQuery Mobile 和类似框架所用的方法非常相似,我们将使用并发 AJAX 调用来预取和缓存网页。
我们来解决几个核心移动网站问题,并了解需要这样做的原因:
- 提取:预取页面可让用户离线使用应用,还可让用户在导航操作之间无需等待。当然,我们不希望在设备上线时占用设备的带宽,因此我们需要谨慎使用此功能。
- 缓存:接下来,我们希望在提取和缓存这些网页时采用并发或异步方法。我们还需要使用 localStorage(因为它在设备中得到了很好的支持),但遗憾的是,它不是异步的。
- AJAX 和解析响应:使用 innerHTML() 将 AJAX 响应插入 DOM 是危险的(且不可靠?)。我们改用可靠的机制来插入 AJAX 响应和处理并发调用。我们还利用了 HTML5 的一些新功能来解析
xhr.responseText。
在滑动、翻转和旋转演示的代码基础上,我们首先添加一些次级页面并链接到这些页面。然后,我们会解析这些链接并即时创建过渡。
如您所见,我们在这里使用了语义标记。只是一个指向其他网页的链接。子页面遵循与其父页面相同的节点/类结构。我们可以更进一步,为“page”节点等使用 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') &&
//'#' 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(使用 HTML5 离线功能在离线状态下工作)中更详细地介绍了如何在 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;
}
}
}
}
遗憾的是,由于 localStorage 使用 UTF-16 进行字符编码,因此每个单字节都存储为 2 个字节,从而使我们的存储空间上限从 5MB 降至总共 2.6MB。下一部分将揭示在应用缓存范围之外提取和缓存这些网页/标记的全部原因。
随着 HTML5 中 iframe 元素的最新发展,我们现在有了一种简单有效的方法来解析从 AJAX 调用中返回的 responseText。有许多 3000 行的 JavaScript 解析器和正则表达式,它们会移除脚本标记等。但为什么不让浏览器做它最擅长的事情呢?在此示例中,我们将 responseText 写入一个临时的隐藏 iframe。我们使用的是 HTML5“沙盒”属性,该属性会停用脚本并提供许多安全功能…
规范中的说明: 如果指定了 sandbox 属性,则会对 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 中,我们必须告知代码它是通过事件还是实际页面请求或刷新调用的。主要原因是,在切换在线和离线模式时,系统不会触发 body onload 事件。
接下来,我们对 ononline 或 onload 事件进行简单检查。此代码会在从离线切换到在线时重置已停用的链接,但如果此应用更复杂,您可能会插入逻辑来恢复提取内容或处理间歇性连接的用户体验。
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) 提取资源。
边缘(同步)请求时间轴
WIFI(异步)请求时间轴
这样,至少可以根据连接速度的快慢来调整用户体验。 这绝不是一劳永逸的解决方案。另一个待办事项是,当用户点击链接时(在网络连接速度较慢的情况下),如果应用仍在后台提取该链接的页面,则显示一个加载模态框。 这里的重点是减少延迟,同时利用最新、最强大的 HTML5 技术,充分发挥用户连接的全部功能。 点击此处可观看网络检测演示。
总结
移动 HTML5 应用的发展之路才刚刚开始。现在,您已经了解了仅围绕 HTML5 及其支持技术构建的移动“框架”的非常简单和基本的基础。我认为,开发者必须在核心层面使用和处理这些功能,而不是通过封装容器来掩盖它们。