如何使用Combine框架NSObject.KeyValueObservingPublisher?

How to use Combine framework NSObject.KeyValueObservingPublisher?

我正在尝试使用 Combine 框架 NSObject.KeyValueObservingPublisher。我可以通过在 NSObject 上调用 publisher(for:options:) 来了解如何生成此发布者。但我有两个问题:

在我看来,除了在最简单的情况下,这些限制使该发布者几乎无法使用。有什么解决方法吗?

为了获取旧值,我能找到的唯一解决方法是使用 .prior 而不是 .old,这会导致发布者发出 [=28= 的当前值] 它被改变之前,然后使用collect(2).[=16=将该值与下一个排放(这是属性的新值)结合起来]

为了确定什么是初始值与新值,我发现的唯一解决方法是对发布者使用 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([[=10=]]) }

        // 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([=10=]) }

        // 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 [=10=] {
            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)\"" }
}

我对 TylerTheCompiler 的回答没有太多要补充的,但我想说明几点:

  1. 根据我的测试,NSObject.KeyValueObservingPublisher 内部不使用更改字典。它总是使用关键路径来获取 属性.

  2. 的值
  3. 如果您传递 .prior,发布者将在每次 属性 更改时分别发布之前和之后的值。

  4. 获取 属性 前后值的一种更短的方法是使用 scan 运算符:

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

    如果你也使用.initial,那么withPriorValue的第一个输出将是(prior: nil, new: currentValue)