WWDC Quick Look 💓 By SwiftGGTeam
Make blazing fast lists and collection views

Make blazing fast lists and collection views

观看原视频

Highlight

Apple 在 iOS 15 为 UITableView 和 UICollectionView 改进了 cell 预取、diffable data source 更新和 UIImage 预备解码 API,开发者可以减少滚动掉帧、异步内容错位和大图解码卡顿。

核心内容

很多应用的首页就是列表。旅行 App 展示目的地照片,社交 App 展示帖子,电商 App 展示商品卡片。每个 cell 看起来很简单:一张图,两行文字。用户一滑动,问题就暴露出来。

旧做法把太多工作放在 cell 即将显示的那一帧。collection view 需要取 cell、配置内容、计算布局,再把结果提交给渲染系统。如果这次提交超过当前屏幕刷新率给出的时间窗口,用户看到的就是一次停顿。

iOS 15 先改了系统层面的预取。UITableView 和 UICollectionView 会利用滚动过程中的空闲时间,提前准备下一个 cell。等它真正进入屏幕时,系统只需要显示已经准备好的结果。

这项优化不要求你改业务代码。只要用 iOS 15 SDK 构建,系统就能启用新的 cell 预取机制。它覆盖 UICollectionView 的列表、其他 compositional layout,也覆盖 UITableView。

系统预取只能帮你争取时间。cell 配置如果很慢,或者图片在主线程解码,仍然会掉帧。所以 Apple 又给出三条配套规则:数据源保存稳定 ID,异步内容用 reconfigureItems 更新,图片先用 prepareForDisplayprepareThumbnail 准备好。

稳定 ID 让 diffable data source 少做事

Diffable data source(差异化数据源)保存的是模型标识符。它不应该保存整个模型对象。这样,点赞数、标题、图片状态变化时,item 的身份仍然稳定。

稳定 ID 的好处很直接。snapshot 发生变化时,系统可以只处理真正变化的项目。iOS 15 还改进了无动画 apply 的行为:以前可能内部走 reloadData,现在会应用差异,避免丢弃并重建屏幕上的所有 cell。

cell registration 必须创建一次

Cell registration(cell 注册)把某类 cell 的配置逻辑集中在一起。它会收到 diffable data source 传来的 item ID,然后从你的 store 取出模型和图片。

这里有一个容易踩的坑:registration 要在 cell provider 外面创建。如果每次 provider 调用都创建新的 registration,collection view 就无法复用原来的 cell。

异步图片不能直接抓住 cell

远程图片加载完成时,原来的 cell 可能已经被复用给另一个帖子。直接在回调里写 cell.imageView.image = ...,会把图片放到错误的位置。

iOS 15 的 reconfigureItems 解决这个问题。图片下载完成后,只告诉 data source 哪个 ID 需要重新配置。系统会重新运行 registration handler,并复用现有 cell。

大图要提前准备成可显示位图

图片文件通常是 JPEG、PNG 或 HEIC。UIImageView 把新图片提交到屏幕时,需要把压缩格式解码成像素数据。这个过程可能发生在主线程,时间一长就会错过提交期限。

iOS 15 的 UIImage preparation API 允许你提前做这件事。prepareForDisplay 生成已经准备好的 UIImageprepareThumbnail 还会按目标尺寸缩小图片,节省 CPU 和内存。

详细内容

01:25)用稳定 ID 组织列表数据

Apple 的示例 App 用 DestinationPost 表示一条旅行目的地帖子。这个类型遵守 Identifiable,核心字段是稳定的 id

struct DestinationPost: Identifiable {
    // Each post has a unique identifier
    var id: String

    var title: String
    var numberOfLikes: Int
    var assetID: Asset.ID
}

关键点:

  • DestinationPost: Identifiable 表示每条帖子都有可识别身份。
  • id 是每条帖子的唯一标识,其他属性变化时它保持不变。
  • titlenumberOfLikes 是会变化的内容,不适合拿来当 diffable data source 的 item 身份。
  • assetID 把帖子和图片资源关联起来,cell 配置时再去资源 store 取图。

接下来,data source 使用 DestinationPost.ID 作为 item identifier。

class DestinationGridViewController: UIViewController {
    // Use DestinationPost.ID as the item identifier
    var dataSource: UICollectionViewDiffableDataSource<Section, DestinationPost.ID>

    private func setInitialData() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, DestinationPost.ID>()

        // Only one section in this collection view, identified by Section.main
        snapshot.appendSections([.main])

        // Get identifiers of all destination posts in our model and add to initial snapshot
        let itemIdentifiers = postStore.allPosts.map { $0.id }
        snapshot.appendItems(itemIdentifiers)

        dataSource.apply(snapshot, animatingDifferences: false)
    }
}

关键点:

  • UICollectionViewDiffableDataSource<Section, DestinationPost.ID> 把 item 类型限定为帖子 ID。
  • NSDiffableDataSourceSnapshot<Section, DestinationPost.ID>() 创建同样使用 ID 的 snapshot。
  • snapshot.appendSections([.main]) 添加唯一 section。
  • postStore.allPosts.map { $0.id } 从模型数组提取稳定标识符。
  • snapshot.appendItems(itemIdentifiers) 只把 ID 放入 snapshot。
  • dataSource.apply(snapshot, animatingDifferences: false) 应用初始数据;iOS 15 起,无动画 apply 会应用差异,减少额外重建工作。

03:47)用 cell registration 集中配置 cell

Cell registration 的 handler 会收到 cellindexPathpostID。示例里用 postID 查出帖子,再用 assetID 查出图片资源。

let cellRegistration = UICollectionView.CellRegistration<DestinationPostCell,
                                                         DestinationPost.ID> {
    (cell, indexPath, postID) in

    let post = self.postsStore.fetchByID(postID)
    let asset = self.assetsStore.fetchByID(post.assetID)

    cell.titleView.text = post.region
    cell.imageView.image = asset.image
}

关键点:

  • UICollectionView.CellRegistration<DestinationPostCell, DestinationPost.ID> 声明 cell 类型和 item ID 类型。
  • handler 的 postID 来自 diffable data source。
  • postsStore.fetchByID(postID) 用 ID 取回完整帖子数据。
  • assetsStore.fetchByID(post.assetID) 用资源 ID 取回图片资源。
  • cell.titleView.text = post.region 设置标题内容。
  • cell.imageView.image = asset.image 设置当前可用图片,可能是占位图。

使用 registration 时,把它传给 dequeueConfiguredReusableCell

let cellRegistration = UICollectionView.CellRegistration<DestinationPostCell,
                                                         DestinationPost.ID> {
    (cell, indexPath, postID) in
    ...
}

let dataSource = UICollectionViewDiffableDataSource<Section.ID,
                                                    DestinationPost.ID>(collectionView: cv){
    (collectionView, indexPath, postID) in

     return collectionView.dequeueConfiguredReusableCell(using: cellRegistration,
                                                           for: indexPath,
                                                          item: postID)
}

关键点:

  • cellRegistration 在 data source 的 cell provider 外面创建。
  • UICollectionViewDiffableDataSource 的 cell provider 负责返回 cell。
  • dequeueConfiguredReusableCell 会取出可复用 cell,并运行 registration handler。
  • item: postID 把当前 item ID 传给 handler。
  • registration 如果在 provider 内部重复创建,collection view 会为每个 registration 维护新的复用队列,复用效果会变差。

08:22)让系统用空闲帧提前准备 cell

这段没有新的业务 API。核心变化在 UIKit 内部:iOS 15 的 UITableView 和 UICollectionView 会在短 commit 后利用剩余时间预取 cell。

// Build with the iOS 15 SDK to enable the new cell prefetching behavior
// in UITableView and UICollectionView.

关键点:

  • cell 生命周期分成 preparation(准备)和 display(显示)。
  • preparation 包括取 cell、运行 cell provider、执行 registration handler、查询布局属性和计算尺寸。
  • iOS 15 会把 preparation 提前到 cell 上屏之前。
  • prepared cell 可能永远不显示,例如用户突然反向滚动。
  • 同一个 prepared cell 离屏后也可能进入等待状态,之后再次显示给同一个 index path。
  • heavy work 应放在 preparation 阶段完成,不要等 willDisplay 再做。

14:17)不要在异步回调里直接更新旧 cell

远程图片场景里,资源 store 先返回占位图。错误写法是在下载回调里直接改 cell

let cellRegistration = UICollectionView.CellRegistration<DestinationPostCell,
                                                         DestinationPost.ID> {
    (cell, indexPath, postID) in

    let post = self.postsStore.fetchByID(postID)
    let asset = self.assetsStore.fetchByID(post.assetID)

    if asset.isPlaceholder {
        self.assetsStore.downloadAsset(post.assetID) { asset in
            cell.imageView.image = asset.image
        }
    }

    cell.titleView.text = post.region
    cell.imageView.image = asset.image
}

关键点:

  • asset.isPlaceholder 表示当前资源仍为占位图。
  • downloadAsset 发起异步下载。
  • 回调捕获了 cell 对象。
  • cell 会被复用;下载完成时,这个对象可能已经代表另一个帖子。
  • 这段代码会导致图片错位。

15:15)用 reconfigureItems 重新运行配置逻辑

正确做法是把更新请求交还给 data source。iOS 15 的 reconfigureItems 会重新配置已有 cell,避免重新 dequeue 一个新 cell。

private func setPostNeedsUpdate(id: DestinationPost.ID) {
    var snapshot = dataSource.snapshot()
    snapshot.reconfigureItems([id])
    dataSource.apply(snapshot, animatingDifferences: true)
}

关键点:

  • dataSource.snapshot() 取出当前 snapshot。
  • snapshot.reconfigureItems([id]) 标记指定 item 需要重新配置。
  • dataSource.apply 应用这次配置更新。
  • reconfigureItems 会复用 item 现有 cell,并重新运行 registration handler。
  • 它适合内容变化但 item 身份不变的场景。

下载完成后,只调用 setPostNeedsUpdate

let cellRegistration = UICollectionView.CellRegistration<DestinationPostCell,
                                                         DestinationPost.ID> {
    (cell, indexPath, postID) in

    let post = self.postsStore.fetchByID(postID)
    let asset = self.assetsStore.fetchByID(post.assetID)

    if asset.isPlaceholder {
        self.assetsStore.downloadAsset(post.assetID) { _ in
            self.setPostNeedsUpdate(id: post.id)
        }
    }

    cell.titleView.text = post.region
    cell.imageView.image = asset.image
}

关键点:

  • handler 每次都先按 postID 获取当前帖子。
  • 如果拿到占位图,就开始下载完整资源。
  • 下载回调不再持有并修改旧 cell。
  • setPostNeedsUpdate(id: post.id) 只通知 data source 重新配置这个帖子。
  • 第二次运行 handler 时,fetchByID 会返回完整图片资源。
  • view 更新逻辑仍然集中在 registration handler 里。

15:52)用 data source prefetching 提前下载内容

系统会提前准备 cell。你的数据也可以提前准备。UICollectionViewDataSourcePrefetching 适合启动网络请求,降低占位图显示时间。

var prefetchingIndexPaths: [IndexPath: Cancellable]

func collectionView(_ collectionView: UICollectionView,
                    prefetchItemsAt indexPaths [IndexPath]) {
   // Begin download work
    for indexPath in indexPaths {
        guard let post = fetchPost(at: indexPath) else { continue }
        prefetchingIndexPaths[indexPath] = assetsStore.loadAssetByID(post.assetID)
    }
}

func collectionView(_ collectionView: UICollectionView,
                    cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
    // Stop fetching
    for indexPath in indexPaths {
        prefetchingIndexPaths[indexPath]?.cancel()
    }
}

关键点:

  • prefetchingIndexPaths 保存 index path 与下载任务的对应关系。
  • prefetchItemsAt 在 cell 需要显示前被调用。
  • fetchPost(at:) 根据 index path 找到帖子。
  • assetsStore.loadAssetByID(post.assetID) 开始加载资源。
  • cancelPrefetchingForItemsAt 在预取不再需要时被调用。
  • cancel() 停止对应下载,避免浪费网络和电量。

18:43)用 prepareForDisplay 提前准备大图

图片显示前必须解码成位图。iOS 15 的 prepareForDisplay 可以提前完成这一步,完成后再回主线程更新 image view。

// Initialize the full image
let fullImage = UIImage()

// Set a placeholder before preparation
imageView.image = placeholderImage

// Prepare the full image
fullImage.prepareForDisplay { preparedImage in
    DispatchQueue.main.async {
       self.imageView.image = preparedImage
    }
}

关键点:

  • fullImage 是原始大图。
  • imageView.image = placeholderImage 先显示廉价占位图。
  • prepareForDisplay 在后台准备适合显示的图片。
  • 回调里的 preparedImage 是已经准备好的 UIImage。
  • DispatchQueue.main.async 回到主线程更新 UI。
  • 设置 prepared image 时,image view 不需要再做昂贵准备工作。

在下载路径里,可以把准备和缓存放在 completion handler 之前。

func downloadAsset(_ id: Asset.ID,
                   completionHandler: @escaping (Asset) -> Void) -> Cancellable {
    // Check for an already prepared image
    if let preparedAsset = imageCache.fetchByID(id) {
        completionHandler(preparedAsset)
        return AnyCancellable {}
    }
    return fetchAssetFromServer(assetID: id) { asset in
        asset.image.prepareForDisplay { preparedImage in
            // Store the image in the cache.
            self.imageCache.add(asset: asset.withImage(preparedImage!))
            DispatchQueue.main.async {
                completionHandler(asset)
            }
        }
    }
}

关键点:

  • imageCache.fetchByID(id) 先查找已经准备好的图片资源。
  • 找到缓存后直接执行 completionHandler
  • AnyCancellable {} 返回一个可取消对象,保持方法签名一致。
  • fetchAssetFromServer 下载原始资源。
  • asset.image.prepareForDisplay 在下载后准备图片。
  • imageCache.add(asset: asset.withImage(preparedImage!)) 把准备后的图片放入内存缓存。
  • DispatchQueue.main.async 确保 completion handler 在主线程回调。

20:50)用 prepareThumbnail 为目标尺寸准备缩略图

头像和小图不需要完整大图。prepareThumbnail(of:) 会按目标尺寸缩放并准备图片,减少 CPU 和内存占用。

// Initialize the full image
let profileImage = UIImage(...)

// Set a placeholder before preparation
posterAvatarView.image = placeholderImage

// Prepare the image
profileImage.prepareThumbnail(of: posterAvatarView.bounds.size) { thumbnailImage in
    DispatchQueue.main.async {
        self.posterAvatarView.image = thumbnailImage
    }
}

关键点:

  • profileImage 是原始图片。
  • posterAvatarView.image = placeholderImage 先显示占位图。
  • posterAvatarView.bounds.size 提供目标显示尺寸。
  • prepareThumbnail(of:) 按目标尺寸准备缩略图。
  • 回调得到 thumbnailImage
  • 主线程把缩略图设置到头像视图。

核心启发

  1. 做什么:给图片瀑布流加稳定 ID 数据层。 为什么值得做:diffable data source 依赖稳定 identifier;内容变化时可以只重新配置对应 item。 怎么开始:让模型遵守 Identifiable,snapshot 只保存 Model.ID,cell registration 中再用 ID 查 store。

  2. 做什么:把远程图片列表改成占位图 + reconfigureItems为什么值得做:下载回调直接更新 cell 容易错位;reconfigureItems 会按 item ID 重新运行配置逻辑。 怎么开始:资源 store 返回 placeholder asset,下载完成后调用 snapshot.reconfigureItems([id])

  3. 做什么:为首页大图建立内存级 prepared image cache。 为什么值得做prepareForDisplay 能提前完成图片显示所需的位图准备,减少主线程提交压力。 怎么开始:在下载完成后调用 asset.image.prepareForDisplay,把 asset.withImage(preparedImage) 放入 cache。

  4. 做什么:给头像、商品缩略图、相册网格使用目标尺寸缩略图。 为什么值得做prepareThumbnail(of:) 会按视图尺寸处理图片,节省 CPU 时间和内存。 怎么开始:用 image view 的 bounds.size 作为目标尺寸,准备完成后回主线程设置图片。

  5. 做什么:为列表网络资源接入 data source prefetching。 为什么值得做:预取能让图片下载和准备更早开始,用户看到占位图的时间会缩短。 怎么开始:实现 collectionView(_:prefetchItemsAt:) 启动下载,并在 cancelPrefetchingForItemsAt 里取消无用任务。

关联 Session

评论

GitHub Issues · utterances