iOS中的螢幕導航

weixin_34208283發表於2018-03-20

4095437-cc4ea8cf830b38ad.png

在本文中,我們將介紹在iOS應用程式中顯示螢幕的不同方式。我們將從最簡單的案例開始,最終完成一些高階場景。
簡而言之,我們將新增更多的抽象層,使我們的導航解耦,並可以進行單元測試。
這裡有一些程式碼示例
請注意,我們在這裡描述的導航方式不一定是相容的,同時使用所有這些可能是個壞主意。

UIViewController的Present轉場

這是蘋果鼓勵的最基本的螢幕轉場方式:


4095437-998378e432ffd7e2.png

我們可以做以下的一件事:
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檔案中修改了它(或者有一個地方輸入錯誤),將在執行時崩潰。


4095437-5c7e9f3bb19fd2ff.png

4095437-4acfcd043f875150.png

我們可以使用第三方工具在編譯時檢查識別符號的安全性來解決這個問題。

2)UIViewController需要兩次初始化:這意味著UIViewController的屬性必須設定為“ var”(變數),即使UIViewController沒有設定這些屬性就沒有意義。


4095437-429656d47f377495.png

第一種方法,手動配置,在初始化UIViewController時手動建立新增,這樣就可以使用'let'型別常數。


4095437-0f131293cff29406.png

這種方法要求我們不使用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)可以脫離特定的目標物件發起跳轉。如果您想更改檢視控制器的跳轉方式,或者為了測試此程式碼,這種方式就能允許你注入自定義的跳轉方式。


4095437-c7db681dce963d97.png

特定情況的導航

一個問題仍然沒有答案,我們如何用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耦合。


4095437-bbd714f775c007ac.png

模組間的導航

一種可行的方法時使用Flow Coordinators進行協作開發。下面來詳細探討一下Flow Coordinators:


4095437-9f98db9a2c1df2f8.png
  • Flow Coordinator是模組的對外介面和入口點。
  • 一個模組可能是一組物件,可以是UIViewController、ViewModel/Presenter、Interactor或者Services。
  • 一個模組不一定有UI。

通常,最初的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。


4095437-b41a80b45a57df6b.png

這種方法需要獨立建立和跳轉螢幕的能力。因此,如果螢幕不是直接通過服務進行通訊,那麼開發這樣的系統要容易得多。有多種方法對映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

相關文章