如何在情节提要管理的 UIViewControllers 中进行依赖注入?

Posted

技术标签:

【中文标题】如何在情节提要管理的 UIViewControllers 中进行依赖注入?【英文标题】:How to dependency inject in storyboard managed UIViewControllers? 【发布时间】:2019-03-24 03:22:42 【问题描述】:

大家好,我正在尝试测试我的项目的 ViewController 之一。该类依赖于另一个帮助类,如下所示:

private let dispatcher: Dispatcher = Dispatcher.sharedInstance
private var loginSync = LoginSync.sharedInstance
private var metadataSync = MetadataSync.sharedInstance

这些帮助类在 UIViewController 生命周期中使用,例如 viewDidLoad 或 viewWillAppear。在我的测试中,我使用 UIStoryboard 类实例化 ViewController 类,如下所示:

func testSearchBarAddedIntoNavigationViewForios11OrMore() 
    // Given a YourFlow ViewController embedded in a navigation controller
    let mockLoginSync = MockLoginSync()
    let storyboard = UIStoryboard(name: "Main", bundle: nil)

    // Here is too early and view controller is not instantiated yet and I can't assign the mock.
    let vc = storyboard.instantiateViewController(withIdentifier: "YourFlow")
    // Here is too late and viewDidLoad has already been called so assigning the mock at this point is pointless.
    let navigationController = UINavigationController(rootViewController: vc)

    // Assertion code

所以我的问题是我需要能够模拟 LoginSync 类。在正常情况下,我会通过将这些助手作为参数传递给类构造函数来使用常规依赖注入。在那种情况下,我不能这样做,因为我没有管理 View Controller 生命周期。所以一旦我实例化它,助手就已经被使用了。

我的问题是:“有没有办法为我们无法控制其生命周期的视图控制器进行依赖注入,或者至少是一种解决方法?

谢谢。

编辑:之所以调用 viewDidLoad,是因为我在 didSet 覆盖方法中使用了 IBOutlets,而不是因为调用了 instantiateViewController。因此,我可以在正确实例化视图控制器后将该代码移开并进行注入。

【问题讨论】:

【参考方案1】:

您需要使用 segues 导航到您的视图控制器,以便您可以在 prepareForSegue 期间注入依赖项,如下所示:

override func prepareForSegue(segue: UIStoryboardSegue!, sender: AnyObject!) 
    if (segue.identifier == "SomeSegueToYourFlow") 
        if let yourFlowVC = segue.destination as? YourFlowController 
            let mockLoginSync = MockLoginSync()
            yourFlowVC.loginSync = mockLoginSync
        
    

【讨论】:

你将如何在测试中应用这种方法?【参考方案2】:

情节提要中的视图控制器始终使用init?(coder aDecoder: NSCoder) 进行初始化,因此无法在初始化时设置任何属性。

我发现以下是一个很好的解决方法……

而不是使用

let loginSync: LoginSync

声明为

private (set) var loginSync: LoginSync!

声明

func configure(loginSync: LoginSync) 
    self.loginSync = loginSync

然后

let vc = storyboard.instantiateViewController(withIdentifier: "YourFlow")
vc.configure(loginSync: MockLoginSync())

你也可以在 segues 中使用它……

override func prepare(for segue: UIStoryboardSegue, sender: Any?) 
    switch segue.destination) 
    case let vc as MyViewController:
        vc.configure(loginSync: MockLoginSync())
    default:
        break
    

这并不完美,但是设置属性private (set) 可以确保它不能从另一个类中修改,并且隐式展开 (!) 意味着如果未设置它会导致崩溃。

在每个 UIView/UIViewController 中使用 configure() 方法 - 一旦你习惯了这种模式,它就会成为第二天性。

【讨论】:

我仍然可以在您的方法中发现与我的方法相同的问题:调用 instantiateViewController(withIdentifier:) 方法会自动调用 viewDidLoad()。这是调用 loginSync 的地方。所以在那个电话之后为时已晚。我正在寻找的是一种实例化 IB 管理的 UIViewController 的方法,而无需立即调用其 viewDidLoad 不,这是不正确的。 instantiateViewController(withIdentifier:) not 是否强制 viewDidLoad 也被调用。如果它,那么你正在做其他事情,迫使它发生。请将发生这种情况的视图控制器的源添加到您的问题(显示问题的最小值 哦,这很有趣。我在 viewDidLoad 中有一个断点,每次运行测试时它都会停止。实际上,我正在覆盖一个 IBOutlet 的 didSet 方法。是否调用 viewDidLoad 有何影响? 是的,你是对的。我已经注释掉了代码并且 viewDidLoad 还没有被调用。这让事情变得更容易。非常感谢! 实用提示...如果您需要覆盖属性的 didSet 但又不想强制加载视图,可以先检查视图控制器的 isViewLoaded 属性【参考方案3】:

你可以像这样包裹UIVIewControllerCreation

class func createWith(injection: YourInjection) -> YourViewController 
    let storyboard = UIStoryboard(name: "Main", bundle: nil)
    let vc = storyboard.instantiateViewController(withIdentifier: "YourVCId") as? YourViewController
    vc.injected = injection
    return vc

并像这样使用它:

let vc = YourViewController.createWith(<your injection>)

这是一个例子:

class ViewController: UIViewController 

    override func viewDidLoad() 
        super.viewDidLoad()
    

    override func viewDidAppear(_ animated: Bool) 
        super.viewDidAppear(animated)

        let vc = RedViewController.createWith(injection: "some")
        navigationController?.pushViewController(vc, animated: true)
    



class RedViewController: UIViewController 
    var injected: String = "" 
        didSet 
            print(#function)
        
    

    override func viewDidLoad() 
        super.viewDidLoad()
        view.backgroundColor = .red
        print(#function)
    

    class func createWith(injection: String) -> RedViewController 
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let vc = storyboard.instantiateViewController(withIdentifier: "Red") as! RedViewController
        vc.injected = injection
        return vc
    

故事板设置:

代码运行结果打印:

injected
viewDidLoad()

如您所见,注入发生在 viewDidLoad() 之前

【讨论】:

那个方法还是有同样的问题:“调用instantiateViewController()方法会触发viewDidLoad,我用的是注入的类,所以已经太晚了。 对不起,你错了。用代码示例更新我的答案。 你是对的。我错了,因为其他同事已经帮助我发现了我的错误。你的回答也有道理。谢谢。 @fewlinesofcode 我认为你的方法createWith 必须是静态的。【参考方案4】:

iOS 13、Xcode 11 解决方案

您可以利用instantiateViewController(identifier:creator:) 来实现您想要实现的目标。只需在 init? 初始化程序中命名所有依赖项,然后使用 instantiateViewController 方法来初始化您的 ViewController。这样做的好处是您可以将变量保密:

class MyViewController: UIViewController 
    private let networkManager: MyNetworkManager

    // Name your dependencies here
    init?(coder: NSCoder, networkManager: MyNetworkManager) 
        self.networkManager = networkManager
        super.init(coder: coder)
    

    required init?(coder: NSCoder) 
        fatalError("NetworkManager is missing")
    
    
    /// Returns an instance of the MyViewController with dependency injection
    static func createFromStoryBoardWith(
        networkManager: MyNetworkManager)
        -> MyViewController
    
        let storyboard = UIStoryboard(name: "MyViewControllerStoryboard", bundle: nil)
        let viewController = storyboard.instantiateViewController(identifier: "MyViewControllerStoryboardID", creator:  coder in
            MyViewController(coder: coder, networkManager: networkManager) // The magic happens here
        )
        return viewController
    

NetworkManager 是一个简单的结构:

struct MyNetworkManager 
    var id: String

你可以像这样使用它:

let networkManager = MyNetworkManager(id: "ABCDE")
let myViewController = MyViewController.createFromStoryBoardWith(networkManager: networkManager)

【讨论】:

以上是关于如何在情节提要管理的 UIViewControllers 中进行依赖注入?的主要内容,如果未能解决你的问题,请参考以下文章

管理登录状态+登录视图(使用情节提要)的正确方法是啥?

如何从情节提要中隐藏 UISearchbar

Xcode 8 - 如何在情节提要中完全实现 UIPageViewController?

如何在情节提要中为uilabel设置大于300的字体大小?

watchOS 如何找到主情节提要文件?

watchOS 如何找到主情节提要文件?