hmlongco / Resolver

Swift Ultralight Dependency Injection / Service Locator framework
MIT License
2.14k stars 188 forks source link

Published values from injected services getting lost #90

Closed beeirl closed 3 years ago

beeirl commented 3 years ago

I am currently facing the problem that my nested SwiftUI Views are not getting the latest values published by my Injected Service. I would like to describe my problem using a highly simplified representation of my current project structure to ensure traceability.

extension Resolver: ResolverRegistering {
    public static func registerAllServices() {
        register { Service() }.scope(.shared)
    }
}
final class Service {
    @Published var foo: String? = "bar"

    private var cancellables = Set<AnyCancellable>()

    init() {
        // I subscribe to some async data here
        someRepository.$dataStream
            .sink { [weak self] data in
                // "newValue" or nil should get assigned to foo here
                self?.foo = data?.foo
            }
            .store(in: &cancellables)
    }
}
final class MainViewModel: ObservableObject {
    @Injected var service: Service

    @Published var someCondition = false

    private var cancellables = Set<AnyCancellable>()

    init() {
        service.$foo
            .sink { [weak self] foo in
                // receives "newValue"
                print(foo)

                self?.someCondition = true
            }
            .store(in: &cancellables)
    }
}

struct MainView: View {
    @StateObject var mainViewModel = MainViewModel()

    var body: some View {
        ZStack {
            if someCondition {
                ChildView()
            }
        }
    }
}
final class ChildViewModel: ObservableObject {
    @Injected var service: Service

    @Published var foo: String?

    private var cancellables = Set<AnyCancellable>()

    init() {
        service.$foo
            .sink { [weak self] foo in
                // foo still equals to "bar" here but it should be "newValue"
                self?.foo = foo
            }
            .store(in: &cancellables)
    }
}

struct ChildView: View {
    @StateObject var childViewModel = ChildViewModel()

    var body: some View {
        ZStack {
            Text(childViewModel.foo)
        }
    }
}

Perhaps there is also a relation to this post on SO. Please lmk if you need more details.

Prince2k3 commented 3 years ago

@beeirl i have this issue as well. Not sure if this is a Resolver issue but maybe a Combine one.

beeirl commented 3 years ago

@Prince2k3 oh no way. Could you find a workaround that works at least temporarily or does it block you as much as it blocks me?

Prince2k3 commented 3 years ago

Currently pull to refresh since this issue primarily happens on the feed portion of the app. And since it is the first to appear when app come load or comes from the background it happens to it the most.

hmlongco commented 3 years ago

I guess my first question would be what happens if you take Resolver out of the picture?

final class Service {
  static var shared = Service()
}
final class MainViewModel: ObservableObject {
    let service = Service,shared
}

If the behavior is the same then it's not an injection issue.

In fact... (hint, hint) I'd suggest reading an article of mine: Deep Inside Views, State and Performance in SwiftUI

As I STRONGLY suspect that things aren't happening when you think they're happening. In particular, I'd put some print/log statements inside your initializers. If you do that you'll probably see something like...

INIT MainView
INIT Service
INIT MainViewModel
MAIN foo == bar
INIT ChildView
INIT ChildViewModel
CHILD foo = bar

SERVICE Timer fires
MAIN foo == newValue
CHILD foo == newValue
INIT ChildView

In particular, note that you're going to get the initial values from your published object and that -- perhaps more to the point -- that "INIT ChildViewModel" is occurring BEFORE the timer fires (see code below).

In fact, I suspect it's occurring BEFORE you think that ChildView is even shown!

Some key points here are that...

  1. Views are NOT views, they're definitions of views.
  2. In SwiftUI if you do... IF someCondition { SomeView() } ...then SomeView() WILL be instantiated as it's a required argument to ViewBuilder.
  3. So an instance of ChildView is being created when defining MainView...
  4. Which means that ChildViewModel is created when instantiating ChildView...
  5. Which means your ChildViewModel init is happening way, way, way before you think it does...
  6. And that it's getting the initial, current value of foo long before the timer fires and sets "newValue"

In SwiftUI, doing the sort of heavy lifting you're doing inside of your initializers is NOT recommended.

final class Service {
    static var shared = Service()
    @Published var foo: String = "bar"
    init() {
        print("INIT Service")
        // I subscribe to some async data here
        DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
            print("SERVICE Timer fires")
            self.foo = "newValue"
        }
    }
}
final class MainViewModel: ObservableObject {
    let service = Service.shared
    @Published var someCondition = false
    private var cancellables = Set<AnyCancellable>()
    init() {
        print("INIT MainViewModel")
        service.$foo
            .sink { [weak self] foo in
                print("MAIN foo == \(foo)")
                self?.someCondition = true
            }
            .store(in: &cancellables)
    }
}
struct MainView: View {
    @StateObject var mainViewModel = MainViewModel()
    init() {
        print("INIT MainView")
    }
    var body: some View {
        ZStack {
            if mainViewModel.someCondition {
                ChildView()
            }
        }
    }
}
final class ChildViewModel: ObservableObject {
    let service = Service.shared
    @Published var foo: String = ""
    private var cancellables = Set<AnyCancellable>()
    init() {
        print("INIT ChildViewModel")
        service.$foo
            .sink { [weak self] foo in
                print("CHILD foo == \(foo)")
                self?.foo = foo
            }
            .store(in: &cancellables)
    }
}
struct ChildView: View {
    @StateObject var childViewModel = ChildViewModel()
    init() {
        print("INIT ChildView")
    }
    var body: some View {
        ZStack {
            Text(childViewModel.foo)
        }
    }
}
hmlongco commented 3 years ago

Just noting I ran the above with and without Resolver with the same log trace.

hmlongco commented 3 years ago

So... one other nit.

You're subscribing to some "async" data and storing the value into the published value. But your code doesn't show when you switch that data to the main thread for UI updates.

final class Service {

    @Published var foo: String = "bar"

    private let background = DispatchQueue(label: "bg", qos: .background)
    private var cancellables = Set<AnyCancellable>()

    init() {
        print("INIT Service")
        // I subscribe to some async data here
        Future<String, Never> { promise1 in
            self.background.asyncAfter(deadline: .now() + 10) {
                print("SERVICE Timer fires")
                promise1(.success("newValue"))
            }
        }
        .receive(on: RunLoop.main) // Note switch to main thread
        .sink { [weak self] (value) in
            self?.foo = value
        }
        .store(in: &cancellables)
    }

}

SwiftUI is like UIKit. Data updates that trigger UI updates should occur on the main thread, otherwise those events may appear to be "eaten".