swiftcombine

Why does User Defaults publisher trigger multiple times


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.


Solution

  • 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.