Cleanse icon indicating copy to clipboard operation
Cleanse copied to clipboard

An example of injection within SceneDelegate that supports SwiftUI (+ thinking)

Open DJBen opened this issue 4 years ago • 9 comments

Hi 👋 as iOS app is moving towards multi-window support from iOS 13 an onwards, the example that injects window to the AppDelegate no longer the recommended way. Given that the rising use case of SwiftUI, I made an example that supports SwiftUI and uses SceneDelegate as the root object of the injection.

https://github.com/DJBen/SceneDelegateInjectionExample

I am still unconvinced if AppDelegate still should be the root object, but doing so is difficult - the lifecycle of AppDelegate and SceneDelegate seem to be opaque and they do not seem to be dependent on each other. Let me know if I was wrong, but as of today I think making the SceneDelegate root object that instantiates the window makes more sense.

DJBen avatar Feb 05 '20 18:02 DJBen

@DJBen You bring up a good point about root setup for iOS 13+ since we're now dealing with more objects (UIScene and UISceneDelegate) where we the developers don't control construction. Correct me if I'm wrong, but I think the main issue to address here is creating guidance on how constructing UIScene and UISceneDelegate instances fit into Cleanse.

As a quick aside, the nice thing about Cleanse is that choosing the root object is entirely up to you! 😄 If making the SceneDelegate the root object for your dependency graph makes more sense cognitively and for your project, then definitely do that. We suggest the AppDelegate as a potential root because it's the entry point of your application, thus "root" of your application in general. Reasoning behind this decision is so that you can more easily use the objects you bind in your dependency graph inside the app delegate for say handling push notifications, or setting up some data services on launch.

I've been playing around with scene creation and Apple's design makes it difficult to leverage DI (manually or via a framework) since they handle construction of the object and it doesn't look like there is an easy way to get an instance of your UISceneDelegate to apply property injection (for example, storyboards control construction of view controllers, but expose the UIStoryboard.instantiateViewControllerWithIdentifier(:) method that we can use to retrieve an instance of the VC and apply property injection on it). One could make a scene their root, but that would it hard to share the object graph across multiple scenes and with the AppDelegate.

One possible solution that I'm not particularly proud of is to expose a ProjectInjector instance on your AppDelegate and have your scene delegate reach out to the AppDelegate and apply the injections in func scene(_:, willConnectTo:, options:). I'd rather not expose a service container similar to Swinject or other DI frameworks that is just a singleton bag full of objects a UISceneDelegate instance could reach out to for dependencies. Service containers give DI frameworks a bad name IMO and make them easy to abuse.

Curious about your thoughts on how we might be able to share the object graph between scenes and the AppDelegate. I really like the sample project you posted, and it might be worth putting it under the Examples/ dir.

sebastianv1 avatar Feb 06 '20 17:02 sebastianv1

I think the main issue to address here is creating guidance on how constructing UIScene and UISceneDelegate instances fit into Cleanse.

Definitely agree.

Because there will be essential initialization logic living inside of AppDelegate (examples well illustrated in this article), it has merit to make AppDelegate the root object of the dependency graph to initialize APIs, databases and other things that other application logic may also use later in the app launch.

The UIApplication singleton exposes a windows property, which should contain a list of windows created by multiple instances of UIScenes. Given that each UISceneDelegate holds a reference to its own window. The UIApplication is a singleton that could be accessed anywhere. I propose if we could somehow keep a mirroring copy of window in AppDelegate and link it to each window of the UISceneDelegate, completing the dependency graph.

After viewing the Apple's presentation given that scenes can be freely created, attached, unattached destroyed, I wonder if each scene's dependencies should be isolated into a Cleanse.Component that ensures access safely, and also ease the management of destroying the scene because we could just destroy the entire dependency subtree by 0-reference-counting or to through some explicit destroy command to the component.

Let me know what you think. I am still new in dependency injection frameworks so forgive me if I am being too whimsical or being factually wrong.

DJBen avatar Feb 11 '20 21:02 DJBen

I wonder if each scene's dependencies should be isolated into a Cleanse.Component that ensures access safely, and also ease the management of destroying the scene because we could just destroy the entire dependency subtree by 0-reference-counting or to through some explicit destroy command to the component.

Yes, scenes are a good example where subcomponents can really shine due to scoping and deinit.

The scenario I'm concerned about is how one can bind a UISceneDelegate instance into the Cleanse DI graph when using the AppDelegate as their root to receive dependencies. The scene delegate will likely needs objects bound in the DI graph. For example, in your sample project you inject the main view controller bound in Cleanse to then set as the window's rootViewController. The scene delegate is also a valid place to spin up services as well so it needs a way to receive these dependencies. However, we don't control construction of the UISceneDelegate and aren't given an easy way to retrieve an instance upon construction like we can with storyboard based view controllers.

The one way I see around this is by exposing a ComponentFactory<MyMainScene> in the AppDelegate that the UISceneDelegate can reach out to via UIApplication.shared.delegate (with some extra type casting).

Another option is essentially to add a new operator that will swizzle in a hook for after an object type is initialized, similar to how RxSwift provides sentMessage(:) to observe on any dynamic selector. It could look something like:

binder
    .bind(MyMainScreenDelegate.self)
    .observeInit()
    .apply { (instance: MyMainScreenDelegate, propertyInjector: PropertyInjector<MyMainScreenDelegate>) in
        propertyInjector.injectProperties(into: instance)
    }

We could even require specifying a property injector since by using this special operator you are saying that you have no way to control construction of this particular object and therefore must use property injection.

For a change like this we would add a lot of warning documentation around it and a preprocessor macro for those who don't want it included in their binary.

sebastianv1 avatar Feb 11 '20 23:02 sebastianv1

since SceneDelegate and AppDelegate are no longer necessary with the latest SwiftUI, is this how to inject it?.

struct AppComponent: Cleanse.RootComponent {
    typealias Root = PropertyInjector<AdoptmeApp>

    static func configure(binder: Binder<Singleton>) {
        binder.include(module: UserData.Module.self)
        binder.include(module: User.Module.self)
    }

    static func configureRoot(binder bind: ReceiptBinder<PropertyInjector<AdoptmeApp>>) -> BindingReceipt<PropertyInjector<AdoptmeApp>> {
        bind.propertyInjector { (bind) -> BindingReceipt<PropertyInjector<AdoptmeApp>> in
            return bind.to(injector: AdoptmeApp.injectProperties)
        }
    }
}
@main
class AdoptmeApp: App {
    var userData: UserData!
    var user: User!
    required init() {
        let propertyInjector = try? ComponentFactory.of(AppComponent.self).build(())
        propertyInjector?.injectProperties(into: self)
        precondition(userData != nil)
        precondition(user != nil)
    }
    var body: some Scene {
        WindowGroup {
            HomeScreen(currentUser: user).environmentObject(userData)
        }
    }
}

extension AdoptmeApp {
    func injectProperties(_ userData: UserData, _ user: User) {
        self.userData = userData
        self.user = user
    }
}

here's the sample: https://github.com/chathil/adoptme-ios

chathil avatar Dec 25 '20 15:12 chathil

@chathil Yes, that's how I would build your object graph using SwiftUI's @main annotation. Sample project is great, would you like to add it under the Examples/ folder?

sebastianv1 avatar Dec 26 '20 23:12 sebastianv1

@sebastianv1 sure. i will create the PR ASAP.

chathil avatar Dec 27 '20 03:12 chathil

Thanks @chathil

Your's example looks great. I have tried to run and saw that it worked properly.

However, I have the question, could you please review it also ?

My concern is how to inject UserData and User object at HomeScreen directly, not passed as property and environment object as in the example ?

hieunc278 avatar Feb 23 '21 01:02 hieunc278

@hieunc278 This issue might not be the right place to ask this question? and i don't really understand your question. Do you want to make HomeScreen as the entry point for Cleanse?, or maybe you want to inject both variables via the constructor like this?

HomeScreen(currentUser: user, userData: userData)

i'm not doing any property injection on HomeScreen, only on AdoptmeApp because i don't have access to perform constructor injection. About the environment object that is because i'm using UserData inside child view that's quite deep.

chathil avatar Feb 23 '21 14:02 chathil

Dear @chathil

Thanks for your feedback.

Sorry if I made you confuse, however, I mean that could we use User and UserData as property injection for HomeScreen as AdoptMeApp ? So we have not to pass UserData via Environment object.

hieunc278 avatar Feb 25 '21 23:02 hieunc278