This page looks best with JavaScript enabled

iOS 高级教程:用 MVVM 处理复杂的TableView

 ·  ☕ 8 min read

原文:Advanced iOS tutorial: Use MVVM to tackle complicated TableView

在本文,我们将讨论如何 用 Model-View-ViewModel(MVVM) 模式来组织 table view 代码。
MVVM 是一种架构模型,它使用数据模型表示视图状态。我们可以使用很多 Swift 技术, 使 UI 逻辑包装成数据模型。例如使用协议和闭包简化 table view 中的代码。建议查看 文章 以全面了解 MVVM 模式。

Newsfeed App

今天,我们将要做一个 newsfeed app 。App 主页是一个墙(wall)。在墙上,有两种 feed ,一种是照片的 feed,一种是用户的 feed。
我们包含介绍以下用例:

  1. tableView 正确显示 Member 的单元格和 Photo 的单元格。
  2. “+” 按钮显示在等待 follow-a-member API 的响应时的加载。
  • 当用户按下 Photo 单元格时触发 open-detail 事件。

界面如下:

MemberCell

member cell 可以推荐一些你可以想要关注的用户,你可以点击 Cell 上的 + 按钮,Cell 有 3种状态:

  • Normal

  • Loading

  • Checked

PhotoCell

第二个 cell 会展示照片跟它的标题与介绍,用户点击了这个 cell 就会打开照片的详细介绍。

我们假设 API 的响应被解析为两种 model: Member 和 Photo.

你可以在 Github 上找到这个项目的完整代码:GitHub – koromiko/TheGreatWall: Using MVVM to tackle complicated feed view

开始

我们新建一个 FeedListViewController ,并将它设置为 tableView 的 dataSource,这个 viewController 大概会长这个样子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var feeds: [Feed] = [Feed]()
 
var tableView: UITableView
 
func viewDidLoad() {
        self.service.fetchFeeds { [weak self] feeds in 
            self?.feeds = feeds
            self?.tableView.reloadData()
        }
}
 
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return feeds.count
}
 
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let feed = feeds[indexPath.row]
 
    if let memberFeed = feed as? Member, let cell = tableView.dequeueReusableCell(withIdentifier: MemberCell.cellIdentifier(), for: indexPath) as? MemberCell {
        cell.nameLabel.text = memberFeed.name
        cell.profileImageView.image = memberFeed.image
        cell.addBtn.isSelected = memberFeed.isFollwing
        return cell
    } else if let photoFeed = feed as? Photo, let cell = tableView.dequeueReusableCell(withIdentifier: PhotoCell.cellIdentifier(), for: indexPath) as? PhotoCell {
        cell.titleLabel.text = photoFeed.captial
        cell.descriptionLabel.text = photoFeed.description
        cell.coverImageView.image = photoFeed.image
        return cell
    } else {
            fatalError("Unhandled feed \(feed)")
    }
}

我们把请求的数据保存在 feeds: [Feed] 中,func tableView(tableView:, indexPath:) -> UITableViewCell 通过 if-else 处理两种 feed 。

然后我们要处理 Member Cell 的加载状态。通常我们使用数组保存所有 cell 的加载状态。

1
var cellLoadingStatus: [Bool]

然后这样更新 cell :

1
2
3
4
5
if cellLoadingStatus[indexPath.row] {
    cell.loadingIndicator.startAnimation()
} else {
    cell.loadingIndicator.stopAnimation()
}

我们还有一件事要做。处理状态更新,如下所示:

1
2
3
4
func updateLoadingState(isLoading: Bool, at indexPath: IndexPath) {
    cellLoadingStates[indexPath.row] = isLoading
    tableView.reloadRows(at indexPaths: [indexPath], with animation: .none)
}

看起来没有什么问题,但是当我们被要求添加一个 cell 类型时,我们应该怎么做呢?在 func tableView(tableView:,indexPath:) -> UITableViewCell 添加一个 else-if 处理。
如果新 Cell 具有不同的状态,我们将添加一个数组来处理状态更改并添加一个函数来更新状态。唷!碎片化代码使需求变更成为一项具有挑战性的任务。

下图显示了体系结构:

在上面的示例中,dataSource 具有以下职责:

  • 设置 cell 的 UI 组件,例如 nameLabel,imageView
  • 记录 cell 的状态,如 cellLoadingStatus
  • 调用 API 并且保存 model 数据

每当我们添加一个单元格类型时,我们都会在 FeedListViewController 中执行这些任务,而这些任务根本不可扩展。
更好的方法是分离职责以提高可扩展性。添加单元格类型应该更像是将 USB 设备插入计算机,这不会改变计算机的设计。

分拆 cell UI 设定

回到 func tableView(tableView:, cellForRowAt indexPath: ) -> UITableViewCell ,这里面有 UI 设定,对于 dataSource 来说,了解 cell 的实现细节并不是一个好的主意。所以我们的第一步就是将 UI 设定从 dataSource 转移到 cell 中去。

基于这个想法,我们在 MemberCell 中添加一个方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// In MemberCell.swift
func setup(name: String, profileImage: UIImage, isFollowing: Bool, isLoading: Bool) {
    self.nameLabel.text = name
    self.profileImageView.image = profileImage
    self.addBtn.isSelected = isFollowing
    if isLoading { 
        self.loadingIndicator.startAnimation()
    } else {
        self.loadingIndicator.stopAnimation()
    }
}

在 setup 的帮助下,func tableView(tableView:, indexPath:) -> UITableViewCell 会变得更加简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
if let memberFeed = feed as? Member, let cell = tableView.dequeueReusableCell(withIdentifier: MemberCell.cellIdentifier(), for: indexPath) as? MemberCell {
    cell.setup(name: memberFeed.name, 
                     profileImage: memberFeed.image,
                     isFollowing: memberFeed.isFollowing,
                     isLoading: self.loadingStatus[indexPath.row])
    return cell
} else if let photoFeed = feed as? Photo, let cell = tableView.dequeueReusableCell(withIdentifier: PhotoCell.cellIdentifier(), for: indexPath) as? PhotoCell {
    cell.setup(title: photoFeed.title, 
                      description: photoFeed.description,
                      image: photoFeed.image)
    return cell
} else {
    fatalError("Unhandled feed \(feed)")
}

我们可以看到有超过三个参数的函数。有一个对象包装这些参数会更好。因此,这是 MVVM 的好处! MVVM 可以帮助您将所有 UI 逻辑封装到一个简单的对象中。

通过将 UI 操作转换为对象操作,我们可以显着减少 dataSource 和 ViewController 的开销。

为 Cell 设计 ViewModel

先看架构:

我们用两种 viewmodel :MemberViewModel 和 PhotoViewModel ,分别代表 MemberCell 和 PhotoCell 。 我们使用绑定技术将 ViewModel 的属性绑定到相应 Cell 中的UI组件。这意味着每当我们更改ViewModel的属性时,单元格中的绑定UI组件都会相应地更改。

MemberViewModel 的实现如下:

1
2
3
4
5
class MemberCellViewModel {
    let name: String
    let avatar: UIImage
    let isLoading: Observable<Bool>
}

我们使用自定义对象 Observable 作为绑定工具。它保留泛型类型值,并通过触发名为 valueChanged的闭包来通知将存储值更改为观察者。实现非常简单

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Observable<T> {
    var value: T {
        didSet {
            DispatchQueue.main.async {
                self.valueChanged?(self.value)
            }
        }
    }
    var valueChanged: ((T) -> Void)?
}

如果你对更多的 绑定技术感兴趣,请看 Srđan Rašić 的文章 Solving the binding problem with Swfit - Five

回到文章, 相应的 MemberCell 看起来如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// In MemberCell.swift
func setup(viewModel: MemberViewModel) {
    // The imange and the name won't be changed after setup 
    profileImageView.image = viewModel.image 
    nameLabel.text = viewModel.name
        
    // Listen to the change of the isLoading property to update the UI state
    viewModel.isLoading.valueChanged { [weak self] (isLoading) in
        if isLoading {
            self?.loadingIndicator.startAnimating()
        } else {
            self?.loadingIndicator.stopAnimating()
        }
    }
}

setup(viewModel:) 函数中,我们使用 viewModel 参数中的信息在单元格上设置 UI 组件。
此外,我们设置 valueChanged 闭包来监听 isLoading 属性的值更改。 当用户按下单元格上的 “+” 按钮时,isLoading 变为 “true” 。通过使用 MVVM ,我们简化了设置功能的参数。
此外,将 UI 逻辑转换为对象操作对于更多Swift技术非常有益,这将在下一节中介绍。

在这里,我们还有一件事要做。在单元格中,我们使用转义闭包 valueChanged 来通知单元格 UI 更新。基本上,当用户滚动表视图时,将重用该单元格。 ViewModel 和 Cell 之间的关系可以用下图表示

当用户不断地向下滚动,离开屏幕的 MemberCell A 原本是与 MemberViewModel 1 绑定的,现在被 reuse ,给 MemberViewModel 3 使用,但是因为 MemberViewModel 1 的 valueChanged closure 还没有释放,所以当 MemberViewModel 的 isLoading 修改后, MemberViewModel 1 仍然会通知 MemberCell A 。也就是说, MemberViewModel 1MemberViewModel 3 的 isLoading 属性更新了同一个单元格。

所以在设定绑定的同时,也要记得在 reuse 时把 closure 清空:

1
2
3
4
override func prepareForReuse() {
    super.prepareForReuse()
    viewModel?.isLoading.valueChanged = nil
}

这样就可以确保 cell 只针对正确的 ViewModel 做反应,而不会因为 reuse 造成错误的更新。

现在,可以对 dataSource 再进一步简化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let viewModel = viewModels[indexPath.row]
if let memberViewModel = viewModel as? MemberViewModel, let cell = tableView.dequeueReusableCell(withIdentifier: MemberCell.cellIdentifier(), for: indexPath) as? MemberCell {
    cell.setup(viewModel: memberViewModel)
    return cell
} else if let photoViewModel = viewModel as? PhotoViewModel, let cell = tableView.dequeueReusableCell(withIdentifier: PhotoCell.cellIdentifier(), for: indexPath) as? PhotoCell {
    cell.setup(viewModel: photoViewModel)
    return cell
} else {
    fatalError("Unhandled feed \(feed)")
}

dataSource 的职责非常简单:使用 cell 的 ViewModel 设置 tableview 。 另一方面,设置 UI 组件的细节被委托给 cell 。cell 的状态储存在 ViewModel 中(由Controller 而不是 ViewController 拥有)。

实际上,还有改进的余地!由于我们统一了单元的接口,我们可以通过使用 Protocol 来减少冗余!

利用 Protocol 减少冗余

func tableView(tableView:, indexPath:) -> UITableViewCell 中调用了两次 setup(viewModel:) 。我们可以使用 Swift Protocol 为各种 cell 创建通用设置函数。
让我们创建一个名为 CellConfigurable 的协议

1
2
3
protocol CellConfigurable {
    func setup(viewModel: RowViewModel) // Provide a generic function
}

这是 cell 协议。可以使用 RowViewModel 实例设置确认协议的单元。
同时,RowViewModel是另一种协议:

1
2
3
4
5
protocol RowViewModel {}

// make view models conform the RowViewModel protocol
class MemberViewModel: RowViewModel {...}
class PhotoViewModel: RowViewModel {...}

符合 RowViewModel 的 ViewModel 可以用来设置 CellConfigurable 。通过使用协议,我们可以进一步简化 dataSource :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let rowViewModel = self.viewModels[indexPath.row]

    let cell = tableView.dequeueReusableCell(withIdentifier: self.cellIdentifier(for: rowViewModel), for: indexPath)

    if let cell = cell as? CellConfigurable {
        cell.setup(viewModel: rowViewModel)
    }
    return cell
}

/// Map the view model with the cell identifier (which will be moved to the Controller)
private func cellIdentifier(for viewModel: RowViewModel) {
    switch viewModel {
    case is PhotoCellViewModel:
        return PhotoCell.cellIdentifier()
    case is MemberCellViewModel:
        return MemberCell.cellIdentifier()
    default:
        fatalError("Unexpected view model type: \(viewModel)")
    }
}

现在添加一个单元格类型不会改变 func tableView(tableView:,indexPath :) - > UITableViewCell 的实现!换句话说,dataSource 的可扩展性变得很好:无论我们添加多少种 cell,复杂性都保持不变!

更多的 Protocol

Protocol 还可以用来简化与 cell 的交互:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
protocol ViewModelPressible {
    var cellPressed: (()->Void)? { get set }
}

class PhotoViewModel: RowViewModel, ViewModelPressible {
    let title: String
    let desc: String
    var image: AsyncImage

    var cellPressed: (() -> Void)? // Conform the ViewModelPressible protocol
}

创建一个 ViewModelPressible 协议。有一个必需的实现:一个名为cellPressed的闭包变量。
然后,我们通过添加变量 cellPressed 使 PhotoViewModel 符合 ViewModelPressible 。符合 ViewModelPressible 意味着 ViewModel 能够处理用户按下事件。

在 tableView 的代理,我们添加 func tableView(_ tableView:, indexPath:) 的实现代码:

1
2
3
4
5
6
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let viewModel = self.viewModels[indexPath.row]
    if let viewModel = viewModel as? ViewModelPressible {
        rowViewModel.cellPressed?()
    }
}

现在所有 didSelectRow 事件都转换为相应的 ViewModel 。以下示例显示了用法:

// When creating the view model
let photoCellViewModel = PhotoCellViewModel()
photoCellViewModel.cellPressed = {
                    print("Ask the coordinator or the view controller to open a photo viewer!")
                }

同样,我们成功地将交互包装到 ViewModel 对象中,并保持表视图委托的干净!

完成所有这些工作后,我們稍微把镜头拉远一点,回头看一下我们的 FeedListViewController 。

处理业务逻辑

FeedListViewController还有一个不相关的作业:

  • 设定 cell 的 UI 组件,如 nameLabel, imageView
  • 记录单元格的状态,例如 cellLoadingStates
  • 进行 API 调用并保存数据

新的构架:

我们想让 FeedListViewController 成为一个简单的视图,并将业务逻辑移动到另一个:FeedListController 。 FeedListController 负责进行 API 调用并保存数据模型。
我们可以说 FeedListController 的职责是处理业务逻辑。基于这个想法,让我们创建一个FeedListController:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// FeedListController.swift
let viewModels = Observable<[RowViewModel]>(value: [])

func start() {
    service.fetchFeed { [weak self] feeds in
        self?.buildViewModels(feeds: feeds)
    }
}

func buildViewModels(feeds: [Feed]) {
    var viewModels = [RowViewModel]()
    for feed in feeds {
        if let feed = feed as? Member {
            viewModels.append( MemberViewModel.from(feed) )
        } else if let feed = feed as? Photo {
            var photoViewModel = PhotoViewModel.from(feed)
            photoViewModel.cellPressed = { [weak self] in  
                self?.handleCellPressed(feed)
            }
            viewModels.append(photoViewModel)
        }
    }
    self.viewModels.value = viewModels
}

func handleCellPressed(_ feed: Feed) {
    // Send analytics, fetch detail data, etc
    // Open detail photo view
}

从上面的代码中可以看到,controller 不知道任何 UI 组件的逻辑,相反, ViewModel 在此处成为了 View 和 ViewController 的中间人。

从另一个角度来看,FeedListViewController变为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var viewModels: Observable<[RowViewModel]> {
    return controller.viewModels
}

override func viewDidLoad() {
    viewModels.valueUpdate { [weak self] (_) in
        self?.tableView.reloadData()
    }
    controller.start()
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return rowViewModels.value.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let rowViewModel = self.rowViewModels.value[indexPath.row]
    let cell = tableView.dequeueReusableCell(withIdentifier: controller.cellIdentifier(for: rowViewModel), for: indexPath)
    if let cell = cell as? CellConfigurable {
        cell.setup(viewModel: rowViewModel)
    }
    return cell
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if let rowViewModel = rowViewModels.value[indexPath.row] as? ViewModelPressible {
        rowViewModel.cellPressed?()
    }
}

FeedViewController 的唯一职责是设置表视图,充当 dataSource 和表视图的委托。添加单元格类型或修改交互不会更改此 ViewController。

这样做有很多好处:可以编写单元测试来测试 UI 状态,用户交互以及服务器连接行为!
除了可测试性的提高外,整个 feed 模块的可扩展性也得到了提高!

Summary

在本文中,我们展示了如何通过执行这些更改来简化复杂的 TableView :

  1. 使单元格处理UI组件设置;
  • 使用MVVM抽象UI组件和交互;
  • 使用协议来整合设置界面;
  • 使用 Controller 来处理业务逻辑。

Massive View Controller 是一个臭名昭着的反模式。现在我们知道问题不是 MVC 模式本身。有许多方法可以分离系统的职责,包括但不限于 MVVM 模式。我会说 MVVM 模式是提高代码质量的少数工具,但它不是一个灵丹妙药。
除了 MVVM 之外,我们还需要处理诸如可扩展性,可测试性以及 SOLID-Wikipedia 等更多原则之类的事情。
因此,在采用任何架构之前,请彻底了解架构的设计,仔细选择最适合您需求的架构。

相关文章

Share on

Serendipity
WRITTEN BY
Serendipity
iOS/Golang/Rust

What's on this Page