查看原文
其他

设计与算法 | Google Photos Web UI

Google Play 谷歌开发者 2020-10-29

作者 / Antin Harasymiv, UX Engineer, Google
* 很多时候,体验设计和算法的联系会比想象中要紧密得多。本文将从代码和体验两个层面和大家深度分享。


几年前,我有幸成为了 Google Photos 团队的工程师,并参加了 2015 年的首发活动。很多人为这个产品做出了贡献,从设计师、产品经理、研究人员,到无数的工程师 (横跨了 Android、iOS、Web 和服务器端),而这还只是参与到这个产品里的主要角色的一部分而已。我负责的是 Web UI,更具体地说,是照片的网格布局。


我们的目标——支持全浏览器宽度自适应布局,保留每张照片原本的宽高比,可随机浏览 (允许用户随时跳转到照片库中的任意位置),能处理数以十万计的照片,以及确保 60fps 的帧率,且做到照片的瞬时加载


在当时没有其他照片库能够做到上面提到的所有特性。据我所知,现在除我们之外仍然没有。虽然许多照片库现在支持其中的一部分功能,但它们通常会对每张照片进行正方形裁剪,这样整个布局才能正常运作。


我们是怎么做的呢?以下是关于我们如何解决这些挑战的技术分享。



难点在哪里?


其中两个最大的挑战来自数据量。


第一个挑战是,对于拥有大量照片的用户 (一些用户上传的照片超过 25 万张),元数据太多了。即使仅发送最精简的信息 (照片 URL、宽度、高度和时间戳),整个照片集都将产生出大量的数据 (MB 级别),而这直接和我们 "瞬时加载" 的目标冲突。


第二个挑战是照片本身。在使用现代 HDPI (高像素密度) 屏幕的情况下,即使是照片缩略图,其体积通常也会达到 50KB 或更多。1,000 个缩略图的文件体积可达 50MB,下载起来相当费劲,而且如果您想要立刻将它们全部放在网页中,就会拖慢浏览器的速度。旧版的 Google+ Photos 滚动浏览 1,000-2,000 张照片后就会变得迟缓,Chrome 的标签页最终会在加载 10,000 张照片后崩溃。


下面我们开始逐项详细讨论,具体分下面几个课题: 
  • 随机浏览: 能够快速跳转到照片库的任何位置。
  • 自适应宽度布局: 用照片填充浏览器的宽度,并保留每张照片的宽高比 (不采用正方形裁切)。
  • 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, // etc... "1999_11": 212}

△ 按月分割,每个月包含一个数值,即该月中照片的数量。

在极端情况下,对于在特定月份拍摄大量照片的用户 (如专业摄影师) 来说,这种做法仍然会有问题——分割处理的目标是将每个区块的元数据量减少到可控的程度,但对于重度用户来说,一个月可能会拍出数千张照片 (这意味着很多 MB 的数据)。不过我们的基础架构团队构建出了一个精巧的解决方案,在分割时整合了各种因素,例如地理位置信息、相近的时间戳等等,从而为每个用户创建出定制的分割规则。


通过分割信息,客户端可以估算每个单元需要占用多少尺寸,并将占位符放入网页 DOM 中,当用户快速滚动时,客户端就会从服务器中检索相应的照片元数据,计算完整布局并更新页面。


在客户端上,一旦我们获得了某个分段的元数据,我们就会更进一步,将每个分段 (section) 中的照片按照日期划分出子分段 (segment)。我们还讨论了动态子分段 (例如按地理位置、人物、日期等要素) 技术,将来有可能会由此发展出很棒的功能。

△ 一个照片集被分割成分段、子分段,以及一个个单元格。
估算一个分段的尺寸是非常简单的,您可以只取一个分段的照片数量,然后将最常用的照片宽高比带进去做乘法即可: 

// Ideally we would use the average aspect ratio for the photoset, however assume// a normal landscape aspect ratio of 3:2, then discount for the likelihood we// will be scaling down and coalescing.const unwrappedWidth = (3 / 2) * photoCount * targetHeight * (7 / 10);const rows = Math.ceil(unwrappedWidth / viewportWidth);const height = rows * targetHeight;


您可能会问: 这样的乘法一点都不精确吧?的确如此,而且还可能差得很远。


幸运的是,我最初把这部分的问题想得过于复杂了 (我将在下文中的布局部分对此加以解释)。事实证明,您不需要估得非常准 (如果照片数量很大,偏差达数十万像素之多都有可能)。唯一重要的,您的估算需要具有大致上的代表性,让滚动条可以大致准确地表达出比例感。


分享一个简单的诀窍: 当您最终加载出了一个分段的照片时,您可以计算实际高度与估测高度之间的差异。如果存在差异,只需将其下方的所有分段根据该差值垂直移动即可。


另外,如果您正在加载滚动点上方的部分,那么您可能还需要更新一下滚动位置 (scroll position)。不用担心,所有这些计算和位移都可以在眨眼间完成,耗时差不多一个动画帧,因此用户不会感到任何异常。

△ 完成实际的加载后,根据高度差值更新布局。



2. 自适应宽度布局


我所知道的所有其他自适应图像布局都使用了一种相对简单的巧妙做法: 它们使用了不同行高的网格。单行中的所有照片都缩放到相同的高度,每行的宽度相同,但任何两行的高度都有可能不同,不过差异通常不太明显。


通过放弃使用统一行高,您可以保留每张照片的宽高比,同时实现拥有均匀间距的自适应宽度网格。实现它的算法不是很难,先用最大的行高进行计算,然后依次将照片缩放到该高度,然后将照片的宽度添加到计数器里,当计数器里的宽度超过了页面宽度时,将行中的每张照片等比例缩小,从而让这一行匹配页面宽度 (自然的,行高会缩水)。


例如,列出 14 张照片,注意看前 3 组照片为了满足固定的宽度而使用了不同的缩放比例:

这个解决方案简单,但有效。Google+ 之前也是这么做的,Google Search 也在用它的一种变体,并且 Flickr 在 2016 年友好地开源了他们的实现过程 (他们的做法更聪明一些,会分别计算在拿掉或者补上一张照片情况下的缩放比例)。代码非常简单,如下所示:
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);
  • Flickr 开源的布局算法
    http://code.flickr.net/2016/04/05/our-justified-layout-goes-open-source/

然而,我最初 (不必要地) 担心过估测值能否与最终布局完全匹配,于是我就去寻找更精巧的解决方案,并且最终获得了更好的解决方案。


我的理论是,一旦估测出了一个分段的尺寸,就应该能够将照片放到该区域里。这本质上是一个文本换行问题,在很多方面非常类似于文本布局 (在文本中加入空白以便实现段落的左右对齐,经常用排版软件的朋友肯定懂了)。Knuth & Plass 断行算法是一套文档清晰的动态布局算法 (在数学上这种算法被称作贪心算法,greedy algorithm),我觉得它会比较适合我们的照片布局。


  • Knuth & Plass 断行算法

    https://onlinelibrary.wiley.com/doi/abs/10.1002/spe.4380111102


这个算法不是逐行进行计算,而是将段落作为整体进行布局,所以每一行都可能受到连续的影响。


它通过 "盒子"、"胶水" 和 "惩罚" 的组合来实现这一点。盒子指的是不可分割的块 (通常是单词,但有时是字符),胶水指的是可以拉伸或缩小的块 (通常是行中的空白),惩罚的目的是用来阻止某些东西 (通常是连字符或换行符)。


我们看个简单的例子。在下图中,您可以看到在不同的行之间,盒子 (单词) 与盒子中间的胶水 (空白) 的大小其实各不相同。

△ 文本左右对齐

照片布局和文本当然不可同日而语,但知道断行算法后问题变得更简单了。在查看文本时,人们可以接受更多的变化——您可以改变单词之间的间距,甚至改变单词中各个字母之间的间距,还可以在单词之中添加连字符。但对于照片而言,如果照片与照片之间的距离不同,就会分散人们的注意力,而且在图片之间添加连字符是一个根本不存在的操作。


关于文本布局算法的运作原理,有人写出了很好的总结。在这里,我来讲解一下我们是如何将它应用在照片布局上的。

  • Knuth & Plass line-breaking Revisited
    http://defoe.sourceforge.net/folio/knuth-plass.html


照片自然就是 "盒子",我们可以完全放弃 "胶水" 这个概念 (因为不希望照片间距不同),也可以简化掉 "惩罚" 这个概念。不过也许更恰当的表述是,照片是盒子同时也是胶水 (也就是说,我们布局中的可变动部分是照片,而不是照片间的空白)。我们的盒子是有弹性的。


我们不改变照片之间的间距,而是遵循其他布局的方法来动态设置行高。大多数情况下,换行的条件可以有很多,照片没填满当前行时换行会加大行高 (按比例扩大以填充宽度) ,照片超出后换行会减小行高 (按比例缩小以便适配宽度)。通过考虑所有可能的行的排列方式,我们就可以找到最适合的组合。


这意味着我们要考虑 3 个主要因素: 理想行高最大收缩系数 (可以相较于理想行高缩小多少),以及最大伸展系数 (可以相较于理想行高放大多少)。


该算法的运作原理是,每次检查 1 张照片,直到所有照片检查完毕,寻找可行的断行位置。也就是说当按比例缩放一组照片以适应宽度时,其高度将落在可接受的范围内 (最大收缩系数 ≤ 理想行高 ≤ 最大伸展系数)。每当它找到可行的断行点时,就会将其添加到可能性列表中,并从那里查找可行的断行组合,直到每张照片和每个可能的行都完成检查为止。


例如下图,对于这 14 张照片,可接受的断行可能会出现 3 或 4 之前,如果我们在照片 3 处换行,那么在照片 6 或 7 那里换行就是可接受的,如果我们在照片 4 处换行,那么在照片 7 或 8 那里换行就是可接受的。它们代表着多个完全不同但都可行的网格布局。

最后,我们需要计算每行的不良系数 (badness)。也就是它不理想的程度: 如果正好满足理想行高,它的不良系数就为 0,该行缩小或放大得越多,不良系数就越高。每一行的最终成本 (cost, 稍后我来详细讲解这个概念) 是按照它的缺陷系数 (demerits) 计算的,缺陷系数通常是不良系数的立方或平方,外加一些惩罚量 (例如换行)。有很多文章都讲到了如何计算不良系数和缺陷系数,在我们的例子中,我们使用每行与最大拉伸/收缩尺寸的乘方 (乘方会对与理想行高相差很远的行施加更严重的惩罚)。


运行算法后,我们最终会得到一个图 (严格来说,是有向无环图),其中每个结点代表一张可能触发换行的照片,每个边代表一行照片 (任何给定结点都可能会有多个边,表示从任何照片开始都可能有多个断行点)。对于每个边,我们都可以给出一个缺陷值。

例如上图,对于我们的 14 张照片而言,设目标行高 180px,给定行宽 1,120px,它找到了 19 种可能的行排列 (边),产生了 12 种网格组合 (即贯通全图的路径)。下面显示的是每个可行的行,以及它可以连接的行。蓝色路线是最不坏的 (缺陷最小) 一条。如果您按照这些路径来布局,您会看到每个组合都构成了一个包含每张照片的可行网格——没有两行是相同的,没有两张照片是相同的。


找到最佳照片网格 (即具有最低缺陷值的网格) 就像计算通过图的最短路径一样简单。


幸运的是,我们生成的图是有向无环图 (DAG, Directed Acyclic Graph),您只能走一个路径,不能重复经过同一个结点或照片。这意味着计算最短路径可以在线性时间内完成。更棒的是,我们实际上可以在生成图的同时计算最短路径。


  • 线性时间
    指计算时间与输入的数据量成正比。设计师在阅读时可以先将这个术语理解为 "算起来会很快" 。


为了计算路径的长度,我们简单地将分配给每一行的成本加起来,每当我们找到连接到结点的新边时,检查它是否为从起点到该结点的最短路径——如果是的话,记住它。


下面是计算机在遍历这 14 张照片时所看到的信息的图示——顶行显示了当前正在计算的照片 (一行的起始和结束照片),下图显示了它发现了哪些断行点,以及哪些边是可以连接的,并且在每个点上,它将以粉红色突出显示当前最短路径。这实际上只是上面那张图的另一种表现形式——盒子之间的每个边都对应上图中的一个行。

从第一张照片开始,它在索引 2 处找到一个可接受的断点,成本为 114。然后在索引 3 处找到另一个可接受的断点,成本高得多,9,483。现在需要检查这两个新索引 (2和3) 可以接下来在哪里换行。它从 2 找到 5 和 6,此时 6 的最短路径通过 2 (114 + 1,442 = 1,556) 返回,因此它被做了标记。当照片 3 找到 6 的路径时,我们再次检查成本,但因为最初达到 3 是如此昂贵,所以总成本 (9,483 + 1,007 = 10,490) 意味着 6 和 2 之间的那条最短路径保持住了优势。接近动画结束时,您可以看到通过 11 的第一条路径不够理想,并在计算节点 8 时做出了切换。


继续执行上述的计算,直到我们到达最后一张照片 (索引 13)。此时,计算出的最短路径 (在动画中以蓝色标出) 就是最佳布局。


那现在我们来比较一下之前提到的简单算法 (下图左侧) 和我们的换行算法 (下图右侧)。两者的目标行高均为 180px。您会有两个有趣的发现,一个是,简单的布局算法得到的实际行高往往会小一些,而我们的换行算法则倾向于大一些——但是换行算法产生了一套整体上更趋近目标行高的网格。

△ 同样使用 180px 作为目标行高,两个算法的对比。
我们在测试中发现,换行算法 (我们将其命名为 FlexLayout) 在客观和主观层面上都可以产生出更理想的网格。它始终如一地产生拥有更均匀高度的网格 (行之间的变化更小),并且平均行高度更接近目标值。而且在涉及到全景照片 (panorama, 特点是超宽的画幅) 和其他边缘情况时表现好得多——这是因为在简单算法中,全景照片会在它被纳入计算时直接被添加进当前行,但如果当前行已经存在不少照片的话,全景照片会被缩得非常小;而 FlexLayout 则会考虑所有可能的断行情况,过度缩小全景照片的断行将拥有较高的缺陷值,从而会更倾向于在某一行单独放置全景照片,或是只放置全景照片和极少数其他图片。

整体上来看,这意味着有一些行会拥有更高的不良系数 (比目标行高多或少几个像素),但这会防止某一行出现跳跃式的不良表现。它最大限度地减少了 "布局意外" 的发生。

有许多因素都会影响可能遍历的布局数量。照片的数量是最大的因素之一,但视图宽度也可以造成影响,此外,不同的缩放比例范围也能产生很大的影响。


通过下图,您可以理解在狭窄、中等和宽阔的视图中布局 25 张照片时的可能性数量。在狭窄的窗口中,只有几个断行点可用,但是我们需要很多行;在中等窗口中有较多的断行点,而在宽窗口中有更多的断行点,但由于计算下来行数却变少了,所以实际上总布局的数量会比较少。

布局可能性的总数也会随着照片数量呈指数增长。对于中等宽度的窗口,我计算了不同照片数量会带来的路径数量,您可以了解一下指数增长的威力:

5 photos = 2 paths 10 photos = 5 paths 50 photos = 24136 paths 75 photos = 433144 paths100 photos = 553389172 paths


对于 1,000 张照片来说,计算机需要计算的量过多,所以它实际上无法计算出布局的精确数量。(但在这种情况下,算法几乎可以瞬间知道它找到了最佳路径,虽然它无法在合理的时间内验证这个结论。这是件有趣的怪事。)


不过我们还是可以估算出布局数量的: 以每行允许的断点平均数量为底数,以可能的行数为指数即可。比如在通常浏览器宽度的情况下每行支持 2-3 个断点,并且大多数行大约有 5 张或更多张照片,您可以使用 2.5 ^ (count / 5) 来估算布局数。


对于 1,000 张照片来说,这个数字的最后会带着 79 个零。1,260 张照片可能拥有的布局数量是 10 的 100 次方 (10^100 = 1 googol)。

简单的布局只考虑单个布局可能性,但换行算法则会考虑数百万、数十亿、数万亿种布局可能性,并从中选择最佳布局。

不过别担心,这个过程很快。计算 100 张照片的布局大约需要千分之二秒 (2毫秒)。计算 1 千张照片需要 10 毫秒,1 万张照片需要 50 毫秒,而 100 万张照片只需要 1.5 秒 (我们已经测试过了)。相比之下,对于上述照片量而言,简单算法分别需要大约 2ms、3ms、30ms 和 400ms ——速度当然更快,但没有快到有实际意义的程度。


算法最初的意图是遍历大量可能的布局来选择最适合可用空间的布局 (也就是使布局与估测相匹配),但我们发现它可以让估算与实际尺寸的偏差最优化,这就意味着我们始终可以为用户提供最佳的网格布局。


这个布局算法表现得很好,后来团队也将其移植到了 Android 和 iOS 平台,并且让三个平台的实现保持同步。


这还没完。我们的最后一个布局技巧是,为每个分段运行两次算法。我们在第一次运行时为所有位于子分段内部的照片完成布局,在第二次时则为所有位于分段内部的子分段完成布局。我们这样做的主要原因是,有时会有非常短的子分段无法填充进一个行,所以在第二次运行布局算法时会把它们合并起来——和照片一样,它会查看所有可能的分组,并选出最理想的那个。

△ 由于拍摄日期比较分散,上面的每一张照片都被划入到了一个单独的子分段里。在第二次运行算法时它们会被拼合起来。



3. 60fps 帧率


布局算法再好,随机浏览再美,如果浏览器渲染不过来也毫无意义。事实上浏览器确实无法单凭自身完成这个计算工作——但我们可以帮助浏览器做到。


网站让人感到缓慢的最大原因 (除了初始加载时间),就是它们对用户交互 (尤其是滚动) 的响应速度。浏览器会尝试每秒刷新屏幕内容 60 次 (即 60fps),如果执行顺利的话,页面看起来就会显得非常流畅,反之就会显得卡顿。


为了保持 60fps 的刷新速度,每次刷新需要在 16 毫秒 (1/60 秒) 内呈现,但这里面浏览器自己就需要占用一些时间——它必须对交互事件进行处理,解析样式信息,计算布局,将所有构图元素渲染为像素,最后再将它们绘制到屏幕上——这样一来,留给应用本身的时间就只剩下 10 毫秒了。

在这 10ms  内,应用需要既能高效完成任务,又要注意别让浏览器执行不必要的工作。

维持一个恒定大小的 DOM
网页性能的一大杀手就是元素过多。这里带来的问题其实有两个: 一是让浏览器消耗了更多内存 (例如,缩略图体积按 50KB 计算时,1,000 张照片缩略图就是 50MB,前面提到的之前足以导致 Chrome 崩溃的 10,000 张照片会占用 0.5GB);此外,浏览器需要针对如此多的元素样式和位置进行单独计算,并在布局期间进行合成,这也是个庞大的负担。


大多数用户的照片库中会有数千张照片,但屏幕区域内通常只能容纳几十张。因此,不需要把每张照片都一直放在页面上,每次用户进行滚动操作时,我们都会计算出哪些照片应该是可见的,并确保它们就在页面文件中。


对于以前在页面中可见但被滚动出可见范围的照片,我们会把它们从页面中摘出来。

△ 不必要的元素会被移除。
滚动页面时,即使用户在数万张图片中滚动,屏幕上同时显示的图片也从不会超过 50 张。这样一来,页面的视觉反馈就始终是灵敏的,页面崩溃的可能性也随之降低。


而且,因为我们将照片分成了分段和子分段,所以我们常常可以走捷径,直接针对整个分段进行操作,而无需针对单张照片。


尽量减少变化
Google Developers 网站上已经有一些关于渲染性能以及如何使用 Google Chrome 内置的强大分析工具的精彩文章,这里我将讨论涉及照片几个方面,首先要了解的是页面渲染的流程:

△ Chrome 的像素渲染流程
每当页面发生变化时 (通常由 JavaScript 触发,但有时是 CSS 样式或动画),浏览器会检查哪些样式适用于受影响的元素,重新计算其布局 (大小和位置),然后绘制所有元素 (即将文本、图像等转换为像素)。为了提高效率,浏览器通常会将页面分成不同的部分 (称作 layers),它会调用 layers 并单独绘制这些部分,最后一步就是合成 (排列) 这些 layers。


  • 了解渲染性能
    https://developers.google.cn/web/fundamentals/performance/rendering/
  • 了解 Chrome 分析工具
    https://developers.google.cn/web/tools/chrome-devtools/evaluate-performance/reference


大多数时候,您根本无需考虑这个流程,浏览器是相当聪明的。但如果您不断更改页面 (例如不断添加或删除照片),那么您就需要用更高效的做法。


我们减小页面变化的一种方法是,将所有内容相对于其父级进行定位。分段 (section) 被定位为与整体网格 (grid) 绝对相关,子分段 (segment) 被定位为与它们所对应的分段绝对相关,而照片单元格 (tile) 则被定位为与子分段 (segment) 绝对相关。
△ 分段、子分段以及单元格都使用了绝对定位。
  • 绝对定位
    https://developer.mozilla.org/en-US/docs/Web/CSS/position


这意味着,当估算和实际的布局高度不同,导致我们需要移动一个分段时,我们不需要对它下面的每张照片进行数百 (或数千) 次更改,只需更新这个分段下面其他分段的纵坐标即可。这个结构有助于将网格的每个部分与不必要的更新隔离开来。


现代 CSS 甚至提供了一种告知浏览器的方法——使用 contain 关键字可以指示一个元素相对于 DOM 的独立程度。我们也会针对 section 和 segment 进行相应的标注。

/* Indicates that nothing outside the element may affect its internal layout and vice versa. */contain: layout;


还有一些容易踩到的性能陷阱。例如,滚动事件可以在一个帧内多次触发,调整窗口大小也存在相同的问题。如果您还会对这些事件导致的布局作出第二次甚至更多次计算,则无需强制浏览器计算第一次事件发生时导致的样式和布局变动。


幸运的是,有一种方便的方法可以避免这种情况。您可以使用 window.requestAnimationFrame(callback) 让浏览器在下次重绘之前执行特定的功能。在处理滚动和调整窗口尺寸时,我们就只安排了单个回调而不是立即更新,在调整窗口尺寸时,我们还更进一步,设置了半秒的更新延迟,直到用户确定最终窗口大小。


第二个常见的陷阱是布局抖动 (layout thrashing)。一旦浏览器计算出布局,它就会对其进行缓存,因此您可以轻松请求任何元素的宽度、高度或位置。但是,如果对可能影响布局的属性 (例如宽度、高度、横纵坐标位置) 进行任何更改,则会立即使该缓存无效,如果您再次尝试读取其中一个属性,浏览器将被强制重新计算布局 (可能在同一帧中多次进行计算)。

  • window.requestAnimationFrame(callback)
    https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
  • 布局抖动
    https://developers.google.cn/web/fundamentals/performance/rendering/avoid-large-complex-layouts-and-layout-thrashing


真正可能出现问题的是,当循环中有许多元素的更新 (例如数百张照片) 的时候,如果在每个循环中您读取一个布局属性,然后更改它们 (比如将照片或分段移动到正确的位置),那么您就在循环中的每个步骤里触发了新的布局计算。


避免这种情况的简单方法是首先读取所需的所有值,然后统一写入所有值 (也就是说,对读取和写入进行分离和分批处理)。具体到我们的操作中,我们甚至会避免读取值,而是计算每张照片应有的大小和位置,并对其进行绝对定位。在滚动页面或调整窗口大小时,我们可以根据我们一直跟踪的位置重新运行所有计算,并安全地更新,因为我们知道这样一来就永远不会发生布局抖动了。一个典型的滚动动画帧看起来应该是这样的 (即所有计算都只调用了一次):
△ 滚动造成的更新所触发的渲染和绘制过程
避免耗时长的代码
除了 Web Workers 和一些本地异步处理程序 (如 Fetch API) 之外,一个浏览器标签中的所有内容基本上都在同一个线程上运行——包括渲染和 JavaScript。这意味着开发者运行的任何代码都会阻止页面重绘,直到这些代码被执行完毕。例如长时间运行的滚动事件处理程序。


我们的网格布局做的最耗时的两件事情就是布局和元素创建。我们尝试着限制这两件事情,只让它们做最必要的操作。


例如,1,000 张照片的布局算法需要 10 毫秒,10,000 张需要 50 毫秒——这可能会耗尽我们在帧内的计算时间。然而,因为我们将网格细分为多个分段 (section) 和子分段 (segment),这让我们通常只需要布局几百张照片,仅需 2-3 毫秒。


最 "昂贵" 的布局事件应该是调整浏览器窗口大小,因为这需要我们重新计算每个部分的大小。所以,我们采用了简单的估算,即使对于已加载的部分,也只对当前可见的部分执行完整的 FlexLayout 算法。然后我们可以推迟其他部分的完整布局计算,直到用户滚动看到它们为止。


元素创建也是如此——我们只在确有需要之前创建照片单元格。


我们做到的结果
所有艰苦工作的最终结果是,在大多数情况下,我们的网格布局可以保持 60fps,即使它偶尔会丢帧。


丢帧现象通常在主要布局事件发生时 (例如插入一个全新的分段) 或偶尔在浏览器对非常旧的元素执行垃圾回收时产生。

△ 网格布局的帧率只在极个别情况下出现抖动。



4. 瞬时加载


我猜大多数前端工程师都会同意,在很多优秀的 UI 中,一些 "花招" 都扮演着重要的角色。这里面的诀窍在于,使用怎样的 "障眼法",如何 "瞒过" 用户的眼睛。


谈到这方面,我最喜欢的一个例子来自 YouTube 的同事与我分享的秘密: 那就是导航进度条 (当您跳转页面时出现在最顶部的红色进度条),实际上他们根本不知道实际的加载进度,因此他们只是按照大多数页面所用的速度绘制了一套动画,然后这个进度条会在接近终点的时候 "挂起",直到页面实际加载完成。我不知道现在 YouTube 的进步条是否还在玩这一招,但重点是,"真实的" 加载进度在这个体验里并不是关键要素。
△ YouTube 的加载进度条。

精确有时候并不重要,重要的是这种做法会让观众感觉网页正在做出灵敏的响应。


这里我将分享一些我们用来使 Google Photos 看起来比实际更快的技巧——主要讲讲我们是如何对图片加载时间进行伪装的。


我们做的第一件事,也许也是最有效的事,就是预先加载我们认为您将要看到的内容。


在加载完所有可见的图块之后,我们会试着在您滚动页面之前就先加载好下一个页面中的缩略图,以便在您滚动时能看到。
但是,特别是对于 HDPI 屏幕 (这意味着需要加载更大尺寸的缩略图) 而言,如果您快速滚动,网络连接可能无法及时满足所有这些请求。因此如上图所示,我们会在 4 到 5 个全屏高度内加载极小尺寸的占位缩略图,并在它们靠近可视区域时用真正的缩略图替换它们。


这意味着,如果您的滚动速度相对较慢 (按照正常查看图片的速度进行滚动),那么您应该永远无法察觉到加载过程,如果您快速滚动页面 (这个速度意味着您正在搜寻照片),我们也可以为您提供足够的视觉线索来帮助您进行搜寻。


是要多做一些额外的加载工作来获取 "过量" 的内容,还是要提供更好的整体体验?这是一个复杂的权衡过程。


我们考虑了一些因素。第一是计算滚动方向,只针对用户前进方向的内容进行单向预加载。我们还会测量滚动速度,并在我们认为您正在高速滚动时放弃加载全分辨率缩略图而使用占位缩略图。而如果您正在更快速地转动滚轮,那占位缩略图的加载也会被放弃,直接使用单元格的灰色背景。


另外,在每种情况下我们都有在缩放图像。如今的现代化屏幕拥有极高的分辨率,在这种情况下,想要确保图像看起来清晰,常见做法是加载一个比图片的屏幕尺寸大两倍的图像,然后缩小它 (因此实际像素比它所占用的空间多)。对于低分辨率占位缩略图,我们只加载非常小的图像,并且压缩质量较低 (例如 25%),然后进行放大。


举个例子,这是一只正在打瞌睡的豹子——当图片单元格完全加载时,我们使用最左边的图像,并缩小到原先的一半大小。而当您快速转动滚轮时,我们只加载右侧左边的低分辨率占位缩略图,并把它放大后放置在单元格里。

△ 正常缩略图与低分辨率缩略图,以及其缩放。
此外,还要观察字节大小。HDPI 缩略图的体积为 71.2KB (gzip),而低分辨率占位缩略图的体积仅为 889B (gzip)——相差 80 倍!换句话说,网格中一个完整分辨率的单张缩略图占用的内存差不多相当于 4 屏的占位缩略图。

只需额外增加一点点网络流量,我们就可以为用户提供更好的体验,让照片网格时刻显示出内容,并始终为用户提供视觉线索。

关于低分辨率缩略图的最后一点小技巧是,我们会指定浏览器渲染它们的方式。在默认情况下,当您放大图像时,浏览器会稍微加上一点模糊效果 (参见下方中图),但这样看起来效果不太好。您可以应用模糊滤镜 (参见最右图),让它看起来更像是刻意呈现出的视觉效果,但这样做的缺点是,滤镜的计算成本过高,如果将其应用于数百个元素,则会对渲染和滚动性能产生负面影响。所以我们决定反其道而行之,要求浏览器将图像保持像素化 (参见最左图)。我不是很确定我们现在的产品是否还在采用这种做法,因为产品已经进行了一些重构。

△ 浏览器针对低分辨率图片采用不同渲染方式时呈现的结果
虽然我们希望用户永远不会看到低分辨率图像 (快速滚动期间除外),但是当它们在可视区域中被显示出来时,我们会使用一个过渡动画让用户觉得低分辨率缩略图会经过一个过程加载成正常缩略图。这很容易,只需要把两张图片叠在一起然后修改其透明度即可。这种渐变技术在整个网络中非常普遍。


这样一来,用户看起来就会觉得图像正在加载,如下图。而这个过程很迅速,用时不足 100 毫秒,这个时间刚好足够我们处理读取和渲染的事情。下图的动画是放慢过的,让大家更容易观看。

△ "加载" 动画,慢速播放。其实只是修改了叠加图片的透明度。
这里多说一点。从缩略图转换到全屏单张照片的视图时,我们也使用了这种技巧。当用户点击一个缩略图时,我们立即开始加载完整图片,同时进行缩放,并为缩略图设置透明度动画,当完整图片加载完毕时,我们会让它和缩略图重叠,并播放透明度动画。唯一的区别是,由于这里我们只将视觉效果应用于单个元素,所以我们能够使用更昂贵的模糊滤镜。而且这样做效果也更好,因为在大图上看马赛克的观感并不好。

△ 从图片网格切换到单图视图的转场
在任何时候,当用户滚动浏览照片或切换到全屏视图时,我们都试图提供流畅的体验,即使这时内容尚未真正就绪,我们也会让用户始终感觉页面正在响应他们的输入。您可以和其他实现方式做一下脑内对比: 单击某个缩略图,它会显示一个空白屏幕,或者在载入完整照片之前不响应任何操作。


我们甚至将这个概念应用于网格的空白部分 (即未加载部分)。我们之前讲过,我们的随机浏览网格只在需要时才会加载分段。这意味着,即便您直接拖拽滚动条,在相册中高速前进时,网格也已为分段们预先分配了空间,只是尚不知道那里有什么照片或更具体的内部布局是什么而已。


为了使滚动感觉更自然,我们在尚未加载的分段里填充了一个纹理,看起来像是一行一行的灰块 (见下图左)。团队最近将纹理改成行和列的灰块 (见下图右)。下图中间的图片是当分段已经加载完成但是单元格里的缩略图还没有加载时的样子。

△ 左: 初版分段尚未加载时的视觉;中: 分段已加载但单元格缩略图尚未加载;右: 2018 年时分段尚未加载时的视觉

产品总是会一直在演进,下次您在 Google Photos 中进行滚动翻阅时,看看是否可以发现新的变化。

注意上面您看到的那些灰块,它们实际上是使用 CSS 创建的,而不是使用图片。这样做的好处很多,比如可以动态设置宽度和高度,以匹配用于网格的目标行高。

/* Until the section is loaded make it look like rows of 4:3 photos. */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 次左右下载,这样如果用户突然开始滚动页面,我们也不至于被迫一口气放弃已经下载好的 90 张图。同样,相较于屏幕外的缩略图,我们始终优先加载位于可见区域内的缩略图。


我们甚至会考虑复用已经加载过的缩略图——这主要发生在调整浏览器窗口尺寸的时候。因为在用户完成窗口缩放后,新的网格里的各个单元格在尺寸上和之前差距往往不会特别大。这时我们不会再重新下载每张照片,而是会略微缩放我们已有的缩略图,只有在差异太大时才会选择重新下载。



最后


Google Photos 体验的每个细节都投入了大量的思考与取舍,今天和大家分享的照片网格布局只是产品中的一部分。


虽然网格看起来很简单,甚至是静止的,但它几乎总是在思考——加载、预加载、动画、创建、移除,以及选择呈现内容的最佳方式。


保持网格良好运行,并不断迭代改进一直是团队工作中的优先事项。我们设置了全面的量化系统,来衡量滚动帧率、分段以及图像加载时间,以及许多其他指标,每年都会不断改进性能和体验。


最后让我们看一段录屏,来整体上感觉一下 Google Photos 的体验。在低速滚动时,您只能看到全分辨率的缩略图,在开始加速滚动时,您可能会开始看到像素化的占位缩略图,如果您此时再降低滚动速度,那些像素化的占位图就会开始逐渐变得清晰,如果您以飞快的速度滚动,则会看到空白的灰色块,随后更准确的单元格细节才会逐渐跟上。

非常感谢我的上一任主管 Vincent Mo,他为本文提供了大量支持,还拍摄了文章中用到的所有精彩照片,这些照片也在产品开发过程中用作测试图集。此外我还要感谢 Photos Web 主管 Jeremy Selier 和他的团队,他们如今正在继续维护和改进 Google Photos Web UI。


最后,感谢大家读完本文!这是一次非常细致的分享,我希望您不会觉得枯燥和漫长。如果您在体验设计以及界面虚拟化上有任何经验或者想法,欢迎在评论区和大家分享。



 点击屏末 | 阅读原文 | 进入 Material Design 开发技术专区


  想了解更多 Material Design 内容?


  • 在公众号首页发送关键词 “Material Design” ,获取相关历史技术文章;

  • 还有更多疑惑?欢迎点击菜单 “联系我们” 反馈您在开发过程中遇到的问题。


推荐阅读





    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存