Imperio 3.0.0

Imperio 3.0.0

Cihat Gündüz维护。



Imperio 3.0.0

Build Status codebeat badge Version: 3.0.0 Swift: 4.2 Platforms: iOS | tvOS License: MIT

安装使用方法问题贡献许可

Imperio

该库的目的是通过将界面流程和其他职责从视图控制器中移出,以保持视图控制器简洁并使其易于测试。相反,流程控制器用于处理界面流程并触发视图中的更改,视图控制器处理这些更改。从模式上讲,此方法结合了 MVC、MVP、MVVM、VIPER 以及 Lotus 的思想。

安装

目前正在支持通过 CarthageCocoaPods 安装。

由于该框架使用了UIKit,因此目前不支持SPM。

使用方法

以下提供如何使用 Imperio 的逐步指导。还有一个示例项目,您可以检查其结构。下面的解释中的示例代码都是来自示例项目。

FlowController

使用Imperio的第一步是放松身体,花点时间思考一下你的屏幕流程。你不需要认出每个屏幕,而是应该集中精力思考哪些用例你需要考虑,并为每个用例简单地写一个流程控制器。例如,入职或教程可以是一个屏幕流程,尽管它可能包含一个或多个屏幕和控制器。

一旦你有一张初始的屏幕流程列表(或第一个),给它命名,并使用该名称编写一个子类。让我们创建一个用于管理首次启动应用程序的教程的流程控制器。流程控制器必须是FlowController的子类。

import Imperio

class TutorialFlowController: FlowController {
    // TODO: not yet implemented
}

start(from:)方法

每个协调器的子类都需要覆盖至少一个start方法,这个方法打开屏幕流程的初始视图控制器。例如

import Imperio

class TutorialFlowController: FlowController {
    private var navigationCtrl: UINavigationController?
    
    override func start(from presentingViewController: UIViewController) {
        let page1ViewCtrl = Page1ViewController()
        navigationCtrl = UINavigationController(rootViewController: page1ViewCtrl)
        
        // TODO: set up the flow delegate
        
        presentingViewController.present(navigationCtrl!, animated: true)
    }
}

请注意,在start方法中你应该

  • 初始化你的第一个视图控制器
  • 设置流程委托(稍后将进行解释)
  • 显示你的第一个视图控制器

子流程控制器

现在,每当你想从一个流程控制器内部开始你的屏幕流程时,你可以这样做

func tutorialStartButtonPressed() {
    let tutorialFlowCtrl = TutorialFlowController()
    add(subFlowController: tutorialFlowCtrl)
    tutorialFlowCtrl.start(from: someViewController!)
}

请注意,这基本上就像向UIView添加一个子视图一样,使用myView.addSubview(subview):你添加一个子流程控制器并启动它。一旦你完成了屏幕流程,你将取消显示最后一个视图控制器,并通过调用removeFromSuperFlowController()从其超流程控制器中移除子流程控制器。例如

func completeButtonPressed() {
    navigationCtrl?.dismiss(animated: true) {
        self.removeFromSuperFlowController()
    }
}

InitialFlowController

对于流程控制器有一个特殊情况:在应用程序启动时要启动的第一个屏幕流程。由于在应用程序启动时没有要显示的视图控制器,我们无法使用要求视图控制器的start(from:)方法。相反,如果你正在定义初始流程控制器,你需要进行三个小的更改

  1. 将子类改为InitialFlowController而不是FlowController
  2. 覆盖start(from window: UIWindow)而不是start(from presentingViewController: UIViewController)
  3. 设置windowrootViewController而不是显示第一个视图控制器。

这就是所有不同之处。以下是与这些更改相对应的上述示例的示例

import Imperio

class TutorialFlowController: InitialFlowController {
    private var navigationCtrl: UINavigationController?
    
    override func start(from window: UIWindow) {
        let page1ViewCtrl = Page1ViewController()
        navigationCtrl = UINavigationController(rootViewController: page1ViewCtrl)
        
        // TODO: set up the flow delegate
        
        window.rootViewController = navigationCtrl
    }
}

Flow Delegate

为了让流程控制器得到任何用户操作的通知,视图控制器需要定义可能出现操作的类协议。这通常应该在视图控制器类文件的最顶部进行。然后在你定义的视图控制器中,定义一个带有协议类型的weak var flowDelegate属性。例如

protocol Page1FlowDelegate: class {
    func nextToPage2ButtonPressed()
}

class Page1ViewController: UIViewController {
    weak var flowDelegate: Page1FlowDelegate?

    // TODO: action not yet implemented
}

请注意,操作名称不应包含关于屏幕流程的任何语义:始终使用nextToPage2ButtonPressed(),这是用户实际执行的操作,而不是showNextScreen,因为它已经包含了对下一步操作的语义。由流程控制器决定下一步要做什么,而不是视图控制器!

当然,当操作完成后,我们需要调用我们委托的方法,以协调将下一步做什么的责任传递给流程控制器

class Page1ViewController: UIViewController {
    // ...

    @IBAction func nextButtonPressed() {
        flowDelegate?.nextToPage2ButtonPressed()
    }
}

最后,流程控制器需要对这些委托方法做出反应。这是一个两步的过程。第一步是使流程控制器遵守《Page1FlowDelegate》协议

extension TutorialFlowController: Page1FlowDelegate {
    func nextToPage2ButtonPressed() {
        let page2ViewCtrl = Page2ViewController()
        navigationCtrl?.pushViewController(page2ViewCtrl, animated: true)
    }
}

第二步是将流程控制器设置为视图控制器的流程代理。对于初始视图控制器,这需要在start(from:)方法中完成。因此,让我们像这样替换其中的TODO

override func start(from presentingViewController: UIViewController) {
    // ...
    
    page1ViewCtrl.flowDelegate = self

    // ...
}

那就行了吗。一切设置已经完成,现在应该可以工作了。流程控制器管理屏幕流程!

常见问题解答

如何从AppDelegate启动初始流程控制器?

下面是一个例子,展示它可能的样子

import Imperio
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?
    var initialFlowController: InitialFlowController?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow(frame: UIScreen.main.bounds)
        window?.makeKeyAndVisible()

        initialFlowController = MainFlowController()
        initialFlowController?.start(from: window!)

        return true
    }
}

请注意,您需要在窗口上调用makeKeyAndVisible()。否则您可能只会看到一个黑色屏幕。另外,确保您继承自InitialFlowController而不是FlowController。请参考上面的《InitialFlowController》部分,了解如何进行此操作。

如何在不同流程控制器之间传递数据?

这里有两种不同的情况

  • 将数据传递到流程控制器中
  • 将数据 返回 到超级流程控制器

第一种情况很简单:给流程控制器添加一个属性并传递给它,然后在它的 init 方法中添加一个参数。换句话说:只需使用 Swift。例如

class EditProfileFlowController: FlowController {
    private let profile: Profile

    init(profile: Profile) {
        self.profile = profile
        super.init()
    }
}

第二种情况需要更多的工作。给流程控制器添加一个返回值的属性,并给它 init 方法添加一个参数。换句话说:与上面做完全相同的事情。但这次,它是一个闭包。例如

class ImagePickerFlowController: FlowController {
    typealias ResultClosure = (UIImage) -> Void

    let resultCompletion: ResultClosure

    init(resultCompletion: @escaping ResultClosure) {
        self.resultCompletion = resultCompletion
        super.init()
    }
    
    // ...
}

由于 ResultClosure 参数是一个逃逸闭包,所以在调用初始化器时通常会使用 [weak self][unowned self],然后将它们映射到如 strongSelf 之类的东西,以防止循环引用和内存泄漏。Imperio 有更好的处理这种情况的方法,只需将您的代码更改为如下

class ImagePickerFlowController: FlowController {
    let resultCompletion: SafeResultClosure<UIImage>

    init(resultCompletion: SafeResultClosure<UIImage>) {
        self.resultCompletion = resultCompletion
        super.init()
    }
    
    // ...
}

那么使用方面看起来是这样的

func imagePickerStartButtonPressed() {
    let resultCompletion = SafeResultClosure<UIImage>(weak: self) { (self, pickedImage) in
	       // do something with the result
    }

    let imagePickerFlowCtrl = ImagePickerFlowController(resultCompletion: resultCompletion)
    add(subFlowController: imagePickerFlowCtrl)
    imagePickerFlowCtrl.start(from: mainViewController!)
}

SafeResultClosure 是一个包装器,您传递 self 的强引用到其中,并从其中获得作为第一个参数的 self 的强引用。当 self 不为 nil 时,将调用闭包。这样,您可以避免在闭包参数列表中编写 [weak self][unowned self],因此默认情况下获得安全性。

SafeResultClosure 是 Imperio 的一部分,是实现在此处描述的委托模式的实现(https://medium.com/anysuggestion/preventing-memory-leaks-with-swift-compile-time-safety-49b845df4dc6).

如何在不同流程控制器和它的视图控制器之间传递数据?

这里有两种不同的情况

  • 将数据 传递 到视图控制器
  • 将数据 返回 到流程控制器

对于将数据传递到视图控制器,我们建议使用表示视图状态的 struct,我们称它们为 ViewModel。这里有一个简单的视图模型

struct MainViewModel {
    let backgroundColor: UIColor
    var pickedImage: ObservableProperty<UIImage?>
}

请注意,对于不会变化的属性,我们直接使用 let 和类型。对于可能会随时间变化的属性,我们使用 ObservableProperty 包装器。它是 Imperio 的一部分,允许视图控制器订阅属性的任何变化并相应反应。只需将您的视图模型嵌入您的视图控制器中,如下所示

class MainViewController: UIViewController {
    // ...
    var viewModel: MainViewModel?
    // ...
}

现在在您的 viewDidLoad() 方法中,您可以直接使用常量属性,并像这样观察变量属性

@IBOutlet private var pickedImageView: UIImageView!

override func viewDidLoad() {
    super.viewDidLoad()

    view.backgroundColor = viewModel?.backgroundColor

    viewModel?.pickedImage.didSet(weak: self) { (self, pickedImage) in
        self.pickedImageView.image = pickedImage
    }
}

传递到 didSet() 的强 self 安全地作为闭包的参数返回为强 self。(它由 Imperio 内部自动转换为弱 self。)

当您想修改 pickedImage 属性时,只需像这样使用 ObservablePropertysetValue() 方法

mainViewController.viewModel.pickerImage.setValue(pickedImage)

至于第二种情况——将数据返回给流程控制器——只需向您的流程代理方法添加参数。例如

protocol AddressListFlowDelegate: class {
    func didSelectEntry(at index: NSIndexPath)
    func searchFieldTextChanged(to text: String)
}

现在我的视图控制器已经很精简了,我该如何测试它们?

如果你遵循了我们的建议,创建了一个定义视图控制器视图状态的模型视图,那么以下是做法的步骤

  • 初始化一个视图控制器。
  • 将其 viewModel 属性设置为你想要测试的状态。
  • 对视图控制器的 view 属性进行快照,并验证它没有变化。

最后一步是通过框架 FBSnapshotTestCase 实现的。以下是从演示项目中一个完整的例子

import Bond
import FBSnapshotTestCase
@testable import Imperio_Demo
import UIKit

class MainViewControllerTests: FBSnapshotTestCase {
    override func setUp() {
        super.setUp()
        self.recordMode = false
    }

    func testRedBackgroundWithHogwartsImage() {
        let mainViewController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as? MainViewController
        mainViewController?.viewModel = MainViewModel(backgroundColor: .red, pickedImage: ObservableProperty(#imageLiteral(resourceName: "hogwarts")))
        FBSnapshotVerifyView(mainViewController!.view)
    }
}

我该如何处理像 UINavigationController 或 UITabBarController 这样的容器视图控制器?

如果你遇到那些已经封装了一部分屏幕流程的类型,请不要试图将它们强制融入到这里建议的结构中。也不要只是为了从流程控制器中提取处理代理而创建视图控制器作为包装器。在某些情况下,偏离传递数据或职责分离的方式是完全有效的。例如,这是有效的

class ImagePickerFlowController: FlowController {
    override func start(from presentingViewController: UIViewController) {
        presentingViewController.present(instantiateSourceChooser(from: presentingViewController), animated: true)
    }

    func instantiateSourceChooser(from viewController: UIViewController) -> UIAlertController {
        let alertCtrl = UIAlertController(title: "Choose source.", message: "How do you want to choose your image?", preferredStyle: .actionSheet)

        alertCtrl.addAction(UIAlertAction(title: "Camera", style: .default) { [unowned self] _ in
            self.startCamera(from: viewController)
        })

        alertCtrl.addAction(UIAlertAction(title: "Albums", style: .default) { [unowned self] _ in
            self.startImagePicker(from: viewController)
        })

        alertCtrl.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [unowned self] _ in
            self.removeFromSuperFlowController()
        })

        return alertCtrl
    }
}

UIAlertController 类已经封装了它的渲染方式。我们在这里只是传递一些数据,而且不能为它创建模型视图,因为控制器的 API 定义不同(使用 addAction 方法)。或者另一个例子

class ImagePickerFlowController: FlowController {
    // ...

    func startCamera(from viewController: UIViewController) {
        let imagePicker = UIImagePickerController()
        imagePicker.sourceType = .camera
    
        imagePicker.delegate = self
        viewController.present(imagePicker, animated: true)
    }
    
    func startImagePicker(from viewController: UIViewController) {
        let imagePicker = UIImagePickerController()
        imagePicker.sourceType = .savedPhotosAlbum
    
        imagePicker.delegate = self
        viewController.present(imagePicker, animated: true)
    }
}

extension ImagePickerFlowController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        picker.dismiss(animated: true) {
            self.removeFromSuperFlowController()
        }
    }

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : Any]) {
        if let pickedImage = info[UIImagePickerControllerOriginalImage] as? UIImage {
            resultCompletion.reportResult(result: pickedImage)
            picker.dismiss(animated: true) {
                self.removeFromSuperFlowController()
            }
        }
    }
}

reportResult() 方法是 SafeResultClosure 的一部分,必须在其结果可用时使用来报告结果。

我们在流程控制器中直接处理图像选择器源类型及其代理方法。不需要创建额外的类型来从流程控制器中提取它们。UIImagePickerController 已经是一个经过良好测试的视图控制器,我们只需遵守其接口。同样适用于 UITabBarControllerUINavigationControllerUISplitViewController

我该如何(进一步)防止“大规模视图/流程控制器”问题?

让您的 ViewController 只处理视图逻辑,将流程控制移到 FlowController,这是一个重要的第一步。但你可能会发现自己处于这样的情况,许多之前属于 ViewController 的代码现在Moving进入你的 FlowController,或者由于复杂的 UI 处理,你的 ViewController 仍然很庞大。

为了避免这些问题,我们可以应用来自 Lotus 模式的一个想法:ModelController

ModelControllers可以承担将数据带到视图控制器中,而非FlowControllers的责任。ModelControllers分为两种:共享的和视图特定的。

共享ModelControllers的典型责任包括

  • 管理网络请求(API、Logger、Analytics)
  • 访问存储系统(文件、Core Data、UserDefaults)
  • 读取传感器数据(GPS、陀螺仪、加速度计)

特定ModelControllers的典型责任包括

  • 实现UITableViewDataSource & UICollectionViewCellDataSource
  • 复杂视图控制器逻辑的状态机

共享ModelControllers通常全局可达,可以使用单例模式。因此,视图控制器和FlowControllers都可以使用它们。视图特定的ModelControllers通常只需要用于更复杂的视图控制器,并且由它们创建,同时也对它们持有强引用。

因此,在需要的地方使用ModelControllers,以防止巨量的Flow/视图控制器问题。

贡献

参阅CONTRIBUTING.md文件。

许可协议

本库遵循MIT许可协议。有关详细信息,请参阅LICENSE文件。