Multiple bindings and disposal using "Boxing"-style
Asked Answered
K

1

9

This is a very specific and long question, but I'm not smart enough to figure it out by myself..

I was very intrigued by this YouTube-video from raywenderlich.com, which uses the "boxing" method to observe a value.

Their Box looks like this:

class Box<T> {
    typealias Listener = T -> Void
    var listener: Listener?

    var value: T {
        didSet {
            listener?(value)
        }
    init(_ value: T) {
        self.value = value
    }
    func bind(listener: Listener?) {
        self.listener = listener
        listener?(value)
    }
}

It's apparent that only one listener is allowed per "box".

let bindable:Box<String> = Box("Some text")
bindable.bind { (text) in
    //This will only be called once (from initial setup)
}
bindable.bind { (text) in
    // - because this listener has replaced it. This one will be called when value changes.
}

Whenever a bind like this is set up, the previous binds would be disposed, because Box replaces the listener with the new listener.

I need to be able to observe the same value from different places. I have reworked the Box like this:

class Box<T> {
    typealias Listener = (T) -> Void
    var listeners:[Listener?] = []

    var value:T{
        didSet{
            listeners.forEach({$0?(value)})
        }
    }
    init(_ value:T){
        self.value = value
    }
    func bind(listener:Listener?){
        self.listeners.append(listener)
        listener?(value)
    }
}

However - this is also giving me trouble, obviously.. There are places where I want the new binding to remove the old binding. For example, if I observe a value in an object from a reusable UITableViewCell, it will be bound several times when scrolling. I now need a controlled way to dispose specific bindings.

I attempted to solve this by adding this function to Box:

func freshBind(listener:Listener?){
    self.listeners.removeAll()
    self.bind(listener)
}

This worked in a way, I could now use freshBind({}) whenever I wanted to remove the old listeners, but this isn't exactly what I want either. I'd have to use this when observing a value from UITableViewCells, but I also need to observe the same value from elsewhere. As soon as a cell was reused, I removed the old observers as well as the other observers I needed.

I am now confident that I need a way to retain a disposable object wherever I would later want to dispose them.

I'm not smart enough to solve this on my own, so I need help.

I've barely used some of the reactive-programming frameworks out there (like ReactiveCocoa), and I now understand why their subscriptions return Disposable objects which I have to retain and dispose of when I need. I need this functionality.

What I want is this: func bind(listener:Listener?)->Disposable{}

My plan was to create a class named Disposable which contained the (optional) listener, and turn listeners:[Listener?] into listeners:[Disposable], but I ran into problems with <T>..

Cannot convert value of type 'Box<String?>.Disposable' to expected argument type 'Box<Any>.Disposable'

Any smart suggestions?

Kaye answered 2/5, 2018 at 13:22 Comment(0)
W
16

The way I like to solve this problem is to give each observer a unique identifier (like a UUID) and use that to allow the Disposable to remove the observer when it's time. For example:

import Foundation

// A Disposable holds a `dispose` closure and calls it when it is released
class Disposable {
    let dispose: () -> Void
    init(_ dispose: @escaping () -> Void) { self.dispose = dispose }
    deinit { dispose() }
}

class Box<T> {
    typealias Listener = (T) -> Void

    // Replace your array with a dictionary mapping
    // I also made the Observer method mandatory. I don't believe it makes
    // sense for it to be optional. I also made it private.
    private var listeners: [UUID: Listener] = [:]

    var value: T {
        didSet {
            listeners.values.forEach { $0(value) }
        }
    }

    init(_ value: T){
        self.value = value
    }

    // Now return a Disposable. You'll get a warning if you fail
    // to retain it (and it will immediately be destroyed)
    func bind(listener: @escaping Listener) -> Disposable {

        // UUID is a nice way to create a unique identifier; that's what it's for
        let identifier = UUID()

        // Keep track of it
        self.listeners[identifier] = listener

        listener(value)

        // And create a Disposable to clean it up later. The Disposable
        // doesn't have to know anything about T. 
        // Note that Disposable has a strong referene to the Box
        // This means the Box can't go away until the last observer has been removed
        return Disposable { self.listeners.removeValue(forKey: identifier) }
    }
}

let b = Box(10)

var disposer: Disposable? = b.bind(listener: { x in print(x)})
b.value = 5
disposer = nil
b.value = 1

// Prints:
// 10
// 5
// (Doesn't print 1)

This can be nicely expanded to concepts like DisposeBag:

// Nothing but an array of Disposables.
class DisposeBag {
    private var disposables: [Disposable] = []
    func append(_ disposable: Disposable) { disposables.append(disposable) }
}

extension Disposable {
    func disposed(by bag: DisposeBag) {
        bag.append(self)
    }
}

var disposeBag: DisposeBag? = DisposeBag()

b.bind { x in print("bag: \(x)") }
    .disposed(by: disposeBag!)

b.value = 100
disposeBag = nil
b.value = 500

// Prints:
// bag: 1
// bag: 100
// (Doesn't print "bag: 500")

It's nice to build some of these things yourself so you get how they work, but for serious projects this often is not really sufficient. The main problem is that it isn't thread-safe, so if something disposes on a background thread, you may corrupt the entire system. You can of course make it thread-safe, and it's not that difficult.

But then you realize you really want to compose listeners. You want a listener that watches another listener and transforms it. So you build map. And then you realize you want to filter cases where the value was set to its old value, so you build a "only send me distinct values" and then you realize you want filter to help you there, and then....

you realize you're rebuilding RxSwift.

This isn't a reason to avoid writing your own at all, and RxSwift includes a ton of features (probably too many features) that many projects never need, but as you go along you should keep asking yourself "should I just switch to RxSwift now?"

Westfall answered 2/5, 2018 at 13:55 Comment(5)
I am speechless. What an incredible answer! I wish I could give you more upvotes. I thought my question was too specific, but you gave me exactly what I asked for and needed - within an hour! When I first realised I needed the Disposable-object, the first thing I thought was "Oh god, am I creating RxSwift?" :) But I still want to go for this, I (think) I know what I need for this project - which makes me curious, could you elaborate on the issue with this not being thread safe?Kaye
If a observation is added or removed on one thread at the same time that an observation is added or removed on another thread (or if an observation fires on one thread while an observation is being added or removed on another), then there will be an unsafe access to the listeners dictionary, which could corrupt the dictionary and leads to undefined behavior. You can fix this by using an internal DispatchQueue or (like RxSwift) using atomic compare-and-swap (but atomics are very difficult to use correctly, even by experts).Westfall
BTW, KVO has this same problem. It's not thread-safe, and it's pretty complicated to make it thread-safe. See github.com/postmates/PMKVObserver for an example of a project that does. People get away with ignoring this fact a lot, but it's still unsafe and can cause corruption or crashes.Westfall
I might be stepping into the "bad developer"-shoes now, but ,technically, if I always start these bindings (and dispose) on the main thread, this can't happen, right? Any time a holder of a disposableBag is deallocated - the bag and its listeners will deinit with it, and that only happens on main thread?Kaye
If you start these bindings, modify all observables, and make sure the last strong reference is removed on the main thread, then yes, it all works. And this is pretty common in iOS apps. In many cases it's all fine. A lot of iOS work is not thread-safe, but we tend to do most work on the main queue (those two facts are not unrelated…) So no, not bad developer at all. It's just something you need to be aware of so you know the rules (and have good intuitions of where to look when you're looking at a bizarre crash from the field…as I have… :D) Best of luck!Westfall

© 2022 - 2024 — McMap. All rights reserved.