見微知著,Google Photos Web UI 完善之旅

見微知著,Google Photos Web UI 完善之旅

已獲翻譯授權,原文地址: Building the Google Photos Web UI
原文深入淺出,推薦閱讀。

幾年前我有幸以工程師的身份加入Google Photos 團隊,並參與了2015 年發布的第一個版本。不計其數的設計師、產品經理、學者還有工程師(包括了各平台、前後端)投入其中,這裡列出的只是幾個主要職責。我所負責的是Web UI 部分,更精確點來說,我負責了照片的網格佈局。

我們立下雄心壯志,要做出完美的佈局方案:支持全屏自適應、保證原圖比例、交互便捷(比如用戶可以跳轉到指定的位置)、既展現海量圖片又保證頁面的高性能高速加載

當時,市面上還沒有任何相冊產品能實現以上所有效果。據我所知,到目前為止也尚未出現能和Google Photos 相媲美的產品。特別是在頁面佈局和圖片比例上,大部分產品依然將圖片裁剪成正方形以保證佈局優美。

下面我將會分享我們是如何完成這些挑戰,以及Web 版的Google Photos 中的一些技術細節。

為什麼這個任務如此艱難?

有兩個和'size' 相關的難關。

第一個'size' 挑戰來自於龐大的圖片量(有些用戶上傳了超過25萬張圖片),大量的元數據存儲在服務器中。即便單張圖片要傳遞的訊息量(比如圖片url、寬高、時間戳…)並不多,但由於圖片數量非常多,直接導致頁面的加載時間變長。

第二個'size' 問題在圖片自身。現代高清屏上,一張小照片也至少有50KB,1000張這樣的照片就有50MB。不僅服務器傳輸數據會很慢,更糟糕的是一次性渲染這麼多內容,瀏覽器容易崩潰。早期的Google+ Photos 加載1000~2000張圖片時就會變卡,加載10000張圖片時瀏覽器標籤頁就直接崩潰。

下面我將分成四個部分回溯我們是如何解決這兩個問題的:

  1. “獨立”的圖片—迅速定位到圖片庫中的指定位置。
  2. 自適應佈局—根據瀏覽器寬度,盡可能鋪滿圖片且要保留圖片的原始比例(不做正方形裁剪)。
  3. 60fps的流暢滾動—巨大數據量面前,也要保證頁面交互的流暢。
  4. 及時反饋—加載時間最小化。

1. “獨立”的圖片

相信大家也見過不少大量數據的展現方案。比如最傳統的分頁,每一頁展示固定的結果數,通過點擊“下一頁”獲取新的數據,往復向後就能看到所有的結果;現在更流行的方法是無限滾動,一次加載定量的數據,當用戶滾動頁面接近當前數據末端時自動拉取新數據,插入頁面。如果整個過程足夠流暢,就能一直往下滾動頁面—— 所謂的無限滾動。

但分頁和無限滾動都存在一個問題:在加載完所有數據後,如果用戶想要尋找最開始的某一張照片—— 一個噩夢。

對大部分頁面來說,用戶還能通過滾動條定位。但對分頁來說,滾動條頂多能定位到當前頁面的底端,而不是整個圖片庫的最後一張;無限滾動呢,滾動條的位置永遠在變,除非數據全部都傳到客戶端了,不然別想用滾動條觸底。

獨立圖片網格提供了另一種思路,在這個方案裡滾動條將正常表現

為了讓用戶能夠使用滾動條去定位到指定位置,我們需要將頁面空間預留好。假如用戶的所有照片能夠一次性被傳過來,還挺好實現;但問題是數據量大到無法一次搞定。看來我們需要試試其他的方法了。

這也是其他圖片庫需要面對的問題,為了提前佈局,常見的解決方案是把所有圖片都做方形裁剪。這個方法只需要知道總圖片數:用視口寬度除以確定的方形佔位尺寸,得到列數,再通過總圖片數,進而得到行數。

constcolumns=Math.floor(viewportWidth/(thumbnailSize+thumbnailMargin));constrows=Math.ceil(photoCount/columns);constheight=rows*(thumbnailSize+thumbnailMargin);

三行代碼就能實現,不出十二行代碼就能搞定整體佈局。

為了減少首次傳送元數據,我們想到的是將用戶的照片分成獨立的模塊,首次加載時只傳送模塊名和每個模塊下照片的數量。舉個例子,以“月”為維度劃分模塊—— 這一步可以在服務器端實現(也就是提前計算好)。如果數據量達到百萬級別,甚至可以以“十年”為單位來統計。首次加載時所用的數據大概是這個樣子的:

{ "2014_06" : 514 , "2014_05" : 203 , "2014_04" : 1678 , "2014_03" : 973 , "2014_02" : 26 , // ... "1999_11" : 212 }

如果由用戶(比如攝影師)在同一個時間段內就能產出大量圖片,這個方案還是有缺陷的—— 將數據分為一個個模塊的原因是方便處理元數據,但對於重度用戶來說,每個月的數據量依然極大。偉大的基礎服務團隊想到了解決方案—— 允許用戶創建自定義的分類方式(比如地點、時間戳...)。

網格佈局由section、segment 和單張圖片組成

有了這些訊息之後,我們就能給每個模塊佔位了。當用戶快速滾動頁面時,客戶端獲取到對應的圖片元數據,計算出完整的佈局並更新頁面。

在瀏覽器端,拿到了模塊的元數據後,我們會將照片按照日期度再做一次整理。我們討論過的動態分組(比如根據位置、人物、日期…)也將是很棒的特性。

現在預估模塊的尺寸就很簡單了,通過照片數量和預估的單張照片的比例後,進行計算:

// 理想情况下,我们应该先计算出当前模块的比例均值// 不过我们先假设照片比例是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優化後(他們進一步比較,在即將超過視口寬度時是少放一張圖片,還是多放一張圖片效果更好)將他們的方案開源。簡化版如下:

letrow=[];letcurrentWidth=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 寬度是不定的:

文本的佈局—— 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條路徑)。藍線所示是最不壞的方法(我可不敢說是最佳)。跟著這些邊,你會發現底下的組合裡囊括了所有佈局可能性,沒有重複的行也沒有重複的佈局結果。

14張圖片的佈局可能性

要找到佈局的最優解(或者說是盡可能優的解)就和找到圖中最短路徑一樣簡單。

幸運的是,我們得到的是有向無環圖(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處的才是。

尋找14張圖片佈局的最優解

如此往復,直到最後一張圖片(索引13),此時最短路徑也就是最佳佈局方案已經出來了(即上圖中的藍色路線)。

下面左圖是傳統的佈局算法,右圖是折行優化算法。它們的理想行高都是180px,仔細觀察,我們可以得到到兩個有趣的結論:傳統算法總會壓縮行高;優化算法則是會大膽地增加行高。最終的結果也確實是優化算法更接近理想高度。

理想行高是180px,比較兩種佈局算法

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

這意味著,存在某些行的高度和預設高度不同,但也不至於偏差很大。

有很多變量都會影響最終結果:圖片的數量是最大影響因素之一;視口寬度和壓縮/拉伸比也很重要。

25張圖片在不同的視口尺寸下的佈局

上圖是FlexLayout 在窄屏、中等屏和寬屏上實現25 張圖片的佈局方案將會生成的圖。在窄屏下的換行點可選餘地不多,但會產生的行數很多。隨著屏幕變寬,同一行的換行點可能性變多,相應地行數會減少,佈局的可能性也會減少。

隨著圖片的增多,佈局方案的數量會指數倍的增長。在中等寬的視口裡,不同的圖片數量,對應的路徑數如下:

5photos=2paths10photos=5paths50photos=24136paths75photos=433144paths100photos=553389172paths

如果有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。首先來了解一下頁面渲染的生命週期:

Chrome 像素管道

每當頁面出現變化時(通常是通過JS觸發的,但也有被樣式或者動畫引發的場景),瀏覽器會先確認具體是哪些樣式產生的改變,重新計算元素佈局(尺寸和位置),接著重新繪製受到影響的所有元素(比如將文本、圖片…轉為像素)。為了提高頁面內容的更新效率,瀏覽器通常會將元素分到不同的中,以層為單位繪製,最後一步是層的合成

大部分情況下,瀏覽器已經夠聰明的了,你可能都想不起這條渲染管道。但假如頁面的內容變動太頻繁(比如持續增/減圖片),那就要小心了。

section、segment 和圖片都是絕對定位的

為了盡可能縮小頁面的變化範圍,我們讓所有的子元素都相對它們的父元素定位。 section是絕對定位於整個網格佈局的,segment相對它所在的section絕對定位。依次類推,圖片就是絕對定位於它所屬的segment。

將全部元素都做定位佈局後,當我們需要改變一個section 的尺寸(實際高度和預估高度往往不同,就會出現這樣的更新)時,在它物理位置之下的所有元素只需要修改top 值即可。這種佈局方式能避免不少不必要的DOM 更新。

CSS的contain屬性能定義某個元素的獨立程度,這樣瀏覽器就知道該元素會多大程度上影響上下文的其他內容。所以我們給section 和segment 都加上這個屬性:

/* 元素内外部内容不会相互影响 */contain:layout;

還有一些比較好處理的性能問題,比如單幀內會觸發好幾次滾動事件,瀏覽器窗口縮放的時候也會連續觸發滾動。如果佈局持續地在發生變化,那麼在最開始變化的時候,瀏覽器可以不用重新計算樣式和佈局。

幸好,這個默認行為可以通過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,#fff0,transparent0,transparent294px,#fff294px,#fff),linear-gradient(0deg,#fff0,transparent0,transparent220px,#fff220px,#fff);background-size:298px224px;background-position:00,0-4px;

除此之外我們還有不少小技巧,大多是和優化請求順序有關的。比如,我們不會一次性就請求100張縮略圖,而是分成10批,一次請求10張。所以如果用戶突然開始飛速滾動頁面,不至於浪費後面90張的流量。類似的邏輯還有,總會優先請求視口區域內的圖片,視口外的圖片稍微等等。

甚至我們還會復用尺寸近似的縮略圖—— 比如用戶縮放窗口後,網格佈局並沒有發生本質上的改變,只是行數和之前不同了。這種情況下我們不會重新下載另一個尺寸的縮略圖,而是將已有的圖片進行縮放,只有當窗口尺寸被完全改變的時候,才會重新請求圖片。

結論

Google Photos 考慮了大量的用戶體驗細節,網格佈局僅僅是其中的冰山一角

乍看之下僅僅是簡單甚至是靜態的佈局,但實際上網格一直在實時變化著—— 加載、預抓取、動畫、創建、移除…盡它所能帶給用戶最好的體驗。

團隊總會優先考慮保證並提高產品的性能。 Google Photos 團隊通過滾動幀率、模塊加載頻率…等指標實時監控著產品的體驗,Google Photos 一直在前進啊。

下面是一段滾動Google Photos 頁面的錄屏。當用戶慢慢瀏覽頁面時,能看到清晰的縮略圖;當提高滾動速度時,看到的就是像素化的佔位圖,當再次回到慢速滾動時高清圖又顯示出來了;而飛速劃過頁面時,看到的就是灰色的佔位色塊了。 滾動速度不同加載效果不同

https://www.youtube.com/watch?v=d57mzcSrSQw&feature=youtu.be

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

What do you think?

Written by marketer

在深談TCP/IP三步握手&四步揮手原理及衍生問題—長文解剖IP——節選版

像呼吸一樣自然:React hooks + RxJS