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

8 天前

原文地址:https://medium.com/google-design/google-photos-45b714dfbed1

见微知著,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张图片时浏览器标签页就直接崩溃。

下面我将分成四个部分回溯我们是如何解决这两个问题的:

  1. “独立”的图片 — 迅速定位到图片库中的指定位置。

  2. 自适应布局 — 根据浏览器宽度,尽可能铺满图片且要保留图片的原始比例(不做正方形裁剪)。

  3. 60fps 的流畅滚动 — 巨大数据量面前,也要保证页面交互的流畅。

  4. 及时反馈 — 加载时间最小化。

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
}

如果由用户(比如摄影师)在同一个时间段内就能产出大量图片,这个方案还是有缺陷的 —— 将数据分为一个个模块的原因是方便处理元数据,但对于重度用户来说,每个月的数据量依然极大。伟大的基础服务团队想到了解决方案 —— 允许用户创建自定义的分类方式(比如地点、时间戳...)。

图片网格被分为 section 和 segment

有了这些信息之后,我们就能给每个模块占位了。当用户快速滚动页面时,客户端获取到对应的图片元数据,计算出完整的布局并更新页面。

在浏览器端,拿到了模块的元数据后,我们会将照片按照日期度再做一次整理。我们讨论过的动态分组(比如根据位置、人物、日期…)也将是很棒的特性。

现在预估模块的尺寸就很简单了,通过照片数量和预估的单张照片的比例后,进行计算:

// 理想情况下,我们应该先计算出当前模块的比例均值
// 不过我们先假设照片比例是 3:2,
// 然后在它的基础上做一些调整
const unwrappedWidth = (3 / 2) * photoCount * targetHeight * (7 / 10);
const rows = Math.ceil(unwrappedWidth / viewportWidth);
const height = rows * targetHeight;

你可能猜到了,这样的估算结果并不准确,甚至偏差相当大。

我一开始把问题复杂化了(布局环节将会详细聊到),但从结果来看一开始也未必需要得到准确的数值(在照片数量很大的情况下,甚至能偏差上千像素)。我们之所以要做估算,也是为了保证滚动条位置,事实证明即使如此粗略,滚动条的定位依然能用。

02.39ad6a0ca291.gif

这里有个小技巧,当模块真正被加载出来的时候,浏览器也就知道了实际需要的占位高度和预估占位高度之间的差,只要直接将页面剩余模块向下移动高度差的距离就行了。

如果要加载的模块在视口之上,那么模块加载好后还需要更新滚动条的位置。所有的更新操作可以在一秒内用一个动画帧完成,对用户造成的影响并不大,速度如果够快用户甚至是无感知的。

2. 自适应布局

据我所知,市面上主流的图片自适应布局都采用了一种巧妙由又简便的方法:每行高度不同但都占满视口,同一行内的图片根据宽高比缩放,以确保同一行内的图片高度。用户也不会容易注意到行与行之间的高度差。

放弃把所有图片的高度都变成一样的,保证原图的比例,再固定图片之间的间距。实现起来也不难,找到最高的行,按照宽高比缩放每张照片,更新当前网格宽度,如果发现要超过视口宽度了,就按照比例缩小该行内每一张图片,当然此时这一行的高度也会变小。

比如有14张图片的时候:

03.8c689a97f693.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 宽度是不定的:

文本的布局 —— 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 张图片的布局方案将会生成的图。在窄屏下的换行点可选余地不多,但会产生的行数很多。随着屏幕变宽,同一行的换行点可能性变多,相应地行数会减少,布局的可能性也会减少。

随着图片的增多,布局方案的数量会指数倍的增长。在中等宽的视口里,不同的图片数量,对应的路径数如下:

  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。首先来了解一下页面渲染的生命周期: 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 是否把加载动画和页面实际加载进度对应起来了,但它的整体思路是这样的。

16.4759e60d9537.png

加载进度的精确性是次要的,最重要的是要让用户切实感受到,这个页面进度是在往前走着的。

这一节中我将会分享一些技巧,让用户觉得 Google Photos 用起来很流畅(比真实情况要更流畅)—— 大部分技巧都和图片加载有关。

第一件事,也可能是最有效的,用户最可能看到的内容会被最先加载。

17.62d448543e50.png

在加载好视口范围内的图片后,还会再额外加载一屏图片,为了保证下次用户滚动页面时能立刻看到新的图片。

但是对于 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 页面的录屏。当用户慢慢浏览页面时,能看到清晰的缩略图;当提高滚动速度时,看到的就是像素化的占位图,当再次回到慢速滚动时高清图又显示出来了;而飞速划过页面时,看到的就是灰色的占位色块了。滚动速度不同加载效果不同23.7aea417706b6.png

感谢我在 Google Photos 时的领导 Vincent Mo,他一直非常支持我们,而且本文中所用到的照片都是由他拍摄的(产品测试阶段同样也用了 Vincent 拍的照片)。感谢 Jeremy Selier,Google Photos Web 端的负责人,现在他正带领着团队持续维护并提升 Google Photos Web 端的体验。

1
推荐阅读