精通 UITableViewDiffableDataSource——从入门到重构的现代 iOS 列表开发指南
Contents
UITableView 是 iOS 开发的基石,但多年来,我们一直与一个棘手的问题作斗争:数据源管理。传统的 dataSource 代理模式,尤其是 performBatchUpdates,常常因为数据状态与 UI 更新不匹配而导致应用崩溃,这成为了许多开发者挥之不去的噩梦。
UITableViewDiffableDataSource 的出现,彻底改变了这一局面。它是 Apple 在现代 UI 开发中引入的革命性工具,是 UITableView 开发的未来。它通过一个全新的范式,将数据管理的复杂性从开发者手中解放出来。你不再需要手动计算 IndexPath 的增、删、改、移,只需向系统提供一个代表 UI 最终状态的“快照”(Snapshot),DiffableDataSource 就会自动计算差异,并为你执行平滑、高效且绝不会崩溃的动画更新。
相比传统的 dataSource 代理模式,它解决了以下核心痛点:
- 状态同步的烦恼:彻底告别因数据源与 UI 状态不一致而引发的
NSInternalInconsistencyException崩溃。 - 复杂的批量更新:不再需要手动管理
beginUpdates()和endUpdates(),以及在其中小心翼翼地调用insertRows,deleteRows等方法。 - 动画计算的负担:自动处理复杂的移动、重载动画,让 UI 响应更加自然流畅。
本文的目标,正是通过从零开始构建一个功能丰富的音乐播放列表 App,带你由浅入深,全面掌握 UITableViewDiffableDataSource 的所有核心知识。我们将从项目的基础设置讲起,逐步实现拖拽重排、滑动删除、跨分区移动等高级交互,并最终通过封装与重构,探索现代列表开发的最佳实践。
准备好了吗?让我们一起告别旧时光,拥抱 Diffable 带来的优雅与高效。
第一部分:基础入门——奠定坚实的基础
1. 项目初始化:纯代码的优雅
为了完全掌控 UI 的创建和布局,我们将抛弃 Storyboard,采用纯代码的方式来构建界面。这种方式不仅能让我们更清晰地理解视图层级,也更便于团队协作和代码维护。
移除 Storyboard
首先,我们需要告诉项目,不再使用默认的 Main.storyboard 文件来启动应用。这需要两步操作:
第一步:修改 Info.plist
在项目的 Info.plist 文件中,找到 Application Scene Manifest -> Scene Configuration -> Application Session Role -> Item 0 (Default Configuration) 路径,并删除 Storyboard Name 这个键(在源码中对应 UISceneStoryboardFile)。
删除前:
<key>UISceneStoryboardFile</key>
<string>Main</string>
删除后,Info.plist 中将不再包含此项。
第二步:修改 SceneDelegate.swift
由于我们不再通过 Storyboard 自动创建 UIWindow,因此需要在 SceneDelegate 的 scene(_:willConnectTo:options:) 方法中手动创建它。
// In SceneDelegate.swift
guard let windowScene = (scene as? UIWindowScene) else { return }
let window = UIWindow(windowScene: windowScene)
let viewController = ModernViewController() // 我们使用 ModernViewController 作为根视图
let navigationController = UINavigationController(rootViewController: viewController)
window.rootViewController = navigationController
self.window = window
window.makeKeyAndVisible()
通过这几行代码,我们完成了以下工作:
- 获取当前的
UIWindowScene。 - 创建一个新的
UIWindow实例并关联到该 Scene。 - 实例化我们的主视图控制器
ModernViewController。 - 将其嵌入到一个
UINavigationController中,以便后续使用导航栏功能。 - 将
navigationController设置为window的根视图控制器。 - 最后,让
window成为主窗口并显示出来。
至此,我们的应用已经成功摆脱了对 Storyboard 的依赖,为纯代码的 UI 构建之旅奠定了坚实的基础。
2. 数据模型:Diffable 的基石
DiffableDataSource 的高效运作离不开一个关键要求:所有数据模型都必须遵循 Hashable 协议。Hashable 协议使得 DiffableDataSource 能够唯一标识每个数据项和分区,从而精确计算两次数据快照(Snapshot)之间的差异,并自动生成流畅的动画效果。
在我们的项目中,我们定义了两个核心模型:Song 和 Section。
Song 模型
Song 模型是一个结构体,用于表示播放列表中的一首歌曲。
// 文件: UITableViewDiffableDataSourceDemo/ViewController.swift
struct Song: Hashable {
var name: String
let artist: String
let image: String
var isFavorite: Bool = false
// 自定义 Hashable 实现
func hash(into hasher: inout Hasher) {
hasher.combine(name)
hasher.combine(artist)
hasher.combine(image)
}
// 自定义 Equatable 实现
static func == (lhs: Song, rhs: Song) -> Bool {
return lhs.name == rhs.name && lhs.artist == rhs.artist && lhs.image == rhs.image
}
}
重点解析:
- 遵从
Hashable:这是DiffableDataSource的硬性要求。 - 自定义
Hashable和Equatable:我们特意重写了hash(into:)和==方法,仅基于歌曲的固有属性(name,artist,image)来判断其唯一性。这意味着,即使isFavorite这样的状态属性发生变化,DiffableDataSource仍然认为这是同一个对象。这个精妙的设计是实现“原地刷新”(reconfigure)而非“删除+插入”动画的关键。我们将在后续章节深入探讨这一点。
Section 模型
Section 是一个枚举,用于定义 UITableView 的各个分区。
// 文件: UITableViewDiffableDataSourceDemo/ViewController.swift
enum Section: String, CaseIterable {
case favorites = "Favorites"
case disney = "Disney"
case pop = "Pop"
}
重点解析:
CaseIterable:遵循此协议可以让我们轻松地遍历所有分区,方便地在snapshot中一次性添加所有分区。String原始值:为每个case关联一个字符串,便于在需要时(例如分区页眉)直接使用。
3. 创建你的第一个 DiffableDataSource
有了数据模型,我们就可以实例化 UITableViewDiffableDataSource 了。DiffableDataSource 将 UITableView 的数据源管理从传统的 delegate 和 dataSource 方法中解放出来,变成一个独立的、可配置的对象。
在 ModernViewController.swift 中,我们看到了一个更优雅的实现,它将 DiffableDataSource 的配置封装在了一个 DiffableTableAdapter 中。让我们先从基础版本开始理解,然后再看这个高级封装。
基础 dataSource 初始化
DiffableDataSource 的初始化需要一个 cellProvider 闭包。这个闭包取代了传统的 tableView(_:cellForRowAt:) 方法,它的职责是为给定的 indexPath 和数据模型返回一个配置好的 UITableViewCell。
// 文件: UITableViewDiffableDataSourceDemo/ModernViewController.swift
// 1. 声明 dataSource 变量
var dataSource: UITableViewDiffableDataSource<Section, Song>!
// 2. 在 viewDidLoad 中初始化
override func viewDidLoad() {
super.viewDidLoad()
// ...
configureDataSource()
// ...
}
// 3. 配置 dataSource
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource(tableView: tableView) {
tableView, indexPath, song -> UITableViewCell? in
// 根据分区和数据返回不同类型的 cell
// ... 详细实现见后续章节 ...
// 暂时返回一个基础 cell
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
var content = cell.defaultContentConfiguration()
content.text = song.name
content.secondaryText = song.artist
cell.contentConfiguration = content
return cell
}
}
使用 snapshot 填充数据
DiffableDataSource 的所有数据更新都通过 NSDiffableDataSourceSnapshot 完成。snapshot 是一个代表 UI 状态的“蓝图”。你可以在后台构建和修改 snapshot,然后一次性地将其应用(apply)到 dataSource,DiffableDataSource 会自动计算差异并执行动画。
// 文件: UITableViewDiffableDataSourceDemo/ModernViewController.swift
private func applyInitialSnapshot() {
// 1. 创建一个空的 snapshot
var snapshot = NSDiffableDataSourceSnapshot<Section, Song>()
// 2. 添加所有分区
snapshot.appendSections(Section.allCases)
// 3. 在指定分区中添加歌曲
snapshot.appendItems(disneySongs, toSection: .disney)
snapshot.appendItems(popSongs, toSection: .pop)
// 4. 将 snapshot 应用到 dataSource
dataSource.apply(snapshot, animatingDifferences: false)
}
核心优势:
- 线程安全:你可以在后台线程创建和配置
snapshot,然后在主线程应用它,避免了主线程卡顿。 - 声明式 API:你只需描述最终的 UI 状态,而无需关心如何从当前状态过渡过去。
DiffableDataSource会为你处理所有复杂的插入、删除、移动和刷新操作。 - 告别
performBatchUpdates:再也不用手动管理beginUpdates()和endUpdates(),也告别了因indexPath计算错误而导致的NSInternalInconsistencyException崩溃。
到这里,我们已经完成了 DiffableDataSource 的基础设置。表格现在能够正确显示分区和歌曲了。在下一部分,我们将深入探讨如何通过自定义 UITableViewCell 来丰富我们的播放列表,并实现更复杂的交互。
第二部分:自定义单元格与核心交互
在奠定了 DiffableDataSource 的基础之后,现在是时候通过自定义 UITableViewCell 和实现核心交互来让我们的播放列表应用焕发光彩了。这一部分将深入探讨如何创建多样化的单元格来展示丰富的歌曲信息,并实现拖拽重排、滑动删除等关键功能。
1. 为丰富内容定制 UITableViewCell
静态的、千篇一律的列表是乏味的。为了构建一个引人入胜的应用,我们需要根据数据内容和分区类型展示不同的 UI。在我们的项目中,我们定义了四种不同的自定义单元格,每一种都有其独特的用途和设计。
在开始之前,请确保在 viewDidLoad() 中注册所有自定义单元格:
// 文件: ModernViewController.swift
tableView.register(SongTableViewCell.self, forCellReuseIdentifier: SongTableViewCell.reuseIdentifier)
// ... 注册其他所有自定义 cell ...
SongTableViewCell: 经典布局
这是我们最基础的自定义单元格,用于展示歌曲的核心信息:封面、歌名和艺术家。它采用了经典的左侧图片、右侧文字的布局。
// 文件: UITableViewDiffableDataSourceDemo/SongTableViewCell.swift
class SongTableViewCell: UITableViewCell {
// ... UI 控件声明 ...
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI() // 使用 Auto Layout 布局
}
func configure(with song: Song) {
nameLabel.text = song.name
artistLabel.text = song.artist
// ... 设置图片 ...
}
}
设计解析:
- 关注点分离:
setupUI()负责布局,configure(with:)负责数据填充,代码结构清晰。 - Auto Layout:通过
NSLayoutConstraint.activate以纯代码方式定义约束,确保了 UI 在不同屏幕尺寸下的适应性。
NewSongTableViewCell: 利用 UIContentConfiguration
这个单元格展示了 iOS 14 及以后版本中引入的现代化 cell 配置方式:UIContentConfiguration。它无需我们手动创建和布局 UILabel、UIImageView 等控件,而是通过配置一个 contentConfiguration 对象来描述单元格的内容。
// 文件: UITableViewDiffableDataSourceDemo/NewSongTableViewCell.swift
class NewSongTableViewCell: UITableViewCell {
// ...
func configure(with song: Song) {
var content = self.defaultContentConfiguration()
content.text = song.name
content.secondaryText = song.artist
content.image = UIImage(systemName: "music.mic")
content.imageProperties.tintColor = .red
self.contentConfiguration = content
}
}
核心优势:
- 简洁高效:代码量大大减少,我们只需描述“需要什么”,而不是“如何实现”。
- 系统级优化:
UIContentConfiguration能够更好地处理状态变化(如高亮、选中),并提供一致的系统级外观。
SwitchableSongTableViewCell: 交互与代理
这个单元格引入了交互性。它包含一个 UISwitch,允许用户将歌曲添加到“收藏”列表。为了将开关的状态变化通知给 ViewController,我们使用了 delegate 模式。
// 文件: UITableViewDiffableDataSourceDemo/SwitchableSongTableViewCell.swift
// 1. 定义代理协议
protocol SwitchableSongTableViewCellDelegate: AnyObject {
func didChangeSwitchValue(for cell: SwitchableSongTableViewCell, isOn: Bool)
}
class SwitchableSongTableViewCell: UITableViewCell {
weak var delegate: SwitchableSongTableViewCellDelegate?
private let songSwitch = UISwitch()
// 2. 在初始化时设置 accessoryView 和 target
private func setupSwitch() {
accessoryView = songSwitch
songSwitch.addTarget(self, action: #selector(switchValueChanged), for: .valueChanged)
}
// 3. 在 configure 中同步开关状态
func configure(with song: Song) {
// ...
songSwitch.isOn = song.isFavorite
}
// 4. 状态变化时通知代理
@objc private func switchValueChanged() {
delegate?.didChangeSwitchValue(for: self, isOn: songSwitch.isOn)
}
}
实现要点:
- 定义协议:创建一个清晰的通信契约。
- 弱引用代理:
weak var delegate避免了ViewController和cell之间的循环引用。 - Target-Action:当
UISwitch的值改变时,触发一个内部方法。 - 通知代理:在内部方法中,调用代理方法,将自身和新的状态传递出去。
CustomSongCardCell: 动态内容与自适应高度
这是我们最复杂的单元格,它模拟了真实世界应用中的“卡片式”设计,并展示了如何处理动态内容和自适应高度。
核心特性:
- 动态副标题:根据歌曲信息(例如,是否存在歌手简介、是否已收藏)动态生成不同长度和内容的副标题。
- 自适应高度:通过设置
subtitleLabel.numberOfLines = 0,并结合UITableView的自动行高计算,使得单元格能够根据内容自动调整高度。 - 闭包高度估算:提供了一个静态方法
preferredHeight(for:),用于在“闭包高度模式”下,根据歌曲内容预估行高,这在性能敏感的场景下非常有用。
// 文件: UITableViewDiffableDataSourceDemo/CustomSongCardCell.swift
final class CustomSongCardCell: UITableViewCell {
// ...
func configure(with song: Song) {
// ...
if let bio = Self.artistBios[song.artist] {
if song.isFavorite {
// 收藏 + 有简介 -> 显示更丰富的多行内容
subtitleLabel.numberOfLines = 0 // 允许多行,触发自动高度
} else {
// 未收藏 + 有简介 -> 显示简短介绍
subtitleLabel.numberOfLines = 2
}
} else {
// 无简介 -> 默认显示
subtitleLabel.numberOfLines = 2
}
// ...
}
}
通过这四种自定义单元格,我们的播放列表不再单调。我们不仅能够展示丰富多样的内容,还为接下来的高级交互功能奠定了坚实的基础。
2. 拖拽重排:优雅的数据移动
DiffableDataSource 让拖拽重排(Drag and Drop)的实现变得前所未有的简单和健壮。我们不再需要手动去追踪和更新 indexPath,而是直接在数据源层面操作数据。
在我们的项目中,这个功能被巧妙地封装在了 BaseReorderableDiffableDataSource 类中,它是 UITableViewDiffableDataSource 的一个子类。
实现拖拽重排的关键
要启用拖拽功能,我们需要重写 UITableViewDiffableDataSource 中的两个关键方法:
tableView(_:canMoveRowAt:)-> BooltableView(_:moveRowAt:to:)
让我们看看 DiffableDataSourceKit.swift 中是如何实现的:
// 文件: UITableViewDiffableDataSourceDemo/DiffableDataSourceKit.swift
open class BaseReorderableDiffableDataSource<SectionIdentifierType, ItemIdentifierType>: UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>
where SectionIdentifierType: Hashable, ItemIdentifierType: Hashable {
// 1. 允许移动
open override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
return true
}
// 2. 处理移动操作
open override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
// ... (日志记录)
// 防止跨区移动
guard sourceIndexPath.section == destinationIndexPath.section else {
// 如果不允许跨区,可以选择应用当前快照以“撤销”拖拽
self.apply(self.snapshot(), animatingDifferences: false)
return
}
// 获取被移动的 item
guard let sourceIdentifier = itemIdentifier(for: sourceIndexPath) else { return }
// 获取目标位置的 item
guard let destinationIdentifier = itemIdentifier(for: destinationIndexPath) else { return }
// 获取当前快照
var snapshot = self.snapshot()
// 在快照中移动 item
if sourceIdentifier != destinationIdentifier {
if let sourceIndex = snapshot.indexOfItem(sourceIdentifier),
let destinationIndex = snapshot.indexOfItem(destinationIdentifier) {
let isAfter = destinationIndex > sourceIndex
snapshot.moveItem(sourceIdentifier, afterItem: destinationIdentifier)
}
}
// 应用更新后的快照
apply(snapshot, animatingDifferences: true)
}
}
代码深度解析:
canMoveRowAt: 简单地返回true,开启了UITableView的拖拽功能。你可以根据indexPath添加更复杂的逻辑,例如,禁止移动某个特定分区或行。moveRowAt: 这是核心所在。当用户完成一次拖拽操作后,此方法被调用。- 防止跨区移动:代码首先检查
sourceIndexPath.section和destinationIndexPath.section是否相同。如果不同,它会重新应用当前的snapshot,这会立即取消用户的拖拽操作,让被拖拽的cell“弹回”原位。这是一个非常优雅的错误处理方式。 - 获取
ItemIdentifier:我们使用itemIdentifier(for:)方法,通过indexPath安全地获取到对应的数据模型。这是DiffableDataSource的巨大优势——我们操作的是稳定的、与indexPath解耦的数据标识符。 - 在
snapshot中移动:NSDiffableDataSourceSnapshot提供了便捷的moveItem(_:afterItem:)和moveItem(_:beforeItem:)方法。我们只需告诉snapshot要移动哪个item到哪个item的前面或后面,而完全无需关心底层的数组操作。 - 应用
snapshot:最后,调用apply(snapshot, animatingDifferences: true)。DiffableDataSource会自动计算出cell移动的动画,并更新 UI。
- 防止跨区移动:代码首先检查
在 ViewController 中启用
为了利用我们刚刚在 BaseReorderableDiffableDataSource 中定义的拖拽功能,我们需要更新 ModernViewController。之前,我们为了基础设置使用了标准的 UITableViewDiffableDataSource。现在,是时候将它替换为我们的子类了。
第一步:更新 dataSource 的类型和实例化
回到 ModernViewController.swift,找到 dataSource 的声明和 configureDataSource 方法,并进行如下修改:
// 文件: ModernViewController.swift
// 1. 将类型从 UITableViewDiffableDataSource 更改为我们的子类
var dataSource: BaseReorderableDiffableDataSource<Section, Song>!
// ...
private func configureDataSource() {
// 2. 使用 BaseReorderableDiffableDataSource 进行实例化
dataSource = BaseReorderableDiffableDataSource(tableView: tableView) {
tableView, indexPath, song -> UITableViewCell? in
// ... cell provider 的实现保持不变 ...
// 暂时返回一个基础 cell
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
var content = cell.defaultContentConfiguration()
content.text = song.name
content.secondaryText = song.artist
cell.contentConfiguration = content
return cell
}
}
通过这个简单的更改,我们的 dataSource 现在就具备了处理拖拽移动的能力。
第二步:开启编辑模式
接下来,在 viewDidLoad 中添加一个编辑按钮来开启 UITableView 的编辑模式。
// 文件: ModernViewController.swift
override func viewDidLoad() {
super.viewDidLoad()
// ...
// 添加编辑按钮到导航栏
navigationItem.leftBarButtonItem = editButtonItem
}
// `editButtonItem` 会自动触发 setEditing(_:animated:)
override func setEditing(_ editing: Bool, animated: Bool) {
super.setEditing(editing, animated: animated)
tableView.setEditing(editing, animated: animated) // 将编辑状态同步到 tableView
}
通过这种方式,DiffableDataSource 将复杂的拖拽操作简化为了几个简单的、声明式的步骤。代码不仅更易于阅读和维护,而且从根本上避免了因手动管理 indexPath 而可能引发的各种运行时崩溃。
3. 滑动删除:数据与UI的无缝同步
与拖拽重排一样,实现滑动删除(Swipe to Delete)在 DiffableDataSource 的世界里也变得异常直观。我们只需配置 UITableViewDelegate 的相关方法,然后在数据层面(snapshot)上执行删除操作即可。
实现 trailingSwipeActionsConfigurationForRowAt
UITableViewDelegate 中的 tableView(_:trailingSwipeActionsConfigurationForRowAt:) 方法允许我们定义当用户在 cell 上向左滑动时出现的操作按钮。在 ModernViewController.swift 中,我们是这样实现的:
// 文件: ModernViewController.swift
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
// 确保我们能获取到对应的 item
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return nil
}
// 创建删除操作
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] (_, _, completion) in
guard let self = self else {
completion(false)
return
}
// 1. 从快照中删除 item
var snapshot = self.dataSource.snapshot()
snapshot.deleteItems([item])
// 2. 应用快照,自动触发动画
self.dataSource.apply(snapshot, animatingDifferences: true)
completion(true)
}
// 返回包含删除操作的配置
return UISwipeactionsConfiguration(actions: [deleteAction])
}
代码深度解析:
-
获取
ItemIdentifier:我们再次使用dataSource.itemIdentifier(for: indexPath)来安全地获取数据模型。这是关键一步,它确保了我们操作的是正确的数据,无论cell的位置如何变化。 -
创建
UIContextualAction:我们创建了一个style为.destructive的UIContextualAction。在其handler闭包中,我们执行删除逻辑。 -
从
snapshot中删除:这是最核心的部分。我们获取当前的snapshot,调用snapshot.deleteItems([item]),将目标item从snapshot中移除。注意,这里我们操作的是snapshot,一个数据的“未来状态”,而不是直接操作数据数组。 -
应用
snapshot:调用dataSource.apply(snapshot, animatingDifferences: true)。DiffableDataSource会将新的snapshot与旧的snapshot进行比较,发现有一个item消失了,于是它会自动执行一个平滑的“删除”动画,将对应的cell从UITableView中移除。
告别 beginUpdates 和 endUpdates
如果你有使用传统 UITableViewDataSource 的经验,你一定对 tableView.beginUpdates()、tableView.deleteRows(at:with:) 和 tableView.endUpdates() 这套组合不陌生。这套 API 功能强大,但也极易出错。如果 deleteRows 的 indexPath 与数据源的更新不同步,就会导致经典的 NSInternalInconsistencyException 崩溃。
DiffableDataSource 从根本上解决了这个问题。我们不再需要手动调用这些方法,也不再需要关心 indexPath。我们只需要声明式地告诉 DiffableDataSource 数据的最终状态(通过 snapshot),它就会为我们处理好所有复杂的 UI 更新和动画,既安全又高效。
至此,我们已经掌握了 DiffableDataSource 的核心交互:拖拽重排和滑动删除。接下来,我们将进入更高级的主题,探索如何利用 DiffableDataSource 构建更加动态和复杂的列表界面。
第三部分:高级交互与动态内容
1. 核心理念:数据源与代理的职责分离
在深入探讨动态行高和更复杂的交互之前,我们必须先澄清一个核心的设计理念:UITableViewDiffableDataSource 与 UITableViewDelegate 之间的关系。
很多初学者可能会感到困惑:既然有了 DiffableDataSource,为什么我们还需要 Delegate?它们一起使用是否意味着“混合”了新旧两种模式?
答案是:这不是混合,而是各司其职的协作。这恰恰是 Apple 推荐的最佳实践,完美体现了“关注点分离”(Separation of Concerns)的设计原则。
我们可以用一个简单的比喻来理解:
UITableViewDiffableDataSource是你的“库存清单”:它的唯一职责是管理数据状态。它告诉你书架上(UITableView)应该有哪些书(Item)、这些书分成了哪些类别(Section),以及它们的顺序。它关心的是“什么”被展示。UITableViewDelegate是你的“展厅设计师”:它的职责是管理视图的外观和交互。它决定了每个书架隔层应该有多高(heightForRowAt)、书本被点击时会发生什么(didSelectRowAt)、是否可以对某本书进行操作(contextMenuConfigurationForRowAt)等。它关心的是“如何”展示以及“如何”与用户互动。
DiffableDataSource 的出现,是为了取代 UITableViewDataSource,而不是 UITableViewDelegate。它将数据源的管理从一系列分散的代理方法(如 numberOfRowsInSection, cellForRowAt 等)中解放出来,变成一个统一的、声明式的 snapshot 管理机制。这使得数据状态的管理变得前所未有的健壮和简单。
而 UITableViewDelegate 的角色始终如一:处理所有与视图渲染和用户交互相关的逻辑。
因此,当你在 ModernViewController 中同时看到 dataSource.apply(snapshot) 和 tableView(_:heightForRowAt:) 时,你应该认识到这是一个优雅的协作:
dataSource通过snapshot确定了要显示哪些歌曲。delegate通过heightForRowAt告诉UITableView这些歌曲的单元格应该有多高。
两者目标一致,但职责清晰,共同构建了现代、高效且易于维护的 UITableView。
2. 动态行高:自适应内容的艺术
DiffableDataSource 的真正威力不仅在于它简化了基本的数据展示,更在于它处理动态更新的方式。要构建高性能和响应迅速的用户界面,对它的更新机制有细致的理解至关重要。本部分将超越基础知识,探讨 DiffableDataSource 所支持的架构模式和高级策略,重点关注 DiffableDataSourceKit 提供的抽象。
高级更新策略:reload vs. reconfigure vs. 模型哈希值变更
当一个项目的数据发生变化时,你应该如何通知 DiffableDataSource?你有三种主要工具可供选择:reloadItems、reconfigureItems 和手动的“模型哈希值变更”(删除 + 追加)。选择正确的工具取决于性能和意图。
传统方式:reloadItems(_:)
这是更新单元格的传统方法。当你调用 reloadItems 时,你是在告诉数据源,该项目的数据已经发生了根本性的变化,以至于现有的单元格不再有效。
它做了什么:
- 销毁现有的
UITableViewCell实例。 - 在旧单元格上调用
prepareForReuse()。 - 通过重新运行
cellProvider创建一个全新的单元格。 - 将新单元格动画地放入位置。
何时使用:
- 当数据变更导致单元格的高度或布局结构需要重新计算时。例如,一个项目的文本内容从单行变为多行,需要动态调整单元格高度。
- 当单元格的状态变化需要完全不同的 UI 布局时。例如,从一个紧凑的摘要视图切换到一个包含图表的扩展视图。
与 reconfigureItems 相比,reloadItems 是一个更重的操作,因为它会销毁并重新创建 UITableViewCell 实例。因此,仅在 reconfigureItems 无法满足 UI 更新需求时才应使用它。
在我们的 DiffableTableAdapter 中,这被公开为 reload(_:)。
// 在 DiffableDataSourceKit.swift 中
public func reload(_ item: Item, animatingDifferences: Bool = true) {
var snap = dataSource.snapshot()
snap.reloadItems([item])
dataSource.apply(snap, animatingDifferences: animatingDifferences)
}
现代高性能选择:reconfigureItems(_:)
reconfigureItems 是在 iOS 15 中引入的,它在性能上是一个游戏规则的改变者。它认识到,通常只有单元格的内容发生变化,而不是它的整个身份或结构。
它做了什么:
- 保留现有的
UITableViewCell实例。 - 为该项目重新运行
cellProvider。 - 将新的配置应用到同一个单元格。
- 不调用
prepareForReuse()。 - 执行一个微妙的、通常难以察觉的交叉淡入淡出动画。
何时使用:
- 对于不影响单元格高度或布局的频繁、微小的数据更新。
- 典型的例子是切换“收藏”状态,如
ModernViewController中所示。星星图标会改变,但单元格的尺寸和结构保持不变。
// 在 ModernViewController.swift 中,处理收藏切换
@objc private func favoriteButtonTapped(_ sender: UIButton) {
guard let song = dataSource.itemIdentifier(for: IndexPath(row: sender.tag, section: 0)) else { return }
// 1. 修改底层数据模型
var updatedSong = song
updatedSong.isFavorite.toggle()
// 2. 更新数据源
if let index = songs.firstIndex(where: { $0.id == song.id }) {
songs[index] = updatedSong
}
// 3. 高效地更新 UI
// 这是关键!我们只是重新配置项目。
adapter.reconfigure(updatedSong)
}
这种方法比完全 reload 要快得多,因为它避免了销毁和重新分配单元格的昂贵过程。它带来了更平滑、无闪烁的用户体验。
手动操作快照:delete + append
在 reload 和 reconfigure 无法满足需求时,我们可以通过直接操作快照来实现更复杂的 UI 更新。最常见的组合是 delete 和 append。这个模式为你提供了对数据布局的完全控制。
核心操作流程:
- 从当前快照中
delete一个或多个项目。 - 在同一个快照中
append一个或多个项目(可以指定新的分区)。 - 将修改后的快照
apply到数据源。
这个模式主要适用于以下两种截然不同的场景:
场景一:跨分区移动项目
当你想把一个项目从一个分区移动到另一个分区,而项目本身的身份(哈希值)保持不变时,这是最理想的方法。
- 何时使用: 用户执行了一个操作,需要将项目重新分类。例如,将一首歌从“播放列表”分区移动到“已收藏”分区。
- 关键点: 被移动的项目在
delete和append操作中是同一个实例,其哈希值没有改变。
我们的 DiffableTableAdapter 为此场景提供了一个方便的 move 方法。在单个快照中进行原子性的删除和追加操作,使得 DiffableDataSource 能够理解这两个操作之间的关系,并产生一个平滑的动画,将项目从旧位置移动到新位置。
// 在 DiffableTableAdapter.swift 中
public func move(_ item: Item, to section: Section, animatingDifferences: Bool = true) {
var snap = dataSource.snapshot()
snap.deleteItems([item])
snap.appendItems([item], toSection: section)
dataSource.apply(snap, animatingDifferences: animatingDifferences)
}
场景二:模型哈希值变更
当一个项目的核心身份(由其 Hashable 实现决定)发生改变时,DiffableDataSource 会视其为一个全新的项目。在这种情况下,reload 或 reconfigure 会因为找不到旧项目而失败。
- 何时使用: 你修改了模型中参与哈希计算的属性。例如,在我们的
Song模型中,修改了name、artist或image。 - 关键点:
oldSong和updatedSong是两个不同的实例,它们的哈希值不同。你必须先删除旧的,再添加新的。
底层逻辑探索
这里提出一个问题:当模型哈希值变更的情况下,即执行delete + append后,UITableViewCell会被重新创建吗? 这个操作与执行realod的底层逻辑一致吗?
delete + append 会重新创建 UITableViewCell 吗?
答案是:是的。
当一个模型的哈希值发生改变时,DiffableDataSource 会将这个“更新后”的项目视为一个全新的、完全不同的实体。从数据源的角度来看,旧的项目已经消失了,一个新的项目出现了。
因此,当你应用一个包含 deleteItems([oldItem]) 和 appendItems([newItem]) 的快照(snapshot)时,DiffableDataSource 会指示 UITableView 执行两个独立的操作:
- 为对应
oldItem的那一行执行删除动画。 - 为对应
newItem的那一行执行插入动画。
这个插入操作总是会触发 cellProvider 闭包,为这个新项目创建一个全新的 UITableViewCell 实例。所以,最终的结果确实是在屏幕上出现了一个新的单元格。
其底层逻辑与 reloadItems 一致吗?
答案是:不,底层逻辑是不同的,尽管它们最终的视觉效果(一个新创建的 cell)是相同的。
让我们来比较一下这两者:
| 特性 | delete + append (因哈希值变更) |
reloadItems (因内容变更) |
|---|---|---|
| 核心概念 | 身份变更。旧项目消失,一个新项目取而代之。 | 内容变更。项目还是同一个,但它的展现方式需要一次彻底的重置。 |
DiffableDataSource 行为 |
它看到了两个不同的哈希值。它计算出的差异(diff)包含一次删除和一次插入。 | 它在新旧快照中找到了一个哈希值相同的项目,并将其标记为需要“重新加载 (reload)”。 |
UITableView 操作 |
对应于 tableView.deleteRows(at:) 和 tableView.insertRows(at:)。 |
对应于 tableView.reloadRows(at:)。 |
| 动画效果 | 通常是旧的淡出,新的淡入,或者如果位置也变了,会是更复杂的移动动画。 | 在原地进行一次交叉溶解(cross-dissolve)的淡入淡出动画。 |
| 使用场景 | 项目的身份(由其 Hashable 实现决定)改变了。此时若用 reloadItems 会导致崩溃,因为它在旧数据中找不到“新”项目的哈希值。 |
项目的身份没变,但发生了重大变化(例如需要改变行高)。 |
一个比喻
想象一下你有一个带编号的停车位:
reloadItems:同一辆车还在那个车位,但它刚刚换了个新颜色。你告诉停车场管理员:“请重新检查一下 5 号车位的车”。管理员看着同一辆车,更新了它的描述。delete+append:原来在 5 号车位的车开走了,一辆完全不同的车停了进来。你告诉管理员:“5 号车位的车走了。现在,一辆新车停在了 5 号车位”。管理员需要处理一次离场和一次新入场。
总结
delete + append与reloadItems 它们都是强制 UITableViewCell 被完全重建的策略。但是,它们并不能互换使用。如何选择取决于一条简单的规则:
- 如果项目的
Hashable身份发生了改变,你必须使用delete+append。 - 如果项目的
Hashable身份保持不变,但你需要强制进行一次完整的 cell 重建(例如为了改变行高),你应该使用reloadItems。
3. 架构思考:从数据源到视图适配器
虽然 UITableViewDiffableDataSource 极大地简化了 UITableView 的数据管理,但它本身只是一个“数据引擎”。在复杂的真实世界应用中,直接在 ViewController 中使用它会很快导致代码臃肿和责任不清。DiffableDataSourceKit.swift 的设计哲学,正是为了解决这一问题,它通过两个核心组件——BaseReorderableDiffableDataSource 和 DiffableTableAdapter——构建了一个更强大、更优雅的架构。
核心组件一:BaseReorderableDiffableDataSource —— 安全地增强,而非粗暴地替换
直接使用 UITableViewDiffableDataSource 会遇到一些棘手的问题:
- 重排(Reordering)的复杂性:实现拖拽重排需要重写
tableView(_:moveRowAt:to:)方法,这涉及到手动操作快照,逻辑很容易出错,尤其是在处理分区移动时。 - 上下文缺失:
cellProvider闭包在初始化时只能访问(UITableView, IndexPath,Item),它对所在的Section一无所知,这在需要根据分区类型来配置单元格时非常不便。 - 循环引用风险:为了在
cellProvider之外访问数据源(例如,获取分区信息),开发者很容易在闭包中捕获self,从而引发循环引用。
BaseReorderableDiffableDataSource 通过精巧的子类化和工厂模式解决了这些问题:
- 内置重排逻辑:它重写了
canMoveRowAt和moveRowAt,将复杂的快照操作封装起来,并提供allowCrossSectionMove选项来控制行为,极大地简化了重排功能的实现。 - 静态工厂方法与上下文注入:
create工厂方法是设计的核心。它避免了在init方法中直接使用cellProvider,而是通过一个局部变量ds来打破循环引用的链条。更重要的是,它重新定义了一个更强大的cellBuilder,该闭包拥有(UITableView, IndexPath,Item,Section?)四个参数。通过在内部的cellProvider中查询ds.sectionIdentifier(for:),它巧妙地将Section上下文注入到了单元格的构建过程中。
// 在 BaseReorderableDiffableDataSource.swift 中
public static func create(
// ...
cellBuilder: @escaping (UITableView, IndexPath, Item, Section?) -> UITableViewCell
) -> BaseReorderableDiffableDataSource<Section, Item> {
var ds: BaseReorderableDiffableDataSource<Section, Item>! // 1. 声明一个弱引用变量
ds = BaseReorderableDiffableDataSource<Section, Item>(tableView: tableView) { tableView, indexPath, item in
// 3. 在闭包内部,ds 已经被捕获,但此时它是一个已初始化的对象
let section = ds.sectionIdentifier(for: indexPath.section) // 4. 注入 Section 上下文
return cellBuilder(tableView, indexPath, item, section)
}
// 2. 完成初始化,ds 被赋值
return ds
}
这种设计体现了“增强而非替换”的原则。它没有重新发明轮子,而是在 Apple 提供的基础上,通过安全的扩展来弥补其原生设计的不足。
核心组件二:DiffableTableAdapter —— 统一数据操作与视图布局
ViewController 的职责应该是协调数据和视图,而不是陷入快照操作和 UITableViewDelegate 的实现细节中。DiffableTableAdapter 正是为此而生,它扮演了一个关键的“视图适配器”角色,将数据操作和视图布局的逻辑从 ViewController 中彻底解耦。
-
统一的数据操作API:适配器将所有快照操作(如
append,delete,move,reconfigure)封装成简单、表意清晰的方法。ViewController只需调用adapter.move(song, to: .favorites),而无需关心背后复杂的snapshot构建和应用过程。这使得ViewController的代码更加简洁、可读,并专注于业务逻辑。 -
解耦
UITableViewDelegate:UITableViewDelegate中的方法,如heightForRowAt,通常需要访问模型数据来动态计算高度。传统做法是在ViewController中实现这些代理方法,这破坏了封装性。DiffableTableAdapter通过提供heightProvider和estimatedHeightProvider闭包,将高度计算的逻辑也纳入了适配器的管理范畴。
// 在 ModernViewController.swift 中
adapter.setHeightProviders(
height: { tableView, indexPath, item, section in
// 根据 item 和 section 计算并返回高度
return 120
}
)
// 在 UITableViewDelegate 回调中
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return adapter.heightForRow(at: indexPath) ?? UITableView.automaticDimension
}
通过这种方式,DiffableTableAdapter 成为了 UITableView 数据和布局的唯一“真理之源”。它不仅管理着 dataSource 的快照,还负责提供单元格的高度信息,从而实现了数据层和视图表现层的完美统一。
架构优势总结
DiffableDataSourceKit 的架构设计带来了多重好处:
-
极致的关注点分离 (SoC):
ViewController负责“做什么”(业务逻辑),DiffableTableAdapter负责“如何做”(数据操作与布局),而BaseReorderableDiffableDataSource则专注于解决数据源本身的技术难题。各司其职,代码结构清晰。 -
高度的可重用性与可测试性:
DiffableTableAdapter和BaseReorderableDiffableDataSource都是完全通用的泛型类,可以轻松地在任何项目中使用。同时,由于逻辑被集中和简化,它们也变得非常容易进行单元测试。 -
提升开发效率与代码质量:通过提供一套高级、易用的 API,该架构极大地减少了样板代码,让开发者能够更专注于实现功能,而不是处理底层的复杂性。
总而言之,DiffableDataSourceKit 不仅仅是一个简单的工具集,它体现了一种先进的架构思想:通过分层和抽象,将复杂性封装在专门的组件中,从而为上层提供一个简洁、强大且安全的接口。 这是从“能用”的代码迈向“专业级”代码的关键一步。
结论:拥抱 UITableView 的未来
从传统的 UITableViewDataSource 到现代的 UITableViewDiffableDataSource,我们不仅仅是更换了一套 API,更是完成了一次思维模式的深刻转变。
通过本文的旅程,我们从零开始,构建了一个功能完备、交互丰富的音乐播放列表App。在这个过程中,我们深入体验了 DiffableDataSource 带来的革命性优势:
-
绝对的类型安全:通过泛型,我们彻底告别了
AnyObject和强制类型转换,让编译器在编码阶段就为我们发现潜在的类型错误。 -
从根本上消除崩溃:
DiffableDataSource将数据状态的管理和UI更新解耦。通过snapshot作为单一、可靠的数据来源,它从根本上杜绝了因数据与UI不同步而导致的NSInternalInconsistencyException崩溃。 -
声明式的API:我们不再需要编写命令式的指令去“命令”
UITableView如何更新(例如insertRows,deleteRows)。我们只需要声明式地告诉DiffableDataSource数据的“最终状态”,它就会为我们计算出最高效、最平滑的更新路径和动画。 -
简化的复杂交互:无论是拖拽重排、滑动删除,还是跨区移动,这些过去需要大量复杂逻辑才能实现的交互,现在都变得异常简单和直观。
-
更优雅的代码结构:通过将
DiffableDataSource封装到像DiffableTableAdapter这样的适配器中,我们可以极大地简化ViewController,使其更专注于业务逻辑,从而提升代码的可读性、可维护性和可测试性。
DiffableDataSource(以及与之配套的 UICollectionViewDiffableDataSource)代表了 Apple 对构建列表式UI的未来愿景。它不仅仅是一个新工具,更是一种更安全、更高效、更愉悦的编程范式。
如果你还在为 UITableView 的各种崩溃和复杂的更新逻辑而烦恼,那么现在就是拥抱 DiffableDataSource 的最佳时机。它将为你打开一扇通往更现代化、更健壮的 iOS 开发世界的大门。
希望这篇深度解析能为你掌握 DiffableDataSource 提供坚实的基础和清晰的指引。现在,去你的项目中实践这些技巧吧!
源码:https://github.com/kingnight/DiffableDataSourceKit