From 7c5a76cf2bdae737a38b571f86bb31cb19c1201d Mon Sep 17 00:00:00 2001 From: Sergej Jaskiewicz Date: Tue, 26 Nov 2019 17:46:01 +0300 Subject: [PATCH] Fix @Published (#112) --- .circleci/config.yml | 18 +++ RemainingCombineInterface.swift | 65 ---------- Sources/OpenCombine/ObservableObject.swift | 85 ++++++++++++++ Sources/OpenCombine/Published.swift | 66 ++++++----- .../Helpers/TrackingSubscriber.swift | 27 ++++- .../ObservableObjectPublisherTests.swift | 76 ++++++++++++ Tests/OpenCombineTests/PublishedTests.swift | 111 ++++++++++++++++++ 7 files changed, 349 insertions(+), 99 deletions(-) create mode 100644 Sources/OpenCombine/ObservableObject.swift create mode 100644 Tests/OpenCombineTests/ObservableObjectPublisherTests.swift create mode 100644 Tests/OpenCombineTests/PublishedTests.swift diff --git a/.circleci/config.yml b/.circleci/config.yml index 5f82205..6fce731 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -37,7 +37,10 @@ jobs: -scheme OpenCombine-Package \ -sdk macosx10.15 \ -derivedDataPath DerivedData \ + | tee xcodebuild_build-for-testing.log \ | xcpretty + - store_artifacts: + path: xcodebuild_build-for-testing.log - run: name: Testing on macOS 10.15.0 with xcodebuild command: | @@ -46,7 +49,10 @@ jobs: -scheme OpenCombine-Package \ -sdk macosx10.15 \ -derivedDataPath DerivedData \ + | tee xcodebuild_test-without-building.log \ | xcpretty --report junit -o build/reports/results.xml + - store_artifacts: + path: xcodebuild_test-without-building.log - store_test_results: path: build/reports - run: @@ -72,7 +78,10 @@ jobs: -scheme OpenCombine-Package \ -destination "platform=iOS Simulator,name=iPhone 11,OS=13.2.2" \ -derivedDataPath DerivedData \ + | tee xcodebuild_build-for-testing.log \ | xcpretty + - store_artifacts: + path: xcodebuild_build-for-testing.log - run: name: Testing against Combine on iOS 13.2.2 with xcodebuild command: | @@ -81,7 +90,10 @@ jobs: -scheme OpenCombine-Package \ -destination "platform=iOS Simulator,name=iPhone 11,OS=13.2.2" \ -derivedDataPath DerivedData \ + | tee xcodebuild_test-without-building.log \ | xcpretty --report junit -o build/reports/results.xml + - store_artifacts: + path: xcodebuild_test-without-building.log - store_test_results: path: build/reports @@ -123,7 +135,10 @@ jobs: -scheme OpenCombine-Package \ -destination "platform=iOS Simulator,name=iPhone 4s,OS=9.3" \ -derivedDataPath DerivedData \ + | tee xcodebuild_build-for-testing.log \ | xcpretty + - store_artifacts: + path: xcodebuild_build-for-testing.log - run: name: Testing on iOS 9.3 with xcodebuild command: | @@ -132,7 +147,10 @@ jobs: -scheme OpenCombine-Package \ -destination "platform=iOS Simulator,name=iPhone 4s,OS=9.3" \ -derivedDataPath DerivedData \ + | tee xcodebuild_test-without-building.log \ | xcpretty --report junit -o build/reports/results.xml + - store_artifacts: + path: xcodebuild_test-without-building.log - store_test_results: path: build/reports - run: diff --git a/RemainingCombineInterface.swift b/RemainingCombineInterface.swift index 413ca6d..e9c4f5a 100644 --- a/RemainingCombineInterface.swift +++ b/RemainingCombineInterface.swift @@ -2094,68 +2094,3 @@ extension Publishers.Zip4 : Equatable where A : Equatable, B : Equatable, C : Eq /// - rhs: Another value to compare. public static func == (lhs: Publishers.Zip4, rhs: Publishers.Zip4) -> Bool } - -/// A type of object with a publisher that emits before the object has changed. -/// -/// By default an `ObservableObject` will synthesize an `objectWillChange` -/// publisher that emits before any of its `@Published` properties changes: -/// -/// class Contact: ObservableObject { -/// @Published var name: String -/// @Published var age: Int -/// -/// init(name: String, age: Int) { -/// self.name = name -/// self.age = age -/// } -/// -/// func haveBirthday() -> Int { -/// age += 1 -/// return age -/// } -/// } -/// -/// let john = Contact(name: "John Appleseed", age: 24) -/// john.objectWillChange.sink { _ in print("\(john.age) will change") } -/// print(john.haveBirthday()) -/// // Prints "24 will change" -/// // Prints "25" -/// -public protocol ObservableObject : AnyObject { - - /// The type of publisher that emits before the object has changed. - associatedtype ObjectWillChangePublisher : Publisher = ObservableObjectPublisher where Self.ObjectWillChangePublisher.Failure == Never - - /// A publisher that emits before the object has changed. - var objectWillChange: Self.ObjectWillChangePublisher { get } -} - -extension ObservableObject where Self.ObjectWillChangePublisher == ObservableObjectPublisher { - - /// A publisher that emits before the object has changed. - public var objectWillChange: ObservableObjectPublisher { get } -} - -/// The default publisher of an `ObservableObject`. -final public class ObservableObjectPublisher : Publisher { - - /// The kind of values published by this publisher. - public typealias Output = Void - - /// The kind of errors this publisher might publish. - /// - /// Use `Never` if this `Publisher` does not publish errors. - public typealias Failure = Never - - public init() - - /// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)` - /// - /// - SeeAlso: `subscribe(_:)` - /// - Parameters: - /// - subscriber: The subscriber to attach to this `Publisher`. - /// once attached it can begin to receive values. - final public func receive(subscriber: S) where S : Subscriber, S.Failure == ObservableObjectPublisher.Failure, S.Input == ObservableObjectPublisher.Output - - final public func send() -} diff --git a/Sources/OpenCombine/ObservableObject.swift b/Sources/OpenCombine/ObservableObject.swift new file mode 100644 index 0000000..d8133a4 --- /dev/null +++ b/Sources/OpenCombine/ObservableObject.swift @@ -0,0 +1,85 @@ +// +// ObservableObject.swift +// +// +// Created by Sergej Jaskiewicz on 08/09/2019. +// + +/// A type of object with a publisher that emits before the object has changed. +/// +/// By default an `ObservableObject` will synthesize an `objectWillChange` +/// publisher that emits before any of its `@Published` properties changes: +/// +/// class Contact : ObservableObject { +/// @Published var name: String +/// @Published var age: Int +/// +/// init(name: String, age: Int) { +/// self.name = name +/// self.age = age +/// } +/// +/// func haveBirthday() -> Int { +/// age += 1 +/// } +/// } +/// +/// let john = Contact(name: "John Appleseed", age: 24) +/// john.objectWillChange.sink { _ in print("will change") } +/// print(john.haveBirthday) +/// // Prints "will change" +/// // Prints "25" +/// +public protocol ObservableObject: AnyObject { + + /// The type of publisher that emits before the object has changed. + associatedtype ObjectWillChangePublisher: Publisher = ObservableObjectPublisher + where ObjectWillChangePublisher.Failure == Never + + /// A publisher that emits before the object has changed. + var objectWillChange: ObjectWillChangePublisher { get } +} + +extension ObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher { + // swiftlint:disable let_var_whitespace +#if swift(>=5.1) + /// A publisher that emits before the object has changed. + @available(*, unavailable, message: """ + The default implementation of objectWillChange is not available yet. \ + It's being worked on in \ + https://github.com/broadwaylamb/OpenCombine/pull/97 + """) + public var objectWillChange: ObservableObjectPublisher { + fatalError("unimplemented") + } +#else + public var objectWillChange: ObservableObjectPublisher { + return ObservableObjectPublisher() + } +#endif + // swiftlint:enable let_var_whitespace +} + +/// The default publisher of an `ObservableObject`. +public final class ObservableObjectPublisher: Publisher { + + public typealias Output = Void + + public typealias Failure = Never + + private let subject: PassthroughSubject + + public init() { + subject = .init() + } + + public func receive(subscriber: Downstream) + where Downstream.Input == Void, Downstream.Failure == Never + { + subject.subscribe(subscriber) + } + + public func send() { + subject.send() + } +} diff --git a/Sources/OpenCombine/Published.swift b/Sources/OpenCombine/Published.swift index 038ce52..510d795 100644 --- a/Sources/OpenCombine/Published.swift +++ b/Sources/OpenCombine/Published.swift @@ -14,16 +14,18 @@ /// of the property first. /// Note that the `@Published` property is class-constrained. /// Use it with properties of classes, not with non-class types like structures. +@available(swift, introduced: 5.1) @propertyWrapper public struct Published { - /// Initialize the storage of the Published - /// property as well as the corresponding `Publisher`. + /// Initialize the storage of the `Published` property as well as the corresponding + /// `Publisher`. public init(initialValue: Value) { - value = initialValue + self.init(wrappedValue: initialValue) } - @available(*, unavailable) + /// Initialize the storage of the `Published` property as well as the corresponding + /// `Publisher`. public init(wrappedValue: Value) { value = wrappedValue } @@ -31,21 +33,10 @@ public struct Published { /// A publisher for properties marked with the `@Published` attribute. public struct Publisher: OpenCombine.Publisher { - /// The kind of values published by this publisher. public typealias Output = Value - /// The kind of errors this publisher might publish. - /// - /// Use `Never` if this `Publisher` does not publish errors. public typealias Failure = Never - /// This function is called to attach the specified - /// `Subscriber` to this `Publisher` by `subscribe(_:)` - /// - /// - SeeAlso: `subscribe(_:)` - /// - Parameters: - /// - subscriber: The subscriber to attach to this `Publisher`. - /// once attached it can begin to receive values. public func receive(subscriber: Downstream) where Downstream.Input == Value, Downstream.Failure == Never { @@ -61,8 +52,12 @@ public struct Published { private var value: Value - /// The property that can be accessed with the - /// `$` syntax and allows access to the `Publisher` + private var publisher: Publisher? + + internal var objectWillChange: ObservableObjectPublisher? + + /// The property that can be accessed with the `$` syntax and allows access to + /// the `Publisher` public var projectedValue: Publisher { mutating get { if let publisher = publisher { @@ -74,28 +69,35 @@ public struct Published { } } - @available(*, unavailable, message: - "@Published is only available on properties of classes") - + // swiftlint:disable let_var_whitespace + @available(*, unavailable, message: """ + @Published is only available on properties of classes + """) public var wrappedValue: Value { - get { value } - set { - value = newValue - publisher?.subject.value = newValue - } + get { fatalError() } + set { fatalError() } // swiftlint:disable:this unused_setter_value } + // swiftlint:enable let_var_whitespace - private var publisher: Publisher? - - @available(*, unavailable, message: - "This subscript is unavailable in OpenCombine yet") public static subscript( _enclosingInstance object: EnclosingSelf, wrapped wrappedKeyPath: ReferenceWritableKeyPath, storage storageKeyPath: ReferenceWritableKeyPath> ) -> Value { - get { fatalError() } - set { fatalError() } + get { + return object[keyPath: storageKeyPath].value + } + set { + object[keyPath: storageKeyPath].objectWillChange?.send() + object[keyPath: storageKeyPath].publisher?.subject.send(newValue) + object[keyPath: storageKeyPath].value = newValue + } + // TODO: Benchmark and explore a possibility to use _modify } } -#endif +#else + +@available(swift, introduced: 5.1) +public typealias Published = Never + +#endif // swift(>=5.1) diff --git a/Tests/OpenCombineTests/Helpers/TrackingSubscriber.swift b/Tests/OpenCombineTests/Helpers/TrackingSubscriber.swift index 1b50361..bc67db5 100644 --- a/Tests/OpenCombineTests/Helpers/TrackingSubscriber.swift +++ b/Tests/OpenCombineTests/Helpers/TrackingSubscriber.swift @@ -195,6 +195,18 @@ extension TrackingSubscriberBase where Value: Equatable { } } +@available(macOS 10.15, iOS 13.0, *) +extension TrackingSubscriberBase where Value == Void { + func assertHistoryEqual(_ expected: [Event], + file: StaticString = #file, + line: UInt = #line) { + assertHistoryEqual(expected, + valueComparator: { _, _ in true }, + file: file, + line: line) + } +} + @available(macOS 10.15, iOS 13.0, *) extension TrackingSubscriberBase.Event { func isEqual(to other: TrackingSubscriberBase.Event, @@ -222,12 +234,23 @@ extension TrackingSubscriberBase.Event { @available(macOS 10.15, iOS 13.0, *) extension TrackingSubscriberBase.Event: Equatable where Value: Equatable { - static func == (lhs: TrackingSubscriberBase.Event, - rhs: TrackingSubscriberBase.Event) -> Bool { + static func == (lhs: TrackingSubscriberBase.Event, + rhs: TrackingSubscriberBase.Event) -> Bool { return lhs.isEqual(to: rhs, valueComparator: ==) } } +@available(macOS 10.15, iOS 13.0, *) +extension TrackingSubscriberBase.Event where Value == Void { + + static var signal: TrackingSubscriberBase.Event { return .value(()) } + + static func == (lhs: TrackingSubscriberBase.Event, + rhs: TrackingSubscriberBase.Event) -> Bool { + return lhs.isEqual(to: rhs, valueComparator: { _, _ in true }) + } +} + @available(macOS 10.15, iOS 13.0, *) typealias TrackingSubject = TrackingSubjectBase diff --git a/Tests/OpenCombineTests/ObservableObjectPublisherTests.swift b/Tests/OpenCombineTests/ObservableObjectPublisherTests.swift new file mode 100644 index 0000000..34ae2d3 --- /dev/null +++ b/Tests/OpenCombineTests/ObservableObjectPublisherTests.swift @@ -0,0 +1,76 @@ +// +// ObservableObjectPublisherTests.swift +// +// +// Created by Sergej Jaskiewicz on 26.11.2019. +// + +import XCTest + +#if OPENCOMBINE_COMPATIBILITY_TEST +import Combine +#else +import OpenCombine +#endif + +@available(macOS 10.15, iOS 13.0, *) +final class ObservableObjectPublisherTests: XCTestCase { + + func testBasicBehavior() { + let publisher = ObservableObjectPublisher() + var downstreamSubscription1: Subscription? + let tracking1 = TrackingSubscriberBase( + receiveSubscription: { downstreamSubscription1 = $0 } + ) + publisher.subscribe(tracking1) + tracking1.assertHistoryEqual([.subscription("PassthroughSubject")]) + downstreamSubscription1?.request(.max(1)) + tracking1.assertHistoryEqual([.subscription("PassthroughSubject")]) + publisher.send() + tracking1.assertHistoryEqual([.subscription("PassthroughSubject"), + .signal]) + publisher.send() + publisher.send() + downstreamSubscription1?.request(.max(3)) + tracking1.assertHistoryEqual([.subscription("PassthroughSubject"), + .signal]) + publisher.send() + publisher.send() + publisher.send() + publisher.send() + tracking1.assertHistoryEqual([.subscription("PassthroughSubject"), + .signal, + .signal, + .signal, + .signal]) + downstreamSubscription1?.request(.unlimited) + + let tracking2 = TrackingSubscriberBase( + receiveSubscription: { $0.request(.unlimited) } + ) + publisher.subscribe(tracking2) + tracking2.assertHistoryEqual([.subscription("PassthroughSubject")]) + + publisher.send() + tracking1.assertHistoryEqual([.subscription("PassthroughSubject"), + .signal, + .signal, + .signal, + .signal, + .signal]) + tracking2.assertHistoryEqual([.subscription("PassthroughSubject"), + .signal]) + + downstreamSubscription1?.cancel() + publisher.send() + tracking1.assertHistoryEqual([.subscription("PassthroughSubject"), + .signal, + .signal, + .signal, + .signal, + .signal]) + tracking2.assertHistoryEqual([.subscription("PassthroughSubject"), + .signal, + .signal]) + } +} diff --git a/Tests/OpenCombineTests/PublishedTests.swift b/Tests/OpenCombineTests/PublishedTests.swift new file mode 100644 index 0000000..e512428 --- /dev/null +++ b/Tests/OpenCombineTests/PublishedTests.swift @@ -0,0 +1,111 @@ +// +// PublishedTests.swift +// +// +// Created by Sergej Jaskiewicz on 08/09/2019. +// + +import XCTest + +#if swift(>=5.1) + +#if OPENCOMBINE_COMPATIBILITY_TEST +import Combine + +@available(macOS 10.15, iOS 13.0, *) +private typealias Published = Combine.Published + +@available(macOS 10.15, iOS 13.0, *) +private typealias ObservableObject = Combine.ObservableObject +#else +import OpenCombine + +private typealias Published = OpenCombine.Published + +private typealias ObservableObject = OpenCombine.ObservableObject +#endif + +@available(macOS 10.15, iOS 13.0, *) +final class PublishedTests: XCTestCase { + + func testBasicBehavior() { + let testObject = TestObject() + var downstreamSubscription1: Subscription? + let tracking1 = TrackingSubscriberBase( + receiveSubscription: { downstreamSubscription1 = $0 } + ) + testObject.$state.subscribe(tracking1) + XCTAssertEqual(tracking1.history, [.subscription("CurrentValueSubject")]) + downstreamSubscription1?.request(.max(2)) + XCTAssertEqual(tracking1.history, [.subscription("CurrentValueSubject"), + .value(0)]) + testObject.state += 1 + testObject.state += 2 + testObject.state += 3 + XCTAssertEqual(tracking1.history, [.subscription("CurrentValueSubject"), + .value(0), + .value(1)]) + downstreamSubscription1?.request(.max(10)) + XCTAssertEqual(tracking1.history, [.subscription("CurrentValueSubject"), + .value(0), + .value(1), + .value(6)]) + + let tracking2 = TrackingSubscriberBase( + receiveSubscription: { $0.request(.unlimited) } + ) + testObject.$state.subscribe(tracking2) + XCTAssertEqual(tracking2.history, [.subscription("CurrentValueSubject"), + .value(6)]) + + testObject.state = 42 + XCTAssertEqual(tracking1.history, [.subscription("CurrentValueSubject"), + .value(0), + .value(1), + .value(6), + .value(42)]) + XCTAssertEqual(tracking2.history, [.subscription("CurrentValueSubject"), + .value(6), + .value(42)]) + + downstreamSubscription1?.cancel() + testObject.state = -1 + XCTAssertEqual(tracking1.history, [.subscription("CurrentValueSubject"), + .value(0), + .value(1), + .value(6), + .value(42)]) + XCTAssertEqual(tracking2.history, [.subscription("CurrentValueSubject"), + .value(6), + .value(42), + .value(-1)]) + } + + func testObservableObjectWithCustomObjectWillChange() { + let testObject = TestObject() + var downstreamSubscription: Subscription? + let tracking1 = TrackingSubscriberBase( + receiveSubscription: { downstreamSubscription = $0 } + ) + testObject.objectWillChange.subscribe(tracking1) + tracking1.assertHistoryEqual([.subscription("PassthroughSubject")]) + downstreamSubscription?.request(.max(2)) + tracking1.assertHistoryEqual([.subscription("PassthroughSubject")]) + testObject.state = 100 + tracking1.assertHistoryEqual([.subscription("PassthroughSubject")]) + } +} + +@available(macOS 10.15, iOS 13.0, *) +private final class TestObject: ObservableObject { + + let objectWillChange = ObservableObjectPublisher() + + @Published var state: Int + + init() { + _state = Published(initialValue: 0) + } +} + +#endif