Why does User Defaults publisher trigger multiple times
Asked Answered
S

2

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.

Spivey answered 30/1, 2021 at 7:30 Comment(4)
This was happening in a unit test and causing problems in my app, I extracted it into playground to get some help.Spivey
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.Spivey
Probable duplicate of #60386500Bratcher
Thx for linking that question! That does indeed shed more light on the behaviour from a different perspective.Spivey
W
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.

Womanlike answered 30/1, 2021 at 12:37 Comment(5)
It's not that it publishes twice after that, somehow the changes made before the subscription triggers it later for some reason.Spivey
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.Spivey
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.Womanlike
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.Spivey
This is proper weird, it's like it's repeating it twice so that you get the message ;-)Womanlike
P
0

As mention @LuLuGaGa, the first published value is the initial value.

Concerning to the second and third fires, it's the same new value which is published 2 times.

It's because the name of your computed property and the key are the same. I think it's a conflict in Objc between KVC/KVO setValue(:forKey:) with UserDefaults's methods or @objc attribute in Swift extension

So, if you define your extension like this:

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

and subscribe like this:

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

Setting the value using:

  defaults.someProperty2 = false

will publish value once, but setting value using:

  defaults.set(false, forKey:"someProperty")
  // or
  defaults.set(false, forKey:"someProperty2")

will not publish value at all.

My advice is to not use the official KVO publisher(for:options) with UserDefaults. You can create your own KVO Publisher using String Keypath based on the official KVO publisher source

Partible answered 30/4 at 13:47 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.