20 View Model 架构:如何准备 UI 层的数据?

UI 是 App 的重要组成部分,因为所有 App 都必须呈现 UI,并接收用户的事件。为了让 UI 能正确显示,我们需要把 Model 数据进行转换。例如,当我们显示图片的时候,需要把字符串类型的 URL 转换成 iOS 所支持 URL 类型;当显示时间信息时,需要把 UTC 时间值转换成设备所在的时区。

不过存在一个问题,如果我们把所有类型转换的逻辑都放在 UI/View 层里面,作为 View 层的 View Controller 往往会变得越来越臃肿。 为了避免这一情况,我使用了 MVVM 模式和 RxSwift 来架构 Moments App。MVVM 模式的核心部分是 ViewModel 模块,主要用于把 Model 转换成 UI/View 层所需的数据。为了简化转换的工作,我使用了 RxSwift 的操作符(Operator)。

所以,在这一讲中,我会和你介绍下 ViewModel 模式是怎样工作的,以及如何使用 RxSwift 里常用的操作符。

ViewModel 模式的架构

首先我们以朋友圈功能为例,看看 ViewModel 模式的架构图。

View 模块负责呈现 UI,并接收用户的事件。在朋友圈功能中,MomentsTimelineViewController负责呈现朋友圈的时间轴列表。为了正确显示该页面,我们需要为它准备好一些的数据,例如朋友的名字,朋友头像的 URL 等等,那些数据可以从 ViewModel 模块中读取。

ViewModel 模块是 MVVM 模式的核心,该模块由两个重要的协议所组成:ListViewModelListItemViewModel。其中ListViewModel协议用于定义列表页面所需的 ViewModel,而ListItemViewModel用于定义每一条列表项所需的 ViewModel。当他们需要读写数据时,会调用 Repository 模块。比如在朋友圈功能里面,它们都调用MoomentsRepoType来读写数据。

ViewModel 模式的实现

有了上述的架构图,我们就可以看看 ViewModel 模块是怎样实现的。首先看一下ListViewModel协议的定义。

protocol ListViewModel {var hasContent: Observable<Bool> { get }var hasError: BehaviorSubject<Bool> { get }func trackScreenviews()func loadItems() -> Observable<Void>var listItems: BehaviorSubject<[SectionModel<String, ListItemViewModel>]> { get }
}

下面我们逐一介绍该协议的各个属性与方法。hasContent属性用于通知 UI 是否有内容。例如,当 BFF 没有返回数据时,我们可以在页面上提示用户“目前还没有朋友圈信息,可以添加好友来查看更多的朋友圈信息”。

为了代码共享,我们为hasContent属性提供了一个默认的实现,代码如下。

extension ListViewModel {var hasContent: Observable<Bool> {return listItems.map(\.isEmpty).distinctUntilChanged().asObservable()}
}

这个方法使用mapdistinctUntilChanged操作符来把listItems转换成 Bool 类型的hasContent。其中map用于提取listItems里的数组并检查是否为空,distinctUntilChanged用来保证只有在值发生改变时才发送新事件。

hasError属性是一个BehaviorSubject,其初始值为false。它用于通知 UI 是否需要显示错误信息。

trackScreenviews()方法用于发送用户行为数据。而loadItems() -> Observable<Void>方法用于读取数据。

最后看一下listItems属性。 该属性用于准备 TableView 所需的数据,其存放了类型为ListItemViewModel的数据。ListItemViewModel能为 TableView 的各个 Cell 提供所需数据。该协议只定义一个名为reuseIdentifier的静态属性 ,如下所示。

protocol ListItemViewModel {static var reuseIdentifier: String { get }
}
extension ListItemViewModel {static var reuseIdentifier: String {String(describing: self)}
}

reuseIdentifier属性作为 TableView Cell 的唯一标示,为了重用,我们通过协议扩展来为该属性提供一个默认的实现并把类型的名字作为字符串进行返回。
上述就是ListViewModel协议的定义,接下来看它的实现结构体MomentsTimelineViewModel

由于MomentsTimelineViewModel遵循了ListViewModel协议,因此需要实现了该协议中listItemshasError属性以及loadItems()trackScreenviews()方法。我们首先看一下loadItems()方法的实现。

func loadItems() -> Observable<Void> {return momentsRepo.getMoments(userID: userID)
}

当 ViewModel 需要读取数据的时候,会调用 Repository 模块的组件,在朋友圈功能中,我们调用了MomentsRepoTypegetMoments()方法来读取数据。

接着看看trackScreenviews()方法的实现。在该方法里面,我们调用了TrackingRepoTypetrackScreenviews()方法来发送用户的行为数据,具体实现如下。

func trackScreenviews() {trackingRepo.trackScreenviews(ScreenviewsTrackingEvent(screenName: L10n.Tracking.momentsScreen, screenClass: String(describing: self)))}

ViewModel 模块的一个核心功能,是把 Model 数据转换为用于 UI 呈现所需的 ViewModel 数据,我通过下面代码看它是怎样转换的。

func setupBindings() {momentsRepo.momentsDetails.map {[UserProfileListItemViewModel(userDetails: $0.userDetails)]+ $0.moments.map { MomentListItemViewModel(moment: $0) }}.subscribe(onNext: {listItems.onNext([SectionModel(model: "", items: $0)])}, onError: { _ inhasError.onNext(true)}).disposed(by: disposeBag)
}

从代码中你可以发现,我们订阅了momentsRepomomentsDetails属性,接收来自 Model 的数据更新。因为该属性的类型是MomentsDetails,而 View 层用所需的数据类型为ListItemViewModel。我们通过 map 操作符来进行类型转换,在转换成功后,调用listItemsonNext()方法把准备好的 ViewModel 数据发送给 UI。如果发生错误,就通过hasError属性发送出错信息。

在 map 操作符的转换过程中,我们分别使用了UserProfileListItemViewModelMomentListItemViewModel结构体来转换用户简介信息和朋友圈条目信息。这两个结构体都遵循了ListItemViewModel协议。

接下来是它们的实现,首先看一下UserProfileListItemViewModel

struct UserProfileListItemViewModel: ListItemViewModel {let name: Stringlet avatarURL: URL?let backgroundImageURL: URL?init(userDetails: MomentsDetails.UserDetails) {name = userDetails.nameavatarURL = URL(string: userDetails.avatar)backgroundImageURL = URL(string: userDetails.backgroundImage)}
}

该结构体只包含了三个属性:nameavatarURLbackgroundImageURL

其中,由于name属性的类型与MomentsDetails.UserDetailsname属性的类型都是字符串,我们只需要直接赋值就可以了。

avatarURLbackgroundImageURL用于在 UI 上显示图片。因为 BFF 返回的 URL 值都是字符串类型,我们需要把字符串转换成URL类型。所有的转换工作我都放在init(userDetails: MomentsDetails.UserDetails)方法里面完成,我们只需要调用URL的初始化函数即可。

接着看一下MomentListItemViewModel结构体,它也是负责把 Model 的数据类型转换成用于 View 层显示 UI 的 ViewModel 数据。其转换的逻辑也封装在init()方法中,我们一起看看该方法是如何工作的。

init(moment: MomentsDetails.Moment, now: Date = Date(), relativeDateTimeFormatter: RelativeDateTimeFormatterType = RelativeDateTimeFormatter()) {userAvatarURL = URL(string: moment.userDetails.avatar)userName = moment.userDetails.nametitle = moment.title

if let firstPhoto = moment.photos.first {
        photoURL = URL(string: firstPhoto)
    } else {
        photoURL = nil
    }
    var formatter = relativeDateTimeFormatter
    formatter.unitsStyle = .full
    if let timeInterval = TimeInterval(moment.createdDate) {
        let createdDate = Date(timeIntervalSince1970: timeInterval)
        postDateDescription = formatter.localizedString(for: createdDate, relativeTo: now)
    } else {
        postDateDescription = nil
    }
}

userNametitle属性都是字符串类型,只需要简单的赋值就可以了。而userAvatarURLphotoURL属性需要把字符串转换为URL类型来呈现图片。

postDateDescription属性相对复杂些,它的用途是显示一个相对的时间值,例如 “5 分钟前”“2 小时前”等。我们需要把朋友圈信息生成的时间与当前时间进行对比,然后根据手机上的语言配置来显示相对时间值。

RxSwift 操作符

ViewModel 的核心功能是把 Model 数据转换为用于 UI 呈现所需的数据。其实RxSwift 的操作符就是负责转换的,使用合适的操作符能帮我们减少代码量并提高生产力。因此我建议你把 RxSwift 所提供的所有操作符都看一遍,然后在实际工作再挑选合适的来满足业务需求。

在这里,我着重介绍下过滤操作符,转换操作符和合并操作符中常用的 filter、distinctUntilChanged、map 和 combineLatest 等用法。

过滤操作符

过滤操作符用于过滤事件,我们可以使用过滤操作符把订阅者不关心的事件给过滤掉。常用的过滤操作符有 filter 和 distinctUntilChanged。

filter操作符常用于通过规则过滤不需要的事件,例如在朋友圈功能里面,可以把发布时间早于一天前的信息过滤掉不显示。为了方便理解,我就以几个数字来解释下。如下所示,有 2、23、5、60、1、31,我想把小于 10 的数过滤掉,就可以通过 filter 设置过滤规则,然后打印出来的数字就是 23、 60、31。代码示例如下。

Observable.of(2, 23, 5, 60, 1, 31).filter { $0 > 10 }.subscribe(onNext: {print($0)}).disposed(by: disposeBag)

过滤操作符 filter 的效果

distinctUntilChanged用于把相同的事件过滤掉。如下面例子中的第二个 1 和第四个 2,使用distinctUntilChanged 就可以把它们给过滤掉,然后打印出 1、 2、 1。代码和图例如下所示。

Observable.of(1, 1, 2, 2, 1).distinctUntilChanged().subscribe(onNext: {print($0)}).disposed(by: disposeBag)

过滤操作符 distinctUntilChanged 的效果

除了相同的事件,我们还可以使用操作符distinctUntilChanged过滤掉相同的状态,从而避免频繁更新 UI。例如,我们先使用本地缓存数据呈现 UI,然后发起网络请求。当请求成功以后可以把结果数据与缓存进行对比,如果数据一致就没必要再次更新 UI。

转换操作符

转换操作符非常实用,能帮助我们从一种数据类型转变成另外一种类型,例如我们可以把用于数据传输和存储的 Model 类型转换成用于 UI 呈现的 ViewModel 类型。在这里,我就以几个常用的转换操作符 map,compactMap 和 flapMap 来介绍下如何使用它们。

map是一个十分常用的操作符,可用于从一种类型转换成另外一种类型,例如下面的例子,我把数值类型转换成字符串。程序执行的时候会打印 "String: 1" 和 "String: 2"。代码和图例如下所示。

Observable.of(1, 2).map { "String: " + String($0) }.subscribe(onNext: {print($0)}).disposed(by: disposeBag)

转换操作符 map 的效果

compactMap常用于过滤掉值为nil的操作符,你可以把 compactMap 理解为同时使用 filter 和 map 的两个操作符。filter 把nil的值过滤掉,而 map 把非空的值进行转换。

例如下面的例子中,我把字符串的值转换为数值类型,并把转换不成功的值过滤掉。由于 "not-a-number" 不能转换成数值类型,因此被过滤掉了,执行的时候会打印 1 和 2。代码示例如下所示:

Observable.of("1", "not-a-number", "2").compactMap { Int($0) }.subscribe(onNext: {print($0)}).disposed(by: disposeBag)

转换操作符 compactMap 效果

flatMap用于把两层的 Observable 序列合并到一层。我们通过一个例子来解析到底怎样合并。

请看代码示例:

struct TemperatureSensor {let temperature: Observable<Int>
}
let sensor1 = TemperatureSensor(temperature: Observable.of(21, 23))
let sensor2 = TemperatureSensor(temperature: Observable.of(22, 25))
Observable.of(sensor1, sensor2).flatMap { $0.temperature }.subscribe(onNext: {print($0)}).disposed(by: disposeBag)

在这个例子中,我定义一个叫作TemperatureSensor的结构体,用来表示收集温度的传感器,该结构体包含了一个类型为Observabletemperature的属性。

假如天气站有多个这样的传感器,我们要把它们的温度信息合并到一个单独的 Observable 序列中方便统计,此时就可以使用 flatMap 来完成这项任务。

具体来说,我们在flatMap方法的闭包里面返回temperature属性,由于该属性是一个Observable对象,因此flatMap方法会把这些序列统一合并到一个单独的 Observable 序列里面,并打印出 21、23、22、25。

转换操作符 flatMap 的效果

合并操作符

合并操作符用于组装与合并多个 Observable 序列。我们通过 startWith,concat 和 merge 等几个常用的合并操作符,来看看它们是怎样运作的。

startWith可以使订阅者在接收到 Observable 序列的事件前,先收到传给 startWith 方法的事件。它的使用非常简单,例如在下面的例子中,我们把 3 和 4 传递给startWith。那么在执行过程中,会先把 3 和 4 事件发送给订阅者,其运行效果为 3、4、1、2。代码示例如下:

Observable.of(1, 2).startWith(3, 4).subscribe(onNext: {print($0)}).disposed(by: disposeBag)

合并操作符 startWith 效果

日常中我们可以通过startWith方法,把加载事件插入网络数据事件之前,以此来保持 UI 状态的自动更新。

concat能把多个 Observable 序列按顺序合并在一起。例如,在下面的例子中我们合并了两个 Observable 序列,第一个包含 1 和 2,第二个包含 3 和 4,那么执行的时候会打印 1、2、3、4。代码示例如下。

Observable.of(1, 2).concat(Observable.of(3, 4)).subscribe(onNext: {print($0)}).disposed(by: disposeBag)

合并操作符 concat 效果

merge,常用于合并多个 Observable 序列的操作符,和 concat 不一样的地方是它能保持原来事件的顺序。我们可以通过一个例子来看看,它是怎样合并 Observable 序列的。代码示例如下:

let first = PublishSubject<Int>()
let second = PublishSubject<Int>()
Observable.of(first, second).merge().subscribe(onNext: {print($0)}).disposed(by: disposeBag)
first.onNext(1)
first.onNext(2)
second.onNext(11)
first.onNext(3)
second.onNext(12)
second.onNext(13)
first.onNext(4)

我们调用merge方法把两个 PublishSubject 合并在一起,然后不同的 PublishSubject 会分别发出不同的next事件,订阅者根据事件发生的顺序来接收到相关事件。如下图所示,程序执行时会打印 1、2、11、3、12、13、4。

合并操作符 merge 的效果

combineLatest会把两个 Observable 序列里最后的事件合并起来,代码示例如下。

let first = PublishSubject<String>()
let second = PublishSubject<String>()
Observable.combineLatest(first, second) { $0 + $1 }.subscribe(onNext: {print($0)}).disposed(by: disposeBag)
first.onNext("1")
second.onNext("a")
first.onNext("2")
second.onNext("b")
second.onNext("c")
first.onNext("3")
first.onNext("4")

在程序执行过程中,当其中一个 PublishSubject 发出next事件时,就会从另外一个 PublishSubject 取出其最后一个事件,然后调用combineLatest方法的闭包,把这两个事件合并起来并通知订阅者。上述的例子在执行时会打印 1a、2a、2b、2c、3c、4c。

合并操作符 combineLatest

在实际开发中,combineLatest方法非常实用。我们可以用它来监听多个 Observable 序列,然后组合起来统一更新状态。例如在一个登录页面里面,我们可以同时监听用户名和密码两个输入框,当它们同时有值的时候才激活登录按钮。

zip也能用于合并两个 Observable 序列,和 combineLatest 不一样的地方是, zip 只会把两个 Observable 序列的事件配对合并。就像两队小朋友,排在前头的手牵手来到一个新队列。一旦出来就不再留在原有队列了。

为了方便理解 zip 与 combineLatest 的区别,我在下面例子中也使用了一样的数据并保持事件发送的顺序。

let first = PublishSubject<String>()
let second = PublishSubject<String>()
Observable.zip(first, second) { $0 + $1 }.subscribe(onNext: {print($0)}).disposed(by: disposeBag)
first.onNext("1")
second.onNext("a")
first.onNext("2")
second.onNext("b")
second.onNext("c")
first.onNext("3")
first.onNext("4")

在上述的例子中,有两个 PublishSubject,其中first发出 1、2、3、4,而second发出 a、b、c。zip方法会返回它们的合并事件 1a、2b、3c。由于first所发出next("4")事件没有在second里面找到对应的事件,所以合并后的 Observable 序列只有三个事件。

合并操作符 zip 的效果

上面是常用的操作符,灵活使用它们,我们可以完成绝大部分的任务了。

总结

在这一讲中,我们介绍了 ViewModel 模式的架构与实现和 RxSwift 的操作符。有了 ViewModel,我们可以把业务逻辑从 View 层抽离出来,甚至把 View 层进行替换,例如把 UIKit 替换成 SwiftUI。而 UI 所需的数据,可以通过 ViewModel 模块把 Model 数据转换出来。至于转换工作,我们可以借助操作符来完成。

有关本讲操作符的例子代码,我都放在项目中的RxSwift Playground 文件里面,希望你能多练习,灵活运用。

RxSwift 为我们提供了 50 多个操作符,我建议你到 rxmarbles.com 或者到 App Store 下载 RxMarbles App,并在 App 中替换各种参数来观察执行的结果,这样能帮助你学会所有的操作符,在现实工作中能选择合适的操作符来简化大量的开发工作。

思考题

请问你会把所有逻辑都编写在 ViewController 里面吗?如果没有,使用了怎样模式与架构来解耦呢?能分享一下这方面的经验吗?

请把你的想法写到留言区哦,下一讲我将介绍如何开发统一并且灵活的 UI。

源码地址:

RxSwift Playground 文件地址:
https://github.com/lagoueduCol/iOS-linyongjian/blob/main/Playgrounds/RxSwiftPlayground.playground/Contents.swift
ViewModel 协议定义的源码地址:https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Foundations/ViewModels
朋友圈功能 ViewModel 实现的源码地址:
https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Features/Moments/ViewModels


21 UI 层架构:如何开发统一并且灵活的 UI?

作为 iOS 开发者,我们每天都花大量的时间来开发和调试 UI,那有没有什么办法帮助我们把繁杂的 UI 开发工作简化成有章可循的步骤,从而提高开发的效率呢?在这一讲中,我就和你聊聊,如何架构和开发一套灵活的 UI 框架。

通用列表 UI 模块的架构与实现

列表 UI 是 App 最为常用的 UI 页面,它可以帮我们通过滚动的方式支持无限的内容。为了简化大量的重复性劳动,我在 Moments App 架构实现了一个通用的列表 UI 模块。下面是这个模块的架构图。


这个框架使用了UIViewControllerUITableView来封装列表页面。其核心是BaseTableViewControllerBaseTableViewController继承于BaseViewController,而BaseViewController继承自UIViewController

我们先看看BaseViewController的具体实现,代码示例如下。

class BaseViewController: UIViewController {lazy var disposeBag: DisposeBag = .init()init() {super.init(nibName: nil, bundle: nil)}@available(*, unavailable, message: "We don't support init view controller from a nib.")override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)}@available(*, unavailable, message: "We don't support init view controller from a nib.")required init?(coder: NSCoder) {fatalError(L10n.Development.fatalErrorInitCoderNotImplemented)}
}

因为 Moments App 是使用纯代码的方式来编写 UI,所以BaseViewController重写了init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)init?(coder: NSCoder)两个方法,并直接抛出异常。这样做使得所有继承BaseViewController的子类,都没办法通过 Storyboard 或者 Xib 文件来生成 ViewController 的实例。因为我们使用 RxSwift,BaseViewController还定义了一个disposeBag属性来方便管理所有 Obervable 序列的订阅。

BaseTableViewController继承了BaseViewController,并使用UITableView来封装一个通用的列表页面。我们一起看看它是怎样实现的。

在 MVVM 模式里,View 依赖于 ViewModel。作为 View 的BaseTableViewController依赖于 ViewModel 层的ListViewModel协议,这使得BaseTableViewController只依赖于接口而不是具体的类型,从而提高了程序的可扩展性。

同时,BaseTableViewController还定义了三个属性来显示 UI 控件:

  • tableView属性用于显示一个 TableView;

  • activityIndicatorView属性用于显示俗称小菊花的加载器;

  • errorLabel用于显示出错信息的标签控件。

以下是属性定义的代码示例。

var viewModel: ListViewModel!
private let tableView: UITableView = configure(.init()) {$0.translatesAutoresizingMaskIntoConstraints = false$0.separatorStyle = .none$0.rowHeight = UITableView.automaticDimension$0.estimatedRowHeight = 100$0.contentInsetAdjustmentBehavior = .never$0.backgroundColor = UIColor.designKit.background
}
private let activityIndicatorView: UIActivityIndicatorView = configure(.init(style: .large)) {$0.translatesAutoresizingMaskIntoConstraints = false
}
private let errorLabel: UILabel = configure(.init()) {$0.translatesAutoresizingMaskIntoConstraints = false$0.isHidden = true$0.textColor = UIColor.designKit.primaryText$0.text = L10n.MomentsList.errorMessage
}

为了方便初始化 UIKit 的控件,我写了一个公共的configure()方法,具体代码如下:

func configure<T: AnyObject>(_ object: T, closure: (T) -> Void) -> T {closure(object)return object
}

有了该方法,我们就可以把所有初始化操作都放在一个闭包(Closure)里面,方便代码的维护。

接着我们看一下setupUI()方法的代码实现。

func setupUI() {view.backgroundColor = UIColor.designKit.backgroundtableViewCellsToRegister.forEach {tableView.register($0.value, forCellReuseIdentifier: $0.key)}[tableView, activityIndicatorView, errorLabel].forEach {view.addSubview($0)}
}

该方法负责设置 UI 的样式,例如设置背景颜色,注册 TableView Cell 和添加子控件。

配置完 UI 的样式以后,下一步是配置自动布局的约束(Auto Layout Constraint)。当使用 UIKit 作为 View 层的时候,我推荐使用苹果公司所推荐的自动布局来排版 UI 页面。

自动布局能帮助我们支持不同分辨率和屏幕对比率的页面,而且苹果公司每年都在不断优化自动布局引擎的性能。不过,它也有一个缺点,那就是手写自动布局的约束代码会十分冗长,为此我使用一个名叫SnapKit 的库来进行简化。下面我们就通过setupConstraints()的代码,来看看 SnapKit 的威力。

func setupConstraints() {tableView.snp.makeConstraints {$0.edges.equalToSuperview()}activityIndicatorView.snp.makeConstraints {$0.center.equalToSuperview()}errorLabel.snp.makeConstraints {$0.center.equalToSuperview()}
}

如上述代码所示,当使用 SnapKit 来配置自动布局的约束时,我们需要调用它的扩展方法makeConstraints,然后把所有约束的配置都放到闭包里面。在这里,我是通过edges.equalToSuperview()tableView延伸到它的父组件(也就是BaseTableViewControllerview)中,然后通过center.equalToSuperview()方法把activityIndicatorViewerrorLabel都分别居中。

假如不使用 SnapKit,要完成延伸tableView的操作,就需要以下的代码。

NSLayoutConstraint.activate([tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),tableView.topAnchor.constraint(equalTo: view.topAnchor),tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])

我们不得不分别配置每一个约束,并放进一个数组里面,然后传递给静态方法NSLayoutConstraint.activate。 所以你看,使用 SnapKit 多么方便。

完成了 UI 的布局以后,我们看一下数据绑定。Moments App 使用了 RxSwift 把 ViewModel 层和 View 层进行绑定,绑定的代码在setupBindings()函数里,具体如下。

func setupBindings() {tableView.refreshControl = configure(UIRefreshControl()) {let refreshControl = $0$0.rx.controlEvent(.valueChanged).filter { refreshControl.isRefreshing }.bind { [weak self] _ in self?.loadItems() }.disposed(by: disposeBag)}let dataSource = RxTableViewSectionedReloadDataSource<SectionModel<String, ListItemViewModel>>(configureCell: { _, tableView, indexPath, item inlet cell = tableView.dequeueReusableCell(withIdentifier: String(describing: type(of: item)), for: indexPath)(cell as? ListItemCell)?.update(with: item)return cell})viewModel.listItems.bind(to: tableView.rx.items(dataSource: dataSource)).disposed(by: disposeBag)viewModel.hasError.map { !$0 }.bind(to: errorLabel.rx.isHidden).disposed(by: disposeBag)
}

这个函数由三部分组成,第一部分是通过 RxSwift 和 RxCocoa ,把UIRefreshControl控件里的isRefreshing事件和loadItems()函数绑定起来。当用户下拉刷新控件的时候会调用loadItems()函数来刷新列表的数据。

第二部分是把 TableView Cell 控件与 ViewModel 的listItemsSubject 属性绑定起来,当listItems发出新的事件时,我们会调用ListItemCellupdate(with viewModel: ListItemViewModel)方法来更新 UI。经过了这一绑定,UI 就能随着 ViewModel 的数据变化而自动更新。

第三部分与第二部分类似,都是把 ViewModel 与 View 层的控件进行绑定。在这里,我们把 ViewModel 的hasErrorSubject 属性绑定到errorLabel.rx.isHidden属性来控制errorLabel是否可见。

你可能注意到在errorLabel后面有.rx属性,这是 RxCocoa 为UILabel控件所提供的一个扩展,它为isHidden属性提供了响应式编程的功能。有了这一功能,它就可以与 ViewModel 的 Subject 属性进行绑定,从而实现自动更新。

数据绑定以后,我们一起看看loadItems()函数的实现。

func loadItems() {viewModel.hasError.onNext(false)viewModel.loadItems().observeOn(MainScheduler.instance).do(onDispose: { [weak self] inself?.activityIndicatorView.rx.isAnimating.onNext(false)self?.tableView.refreshControl?.endRefreshing()}).map { false }.startWith(true).distinctUntilChanged().bind(to: activityIndicatorView.rx.isAnimating).disposed(by: disposeBag)
}

loadItems()方法用于加载数据。当我们第一次进入朋友圈页面的时候,或者用户下拉刷新控件的时候,就会调用该方法来重新加载数据。

该方法主要做两项工作,第一项是调用viewModel.hasError.onNext(false)来更新 ViewModel 的hasError属性, 它能让 UI 上的错误标签信息消失。

从代码中你可以看到,尽管我们想更新 UI 层的errorLabel控件,却没有直接通过errorLabel.isHidden = true的方式来更新,而是通过 ViewModel 的hasError属性来完成。这是因为我要保证 View/UI 层都是由 ViewModel 驱动,通过单方向的数据流来减少 Bug ,从而提高代码的可维护性。

loadItems()方法的第二项工作,是让 ViewModel 去加载数据并绑定到activityIndicatorView控件的isAnimating属性上。因为我们需要在主排程器上执行 UI 任务,因此调用了.observeOn(MainScheduler.instance),把所有任务都安排到主排程器上。

当 ViewModel 的loadItems()方法开始执行的时候,先通过.startWith(true)来让activityIndicatorView启动动画效果。当 ViewModel 的loadItems()方法返回数据时,把结果数据通过.map { false }方法来返回false,从而使得activityIndicatorView停止动画效果。

假如用户在调用 ViewModel 的loadItems()方法的过程中,退出列表页面,我们通过.do(onDispose:{})方法来停止activityIndicatorViewrefreshControl两个控件的刷新动画。

到此为止,我们已经知道BaseTableViewController是如何通过 TableView 来实现列表 UI 的了。

为了显现不同的 TableView Cell,接下来我们了解下通用的 Cell 是如何实现的。

这部分由四个类型所组成,分别是ListItemCell协议及其子结构体BaseTableViewCell,以及ListItemView协议及其子结构体BaseListItemView

ListItemCell协议的定义非常简单,如下所示。

protocol ListItemCell: class {func update(with viewModel: ListItemViewModel)
}

该协议只包含了一个update(with viewModel: ListItemViewModel)方法来让其子类型根据ListItemViewModel的数据进行更新。

其子类型BaseTableViewCell的具体代码如下:

final class BaseTableViewCell<V: BaseListItemView>: UITableViewCell, ListItemCell {private let view: Voverride init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {view = .init()super.init(style: style, reuseIdentifier: reuseIdentifier)selectionStyle = .nonecontentView.addSubview(view)view.snp.makeConstraints {$0.edges.equalToSuperview()}}required init?(coder: NSCoder) {fatalError(L10n.Development.fatalErrorInitCoderNotImplemented)}func update(with viewModel: ListItemViewModel) {view.update(with: viewModel)}
}

BaseTableViewCell是一个UITableViewCell的子类,并遵循了ListItemCell协议,因此它需要实现update(with viewModel: ListItemViewModel)方法。在该方法里面,它直接调用view属性的update(with viewModel: ListItemViewModel)来更新BaseListItemView组件的 UI。

那为什么我们不把所有 UI 子控件都直接写在 Cell 里,而使用一个额外的BaseListItemView呢?因为这样做可以把BaseListItemView复用到UICollectionView等其他容器中。

接下来我们一起看看BaseListItemView及其所遵循的ListItemView协议的代码。

protocol ListItemView: class {func update(with viewModel: ListItemViewModel)
}
class BaseListItemView: UIView, ListItemView {lazy var disposeBag: DisposeBag = .init()func update(with viewModel: ListItemViewModel) {fatalError(L10n.Development.fatalErrorSubclassToImplement)}
}

ListItemView协议只定义了update(with viewModel: ListItemViewModel)接口来通过 ViewModel 更新 UI。因为每个 UI 组件的布局与呈现都可能不一样,因此,BaseListItemView在实现update(with viewModel: ListItemViewModel)方法时,直接抛出了异常,这样能迫使其子类重写该方法。

上面就是通用列表 UI 模块的架构与实现,有了这一个框架,我们就能快速实现不同的列表页面,下面以朋友圈功能作为例子来看看如何实现一个朋友圈时间轴页面。

朋友圈时间轴页面的实现

首先我们一起看看朋友圈时间轴页面的架构图。


MomentsTimelineViewController用于显示朋友圈时间轴页面,其具体代码如下。

final class MomentsTimelineViewController: BaseTableViewController {override init() {super.init()viewModel = MomentsTimelineViewModel(userID: UserDataStore.current.userID)}override func viewDidAppear(_ animated: Bool) {super.viewDidAppear(animated)viewModel.trackScreenviews()}override var tableViewCellsToRegister: [String : UITableViewCell.Type] {return [UserProfileListItemViewModel.reuseIdentifier: BaseTableViewCell<UserProfileListItemView>.self,MomentListItemViewModel.reuseIdentifier: BaseTableViewCell<MomentListItemView>.self]}
}

因为BaseViewController已经封装好绝大部分的 UI 处理逻辑,作为子类,MomentsTimelineViewController的实现变得非常简单,只需三部分。

首先是初始化viewModel。因为BaseViewController通过var viewModel: ListViewModel!来定义viewModel属性,作为子类的MomentsTimelineViewController也必须初始化viewModel属性,否则程序会崩溃。具体来说,我们只需创建一个MomentsTimelineViewModel对象来完成初始化即可。它的实现我在上一讲已经介绍过了,你可以再留意下。

然后,我在func viewDidAppear(_ animated: Bool)方法里面调用viewModel.trackScreenviews()来让 ViewModel 发送用户行为数据。

为了帮BaseViewController提供需要注册的 TableView Cell ,最后我重写了tableViewCellsToRegister属性。该属性存放BaseTableViewCell的实例。BaseTableViewCell使用范型(generic)来存放BaseListItemView的子类,这些子类包括UserProfileListItemViewMomentListItemView

你可以从下图中看到它们所呈现的 UI 组件。

MomentsTimelineViewController我们已介绍完毕了,下面咱们以UserProfileListItemView为例,看一下开发子控件的步骤与实现。

UserProfileListItemView用于显示用户自己的资料,例如用户名字,头像和背景图。因为有了通用和统一的 UI 开发框架,每次开发 UI 页面的步骤都是一致的,具体我分为以下几步完成:

  1. 初始化 UI 控件的属性;

  2. 配置 UI 控件的样式;

  3. 设置自动布局的约束;

  4. 重写update(with viewModel: ListItemViewModel)方法,根据 ViewModel 的数据来更新 UI。

先看一下初始化 UI 控件属性的代码。

private let backgroundImageView: UIImageView = configure(.init()) {$0.translatesAutoresizingMaskIntoConstraints = false$0.contentMode = .scaleAspectFill$0.accessibilityIgnoresInvertColors = true
}
private let avatarImageView: UIImageView = configure(.init()) {$0.translatesAutoresizingMaskIntoConstraints = false$0.asAvatar(cornerRadius: 8)$0.contentMode = .scaleAspectFill$0.accessibilityIgnoresInvertColors = true
}
private let nameLabel: UILabel = configure(.init()) {$0.translatesAutoresizingMaskIntoConstraints = false$0.font = UIFont.designKit.title3$0.textColor = .white$0.numberOfLines = 1
}

我们分别调用configure()函数来初始化三个 UI 控件的属性,backgroundImageView用于显示背景图,avatarImageView用于显示用户头像,而nameLabel用于显示用户名字。

你可以根据下图,看到它们分别使用在哪里。

接着来看在第二步中如何配置 UI 控件的样式,我用setupUI()方法来实现。

func setupUI() {backgroundColor = UIColor.designKit.background[backgroundImageView, avatarImageView, nameLabel].forEach {addSubview($0)}
}

在这里,我使用了 DesignKit 来设置了背景颜色,并把子控件添加到当前 View 里面。

然后看第三步如何设置自动布局的约束,其实现代码如下。

func setupConstraints() {backgroundImageView.snp.makeConstraints {$0.top.leading.trailing.equalToSuperview()$0.bottom.equalToSuperview().offset(-Spacing.medium)$0.height.equalTo(backgroundImageView.snp.width).multipliedBy(0.8).priority(999)}avatarImageView.snp.makeConstraints {$0.right.equalToSuperview().offset(-Spacing.medium)$0.bottom.equalToSuperview()$0.height.equalTo(80)$0.width.equalTo(80)}nameLabel.snp.makeConstraints {$0.right.equalTo(self.avatarImageView.snp.left).offset(-Spacing.medium)$0.centerY.equalTo(self.avatarImageView.snp.centerY)}
}

其中backgroundImageView的顶部和两边都延展到父控件,因为底部需要留白来显示用户头像,因此添加了medium作为间距。背景图片的长宽比是 5:4。

avatarImageView位于父控件的右下角,并设定长度和宽度都为 80pt。nameLabel位于avatarImageView的左边,并与之水平。这样我们就使用 SnapKit 完成用户资料 UI 的布局了。
最后一部分是调用update()方法来更新 UI,其代码如下。

override func update(with viewModel: ListItemViewModel) {guard let viewModel = viewModel as? UserProfileListItemViewModel else {return}backgroundImageView.kf.setImage(with: viewModel.backgroundImageURL)avatarImageView.kf.setImage(with: viewModel.avatarURL)nameLabel.text = viewModel.name
}

因为UserProfileListItemViewModel已经为UserProfileListItemView准备好呈现所需的所有数据,因此,只要简单的赋值就可以更新 UI 了。

MomentListItemView的代码结构和UserProfileListItemView基本一样,你可以到拉勾教育的代码仓库进行查看。

总结

在这一讲中,我为你介绍了如何架构和实现一个通用的列表 UI 模块,有了这个模块,我们按照以下这几个步骤就可以完成 UI 的开发了。

  1. 初始化 UI 控件的属性,把 UI 分解成不同的子控件,然后通过configure()来初始化各个控件属性。

  2. 配置 UI 控件的样式,如配置背景颜色等,并把各个子控件添加到父控件里面。

  3. 设置自动布局的约束,推荐使用 SnapKit 来简化配置约束的工作。

  4. 重写update(with viewModel: ListItemViewModel)方法,根据 ViewModel 的数据来更新 UI。如果有数据绑定,那么使用 RxSwift 和 RxCocoa 把 ViewModel 的 Subject 属性绑定到 UI 控件上。如果不需要数据绑定,只需把 ViewModel 准备好的值赋给 UI 控件即可。

思考题

请问你们使用苹果提供的自动布局吗?如果是,是使用原生语法还是类似 SnapKit 那种库呢?或者说使用 Texture 等其他非苹果的框架进行布局?能分享你的使用经验吗?

可以把你的思考写到留言区哦,下一讲,我会介绍如何使用现有架构添加点赞功能。

源码地址

通用列表 UI 的源码地址:
https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Foundations/Views
朋友圈时间轴页面实现的源码地址:
https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Features/Moments/Views


22 功能实战:如何使用现有架构添加点赞功能?

你有没有遇到过接手一份新的代码却不知道如何下手的情况?其实,一套良好的开发框架就能有效地解决这种问题。规范的架构与框架不仅具有良好的可扩展性,例如,可以灵活地替换网络层、数据库甚至 UI 层的实现,而且还为开发者提供了统一的开发步骤与规范,方便新功能的快速迭代。

我们的 Moments App 使用了 MVVM 架构来支持快速开发,在这一讲中,我们再以添加点赞功能为例来看看如何一步一步去开发一个新功能。

如下面的动图所示,我们可以摇动手机来打开内部功能菜单页面,在该页面内点击开启点赞按钮来启动点赞功能。当重启 App 以后,我们就能在朋友圈页面里看到点赞按钮了。

![在这里插入图片描述](https://img-blog.csdnimg.cn/7294a24d0a47408989cf8fa7f8557db4.png#pic_center)

根据组件间的依赖关系,我们可以按照以下五个步骤来进行开发:

  • 增加“添加点赞功能”的功能开关;

  • 开发网络层来更新 BFF 的点赞信息;

  • 开发 Repository 层来存储数据;

  • 开发 ViewModel 层来准备 UI 所需的数据;

  • 开发 UI/View 层呈现点赞按钮和点赞朋友列表。

下面我们就来详细说明这每一个步骤。

增加功能开关

当我们开发一个周期比较长的新功能时,通常会使用功能开关。

如果没有功能开关,当开发周期超过一周以上时,我们就不得不把开发中的功能放在一个“长命”功能分支下,直到整个功能完成后才合并到主分支,这往往会增加合并分支的难度。

另一种方法是延迟发布的时间,在功能完整开发出来后才进行发布。假如有多个团队一直在开发新功能,那么发布计划就可能一直在延迟。但如果我们使用了功能开关,就可以把未完成的功能一直隐藏着,直到通过完整的测试和产品验证后才把开关启动并进行发布。总之,有了功能开关,我们可以支持多个团队并行开发,并在此期间随时发布新版本的 App

下面我们看看如何为添加点赞功能增加一个功能开关,具体代码如下:

enum InternalToggle: String, ToggleType {case isLikeButtonForMomentEnabled
}

首先,我们为枚举类型InternalToggle添加isLikeButtonForMomentEnabled来表示启动点赞功能的功能开关。

接着在InternalTogglesDataStore里把该值初始化为false表示默认关闭该功能,这样就能保证 App Store 版本的 App 都看不到这个功能,代码如下:

struct InternalTogglesDataStore: TogglesDataStoreType {private init(userDefaults: UserDefaults) {self.userDefaults.register(defaults: [InternalToggle.isLikeButtonForMomentEnabled.rawValue: false])}
}

最后一步是通过isLikeButtonForMomentEnabled初始化InternalMenuFeatureToggleItemViewModel,并添加到InternalMenuViewModelsections属性里面 ,代码如下:

let featureTogglesSection = InternalMenuSection(title: L10n.InternalMenu.featureToggles,items: [InternalMenuFeatureToggleItemViewModel(title: L10n.InternalMenu.likeButtonForMomentEnabled, toggle: InternalToggle.isLikeButtonForMomentEnabled)])
sections = .just([featureTogglesSection,... // other sections
])

这样子就为内部隐藏菜单增加了启动点赞功能的功能开关。功能开关是其他模块的基础,你会看到我们在其他模块中也都会使用到该开关。

开发网络层

Moments App 使用了 BFF 来读取朋友圈信息,那我们也把点赞信息存储在 BFF 里面。因为 Moments App 的 BFF 使用了 GraphQL,要更新 BFF 上的数据,我们就需要使用 Mutation。和 Restful API 的 Post 操作不一样,在 GraphQL 的 Mutation 不仅能更新数据,还可以返回数据。

下面我们就来一起看看网络层的实现,首先定义一个名叫UpdateMomentLikeSessionType的协议来提供更新点赞信息的接口,具体代码如下:

protocol UpdateMomentLikeSessionType {func updateLike(_ isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable<MomentsDetails>
}

该协议只定义了一个updateLike()方法,该方法会接收以下的入口参数,并返回类型为MomentsDetails的 Observable 序列。

  • isLiked是一个布尔类型,用于表示是否点赞了。

  • momentID表示被点赞的那条朋友圈的 ID。

  • userID表示点赞的用户 ID。

接着我们定义了一个遵循UpdateMomentLikeSessionType协议的结构体,它名叫UpdateMomentLikeSessionUpdateMomentLikeSession的实现方法和GetMomentsByUserIDSession代码基本一致,我们已经在《18 | 网络层架构:如何设计网络访问与 JSON 数据解析?》那一讲中详细讲述了GetMomentsByUserIDSession的实现,如有需要你可以回去复习一下。

不同的地方是在query属性的定义里,UpdateMomentLikeSession使用了mutation而不是query,具体定义如下:

private static let query = """mutation updateMomentLike($momentID: ID!, $userID: ID!, $isLiked: Boolean!) { // the response for updateMomentLike }
"""

这样子,我们就能往 BFF 发送一个 Mutation 请求并接收更新后的MomentsDetails信息了。

除了更新点赞信息以外,我们还要修改GetMomentsByUserIDSession读取点赞朋友的列表信息

不过,点赞信息只有在功能开关开启的时候才能看到,因此在读取朋友圈信息的时候需要进行检查。要检查内部功能开关,需要使用到一个InternalTogglesDataStore的实例,因此我们在初始化GetMomentsByUserIDSession的时候可以将InternalTogglesDataStore.shared传递进去,代码如下:

private let togglesDataStore: TogglesDataStoreType
init(togglesDataStore: TogglesDataStoreType = InternalTogglesDataStore.shared, sessionHandler: ...) {self.togglesDataStore = togglesDataStore
}

GetMomentsByUserIDSession接收到InternalTogglesDataStore的实例时,可以直接保存到togglesDataStore属性里面,这样我们就能使用togglesDataStore属性来检查点赞功能是否开启了。下面代码展示的是内嵌Session结构体的init()方法:

init(userID: String, togglesDataStore: TogglesDataStoreType) {let variables: [AnyHashable: Encodable] = ["userID": userID,"withLikes": togglesDataStore.isToggleOn(InternalToggle.isLikeButtonForMomentEnabled)]
}

我们通过调用togglesDataStore.isToggleOn(InternalToggle.isLikeButtonForMomentEnabled)来判断是否开启了点赞功能,如果是,就把withLikes属性赋值为true,否则赋为false。这样就可以在 Query 里面使用withLikes属性了,代码如下:

private static let query = """query getMomentsDetailsByUserID($userID: ID!, $withLikes: Boolean!) {getMomentsDetailsByUserID(userID: $userID) {// other fieldscreatedDateisLiked @include(if: $withLikes)likes @include(if: $withLikes) {idavatar}}}}
"""

在定义query属性的地方,我们把withLikes传递给getMomentsDetailsByUserIDQuery,然后通过@include来控制是否读取isLikedlikes属性,从而保证只有当isLikeButtonForMomentEnabled开关开启时,才需要读取这两个属性。

到此为止,网络层的开发就完成了,下面我们再来看看 Repository 层的开发。

开发 Repository 层

在朋友圈功能里面,Respository 层的关键组件是MomentsRepo。当它要更新点赞信息时,就会用UpdateMomentLikeSessionType协议,因此我们在初始化的时候也注入对该协议的依赖,具体代码如下:

private let updateMomentLikeSession: UpdateMomentLikeSessionType
static let shared: MomentsRepo = {return MomentsRepo(..., updateMomentLikeSession: UpdateMomentLikeSession())
}()
init(..., updateMomentLikeSession: UpdateMomentLikeSessionType) {self.updateMomentLikeSession = updateMomentLikeSession
}

我们把UpdateMomentLikeSession结构体的实例赋值给updateMomentLikeSession属性,当需要访问网络层时就可以使用该属性的方法,接着看一下updateLike()方法的实现:

func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable<Void> {return updateMomentLikeSession.updateLike(isLiked, momentID: momentID, fromUserID: userID).do(onNext: { persistentDataStore.save(momentsDetails: $0) }).map { _ in () }.catchErrorJustReturn(())
}

我们通过调用updateMomentLikeSession属性的updateLike()方法来更新点赞信息,然后把返回的结果通过persistentDataStoresave()方法保存到本地数据存储中。

到这里,Repository 层的开发也完成了,我们接着修改 ViewModel 层的代码来支持点赞功能。

开发 ViewModel 层

因为点赞功能只使用在 UI 层的MomentListItemView里面,所以我们只需要更新该 View 所对应的 ViewModelMomentListItemViewModel即可。为此,我们增加了两个属性:第一个是isLiked属性,用于表示用户是否已经点赞了该朋友圈信息;第二个是likes属性,用于显示点赞了朋友的头像列表。

有了这两个属性,我们就可以在init()方法里面把MomentsDetails.Moment数据映射到这两个属性中去,具体代码如下:

isLiked = moment.isLiked ?? false
likes = moment.likes?.compactMap { URL(string: $0.avatar) } ?? []

isLiked属性的映射比较简单,只是简单的赋值即可。而likes属性则需要我们把 BFF 返回的 URL 字符串转换为用于呈现图片的URL类型。

当用户在页面中点击点赞按钮后,我们就需要调用MomentListItemViewModel来完成具体的操作,因此我们在MomentListItemViewModel也定义了两个方法,具体代码如下:

func like(from userID: String) -> Observable<Void> {return momentsRepo.updateLike(isLiked: true, momentID: momentID, fromUserID: userID)
}
func unlike(from userID: String) -> Observable<Void> {return momentsRepo.updateLike(isLiked: false, momentID: momentID, fromUserID: userID)
}

可以看到,like(from userID: String)unlike(from userID: String)方法都调用了momentsRepo.updateLike()方法来更新点赞信息。至此,ViewModel 层也开发完毕了。

开发 UI/View 层

其他模块开发完毕以后,最后就是更新 UI/View 层了。因为点赞按钮在每一条朋友圈信息里面,所以我们只需要修改MomentListItemView就可以了。你可以从下面的这个示例图看到新加的组件:

从示例图可以看到,新加的组件主要有以下三个。

  • likesStakeView用于存放点赞朋友的列表。

  • likesContainerView是一个用来存放likesStakeView的容器视图,我们还可以使用它来设置背景颜色和配置圆角效果。

  • favoriteButton表示点赞按钮。

这些 UI 组件的属性定义如下:

private let likesContainerView: UIView = configure(.init()) {$0.translatesAutoresizingMaskIntoConstraints = false$0.backgroundColor = UIColor.designKit.secondaryBackground$0.layer.cornerRadius = 4
}
private let likesStakeView: UIStackView = configure(.init()) {$0.translatesAutoresizingMaskIntoConstraints = false$0.spacing = Spacing.twoExtraSmall
}
private let favoriteButton: UIButton = configure(.init()) {$0.translatesAutoresizingMaskIntoConstraints = false$0.asHeartFavoriteButton()
}

有了这些属性以后,我们还需要把它们添加到 UI 里面,下面是setupUI()方法的代码:

func setupUI() {if togglesDataStore.isToggleOn(InternalToggle.isLikeButtonForMomentEnabled) {likesContainerView.addSubview(likesStakeView)verticalStackView.addArrangedSubview(likesContainerView)addSubview(favoriteButton)}
}

从上面的代码可以看到,只有当isLikeButtonForMomentEnabled开关开启时,才需要添加新的组件。添加新组件的逻辑相对比较简单,我们把likesStakeView添加到likesContainerView里面,然后把likesContainerView添加到verticalStackView,这样就可以把点赞的朋友列表放在父视图的底部,最后再把favoriteButton放到父视图里面。

接着我们为新的组件配置自动布局的约束条件,这就一起来看看setupConstraints()方法的实现:

func setupConstraints() {if togglesDataStore.isToggleOn(InternalToggle.isLikeButtonForMomentEnabled) {likesStakeView.snp.makeConstraints {$0.top.leading.equalToSuperview().offset(Spacing.twoExtraSmall)$0.bottom.trailing.equalToSuperview().offset(-Spacing.twoExtraSmall)}favoriteButton.snp.makeConstraints {$0.bottom.trailing.equalToSuperview().offset(-Spacing.medium)}}
}

Moments App 使用了 SnapKit 库来配置约束。在这个例子中,我们通过调用equalToSuperview().offset(Spacing.twoExtraSmall)likesStakeView添加填充(padding),然后把favoriteButton放置在父视图的右下角。

配置好布局以后,我们通过绑定的方式来处理点赞按钮的点击事件,具体代码如下:

func setupBindings() {if togglesDataStore.isToggleOn(InternalToggle.isLikeButtonForMomentEnabled) {favoriteButton.rx.tap.bind(onNext: { [weak self] inguard let self = self else { return }if self.favoriteButton.isSelected {self.viewModel?.like(from: self.userDataStore.userID).subscribe().disposed(by: self.disposeBag)} else {self.viewModel?.unlike(from: self.userDataStore.userID).subscribe().disposed(by: self.disposeBag)}}).disposed(by: disposeBag)}
}

同样地,在进行绑定前,我们先检查isLikeButtonForMomentEnabled开关是否开启。当开关开启了,我们就使用 RxCocoa 中UIButton.rx.tap扩展属性来绑定favoriteButton的点击事件。当用户点击了点赞按钮时,就会调用viewModellike()或者unlike()方法来更新点赞状态。

到此为止,我们已经开发了一个完整的点赞功能。

总结

在这一讲中,我们以添加点赞功能为例讲解了如何快速开发一个新功能。因为 Moments App 使用了 MVVM 和 RxSwift 来进行架构,这就保证了每一层都有明确的责任与分工。

当你开发新功能时,就可以按照我今天讲解的这些步骤一层层来进行开发:添加功能开关,开发网络层、Repository 层、ViewModel 层和 View 层。这样能大大减低代码接手的难度,使得整个团队都遵循统一的步骤与规范,从而降低沟通成本,并同时保证代码的质量。

思考题

你可能已经注意到,当一个类型需要依赖其他类型时,例如当 GetMomentsByUserIDSession 使用 TogglesDataStoreType 时,我们都是通过 init() 方法进行注入的。那为什么我们不在 GetMomentsByUserIDSession 定义 togglesDataStore 属性时直接初始化呢?

你可以把自己的思考写到下面的留言区哦,这一讲就介绍到这里了,下一讲我将介绍如何使用 TDD 来保证功能模块的高质量。

源码地址

朋友圈点赞功能的源码地址:
https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Features/Moments


ios架构与开发第七课 UI层架构与点赞实战相关推荐

  1. ios架构与开发第五课 BFF、MVVM和响应式编程

    15 跨平台架构:如何设计 BFF 架构系统? 上一模块,我和你介绍了iOS 工程化实践中的基础组件设计, 接下来这部分,我们将进入核心内容:移动端系统架构的设计与实现. 首先请你想一想:如果没有一套 ...

  2. JQuery Smart UI 简介(五) — 框架设计【前篇】(前台UI层架构)

    有不少朋友,在使用演示demo时,出现了打不开access数据库的情况,经园友@smalltide的帮助下,找到了问题,数据层读取默认连接信息时有误(该问题也可能存在其他多数据并存的系统),暂时不更新 ...

  3. android与ios ui切图关系,iOS、Android 开发单位换算及 UI 切图要求

    在移动端 UI 设计中,经常会用到的单位有 4 种:px.pt.dp 和 sp,很多人分辨不清这几种单位及其换算关系,以及 iOS 和 Android 的切图要求,我在这里做下简单的介绍,希望大家读完 ...

  4. 苹果切图换算android,iOS、Android 开发单位换算及 UI 切图要求

    在移动端 UI 设计中,经常会用到的单位有 4 种:px.pt.dp 和 sp,很多人分辨不清这几种单位及其换算关系,以及 iOS 和 Android 的切图要求,我在这里做下简单的介绍,希望大家读完 ...

  5. 三层架构之UI层完全分离

    前几天在论坛看到有人讨论三层架构,有人说应该吧UI层和其他层完全分离,UI层只接收传值工作.觉得很有道理.所以动手做了个.在DAL层查询语句.BLL层把数据封装为数据集,然后在UI层直接引用数据集.相 ...

  6. 有赞统一接入层架构演进

    本文系云原生应用最佳实践杭州站活动演讲稿整理.杭州站活动邀请了 Apache APISIX 项目 VP 温铭.又拍云平台开发部高级工程师莫红波.蚂蚁金服技术专家王发康.有赞中间件开发工程师张超,分享云 ...

  7. [4G5G专题-54]:L2 MAC层-架构概述

    目录 第1章 RAN协议栈 第2章 MAC层协议架构演进 2.1 MAC层架构概述 2.2 R8 LTE MAC层架构 2.3 R10 LTE MAC层架构 2.4 R15 NR MAC层架构 第3章 ...

  8. 回头再看N层架构(图解)

    不知不觉来博客园已经快两半了,时间过的真快. 这次的目标是再回顾一下传统的N层架构并且分析一下在DDD中的N层架构. 一.先来看一看传统的N层架构 N-层架构的出现,主要是由于观注点的分离而产生,这三 ...

  9. 2011斯坦福大学iOS应用开发教程学习笔记(第七课)Pad和iPhone的通用程序

    第七课的主要内容:iPad 和iPhone的通用程序 这节课主要讲如何在一个程序里适配iPad 和iPhone. 内容简介 1.UIToolbar上面放的都是UIBarButtonItem 可以参考i ...

最新文章

  1. Object #HTMLCanvasElement 的drawImage没有作用的原因
  2. 全球首款64核AMD工作站发布,搭载最新线程撕裂者Pro,号称“地表最强”
  3. APUE 学习笔记(一) Unix基础知识
  4. [转] 哈佛大学研究:一个人成为废物的九大根源
  5. shell中字分隔的妙用:变量IFS
  6. 文科思维Java_开源之Processing:这好玩的编程语言是为文科生艺术家准备的
  7. Linux复制指定目录及子目录下特定类型的文件
  8. VLC帮助文档-中文详细版(vlc-2.2.6)
  9. 抠图软件哪个好用?这些软件你了解吗?
  10. credential provider filter注意
  11. siamRPN论文理解与复现
  12. Vbox虚拟机无法启动错误Mark
  13. 【有感】成为一个不惑、不忧、不惧的人
  14. 屠杀熊猫烧香方法记录
  15. c语言 运行经过八个翻译阶段,GitHub - BUAA-SE-Compiling/miniplc0-handbook: miniplc0 指导书...
  16. 卡特兰数——Catalan数
  17. 微信小程序支付开发具体步骤
  18. Android Camera HAL3 -SessionParameter
  19. 一个浙大教授让人发冷汗的讲演
  20. Concourse CI资源整合

热门文章

  1. 发现自己做事的动机都是取悦别人,应该如何改正?
  2. Grafana监控告警发送通知到钉钉群、邮件
  3. quill编辑器使用
  4. Struts2(2)
  5. 三分钟带你了解专精特新的申报时间和流程
  6. nordic/nrf52 SDK主机设置short name filter(简称过虑)
  7. PPT设计中文字精简规则技巧
  8. [LeetCode javaScript] 495. 提莫攻击
  9. iOS NSURLSession Https请求
  10. python基础 输入圆的半径,求圆的周长