积木法搭建 iOS 应用—— VIPER

发表于 4年以前  | 总阅读数:1548 次

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

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

想必大家已经对经常使用的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 平台上提出,所以还十分年轻,因此缺少大量参与者,希望我的实践可以帮助大家提供一些思路和方法。

本文由哈喽比特于4年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/ovmd_YTH3I992hO_jBiNog

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

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

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

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

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

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

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

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

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

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

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

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

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

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

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

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

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

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

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

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

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

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

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

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

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

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

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

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

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

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

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

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

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

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

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

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

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

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

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
快速配置 Sign In with Apple 5年以前  |  7676次阅读
使用 GPUImage 实现一个简单相机 5年以前  |  5975次阅读
APP适配iOS11 5年以前  |  5750次阅读
 目录