3

I'm subscribing the the built-in User Defaults extension, but it seems to be firing multiple times unnecessarily.

This is the code I'm using:

import Combine
import Foundation
import PlaygroundSupport

extension UserDefaults {
    
    @objc var someProperty: Bool {
        get { bool(forKey: "someProperty") }
        set { set(newValue, forKey: "someProperty") }
    }
}

let defaults = UserDefaults.standard

defaults.dictionaryRepresentation().keys
    .forEach(defaults.removeObject)

print("Before: \(defaults.someProperty)")

var cancellable = Set<AnyCancellable>()

defaults
    .publisher(for: \.someProperty)
    .sink { print("Sink: \($0)") }
    .store(in: &cancellable)

defaults.someProperty = true
cancellable.removeAll()

PlaygroundPage.current.needsIndefiniteExecution = true

This prints:

Before: false
Sink: false
Sink: true
Sink: true

Why is it firing the sink 3 times instead of only once?

I can maybe understand it firing on subscribe, which is confusing because it doesn't seem to be a PassthroughSubject or any documentation of this. However, what really confuses me is the third time it fires.

UPDATE:

It's strange but it seems the initial value gets factored into the new/old comparison:

defaults.someProperty = false
defaults.someProperty = true
defaults.someProperty = false
defaults.someProperty = true

print("Initial: \(defaults.someProperty)")

defaults
    .publisher(for: \.someProperty, options: [.new])
    .sink { print("Sink: \($0)") }
    .store(in: &cancellable)

defaults.someProperty = true

The above will print which looks good:

Initial: true
Sink: true

But when the initial value is different than what you set it to:

defaults.someProperty = false
defaults.someProperty = true
defaults.someProperty = false
defaults.someProperty = true
defaults.someProperty = false

print("Initial: \(defaults.someProperty)")

defaults
    .publisher(for: \.someProperty, options: [.new])
    .sink { print("Sink: \($0)") }
    .store(in: &cancellable)

defaults.someProperty = true

The above will strangely print:

Initial: false
Sink: true
Sink: true

This is untiutive because it's treating the initial value as a trigger of [.new], then compares again for what was set.

TruMan1
  • 33,665
  • 59
  • 184
  • 335
  • 2
    This was happening in a unit test and causing problems in my app, I extracted it into playground to get some help. – TruMan1 Jan 30 '21 at 12:11
  • If I remove the remove objects part that comes before, if fire twice not 3 times. Which is strange because that remove object comes before the subscription. It’s almost as if it’s replaying everything. What kind of publisher is this and how can I make work intuitively, really this example should fire once to me. – TruMan1 Jan 30 '21 at 12:14
  • Probable duplicate of https://stackoverflow.com/questions/60386000/how-to-use-combine-framework-nsobject-keyvalueobservingpublisher – matt Jan 30 '21 at 18:05
  • Thx for linking that question! That does indeed shed more light on the behaviour from a different perspective. – TruMan1 Jan 30 '21 at 18:08

1 Answers1

2

The first published value is the initial value when you subscribe, if you don't want to receive the initial value you can specify this in options (they are NSKeyValueObservingOptions):

defaults
    .publisher(for: \.someProperty, options: [.new])
    .sink { print("Sink: \($0)") }
    .store(in: &cancellable)

Every new value is indeed published twice, but you can just remove duplicates:

defaults
    .publisher(for: \.someProperty, options: [.new])
    .removeDuplicates()
    .sink { print("Sink: \($0)") }
    .store(in: &cancellable)

Which will give you the behaviour you want.

UPDATE:

if you define your extension like this:

extension UserDefaults {
    
    @objc var someProperty: Bool {
        bool(forKey: "someProperty")
    }
}

and then set the value using:

defaults.set(false, forKey: "someProperty")

The values are published only once.

LuLuGaGa
  • 13,089
  • 6
  • 49
  • 57
  • It's not that it publishes twice after that, somehow the changes made before the subscription triggers it later for some reason. – TruMan1 Jan 30 '21 at 17:38
  • It seems what's happening is it fires twice if the initial value is different than the value you set. So if you have `[.new]` set, starts off `false`, then set to `true`, it will fire the first new value then the second time to represent a new value was changed. Very confusing and strange. When setting to `[.new]`, I'd expect to get new values after the subscription happens, not when the initial value is compared to the first set. `removeDuplicates` does indeed solve it but as a workaround. – TruMan1 Jan 30 '21 at 17:51
  • I am not quite sure. If you add `defaults.someProperty =false` under `defaults.someProperty = true` - in your initial setup it gives false/true/true/false/false. It is all quite surprising tbh. – LuLuGaGa Jan 30 '21 at 17:53
  • I added an extra example in my original question. There's some consistency to the madness at least, but not very intuitive about the initial value firing if it's different than the first subsequent set value. – TruMan1 Jan 30 '21 at 18:01
  • This is proper weird, it's like it's repeating it twice so that you get the message ;-) – LuLuGaGa Jan 30 '21 at 18:05