簡介
在現今的行動版網站環境中,旋轉重新整理、頁面轉換不流暢,以及輕觸事件定期延遲,只是其中幾項令人頭痛的問題。開發人員會盡可能追求原生體驗,但往往會受到駭客攻擊、重設和僵化架構的干擾。
本文將探討建立行動 HTML5 網頁應用程式的最低要求,主要目的是揭露現今行動架構試圖隱藏的複雜性。您將看到極簡方法 (使用核心 HTML5 API) 和基本原理,讓您能編寫自己的架構,或為目前使用的架構做出貢獻。
硬體加速
一般來說,GPU 會處理精細的 3D 模型或 CAD 圖表,但在這個情況下,我們希望原始繪圖 (div、背景、有投射陰影的文字、圖片等) 能透過 GPU 順暢顯示和動畫。不幸的是,大多數前端開發人員都將這個動畫過程交給第三方框架,而不關心語義,但是這些核心 CSS3 功能是否應該被遮罩?以下是重視這類資訊的幾個原因:
記憶體配置和運算負擔:如果只是為了硬體加速而合成 DOM 中的每個元素,下一個處理程式碼的人可能會追著你跑,並狠狠揍你一頓。
耗電量:顯然,硬體啟動時,電池也會啟動。開發行動網頁應用程式時,開發人員必須考量各種裝置限制。隨著瀏覽器製造商開始開放存取越來越多裝置硬體,這種情況將更為普遍。
衝突:將硬體加速套用至已加速的網頁部分時,我遇到不穩定的行為。因此,瞭解加速是否重疊非常重要。
為了讓使用者互動順暢,並盡可能接近原生體驗,我們必須讓瀏覽器為我們服務。理想情況下,我們希望行動裝置 CPU 設定初始動畫,然後在動畫過程中,GPU 只負責合成不同的圖層。這就是 translate3d、scale3d 和 translateZ 的作用:為動畫元素提供專屬圖層,讓裝置能流暢地一併算繪所有內容。如要進一步瞭解加速合成和 WebKit 的運作方式,Ariya Hidayat 在他的網誌中提供了許多實用資訊。
頁面轉場效果
接下來,我們來看看開發行動網頁應用程式時,最常見的三種使用者互動方式:滑動、翻轉和旋轉效果。
您可以前往 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>
請注意,我們有左側或右側暫存頁面的概念。基本上可以是任何方向,但這是最常見的方向。
現在,我們只要使用幾行 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 中查看這個頁面,就會在左上角看到紅色的每秒影格數計數器。
這表示硬體加速功能已開啟。此外,我們也能瞭解動畫的執行方式,以及是否有任何洩漏 (應停止但持續執行的動畫)。
另一個實際呈現硬體加速的方法,是在 Safari 中開啟同一個頁面 (使用上述環境變數)。每個加速 DOM 元素都會帶有紅色色調。這會顯示各層合成的確切內容。請注意,白色導覽列並未加速,因此不會顯示為紅色。
Chrome 也有類似的設定,位於 about:flags 的「Composited render layer borders」。
另一個查看合成層的好方法,是在套用這個模式時,查看 WebKit 落葉示範。
最後,為了真正瞭解應用程式的圖形硬體效能,我們來看看記憶體的使用情況。這裡可以看到,我們將 1.38 MB 的繪圖指令推送至 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」物件,確保非同步後續處理作業正確執行。如需進一步瞭解如何在 AJAX 呼叫中使用 localStorage,請參閱「Working Off the Grid with HTML5 Offline」(使用 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 個位元組,因此儲存空間限制會從 5 MB 降至總共 2.6 MB。下一節將說明在應用程式快取範圍外擷取及快取這些網頁/標記的原因。
隨著 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「隨機停止運作」進行任何大量測試,但如果能看到所有受影響的平台,應該會很有趣。此外,我也想知道哪種做法的成效較佳,因為我聽過雙方對此各有說法。
網路類型偵測、處理和剖析
現在我們有能力緩衝 (或預測快取) 網頁應用程式,因此必須提供適當的連線偵測功能,讓應用程式更聰明。因此,行動應用程式開發人員必須特別注意線上/離線模式和連線速度。輸入「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);
在上述 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) 方式擷取資源。
Edge (同步) 要求時間軸
WIFI (非同步) 要求時間軸
這樣一來,至少能根據連線速度調整使用者體驗。這絕非萬能的解決方案。另一個待辦事項是,當使用者點選連結 (連線速度緩慢),而應用程式可能仍在背景擷取該連結的網頁時,顯示載入中互動視窗。 這裡的重點是減少延遲時間,同時充分利用使用者連線的功能,提供最新、最優質的 HTML5。觀看網路偵測示範影片。
結論
行動 HTML5 應用程式的發展才剛起步。現在,您可以看到行動「架構」非常簡單的基本基礎,完全圍繞 HTML5 及其支援技術建構。我認為開發人員應直接使用並處理這些功能,而不是透過包裝函式遮蓋。