iOS应用程序中的组件UI架构



哈Ha!

我叫Valera,作为Badoo团队的一员,两年来我一直在开发iOS应用程序。 我们的优先事项之一是易于维护代码。 由于每周都会使用大量新功能,因此我们需要首先考虑应用程序的体系结构,否则很难在不破坏现有功能的情况下向产品添加新功能。 显然,这也适用于用户界面(UI)的实现,而不管这是使用代码,Xcode(XIB)还是混合方法完成的。 在本文中,我将介绍一些UI实现技术,这些技术使我们能够简化用户界面的开发,使其灵活,方便地进行测试。 本文还有英文版

开始之前...


我将使用Swift编写的示例应用程序考虑用户界面实现技术。 单击按钮的应用程序将显示一个朋友列表。

它包括三个部分:

  1. 组件是自定义UI组件,即仅与用户界面相关的代码。
  2. 演示应用程序 -演示视图模型和其他仅具有UI依赖项的用户界面实体。
  3. 真正的应用是视图模型和其他可能包含特定依赖关系和逻辑的实体。

为什么会有这样的分离? 我将在下面回答这个问题,但现在,请查看我们应用程序的用户界面:


这是一个弹出视图,其内容位于另一个全屏视图的顶部。 一切都很简单。

该项目的完整源代码可在GitHub找到

在研究UI代码之前,我想向您介绍这里使用的辅助类Observable。 其界面如下所示:

var value: T func observe(_ closure: @escaping (_ old: T, _ new: T) -> Void) -> ObserverProtocol func observeNewAndCall(_ closure: @escaping (_ new: T) -> Void) -> ObserverProtocol 

它只是将所有更改通知所有先前签署的观察者,因此这是KVO(键值观察)的一种替代方法,或者,如果您愿意,还可以采用反应式编程。 这是一个用法示例:

 self.observers.append(self.viewModel.items.observe { [weak self] (_, newItems) in   self?.state = newItems.isEmpty ? .zeroCase(type: .empty) : .normal   self?.collectionView.reloadSections(IndexSet(integer: 0)) }) 

控制器将更改订阅self.viewModel.items属性,并且当更改发生时,处理程序将执行业务逻辑。 例如,它更新视图状态并用新项目重新加载集合视图。

您将在下面看到更多使用示例。

方法论


在本节中,我将讨论Badoo中使用的四种UI开发技术:

1.用代码实现用户界面。

2.使用布局锚点。

3.组成部分-分而治之。

4.用户界面和逻辑的分离。

#1:在代码中实现用户界面


在Badoo中,大多数用户兴趣是通过代码实现的。 我们为什么不使用XIB或情节提要? 公平的问题。 主要原因是为中型团队维护代码的便利性,即:

  • 代码中的更改清晰可见,这意味着无需解析XML故事板/ XIB文件即可查找同事所做的更改;
  • 版本控制系统(例如,Git)比使用“大量” XLM文件更容易使用代码,尤其是在中等冲突时; 还应考虑到,即使界面没有更改,XIB /情节提要文件的内容也会在每次保存时更改(尽管我听说在Xcode 9中该问题已得到解决);
  • 在子视图(布局子视图)的重新布局过程中,可能很难在Interface Builder(IB)中修改和维护某些属性,例如CALayer属性,这可能导致视图状态的多个事实来源;
  • Interface Builder不是最快的工具,有时直接使用代码会更快。

看一下以下控制器(FriendsListViewController):

 final class FriendsListViewController: UIViewController { struct ViewConfig { let backgroundColor: UIColor let cornerRadius: CGFloat } private var infoView: FriendsListView! private let viewModel: FriendsListViewModelProtocol private let viewConfig: ViewConfig init(viewModel: FriendsListViewModelProtocol, viewConfig: ViewConfig) { self.viewModel = viewModel self.viewConfig = viewConfig super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() self.setupContainerView() } private func setupContainerView() { self.view.backgroundColor = self.viewConfig.backgroundColor let infoView = FriendsListView( frame: .zero, viewModel: self.viewModel, viewConfig: .defaultConfig) infoView.backgroundColor = self.viewConfig.backgroundColor self.view.addSubview(infoView) self.infoView = infoView infoView.translatesAutoresizingMaskIntoConstraints = false infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true } // …. } 

本示例说明您只能通过提供视图模型和视图配置来创建视图控制器。 您可以在此处阅读有关表示模型的更多信息,即MVVM设计模型(Model-View-ViewModel)。 由于视图配置是定义视图的布局和样式(即缩进,大小,颜色,字体等)的简单结构实体,因此我认为提供这样的标准配置是合适的:

 extension FriendsListViewController.ViewConfig {   static var defaultConfig: FriendsListViewController.ViewConfig {       return FriendsListViewController.ViewConfig(backgroundColor: .white,                                                   cornerRadius: 16)   } } 

所有视图初始化都在setupContainerView方法中进行,当已经创建并加载视图但尚未在屏幕上绘制视图时,仅从viewDidLoad调用一次,也就是说,所有必要的元素(子视图)都简单地添加到视图层次结构中,然后应用标记(布局)和样式。

这是视图控制器现在的样子:

 final class FriendsListPresenter: FriendsListPresenterProtocol {   // …   func presentFriendsList(from presentingViewController: UIViewController) {       let controller = Class.createFriendsListViewController( presentingViewController: presentingViewController,           headerViewModel: self.headerViewModel,           contentViewModel: self.contentViewModel)       controller.modalPresentationStyle = .overCurrentContext       controller.modalTransitionStyle = .crossDissolve       presentingViewController.present(controller, animated: true, completion: nil)   }   private class func createFriendsListViewController( presentingViewController: UIViewController, headerViewModel: FriendsListHeaderViewModelProtocol,       contentViewModel: FriendsListContentViewModelProtocol) -> FriendsListContainerViewController {      let dismissViewControllerBlock: VoidBlock = { [weak presentingViewController] in           presentingViewController?.dismiss(animated: true, completion: nil)       }       let infoViewModel = FriendsListViewModel( headerViewModel: headerViewModel,           contentViewModel: contentViewModel)       let containerViewModel = FriendsListContainerViewModel(onOutsideContentTapAction: dismissViewControllerBlock)       let friendsListViewController = FriendsListViewController( viewModel: infoViewModel, viewConfig: .defaultConfig)       let controller = FriendsListContainerViewController( contentViewController: friendsListViewController,           viewModel: containerViewModel,           viewConfig: .defaultConfig)       return controller   } } 

您可以清楚地看到职责分工 ,这个概念并不比在故事板上调用segue复杂得多。

鉴于我们拥有模型,因此创建视图控制器非常简单,您可以简单地使用标准视图配置:

 let friendsListViewController = FriendsListViewController( viewModel: infoViewModel, viewConfig: .defaultConfig) 

#2:使用布局锚点


这是布局代码:

 self.view.addSubview(infoView) self.infoView = infoView infoView.translatesAutoresizingMaskIntoConstraints = false infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true 

简而言之,此代码将infoView置于父视图( infoView视图)的内部,相对于infoView视图的原始大小位于坐标(0,0)处。

为什么要使用布局锚点? 快速简便。 当然,您可以手动设置UIView.frame并即时计算所有位置和大小,但是有时它可能会变得过于混乱和/或代码过于庞大。

您还可以使用文本格式进行标记,如此处所述,但这经常会导致错误,因为您需要严格遵循格式,并且Xcode在编写/编译代码阶段不会检查标记描述文本,并且您不能使用《安全区域布局指南》:

 NSLayoutConstraint.constraints( withVisualFormat: "V:|-(\(topSpace))-[headerView(headerHeight@200)]-[collectionView(collectionViewHeight@990)]|",   options: [],   metrics: metrics,   views: views) 

在定义标记的文本字符串中犯错误或错字很容易,对吧?

#3:组件-分而治之


我们的示例用户界面分为多个组件,每个组件仅执行一项特定功能。

例如:

  1. FriendsListHeaderView显示有关朋友的信息和“关闭”按钮。
  2. FriendsListContentView显示具有可单击单元格的朋友列表,到达列表末尾时将动态加载内容。
  3. FriendsListView先前两个视图的容器。

如前所述,当每个组件负责一个单独的功能时,Badoo我们喜欢唯一负责原则 。 这不仅在错误修复过程中(这可能不是iOS开发人员工作中最有趣的部分)有所帮助,而且在开发新功能期间也有帮助,因为这种方法极大地扩展了将来重用代码的可能性。

#4:分离用户界面和逻辑


最后但同样重要的一点是用户界面和逻辑的分离。 一种可以为您的团队节省时间和神经的技术。 从字面上看:一个用于用户界面的项目和一个用于业务逻辑的项目。

让我们回到我们的例子。 您还记得,演示文稿(presenter)的本质如下所示:

 func presentFriendsList(from presentingViewController: UIViewController) {   let controller = Class.createFriendsListViewController( presentingViewController: presentingViewController,       headerViewModel: self.headerViewModel,       contentViewModel: self.contentViewModel)   controller.modalPresentationStyle = .overCurrentContext   controller.modalTransitionStyle = .crossDissolve   presentingViewController.present(controller, animated: true, completion: nil) } 

您只需要提供标题和内容的视图模型。 其余部分隐藏在UI组件的上述实现中。

标头视图模型协议如下所示:

 protocol FriendsListHeaderViewModelProtocol {   var friendsCountIcon: UIImage? { get }   var closeButtonIcon: UIImage? { get }   var friendsCount: Observable<String> { get }   var onCloseAction: VoidBlock? { get set } } 

现在,假设您要为UI添加视觉测试-就像为UI组件传递存根模型一样简单。

 final class FriendsListHeaderDemoViewModel: FriendsListHeaderViewModelProtocol {   var friendsCountIcon: UIImage? = UIImage(named: "ic_friends_count")   var closeButtonIcon: UIImage? = UIImage(named: "ic_close_cross")   var friendsCount: Observable<String>   var onCloseAction: VoidBlock?   init() {       let friendsCountString = "\(Int.random(min: 1, max: 5000))"       self.friendsCount = Observable(friendsCountString)   } } 

看起来很简单,对吧? 现在,我们想向应用程序的组件中添加业务逻辑,这可能需要数据提供者,数据模型等:

 final class FriendsListHeaderViewModel: FriendsListHeaderViewModelProtocol {   let friendsCountIcon: UIImage?   let closeButtonIcon: UIImage?   let friendsCount: Observable<String> = Observable("0")   var onCloseAction: VoidBlock?   private let dataProvider: FriendsListDataProviderProtocol   private var observers: [ObserverProtocol] = []   init(dataProvider: FriendsListDataProviderProtocol,        friendsCountIcon: UIImage?,        closeButtonIcon: UIImage?) {       self.dataProvider = dataProvider       self.friendsCountIcon = friendsCountIcon       self.closeButtonIcon = closeButtonIcon       self.setupDataObservers()   }   private func setupDataObservers() {       self.observers.append(self.dataProvider.totalItemsCount.observeNewAndCall { [weak self] (newCount) in           self?.friendsCount.value = "\(newCount)"       })   } } 

有什么会更容易? 只需实现数据提供程序-那就开始吧!

内容模型的实现看起来有些复杂,但是职责分离仍然大大简化了生活。 这是一个如何在单击按钮时实例化并显示朋友列表的示例:

 private func presentRealFriendsList(sender: Any) {   let avatarPlaceholderImage = UIImage(named: "avatar-placeholder")   let itemFactory = FriendsListItemFactory(avatarPlaceholderImage: avatarPlaceholderImage)   let dataProvider = FriendsListDataProvider(itemFactory: itemFactory)   let viewModelFactory = FriendsListViewModelFactory(dataProvider: dataProvider)   var headerViewModel = viewModelFactory.makeHeaderViewModel()   headerViewModel.onCloseAction = { [weak self] in       self?.dismiss(animated: true, completion: nil)   }   let contentViewModel = viewModelFactory.makeContentViewModel()   let presenter = FriendsListPresenter( headerViewModel: headerViewModel,       contentViewModel: contentViewModel)   presenter.presentFriendsList(from: self) } 

此技术有助于将用户界面与业务逻辑隔离。 此外,这使您可以通过可视化测试覆盖整个UI,并将测试数据传递给组件! 因此,无论是启动产品还是已经完成的产品,用户界面和相关业务逻辑的分离对于项目的成功至关重要。

结论


当然,这些只是Badoo中使用的一些技术,并不是所有情况下的通用解决方案。 因此,请在评估它们是否适合您和您的项目后使用它们。

还有其他方法,例如使用Interface Builder的XIB可配置UI组件(在我们的一篇文章中进行了介绍 ),但是由于各种原因,它们在Badoo中没有使用。 请记住,每个人对全局都有自己的看法和远见,因此,为了开发成功的项目,您应该在团队中达成共识,并选择最适合大多数情况的方法。

愿斯威夫特和你在一起!

资料来源

Source: https://habr.com/ru/post/zh-CN421559/


All Articles