![%title插图%num Google Photos Web UI 探索之旅](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_GfYo2WZaEjsfGjKKCmkXdw-e1578375566850.png)
已获翻译授权,原文地址: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张图片时浏览器标签页就直接崩溃。
下面我将分成四个部分回溯我们是如何解决这两个问题的:
- “独立”的图片 — 迅速定位到图片库中的指定位置。
- 自适应布局 — 根据浏览器宽度,尽可能铺满图片且要保留图片的原始比例(不做正方形裁剪)。
- 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
}
如果由用户(比如摄影师)在同一个时间段内就能产出大量图片,这个方案还是有缺陷的 —— 将数据分为一个个模块的原因是方便处理元数据,但对于重度用户来说,每个月的数据量依然极大。伟大的基础服务团队想到了解决方案 —— 允许用户创建自定义的分类方式(比如地点、时间戳…)。
![%title插图%num 网格布局由 section、segment 和单张图片组成](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_sB87boWM7XYi7gftNNRRDg-2.png)
有了这些信息之后,我们就能给每个模块占位了。当用户快速滚动页面时,客户端获取到对应的图片元数据,计算出完整的布局并更新页面。
在浏览器端,拿到了模块的元数据后,我们会将照片按照日期度再做一次整理。我们讨论过的动态分组(比如根据位置、人物、日期…)也将是很棒的特性。
现在预估模块的尺寸就很简单了,通过照片数量和预估的单张照片的比例后,进行计算:
// 理想情况下,我们应该先计算出当前模块的比例均值
// 不过我们先假设照片比例是 3:2,
// 然后在它的基础上做一些调整
const unwrappedWidth = (3 / 2) * photoCount * targetHeight * (7 / 10);
const rows = Math.ceil(unwrappedWidth / viewportWidth);
const height = rows * targetHeight;
你可能猜到了,这样的估算结果并不准确,甚至偏差相当大。
我一开始把问题复杂化了(布局环节将会详细聊到),但从结果来看一开始也未必需要得到准确的数值(在照片数量很大的情况下,甚至能偏差上千像素)。我们之所以要做估算,也是为了保证滚动条位置,事实证明即使如此粗略,滚动条的定位依然能用。
![%title插图%num Google Photos Web UI 探索之旅](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_kOs6DlMR_0vcaSLlmnpVlw-2.gif)
这里有个小技巧,当模块真正被加载出来的时候,浏览器也就知道了实际需要的占位高度和预估占位高度之间的差,只要直接将页面剩余模块向下移动高度差的距离就行了。
如果要加载的模块在视口之上,那么模块加载好后还需要更新滚动条的位置。所有的更新操作可以在一秒内用一个动画帧完成,对用户造成的影响并不大,速度如果够快用户甚至是无感知的。
2. 自适应布局
据我所知,市面上主流的图片自适应布局都采用了一种巧妙由又简便的方法:每行高度不同但都占满视口,同一行内的图片根据宽高比缩放,以确保同一行内的图片高度。用户也不会容易注意到行与行之间的高度差。
放弃把所有图片的高度都变成一样的,保证原图的比例,再固定图片之间的间距。实现起来也不难,找到最高的行,按照宽高比缩放每张照片,更新当前网格宽度,如果发现要超过视口宽度了,就按照比例缩小该行内每一张图片,当然此时这一行的高度也会变小。
比如有14张图片的时候:
![%title插图%num Google Photos Web UI 探索之旅](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1__IkolRdIGAh3jqoYH6ZiqA-2.png)
这个方法性价比很高,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 宽度是不定的:
![%title插图%num 文本的布局 —— Box 和 Glue](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_bTik3yCnfjzSGEOl3XNSxw-2.png)
图片的折行问题比文字截断更简单。对文字而言,人们可以接受多种截断方案 —— 在文字之间增加空格;或者增加字间距;还可以使用连字符。但在图片的场景里,如果图片的间隙宽度不同,用户一定会发觉;也不存在“图片连字符”的概念。
可以看这里了解更多关于文字折行算法,本文将不再展开。回到图片的话题,我们将会用刚刚提及的算法来实现我们的图片折行。
为了应用到图片布局上,我们想直接抛弃了 Glue 的概念,再简化 Penalty 的使用,将图片视为 Box。话虽如此,可能更贴切来说,我们是抛弃了 Box 保留了 Glue,在设想中尺寸可变的是图片而不是它们的间距。或者干脆认为我们的 Box 尺寸不变。
不改变图片间距,我们选择调整行的高度从而调整布局。大部分时候,折行都需要额外的空间。提前折行时,为了保证填满宽度就会增加纵向空间,因为原来的行需要变高;反之,延迟折行时,行的高度会变矮。通过计算所有的可能性,找到最合适的尺寸方案。
现在我们只有三点需要考虑了:理想的行高、最大压缩系数(一行的高度可以压缩到多矮)和最大拉伸系数(或者能拉伸到多高)。
算法原理是:每次检查一张照片,寻找可能存在的换行点 —— 比如当放大一组照片的时候,它们的高度应该在规定范围内(maxShrink ≤ 图片高 ≤ maxStretch)。每当发现一个可以作为换行点的位置时,记下它,在这个位置的基础上再往后继续寻找,直到检查完所有图片和所有的换行可能性。
比如下面这14张图片,一行能放下三张或者四张图片。如果第一行放三张图片,那么第二行的换行点可能是第六张或第七张图片处;假如第一行放四张,那么第二行的换行点就会在第七或第八的位置。看,前一行的换行点将会决定后面的图片布局,不过无论是在哪个位置截断,总归都是网格布局。
![%title插图%num 可以换行的位置](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_iUZK1_GhGSKV2kkCVKg2-Q-2.png)
最后一步是计算每一行的“坏值 (badness value)”,也就是计算当前换行方案的不理想程度。和我们预设高度相同的行,坏值为0;行高被压缩/拉伸越厉害,这个值就越大,换言之就是该行的布局越不理想。最后,通过一些计算将每一行的分数折算为一个值 (称之为 demerits)。不少文章撰写过相关的公式,通常是对坏值求和,然后取平方或立方,再加上一些常数。在 Google Photos 中我们用的是求和与最大伸缩值的比例的幂(行高越不理想,demerits 将会越大)。
最终结果是一张“图“,图上每个节点表示一张图片,这个图片就是换行点,每条边代表一行(一个节点可能连着多条边,这说明从一张图片的后面会多个换行可能性),我们会计算每条边的值也就是前面的 demerits。
举个例子,下面有14张图片,我们希望每行高度是180px,现在视口的宽度是1120px。可以发现,有19种换行方式(19条边)最终会产生12种不同的布局效果(12条路径)。蓝线所示是最不坏的方法(我可不敢说是最佳)。跟着这些边,你会发现底下的组合里囊括了所有布局可能性,没有重复的行也没有重复的布局结果。
![%title插图%num 14张图片的布局可能性](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_XT4UaQufiMMvLqIFJvq2Sw-1.jpg)
要找到布局的最优解(或者说是尽可能优的解)就和找到图中最短路径一样简单。
幸运的是,我们得到的是有向无环图 (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处的才是。
![%title插图%num 寻找14张图片布局的最优解](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_lcldpRyTUaQmKXuVMYZU-Q-2.gif)
如此往复,直到最后一张图片(索引13),此时最短路径也就是最佳布局方案已经出来了(即上图中的蓝色路线)。
下面左图是传统的布局算法,右图是折行优化算法。它们的理想行高都是180px,仔细观察,我们可以得到到两个有趣的结论:传统算法总会压缩行高;优化算法则是会大胆地增加行高。最终的结果也确实是优化算法更接近理想高度。
![%title插图%num 理想行高是180px,比较两种布局算法](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_Wq_n1gOkjsWE9DS8_o1Tog-2.png)
经过测试,FlexLayout 算法(我们给图片折行算法取了个名字)确实能够生成更理想的网格布局。它能生成更均匀的网格(每行的高度相差无几),最后平均行高将会更接近预设的高度。由于 FlexLayout 会考虑不同的排列组合情况,类似于全景照片这样的极端案例也会有解决方案。如果全景图被压缩到非常矮,在 FlexLayout 中该边的坏值会很高,那么这条边肯定不会出现在最终结果里。而传统算法遇到全景(超宽)照片时,它会将该图视作第一行中的一张图片,为了把它塞入第一行,就会压缩地特别矮。
这意味着,存在某些行的高度和预设高度不同,但也不至于偏差很大。
有很多变量都会影响最终结果:图片的数量是最大影响因素之一;视口宽度和压缩/拉伸比也很重要。
![%title插图%num 25张图片在不同的视口尺寸下的布局](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_MURVPPuBBTxSaeD_T4hnww.png?x-oss-process=style%2Flarge)
上图是 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 或图片数量太少的情况,导致一行都没有占满,所以要计算第二次,此时布局算法会建议将不足一行的内容合并,以达到最佳视觉效果。
![%title插图%num 完整的一节](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_s9J3Wpv-OWWNOKoNy3msDQ-2.png)
3. 达到 60fps 的页面滚动
走到现在我们为实现最佳布局已经做了不少优化,但如果浏览器没法处理这么多数据,那之前的工作算是白做了。不过还好,浏览器允许开发者们优化页面渲染。
除了首次页面加载外,用户通常在操作页面的时候会感受到“慢”,特别是滚动。浏览器的机制是每秒绘制60帧画面(也就是 60fps),按照这个速度绘制,用户才会觉得操作页面很流畅,反之就会感觉到卡顿。
60fps 的意思是什么呢?也就是每帧渲染时间不能超过16毫秒 (1/60)。但除了要渲染页面内容外,浏览器还有不少任务 —— 处理事件、解析样式、计算布局、将所有元素单位都转为像素、最后才是绘制 —— 至少要留下10毫秒。
在这宝贵的10毫秒中,既要保证高效执行完这些工作,还要确保没有浪费时间。
保持 DOM 尺寸不变
元素太多会影响页面性能,主要原因有两重:一是浏览器占用内存过多(1000张 50KB 的图片需要50MB 内存,10000张就会占用 0.5GB 内存,足以让 Chrome 崩溃);还有一点是,元素多说明浏览器要做的样式、布局和合成工作也越多。
![%title插图%num 移除不必要的元素](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_PdGX1EBnfRHn-R2B88lVSA-2.png)
虽然用户在 Google Photos 中已经存了上千张图片,但其实一次也只能看到一屏,大部分情况下一屏只能显示几十张。
我们认为没有必要一次性把所有的图片都加载进页面,而是监听用户对页面的操作,当滚动页面时,再显示出对应位置上的图片。
有些图片虽然之前可见,但现在由于页面滚动,已经被移出了视口,那就把它们拿出来。
即使用户已经在页面上浏览过成百上千张照片,但由于视口的限制,每次需要渲染的图片却都不会超过50张。这样的策略下,用户的交互总能得到及时的响应,浏览器也不容易发生崩溃。
幸好事先把图片按照 segment 和 section 的维度分好了组,现在不需要操作单张图片,可以一次性挂载/挂起完整的模块。
变数最小化
在 Google Developers 上有很多聊到渲染性能的好文章,还有不少教程指导如何使用 Chrome 中内置的性能检测工具。这里我将快速介绍 Google Photos 中用到的一些技巧,更多细节还请各位访问 Google Developers。首先来了解一下页面渲染的生命周期:
![%title插图%num Chrome 像素管道](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_3EjkvVtB2sCw6MtkqOYDyQ-1.jpg)
每当页面出现变化时(通常是通过 JS 触发的,但也有被样式或者动画引发的场景),浏览器会先确认具体是哪些样式产生的改变,重新计算元素布局(尺寸和位置),接着重新绘制受到影响的所有元素(比如将文本、图片…转为像素)。为了提高页面内容的更新效率,浏览器通常会将元素分到不同的层中,以层为单位绘制,最后一步是层的合成。
大部分情况下,浏览器已经够聪明的了,你可能都想不起这条渲染管道。但假如页面的内容变动太频繁(比如持续增/减图片),那就要小心了。
![%title插图%num section、segment 和图片都是绝对定位的](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_WiCld-CLxf3QQ0umLUsblw-2.png)
为了尽可能缩小页面的变化范围,我们让所有的子元素都相对它们的父元素定位。section 是绝对定位于整个网格布局的,segment 相对它所在的 section 绝对定位。依次类推,图片就是绝对定位于它所属的 segment。
将全部元素都做定位布局后,当我们需要改变一个 section 的尺寸(实际高度和预估高度往往不同,就会出现这样的更新)时,在它物理位置之下的所有元素只需要修改 top 值即可。这种布局方式能避免不少不必要的 DOM 更新。
CSS 的 contain 属性能定义某个元素的独立程度,这样浏览器就知道该元素会多大程度上影响上下文的其他内容。所以我们给 section 和 segment 都加上这个属性
/* 元素内外部内容不会相互影响 */
contain: layout;
还有一些比较好处理的性能问题,比如单帧内会触发好几次滚动事件,浏览器窗口缩放的时候也会连续触发滚动。如果布局持续地在发生变化,那么在最开始变化的时候,浏览器可以不用重新计算样式和布局。
幸好,这个默认行为可以通过 window.requestAnimationFrame(callback) 禁止,这个方法的作用是在下一帧发生前执行回调函数。在滚动和缩放事件处理中,我们可以通过它先执行回调函数而不是直接更新布局;窗口缩放要做的事稍微复杂一点:在用户确定最终窗口大小的半秒之后,再执行更新。
第二个常见的问题是布局抖动。当浏览器需要计算布局的时候,它会先把缓存布局,这样后面就能迅速找到元素的宽度、高度和布局信息。但是,一旦能影响布局的属性发生改变(比如宽高、top 或者 left …的定位属性),先前的布局缓存就会立刻失效;再读取布局属性时,浏览器会强行重新计算布局(同一帧内会发生多次这样的反复计算)。
在有大量元素循环布局的场景下(比如几百张图片)就会出现问题。读一个布局属性,就要改变布局(把图片或者 section 挪到正确的位置),接着又读一个布局属性触发新一轮的布局计算。
一个简单的方案就能避免上述问题:一次性读取所有的的值,再一次性更新(也就是将读与写分开,并做批处理)。不过我们的方式是避免读值,记录每张照片的尺寸和位置,绝对定位它们。当滚动或窗口缩放发生时,我们就根据所记录的照片信息再执行所有计算。这种更新方法就不会产生抖动。下图是页面滚动更新了一帧时的性能情况(可以看到没有出现重复的渲染管道中的环节):
![%title插图%num 页面滚动更新时的渲染和绘制的事件顺序](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_2xlmuQGaE6S5z5-Dql9hFQ-2.png)
避免代码持续运行
由于 Web Workers 的出现,还有原生异步方法(比如 Fetch)的支持,一个标签页只有一个线程,也就是同一个标签页中的代码都在一个线程中运行 —— 包括渲染和 JS。这就意味着如果有代码(比如一个长运行的滚动事件方法)阻塞了页面的渲染,那用户体检将会极差。
我们的解决方案里最耗时的是创建布局和元素。这两个操作得在一定时间完成才不会影响到用户。
打个比方,1000张图片布局花10毫秒,10000张图片需要50毫秒,这可就把60毫秒的更新时间给花光了。但是因为我们把图片分成了 section 还有 segment,这样一次只需要花2~3毫秒更新几百张图片就行了。
最“昂贵”的布局事件就是窗口缩放了 —— 每一个 section 都要被需要重新。我们干脆用回了最初的算法 —— 即使有的 section 已经被加载好了,我们也不做处理,只对可视位置的 section 使用 FlexLayout 算法。等到其他 section 被滚动到视口范围时再重新计算。
创建元素时用的也是这个逻辑 —— 我们只在图片即将被看到之前才进行布局计算。
结果
做了这么多事情,我们总算得到了还不错的布局方案 —— 大部分情况下能达到 60fps,虽然掉帧偶尔还会出现。
掉帧通常发生在主要的布局场景中(比如插入一个全新的 section),或者浏览器要回收特别旧的元素的时候。
![%title插图%num 页面滚动的实时帧率](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_2T23eXGToqZHnNq0oWuKzg-2.gif)
4. 瞬间之感
我相信大部分前端工程师都会在 UI 上花不少心思炫炫技,比如放点礼花特效之类的。
其中我最爱的“小心机”是一位 YouTube 的同事想到的。他们在处理进度条的时候(页面最顶端的一根红条),并不是用真实的页面加载进度(当时也没有确切的进度信息),但用动画模拟出了“正在加载”的体验,直到页面真正加载完成的同时,这条红线才会到达最右端。我不确定现在的 YouTube 是否把加载动画和页面实际加载进度对应起来了,但它的整体思路是这样的。
![%title插图%num YouTube进度条](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_904ZgUtT2yGbhIkw-_ii5g-2.png)
加载进度的精确性是次要的,最重要的是要让用户切实感受到,这个页面进度是在往前走着的。
这一节中我将会分享一些技巧,让用户觉得 Google Photos 用起来很流畅(比真实情况要更流畅)—— 大部分技巧都和图片加载有关。
第一件事,也可能是最有效的,用户最可能看到的内容会被最先加载。
![%title插图%num Google Photos Web UI 探索之旅](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_ceKYM6jO6G_DGK6VqNHXRA-2.png)
在加载好视口范围内的图片后,还会再额外加载一屏图片,为了保证下次用户滚动页面时能立刻看到新的图片。
但是对于 HDPI 屏幕(在这样的屏幕下我们需要加载更大尺寸的缩略图),在快速滚动页面的时候,响应所有的请求就比较困难了。
于是我们优化了加载方案 —— 先加载未来四五屏内的占位图,这些图片往往非常小,所以立刻就能加载好。当这些图片快要被移动到视口的时候,再加载原图。
这意味着如果用户以正常的速度慢慢滚动页面浏览图片,他就看不到视口以外照片的加载过程了;但也存在飞快滚动页面为了寻找某张图片的场景,那用户看到的就会是图片的缩略图,感受到的是大致的信息。
为了获取页面内容总会有不必要的工作要做,但同时还要提供流畅的用户体验,这是一个复杂的权衡游戏。
我们考虑了以下几个因素。首先要检查页面滚动方向,要预加载的是用户即将看到的内容;还会根据用户滚动页面的速度识别是否要加载高清原图,如果发现用户只是在飞速地浏览图片,那加载原图也就没有必要了;甚至当页面滚动速度快到一定程度,连低分辨率的占位图都不用加载了。
无论加载的是原图还是低分辨率的占位图,都会有缩放图片的场景。现在的显示屏基本都是高清屏,常见的做法是加载一张两倍于占位尺寸大小的图片,然后缩小一半放到对应位置上(这样做,实际一个像素就能承载两倍的信息量)。对于低分辨占位图来说,我们可以请求非常小且压缩率很高(比如压缩率75%)的资源,然后放大它们。
以这只快睡着了的豹子为例,左边的图片是在网格布局里完全加载好以后我们会看到的(它已经被缩小到实际图片尺寸的一半了),右图是一张低分辨率的占位图(还被放大了到占位尺寸),当用户飞速划过时就会看到这样的占位图。
![%title插图%num 正常图片和低分辨率的占位图](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_tLGUCQcdi0qhUp0wIBsl6A-2.png)
也请注意图片的文件大小,压缩后的高清缩略图有 71.2KB,低分辨率的占位图经过同样的压缩算法大小是 889B,仅仅占高清原图的 1/80!换算一下,一张高清原图的流量顶的上四页占位图了。
用很少的流量增加换取更好的用户体验,占位图可以让用户感受到网页内容的丰富,还提供了浏览时的视觉参考。
最后要考虑的一点是,浏览器要如何渲染低分辨率的占位图。默认情况下,当一张很小的图片被拉大的时候浏览器会做像素平滑处理(下图中间),但视觉效果并不太好。如果用模糊来处理(下图最右)效果会好很多。但滤镜非常影响页面性能,如果同时给上百张图片都加上滤镜,那页面性能会差到无法想象。所以我们选了另一条路,让浏览器以像素化的方式处理这些图片(如最左),不过我不确定现在的 Google Photos 是不是依然使用这个方案,这部分有经过改版。
![%title插图%num 低分辨率缩略图的渲染方案](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_A7T9gO4w93CzqW0sIhxAfA-2.png)
如果希望用户永远不要看到低分辨率的图片(除了快速滚动这样实在无法避免的场景外),特别是在即将进入视口,高清原图即将替换掉占位图的时间交接点,之前我们用动画来完成这个过渡(避免直接替换图片太突兀)。具体实现起来就是把占位图和原图叠加在一起,当需要显示原图的时候将占位图从不透明渐变到全透明 —— 常见的过渡手段之一,Medium 中的文章配图也是这么显示的。现在的 Google Photos 可能已经去掉了这个过渡逻辑,但从空网格到有内容的过程可能依然在使用这个效果。
这样的视觉体验会让用户感受到这张图片正在加载,这个动画持续100毫秒 —— 足以在这段时间内加载上原图,下图是慢速播放的动画,方便大家观察:
![%title插图%num 加载过程](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_qruGq8HASadcOUiVoCUutQ-2.gif)
另一个地方也用到了这个技巧:缩略图展开到全屏预览。当用户点击缩略图的时候,我们立刻开始加载原图,在等待原图的同时,将缩略图放大并定位到屏幕中间,原图加载好时,再用改变透明度的方法显示出原图。与缩略图加载不同的是,这次只要操作一张图片,所以用上了模糊滤镜(像素化的体验肯定是比不上模糊效果的)。
![%title插图%num 从网格到全屏的过渡](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_79Ncm3sWkgYOrNTn7k7mCg-2.gif)
无论是滚动页面浏览图片,还是在缩略图模式与全屏预览模式间的切换,我们总是希望用户能感受到,虽然最终结果尚未准备好,但浏览器正在努力处理任务。与这种交互理念相反的表现是,当用户点击缩略图的时候,屏幕上没有任何反馈甚至白屏,直到原图完全被加载好。
空 section 也用上了这一理念。我们的网格布局只有在需要显示 section 的时候,才会去加载它(也存在预加载好的一些图片)。如果用户直接拖动滚动条,就会看到还没有加载好的 section 部分,虽然已经预留了空间,但当用户浏览到这个位置时,还对将看到什么图片和什么样的布局没有心理准备。
为了让滚动体验更自然,我们将这些预留好空间的 section 的高度设定为目标行高,并填充上颜色以表示占位。在加载刚刚开始的时候,section 看起来就是一条条灰色的长矩形(下图最左),最近改版成了下图最右那样有行有列的,更接近一张张图片。下图中间表示的是已经加载好但是图片还没有渲染出来的 section。
![%title插图%num 加载过程中的布局变化](https://pmtemple.oss-cn-hangzhou.aliyuncs.com/wp-content/uploads/2019/09/1_ufLlTgBCYdPO0hNF9_lYFg-2.png)
这样的图片加载过程就像追踪兽迹一样,下次使用 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 端的体验。
本文来自medium.com,本文观点不代表 PmTemple 立场,转载请联系原作者。原文链接:https://medium.com/google-design/google-photos-45b714dfbed1
评论列表(1条)
文章不错非常喜欢