积木法搭建 iOS 应用—— VIPER

在我们构建应用产品的时候,产品的快速发展也迫使我们不断寻求更合适产品高速迭代发展的编程架构。

伴随着产品的发展,让产品每一个部分容易被识别,拥有明显特定的目的,并且与其他部分逻辑清晰、结构明确是我们一直探寻的目标。

想必大家已经对经常使用的MVC、MVP、MVVM非常熟悉了,在本文中我们将探索 VIPER 架构在 iOS 上的成功实践。

我们先大致了解什么是 VIPER。

VIPER 分为五个部分:View、Interactor、Presenter、Entity、Router。

View:视图部分,根据 Presenter 的要求展示界面。

Interactor:业务相关逻辑、从本地和网络获取数据,并存储数据。

Presenter:包含为显示做准备工作的相关视图逻辑(从 Interactor 接收数据,并进一步处理为 View 可以直接展示的数据),并对用户输入进行反馈(根据用户操作对当前数据做变更)。

Entity:包含 Interactor 要使用的基本模型对象。

Router:包含用来描述屏幕显示 view 和显示顺序的导航逻辑。

这种功能划分形式遵循单一职责原则。Interactor 负责业务分析获取内容的部分,Presenter 代表交互设计师为 View 展示做准备,而 View 相当于视觉设计师只负责展示内容,Entity 负责承载数据内容, Router 负责页面模块的显示和导航逻辑。

我们可以把他们之间关系画为下图:

VIPER 的每一个部分的创建、功能实现没有先后顺序,可以根据实际情况调整。

由于遵循职责单一,每一个部分也都可以拿出来给有相同功能的业务使用,

比如狐友APP中的关注、粉丝页面:

再比如小红书中的发现页面和关注页面:

VIPER 的每一个部分就像是房子的梁、柱、墙以及装修材料,我们可以通过把形状、特点相同的结构重复利用搭建在不同的位置上,从而构建出我们想要的漂亮房子。

这种感觉是不是像极了我们小时候玩积木的样子?

房子维修起来也非常方便。

如果我觉得室内的柱子太单调了,想要所有的柱子都统一换成洛可可风格的柱子,因为柱子都是复用的材料,那么我只需要修改一个柱子的属性,所有的柱子都会变成洛可可风格的样子。

下面我们来写一个推荐电影的列表,根据这个例子更深入的探索如何创建 VIPER 架构应用。

首先,我们针对各个部分的关系和功能定义通用协议,就像拼装日式木质结构的房子需要先有标准部件结构,再将标准部件结构组装起来一样,我们需要先构建 VIPER 的基础构件。

其次,后面我们会用这些基础构件搭建我们需要的业务逻辑。

01基础构件

Router

Router 用来描述屏幕显示 View 和显示顺序的导航逻辑。在 VIPER 中我们把 viewController 看做是 View 的一部分,只做 view 的显示控制及用户操作反馈,不实际处理数据逻辑。

这里我们定义了可以获取设置 viewController 的属性。

/// Describes router component in a VIPER architecture.
protocol RouterType: class {
    /// The reference to the view which the router should use
    /// as a starting point for navigation. Injected by the builder.
    var viewController: UIViewController? { get set }
}

Interactor

Interactor 它是获取特定的数据并且组织数据的第一步。它与业务逻辑紧密相连,与展示逻辑分离,可以有独立的测试用例,可以较好的使用 TDD(即 Test Driven Development) 进行开发。Interactor 中的工作应当独立于任何显示界面,Interactor 可以同时运用于不同设备类型的数据提供层。为了保持 Interactor 获取数据部分具体实现时的自由灵活多变,这里我们先不做过多定义。

/// Describes interactor component in a VIPER architecture.
protocol InteractorType: class { }

Presenter

Presenter 从 Interactor 接收数据做显示准备相关的处理后交给相关视图;并且对用户输入进行反馈,如果需要更新数据时通知 interactor 获取新的数据。

这里我们定义了 InteractorType 类型的 interactor 属性。

/// Describes presenter component in a VIPER architecture.
protocol PresenterType: class {

    associatedtype I: InteractorType
    /// A interactor
    var interactor: I { get }  
}

View

View 根据 Presenter 的要求展示界面,所以我们定义刷新视图的方法以及一个遵守 PresenterType 的 presenter。

protocol ViewType {
    associatedtype P: PresenterType
    /// A presenter
    var presenter: P { get }

    // MARK: - refresh View
    func refreshView()

}

现在我们已经搭建好了Router、View、Presenter、Interactor之间的单向关系,如下图:

接下来,我们使用协议来完成各个模块之间的数据流动和用户行为反馈。

ListDataProtocol

由于应用程序中大部分页面都是列表,所以我们对列表也做一些通用的功能处理,减少业务层的重复逻辑。

我们的列表数据需要有 row 和 section ,我们需要定义行和组一些显示需要的通用信息:

protocol ViewModelType {
    var cellId: String { get }
    var cellSize: CGSize { get }
}

protocol SectionType {
    var items: [ViewModelType] { get set }

    var headerSize: CGSize { get }
    var footerSize: CGSize { get }

    var headerId: String { get }
    var footerId: String { get }

    var headerTitle: String { get }
    var footerTitle: String { get }
}

我们定义了一些 row 和 section 的类型 id、size 以供列表使用。因为在实际业务中 ViewModelType 需要根据业务需求定义不同类型,供不同功能需求使用,但是 SectionType 的功能需求及实现大部分相同,所以我们只定义通用的 section 类型如下:

class Section: SectionType {
    var items: [ViewModelType] = []

    var headerSize: CGSize = CGSize.zero
    var footerSize: CGSize = CGSize.zero

    var headerId: String = ""
    var footerId: String = ""

    var headerTitle: String = ""
    var footerTitle: String = ""
}

下面我们定义关于列表数据的协议,把上面的 row 和 section 组织起来为列表提供数据支持。这里定义协议包括:列表数据的数组、获取行和组的信息、判断一个 indexPath 是否是有效的。

protocol ListDataProtocol: class {
    // MARK: -
    // MARK: - Data information
    var viewModels: [Section] { get set }

    func numberOfSections() -> Int
    func numberOfItemsInSection(at index: Int) -> Int

    func item(at indexPath: IndexPath) -> ViewModelType?
    func section(in index: Int) -> Section?

    // MARK: -
    // MARK: - legitimacy
    func indexPathAccessibleInViewModels(_ indexPath: IndexPath) -> Bool
}

indexPathAccessibleInViewModels: 方法接受一个 indexPath ,并且返回这个 indexPath 是否在当前 viewModels 中可以访问的布尔值,以便我们减少重复书写判断数组越界的逻辑。

上面方法的实现通常是相同的,我们写默认实现如下:

  • 获取 row、section 数量:
extension ListDataProtocol {

    // MARK: -
    // MARK: - Data information
    func numberOfSections() -> Int {
        return self.viewModels.count
    }

    func numberOfItemsInSection(at index: Int) -> Int {
        #if DEBUG
        assert(index < self.viewModels.count, "Index out of bounds exception")
        #else
        #endif
        return section(in: index)?.items.count ?? 0
    }
}
  • 获取 row、section 数据模型:
extension ListDataProtocol {

    func item(at indexPath: IndexPath) -> ViewModelType? {
        if indexPathAccessibleInViewModels(indexPath) == false {
            return nil
        }

        return self.viewModels[indexPath.section].items[indexPath.row]
    }

    func section(in index: Int) -> Section? {
        #if DEBUG
        assert(index < self.viewModels.count, "Index out of bounds exception")
        #else
        #endif
        if index >= self.viewModels.count {
            return nil
        }

        return self.viewModels[index]
    }
}
  • 判断 IndexPath 是否在当前 viewModels 中可以访问:
extension ListDataProtocol {
    // MARK: -
    // MARK: - legitimacy
    func indexPathAccessibleInViewModels(_ indexPath: IndexPath) -> Bool {

        #if DEBUG
        assert(indexPath.section < self.viewModels.count, "Index out of bounds exception (        please check indexPath.section)")
        assert(indexPath.row < self.viewModels[indexPath.section].items.count, "Index out of bounds exception (please check indexPath.row)")
        #else
        #endif

        if indexPath.section >= self.viewModels.count ||
            indexPath.row >= self.viewModels[indexPath.section].items.count {
            return false
        }

        return true
    }
}

由于我们经常需要对数据进行修改更新、数据持久化操作,所以在 ListDataProtocol 中定义数据处理的通用协议及实现如下:- 更新row、section 数据:

协议定义:

protocol ListDataProtocol: class {

    // MARK: -
    // MARK: - Data manipulation

    /// Retrieve data from memory 
    func updateSection(section: Section, at index: Int)
    func updateItem(item: ViewModelType, at indexPath: IndexPath)
}

通常实现相同,添加默认实现如下:

extension ListDataProtocol {

    // MARK: -
    // MARK: - Data manipulation

    /// Retrieve data from memory
    func updateSection(section: Section, at index: Int) {
        #if DEBUG
        assert(index < self.viewModels.count, "Index out of bounds exception")
        #else
        #endif

        if index >= self.viewModels.count {
            return
        }

        self.viewModels[index] = section
    }
    func updateItem(item: ViewModelType, at indexPath: IndexPath) {
        guard indexPathAccessibleInViewModels(indexPath) else {
            return
        }

        self.viewModels[indexPath.section].items[indexPath.row] = item
    }
}
  • 插入row、section数据:

协议定义:

protocol ListDataProtocol: class {

    /// Insert data
    func insertSection(section: Section, at index: Int)
    func insertItem(item: ViewModelType, at indexPath: IndexPath)
}

通常实现相同,添加默认实现如下:

extension ListDataProtocol {

    /// Insert data
    func insertSection(section: Section, at index: Int) {
        #if DEBUG
        assert(index <= self.viewModels.count, "Index out of bounds exception")
        #else
        #endif

        if index > self.viewModels.count {
            return
        }

        self.viewModels.insert(section, at: index)
    }
    func insertItem(item: ViewModelType, at indexPath: IndexPath) {
        #if DEBUG
        assert(indexPath.section <= self.viewModels.count, "Index out of bounds exception (indexPath.section)")
        assert(indexPath.row <= self.viewModels[indexPath.section].items.count, "Index out of bounds exception (indexPath.row)")
        #else
        #endif

        if indexPath.section > self.viewModels.count ||
            indexPath.row > self.viewModels[indexPath.section].items.count {
            return
        }
        self.viewModels[indexPath.section].items.insert(item, at: indexPath.row)
    }
}
  • 删除row、section数据:

协议定义:

protocol ListDataProtocol: class {

    /// Delete data
    func deleteSection(at index: Int)
    func deleteItem(at indexPath: IndexPath)
}

通常实现相同,默认实现如下:

extension ListDataProtocol {

    /// Delete data
    func deleteSection(at index: Int) {
        #if DEBUG
        assert(index < self.viewModels.count, "Index out of bounds exception")
        #else
        #endif

        if index >= self.viewModels.count {
            return
        }

        self.viewModels.remove(at: index)
    }
    func deleteItem(at indexPath: IndexPath) {
        guard indexPathAccessibleInViewModels(indexPath) else {
            return
        }

        self.viewModels[indexPath.section].items.remove(at: indexPath.row)
    }
}
  • 清空当前列表数据:
// 协议定义
protocol ListDataProtocol: class {
    /// Clear all data
    func clearList()
}

// 协议实现
extension ListDataProtocol {
    /// Clear all data
    func clearList() {
        self.viewModels = []
    }
}

除此之外还有数据库的数据增删改查操作等等,此处不一一列举实现。

ListViewProtocol

列表的 view 通常需要注册,列表需要有下拉刷新、上拉加载等功能,我们定义列表 view 的协议如下:

protocol ListViewProtocol {

    // MARK: - load
    func pulldown()
    func loadMore()

    // MARK: - register
    func registerCellClass() -> [AnyClass]
    func registerCellNib() -> [AnyClass]
    func registerHeaderClass() -> [AnyClass]
    func registerHeaderNib() -> [AnyClass]
    func registerFooterClass() -> [AnyClass]
    func registerFooterNib() -> [AnyClass]

    // MARK: - refresh
    func setUpRefreshHeader()
    func setUpRefreshFooter()
}

列表的数据是由协议类型 ListDataProtocol 提供,UICollectionView 及 UITableView 的数据代理方法不能写在有泛型的协议中实现,所以我们需要一个实现含有 UICollectionView 或者 UITableView 属性的类。

它就是我们上面提到的 ViewType 协议类型,充当 VIPER 中 view 的角色。

现在我们完成了VIPER 中 View 根据用户操作向 Presenter 索要数据,Presenter 向 view提供显示所需的数据支持,我们需要一个列表 View 去显示 Presenter提供的数据,这就是我们接下来讲的 VTableViewController。

VTableViewController

下面我们实现拥有 UITableView 的 Controller。Controller 从 presenter 获取展示需要的数据直接展示在界面上。

VTableViewController 的 presenter 为视图提供数据的支持,presenter 遵守 PresenterType & ListDataProtocol 两个协议。为了业务层灵活实现 tableView,这里 tableView 是一个泛型:

/// Viper view controller base class.
typealias ListPresenterType = PresenterType & ListDataProtocol

class VTableViewController<P: ListPresenterType, T: UITableView>: UIViewController, ViewType {

    let presenter: P

    init(presenter: P, style: UITableView.Style) {
        self.presenter = presenter
        self.tableView = T.init(frame: CGRect.zero, style: style)
        super.init(nibName: nil, bundle: nil)

        self.view.backgroundColor = UIColor.white
    }

    // MARK: -
    // MARK: - View life cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        hy_setUpUI()
    }

    // MARK: -
    // MARK: - tableView
    var tableView: T
    private func hy_setUpUI() {
        self.view.addSubview(self.tableView)
        self.tableView.frame = self.view.bounds
        self.tableView.dataSource = self
        self.tableView.delegate = self
    }

    // MARK: -
    // MARK: - viewType
    func refreshView() {
        self.tableView.reloadData()
    }
}   

VTableViewController 需要实现 ListViewProtocol 提供视图刷新的方法,具体刷新的功能需要根据业务层的具体需求实现,所以我们在抽象类只增加空实现,如下:

class VTableViewController<P: ListPresenterType, T: UITableView>: UIViewController, ViewType, ListViewProtocol {   
    // MARK: -
    // MARK: - ListViewProtocol
    func pulldown() {}
    func loadMore() {}

    func setUpRefreshHeader() {}
    func setUpRefreshFooter() {}
}

具体业务中还需要实现注册视图的方法,在 VTableViewController 中我们只增加空实现,如下:

class VTableViewController<P: ListPresenterType, T: UITableView>: UIViewController, ViewType, ListViewProtocol {   
    // MARK: -
    // MARK: - ListViewProtocol
    func registerCellClass() -> [AnyClass] { return [] }
    func registerCellNib() -> [AnyClass] { return [] }
    func registerHeaderClass() -> [AnyClass] { return [] }
    func registerHeaderNib() -> [AnyClass] { return [] }
    func registerFooterClass() -> [AnyClass] { return [] }
    func registerFooterNib() -> [AnyClass] { return [] }
}

我们需要根据上面注册类型方法返回类型对 VTableViewController 的 tableView 进行注册视图,实现如下:

class VTableViewController<P: ListPresenterType, T: UITableView>: UIViewController, ViewType, ListViewProtocol { 
// MARK: - private
    private func hy_registeCell() {
        for cellClass in self.registerCellClass() {
            self.tableView.register(cellClass, forCellReuseIdentifier: NSStringFromClass(cellClass))
        }

        for cellClass in self.registerCellNib() {
            self.tableView.register(UINib.init(nibName: NSStringFromClass(cellClass), bundle: nil), forCellReuseIdentifier: NSStringFromClass(cellClass))
        }
    }

    private func hy_registeHeaderAndFooterView() {

        let headerAndFooterClass = self.registerHeaderClass() + self.registerFooterClass()
        for viewClass in headerAndFooterClass {
            self.tableView.register(viewClass, forHeaderFooterViewReuseIdentifier: NSStringFromClass(viewClass))
        }

        let headerAndFooterNib = self.registerHeaderNib() + self.registerFooterNib()
        for viewClass in headerAndFooterNib {
            self.tableView.register(UINib.init(nibName: NSStringFromClass(viewClass), bundle: nil), forHeaderFooterViewReuseIdentifier: NSStringFromClass(viewClass))
        }
    }
}

我们需要在tableView创建之后注册复用view,所以需要更改前面 hy_setUpUI 方法为:

private func hy_setUpUI() {
        self.view.addSubview(self.tableView)
        self.tableView.frame = self.view.bounds
        self.tableView.dataSource = self
        self.tableView.delegate = self
        self.hy_registeCell()
        self.hy_registeHeaderAndFooterView()
}

VTableViewController 需要根据 presenter 提供的数据显示列表视图部分,我们需要实现 UITableViewDelegate, UITableViewDataSource 两个协议,这个时候我们就需要用到 presenter 在 PresenterType 和 ListDataProtocol中定义的方法,从 presenter 中直接拿到可以用来展示的数据给视图展示。

我们接下来添加 UITableViewDataSource 相关的 cell 显示方法实现:

class VTableViewController<P: ListPresenterType, T: UITableView>: UIViewController, ViewType, ListViewProtocol, UITableViewDelegate, UITableViewDataSource {   
    // MARK: -
    // MARK: - tableView data source
    func numberOfSections(in tableView: UITableView) -> Int {
        return self.presenter.numberOfSections()
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.presenter.numberOfItemsInSection(at: section)
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        let cellId = self.presenter.item(at: indexPath)?.cellId ?? ""

        #if DEBUG
        assert(self.presenter.item(at: indexPath) != nil, "There is no item")
        assert(cellId.isEmpty != true, "Item don't has cellId")
        #else
        if cellId.isEmpty {
            return UITableViewCell.init()
        }
        #endif

        guard let cell = tableView.dequeueReusableCell(withIdentifier: cellId) else {
            return UITableViewCell.init()
        }

        return cell
    }
}

通过上面的代码我们可以将 presenter 中已经准备好的数据交给 tableView 显示。

通常列表中除了 cell 的显示还有 sectionHeader、sectionFooter 的显示,我们依然通过 presenter 给的数据来显示这些视图:

class VTableViewController<P: ListPresenterType, T: UITableView>: UIViewController, ViewType, ListViewProtocol, UITableViewDelegate, UITableViewDataSource {
    // MARK: -
    // MARK: - tableView data source
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        #if DEBUG
        assert(self.presenter.section(in: section) != nil, "There is no section")
        #endif
        return self.presenter.section(in: section)?.headerTitle ?? ""
    }

    func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
        #if DEBUG
        assert(self.presenter.section(in: section) != nil, "There is no section")
        #endif
        return self.presenter.section(in: section)?.footerTitle ?? ""
    }

   // MARK: -
   // MARK: - tableView delegate
    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        #if DEBUG
        assert(self.presenter.section(in: section) != nil, "There is no section")
        #endif
        let headerId = self.presenter.section(in: section)?.headerId ?? ""

        // No found header
        guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: headerId) else {
            return nil
        }

        return header
    }

    func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
        #if DEBUG
        assert(self.presenter.section(in: section) != nil, "There is no section")
        #endif
        let footerId = self.presenter.section(in: section)?.footerId ?? ""

        // No found header
        guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: footerId) else {
            return nil
        }

        return header
    }
 }

cell、sectionHeader、sectionFooter 还需要设置大小。

这里我们默认所有 view 都会被注册,在 release 版本中对于不能获取到复用 Id 的视图 size 将被设置为 0 ,它将不展示给用户。在 debug 版本中,我们将依然会展示此 View 以便及时发现问题,并更正错误。

所以现实代理方法如下:

class VTableViewController<P: ListPresenterType, T: UITableView>: UIViewController, ViewType, ListViewProtocol, UITableViewDelegate, UITableViewDataSource {  

    // MARK: -
    // MARK: - tableView delegate
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        #if DEBUG
        assert(self.presenter.item(at: indexPath) != nil, "There is no item")
        return self.presenter.item(at: indexPath)?.cellSize.height ?? 0
        #else

        guard let cellId = self.presenter.item(at: indexPath)?.cellId else {
            return 0
        }

        if cellId.isEmpty {
            return 0
        }

        // No found cell
        let registeCells = registerCellClass() + registerCellNib()
        guard (registeCells.contains { NSStringFromClass($0) == cellId}) else {
            return 0
        }
        return self.presenter.item(at: indexPath)?.cellSize.height ?? 0
        #endif
    }

    func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
        #if DEBUG
        assert(self.presenter.section(in: section) != nil, "There is no section")
        return self.presenter.section(in: section)?.headerSize.height ?? 0
        #else
        // There is no headerId
        guard let headerId = self.presenter.section(in: section)?.headerId else {
            return 0
        }
        if headerId.isEmpty {
            return 0
        }

        // No found header
        let registeHeaders = registerHeaderClass() + registerHeaderNib()
        guard (registeHeaders.contains { NSStringFromClass($0) == headerId}) else {
            return 0
        }
        return self.presenter.section(in: section)?.headerSize.height ?? 0
        #endif
    }

    func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
        #if DEBUG
        assert(self.presenter.section(in: section) != nil, "There is no section")
        return self.presenter.section(in: section)?.footerSize.height ?? 0
        #else
        guard let footerId = self.presenter.section(in: section)?.footerId else {
            return 0
        }
        if footerId.isEmpty {
            return 0
        }

        // No found footer
        let registeFooters = registerFooterClass() + registerFooterNib()
        guard (registeFooters.contains { NSStringFromClass($0) == footerId}) else {
            return 0
        }
        return self.presenter.section(in: section)?.footerSize.height ?? 0
        #endif
    }
}

我们先定义一个 tableViewCell 的基类。

这里 cell 没有用协议而是定义了基类是因为 viewModel 是一个泛型类型,使用协议业务层 tableView 的代理方法中会增加很多重复的代码,这里使用基类更方便。

class HYTableViewCell<T>: UITableViewCell {
    var viewModel: T?
    func setViewModel(_ viewModel: T) {
        self.viewModel = viewModel
    }
}

这样我们就已经写好了 tableView 需要显示的基本内容。

业务层可以用 VTableViewController 快速开始 VIPER 之旅。

这里需要注意的是由于 VTableViewController 是带泛型的协议类型,所以如果子类要调用 tableView 的 UITableViewDelegate 方法和 UITableViewDataSource 方法,父类必须写子类需要调用方法的空实现,否则子类的方法不会被调用。

到这里我们已经完成了 View 显示 Presenter 提供的数据,基础构件已经准备就绪,他们之间的关系结构如下:


同样的 UICollectionView 也可以实现一个 VCollectionViewController 的类型,提供含有 UICollectionView 的Controller,思路一样不赘述,具体实现详见文末 demo。

02产品实践

下面我们开始写推荐电影的列表的产品代码。

RecommendRouter

Router 创建展示页面,提供展示页面。

class RecommendRouter: RouterType {
    var viewController: UIViewController?

    override init() {
        let presenter = RecommendPresenter.init()
        let viewController = RecommendViewController.init(presenter: presenter, style: UITableView.Style.plain)
        self.viewController = viewController
        super.init()
    }
}

Entity

假设我们需要展示每个电影的名字、封面图、简介。创建RecommendModel如下:

class RecommendModel {
    var name = ""
    var image = ""
    var brief = ""
}

class RecommendViewModel: ViewModelType {

    var model:RecommendModel

    init(_ model: RecommendModel) {
        self.model = model
    }

    var cellId: String {
        return NSStringFromClass(RecommendCell.self)
    }

    var cellSize: CGSize {
        return CGSize.init(width: UIScreen.main.bounds.width, height: 180)
    }

    lazy var name: NSAttributedString = {
        return NSAttributedString.init(string: "影片名:\(model.name)", attributes: [NSAttributedString.Key.font : UIFont.systemFont(ofSize: 32, weight: UIFont.Weight.medium)])
    }()

    lazy var brief: NSAttributedString = {
        return NSAttributedString.init(string: "简介:\(model.brief)", attributes: [NSAttributedString.Key.font : UIFont.systemFont(ofSize: 20, weight: UIFont.Weight.regular),NSAttributedString.Key.foregroundColor: UIColor.gray])
    }()
}

RecommendModel 为从网络或本地获取的数据,在给 view 展示之前需要对数据进行处理。RecommendViewModel 将 RecommendModel 中的数据转化成为 view 可以直接使用的数据,缓存一些需要重复计算使用的数据。

RecommendInteractor

Interactor 我们分为两部分功能,一部分是从网络获取数据,一部分是数据的本地化操作。这里我们分别定义DBManager 和 NetWorkManager.

DBManager 包括对本地数据的增删改查操作,这里暂时只添加获取及保存数据操作的伪代码。

struct DBManager {
    static func saveListToDB(list: [Section]) {
        // save RecommendModel in section
    }

    static func saveModelToDB(model: RecommendViewModel) {
        // save RecommendViewModel.model
    }

    static func loadDBData() -> [Section] {
        // get RecommendViewModels
      return []
    }
}

NetWorkManager 主页是从网络获取新数据,这里我们先使用假数据。

struct NetWorkManager {

    static func requestData(completion:(_ success: Bool, _ list:[Section]) -> ()) {
        let theAvengers_4 = RecommendModel.init()
        theAvengers_4.name = "复仇者联盟4:终局之战"
        theAvengers_4.image = "https://pic8.iqiyipic.com/image/20190715/5f/96/a_100302620_m_601_m1_195_260.jpg"
        theAvengers_4.brief = "故事发生在灭霸消灭宇宙一半的生灵并重创复仇者联盟之后,剩余的英雄被迫背水一战,为22部漫威电影写下传奇终章。"
        let viewModel_4 = RecommendViewModel.init(theAvengers_4)

        let theAvengers_3 = RecommendModel.init()
        theAvengers_3.name = "复仇者联盟3:无限战争"
        theAvengers_3.image = "https://img9.doubanio.com/view/photo/l/public/p2517753454.jpg"
        theAvengers_3.brief = "最先与灭霸军团遭遇的雷神索尔一行遭遇惨烈打击,洛基遇害,空间宝石落入灭霸之手。未几,灭霸的先锋部队杀至地球,一番缠斗后掳走奇异博士。为阻止时间宝石落入敌手,斯塔克和蜘蛛侠闯入了敌人的飞船。与此同时,拥有心灵宝石的幻视也遭到外星侵略者的袭击,为此美国队长、黑寡妇等人将其带到瓦坎达王国,向黑豹求助......"
        let viewModel_3 = RecommendViewModel.init(theAvengers_3)

        let theAvengers_2 = RecommendModel.init()
        theAvengers_2.name = "复仇者联盟2:奥创纪元"
        theAvengers_2.image = "https://img3.doubanio.com/view/photo/l/public/p2237747953.jpg"
        theAvengers_2.brief = "托尼·斯塔克试图重启一个已经废弃的维和项目,不料该项目却成为危机导火索。世上最强大的超级英雄——钢铁侠、美国队长、雷神、绿巨人、黑寡妇和鹰眼 ,不得不接受终极考验,拯救危在旦夕的地球。"
        let viewModel_2 = RecommendViewModel.init(theAvengers_2)
        let section = Section.init()
        section.items = [viewModel_4, viewModel_3, viewModel_2]

        completion(true, [section])
    }
}

RecommendPresenter

每次获取到数据后需要通知 View 去显示。这里我们定义 LoadFeedback。LoadFeedback 包括通常获取数据后展示的提示信息 msg,是否需要刷新界面 needRefresh,正在加载视图的状态 loadingState,是否还可以继续上拉加载更多 hasMore,上拉加载提示 footerText。LoadCompletion 为加载本地数据的回调定义,Completion 为加载网络数据的回调定义。在具体产品中可以根据业务情况逻辑自定义。

typealias LoadCompletion = (LoadFeedback) -> ()
typealias Completion = (Bool, LoadFeedback) -> ()

struct LoadFeedback {
    var msg: String = ""
    var needRefresh: Bool = true
    var loadingState: LoadingState = .hidden

    var hasMore = true
    var footerText: String?
}

enum LoadingState {
    case show
    case hidden
}

例子中是一个列表的形式,我们把加载数据分为两种:下拉刷新和上拉加载更多。

enum LoadType {
    case pulldown
    case loadMore
}

下面是 RecommendPresenter 的具体实现:

class RecommendPresenter: ListPresenterType {

    var viewModels: [Section] = []
    let interactor = RecommendInteractor.init()

    func loadList(loadType: LoadType, localCompletion: LoadCompletion, completion: @escaping Completion) {

        //  当前没有展示数据,先使用本地数据
        if (loadType == .pulldown && self.viewModels.count == 0) {

            self.viewModels = interactor.loadDBData()

            var loadFeedback = LoadFeedback.init()
            if self.viewModels.count > 0 {
                loadFeedback.loadingState = .show
            }
            localCompletion(loadFeedback)
        }

        // 请求数据
        self.interactor.requestData { (success, list, msg, hasMore) in
            // 请求未成功
            if !success {
                var loadFeedback = LoadFeedback.init()
                if self.viewModels.count <= 0 {
                    loadFeedback.msg = msg
                }
                completion(false, loadFeedback)
                return
            }

            // 请求成功
            if loadType == .loadMore {
                self.viewModels += list
            } else {
                self.viewModels = list                
            }

            var loadFeedback = LoadFeedback.init()
            loadFeedback.hasMore = hasMore
            if self.viewModels.count <= 0 {
                loadFeedback.msg = msg
            }
            completion(true, loadFeedback)

        }
    }

}

RecommendCell

有了可以直接显示的数据我们需要实现显示的view,view显示名称、简介、图片。这里图片下载我们直接使用SDWebImage。

具体实现如下:

class RecommendCell: HYTableViewCell<RecommendViewModel> {

    lazy var titleLabel = UILabel.init()
    lazy var briefLabel = UILabel.init()
    lazy var pic = UIImageView.init()
    override func setViewModel(_ viewModel: RecommendViewModel) {
        super.setViewModel(viewModel)
        self.backgroundColor = UIColor.white

        self.titleLabel.attributedText = viewModel.name
        self.briefLabel.attributedText = viewModel.brief
        self.pic.sd_setImage(with: viewModel.imageUrl) { (image, error, cacheType, url) in
        }
    }

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        let margin: CGFloat = 10
        let imageTop: CGFloat = 50
        let imageHeight: CGFloat = 127
        let imageWidth: CGFloat = 90
        self.titleLabel.frame = CGRect.init(x: margin, y: margin * 2, width: self.bounds.width, height: 15)
        self.contentView.addSubview(self.titleLabel)

        self.briefLabel.frame =  CGRect.init(x: imageWidth + margin * 2, y: imageTop, width: self.bounds.width - imageWidth - margin * 3, height: imageHeight)
        self.contentView.addSubview(self.briefLabel)
        self.briefLabel.numberOfLines = 0

        self.pic.frame = CGRect.init(x: margin, y: imageTop, width: imageWidth, height: imageHeight)
        self.contentView.addSubview(self.pic)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

RecommendViewController

viewController 从 presenter 获取数据然后将数据展示在视图上。这里上拉下拉我们暂时使用 MJRefresh。

class RecommendViewController: VTableViewController<RecommendPresenter, UITableView>  {

    override init(presenter: P, style: UITableView.Style) {
        super.init(presenter: presenter, style: style)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        self.setUpRefreshHeader()
        self.setUpRefreshFooter()
        pulldown()
    }

    override func setUpRefreshHeader() {
        self.tableView.mj_header = MJRefreshStateHeader.init(refreshingBlock: {
            self.pulldown()
        })
    }

    override func setUpRefreshFooter() {
        self.tableView.mj_footer = MJRefreshAutoGifFooter.init(refreshingBlock: {
            self.loadMore()
        })
    }

    override func pulldown() {
        self.presenter.loadList(loadType: LoadType.pulldown, localCompletion: { (loadFeedback) in

            self.view.loading = loadFeedback.loadingState
            self.tableView.reloadData()
        }) { (success, loadFeedback) in
            if !success {
                self.tableView.mj_footer.endRefreshing()
                return
            }

            self.view.loading = loadFeedback.loadingState
            if (loadFeedback.needRefresh) {
                  self.tableView.reloadData()
            }
            self.tableView.mj_footer.endRefreshing()
        }
    }

    override func loadMore() {
        self.presenter.loadList(loadType: .loadMore, localCompletion: { (loadFeedback) in
        }) { (success, loadFeedback) in
            if !success {
                self.tableView.mj_footer.endRefreshing()
                return
            }

            self.view.loading = loadFeedback.loadingState
            if (loadFeedback.needRefresh) {
                  self.tableView.reloadData()
            }
            self.tableView.mj_footer.endRefreshing()
        }
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = super.tableView(tableView, cellForRowAt: indexPath)

        guard let item = self.presenter.item(at: indexPath) as? RecommendViewModel else {
            return cell
        }

        if let hy_cell = cell as? HYTableViewCell<RecommendViewModel> {
            hy_cell.setViewModel(item)
        }
        return cell
    }

    override func registerCellClass() -> [AnyClass] {
        return [RecommendCell.self]
    }
}

现在我们就完成了一个电影列表的初步展示。

显示如下:

在实际电影列表的应用中,各个模块间的关系如下图显示:

至此,我们已经完成了使用 VIPER 创建了一个页面的需求。

当应用中有多个页面跳转交互时,我们可以通过router 控制页面跳转,减少模块间代码耦合度。

以上代码详见demo:https://github.com/momosn/VIPERPractice

总结

VIPER 的特色就是职责明确,粒度细,隔离关系明确,这样能带来很多优点:

  • 可测试性好。UI测试和业务逻辑测试可以各自单独进行。
  • 易于迭代。各部分遵循单一职责,可以很明确地知道新的代码应该放在哪里。
  • 隔离程度高,耦合程度低。一个模块的代码不容易影响到另一个模块。
  • 易于团队合作。各部分分工明确,团队合作时易于统一代码风格,可以快速接手别人的代码。

但是同时职责划分细也带来了一些不便:

  • 一个模块内的类数量增加,代码量增加,在层与层之间需要花更多时间设计接口。
  • 模块的初始化较为复杂,打开一个新的界面需要生成 View、Presenter、Interactor,并且设置互相之间的依赖关系。而 iOS 中缺少这种设置复杂初始化的原生方式。

我们可以使用代码模板来自动生成文件和模板代码可以减少很多重复劳动,但是花时间设计和编写接口是减少耦合的路上不可避免的,同时我们也可以使用数据绑定这样的技术来减少一些传递的层次。

ER 是2013年首次在 iOS 平台上提出,所以还十分年轻,因此缺少大量参与者,希望我的实践可以帮助大家提供一些思路和方法。


https://mp.weixin.qq.com/s/ovmd_YTH3I992hO_jBiNog

iOS 隐形水印之 LSB 实现

在音视频的领域里,其涵盖的知识点繁多,学习方向也很多。而本篇就是一篇比较入门的文章它简单地介绍如何在 iOS 上读取图片 RGB 数据,并通过修改最后一位 bit 来记录数字水印的信息下面就介绍《隐形水印之 iOS 实现》

发布于:26天以前  |  89次阅读  |  详细内容 »

声明式 UIKit 在有赞美业的实践

随着 Flutter 的出现,UI 开发形式也越来越趋向相同,Flutter,SwiftUI,RN,Weex 等新兴UI框架无一意外都使用了声明式的 UI 开发模式,和支持了FlexBox的布局系统。

发布于:26天以前  |  92次阅读  |  详细内容 »

iOS 架构谈:剖析 Uber 的 RIB 架构

加入 UBER 是我的 iOS 工程师职业的新篇章,所有这一切都始于称为 RIB 的新架构。该架构背后的主要思想是,应用程序应由业务逻辑而不是视图驱动。展示 RIB 的最佳方法是一棵树:每个 RIB 都是一个节点,并且它可以不包含子节点,也可以包括一个或多个子节点。

发布于:27天以前  |  94次阅读  |  详细内容 »

如何调试支付宝(iOS)

最近在做的一件事情,从代码层面分析下各家小程序(微信、头条、支付宝、百度)的启动性能,探究各家小程序的实现细节和差异。

发布于:29天以前  |  141次阅读  |  详细内容 »

iOS GPUImage源码解读(一)

最近在不断学习、使用的过程中,有了更深刻的理解,特来写一篇源码解读的文章详细介绍下核心代码的具体实现。至于括号里的“一”,主要是觉得GPUImage还有很多值得深入学习和分享的内容,后续的学习和使用过程中有新的心得体会还会继续给大家分享。

发布于:1月以前  |  113次阅读  |  详细内容 »

iOS开发之Masonry框架源码解析

Masonry是iOS在控件布局中经常使用的一个轻量级框架,Masonry让NSLayoutConstraint使用起来更为简洁。Masonry简化了NSLayoutConstraint的使用方式,让我们可以以链式的方式为我们的控件指定约束。本篇博客的主题不是教你如何去使用Masonry框架的,而是对Masonry框架的源码进行解析,让你明白Masonry是如何对NSLayoutConstraint进行封装的,以及Masonry框架中的各个部分所扮演的角色是什么样的。在Masonry框架中,仔细的品味干货还是很多的。Masonry框架是Objective-C版本的,如果你的项目是Swift语言的,那么就得使用SnapKit布局框架了。SnapKit其实就是Masonry的Swift版本,两者虽然实现语言不同,但是实现思路大体一致。

发布于:1月以前  |  115次阅读  |  详细内容 »

iOS 验证码输入一种实现思路

如图所示,现在很多App采用了类似下划线、方块等方式的验证码输入,直观美观!对于这种效果的实现方式,大概有以下几种方式:

发布于:1月以前  |  124次阅读  |  详细内容 »

最多阅读

快速配置 Sign In with Apple 11月以前  |  2406次阅读
给数组NSMutableArray排序 1年以前  |  2112次阅读
开篇 关于iOS越狱开发 1年以前  |  1961次阅读
UITableViewCell高亮效果实现 1年以前  |  1948次阅读
在越狱的iPhone设置上使用lldb调试 1年以前  |  1942次阅读
APP适配iOS11 1年以前  |  1866次阅读
关于Xcode不能打印崩溃日志 1年以前  |  1652次阅读
App Store 审核指南[2017年最新版本] 1年以前  |  1650次阅读
所有iPhone设备尺寸汇总 1年以前  |  1626次阅读
使用ssh访问越狱iPhone的两种方式 1年以前  |  1573次阅读
使用 GPUImage 实现一个简单相机 1年以前  |  1535次阅读
使用ssh 访问越狱iPhone的两种方式 1年以前  |  1517次阅读
UIDevice的简单使用 1年以前  |  1459次阅读
为对象添加一个释放时触发的block 1年以前  |  1381次阅读
使用最高权限操作iPhone手机 1年以前  |  1312次阅读