Skip to content

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

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

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

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

这个框架使用了UIViewControllerUITableView来封装列表页面。其核心是BaseTableViewControllerBaseTableViewController继承于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延伸到它的父组件(也就是BaseTableViewControllerview)中,然后通过center.equalToSuperview()方法把activityIndicatorViewerrorLabel都分别居中。

假如不使用 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 的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()函数的实现。

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:{})方法来停止activityIndicatorViewrefreshControl两个控件的刷新动画。

到此为止,我们已经知道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的子类,这些子类包括UserProfileListItemViewMomentListItemView

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

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

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

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

  2. 配置 UI 控件的样式;

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

  4. 重写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 的开发了。

  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