優化移動體驗的HTML5技巧
簡介
連軸轉的刷新,不斷變向的頁面轉換,以及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></pre>
注意我們是如何讓頁面向左或向右演出的。本質上,它可以是任何方向,但水平是最常見的。
我們現在只需幾行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'; }</pre>
stage-left或stage-right成為stage-center,會推動頁面滑入視圖中心。我們完全依靠CSS3完成繁重的工作。
.stage-left { left: -480px; }.stage-right { left: 480px; }
.stage-center { top: 0; left: 0; }</pre>
接下來,讓我們看看處理移動設備檢測與適應的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; } }</pre>
翻轉
在移動設備上,翻轉實際上以把頁面擊飛(譯者注:如果你熟悉棒球,很容易想像)而聞名。在這里我們用一些簡單的 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; } }</pre>
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); }</pre>
調試硬件加速能力
現在我們講完基本變換的方法了,讓我們看看它們是如何工作和合成的。
為了使這個奇妙的調試會話得以發生,讓我們啟動你喜歡的一個IDE和瀏覽器。我使用Mac,因此操作可能和你的操作系統的命令與方式都不同。首先我在命令行設置一些調試中使用的環境變量,然后啟動Safari瀏覽器。打開Terminal,鍵入以下內容:
- $> export CA_COLOR_OPAQUE=1
- $> export CA_LOG_MEMORY_USAGE=1
- $> /Applications/Safari.app/Contents/MacOS/Safari </ul>
- 打開Google Chrome web瀏覽器。
- 在地址欄輸入about:flags.
- 向下滾動找到 FPS 計數器,激活它。 </ol>
- 提取:預提取頁面允許用戶讓應用程序離線,也能使導航行為之間無需等待。當然,我們不會希望當設備聯機時堵塞設備帶寬,所以我們需要有節制地使用此功能。
- 緩存:接下來,我們要提取和緩存這些頁面時,會使用并發或異步的方式。我們還需要使用localStorage(因為在設備之間它能被很好地支持),但不幸的是,它不是異步的。
- AJAX和解析應答: 用 innerHTML() 把 AJAX 應答插入 DOM 是危險的 (而且 不可靠?)。作為替代,我們對插入 AJAX 應答信息和處理并發調用使用 可靠的機制。我們還利用了HTML5的一些新的特性來解析xhr.responseText。 </ul>
-
通過應用程序緩存進行脫機訪問 。
- 探測是否加為書簽或脫機訪問。
-
探測是否 從 脫機 切換 至 在線 訪問 。
-
檢測 低速連接 并 獲取 基于 網絡 類型 的 內容 。
這樣就能開啟Safari的兩個調試助手功能。CA_COLOR_OPAQUE 會向我們展現哪個元素被實際合成和加速了。 CA_LOG_MEMORY_USAGE 會向我們展現當向backing store發送我們的繪制操作時使用了多少內存。這可以確切告訴你你給移動設備施加了多少壓力,以及可能提示你你對GPU的使用會消耗目標設備多少電量。
現在讓我們啟動Chrome,這樣我們可以很好地看到每秒多少幀(FPS)的信息:
注意:不要在所有頁選項中激活 GPU 合成。當瀏覽器檢測到你標簽中的合成項目,會只在左邊角落顯示FPS計數器,而這不是我們在本案例中想要的。
如果你在威力增強版的Chrome中查看本講座效果頁面,你會在左上方看到紅色的 FPS 計數器。
這就是我們怎樣知道硬件加速功能被開啟的方法。這也給了我們一個關于動畫如何運行的和你是否有任何疏漏的想法(繼續運行本應停止的動畫)。
另一種讓硬件加速變得實際可視化的方法是,如果你通過先設置我上面提到的環境變量來用Safari打開相同的頁面。每個被加速的DOM元素都會有一個紅色色調。這告訴了我們到底層合成了哪些元素。注意,白色的導航因為不能加速而沒有變紅。
另一個看到合成層的好方式,是開啟這個選項之后查看WebKit的落葉演示。

最后,要真正了解我們的應用程序的圖形硬件性能,讓我們來看看內存是如何被消耗的。這里我們可以看到,我們正在把繪圖指令產生的1.38MB數據推進到Mac OS上的CoreAnimation緩沖區。核心動畫緩沖區是被OpenGL ES和GPU共享的,來創建你最終在屏幕上看到的像素。

當我們簡單地調整一下瀏覽器窗口尺寸或把窗口最大化,我們會看到立即膨脹了。

這給你一個想法,內存是如何被消耗在移動設備上,只有當你調整瀏覽器的正確尺寸。如果你在調試或測試iPhone環境,請從320像素調整到480像素。我們現在明白了硬件加速究竟如何工作的,以及怎樣來調試。這是一種用閱讀數字來了解的方式,但也是真正看到GPU內存緩沖區可視化工作的方式,確實讓事情變得透明了。
場景背后:提取和緩存
現在是時候把我們的頁面和資源緩存提升到一個新水平了。就像jQuery Mobile及其類似框架所使用的方法,我們要用并發AJAX調用來預取和緩存我們的網頁。
讓我們來指出一些移動網絡的核心問題和我們為什么需要這么做的原因:
從滑動,翻轉,和旋轉演示 構建代碼,我們開始先加上一些二級頁面并鏈接到它們。然后我們將解析鏈接并飛速創建轉換。

如你所見,這里我們利用了語義標記。僅僅是到另一個頁面的鏈接。子頁面像它的父頁面一樣遵循相同的節點/類結構。我們可以更進一步的給"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(); } }
} };</pre>
我們確保通過使用“ 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? }
} };</pre>
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 創建的移動“框架”以及配套技術。我認為對開發者來說,重要的是在核心中處理解決這些問題,且不要用封裝掩蓋它。