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

作者 | Stan Ostrovskiy
来源 | Medium The Startup

什么是RIB?

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

在应用程序生命周期中,可以附加和分离 RIB ,创建子节点并与之交互。

RIB 即 “路由 + 交互器 + 构造器 (Router Interactor Builder)”。

路由(Router) 负责相邻 RIB 之间的导航

交互器(Interactor) 是处理 RIB 业务逻辑的主要组件。它响应用户交互,与后端对话,并准备数据显示给用户。

构造器(Builder) 是一个将所有 RIB 片段组合在一起的构造器。

还有一个可选的 视图(View)展示器(Presenter)View 本身没有任何业务逻辑,它仅负责呈现 UI 并接受传递给 Interactor 的用户交互。 Interactor 拥有 View ,并且 View 通过委托模式与 Interactor 对话。 Presenter 基本上是 View 实现的协议。

例如,在 View 上点击“登录”按钮将触发 Interactor 中的 Web 任务, Interactor 将告诉 Presenter 显示活动指示器。登录成功后, Interactor 将告诉 Router 导航到下一个页面。

这是一个简单的概述,现在我们可以深入研究 RIB 的每个组件,并了解它们如何协同工作。

注:以下统一使用 RouterInteractorBuilderViewPresenter 来描述这 5 个组件。

深入RIBS

幸运的是,在你想要创建一个带有所有组件的 RIB 时,不需要手写所有样板代码。您可以安装和配置 Xcode 模板。要创建新的 RIB,只需打开文件创建菜单,然后从列表中选择 RIB

我们将创建一个名为 LoginRIB ,并勾选 Owns corresponding view 以让 RIB 带有视图:

这个 Xcode 模板会生成 4 个文件。我们接下来会仔细研究它们中的每一个,并讨论它们的功能。

LoginBuilder

一个 Builder 负责创建所有 RIB 组件。

请注意,以下所有代码都是由 Xcode 模板自动生成的。

import RIBs

protocol LoginDependency: Dependency {
    // TODO: Declare the set of dependencies required by this RIB, but cannot be
    // created by this RIB.
}

final class LoginComponent: Component<LoginDependency> {

    // TODO: Declare 'fileprivate' dependencies that are only used by this RIB.
}

// MARK: - Builder
protocol LoginBuildable: Buildable {
    func build(withListener listener: LoginListener) -> LoginRouting
}

final class LoginBuilder: Builder<LoginDependency>, LoginBuildable {

    override init(dependency: LoginDependency) {
        super.init(dependency: dependency)
    }

    func build(withListener listener: LoginListener) -> LoginRouting {
        let component = LoginComponent(dependency: dependency)
        let viewController = LoginViewController()
        let interactor = LoginInteractor(presenter: viewController)
        interactor.listener = listener
        return LoginRouter(interactor: interactor, viewController: viewController)
    }
}

您会注意到的第一件事是,大多数组件是协议,而不是具体的类。这是 RIB 的主要特性之一,我们将在本文后面讨论。

LoginDependency 用于将依赖项从其父项注入到 RIB。例如,我们有一个 webService 用于执行登录 Web 请求。我们创建一个我们要注入的 WebServicing 协议:

protocol WebServicing: class {
    func login(userName: String, password: String, handler: (Result<String, Error>) -> Void)
}

现在,我们可以更新 LoginDependency 协议,为 Builder 提供对其依赖项的访问:

protocol LoginDependency: Dependency {
    var webService: WebServicing { get }
}

我们在这里使用的下一个组件是 LoginComponent 。我们可以声明一些仅在此 Builder 中使用的局部变量,例如设置或 AdMob ID 等。在我们的示例中不需要这些局部变量,因为我们不需要任何私有依赖项。

下一个协议是 LoginBuildable ,它只有一个方法 build(withListener:) 。这里的 listener 参数是父侦听器。我们可以自由地向此构建方法添加更多参数,只需要根据需求来定制。

LoginBuilder 类实现了 LoginBuildable 协议,它是这里的主要组件。它使用 LoginDependency 创建一个 LoginComponent

LoginComponent 现在封装了这个 RIB 需要的所有依赖项。该构建器还创建一个 LoginViewControllerLoginInteractor ,用于创建和返回 LoginRouter

这里是另一行重要的代码:

interactor.listener = listener

这就是我们将父 Interactor 与子 Interactor 连接的方式。例如,我们有一个与 RootRIB 连接的 LoginRIB 。在这种情况下, RootInteractor 必须实现 LoginInteractor 侦听器将声明的方法。如果 LoginInteractor 有一个 dismissLogin 方法,则根 RIB 将实现此方法以分离 Login 流并显示主页。

稍后,当我们需要使用 Router 的某些依赖项时,我们将返回到 Router ,现在我们来看看下一个组件 Interactor

LoginInteractor

同样, Xcode 模板会自动为您生成以下所有代码。

import RIBs
import RxSwift

protocol LoginRouting: ViewableRouting {
    // TODO: Declare methods the interactor can invoke to manage sub-tree via the router.
}

protocol LoginPresentable: Presentable {
    var listener: LoginPresentableListener? { get set }
    // TODO: Declare methods the interactor can invoke the presenter to present data.
}

protocol LoginListener: class {
    // TODO: Declare methods the interactor can invoke to communicate with other RIBs.
}

final class LoginInteractor: PresentableInteractor<LoginPresentable>, LoginInteractable, LoginPresentableListener {

    weak var router: LoginRouting?
    weak var listener: LoginListener?

    // TODO: Add additional dependencies to constructor. Do not perform any logic
    // in constructor.
    override init(presenter: LoginPresentable) {
        super.init(presenter: presenter)
        presenter.listener = self
    }

    override func didBecomeActive() {
        super.didBecomeActive()
        // TODO: Implement business logic here.
    }

    override func willResignActive() {
        super.willResignActive()
        // TODO: Pause any business logic.
    }
}

LoginRouting 是我们用来从 Login RIB 导航到后续 RIB 的协议。假设我们希望能够导航到 CreateAccount 页面:

protocol LoginRouting: ViewableRouting {
    func routeToCreateAccount()
}

LoginPresentable 用于响应在 Interactor 中执行的业务逻辑来更新 Login 视图。如果打开 LoginViewController ,您会注意到它实现了此协议。 LoginPresentable 还拥有一个 LoginPresentableListener 实例。这是 LoginViewControllerInteractor 进行通信并调用业务逻辑的一种方式。换句话说,这是 InteractorViewController 相互通信的方式:

如上所述,我们希望我们的视图控制器在执行 Web 任务时显示活动指示器。为了实现这一点,我们在 LoginPresentable中添加了一个新方法 showActivityIndicator

protocol LoginPresentable: Presentable {
    var listener: LoginPresentableListener? { get set }
    func showActivityIndicator(_ isLoading: Bool)
}

最后,我们有一个 LoginListener 。还记得 LoginBuilder 中的这一行代码吗?

interactor.listener = listener

这是 Root RIB 将要实现的侦听器。这是子级 RIB 与父级进行通信的一种方式。登录完成后,我们需要通知 Root RIB ,以便可以取消登录流程:

protocol LoginListener: class {
    func dismissLoginFlow()
}

现在我们看一下 LoginInteractor 类。它有两个 weak 变量: routerlistener 。这就是 Interactor 连接到其 Router 和父 Interactor 的方式。可以看到,该 Interactor 还拥有一个 Presenter

回顾一下,RIB 背后的核心思想是该应用程序应由业务逻辑驱动。 Interactor 就是此业务逻辑所在的地方。

这里是我们使用 Interactor 控制应用程序流程的方式:

• 我们调用 presenter 方法来更新登录 UI(我们的示例中有 showActivityIndicator)

• 我们调用 router 方法导航到子 RIB(我们的示例中有 routeToCreateAccount)

• 我们调用 listener 方法与父 RIB 对话(我们的示例中有 dismissLoginFlow)

接下来,我们可以看到一些生命周期方法 didBecomeActivewillResignActive 。这些方法是自解释的,我们不会直接调用它们。例如,我们可以在 didBecomeActive 中执行Web任务以获取所需的数据,或者根据我们的业务逻辑进行初始视图设置。

稍后我们将返回到 Interactor ,现在让我们来完成其余的组件 -- Router , ViewPresenter

LoginRouter

同样,Xcode 模板会自动为您生成以下所有代码。

import RIBs

protocol LoginInteractable: Interactable {
    var router: LoginRouting? { get set }
    var listener: LoginListener? { get set }
}

protocol LoginViewControllable: ViewControllable {
    // TODO: Declare methods the router invokes to manipulate the view hierarchy.
}

final class LoginRouter: ViewableRouter<LoginInteractable, LoginViewControllable>, LoginRouting {

    // TODO: Constructor inject child builder protocols to allow building children.
    override init(interactor: LoginInteractable, viewController: LoginViewControllable) {
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }
}

LoginInteractable 是这里的主要协议,包含两个组件, LoginRoutingLoginListener。我们在 Interactor 中创建它们。

LoginViewControllable 用于操纵视图层次结构。因此,当 Interactor 告诉 Router 使用 LoginRouting 导航到 CreateAccount 时, Router 最终将需要显示 CreateAccount 页面。我们需要添加以下方法:

protocol LoginViewControllable: ViewControllable {
    func present(_ viewController: ViewControllable)
}

如您所见, LoginRouter 实现了 LoginRouting 协议,因此我们需要添加必需的方法 routeToCreateAccount

final class LoginRouter: ViewableRouter<LoginInteractable, LoginViewControllable>, LoginRouting {

    override init(interactor: LoginInteractable, viewController: LoginViewControllable) {
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }

    func routeToCreateAccount() {

    }
}

在展示其 viewController 之前,我们需要有一个 CreateAccount RIB 。创建另一个 RIB

我们不会在此 RIB 中进行任何更改,因此只需将其保留并返回 LoginRouter 即可。

要构建 CreateAccount RIBLoginRouter 需要有一个 CreateAccountBuilder 。声明一个类型为 CreateAccountBuildable 的私有变量,并更新 LoginRouter 构造器,以注入 CreateAccountBuildable

final class LoginRouter: ViewableRouter<LoginInteractable, LoginViewControllable>, LoginRouting {

    private let createAccountBuilder: CreateAccountBuildable

    init(
        interactor: LoginInteractable,
        viewController: LoginViewControllable,
        createAccountBuilder: CreateAccountBuildable
    ) {
        self.createAccountBuilder = createAccountBuilder
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }

    func routeToCreateAccount() {

    }
}

请记住,我们没有使用具体的 CreateAccountBuilder 类型。相反,我们使用协议 CreateAccountBuildable

现在我们可以完成 routeToCreateAccount 方法。

func routeToCreateAccount() {
    let router = createAccountBuilder.build(withListener: interactor)
    attachChild(router)
    viewController.present(router.viewControllable)
}

• 我们使用 createAccountBuilder 构建一个 createAccountRouter 。我们需要在 Build 方法中将当前的 Interactor 作为侦听器传递。

• 我们将 createAccountRouter 作为子级附加到当前 Router。这就是我们构建 RIB 树的方式。

• 我们调用 LoginViewControllable 方法来呈现 CreateAccount 视图控制器。

在这里会遇到的第一件事是以下编译器错误:

Argument type ‘LoginInteractable’ does not conform to expected type ‘CreateAccountListener’

要解决此问题,我们需要确保 LoginInteractable 实现 CreateAccountListener 协议:

protocol LoginInteractable: Interactable, CreateAccountListener {
    var router: LoginRouting? { get set }
    var listener: LoginListener? { get set }
}

这是另一件事要记住的事情。我们使用 attachChild 方法附加 createAccountRouter 。后续需要另一种方法来关闭 CreateAccount 页面。关闭子页面后,我们必须将其 Router 与当前树分离。

当 viewController 不再可用时,相应的 RIB 仍在树中,但我们不想看到这种状态。这最终可能导致内存泄漏和意外行为。

为了避免这种情况,我们将保留对 CreateAccountRouter 的引用。在 LoginRouter 中创建一个变量:

final class LoginRouter: ViewableRouter<LoginInteractable, LoginViewControllable>, LoginRouting {

    private let createAccountBuilder: CreateAccountBuildable
    private let createAccountRouter: CreateAccountRouting?

    // ...
}

现在,我们更新 routeToCreateAccount 方法。我们需要将 createAccountRouter 保存到本地变量。另外,如果已经创建了子 Router ,我们可以防止自己创建 Router 和提供子视图控制器:

func routeToCreateAccount() {
    guard createAccountRouter == nil else { return }

    let router = createAccountBuilder.build(withListener: interactor)
    createAccountRouter = router
    attachChild(router)
    viewController.present(router.viewControllable)
}

最后,当我们要关闭 CreateAccount 页面时,在使用视图层次结构进行操作后,我们必须分离其 Router

func detachCreateAccount() {
    guard let createAccountRouter = createAccountRouter else { return }
    createAccountRouter.viewControllable.uiviewController.dismiss(animated: true, completion: nil)
    detachChild(createAccountRouter)
    self.createAccountRouter = nil
}

Xcode 将显示另一个编译器错误,因此我们需要更新 LoginBuilder 并将 CreateAccountBuilder 传递给 Router 的构造器。我们使用 LoginBuilder 创建并注入一个子 Builder

final class LoginBuilder: Builder<LoginDependency>, LoginBuildable {

    override init(dependency: LoginDependency) {
        super.init(dependency: dependency)
    }

    func build(withListener listener: LoginListener) -> LoginRouting {
        let component = LoginComponent(dependency: dependency)
        let viewController = LoginViewController()
        let interactor = LoginInteractor(presenter: viewController)
        interactor.listener = listener

        let createAccountBuilder = CreateAccountBuilder(dependency: component.dependency)

        return LoginRouter(
            interactor: interactor,
            viewController: viewController,
            createAccountBuilder: createAccountBuilder
        )
    }
}

注意,我们将 component.dependency 用作 createAccountBuilder 依赖项。为此,我们需要 LoginDependency 来实现 CreateAccountDependency 协议。这是我们将依赖关系从父 RIB 连接到子 RIB 的方式:

protocol LoginDependency: CreateAccountDependency {
    var webService: WebServicing { get }
}

在我们的示例中, CreateAccountDependency 没有任何变量。如果有的话,我们需要在某些时候提供它们。在根组件中创建并保留所有依赖项,然后使用此协议继承传递它们,这很方便。我们将在本文结尾处进行此操作。

到目前为止,该应用程序应该编译没有任何错误。

LoginPresenter/LoginViewController

import RIBs
import RxSwift
import UIKit

protocol LoginPresentableListener: class {
    // TODO: Declare properties and methods that the view controller can invoke to perform
    // business logic, such as signIn(). This protocol is implemented by the corresponding
    // interactor class.
}

final class LoginViewController: UIViewController, LoginPresentable, LoginViewControllable {

    weak var listener: LoginPresentableListener?
}

LoginPresentableListener 具有良好的自动生成的文档。我们只需要知道我们要在此 ViewController 上执行哪些操作即可。我们在 LoginPresentableListener 中添加两个方法:

protocol LoginPresentableListener: class {
    func didTapLogin(username: String, password: String)
    func didTapCreateAccount()
}

我们不会专注于 UI,但是如果您希望在实际操作中看到它,则可以继续创建一个简单的 UI。确保按钮触发正确的 listener 方法。

LoginViewController 类实现了我们之前配置的 LoginPresentable 协议(以便 Interactor 可以与 viewController 通信)。这意味着 LoginViewController 必须实现 showActivityIndicator 方法:

final class LoginViewController: UIViewController, LoginPresentable, LoginViewControllable {

    weak var listener: LoginPresentableListener?

    // MARK: - LoginPresentable

    func showActivityIndicator(_ isLoading: Bool) {

    }
}

viewController 实现的下一个协议是 LoginViewControllable (以便 Router 可以修改视图层次结构)。为了符合要求, LoginViewController 必须实现当前方法:

final class LoginViewController: UIViewController, LoginPresentable, LoginViewControllable {

    weak var listener: LoginPresentableListener?

    // MARK: - LoginPresentable

    func showActivityIndicator(_ isLoading: Bool) {

    }

    // MARK: - LoginViewControllable

    func present(_ viewController: ViewControllable) {
        present(viewController.uiviewController, completion: nil)
    }
}

这是我们在 LoginViewController 中需要做的所有事情。同样,您可以添加缺少的 UI 按钮,文本字段和活动指示器。

因为我们向 LoginPresentableListener 添加了一些方法,并且 LoginInteractor 实现了此协议,所以我们需要向 Interactor 添加缺少的方法:

final class LoginInteractor: PresentableInteractor<LoginPresentable>, LoginInteractable, LoginPresentableListener {

  // ...

  // MARK: - LoginPresentableListener

  func didTapLogin(username: String, password: String) {

  }

  func didTapCreateAccount() {

  }
}

didTapCreateAccount 必须路由到 CreateAccount RIB ,因此我们只需要调用现有的 LoginRouting 方法:

  func didTapCreateAccount() {
      router?.routeToCreateAccount()
  }

要调用登录Web任务,我们需要访问我们之前创建的 WebServicing 登录方法。我们将把 WebServicing 传递给 LoginInteractor 构造器:

final class LoginInteractor: PresentableInteractor<LoginPresentable>, LoginInteractable, LoginPresentableListener {

    // ...

    private let webService: WebServicing

    init(presenter: LoginPresentable, webService: WebServicing) {
        self.webService = webService
        super.init(presenter: presenter)
        presenter.listener = self
    }

    // ...
}

Interator 中有 WebServicing ,我们可以完成登录方法:

func didTapLogin(username: String, password: String) {
    presenter.showActivityIndicator(true)
    webService.login(userName: username, password: password) { [weak self] result in
       self?.presenter.showActivityIndicator(false)
       switch result {
       case let .success(userID):
           // do something with userID if needed
           self?.listener?.dismissLoginFlow()
       case let .failure(error):
           // log error
       }
    }
}

在此方法内部,我们实现了所有登录业务逻辑,显示和隐藏活动指示器,在登录成功时关闭 LoginFlow 页面,并在登录失败的情况下记录错误。我们还添加另一个 LoginPresentable 方法 showErrorAlert ,如果登录失败,该方法将通知用户:

protocol LoginPresentable: Presentable {
    var listener: LoginPresentableListener? { get set }
    func showActivityIndicator(_ isLoading: Bool)
    func showErrorAlert()
}

编译器将确保您已在 LoginViewController 中实现此方法。在 login 失败的情况下调用此方法:

webService.login(userName: username, password: password) { [weak self] result in
   self?.presenter.showActivityIndicator(false)
   switch result {
   case let .success(userID):
       // do something with userID if needed
       self?.listener?.dismissLoginFlow()
   case let .failure(error):
       // log error
       self?.presenter.showErrorAlert()
   }
}

最后,我们必须更新 LoginBuilder 并将 WebServicing 依赖项传递到 LoginInteractor 中:

final class LoginBuilder: Builder<LoginDependency>, LoginBuildable {

    override init(dependency: LoginDependency) {
        super.init(dependency: dependency)
    }

    func build(withListener listener: LoginListener) -> LoginRouting {
        let component = LoginComponent(dependency: dependency)
        let viewController = LoginViewController()
        let interactor = LoginInteractor(presenter: viewController, webService: component.dependency.webService)
        interactor.listener = listener

        let createAccountBuilder = CreateAccountBuilder(dependency: component.dependency)

        return LoginRouter(
            interactor: interactor,
            viewController: viewController,
            createAccountBuilder: createAccountBuilder
        )
    }
}

顶级RIB

现在,我们为应用程序提供了完整的登录模块。如果您想查看全部内容,则必须添加一些缺失的部分。

创建一个 Root RIB ,它将成为 Login RIB 的父级(您应该能够使用上面提供的相同步骤将登录连接到 root。主要的区别是在 RootRouterRootBuilder 中,因为它是一个顶级 RIB,所以没有父 RIB)。

除了创建 RootRouting ,我们还需要创建 LaunchRouting(为顶级 RIB 设计的特定 RIB 组件):

import RIBs

protocol RootDependency: Dependency {
}

final class RootComponent: Component<RootDependency> {

    private let rootViewController: RootViewController

    init(dependency: RootDependency,
         rootViewController: RootViewController) {
        self.rootViewController = rootViewController
        super.init(dependency: dependency)
    }
}

// MARK: - Builder
protocol RootBuildable: Buildable {
    func build() -> LaunchRouting
}

final class RootBuilder: Builder<RootDependency>, RootBuildable {

    override init(dependency: RootDependency) {
        super.init(dependency: dependency)
    }

    func build() -> LaunchRouting {
        let viewController = RootViewController()
        let component = RootComponent(
            dependency: dependency,
            rootViewController: viewController
        )

        let interactor = RootInteractor(presenter: viewController)

        return RootRouter(
            interactor: interactor,
            viewController: viewController
        )
    }
}

这是一个非常具体的案例,代码都能自解释,因此,我将不赘述。

RootRouter 还将继承自 LaunchRouting 而不是 ViewableRouter ,后者是特定于启动的路由协议:

final class RootRouter: LaunchRouter<RootInteractable, RootViewControllable>, RootRouting {
    override init(interactor: RootInteractable, viewController: RootViewControllable) {
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }
}

我们还需要创建一个 AppComponent ,它继承自具有 EmptyDependency 泛型类型的 Component 。该组件有我们希望使用依赖协议传递的大多数依赖。您可以创建一个继承自 WebServicing 协议的 WebService 类,并将其保留为 AppComponent 中的变量:

final class AppComponent: Component<EmptyDependency>, RootDependency {

}

AppDelegate 中,我们需要使用此 AppComponent 创建一个 RootRouter ,并在当前窗口中启动它:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    private var launchRouter: LaunchRouting?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        let window = UIWindow(frame: UIScreen.main.bounds)
        self.window = window

        let launchRouter = RootBuilder(dependency: AppComponent()).build()
        self.launchRouter = launchRouter
        launchRouter.launch(from: window)

        return true
    }
}

到目前为止,我们应该能够编译并启动该应用程序。如果添加缺少的 UI,则可以看到它的实际效果。

高级 RIB

Mock generation

我在本文开头提到,在 RIB 中,我们不使用具体类型,而是在大多数组件和依赖项中使用协议。当我们想用单元测试覆盖我们的代码时,这非常方便。由于 RIB 中的所有业务逻辑都存在于 Interactor 中,因此我们尝试将 InteractorRouter 测试覆盖率达到 100%的。协议允许我们模拟我们使用的大多数类型,从而可以在不暴露实际类型的情况下对其进行测试。

但是同时,mock 协议是繁琐的工作,需要大量样板代码。幸运的是,有多种工具可让我们生成协议的所有 mock 代码。其中包括 Mockolo 工具 (https://github.com/uber/mockolo)。您可以单击提供的链接并安装依赖项,或者,可以随时使用任何其他模拟生成工具。使用 Mockolo,您要做的就是用 /// @ mockable 注释标记协议并运行模拟生成。

例如,我们有一个要在测试中使用的 WebServicing 协议。让我们为此服务生成模拟:

class WebServicingMock: WebServicing {
    init() { }

    var loginCallCount = 0
    var loginHandler: ((String, String, (Result<String, Error>) -> Void) -> ())?
    func login(username: String, password: String, handler: (Result<String, Error>) -> Void)  {
        loginCallCount += 1
        if let loginHandler = loginHandler {
            loginHandler(username, password, handler)
        }

    }
}

该 mock 包含一个 loginCallCountloginHandler ,我们将使用它们来测试是否调用了 Login 方法,以及它是否使用了正确的参数和结果。

我们可以为我们所有的 RIB 协议和依赖项生成 mock 代码。

单元测试

我将提供一个示例,说明如何使用 mock generation 通过测试覆盖 LoginInteractor

让我们看一下 LoginInteractor 中的 didTapLogin(:_:) 方法。以下是我们要测试的几个点:

Presenter 显示活动指示器

webService 构建一个登录 Web 任务

• 如果登录任务成功,则侦听器应调用 dismissLoginFlow 方法

• 如果登录任务失败,则 Presenter 应调用 showErrorAlert 方法

• Web任务完成时, Presenter 隐藏活动指示器

这是将所有测试组件连接在一起的 LoginInteractorTests 的初始设置(mock 是由 Mockolo 生成的):

final class LoginInteractorTests: XCTestCase {
    private var interactor: LoginInteractor!
    private var presenter = LoginPresentableMock()
    private var listener = LoginListenerMock()
    private var router = LoginRoutingMock()
    private let webService = WebServicingMock()

    override func setUp() {
        super.setUp()

        interactor = LoginInteractor(presenter: presenter, webService: webService)
        router.viewControllable = ViewControllableMock()
        router.interactable = InteractableMock()

        interactor.router = router
        interactor.listener = listener
    }
}

让我们为 didTapLogin 方法编写测试。

    func test_didTapLogin_triggersLoginWebTask_andEnableActivityIndicator() {
        presenter.showActivityIndicatorHandler = { isLoading in
            XCTAssertTrue(isLoading)
        }

        interactor.didTapLogin(username: "username", password: "password")

        XCTAssertEqual(webService.loginCallCount, 1)
        XCTAssertEqual(presenter.showActivityIndicatorCallCount, 1)
    }

    func test_loginSucceeded_invokesListenerDismissLoginFlow() {

        webService.loginHandler = { username, login, handler in
            return handler(.success("userID"))
        }

        interactor.didTapLogin(username: "username", password: "password")

        XCTAssertEqual(listener.dismissLoginFlowCallCount, 1)
        XCTAssertEqual(presenter.showActivityIndicatorCallCount, 2)
    }

    func test_loginFailed_invokesPresenterShowErrorAlert() {

        webService.loginHandler = { username, login, handler in
            return handler(.failure(WebServiceError.generic))
        }

        interactor.didTapLogin(username: "username", password: "password")

        XCTAssertEqual(presenter.showErrorAlertCallCount, 1)
        XCTAssertEqual(presenter.showActivityIndicatorCallCount, 2)
    }

同样,我们可以涵盖其余的 Interactor 方法,包括使用我们的 didBecomeActive 方法。 Router 可以用相同的方式进行测试。这是可能的,因为在 RIB 中,我们将大多数组件作为协议,而不是具体类型。此外, RouterInteractor 都大多包含实现其他协议的方法。使用 mock generation ,我们无需编写任何其他代码即可使用单元测试覆盖所有应用业务逻辑。

依赖注入

在示例项目中,我们使用 DependencyComponent 处理依赖关系,并且必须从 AppComponent 传递所有方法。拥有协议继承可以使之清晰明了,井井有条,但是连接所有依赖项仍然很繁琐。

我们使用了另一个开源的 Uber 工具: Needle Dependency Injection

我不会在这里详细解释 Needle,但是上面的链接提供了很好的解释,并提供了有关如何集成和使用它的示例。

如果我看到读者对此感兴趣,那么我将在以后的文章中介绍 Needle,所以请不要忘记关注我:)

结论

在本文中,我介绍了 RIBs 体系结构的要点,解释了一些极端情况,并提供了其大多数组件的使用和示例。

对于这个小项目,RIB 看起来像是过度设计,就像我们在示例中使用的那样。但是,如果您了解这些基础知识,那么采用这种架构不会花费太多时间或精力。而且,如果将其与依赖项注入和 mock generation 相结合,则将为大多数应用程序用例提供一个大胆的解决方案。

您可以在此处找到带有UI和所有必需逻辑的完整示例项目 https://github.com/Stan-Ost/RIBsTutorialExample。

iOS14:再见了,“流氓”APP!

最近和苹果有关的重大消息可能就是从8月1日开始,AppStore中国区火速下架未获版号的游戏APP,数量超过30000款,之前小智就和大家说过,这未必不是一件好事,众多低质和“流氓”APP将被最大限度隔绝在iOS系统之外。

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

iOS 14 苹果对 Objective-C Runtime 的优化

Objective-C 是一门古老的语言,诞生于 1984 年,跟随 Apple 一路浮沉,见证了乔布斯创建了 NeXT,也见证了乔布斯重回 Apple 重创辉煌,它用它特立独行的语法,堆砌了 UIKit,AppKit, Foundation 等一个个基石,时间来到 2020 年,面对汹涌的"后浪" Swift,"老前辈" Objective-C 也在发挥着自己的余热,即使面对越来越多阵地失守,唯有“老兵不死,只会慢慢凋亡"才能体现的悲壮。今年,Apple 给 Objective-C Runtime 带来了新的优化,接下来,让我们深入理解这些变化。

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

iOS14 隐私适配及部分解决方案

在刚刚结束的线上 WWDC 2020 发布会上苹果向我们展示了新的 iOS14 系统。iOS14 的适配,很重要的一环就集中在用户隐私和安全方面。 在 iOS13 及以前,当用户首次访问应用程序时,会被要求开放大量权限,比如相册、定位、联系人,实际上该应用可能仅仅需要一个选择图片功能,却被要求开放整个照片库的权限,这确实是不合理的。对于相册,在 iOS14 中引入了 “LimitedPhotos Library” 的概念,用户可以授予应用访问其一部分的照片,对于应用来说,仅能读取到用户选择让应用来读取的照片,让我们看到了 Apple 对于用户隐私的尊重。这仅仅是一部分,在iOS14 中,可以看到诸多类似的保护用户隐私的措施,也需要我们升级适配。 最近在调研 iOS14的适配方案,本文主要分享一下 iOS14 上对于隐私授权的变更和部分适配方案,欢迎补充指正。

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

Metal新特性:大幅度提升iOS端性能

Metal 是一个和 OpenGL ES 类似的面向底层的图形编程接口,通过使用相关的 api 可以直接操作 GPU ,最早在 2014 年的 WWDC 的时候发布。Metal 是 iOS 平台独有的,意味着它不能像 OpenGL ES 那样支持跨平台,但是它能最大的挖掘苹果移动设备的 GPU 能力,进行复杂的运算,像 Unity 等游戏引擎都通过 Metal 对 3D 能力进行了优化, App Store 还有相应的运用 Metal 技术的游戏专题。 闲鱼团队是比较早在客户端侧选择Flutter方案的技术团队,当前的闲鱼工程里也是一个较为复杂的Native-Flutter混合工程。作为一个2C的应用,性能和用户体验一直是闲鱼技术团队在开发中比较关注的点。而Metal这样的直接操作GPU的底层接口无疑会给闲鱼技术团队突破性能瓶颈提供一些新的思路。 下面会详细阐述一下这次大会Metal相关的新特性,以及对于闲鱼技术和整个淘系技术来说,这些新特性带来了哪些技术启发与思考。

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

Core Image:iOS图像处理技术追踪

Core Image是苹果官方提供的图像处理框架,通过丰富的built-in(内置)或自定义Filter(过滤器)高效处理静态图片、动态图片或视频。开发者还可以通过构造Filter链或自定义Core Image Kernel来实现更丰富的效果。 在WWDC20中,苹果官方针对Core Image技术在以下三方面做了优化:Core Image对视频/动图的支持、基于Metal构建Core Image (CI) Kernel以及Core Image的Debug支持。 这三方面会在下文逐一提到,文末笔者也会浅谈Core Image在手淘图片库中的应用可能以及对Core Image技术的展望。

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

闲鱼如何解决iOS环境搭建与APP打包速度问题

随着Flutter等跨端框架的出现,业务开发同学经常需要在Android/IOS上跨端进行业务开发,问题定位等。新的不熟悉的环境的搭建总会遇到各种各样的问题,导致搭建失败,特别是IOS开发环境,是最复杂的,不仅环境搭建繁琐,而且切分支后的打包速度很慢,所以我们设计实现了两个工具,用于优化闲鱼IOS开发体验。

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

iOS14 隐私适配及部分解决方案

在刚刚结束的线上 WWDC 2020 发布会上苹果向我们展示了新的 iOS14 系统。iOS14 的适配,很重要的一环就集中在用户隐私和安全方面。 在 iOS13 及以前,当用户首次访问应用程序时,会被要求开放大量权限,比如相册、定位、联系人,实际上该应用可能仅仅需要一个选择图片功能,却被要求开放整个照片库的权限,这确实是不合理的。对于相册,在 iOS14 中引入了 “LimitedPhotos Library” 的概念,用户可以授予应用访问其一部分的照片,对于应用来说,仅能读取到用户选择让应用来读取的照片,让我们看到了 Apple 对于用户隐私的尊重。这仅仅是一部分,在iOS14 中,可以看到诸多类似的保护用户隐私的措施,也需要我们升级适配。 最近在调研 iOS14的适配方案,本文主要分享一下 iOS14 上对于隐私授权的变更和部分适配方案,欢迎补充指正。

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

iOS 隐形水印之 LSB 实现

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

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

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

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

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

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

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

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

如何调试支付宝(iOS)

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

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

iOS GPUImage源码解读(一)

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

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

最多阅读

快速配置 Sign In with Apple 1年以前  |  3018次阅读
给数组NSMutableArray排序 1年以前  |  2270次阅读
开篇 关于iOS越狱开发 1年以前  |  2239次阅读
在越狱的iPhone设置上使用lldb调试 1年以前  |  2162次阅读
APP适配iOS11 1年以前  |  2152次阅读
UITableViewCell高亮效果实现 1年以前  |  2107次阅读
App Store 审核指南[2017年最新版本] 1年以前  |  1974次阅读
使用 GPUImage 实现一个简单相机 1年以前  |  1945次阅读
所有iPhone设备尺寸汇总 1年以前  |  1921次阅读
关于Xcode不能打印崩溃日志 1年以前  |  1854次阅读
使用ssh访问越狱iPhone的两种方式 1年以前  |  1827次阅读
使用ssh 访问越狱iPhone的两种方式 1年以前  |  1720次阅读
UIDevice的简单使用 1年以前  |  1608次阅读
为对象添加一个释放时触发的block 1年以前  |  1560次阅读
使用最高权限操作iPhone手机 1年以前  |  1495次阅读