Swift Combine - How to subscribe to nested Observable Objects

Viewed 1534

This is a slightly more abstract version of this question. In the app, these nested Observable Objects are indeed used in a view (so I'd rather use Observable Objects rather than straight Publishers). However, I would like to be able to simply subscribe to the view models in order to test them. The protocol is there so I can mock out Nested in tests.

This is the basic setup:

protocol NestedProtocol: AnyObject {
  var string: String { get set }
}
class Nested: ObservableObject, NestedProtocol {
  @Published var string = ""
}
class Parent: ObservableObject {
  @Published var nested: NestedProtocol
  init(nested: NestedProtocol) {
    self.nested = nested
  }
}

var sinkHole = Set<AnyCancellable>()
let nested = Nested()
let parent = Parent(nested: nested)

parent.$nested.sink { newValue in
  print("NEW VALUE \(newValue.string)")
}.store(in: &sinkHole)

Then this command nested.string = "foo1" outputs "NEW VALUE ", which is expected as the initial value of nested. I would like it to output "NEW VALUE foo1". (TIL published variables seem to be current value publishers.)

  1. Of course I could do
nested.string = "foo1"
parent.nested = nested

and I would get "NEW VALUE foo1", but that's smelly.

  1. I tried
protocol NestedProtocol: ObservableObject {
  var string: String { get set }
}
class Nested<T>: ObservableObject where T: NestedProtocol {
...

But in real life, I would like nested to declare some static constants, which is not allowed in generic types. So that doesn't work.

  1. From the cited question/answer, I also tried combinations of
Parent
init() {
  nested.objectWillChange.sink { [weak self] (_) in
    self?.objectWillChange.send()
  }.store(in: sinkHole)
}

Nested
init() {
  string.sink { [weak self] (_) in
    self?.objectWillChange.send()
  }.store(in: sinkHole)
}

No dice. Those methods were getting called but that outer-level sink was still just returning "NEW VALUE "

  1. I also tried calling
parent.nested.string = "foo1"

So now I'm modifying the parent, and that should work, right? Wrong.

2 Answers

There's a bunch to unpack here.

First, you might know that if a property is a value-type, like a struct or String, then marking it as @Published just works:

class Outer {
   @Published var str: String = "default"
}

let outer = Outer()
outer.$str.sink { print($0) }
outer.str = "changed"

Will output:

default
changed

Your question, however, is about a nested observable object, which is a reference type. So, the above wouldn't work with a reference-type.

But in your example you're using a protocol as an existential (i.e. in place of an eventual instance), and as you noted, without inheriting from AnyObject, then it really behaves like a value-type:

protocol InnerProtocol {
   var str: String { get set }
}
class Inner: InnerProtocol {
   @Published var str: String = "default"
}
class Outer {
   @Published var inner: InnerProtocol
   init(_ inner: InnerProtocol) { self.inner = inner }
}

let inner = Inner()
let outer = Outer(inner)
outer.$inner.sink { print($0.str) }
outer.inner.str = "changed"

This would also output:

default
changed

which looks like what you wanted, but in fact it doesn't really "observe" any changes in the nested object. When you do outer.inner.str, it has value-type semantics, so it's as-if you re-assigned the .inner property. But if you are truly interested in observing changes of the object itself, then this approach wouldn't work at all. For example:

nested.str = "inner changed"

would not cause an output. Neither would there be an output if the inner object changed its own property, e.g.:

init() {
   DisplatchQueue.main.asyncAfter(.now() + 1) {
      self.str = "async changed"
   }
}

So, it's unclear what exactly you're trying to achieve. If you want to observe a reference type property, you'd need to observe it directly.

class Inner: ObservableObject {
   @Published var str: String
   //...
}
class Outer: ObservableObject {
   var inner: Inner
   //...
}
//...
outer.inner.$str.sink { ... }
// or
outer.inner.objectWillChange.sink { ... }

You can achieve this with a protocol too, if you insist:

protocol InnerProtocol: ObservableObject {
   var str: String { get set }
}
class Inner: InnerProtocol {
   @Published var str: String = "default"
}
class Outer<T: InnerProtocol>: ObservableObject {
   var inner: T
   init(_ inner: T) { self.inner = inner }
}

let inner = Inner()
let outer = Outer(inner)
outer.inner.$str.sink { ... }
inner.str = "changed"

This took me hours and I stumbled upon it by accident while trying to modify my protocol in various ways.

Lesson 1:

protocol NestedProtocol: AnyObject {
  var string: String { get set }
}

should be

protocol NestedProtocol {
  var string: String { get set }
}

Why? I'm not sure. Apparently, if the Parent cannot assume that the published object is a class, then it watches modifications on it more closely? My instinct tells me the exact opposite, but it goes to show how much I can trust my instinct.

Lesson 2: Indeed my 4th idea was correct and you need to name the parent in the nested object modification:

nested.string = "foo1"

should be

parent.nested.string = "foo1"

Again, they're all classes so it goes slightly against my understanding, but I don't know all the magic that goes on under @Published.

The final complete version looks like this:

protocol NestedProtocol {
  var string: String { get set }
}
class Nested: ObservableObject, NestedProtocol {
  @Published var string = ""
}
class Parent: ObservableObject {
  @Published var nested: NestedProtocol
  init(nested: NestedProtocol) {
    self.nested = nested
  }
}

var sinkHole = Set<AnyCancellable>()
let nested = Nested()
let parent = Parent(nested: nested)

parent.$nested.sink { newValue in
  print("NEW VALUE \(newValue.string)")
}.store(in: &sinkHole)

and

nested.string = "foo1"
parent.nested.string = "foo2"

returns "NEW VALUE " "NEW VALUE foo2"

Related