几年前,我有幸成为了 Google Photos 团队的工程师,并参加了 2015 年的首发活动。很多人为这个产品做出了贡献,从设计师、产品经理、研究人员,到无数的工程师 (横跨了 Android、iOS、Web 和服务器端),而这还只是参与到这个产品里的主要角色的一部分而已。我负责的是 Web UI,更具体地说,是照片的网格布局。
我们的目标——支持全浏览器宽度自适应布局,保留每张照片原本的宽高比,可随机浏览 (允许用户随时跳转到照片库中的任意位置),能处理数以十万计的照片,以及确保 60fps 的帧率,且做到照片的瞬时加载。
在当时没有其他照片库能够做到上面提到的所有特性。据我所知,现在除我们之外仍然没有。虽然许多照片库现在支持其中的一部分功能,但它们通常会对每张照片进行正方形裁剪,这样整个布局才能正常运作。
我们是怎么做的呢?以下是关于我们如何解决这些挑战的技术分享。
难点在哪里?
-
随机浏览: 能够快速跳转到照片库的任何位置。
-
自适应宽度布局: 用照片填充浏览器的宽度,并保留每张照片的宽高比 (不采用正方形裁切)。
-
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;
2. 自适应宽度布局