iOS 控制器转场iOS 优秀实践

iOS中的屏幕导航

2018-03-20  本文已影响49人  忠橙_g

在本文中,我们将介绍在iOS应用程序中显示屏幕的不同方式。我们将从最简单的案例开始,最终完成一些高级场景。
简而言之,我们将添加更多的抽象层,使我们的导航解耦,并可以进行单元测试。
这里有一些代码示例
请注意,我们在这里描述的导航方式不一定是兼容的,同时使用所有这些可能是个坏主意。

UIViewController的Present转场

这是苹果鼓励的最基本的屏幕转场方式:



我们可以做以下的一件事:
1)手动创建一个UIViewController并跳转

private func presentViewControllerManually() {
    let viewController = DetailsViewController(detailsText: "My 
            details") { 
        [weak self] in
        self?.dismiss(animated: true)
    }
    self.present(viewController, animated: true)
}

2)从XIB或storyboard跳转UIViewController

    private func presentViewControllerFromXib() {
        let viewController = DetailsViewControllerFromIB(nibName: 
                "DetailsViewControllerFromXib", bundle: nil)
        viewController.detailsText = "My details"
        viewController.didFinish = { [weak self] in
            self?.dismiss(animated: true)
        }
        self.present(viewController, animated: true)
    }

    private func presentViewControllerFromStoryboard() {
        let viewController = UIStoryboard(name: 
                "DetailsViewControllerFromIB", bundle: nil)
        .instantiateInitialViewController() as! 
                 DetailsViewControllerFromIB
        viewController.detailsText = "My details"
        viewController.didFinish = { [weak self] in
            self?.dismiss(animated: true)
        }
        self.present(viewController, animated: true)
    }

3)performing segues + storyboard跳转

self.performSegue(withIdentifier: "detailsSegue", sender: nil)
...
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    guard segue.identifier == "detailsSegue" else { return }
    let viewController = segue.destination as! 
        DetailsViewControllerFromIB
    viewController.detailsText = "My details"
    viewController.didFinish = { [weak viewController] in
        viewController?.performSegue(withIdentifier: "unwind",
            sender: nil)
    }
}

其中第2种方法和第3种方法迫使我们使用两种不愉快的做法:
1)使用标识符字符串,如果在IB文件中修改了它(或者有一个地方输入错误),将在运行时崩溃。



我们可以使用第三方工具在编译时检查标识符的安全性来解决这个问题。

2)UIViewController需要两次初始化:这意味着UIViewController的属性必须设置为“ var”(变量),即使UIViewController没有设置这些属性就没有意义。



第一种方法,手动配置,在初始化UIViewController时手动创建添加,这样就可以使用'let'类型常数。



这种方法要求我们不使用Interface Builder并在代码中创建所有UI元素,这需要花更多的时间并且产生更多的代码,但这么做能产生最健壮的应用程序。

上面提到的几种方法仍然是一种有效的导航方式,但它们都有几个负面后果:
1)编写这样的跳转是不可能编写单元测试的。当然,我们可以用OCMock和手动初始化要跳转的UIViewController,但这种方法在Swift类中行不通,所以采取它是一种不好的习惯。
2)屏幕耦合:发起跳转的UIViewController明确地创建了需要跳转的UIViewController,所以它知道它是什么屏幕。如果在某些时候你想跳转到另一个View Controller,你可能要一个if语句来决定跳转的View Controller,甚至导致发起跳转的UIViewController需要承担更多的责任。
3)发起跳转的UIViewController知道当按钮被按下时发生了什么——跳转到新的屏幕了。因此,如果要在其他地方复用屏幕A,那么没有什么优雅的方法可以更改按钮按下时的事件。

可测试的解耦导航

我们使用依赖注入(Dependency Injection)和面向协议(protocol-oriented)的编程来解决上述的问题。
Dependency injections 允许我们解耦跳转的controller,让我们使用工厂封装并注入一个detailsViewController:

let detailsViewControllerProvider = { detailsText, didFinish in
        return DetailsViewControllerToInject(detailsText: 
            detailsText, didFinish: didFinish)
    }
examplesViewController.nextViewControllerProvider = detailsViewControllerProvider

然后我们就可以调用这个封装来跳转UIViewController:

private func presentInjectedViewController() {
    let viewController: UIViewController = 
    self.nextViewControllerProvider("My details") { [weak self] in
        self?.dismiss(animated: true)
    }
    self.present(viewController, animated: true)
}

所以现在我们唯一知道的是跳转到下一个屏幕,我们只须设置detailsText和didFinish的回调,然后使用这样的方式产生一些想要跳转的UIViewController。
使用协议(protocol)来覆盖实现的细节,使我们能能够将调用与实现分离,并能够测试代码。让我们用protocol覆盖上面的过程:

protocol ViewControllerPresenting {
    func present(_ viewControllerToPresent: UIViewController,   
        animated flag: Bool, completion: (() -> Swift.Void)?)
    func dismiss(animated flag: Bool, completion: (() -> 
        Swift.Void)?)
}

让UIViewController遵循协议protocol:

extension UIViewController: ViewControllerPresenting { }

像这样把要跳转的UIViewController封装:

let presenterProvider = { [unowned examplesViewController] in 
    return examplesViewController
}
examplesViewController.presenterProvider = presenterProvider 
// presenterProvider will return examplesViewController itself

编写测试代码:

let mockPresenter = MockViewControllerPresenter()
examplesViewController.presenterProvider = {
    return mockPresenter
}
// When select cell causing presentation
examplesViewController.tableView(examplesViewController.tableView, 
    didSelectRowAt: IndexPath(row: 1, section: 1))
// Then presented view controller is the injected VC
let vc = mockPresenter.invokedPresentArguments.0
XCTAssertTrue(vc is DetailsViewControllerToInject)

如果你仔细想想,我们没有改变跳转的方式;发起跳转的仍然是UIViewController。该解决方案的美在UIViewController(self)可以脱离特定的目标对象发起跳转。如果您想更改视图控制器的跳转方式,或者为了测试此代码,这种方式就能允许你注入自定义的跳转方式。


特定情况的导航

一个问题仍然没有答案,我们如何用self.navigationViewController并测试push跳转?事实上,苹果鼓励隐藏跳转的细节,这就是为什么建议使用-showViewController-showDetailsViewController。所以,我建议你可以在你的app的protocol中以同样的方式封装presentViewController方法,或者引入一个精巧的导航API。让我们试着实施第二种方法。
声明要在协议(protocol)中支持的跳转类型:

protocol ViewControllerPresentingWithNavBar:   
             ViewControllerPresenting {
    func presentWithNavigationBar(_ controller: UIViewController,
            animated: Bool, completion: (() -> Void)?)
    func dismissRespectingNavigationBar(animated: Bool, 
            completion: (() -> Void)?)
}

实现UIViewController的协议,如果有必要创建NavigationController :

public func presentWithNavigationBar(_ controller: UIViewController, animated: Bool, completion: (() -> Void)?) {
    if let navigationController = navigationController {
        navigationController.pushViewController(controller, 
             animated: animated, completion: completion)
    } else {
        let navigationController = 
            UINavigationController(rootViewController: controller)
        self.present(navigationController, animated: animated, 
            completion: completion)
        let button = UIBarButtonItem(barButtonSystemItem: .cancel, 
            target: self, action: #selector(userInitiatedDismiss))
        controller.navigationItem.leftBarButtonItem = button
    }
}

封装要跳转的一个UIViewController:

let presenterWithNavBarProvider = { [unowned examplesViewController] in
    return examplesViewController
}
examplesViewController.presenterWithNavBarProvider =   
    presenterWithNavBarProvider

用法简单明了:

private func presentDetailsWithNavigationBar() {
    let presenter = self.presenterWithNavBarProvider()
    let viewController = self.nextViewControllerProvider(
        "My details", didFinish: { [weak self, weak presenter] in
        presenter?.dismissRespectingNavigationBar(animated: true,  
            completion: nil)
    })
    presenter.presentWithNavigationBar(viewController, 
        animated: true, completion: nil)
}

使用ActionHandlers封装事件

现在,我们来解决最后一个问题:我们希望通过引入另一个协议来隐藏用户单击按钮后正在发生的事情的细节:

protocol ActionHandling {
    func handleAction(for detailText: String)
}

创建ActionHandling:

class ActionHandler: ActionHandling {
    private let presenterProvider: () -> ViewControllerPresenting
    private let detailsControllerProvider: (detailLabel: String, 
         @escaping () -> Void) -> UIViewController
    init(presenterProvider: @escaping () -> UIViewController, 
        detailsControllerProvider: @escaping(String, @escaping () 
        -> Void) -> UIViewController) {
        self.presenterProvider = presenterProvider
        self.detailsControllerProvider = detailsControllerProvider
    }
…

然后将界面跳转的代码移到这里:

func handleAction(for detailText: String) {
        let viewController = detailsControllerProvider(detailText) { 
            [weak self] in
            self?.presenterProvider().dismiss(animated: true, 
                completion: nil)
        }
        presenterProvider().present(viewController, animated: true, 
            completion: nil)
    }
}

这样,在ViewController中只需要这样:

private func handleAction() {
        self.actionHandler.handleAction(for: "My details")
    }

在实际的开发中,你可能希望你的ViewModel成为ActionHandler。如果你这样做,这意味着ViewModel将会和UIViewController耦合。这是相当糟糕的,因为它一方面违背清洁架构(Clean Architecture)的依赖准则,另一方面,我们可以考虑ViewModel作为UI和用例(业务逻辑)之间的中介,所以它会和各个部分耦合。
如果我们不想让UIViewController和ViewModel过度耦合,我们可以创建一个ScreenPresenting协议:

protocol ScreenPresenting {
    func presentScreen(for detailText: String, 
        didFinish: @escaping () -> Void)
    func dismissScreen()
}

在ViewModel中这样使用:

class MyViewModel: ActionHandling {
    let screenPresenter: ScreenPresenting
    init(screenPresenter: ScreenPresenting) {
        self.screenPresenter = screenPresenter
    }
    func handleAction(for detailText: String) {
        screenPresenter.presentScreen(for: detailText, didFinish: {  
             [weak self] in
            self?.screenPresenter.dismissScreen()
        })
    }
}

本质上ScreenPresenting和ActionHandler没有太大差异,但是我们只是增加了一个抽象层来避免UIViewControllers和ViewModel耦合。


模块间的导航

一种可行的方法时使用Flow Coordinators进行协作开发。下面来详细探讨一下Flow Coordinators:


通常,最初的FlowCoordinator 应该由AppDelegate持有并启动:

func application(_ application: UIApplication, 
        didFinishLaunchingWithOptions launchOptions: 
        [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    self.window = UIWindow(frame: UIScreen.main.bounds)
    self.window?.rootViewController = UIViewController()
    self.window?.makeKeyAndVisible()
    self.appCoordinator = AppCoordinator(rootViewController: 
        self.window?.rootViewController!)
    self.appCoordinator.start()
    return true
}

Flow Coordinator可以拥有和启动子Flow Coordinator:

func start() {  
    if self.isLoggedIn {
        self.startLandingCoordinator() 
    } else {  
        self.startLoginCoordinator()
    }  
}

Flow Coordinator允许我们组织不同抽象级别的模块之间的协作,例如MVC和VIPER模块的Flow Coordinator将具有相同的API。
关于Flow Coordinators的重要警告是,它们将迫使你维护与UI层次结构平行的FlowCoordinator的层次结构。这可能会出现问题,因为UIViewControllers和ViewModel并没有持有Flow Coordinators,你必须非常谨慎,以确保当UIViewController结束使用时,Flow Coordinator仍然存在的情况不会发生。

这里有一个测试Flow Coordinators两个部分的教程。
也可以逐步过渡到Flow Coordinator,这意味着你的第一个Flow Coordinator可能会代替UIAppDelegate持有你的UIViewController或者ViewModel/Presenter。这样,你可以在你的新功能中添加 Flow Coordinator,而不必重构整个应用程序。

对Deeplink或者推送通知的处理

这两个问题可以归纳为对一个集中式导航系统的需求。通过一个集中的系统,我的意思是一个实体,它知道当前的导航栈,并且可以对它进行整体操作。

根据我的观察,在创建一个集中式导航系统时,有几个规则是必须遵守的:
1)由导航系统跳转的界面不应该打乱现有的窗口导航。
2)导航系统介入时,不应该阻止UIViewController原有的导航。

该系统可以解决以下两个问题:
1)跳转到推送或者外部链接对应的界面(或层次结构)。
2)处理优先级(判断是否要中断当前界面并打开推送或者外部链接)。

打开一堆屏幕(界面)。

完成这项任务的一个原始版本如下:
1)弹出到根视图控制器(RootViewController)
2)依次跳转一些视图控制器(ViewController)

final class Navigator: Navigating {
    func handlePush() {
        self.dismissAll { [weak self] in
            self?.presentTwoDetailsControllers()
        }
    }
...
}

PresentTwoDetailsControllers可能看起来像这样:

private func presentTwoDetailsControllers() {
    let viewController = self.controllerForDetailsProvider(
        "My details") { [weak self] in
        self?.navigationController.dismissRespectingNavigationBar(
            animated: true, completion: nil)
    }
    self.navigationController.presentWithNavigationBar(
        viewController, animated: true, completion: { [weak self] in
        guard let sSelf = self else { return }
        let viewController2 = sSelf.controllerForDetailsProvider(
            "My another details") { [weak viewController] in
            viewController?.dismissRespectingNavigationBar(
                animated: true, completion: nil)
        }
        viewController.presentWithNavigationBar(viewController2,
            animated: true, completion: nil)
    })
}

正如你所看到的,这种方法是不可扩展的,因为它需要手动处理每一种情况。实现这种可扩展性的一种方法是基于图表构建一个更复杂的系统。
尝试下面的步骤:
1)根据实际需求,建立两个UIViewControllers树(一个是当前界面中显示的VC栈,一个是从根VC跳转到所需展示VC的路径栈)。
2)推出UIViewControllers直到实际层次(界面中的VC栈)是需求层次(目标的VC栈)的子集。
3)根据所需的层次结构跳转,直到所需的ViewController。


这种方法需要独立创建和跳转屏幕的能力。因此,如果屏幕不是直接通过服务进行通信,那么开发这样的系统要容易得多。有多种方法映射Deeplink,并根据层次结构进行跳转。例如这篇文章

处理阻塞模式

通常,你的应用程序可能需要等待交互,直到用户输入PIN或确认某些信息为止。
系统必须以特定的方式处理这些类型的屏幕,以满足产品的需求。
如果存在阻塞屏幕,那么愚蠢的解决方案可能是直接忽略任何更改层次结构的请求。

func handlePush() {
    guard self.hasNoBlockingViewController() else { return }
    self.dismissAll { [weak self] in
        self?.presentTwoDetailsControllers()
    }
}
private func hasNoBlockingViewController() -> Bool {
    // return false if any VC in hierarchy is considered to be a   
       blocking VC
    return true
}

更先进的方法是将优先级与屏幕相关联,并以不同的优先级处理不同的屏幕。确切的解决方案将取决于你的需求,也可能很简单,比如不显示具有较低优先级的屏幕,除非在层次结构中有更高优先级的屏幕。
或者,你可能希望根据它们的优先级来显示模式屏幕:显示一个优先级最高的屏幕,并在堆栈中保持休息,直到最后一个被删除。

总结

在这篇文章中,我分享了iOS上屏幕跳转的一些想法,以及你可能需要在应用程序中解决的问题。你可能已经注意到,最棘手的部分是对推送和Deeplink中断的处理,所有这一切都需要在特定的情况下所有的场景进行深入的考虑,这就是为什么没有一个对这些问题的第三方解决方案。

翻译自:Screen navigation in iOS

上一篇 下一篇

猜你喜欢

热点阅读