見(jiàn)微知著,Google Photos Web UI 完善之旅
時(shí)間:2022-08-12 10:30:01 | 來(lái)源:網(wǎng)站運(yùn)營(yíng)
時(shí)間:2022-08-12 10:30:01 來(lái)源:網(wǎng)站運(yùn)營(yíng)
已獲翻譯授權(quán),原文地址:Building the Google Photos Web UI。
原文深入淺出,推薦閱讀。
幾年前我有幸以工程師的身份加入 Google Photos 團(tuán)隊(duì),并參與了 2015 年發(fā)布的第一個(gè)版本。不計(jì)其數(shù)的設(shè)計(jì)師、產(chǎn)品經(jīng)理、學(xué)者還有工程師(包括了各平臺(tái)、前后端)投入其中,這里列出的只是幾個(gè)主要職責(zé)。我所負(fù)責(zé)的是 Web UI 部分,更精確點(diǎn)來(lái)說(shuō),我負(fù)責(zé)了照片的網(wǎng)格布局。
我們立下雄心壯志,要做出完美的布局方案:支持
全屏自適應(yīng)、保證原圖比例、交互
便捷(比如用戶(hù)可以跳轉(zhuǎn)到指定的位置)、既展現(xiàn)海量圖片又保證頁(yè)面的
高性能和
高速加載。
當(dāng)時(shí),市面上還沒(méi)有任何相冊(cè)產(chǎn)品能實(shí)現(xiàn)以上所有效果。據(jù)我所知,到目前為止也尚未出現(xiàn)能和 Google Photos 相媲美的產(chǎn)品。特別是在頁(yè)面布局和圖片比例上,大部分產(chǎn)品依然將圖片裁剪成正方形以保證布局優(yōu)美。
下面我將會(huì)分享我們是如何完成這些挑戰(zhàn),以及 Web 版的 Google Photos 中的一些技術(shù)細(xì)節(jié)。
為什么這個(gè)任務(wù)如此艱難?
有兩個(gè)和 'size' 相關(guān)的難關(guān)。
第一個(gè) 'size' 挑戰(zhàn)來(lái)自于龐大的圖片量(有些用戶(hù)上傳了超過(guò)25萬(wàn)張圖片),大量的元數(shù)據(jù)存儲(chǔ)在服務(wù)器中。即便單張圖片要傳遞的信息量(比如圖片url、寬高、時(shí)間戳…)并不多,但由于圖片數(shù)量非常多,直接導(dǎo)致頁(yè)面的
加載時(shí)間變長(zhǎng)。
第二個(gè) 'size' 問(wèn)題在圖片自身?,F(xiàn)代高清屏上,一張小照片也至少有 50KB,1000張這樣的照片就有 50MB。不僅服務(wù)器傳輸數(shù)據(jù)會(huì)很慢,更糟糕的是一次性渲染這么多內(nèi)容,瀏覽器容易崩潰。早期的 Google+ Photos 加載1000~2000張圖片時(shí)就會(huì)變卡,加載10000張圖片時(shí)瀏覽器標(biāo)簽頁(yè)就直接崩潰。
下面我將分成四個(gè)部分回溯我們是如何解決這兩個(gè)問(wèn)題的:
- “獨(dú)立”的圖片 — 迅速定位到圖片庫(kù)中的指定位置。
- 自適應(yīng)布局 — 根據(jù)瀏覽器寬度,盡可能鋪滿(mǎn)圖片且要保留圖片的原始比例(不做正方形裁剪)。
- 60fps 的流暢滾動(dòng) — 巨大數(shù)據(jù)量面前,也要保證頁(yè)面交互的流暢。
- 及時(shí)反饋 — 加載時(shí)間最小化。
1. “獨(dú)立”的圖片
相信大家也見(jiàn)過(guò)不少大量數(shù)據(jù)的展現(xiàn)方案。比如最傳統(tǒng)的
分頁(yè),每一頁(yè)展示固定的結(jié)果數(shù),通過(guò)點(diǎn)擊“下一頁(yè)”獲取新的數(shù)據(jù),往復(fù)向后就能看到所有的結(jié)果;現(xiàn)在更流行的方法是
無(wú)限滾動(dòng),一次加載定量的數(shù)據(jù),當(dāng)用戶(hù)滾動(dòng)頁(yè)面接近當(dāng)前數(shù)據(jù)末端時(shí)自動(dòng)拉取新數(shù)據(jù),插入頁(yè)面。如果整個(gè)過(guò)程足夠流暢,就能一直往下滾動(dòng)頁(yè)面 —— 所謂的無(wú)限滾動(dòng)。
但分頁(yè)和無(wú)限滾動(dòng)都存在一個(gè)問(wèn)題:在加載完所有數(shù)據(jù)后,如果用戶(hù)想要尋找最開(kāi)始的某一張照片 —— 一個(gè)噩夢(mèng)。
對(duì)大部分頁(yè)面來(lái)說(shuō),用戶(hù)還能通過(guò)滾動(dòng)條定位。但對(duì)分頁(yè)來(lái)說(shuō),滾動(dòng)條頂多能定位到當(dāng)前頁(yè)面的底端,而不是整個(gè)圖片庫(kù)的最后一張;無(wú)限滾動(dòng)呢,滾動(dòng)條的位置永遠(yuǎn)在變,除非數(shù)據(jù)全部都傳到客戶(hù)端了,不然別想用滾動(dòng)條觸底。
獨(dú)立圖片網(wǎng)格提供了另一種思路,在這個(gè)方案里滾動(dòng)條將正常表現(xiàn)
為了讓用戶(hù)能夠使用滾動(dòng)條去定位到指定位置,我們需要將頁(yè)面空間預(yù)留好。假如用戶(hù)的所有照片能夠一次性被傳過(guò)來(lái),還挺好實(shí)現(xiàn);但問(wèn)題是數(shù)據(jù)量大到無(wú)法一次搞定??磥?lái)我們需要試試其他的方法了。
這也是其他圖片庫(kù)需要面對(duì)的問(wèn)題,為了提前布局,常見(jiàn)的解決方案是把所有圖片都做方形裁剪。這個(gè)方法只需要知道總圖片數(shù):用視口寬度除以確定的方形占位尺寸,得到列數(shù),再通過(guò)總圖片數(shù),進(jìn)而得到行數(shù)。
const columns = Math.floor(viewportWidth / (thumbnailSize + thumbnailMargin));const rows = Math.ceil(photoCount / columns);const height = rows * (thumbnailSize + thumbnailMargin);
三行代碼就能實(shí)現(xiàn),不出十二行代碼就能搞定整體布局。
為了減少首次傳送元數(shù)據(jù),我們想到的是將用戶(hù)的照片分成獨(dú)立的模塊,首次加載時(shí)只傳送模塊名和每個(gè)模塊下照片的數(shù)量。舉個(gè)例子,以“月”為維度劃分模塊 —— 這一步可以在服務(wù)器端實(shí)現(xiàn)(也就是提前計(jì)算好)。如果數(shù)據(jù)量達(dá)到百萬(wàn)級(jí)別,甚至可以以“十年”為單位來(lái)統(tǒng)計(jì)。首次加載時(shí)所用的數(shù)據(jù)大概是這個(gè)樣子的:
{ "2014_06": 514, "2014_05": 203, "2014_04": 1678, "2014_03": 973, "2014_02": 26, // ... "1999_11": 212}
如果由用戶(hù)(比如攝影師)在同一個(gè)時(shí)間段內(nèi)就能產(chǎn)出大量圖片,這個(gè)方案還是有缺陷的 —— 將數(shù)據(jù)分為一個(gè)個(gè)模塊的原因是方便處理元數(shù)據(jù),但對(duì)于重度用戶(hù)來(lái)說(shuō),每個(gè)月的數(shù)據(jù)量依然極大。偉大的基礎(chǔ)服務(wù)團(tuán)隊(duì)想到了解決方案 —— 允許用戶(hù)創(chuàng)建自定義的分類(lèi)方式(比如地點(diǎn)、時(shí)間戳...)。
網(wǎng)格布局由 section、segment 和單張圖片組成有了這些信息之后,我們就能給每個(gè)模塊占位了。當(dāng)用戶(hù)快速滾動(dòng)頁(yè)面時(shí),客戶(hù)端獲取到對(duì)應(yīng)的圖片元數(shù)據(jù),計(jì)算出完整的布局并更新頁(yè)面。
在瀏覽器端,拿到了模塊的元數(shù)據(jù)后,我們會(huì)將照片按照日期度再做一次整理。我們討論過(guò)的動(dòng)態(tài)分組(比如根據(jù)位置、人物、日期…)也將是很棒的特性。
現(xiàn)在預(yù)估模塊的尺寸就很簡(jiǎn)單了,通過(guò)照片數(shù)量和預(yù)估的單張照片的比例后,進(jìn)行計(jì)算:
// 理想情況下,我們應(yīng)該先計(jì)算出當(dāng)前模塊的比例均值// 不過(guò)我們先假設(shè)照片比例是 3:2,// 然后在它的基礎(chǔ)上做一些調(diào)整const unwrappedWidth = (3 / 2) * photoCount * targetHeight * (7 / 10);const rows = Math.ceil(unwrappedWidth / viewportWidth);const height = rows * targetHeight;
你可能猜到了,這樣的估算結(jié)果并不準(zhǔn)確,甚至偏差相當(dāng)大。
我一開(kāi)始把問(wèn)題復(fù)雜化了(
布局環(huán)節(jié)將會(huì)詳細(xì)聊到),但從結(jié)果來(lái)看一開(kāi)始也未必需要得到準(zhǔn)確的數(shù)值(在照片數(shù)量很大的情況下,甚至能偏差上千像素)。我們之所以要做估算,也是為了保證滾動(dòng)條位置,事實(shí)證明即使如此粗略,滾動(dòng)條的定位依然能用。
這里有個(gè)小技巧,當(dāng)模塊真正被加載出來(lái)的時(shí)候,瀏覽器也就知道了實(shí)際需要的占位高度和預(yù)估占位高度之間的差,只要直接將頁(yè)面剩余模塊向下移動(dòng)高度差的距離就行了。
如果要加載的模塊在視口之上,那么模塊加載好后還需要更新滾動(dòng)條的位置。所有的更新操作可以在一秒內(nèi)用一個(gè)動(dòng)畫(huà)幀完成,對(duì)用戶(hù)造成的影響并不大,速度如果夠快用戶(hù)甚至是無(wú)感知的。
2. 自適應(yīng)布局
據(jù)我所知,市面上主流的圖片自適應(yīng)布局都采用了一種巧妙由又簡(jiǎn)便的方法:每行高度不同但都占滿(mǎn)視口,同一行內(nèi)的圖片根據(jù)寬高比縮放,以確保同一行內(nèi)的圖片高度。用戶(hù)也不會(huì)容易注意到行與行之間的高度差。
放棄把所有圖片的高度都變成一樣的,保證原圖的比例,再固定圖片之間的間距。實(shí)現(xiàn)起來(lái)也不難,找到最高的行,按照寬高比縮放每張照片,更新當(dāng)前網(wǎng)格寬度,如果發(fā)現(xiàn)要超過(guò)視口寬度了,就按照比例縮小該行內(nèi)每一張圖片,當(dāng)然此時(shí)這一行的高度也會(huì)變小。
比如有14張圖片的時(shí)候:
這個(gè)方法性?xún)r(jià)比很高,Google+ 過(guò)去也是用這個(gè)方法,Google 搜索用的是這個(gè)方法的一種改良,但也還是相同的理念。Flickr 優(yōu)化后(他們進(jìn)一步比較,在即將超過(guò)視口寬度時(shí)是少放一張圖片,還是多放一張圖片效果更好)將他們的方案開(kāi)源。簡(jiǎn)化版如下:
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);
起初我(其實(shí)是多余地)擔(dān)心著估算值和最終值偏差甚遠(yuǎn),把問(wèn)題想得越來(lái)越復(fù)雜。不過(guò)這期間,我意外地找到了解決方案。
我的理念是:圖片網(wǎng)格布局和文字折行問(wèn)題異曲同工。參考了有完整文檔支持的 Knuth & Plass 折行算法,我打算將它運(yùn)用到圖片布局上來(lái)。
和文字折行不同的是,在圖片布局上我們要以模塊為單位考慮問(wèn)題,模塊內(nèi)的每一行都會(huì)影響到它們之后的行的布局。
K&P 算法的基礎(chǔ)單位是 box、glue 和 penalty。Box 就是每個(gè)不可再分的塊,也是我們要定位的對(duì)象,在文章布局里 box 就是是一個(gè)個(gè)單詞或者單個(gè)字符;Glue 是 Box 之間的空隙,對(duì)文字來(lái)說(shuō)就是空格,它們能被拉伸或者壓縮;為防止 Box 被二次分割,所以引入了 Penalty 的概念,常見(jiàn)的 Penalty 就是連字符或者換行符。
看下圖,你發(fā)現(xiàn)了嗎,Box 之間的 Glue 寬度是不定的:
文本的布局 —— Box 和 Glue圖片的折行問(wèn)題比文字截?cái)喔?jiǎn)單。對(duì)文字而言,人們可以接受多種截?cái)喾桨?—— 在文字之間增加空格;或者增加字間距;還可以使用連字符。但在圖片的場(chǎng)景里,如果圖片的間隙寬度不同,用戶(hù)一定會(huì)發(fā)覺(jué);也不存在“圖片連字符”的概念。
可以看這里了解更多關(guān)于文字折行算法,本文將不再展開(kāi)?;氐綀D片的話題,我們將會(huì)用剛剛提及的算法來(lái)實(shí)現(xiàn)我們的圖片折行。
為了應(yīng)用到圖片布局上,我們想直接拋棄了 Glue 的概念,再簡(jiǎn)化 Penalty 的使用,將圖片視為 Box。話雖如此,可能更貼切來(lái)說(shuō),我們是拋棄了 Box 保留了 Glue,在設(shè)想中尺寸可變的是圖片而不是它們的間距?;蛘吒纱嗾J(rèn)為我們的 Box 尺寸不變。
不改變圖片間距,我們選擇調(diào)整行的高度從而調(diào)整布局。大部分時(shí)候,折行都需要額外的空間。提前折行時(shí),為了保證填滿(mǎn)寬度就會(huì)增加縱向空間,因?yàn)樵瓉?lái)的行需要變高;反之,延遲折行時(shí),行的高度會(huì)變矮。通過(guò)計(jì)算所有的可能性,找到最合適的尺寸方案。
現(xiàn)在我們只有三點(diǎn)需要考慮了:理想的行
高、最大
壓縮系數(shù)(一行的高度可以壓縮到多矮)和最大
拉伸系數(shù)(或者能拉伸到多高)。
算法原理是:每次檢查一張照片,尋找可能存在的換行點(diǎn) —— 比如當(dāng)放大一組照片的時(shí)候,它們的高度應(yīng)該在規(guī)定范圍內(nèi)(maxShrink ≤ 圖片高 ≤ maxStretch)。每當(dāng)發(fā)現(xiàn)一個(gè)可以作為換行點(diǎn)的位置時(shí),記下它,在這個(gè)位置的基礎(chǔ)上再往后繼續(xù)尋找,直到檢查完所有圖片和所有的換行可能性。
比如下面這14張圖片,一行能放下三張或者四張圖片。如果第一行放三張圖片,那么第二行的換行點(diǎn)可能是第六張或第七張圖片處;假如第一行放四張,那么第二行的換行點(diǎn)就會(huì)在第七或第八的位置???,前一行的換行點(diǎn)將會(huì)決定后面的圖片布局,不過(guò)無(wú)論是在哪個(gè)位置截?cái)?,總歸都是網(wǎng)格布局。
可以換行的位置最后一步是計(jì)算每一行的“壞值 (badness value)”,也就是計(jì)算當(dāng)前換行方案的不理想程度。和我們預(yù)設(shè)高度相同的行,壞值為0;行高被壓縮/拉伸越厲害,這個(gè)值就越大,換言之就是該行的布局越不理想。最后,通過(guò)一些計(jì)算將每一行的分?jǐn)?shù)折算為一個(gè)值 (稱(chēng)之為 demerits)。不少文章撰寫(xiě)過(guò)相關(guān)的公式,通常是對(duì)壞值求和,然后取平方或立方,再加上一些常數(shù)。在 Google Photos 中我們用的是求和與最大伸縮值的比例的冪(行高越不理想,demerits 將會(huì)越大)。
最終結(jié)果是一張“圖“,圖上每個(gè)節(jié)點(diǎn)表示一張圖片,這個(gè)圖片就是換行點(diǎn),每條邊代表一行(一個(gè)節(jié)點(diǎn)可能連著多條邊,這說(shuō)明從一張圖片的后面會(huì)多個(gè)換行可能性),我們會(huì)計(jì)算每條邊的值也就是前面的 demerits。
舉個(gè)例子,下面有14張圖片,我們希望每行高度是180px,現(xiàn)在視口的寬度是1120px??梢园l(fā)現(xiàn),有19種換行方式(19條邊)最終會(huì)產(chǎn)生12種不同的布局效果(12條路徑)。藍(lán)線所示是最不壞的方法(我可不敢說(shuō)是最佳)。跟著這些邊,你會(huì)發(fā)現(xiàn)底下的組合里囊括了所有布局可能性,沒(méi)有重復(fù)的行也沒(méi)有重復(fù)的布局結(jié)果。
14張圖片的布局可能性要找到布局的最優(yōu)解(或者說(shuō)是盡可能優(yōu)的解)就和找到圖中最短路徑一樣簡(jiǎn)單。
幸運(yùn)的是,我們得到的是有向無(wú)環(huán)圖 (DAG,圖中沒(méi)有重復(fù)的節(jié)點(diǎn)),這樣最短路徑的計(jì)算可以在線性時(shí)間內(nèi)完成(對(duì)電腦來(lái)說(shuō)就是“速度快”的意思)。但其實(shí)我們可以一邊構(gòu)建圖一邊尋找最短路徑。
要得到路徑的總長(zhǎng)度,只要把每條邊的值加到一起。每當(dāng)同一節(jié)點(diǎn)上出現(xiàn)一條新的邊時(shí),檢查它所在的所有路徑,是否出現(xiàn)了更短的總長(zhǎng)度值,如果存在,就把它記下來(lái)。
以上面那14張圖為例,檢查過(guò)程如下 —— 第一條線表示當(dāng)前索引到的圖片(一行中的第一張和最后一張圖),下圖表示找到的換行點(diǎn),以及哪些邊與之相連,當(dāng)前節(jié)點(diǎn)上的最短路徑會(huì)用粉紅色標(biāo)記出來(lái)。這是上圖的一種變型表達(dá) —— Box 之間的每一條邊都與獨(dú)一無(wú)二的行布局相關(guān)。
從第一張圖開(kāi)始往后找,如果在索引2處設(shè)一個(gè)換行點(diǎn),此處的 demerits 為 114。如果在索引3處設(shè)換行點(diǎn),此時(shí)的 demerits 就變成了 9483?,F(xiàn)在我們需要從這兩個(gè)索引出發(fā),再尋找下一個(gè)換行點(diǎn)。索引2的下一步在5或者6的位置,經(jīng)過(guò)計(jì)算發(fā)現(xiàn)在6處換行,路徑更短(114+1442=1556)。索引3的下一步也可以是6,但由于一開(kāi)始在3處的換行成本太高了,導(dǎo)致最終在6處的 demerits 高到驚人(9483 +1007=10490)。所以目前的最優(yōu)路徑是在索引2處截?cái)?,接著在索?處。在動(dòng)畫(huà)的最后你會(huì)看到一開(kāi)始選擇的到索引11的路徑并不是最優(yōu)解,在節(jié)點(diǎn)8處的才是。
尋找14張圖片布局的最優(yōu)解如此往復(fù),直到最后一張圖片(索引13),此時(shí)最短路徑也就是最佳布局方案已經(jīng)出來(lái)了(即上圖中的藍(lán)色路線)。
下面左圖是傳統(tǒng)的布局算法,右圖是折行優(yōu)化算法。它們的理想行高都是180px,仔細(xì)觀察,我們可以得到到兩個(gè)有趣的結(jié)論:傳統(tǒng)算法總會(huì)壓縮行高;優(yōu)化算法則是會(huì)大膽地增加行高。最終的結(jié)果也確實(shí)是優(yōu)化算法更接近理想高度。
理想行高是180px,比較兩種布局算法經(jīng)過(guò)測(cè)試,F(xiàn)lexLayout 算法(我們給圖片折行算法取了個(gè)名字)確實(shí)能夠生成更理想的網(wǎng)格布局。它能生成更均勻的網(wǎng)格(每行的高度相差無(wú)幾),最后平均行高將會(huì)更接近預(yù)設(shè)的高度。由于 FlexLayout 會(huì)考慮不同的排列組合情況,類(lèi)似于全景照片這樣的極端案例也會(huì)有解決方案。如果全景圖被壓縮到非常矮,在 FlexLayout 中該邊的壞值會(huì)很高,那么這條邊肯定不會(huì)出現(xiàn)在最終結(jié)果里。而傳統(tǒng)算法遇到全景(超寬)照片時(shí),它會(huì)將該圖視作第一行中的一張圖片,為了把它塞入第一行,就會(huì)壓縮地特別矮。
這意味著,存在某些行的高度和預(yù)設(shè)高度不同,但也不至于偏差很大。
有很多變量都會(huì)影響最終結(jié)果:圖片的數(shù)量是最大影響因素之一;視口寬度和壓縮/拉伸比也很重要。
25張圖片在不同的視口尺寸下的布局上圖是 FlexLayout 在窄屏、中等屏和寬屏上實(shí)現(xiàn) 25 張圖片的布局方案將會(huì)生成的圖。在窄屏下的換行點(diǎn)可選余地不多,但會(huì)產(chǎn)生的行數(shù)很多。隨著屏幕變寬,同一行的換行點(diǎn)可能性變多,相應(yīng)地行數(shù)會(huì)減少,布局的可能性也會(huì)減少。
隨著圖片的增多,布局方案的數(shù)量會(huì)指數(shù)倍的增長(zhǎng)。在中等寬的視口里,不同的圖片數(shù)量,對(duì)應(yīng)的路徑數(shù)如下:
5 photos = 2 paths 10 photos = 5 paths 50 photos = 24136 paths 75 photos = 433144 paths100 photos = 553389172 paths
如果有1000張圖片,計(jì)算機(jī)來(lái)不及算出布局方案的數(shù)量,但神奇的是卻能立刻找到最佳路徑,雖然它來(lái)不及驗(yàn)證該路徑是否真的是最佳。
但能根據(jù)公式推算出最佳布局,計(jì)算每行的換行點(diǎn)可能性的均值,再求立方,計(jì)算出行數(shù)的總可能性。大部分視口寬度,每行可能有兩三種換行方案,一行可以放五張以上的圖片。通常有 2.5^(圖片數(shù)量/5) 種布局可能。
1000張圖片的組合可能有100...000 (79個(gè)0)種;1260張圖片則有10^100種可能。
傳統(tǒng)算法一次只能輸出一種布局方案,而 FlexLayout 算法是同時(shí)計(jì)算著百萬(wàn)億萬(wàn)種方案,從中選中最好的一個(gè)。
你一定很好奇客戶(hù)端/服務(wù)器端能否承載如此巨大的計(jì)算量,當(dāng)然答案是“當(dāng)然可以”。計(jì)算100張照片的最佳布局耗時(shí)2毫秒;1000張照片耗時(shí)10毫秒;10000張照片是50毫秒…我們還測(cè)試了100,000,000張照片的耗時(shí)是1.5秒。傳統(tǒng)算法在對(duì)應(yīng)場(chǎng)景中的耗時(shí)分別是2毫秒、3毫秒、30毫秒和400毫秒,雖然速度更快但體驗(yàn)比不上 FlexLayout。
一開(kāi)始我們只想選出最合適的布局方案,后來(lái)我們還能微調(diào)網(wǎng)格間距,這樣用戶(hù)總能看到最佳的布局效果。
大家對(duì) FlexLayout 贊不絕口,還實(shí)現(xiàn)了安卓和 iOS 的版本,現(xiàn)在包括網(wǎng)頁(yè)版在內(nèi)的三個(gè)平臺(tái)的實(shí)現(xiàn)方案保持同步更新。
最后再分享一個(gè)技巧,每一個(gè) section 會(huì)被計(jì)算兩次:第一次算的是 section 中 segment 的單張照片,維度是照片;第二次算的是 section 中的 segment,維度是 segment。由于可能存在 segment 或圖片數(shù)量太少的情況,導(dǎo)致一行都沒(méi)有占滿(mǎn),所以要計(jì)算第二次,此時(shí)布局算法會(huì)建議將不足一行的內(nèi)容合并,以達(dá)到最佳視覺(jué)效果。
完整的一節(jié)3. 達(dá)到 60fps 的頁(yè)面滾動(dòng)
走到現(xiàn)在我們?yōu)閷?shí)現(xiàn)最佳布局已經(jīng)做了不少優(yōu)化,但如果瀏覽器沒(méi)法處理這么多數(shù)據(jù),那之前的工作算是白做了。不過(guò)還好,瀏覽器允許開(kāi)發(fā)者們優(yōu)化頁(yè)面渲染。
除了首次頁(yè)面加載外,用戶(hù)通常在操作頁(yè)面的時(shí)候會(huì)感受到“慢”,特別是滾動(dòng)。瀏覽器的機(jī)制是每秒繪制60幀畫(huà)面(也就是 60fps),按照這個(gè)速度繪制,用戶(hù)才會(huì)覺(jué)得操作頁(yè)面很流暢,反之就會(huì)感覺(jué)到卡頓。
60fps 的意思是什么呢?也就是每幀渲染時(shí)間不能超過(guò)16毫秒 (1/60)。但除了要渲染頁(yè)面內(nèi)容外,瀏覽器還有不少任務(wù) —— 處理事件、解析樣式、計(jì)算布局、將所有元素單位都轉(zhuǎn)為像素、最后才是繪制 —— 至少要留下10毫秒。
在這寶貴的10毫秒中,既要保證高效執(zhí)行完這些工作,還要確保沒(méi)有浪費(fèi)時(shí)間。
保持 DOM 尺寸不變
元素太多會(huì)影響頁(yè)面性能,主要原因有兩重:一是瀏覽器占用內(nèi)存過(guò)多(1000張 50KB 的圖片需要50MB 內(nèi)存,10000張就會(huì)占用 0.5GB 內(nèi)存,足以讓 Chrome 崩潰);還有一點(diǎn)是,元素多說(shuō)明瀏覽器要做的樣式、布局和合成工作也越多。
移除不必要的元素雖然用戶(hù)在 Google Photos 中已經(jīng)存了上千張圖片,但其實(shí)一次也只能看到一屏,大部分情況下一屏只能顯示幾十張。
我們認(rèn)為沒(méi)有必要一次性把所有的圖片都加載進(jìn)頁(yè)面,而是監(jiān)聽(tīng)用戶(hù)對(duì)頁(yè)面的操作,當(dāng)滾動(dòng)頁(yè)面時(shí),再顯示出對(duì)應(yīng)位置上的圖片。
有些圖片雖然之前可見(jiàn),但現(xiàn)在由于頁(yè)面滾動(dòng),已經(jīng)被移出了視口,那就把它們拿出來(lái)。
即使用戶(hù)已經(jīng)在頁(yè)面上瀏覽過(guò)成百上千張照片,但由于視口的限制,每次需要渲染的圖片卻都不會(huì)超過(guò)50張。這樣的策略下,用戶(hù)的交互總能得到及時(shí)的響應(yīng),瀏覽器也不容易發(fā)生崩潰。
幸好事先把圖片按照 segment 和 section 的維度分好了組,現(xiàn)在不需要操作單張圖片,可以一次性掛載/掛起完整的模塊。
變數(shù)最小化
在 Google Developers 上有很多聊到渲染性能的好文章,還有不少教程指導(dǎo)如何使用 Chrome 中內(nèi)置的性能檢測(cè)工具。這里我將快速介紹 Google Photos 中用到的一些技巧,更多細(xì)節(jié)還請(qǐng)各位訪問(wèn) Google Developers。首先來(lái)了解一下頁(yè)面渲染的生命周期:
Chrome 像素管道每當(dāng)頁(yè)面出現(xiàn)變化時(shí)(通常是通過(guò) JS 觸發(fā)的,但也有被樣式或者動(dòng)畫(huà)引發(fā)的場(chǎng)景),瀏覽器會(huì)先確認(rèn)具體是哪些
樣式產(chǎn)生的改變,重新計(jì)算元素
布局(尺寸和位置),接著重新
繪制受到影響的所有元素(比如將文本、圖片…轉(zhuǎn)為像素)。為了提高頁(yè)面內(nèi)容的更新效率,瀏覽器通常會(huì)將元素分到不同的
層中,以層為單位繪制,最后一步是層的
合成。
大部分情況下,瀏覽器已經(jīng)夠聰明的了,你可能都想不起這條渲染管道。但假如頁(yè)面的內(nèi)容變動(dòng)太頻繁(比如持續(xù)增/減圖片),那就要小心了。
section、segment 和圖片都是絕對(duì)定位的為了盡可能縮小頁(yè)面的變化范圍,我們讓所有的子元素都相對(duì)它們的父元素定位。section 是絕對(duì)定位于整個(gè)網(wǎng)格布局的,segment 相對(duì)它所在的 section 絕對(duì)定位。依次類(lèi)推,圖片就是絕對(duì)定位于它所屬的 segment。
將全部元素都做定位布局后,當(dāng)我們需要改變一個(gè) section 的尺寸(實(shí)際高度和預(yù)估高度往往不同,就會(huì)出現(xiàn)這樣的更新)時(shí),在它物理位置之下的所有元素只需要修改 top 值即可。這種布局方式能避免不少不必要的 DOM 更新。
CSS 的 contain 屬性能定義某個(gè)元素的獨(dú)立程度,這樣瀏覽器就知道該元素會(huì)多大程度上影響上下文的其他內(nèi)容。所以我們給 section 和 segment 都加上這個(gè)屬性:
/* 元素內(nèi)外部?jī)?nèi)容不會(huì)相互影響 */contain: layout;
還有一些比較好處理的性能問(wèn)題,比如單幀內(nèi)會(huì)觸發(fā)好幾次滾動(dòng)事件,瀏覽器窗口縮放的時(shí)候也會(huì)連續(xù)觸發(fā)滾動(dòng)。如果布局持續(xù)地在發(fā)生變化,那么在最開(kāi)始變化的時(shí)候,瀏覽器可以不用重新計(jì)算樣式和布局。
幸好,這個(gè)默認(rèn)行為可以通過(guò)
window.requestAnimationFrame(callback) 禁止,這個(gè)方法的作用是在下一幀發(fā)生前執(zhí)行回調(diào)函數(shù)。在滾動(dòng)和縮放事件處理中,我們可以通過(guò)它先執(zhí)行回調(diào)函數(shù)而不是直接更新布局;窗口縮放要做的事稍微復(fù)雜一點(diǎn):在用戶(hù)確定最終窗口大小的半秒之后,再執(zhí)行更新。
第二個(gè)常見(jiàn)的問(wèn)題是布局抖動(dòng)。當(dāng)瀏覽器需要計(jì)算布局的時(shí)候,它會(huì)先把緩存布局,這樣后面就能迅速找到元素的寬度、高度和布局信息。但是,一旦能影響布局的屬性發(fā)生改變(比如寬高、top 或者 left …的定位屬性),先前的布局緩存就會(huì)立刻失效;再讀取布局屬性時(shí),瀏覽器會(huì)強(qiáng)行重新計(jì)算布局(同一幀內(nèi)會(huì)發(fā)生多次這樣的反復(fù)計(jì)算)。
在有大量元素循環(huán)布局的場(chǎng)景下(比如幾百?gòu)垐D片)就會(huì)出現(xiàn)問(wèn)題。讀一個(gè)布局屬性,就要改變布局(把圖片或者 section 挪到正確的位置),接著又讀一個(gè)布局屬性觸發(fā)新一輪的布局計(jì)算。
一個(gè)簡(jiǎn)單的方案就能避免上述問(wèn)題:一次性讀取所有的的值,再一次性更新(也就是將讀與寫(xiě)分開(kāi),并做批處理)。不過(guò)我們的方式是避免讀值,記錄每張照片的尺寸和位置,絕對(duì)定位它們。當(dāng)滾動(dòng)或窗口縮放發(fā)生時(shí),我們就根據(jù)所記錄的照片信息再執(zhí)行所有計(jì)算。這種更新方法就不會(huì)產(chǎn)生抖動(dòng)。下圖是頁(yè)面滾動(dòng)更新了一幀時(shí)的性能情況(可以看到?jīng)]有出現(xiàn)重復(fù)的渲染管道中的環(huán)節(jié)):
頁(yè)面滾動(dòng)更新時(shí)的渲染和繪制的事件順序避免代碼持續(xù)運(yùn)行
由于 Web Workers 的出現(xiàn),還有原生異步方法(比如 Fetch)的支持,一個(gè)標(biāo)簽頁(yè)只有一個(gè)線程,也就是同一個(gè)標(biāo)簽頁(yè)中的代碼都在一個(gè)線程中運(yùn)行 —— 包括渲染和 JS。這就意味著如果有代碼(比如一個(gè)長(zhǎng)運(yùn)行的滾動(dòng)事件方法)阻塞了頁(yè)面的渲染,那用戶(hù)體檢將會(huì)極差。
我們的解決方案里最耗時(shí)的是創(chuàng)建布局和元素。這兩個(gè)操作得在一定時(shí)間完成才不會(huì)影響到用戶(hù)。
打個(gè)比方,1000張圖片布局花10毫秒,10000張圖片需要50毫秒,這可就把60毫秒的更新時(shí)間給花光了。但是因?yàn)槲覀儼褕D片分成了 section 還有 segment,這樣一次只需要花2~3毫秒更新幾百?gòu)垐D片就行了。
最“昂貴”的布局事件就是窗口縮放了 —— 每一個(gè) section 都要被需要重新。我們干脆用回了最初的算法 —— 即使有的 section 已經(jīng)被加載好了,我們也不做處理,只對(duì)可視位置的 section 使用 FlexLayout 算法。等到其他 section 被滾動(dòng)到視口范圍時(shí)再重新計(jì)算。
創(chuàng)建元素時(shí)用的也是這個(gè)邏輯 —— 我們只在圖片即將被看到之前才進(jìn)行布局計(jì)算。
結(jié)果
做了這么多事情,我們總算得到了還不錯(cuò)的布局方案 —— 大部分情況下能達(dá)到 60fps,雖然掉幀偶爾還會(huì)出現(xiàn)。
掉幀通常發(fā)生在主要的布局場(chǎng)景中(比如插入一個(gè)全新的 section),或者瀏覽器要回收特別舊的元素的時(shí)候。
頁(yè)面滾動(dòng)的實(shí)時(shí)幀率4. 瞬間之感
我相信大部分前端工程師都會(huì)在 UI 上花不少心思炫炫技,比如放點(diǎn)禮花特效之類(lèi)的。
其中我最?lèi)?ài)的“小心機(jī)”是一位 YouTube 的同事想到的。他們?cè)谔幚磉M(jìn)度條的時(shí)候(頁(yè)面最頂端的一根紅條),并不是用真實(shí)的頁(yè)面加載進(jìn)度(當(dāng)時(shí)也沒(méi)有確切的進(jìn)度信息),但用動(dòng)畫(huà)模擬出了“正在加載”的體驗(yàn),直到頁(yè)面真正加載完成的同時(shí),這條紅線才會(huì)到達(dá)最右端。我不確定現(xiàn)在的 YouTube 是否把加載動(dòng)畫(huà)和頁(yè)面實(shí)際加載進(jìn)度對(duì)應(yīng)起來(lái)了,但它的整體思路是這樣的。
加載進(jìn)度的精確性是次要的,最重要的是要讓用戶(hù)切實(shí)感受到,這個(gè)頁(yè)面進(jìn)度是在往前走著的。
這一節(jié)中我將會(huì)分享一些技巧,讓用戶(hù)覺(jué)得 Google Photos 用起來(lái)很流暢(比真實(shí)情況要更流暢)—— 大部分技巧都和圖片加載有關(guān)。
第一件事,也可能是最有效的,用戶(hù)最可能看到的內(nèi)容會(huì)被最先加載。
在加載好視口范圍內(nèi)的圖片后,還會(huì)再額外加載一屏圖片,為了保證下次用戶(hù)滾動(dòng)頁(yè)面時(shí)能立刻看到新的圖片。
但是對(duì)于 HDPI 屏幕(在這樣的屏幕下我們需要加載更大尺寸的縮略圖),在快速滾動(dòng)頁(yè)面的時(shí)候,響應(yīng)所有的請(qǐng)求就比較困難了。
于是我們優(yōu)化了加載方案 —— 先加載未來(lái)四五屏內(nèi)的占位圖,這些圖片往往非常小,所以立刻就能加載好。當(dāng)這些圖片快要被移動(dòng)到視口的時(shí)候,再加載原圖。
這意味著如果用戶(hù)以正常的速度慢慢滾動(dòng)頁(yè)面瀏覽圖片,他就看不到視口以外照片的加載過(guò)程了;但也存在飛快滾動(dòng)頁(yè)面為了尋找某張圖片的場(chǎng)景,那用戶(hù)看到的就會(huì)是圖片的縮略圖,感受到的是大致的信息。
為了獲取頁(yè)面內(nèi)容總會(huì)有不必要的工作要做,但同時(shí)還要提供流暢的用戶(hù)體驗(yàn),這是一個(gè)復(fù)雜的權(quán)衡游戲。
我們考慮了以下幾個(gè)因素。首先要檢查頁(yè)面滾動(dòng)方向,要預(yù)加載的是用戶(hù)即將看到的內(nèi)容;還會(huì)根據(jù)用戶(hù)滾動(dòng)頁(yè)面的速度識(shí)別是否要加載高清原圖,如果發(fā)現(xiàn)用戶(hù)只是在飛速地瀏覽圖片,那加載原圖也就沒(méi)有必要了;甚至當(dāng)頁(yè)面滾動(dòng)速度快到一定程度,連低分辨率的占位圖都不用加載了。
無(wú)論加載的是原圖還是低分辨率的占位圖,都會(huì)有縮放圖片的場(chǎng)景?,F(xiàn)在的顯示屏基本都是高清屏,常見(jiàn)的做法是加載一張兩倍于占位尺寸大小的圖片,然后縮小一半放到對(duì)應(yīng)位置上(這樣做,實(shí)際一個(gè)像素就能承載兩倍的信息量)。對(duì)于低分辨占位圖來(lái)說(shuō),我們可以請(qǐng)求非常小且壓縮率很高(比如壓縮率75%)的資源,然后放大它們。
以這只快睡著了的豹子為例,左邊的圖片是在網(wǎng)格布局里完全加載好以后我們會(huì)看到的(它已經(jīng)被縮小到實(shí)際圖片尺寸的一半了),右圖是一張低分辨率的占位圖(還被放大了到占位尺寸),當(dāng)用戶(hù)飛速劃過(guò)時(shí)就會(huì)看到這樣的占位圖。
正常圖片和低分辨率的占位圖也請(qǐng)注意圖片的文件大小,壓縮后的高清縮略圖有 71.2KB,低分辨率的占位圖經(jīng)過(guò)同樣的壓縮算法大小是 889B,僅僅占高清原圖的 1/80!換算一下,一張高清原圖的流量頂?shù)纳纤捻?yè)占位圖了。
用很少的流量增加換取更好的用戶(hù)體驗(yàn),占位圖可以讓用戶(hù)感受到網(wǎng)頁(yè)內(nèi)容的豐富,還提供了瀏覽時(shí)的視覺(jué)參考。
最后要考慮的一點(diǎn)是,瀏覽器要如何渲染低分辨率的占位圖。默認(rèn)情況下,當(dāng)一張很小的圖片被拉大的時(shí)候?yàn)g覽器會(huì)做像素平滑處理(下圖中間),但視覺(jué)效果并不太好。如果用模糊來(lái)處理(下圖最右)效果會(huì)好很多。但濾鏡非常影響頁(yè)面性能,如果同時(shí)給上百?gòu)垐D片都加上濾鏡,那頁(yè)面性能會(huì)差到無(wú)法想象。所以我們選了另一條路,讓瀏覽器以像素化的方式處理這些圖片(如最左),不過(guò)我不確定現(xiàn)在的 Google Photos 是不是依然使用這個(gè)方案,這部分有經(jīng)過(guò)改版。
低分辨率縮略圖的渲染方案如果希望用戶(hù)永遠(yuǎn)不要看到低分辨率的圖片(除了快速滾動(dòng)這樣實(shí)在無(wú)法避免的場(chǎng)景外),特別是在即將進(jìn)入視口,高清原圖即將替換掉占位圖的時(shí)間交接點(diǎn),之前我們用動(dòng)畫(huà)來(lái)完成這個(gè)過(guò)渡(避免直接替換圖片太突兀)。具體實(shí)現(xiàn)起來(lái)就是把占位圖和原圖疊加在一起,當(dāng)需要顯示原圖的時(shí)候?qū)⒄嘉粓D從不透明漸變到全透明 —— 常見(jiàn)的過(guò)渡手段之一,Medium 中的文章配圖也是這么顯示的?,F(xiàn)在的 Google Photos 可能已經(jīng)去掉了這個(gè)過(guò)渡邏輯,但從空網(wǎng)格到有內(nèi)容的過(guò)程可能依然在使用這個(gè)效果。
這樣的視覺(jué)體驗(yàn)會(huì)讓用戶(hù)感受到這張圖片正在加載,這個(gè)動(dòng)畫(huà)持續(xù)100毫秒 —— 足以在這段時(shí)間內(nèi)加載上原圖,下圖是慢速播放的動(dòng)畫(huà),方便大家觀察:
加載過(guò)程另一個(gè)地方也用到了這個(gè)技巧:縮略圖展開(kāi)到全屏預(yù)覽。當(dāng)用戶(hù)點(diǎn)擊縮略圖的時(shí)候,我們立刻開(kāi)始加載原圖,在等待原圖的同時(shí),將縮略圖放大并定位到屏幕中間,原圖加載好時(shí),再用改變透明度的方法顯示出原圖。與縮略圖加載不同的是,這次只要操作一張圖片,所以用上了模糊濾鏡(像素化的體驗(yàn)肯定是比不上模糊效果的)。
從網(wǎng)格到全屏的過(guò)渡無(wú)論是滾動(dòng)頁(yè)面瀏覽圖片,還是在縮略圖模式與全屏預(yù)覽模式間的切換,我們總是希望用戶(hù)能感受到,雖然最終結(jié)果尚未準(zhǔn)備好,但瀏覽器正在努力處理任務(wù)。與這種交互理念相反的表現(xiàn)是,當(dāng)用戶(hù)點(diǎn)擊縮略圖的時(shí)候,屏幕上沒(méi)有任何反饋甚至白屏,直到原圖完全被加載好。
空 section 也用上了這一理念。我們的網(wǎng)格布局只有在需要顯示 section 的時(shí)候,才會(huì)去加載它(也存在預(yù)加載好的一些圖片)。如果用戶(hù)直接拖動(dòng)滾動(dòng)條,就會(huì)看到還沒(méi)有加載好的 section 部分,雖然已經(jīng)預(yù)留了空間,但當(dāng)用戶(hù)瀏覽到這個(gè)位置時(shí),還對(duì)將看到什么圖片和什么樣的布局沒(méi)有心理準(zhǔn)備。
為了讓滾動(dòng)體驗(yàn)更自然,我們將這些預(yù)留好空間的 section 的高度設(shè)定為目標(biāo)行高,并填充上顏色以表示占位。在加載剛剛開(kāi)始的時(shí)候,section 看起來(lái)就是一條條灰色的長(zhǎng)矩形(下圖最左),最近改版成了下圖最右那樣有行有列的,更接近一張張圖片。下圖中間表示的是已經(jīng)加載好但是圖片還沒(méi)有渲染出來(lái)的 section。
加載過(guò)程中的布局變化這樣的圖片加載過(guò)程就像追蹤獸跡一樣,下次使用 Google Photos 的時(shí)候試試看分辨這些狀態(tài)吧。
section 的占位色塊不是用圖片而是用 CSS 實(shí)現(xiàn)的,所以即使隨意改變寬高,也不會(huì)有變形或裁剪:
/* 在 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;
除此之外我們還有不少小技巧,大多是和優(yōu)化請(qǐng)求順序有關(guān)的。比如,我們不會(huì)一次性就請(qǐng)求100張縮略圖,而是分成10批,一次請(qǐng)求10張。所以如果用戶(hù)突然開(kāi)始飛速滾動(dòng)頁(yè)面,不至于浪費(fèi)后面90張的流量。類(lèi)似的邏輯還有,總會(huì)優(yōu)先請(qǐng)求視口區(qū)域內(nèi)的圖片,視口外的圖片稍微等等。
甚至我們還會(huì)復(fù)用尺寸近似的縮略圖 —— 比如用戶(hù)縮放窗口后,網(wǎng)格布局并沒(méi)有發(fā)生本質(zhì)上的改變,只是行數(shù)和之前不同了。這種情況下我們不會(huì)重新下載另一個(gè)尺寸的縮略圖,而是將已有的圖片進(jìn)行縮放,只有當(dāng)窗口尺寸被完全改變的時(shí)候,才會(huì)重新請(qǐng)求圖片。
結(jié)論
Google Photos 考慮了大量的用戶(hù)體驗(yàn)細(xì)節(jié),網(wǎng)格布局僅僅是其中的冰山一角
乍看之下僅僅是簡(jiǎn)單甚至是靜態(tài)的布局,但實(shí)際上網(wǎng)格一直在實(shí)時(shí)變化著 —— 加載、預(yù)抓取、動(dòng)畫(huà)、創(chuàng)建、移除…盡它所能帶給用戶(hù)最好的體驗(yàn)。
團(tuán)隊(duì)總會(huì)優(yōu)先考慮保證并提高產(chǎn)品的性能。Google Photos 團(tuán)隊(duì)通過(guò)滾動(dòng)幀率、模塊加載頻率…等指標(biāo)實(shí)時(shí)監(jiān)控著產(chǎn)品的體驗(yàn),Google Photos 一直在前進(jìn)啊。
下面是一段滾動(dòng) Google Photos 頁(yè)面的錄屏。當(dāng)用戶(hù)慢慢瀏覽頁(yè)面時(shí),能看到清晰的縮略圖;當(dāng)提高滾動(dòng)速度時(shí),看到的就是像素化的占位圖,當(dāng)再次回到慢速滾動(dòng)時(shí)高清圖又顯示出來(lái)了;而飛速劃過(guò)頁(yè)面時(shí),看到的就是灰色的占位色塊了。滾動(dòng)速度不同加載效果不同:
https://www.youtube.com/watch?v=d57mzcSrSQw&feature=youtu.be感謝我在 Google Photos 時(shí)的領(lǐng)導(dǎo)
Vincent Mo,他一直非常支持我們,而且本文中所用到的照片都是由他拍攝的(產(chǎn)品測(cè)試階段同樣也用了 Vincent 拍的照片)。感謝
Jeremy Selier,Google Photos Web 端的負(fù)責(zé)人,現(xiàn)在他正帶領(lǐng)著團(tuán)隊(duì)持續(xù)維護(hù)并提升 Google Photos Web 端的體驗(yàn)。