简介
旋转刷新、页面转换不流畅以及点按事件周期性延迟,只是当今移动网络环境中令人头疼的问题之一。开发者会尽可能接近原生应用,但通常会因黑客攻击、重置和僵硬的框架而偏离轨道。
在本文中,我们将讨论创建移动 HTML5 Web 应用所需的最低要求。主要要点是揭示当今移动框架试图隐藏的隐藏复杂性。您将了解一种极简方法(使用核心 HTML5 API)和基本原理,从而能够编写自己的框架或为您当前使用的框架做出贡献。
硬件加速
通常,GPU 会处理详细的 3D 建模或 CAD 图表,但在本例中,我们希望通过 GPU 让基本图形(div、背景、带阴影的文本、图片等)显示流畅并流畅地进行动画处理。 遗憾的是,大多数前端开发者都将此动画过程交给第三方框架处理,而不在意语义,但是否应该掩盖这些核心 CSS3 功能?我来说明一下为什么关注这项内容非常重要:
内存分配和计算负担 - 如果您只是为了实现硬件加速而对 DOM 中的每个元素进行合成,那么下一个处理您代码的人可能会追上您并狠狠地教训您。
功耗 - 显然,硬件启动后,电池也会启动。在针对移动设备进行开发时,开发者在编写移动 Web 应用时不得不考虑各种设备限制。随着浏览器制造商开始支持访问越来越多的设备硬件,这种情况将变得更加普遍。
冲突 - 在对已加速的网页部分应用硬件加速时,我遇到了故障行为。因此,了解是否存在重叠加速非常重要。
为了让用户互动尽可能顺畅且接近原生,我们必须让浏览器正常运行。理想情况下,我们希望移动设备 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-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,因此命令可能会因您的操作系统而异。 打开终端,然后输入以下内容:
- $> 导出 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 元素都会带有红色。这让我们可以准确了解按层合成的具体内容。 请注意,白色导航图标未显示为红色,因为它未加速。
您还可以在 about:flags 中找到适用于 Chrome 的类似设置:“合成渲染层边框”。
如需查看合成层,另一种绝佳方法是在应用此模块时查看 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
。
在“滑动、翻转和旋转”演示中的代码基础上,我们首先添加一些次级页面并链接到它们。然后,我们将解析链接并实时创建转场效果。
如您所见,我们在这里使用了语义标记。只是指向另一个页面的链接。子页面遵循与其父级相同的节点/类结构。我们可以更进一步,为“页面”节点等使用 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”对象来确保适当的异步后处理。有关在 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 进行字符编码,因此每个字节都存储为 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。每当我在演示中展示此功能时,都会有观众举手问:“我可以用它做什么?”。下面介绍一种设置极其智能的移动 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);
在上述 EventListener 中,我们必须告知代码是从事件还是实际网页请求或刷新调用的。主要原因是,在线上和离线模式之间切换时,系统不会触发 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 及其支持技术构建的移动“框架”的非常简单而基本的依据。我认为对于开发者来说,重要的是要在其核心层面使用和处理这些功能,而不是被封装容器所遮盖。