Как использовать объединить фреймворк NSObject.KeyValueObservingPublisher? - PullRequest
4 голосов
/ 25 февраля 2020

Я пытаюсь использовать фреймворк Combine NSObject.KeyValueObservingPublisher. Я могу понять, как создать этого издателя, позвонив по номеру publisher(for:options:) в NSObject. Но у меня две проблемы:

  • Я могу включить .old в options, но значение .old никогда не приходит. Появляются только значения .initial (когда мы подписываемся) и значение .new (каждый раз, когда наблюдаемое свойство изменяется). Я могу подавить значение .initial, но не могу подавить значение .new или добавить значение .old.

  • Если options равны [.initial, .new] ( по умолчанию), я не вижу способа различить guish, является ли полученное значение .initial или .new. С «настоящим» KVO я получаю NSKeyValueChangeKey или NSKeyValueObservedChange, который говорит мне, что я получаю. Но с издателем Combine я не знаю. Я просто получаю немаркированные значения.

Мне кажется, что эти ограничения делают этого издателя практически непригодным для использования, за исключением самых простых случаев. Есть ли обходные пути?

Ответы [ 2 ]

4 голосов
/ 26 февраля 2020

Мне нечего добавить к ответу TylerTheCompiler, но я хочу отметить несколько вещей:

  1. Согласно моему тестированию, NSObject.KeyValueObservingPublisher не использует изменение словарь внутренне. Он всегда использует путь к ключу для получения значения свойства.

  2. Если вы передадите .prior, издатель опубликует sh как до, так и после значения, отдельно, каждый раз, когда свойство изменяется.

  3. Более короткий способ получить значения свойства до и после - использовать оператор scan:

    extension Publisher {
        func withPriorValue() -> AnyPublisher<(prior: Output?, new: Output), Failure> {
            return self
                .scan((prior: Output?.none, new: Output?.none)) { (prior: $0.new, new: $1) }
                .map { (prior: $0.0, new: $0.1!) }
                .eraseToAnyPublisher()
        }
    }
    

    Если вы также используете .initial, тогда первый вывод withPriorValue будет (prior: nil, new: currentValue).

3 голосов
/ 25 февраля 2020

Для получения старого значения единственным способом, который мне удалось найти, было использование .prior вместо .old, что заставляет издателя выдавать текущее значение свойства до . изменили, а затем объедините это значение со следующим выпуском (которое является новым значением свойства), используя collect(2).

. Для определения начального значения по сравнению с новым значением, единственным найденным обходным путем был использовать first() на издателе.

Затем я объединил этих двух издателей и обернул их в симпатичную небольшую функцию, которая выплевывает пользовательское перечисление KeyValueObservation, которое позволяет вам легко определить, является ли это начальным значением или нет, а также дает вам старое значение, если это не начальное значение.

Полный пример кода приведен ниже. Просто создайте новый проект с одним представлением в XCode и замените содержимое ViewController.swift на все нижеприведенное:

import UIKit
import Combine

/// The type of value published from a publisher created from 
/// `NSObject.keyValueObservationPublisher(for:)`. Represents either an
/// initial KVO observation or a non-initial KVO observation.
enum KeyValueObservation<T> {
    case initial(T)
    case notInitial(old: T, new: T)

    /// Sets self to `.initial` if there is exactly one element in the array.
    /// Sets self to `.notInitial` if there are two or more elements in the array.
    /// Otherwise, the initializer fails.
    ///
    /// - Parameter values: An array of values to initialize with.
    init?(_ values: [T]) {
        if values.count == 1, let value = values.first {
            self = .initial(value)
        } else if let old = values.first, let new = values.last {
            self = .notInitial(old: old, new: new)
        } else {
            return nil
        }
    }
}

extension NSObjectProtocol where Self: NSObject {

    /// Publishes `KeyValueObservation` values when the value identified 
    /// by a KVO-compliant keypath changes.
    ///
    /// - Parameter keyPath: The keypath of the property to publish.
    /// - Returns: A publisher that emits `KeyValueObservation` elements each 
    ///            time the property’s value changes.
    func keyValueObservationPublisher<Value>(for keyPath: KeyPath<Self, Value>)
        -> AnyPublisher<KeyValueObservation<Value>, Never> {

        // Gets a built-in KVO publisher for the property at `keyPath`.
        //
        // We specify all the options here so that we get the most information
        // from the observation as possible.
        //
        // We especially need `.prior`, which makes it so the publisher fires 
        // the previous value right before any new value is set to the property.
        //
        // `.old` doesn't seem to make any difference, but I'm including it
        // here anyway for no particular reason.
        let kvoPublisher = publisher(for: keyPath,
                                     options: [.initial, .new, .old, .prior])

        // Makes a publisher for just the initial value of the property.
        //
        // Since we specified `.initial` above, the first published value will
        // always be the initial value, so we use `first()`.
        //
        // We then map this value to a `KeyValueObservation`, which in this case
        // is `KeyValueObservation.initial` (see the initializer of
        // `KeyValueObservation` for why).
        let publisherOfInitialValue = kvoPublisher
            .first()
            .compactMap { KeyValueObservation([$0]) }

        // Makes a publisher for every non-initial value of the property.
        //
        // Since we specified `.initial` above, the first published value will 
        // always be the initial value, so we ignore that value using 
        // `dropFirst()`.
        //
        // Then, after the first value is ignored, we wait to collect two values
        // so that we have an "old" and a "new" value for our 
        // `KeyValueObservation`. This works because we specified `.prior` above, 
        // which causes the publisher to emit the value of the property
        // _right before_ it is set to a new value. This value becomes our "old"
        // value, and the next value emitted becomes the "new" value.
        // The `collect(2)` function puts the old and new values into an array, 
        // with the old value being the first value and the new value being the 
        // second value.
        //
        // We then map this array to a `KeyValueObservation`, which in this case 
        // is `KeyValueObservation.notInitial` (see the initializer of 
        // `KeyValueObservation` for why).
        let publisherOfTheRestOfTheValues = kvoPublisher
            .dropFirst()
            .collect(2)
            .compactMap { KeyValueObservation($0) }

        // Finally, merge the two publishers we created above
        // and erase to `AnyPublisher`.
        return publisherOfInitialValue
            .merge(with: publisherOfTheRestOfTheValues)
            .eraseToAnyPublisher()
    }
}

class ViewController: UIViewController {

    /// The property we want to observe using our KVO publisher.
    ///
    /// Note that we need to make this visible to Objective-C with `@objc` and 
    /// to make it work with KVO using `dynamic`, which means the type of this 
    /// property must be representable in Objective-C. This one works because it's 
    /// a `String`, which has an Objective-C counterpart, `NSString *`.
    @objc dynamic private var myProperty: String?

    /// The thing we have to hold on to to cancel any further publications of any
    /// changes to the above property when using something like `sink`, as shown
    /// below in `viewDidLoad`.
    private var cancelToken: AnyCancellable?

    override func viewDidLoad() {
        super.viewDidLoad()

        // Before this call to `sink` even finishes, the closure is executed with
        // a value of `KeyValueObservation.initial`.
        // This prints: `Initial value of myProperty: nil` to the console.
        cancelToken = keyValueObservationPublisher(for: \.myProperty).sink { 
            switch $0 {
            case .initial(let value):
                print("Initial value of myProperty: \(value?.quoted ?? "nil")")

            case .notInitial(let oldValue, let newValue):
                let oldString = oldValue?.quoted ?? "nil"
                let newString = newValue?.quoted ?? "nil"
                print("myProperty did change from \(oldString) to \(newString)")
            }
        }

        // This prints:
        // `myProperty did change from nil to "First value"`
        myProperty = "First value"

        // This prints:
        // `myProperty did change from "First value" to "Second value"`
        myProperty = "Second value"

        // This prints:
        // `myProperty did change from "Second value" to "Third value"`
        myProperty = "Third value"

        // This prints:
        // `myProperty did change from "Third value" to nil`
        myProperty = nil
    }
}

extension String {

    /// Ignore this. This is just used to make the example output above prettier.
    var quoted: String { "\"\(self)\"" }
}
...