見微知著,Google Photos Web UI 完善之旅
已獲翻譯授權,原文地址: ofollow,noindex">Building the Google Photos Web UI 。
原文深入淺出,推薦閱讀。
幾年前我有幸以工程師的身份加入 Goolge Photos 團隊,並參與了 2015 年釋出的第一個版本。不計其數的設計師、產品經理、學者還有工程師(包括了各平臺、前後端)投入其中,這裡列出的只是幾個主要職責。我所負責的是 Web UI 部分,更精確點來說,我負責了照片的網格佈局。
我們立下雄心壯志,要做出完美的佈局方案:支援 全屏自適應 、保證原圖比例、互動 便捷 (比如使用者可以跳轉到指定的位置)、既展現海量圖片又保證頁面的 高效能 和 高速載入 。
當時,市面上還沒有任何相簿產品能實現以上所有效果。據我所知,到目前為止也尚未出現能和 Google Photos 相媲美的產品。特別是在頁面佈局和圖片比例上,大部分產品依然將圖片裁剪成正方形以保證佈局優美。
下面我將會分享我們是如何完成這些挑戰,以及 Web 版的 Goolge Photos 中的一些技術細節。
為什麼這個任務如此艱難?
有兩大和 'size' 相關的難關。
第一個 'size' 挑戰來自於龐大的圖片量(有些使用者上傳了超過25萬張圖片),大量的元資料儲存在伺服器中。即便單張圖片要傳遞的資訊量(比如圖片url、寬高、時間戳…)並不多,但由於圖片數量非常多,直接導致頁面的 載入時間 變長。
第二個 'size' 問題在圖片自身。現代高清屏上,一張小照片也至少有 50KB,1000張這樣的照片就有 50MB。不僅伺服器傳輸資料會很慢,更糟糕的是一次性渲染這麼多內容,瀏覽器容易崩潰。早期的 Google+ Photos 載入1000~2000張圖片時就會變卡,載入10000張圖片時瀏覽器標籤頁就直接崩潰。
下面我將分成四個部分回溯我們是如何解決這兩個問題的:
- “獨立”的圖片 — 迅速定位到圖片庫中的指定位置。
- 自適應佈局 — 根據瀏覽器寬度,儘可能鋪滿圖片且要保留圖片的原始比例(不做正方形裁剪)。
- 60fps 的流暢滾動 — 巨大資料量面前,也要保證頁面互動的流暢。
- 及時反饋 — 載入時間最小化。
1. “獨立”的圖片
相信大家也見過不少大量資料的展現方案。比如最傳統的 分頁 ,每一頁展示固定的結果數,通過點選“下一頁”獲取新的資料,往復向後就能看到所有的結果;現在更流行的方法是 無限滾動 ,一次載入定量的資料,當用戶滾動頁面接近當前資料末端時自動拉取新資料,插入頁面。如果整個過程足夠流暢,就能一直往下滾動頁面 —— 所謂的無限滾動。
但分頁和無限滾動都存在一個問題:在載入完所有資料後,如果使用者想要尋找最開始的某一張照片 —— 一個噩夢。
對大部分頁面來說,使用者還能通過滾動條定位。但對分頁來說,滾動條頂多能定位到當前頁面的底端,而不是整個圖片庫的最後一張;無限滾動呢,滾動條的位置永遠在變,除非資料全部都傳到客戶端了,不然別想用滾動條觸底。
獨立圖片網格提供了另一種思路,在這個方案裡滾動條將正常表現
為了讓使用者能夠使用滾動條去定位到指定位置,我們需要將頁面空間預留好。假如使用者的所有照片能夠一次性被傳過來,還挺好實現;但問題是資料量大到無法一次搞定。看來我們需要試試其他的方法了。
這也是其他圖片庫需要面對的問題,為了提前佈局,常見的解決方案是把所有圖片都做方形裁剪。這個方法只需要知道總圖片數:用視口寬度除以確定的方形佔位尺寸,得到列數,再通過總圖片數,進而得到行數。
const columns = Math.floor(viewportWidth / (thumbnailSize + thumbnailMargin)); const rows = Math.ceil(photoCount / columns); const height = rows * (thumbnailSize + thumbnailMargin);
三行程式碼就能實現,不出十二行程式碼就能搞定整體佈局。
為了減少首次傳送元資料,我們想到的是將使用者的照片分成獨立的模組,首次載入時只傳送模組名和每個模組下照片的數量。舉個例子,以“月”為維度劃分模組 —— 這一步可以在伺服器端實現(也就是提前計算好)。如果資料量達到百萬級別,甚至可以以“十年”為單位來統計。首次載入時所用的資料大概是這個樣子的:
{ "2014_06": 514, "2014_05": 203, "2014_04": 1678, "2014_03": 973, "2014_02": 26, // ... "1999_11": 212 }
如果由使用者(比如攝影師)在同一個時間段內就能產出大量圖片,這個方案還是有缺陷的 —— 將資料分為一個個模組的原因是方便處理元資料,但對於重度使用者來說,每個月的資料量依然極大。偉大的基礎服務團隊想到了解決方案 —— 允許使用者建立自定義的分類方式(比如地點、時間戳...)。

有了這些資訊之後,我們就能給每個模組佔位了。當用戶快速滾動頁面時,客戶端獲取到對應的圖片元資料,計算出完整的佈局並更新頁面。
在瀏覽器端,拿到了模組的元資料後,我們會將照片按照日期度再做一次整理。我們討論過的動態分組(比如根據位置、人物、日期…)也將是很棒的特性。
現在預估模組的尺寸就很簡單了,通過照片數量和預估的單張照片的比例後,進行計算:
// 理想情況下,我們應該先計算出當前模組的比例均值 // 不過我們先假設照片比例是 3:2, // 然後在它的基礎上做一些調整 const unwrappedWidth = (3 / 2) * photoCount * targetHeight * (7 / 10); const rows = Math.ceil(unwrappedWidth / viewportWidth); const height = rows * targetHeight;
你可能猜到了,這樣的估算結果並不準確,甚至偏差相當大。
我一開始把問題複雜化了( 佈局 環節將會詳細聊到),但從結果來看一開始也未必需要得到準確的數值(在照片數量很大的情況下,甚至能偏差上千畫素)。我們之所以要做估算,也是為了保證滾動條位置,事實證明即使如此粗略,滾動條的定位依然能用。

這裡有個小技巧,當模組真正被加載出來的時候,瀏覽器也就知道了實際需要的佔位高度和預估佔位高度之間的差,只要直接將頁面剩餘模組向下移動高度差的距離就行了。
如果要載入的模組在視口之上,那麼模組載入好後還需要更新滾動條的位置。所有的更新操作可以在一秒內用一個動畫幀完成,對使用者造成的影響並不大,速度如果夠快使用者甚至是無感知的。
2. 自適應佈局
據我所知,市面上主流的圖片自適應佈局都採用了一種巧妙由又簡便的方法:每行高度不同但都佔滿視口,同一行內的圖片根據寬高比縮放,以確保同一行內的圖片高度。使用者也不會容易注意到行與行之間的高度差。
放棄把所有圖片的高度都變成一樣的,保證原圖的比例,再固定圖片之間的間距。實現起來也不難,找到最高的行,按照寬高比縮放每張照片,更新當前網格寬度,如果發現要超過視口寬度了,就按照比例縮小該行內每一張圖片,當然此時這一行的高度也會變小。
比如有14張圖片的時候:

這個方法價效比很高,Google+ 過去也是用這個方法,Google 搜尋用的是這個方法的一種改良,但也還是相同的理念。Flickr 優化後(他們進一步比較,在即將超過視口寬度時是少放一張圖片,還是多放一張圖片效果更好)將他們的方案開源。簡化版如下:
let row = []; let currentWidth = 0; photos.forEach(photo => { row.push(photo); currentWidth += Math.round((maxHeight / photo.height) * photo.width); if (currentWidth >= viewportWidth) { rows.push(row); row = []; currentWidth = 0; } }); row.length && rows.push(row);
起初我(其實是多餘地)擔心著估算值和最終值偏差甚遠,把問題想得越來越複雜。不過這期間,我意外地找到了解決方案。
我的理念是:圖片網格佈局和文字折行問題異曲同工。參考了有完整文件支援的Knuth & Plass 折行演算法,我打算將它運用到圖片佈局上來。
和文字折行不同的是,在圖片佈局上我們要以模組為單位考慮問題,模組內的每一行都會影響到它們之後的行的佈局。
K&P 演算法的基礎單位是 box、glue 和 penalty。Box 就是每個不可再分的塊,也是我們要定位的物件,在文章佈局裡 box 就是是一個個單詞或者單個字元;Glue 是 Box 之間的空隙,對文字來說就是空格,它們能被拉伸或者壓縮;為防止 Box 被二次分割,所以引入了 Penalty 的概念,常見的 Penalty 就是連字元或者換行符。
看下圖,你發現了嗎,Box 之間的 Glue 寬度是不定的:

圖片的折行問題比文字截斷更簡單。對文字而言,人們可以接受多種截斷方案 —— 在文字之間增加空格;或者增加字間距;還可以使用連字元。但在圖片的場景裡,如果圖片的間隙寬度不同,使用者一定會發覺;也不存在“圖片連字元”的概念。
可以看這裡瞭解更多關於文字折行演算法,本文將不再展開。回到圖片的話題,我們將會用剛剛提及的演算法來實現我們的圖片折行。
為了應用到圖片佈局上,我們想直接拋棄了 Glue 的概念,再簡化 Penalty 的使用,將圖片視為 Box。話雖如此,可能更貼切來說,我們是拋棄了 Box 保留了 Glue,在設想中尺寸可變的是圖片而不是它們的間距。或者乾脆認為我們的 Box 尺寸不變。
不改變圖片間距,我們選擇調整行的高度從而調整佈局。大部分時候,折行都需要額外的空間。提前折行時,為了保證填滿寬度就會增加縱向空間,因為原來的行需要變高;反之,延遲折行時,行的高度會變矮。通過計算所有的可能性,找到最合適的尺寸方案。
現在我們只有三點需要考慮了:理想的行 高 、最大 壓縮 係數(一行的高度可以壓縮到多矮)和最大 拉伸 係數(或者能拉伸到多高)。
演算法原理是:每次檢查一張照片,尋找可能存在的換行點 —— 比如當放大一組照片的時候,它們的高度應該在規定範圍內(maxShrink ≤ 圖片高 ≤ maxStretch)。每當發現一個可以作為換行點的位置時,記下它,在這個位置的基礎上再往後繼續尋找,直到檢查完所有圖片和所有的換行可能性。
比如下面這14張圖片,一行能放下三張或者四張圖片。如果第一行放三張圖片,那麼第二行的換行點可能是第六張或第七張圖片處;假如第一行放四張,那麼第二行的換行點就會在第七或第八的位置。看,前一行的換行點將會決定後面的圖片佈局,不過無論是在哪個位置截斷,總歸都是網格佈局。

最後一步是計算每一行的“壞值 (badness value)”,也就是計算當前換行方案的不理想程度。和我們預設高度相同的行,壞值為0;行高被壓縮/拉伸越厲害,這個值就越大,換言之就是該行的佈局越不理想。最後,通過一些計算將每一行的分數折算為一個值 (稱之為 demerits)。不少文章撰寫過相關的公式,通常是對壞值求和,然後取平方或立方,再加上一些常數。在 Google Photos 中我們用的是求和與最大伸縮值的比例的冪(行高越不理想,demerits 將會越大)。
最終結果是一張“圖“,圖上每個節點表示一張圖片,這個圖片就是換行點,每條邊代表一行(一個節點可能連著多條邊,這說明從一張圖片的後面會多個換行可能性),我們會計算每條邊的值也就是前面的 demerits。
舉個例子,下面有14張圖片,我們希望每行高度是180px,現在視口的寬度是1120px。可以發現,有19種換行方式(19條邊)最終會產生12種不同的佈局效果(12條路徑)。藍線所示是最不壞的方法(我可不敢說是最佳)。跟著這些邊,你會發現底下的組合裡囊括了所有佈局可能性,沒有重複的行也沒有重複的佈局結果。

要找到佈局的最優解(或者說是儘可能優的解)就和找到圖中最短路徑一樣簡單。
幸運的是,我們得到的是有向無環圖 (DAG,圖中沒有重複的節點),這樣最短路徑的計算可以線上性時間內完成(對電腦來說就是“速度快”的意思)。但其實我們可以一邊構建圖一邊尋找最短路徑。
要得到路徑的總長度,只要把每條邊的值加到一起。每當同一節點上出現一條新的邊時,檢查它所在的所有路徑,是否出現了更短的總長度值,如果存在,就把它記下來。
以上面那14張圖為例,檢查過程如下 —— 第一條線表示當前索引到的圖片(一行中的第一張和最後一張圖),下圖表示找到的換行點,以及哪些邊與之相連,當前節點上的最短路徑會用粉紅色標記出來。這是上圖的一種變型表達 —— Box 之間的每一條邊都與獨一無二的行佈局相關。
從第一張圖開始往後找,如果在索引2處設一個換行點,此處的 demerits 為 114。如果在索引3處設換行點,此時的 demerits 就變成了 9483。現在我們需要從這兩個索引出發,再尋找下一個換行點。索引2的下一步在5或者6的位置,經過計算髮現在6處換行,路徑更短(114+1442=1556)。索引3的下一步也可以是6,但由於一開始在3處的換行成本太高了,導致最終在6處的 demerits 高到驚人(9483 +1007=10490)。所以目前的最優路徑是在索引2處截斷,接著在索引6處。在動畫的最後你會看到一開始選擇的到索引11的路徑並不是最優解,在節點8處的才是。

如此往復,直到最後一張圖片(索引13),此時最短路徑也就是最佳佈局方案已經出來了(即上圖中的藍色路線)。
下面左圖是傳統的佈局演算法,右圖是折行優化演算法。它們的理想行高都是180px,仔細觀察,我們可以得到到兩個有趣的結論:傳統演算法總會壓縮行高;優化演算法則是會大膽地增加行高。最終的結果也確實是優化演算法更接近理想高度。

經過測試,FlexLayout 演算法(我們給圖片折行演算法取了個名字)確實能夠生成更理想的網格佈局。它能生成更均勻的網格(每行的高度相差無幾),最後平均行高將會更接近預設的高度。由於 FlexLayout 會考慮不同的排列組合情況,類似於全景照片這樣的極端案例也會有解決方案。如果全景圖被壓縮到非常矮,在 FlexLayout 中該邊的壞值會很高,那麼這條邊肯定不會出現在最終結果裡。而傳統演算法遇到全景(超寬)照片時,它會將該圖視作第一行中的一張圖片,為了把它塞入第一行,就會壓縮地特別矮。
這意味著,存在某些行的高度和預設高度不同,但也不至於偏差很大。
有很多變數都會影響最終結果:圖片的數量是最大影響因素之一;視口寬度和壓縮/拉伸比也很重要。

上圖是 FlexLayout 在窄屏、中等屏和寬屏上實現 25 張圖片的佈局方案將會生成的圖。在窄屏下的換行點可選餘地不多,但會產生的行數很多。隨著螢幕變寬,同一行的換行點可能性變多,相應地行數會減少,佈局的可能性也會減少。
隨著圖片的增多,佈局方案的數量會指數倍的增長。在中等寬的視口裡,不同的圖片數量,對應的路徑數如下:
5 photos =2 paths 10 photos =5 paths 50 photos =24136 paths 75 photos =433144 paths 100 photos = 553389172 paths
如果有1000張圖片,計算機來不及算出佈局方案的數量,但神奇的是卻能立刻找到最佳路徑,雖然它來不及驗證該路徑是否真的是最佳。
但能根據公式推算出最佳佈局,計算每行的換行點可能性的均值,再求立方,計算出行數的總可能性。大部分視口寬度,每行可能有兩三種換行方案,一行可以放五張以上的圖片。通常有 2.5^(圖片數量/5) 種佈局可能。
1000張圖片的組合可能有100...000 (79個0)種;1260張圖片則有10^100種可能。
傳統演算法一次只能輸出一種佈局方案,而 FlexLayout 演算法是同時計算著百萬億萬種方案,從中選中 最好 的一個。
你一定很好奇客戶端/伺服器端能否承載如此巨大的計算量,當然答案是“當然可以”。計算100張照片的最佳佈局耗時2毫秒;1000張照片耗時10毫秒;10000張照片是50毫秒…我們還測試了100,000,000張照片的耗時是1.5秒。傳統演算法在對應場景中的耗時分別是2毫秒、3毫秒、30毫秒和400毫秒,雖然速度更快但體驗比不上 FlexLayout。
一開始我們只想選出最合適的佈局方案,後來我們還能微調網格間距,這樣使用者總能看到最佳的佈局效果。
大家對 FlexLayout 讚不絕口,還實現了安卓和 iOS 的版本,現在包括網頁版在內的三個平臺的實現方案保持同步更新。
最後再分享一個技巧,每一個 section 會被計算兩次:第一次算的是 section 中 segment 的單張照片,維度是照片;第二次算的是 section 中的 segment,維度是 segment。由於可能存在 segment 或圖片數量太少的情況,導致一行都沒有佔滿,所以要計算第二次,此時佈局演算法會建議將不足一行的內容合併,以達到最佳視覺效果。

3. 達到 60fps 的頁面滾動
走到現在我們為實現最佳佈局已經做了不少優化,但如果瀏覽器沒法處理這麼多資料,那之前的工作算是白做了。不過還好,瀏覽器允許開發者們優化頁面渲染。
除了首次頁面載入外,使用者通常在操作頁面的時候會感受到“慢”,特別是滾動。瀏覽器的機制是每秒繪製60幀畫面(也就是 60fps),按照這個速度繪製,使用者才會覺得操作頁面很流暢,反之就會感覺到卡頓。
60fps 的意思是什麼呢?也就是每幀渲染時間不能超過16毫秒 (1/60)。但除了要渲染頁面內容外,瀏覽器還有不少任務 —— 處理事件、解析樣式、計算佈局、將所有元素單位都轉為畫素、最後才是繪製 —— 至少要留下10毫秒。
在這寶貴的10毫秒中,既要保證高效執行完這些工作,還要確保沒有浪費時間。
保持 DOM 尺寸不變
元素太多會影響頁面效能,主要原因有兩重:一是瀏覽器佔用記憶體過多(1000張 50KB 的圖片需要50MB 記憶體,10000張就會佔用 0.5GB 記憶體,足以讓 Chrome 崩潰);還有一點是,元素多說明瀏覽器要做的樣式、佈局和合成工作也越多。

雖然使用者在 Google Photos 中已經存了上千張圖片,但其實一次也只能看到一屏,大部分情況下一屏只能顯示幾十張。
我們認為沒有必要一次性把所有的圖片都載入進頁面,而是監聽使用者對頁面的操作,當滾動頁面時,再顯示出對應位置上的圖片。
有些圖片雖然之前可見,但現在由於頁面滾動,已經被移出了視口,那就把它們拿出來。
即使使用者已經在頁面上瀏覽過成百上千張照片,但由於視口的限制,每次需要渲染的圖片卻都不會超過50張。這樣的策略下,使用者的互動總能得到及時的響應,瀏覽器也不容易發生崩潰。
幸好事先把圖片按照 segment 和 section 的維度分好了組,現在不需要操作單張圖片,可以一次性掛載/掛起完整的模組。
變數最小化
在 Google Developers 上有很多聊到渲染效能的好文章,還有不少教程指導如何使用 Chrome 中內建的效能檢測工具。這裡我將快速介紹 Google Photos 中用到的一些技巧,更多細節還請各位訪問 Google Developers。首先來了解一下頁面渲染的生命週期:

每當頁面出現變化時(通常是通過 JS 觸發的,但也有被樣式或者動畫引發的場景),瀏覽器會先確認具體是哪些 樣式 產生的改變,重新計算元素 佈局 (尺寸和位置),接著重新 繪製 受到影響的所有元素(比如將文字、圖片…轉為畫素)。為了提高頁面內容的更新效率,瀏覽器通常會將元素分到不同的 層 中,以層為單位繪製,最後一步是層的 合成 。
大部分情況下,瀏覽器已經夠聰明的了,你可能都想不起這條渲染管道。但假如頁面的內容變動太頻繁(比如持續增/減圖片),那就要小心了。

為了儘可能縮小頁面的變化範圍,我們讓所有的子元素都相對它們的父元素定位。section 是絕對定位於整個網格佈局的,segment 相對它所在的 section 絕對定位。依次類推,圖片就是絕對定位於它所屬的 segment。
將全部元素都做定位佈局後,當我們需要改變一個 section 的尺寸(實際高度和預估高度往往不同,就會出現這樣的更新)時,在它物理位置之下的所有元素只需要修改 top 值即可。這種佈局方式能避免不少不必要的 DOM 更新。
CSS 的contain 屬效能定義某個元素的獨立程度,這樣瀏覽器就知道該元素會多大程度上影響上下文的其他內容。所以我們給 section 和 segment 都加上這個屬性:
/* 元素內外部內容不會相互影響 */ contain: layout;
還有一些比較好處理的效能問題,比如單幀內會觸發好幾次滾動事件,瀏覽器視窗縮放的時候也會連續觸發滾動。如果佈局持續地在發生變化,那麼在最開始變化的時候,瀏覽器可以不用重新計算樣式和佈局。
幸好,這個預設行為可以通過 API/window/requestAnimationFrame" target="_blank" rel="nofollow,noindex">window.requestAnimationFrame(callback) 禁止,這個方法的作用是在下一幀發生前執行回撥函式。在滾動和縮放事件處理中,我們可以通過它先執行回撥函式而不是直接更新佈局;視窗縮放要做的事稍微複雜一點:在使用者確定最終視窗大小的半秒之後,再執行更新。
第二個常見的問題是佈局抖動。當瀏覽器需要計算佈局的時候,它會先把快取佈局,這樣後面就能迅速找到元素的寬度、高度和佈局資訊。但是,一旦能影響佈局的屬性發生改變(比如寬高、top 或者 left …的定位屬性),先前的佈局快取就會立刻失效;再讀取佈局屬性時,瀏覽器會強行重新計算佈局(同一幀內會發生多次這樣的反覆計算)。
在有大量元素迴圈佈局的場景下(比如幾百張圖片)就會出現問題。讀一個佈局屬性,就要改變佈局(把圖片或者 section 挪到正確的位置),接著又讀一個佈局屬性觸發新一輪的佈局計算。
一個簡單的方案就能避免上述問題:一次性讀取所有的的值,再一次性更新(也就是將讀與寫分開,並做批處理)。不過我們的方式是避免讀值,記錄每張照片的尺寸和位置,絕對定位它們。當滾動或視窗縮放發生時,我們就根據所記錄的照片資訊再執行所有計算。這種更新方法就不會產生抖動。下圖是頁面滾動更新了一幀時的效能情況(可以看到沒有出現重複的渲染管道中的環節):

避免程式碼持續執行
由於 Web Workers 的出現,還有原生非同步方法(比如 Fetch)的支援,一個標籤頁只有一個執行緒,也就是同一個標籤頁中的程式碼都在一個執行緒中執行 —— 包括渲染和 JS。這就意味著如果有程式碼(比如一個長執行的滾動事件方法)阻塞了頁面的渲染,那使用者體檢將會極差。
我們的解決方案裡最耗時的是建立佈局和元素。這兩個操作得在一定時間完成才不會影響到使用者。
打個比方,1000張圖片佈局花10毫秒,10000張圖片需要50毫秒,這可就把60毫秒的更新時間給花光了。但是因為我們把圖片分成了 section 還有 segment,這樣一次只需要花2~3毫秒更新幾百張圖片就行了。
最“昂貴”的佈局事件就是視窗縮放了 —— 每一個 section 都要被需要重新。我們乾脆用回了最初的演算法 —— 即使有的 section 已經被載入好了,我們也不做處理,只對可視位置的 section 使用 FlexLayout 演算法。等到其他 section 被滾動到視口範圍時再重新計算。
建立元素時用的也是這個邏輯 —— 我們只在圖片即將被看到之前才進行佈局計算。
結果
做了這麼多事情,我們總算得到了還不錯的佈局方案 —— 大部分情況下能達到 60fps,雖然掉幀偶爾還會出現。
掉幀通常發生在主要的佈局場景中(比如插入一個全新的 section),或者瀏覽器要回收特別舊的元素的時候。

4. 瞬間之感
我相信大部分前端工程師都會在 UI 上花不少心思炫炫技,比如放點禮花特效之類的。
其中我最愛的“小心機”是一位 YouTube 的同事想到的。他們在處理進度條的時候(頁面最頂端的一根紅條),並不是用真實的頁面載入進度(當時也沒有確切的進度資訊),但用動畫模擬出了“正在載入”的體驗,直到頁面真正載入完成的同時,這條紅線才會到達最右端。我不確定現在的 YouTube 是否把載入動畫和頁面實際載入進度對應起來了,但它的整體思路是這樣的。

載入進度的精確性是次要的,最重要的是要讓使用者切實感受到,這個頁面進度是在往前走著的。
這一節中我將會分享一些技巧,讓使用者覺得 Google Photos 用起來很流暢(比真實情況要更流暢)—— 大部分技巧都和圖片載入有關。
第一件事,也可能是最有效的,使用者最可能看到的內容會被最先載入。

在載入好視口範圍內的圖片後,還會再額外載入一屏圖片,為了保證下次使用者滾動頁面時能立刻看到新的圖片。
但是對於 HDPI 螢幕(在這樣的螢幕下我們需要載入更大尺寸的縮圖),在快速滾動頁面的時候,響應所有的請求就比較困難了。
於是我們優化了載入方案 —— 先載入未來四五屏內的佔位圖,這些圖片往往非常小,所以立刻就能載入好。當這些圖片快要被移動到視口的時候,再載入原圖。
這意味著如果使用者以正常的速度慢慢滾動頁面瀏覽圖片,他就看不到視口以外照片的載入過程了;但也存在飛快滾動頁面為了尋找某張圖片的場景,那使用者看到的就會是圖片的縮圖,感受到的是大致的資訊。
為了獲取頁面內容總會有不必要的工作要做,但同時還要提供流暢的使用者體驗,這是一個複雜的權衡遊戲。
我們考慮了以下幾個因素。首先要檢查頁面滾動方向,要預載入的是使用者即將看到的內容;還會根據使用者滾動頁面的速度識別是否要載入高清原圖,如果發現使用者只是在飛速地瀏覽圖片,那載入原圖也就沒有必要了;甚至當頁面滾動速度快到一定程度,連低解析度的佔位圖都不用載入了。
無論載入的是原圖還是低解析度的佔位圖,都會有縮放圖片的場景。現在的顯示屏基本都是高清屏,常見的做法是載入一張兩倍於佔位尺寸大小的圖片,然後縮小一半放到對應位置上(這樣做,實際一個畫素就能承載兩倍的資訊量)。對於低分辨佔位圖來說,我們可以請求非常小且壓縮率很高(比如壓縮率75%)的資源,然後放大它們。
以這隻快睡著了的豹子為例,左邊的圖片是在網格佈局裡完全載入好以後我們會看到的(它已經被縮小到實際圖片尺寸的一半了),右圖是一張低解析度的佔位圖(還被放大了到佔位尺寸),當用戶飛速劃過時就會看到這樣的佔位圖。

也請注意圖片的檔案大小,壓縮後的高清縮圖有 71.2KB,低解析度的佔位圖經過同樣的壓縮演算法大小是 889B,僅僅佔高清原圖的 1/80!換算一下,一張高清原圖的流量頂的上四頁佔位圖了。
用很少的流量增加換取更好的使用者體驗,佔位圖可以讓使用者感受到網頁內容的豐富,還提供了瀏覽時的視覺參考。
最後要考慮的一點是,瀏覽器要如何渲染低解析度的佔位圖。預設情況下,當一張很小的圖片被拉大的時候瀏覽器會做畫素平滑處理(下圖中間),但視覺效果並不太好。如果用模糊來處理(下圖最右)效果會好很多。但濾鏡非常影響頁面效能,如果同時給上百張圖片都加上濾鏡,那頁面效能會差到無法想象。所以我們選了另一條路,讓瀏覽器以畫素化的方式處理這些圖片(如最左),不過我不確定現在的 Google Photos 是不是依然使用這個方案,這部分有經過改版。

如果希望使用者永遠不要看到低解析度的圖片(除了快速滾動這樣實在無法避免的場景外),特別是在即將進入視口,高清原圖即將替換掉佔位圖的時間交接點,之前我們用動畫來完成這個過渡(避免直接替換圖片太突兀)。具體實現起來就是把佔位圖和原圖疊加在一起,當需要顯示原圖的時候將佔點陣圖從不透明漸變到全透明 —— 常見的過渡手段之一,Medium 中的文章配圖也是這麼顯示的。現在的 Google Photos 可能已經去掉了這個過渡邏輯,但從空網格到有內容的過程可能依然在使用這個效果。
這樣的視覺體驗會讓使用者感受到這張圖片正在載入,這個動畫持續100毫秒 —— 足以在這段時間內載入上原圖,下圖是慢速播放的動畫,方便大家觀察:

另一個地方也用到了這個技巧:縮圖展開到全屏預覽。當用戶點選縮圖的時候,我們立刻開始載入原圖,在等待原圖的同時,將縮圖放大並定位到螢幕中間,原圖載入好時,再用改變透明度的方法顯示出原圖。與縮圖載入不同的是,這次只要操作一張圖片,所以用上了模糊濾鏡(畫素化的體驗肯定是比不上模糊效果的)。

無論是滾動頁面瀏覽圖片,還是在縮圖模式與全屏預覽模式間的切換,我們總是希望使用者能感受到,雖然最終結果尚未準備好,但瀏覽器正在努力處理任務。與這種互動理念相反的表現是,當用戶點選縮圖的時候,螢幕上沒有任何反饋甚至白屏,直到原圖完全被載入好。
空 section 也用上了這一理念。我們的網格佈局只有在需要顯示 section 的時候,才會去載入它(也存在預載入好的一些圖片)。如果使用者直接拖動滾動條,就會看到還沒有載入好的 section 部分,雖然已經預留了空間,但當用戶瀏覽到這個位置時,還對將看到什麼圖片和什麼樣的佈局沒有心理準備。
為了讓滾動體驗更自然,我們將這些預留好空間的 section 的高度設定為目標行高,並填充上顏色以表示佔位。在載入剛剛開始的時候,section 看起來就是一條條灰色的長矩形(下圖最左),最近改版成了下圖最右那樣有行有列的,更接近一張張圖片。下圖中間表示的是已經載入好但是圖片還沒有渲染出來的 section。

這樣的圖片載入過程就像追蹤獸跡一樣,下次使用 Google Photos 的時候試試看分辨這些狀態吧。
section 的佔位色塊不是用圖片而是用 CSS 實現的,所以即使隨意改變寬高,也不會有變形或裁剪:
/* 在 section 載入好之前,佔位的寬高比是 4:3 */ background-color: #eee; background-image: linear-gradient(90deg, #fff 0, transparent 0, transparent 294px, #fff 294px, #fff), linear-gradient(0deg,#fff 0, transparent 0, transparent 220px, #fff 220px, #fff); background-size: 298px 224px; background-position: 0 0, 0 -4px;
除此之外我們還有不少小技巧,大多是和優化請求順序有關的。比如,我們不會一次性就請求100張縮圖,而是分成10批,一次請求10張。所以如果使用者突然開始飛速滾動頁面,不至於浪費後面90張的流量。類似的邏輯還有,總會優先請求視口區域內的圖片,視口外的圖片稍微等等。
甚至我們還會複用尺寸近似的縮圖 —— 比如使用者縮放視窗後,網格佈局並沒有發生本質上的改變,只是行數和之前不同了。這種情況下我們不會重新下載另一個尺寸的縮圖,而是將已有的圖片進行縮放,只有當視窗尺寸被完全改變的時候,才會重新請求圖片。
結論
Google Photos 考慮了大量的使用者體驗細節,網格佈局僅僅是其中的冰山一角
乍看之下僅僅是簡單甚至是靜態的佈局,但實際上網格一直在實時變化著 —— 載入、預抓取、動畫、建立、移除…盡它所能帶給使用者最好的體驗。
團隊總會優先考慮保證並提高產品的效能。Google Photos 團隊通過滾動幀率、模組載入頻率…等指標實時監控著產品的體驗,Google Photos 一直在前進啊。
下面是一段滾動 Google Photos 頁面的錄屏。當用戶慢慢瀏覽頁面時,能看到清晰的縮圖;當提高滾動速度時,看到的就是畫素化的佔位圖,當再次回到慢速滾動時高清圖又顯示出來了;而飛速劃過頁面時,看到的就是灰色的佔位色塊了。 滾動速度不同載入效果不同 :

感謝我在 Google Photos 時的領導 Vincent Mo ,他一直非常支援我們,而且本文中所用到的照片都是由他拍攝的(產品測試階段同樣也用了 Vincent 拍的照片)。感謝 Jeremy Selier ,Google Photos Web 端的負責人,現在他正帶領著團隊持續維護並提升 Google Photos Web 端的體驗。