Appearance
21UI层架构:如何开发统一并且灵活的UI?
作为 iOS 开发者,我们每天都花大量的时间来开发和调试 UI,那有没有什么办法帮助我们把繁杂的 UI 开发工作简化成有章可循的步骤,从而提高开发的效率呢?在这一讲中,我就和你聊聊,如何架构和开发一套灵活的 UI 框架。
通用列表 UI 模块的架构与实现
列表 UI 是 App 最为常用的 UI 页面,它可以帮我们通过滚动的方式支持无限的内容。为了简化大量的重复性劳动,我在 Moments App 架构实现了一个通用的列表 UI 模块。下面是这个模块的架构图。
这个框架使用了UIViewController
和UITableView
来封装列表页面。其核心是BaseTableViewController
。BaseTableViewController
继承于BaseViewController
,而BaseViewController
继承自UIViewController
。
我们先看看BaseViewController
的具体实现,代码示例如下。
swift
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
用于显示出错信息的标签控件。
以下是属性定义的代码示例。
swift
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()
方法,具体代码如下:
swift
func configure<T: AnyObject>(_ object: T, closure: (T) -> Void) -> T {
closure(object)
return object
}
有了该方法,我们就可以把所有初始化操作都放在一个闭包(Closure)里面,方便代码的维护。
接着我们看一下setupUI()
方法的代码实现。
swift
func setupUI() {
view.backgroundColor = UIColor.designKit.background
tableViewCellsToRegister.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 的威力。
swift
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
延伸到它的父组件(也就是BaseTableViewController
的view
)中,然后通过center.equalToSuperview()
方法把activityIndicatorView
和errorLabel
都分别居中。
假如不使用 SnapKit,要完成延伸tableView
的操作,就需要以下的代码。
swift
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()
函数里,具体如下。
swift
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 in
let 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 的listItems
Subject 属性绑定起来,当listItems
发出新的事件时,我们会调用ListItemCell
的update(with viewModel: ListItemViewModel)
方法来更新 UI。经过了这一绑定,UI 就能随着 ViewModel 的数据变化而自动更新。
第三部分与第二部分类似,都是把 ViewModel 与 View 层的控件进行绑定。在这里,我们把 ViewModel 的hasError
Subject 属性绑定到errorLabel.rx.isHidden
属性来控制errorLabel
是否可见。
你可能注意到在errorLabel
后面有.rx
属性,这是 RxCocoa 为UILabel
控件所提供的一个扩展,它为isHidden
属性提供了响应式编程的功能。有了这一功能,它就可以与 ViewModel 的 Subject 属性进行绑定,从而实现自动更新。
数据绑定以后,我们一起看看loadItems()
函数的实现。
swift
func loadItems() {
viewModel.hasError.onNext(false)
viewModel.loadItems()
.observeOn(MainScheduler.instance)
.do(onDispose: { [weak self] in
self?.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:{})
方法来停止activityIndicatorView
和refreshControl
两个控件的刷新动画。
到此为止,我们已经知道BaseTableViewController
是如何通过 TableView 来实现列表 UI 的了。
为了显现不同的 TableView Cell,接下来我们了解下通用的 Cell 是如何实现的。
这部分由四个类型所组成,分别是ListItemCell
协议及其子结构体BaseTableViewCell
,以及ListItemView
协议及其子结构体BaseListItemView
。
ListItemCell
协议的定义非常简单,如下所示。
swift
protocol ListItemCell: class {
func update(with viewModel: ListItemViewModel)
}
该协议只包含了一个update(with viewModel: ListItemViewModel)
方法来让其子类型根据ListItemViewModel
的数据进行更新。
其子类型BaseTableViewCell
的具体代码如下:
swift
final class BaseTableViewCell<V: BaseListItemView>: UITableViewCell, ListItemCell {
private let view: V
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
view = .init()
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
contentView.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
协议的代码。
swift
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
用于显示朋友圈时间轴页面,其具体代码如下。
swift
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
的子类,这些子类包括UserProfileListItemView
和MomentListItemView
。
你可以从下图中看到它们所呈现的 UI 组件。
MomentsTimelineViewController
我们已介绍完毕了,下面咱们以UserProfileListItemView
为例,看一下开发子控件的步骤与实现。
UserProfileListItemView
用于显示用户自己的资料,例如用户名字,头像和背景图。因为有了通用和统一的 UI 开发框架,每次开发 UI 页面的步骤都是一致的,具体我分为以下几步完成:
初始化 UI 控件的属性;
配置 UI 控件的样式;
设置自动布局的约束;
重写
update(with viewModel: ListItemViewModel)
方法,根据 ViewModel 的数据来更新 UI。
先看一下初始化 UI 控件属性的代码。
swift
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()
方法来实现。
swift
func setupUI() {
backgroundColor = UIColor.designKit.background
[backgroundImageView, avatarImageView, nameLabel].forEach {
addSubview($0)
}
}
在这里,我使用了 DesignKit 来设置了背景颜色,并把子控件添加到当前 View 里面。
然后看第三步如何设置自动布局的约束,其实现代码如下。
swift
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,其代码如下。
swift
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 的开发了。
初始化 UI 控件的属性,把 UI 分解成不同的子控件,然后通过
configure()
来初始化各个控件属性。配置 UI 控件的样式,如配置背景颜色等,并把各个子控件添加到父控件里面。
设置自动布局的约束,推荐使用 SnapKit 来简化配置约束的工作。
重写
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