diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb460e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/B9Condition.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/B9Condition.xcscheme new file mode 100644 index 0000000..e3f1a36 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/B9Condition.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4bfac23 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © 2022 BB9z + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..e41a6cf --- /dev/null +++ b/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "B9Action", + "repositoryURL": "https://github.com/b9swift/Action.git", + "state": { + "branch": null, + "revision": "ad6665b193e5f79658e2bca2a013e99ed6c3dd56", + "version": "1.0.0" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..17308b2 --- /dev/null +++ b/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version:5.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let mainName = "B9Condition" + +// https://developer.apple.com/documentation/swift_packages/package +let package = Package( + name: mainName, +// platforms: [.iOS(.v10), .macOS(.v10_12), .tvOS(.v10), .watchOS(.v3)], + products: [ + // https://developer.apple.com/documentation/swift_packages/product + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: mainName, + targets: [mainName]) + ], + dependencies: [ + // https://developer.apple.com/documentation/swift_packages/target/dependency + // Dependencies declare other packages that this package depends on. + .package(name: "B9Action", url: "https://github.com/b9swift/Action.git", from: "1.0.0") + ], + targets: [ + // https://developer.apple.com/documentation/swift_packages/target + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: mainName, + dependencies: ["B9Action"]), + .testTarget( + name: mainName + "Tests", + dependencies: [ + Target.Dependency(stringLiteral: mainName) + ]) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fec648 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# B9Condition + +[![Swift Version](https://img.shields.io/badge/Swift-5+-F05138.svg?style=flat-square)](https://swift.org) +[![Swift Package Manager](https://img.shields.io/badge/spm-compatible-F05138.svg?style=flat-square)](https://swift.org/package-manager) +[![Build Status](https://img.shields.io/github/workflow/status/b9swift/Condition/Swift?style=flat-square&colorA=555555&colorB=F05138)](https://github.com/b9swift/Condition/actions) +[![gitee 镜像](https://img.shields.io/badge/%E9%95%9C%E5%83%8F-gitee-C61E22.svg?style=flat-square)](https://gitee.com/b9swift/Condition) +[![GitHub Source](https://img.shields.io/badge/Source-GitHub-24292F.svg?style=flat-square)](https://github.com/b9swift/Condition) + +维护一组状态,当满足特定状态时执行相应的监听 +Maintains a set of states that allows it to perform observation actions when the specific states are satisfied. + +## 集成 + +使用 Swift Package Manager 或手工导入。 + +You can also use [GitHub source](https://github.com/b9swift/Condition). + +## Installation + +Using Swift Package Manager or import manually. + +你也可以使用 [gitee 镜像](https://gitee.com/b9swift/Condition)。 diff --git a/Sources/B9Condition/B9Condition.swift b/Sources/B9Condition/B9Condition.swift new file mode 100644 index 0000000..7e87f58 --- /dev/null +++ b/Sources/B9Condition/B9Condition.swift @@ -0,0 +1,235 @@ +/* + B9Condition.swift + + Copyright © 2022 BB9z. + https://github.com/b9swift/Condition + + The MIT License + https://opensource.org/licenses/MIT + */ + +@_exported import B9Action +import Foundation + +/** + 维护一组状态,当满足特定状态时执行相应的监听操作 + Maintains a set of states that allows it to perform observation actions when the specific states are satisfied. + + 支持多线程,可以在任意线程设置状态;当状态满足后,监听将在指定的队列(未指定用 Condition 自身的队列)被触发。但通常情况,状态设置和监听操作建议都用主线程,因为有些极端情况无法做到完美: + Muilt-thread is supported. You can modify states on any thread. The observer will be triggered in its queue or use Condition's if not set, when the status meets. + + 1. 虽然在监听操作执行前会再次检查状态是否满足,即触发时正常都是满足条件的。但可能状态这边刚检查完,又在其他线程被改了,导致操作执行时与预期状态不符; + 1. Observer actions are normally performed with satisfied states by checking the status again before execution. However, it is possible that the states has been changed in other thread just after the check, resulting an action is performed when the states not match the expectation. + 2. 在一个线程周期变化状态,在另外的线程触发监听,最坏情况是监听操作永远不会被执行(到监听队列检查时,状态又被改回了) + 2. When changing state periodically in one thread and observer in another thread, the worst case is that the observer may never execute, as the status are set back while checking in the observer queue. + + 监听操作的执行总是会延后于状态变更 + It always has a delay between status changing and observer triggering. + + 需要有强引用以保持实例,引用解除会立即释放并取消全部任务,但有一个例外: + Instance must be hold with strong refrence, or it will be released and all observe will be canceled immediately. With one exception: + + - 如果有在其他线程执行中的任务,会临时保持实例 + - If there are actions being executed in other threads, this instance will be temporarily held. + + */ +public final class Condition { + + /// 创建 Condition 实例 + /// Create a new Condition instance + /// + /// - Parameter queue: 队列,默认主线程 + /// - Parameter queue: A dispatch queue, use the main queue if not specified. + public init(queue: DispatchQueue = .main) { + self.queue = queue + } + + /// 默认队列,监听若未特别设置队列,则在这个队列上执行 + /// The defualt queue. Observer action is perfromed in this queue if not specified. + public let queue: DispatchQueue + + /// 检查当前状态是否满足给定的标记 + /// Returns whether current states meets the given flags. + public func meets(_ flags: T) -> Bool { + self.flags.isSuperset(of: flags) + } + + /// 开启状态标记 + /// Turn on flags. + /// - Parameter flags: 需要开启的状态 + /// - Parameter flags: Flags needs turn on. + public func set(on flags: T) { + lock.lock() + self.flags = self.flags.union(flags) + flagsChange.set() + lock.unlock() + } + + /// 关掉状态标记 + /// Turn off flags. + /// - Parameter flags: 需要关闭的状态 + /// - Parameter flags: Flags needs turn off. + public func set(off flags: T) { + lock.lock() + self.flags.subtract(flags) + flagsChange.set() + lock.unlock() + } + + /// 添加状态监听,当状态满足时执行操作 + /// Add status observer that performs given action when status are satisfied. + /// + /// - Parameters: + /// - flags: 需要满足的状态 + /// - flags: Status to be satisfied + /// - action: 状态满足时执行的操作 + /// - action: Action performed if the status are satisfied. + /// - queue: 操作执行的队列,可选 + /// - queue: Optional. The queue when the action is performed. + /// - autoRemove: 执行一次后是否自动移除监听,默认不自动移除 + /// - autoRemove: Should remove the observer after action performed. No by default. + /// - Returns: 可以用于取消监听的对象,监听被取消前 `Condition` 会持有该对象 + /// - Returns: An object can used to remove observer. `Condition` strongly holds this return value until the observer is removed. + @discardableResult + public func observe(_ flags: T, action: Action, queue: DispatchQueue? = nil, autoRemove: Bool = false) -> AnyObject { + let observer = Observer(flags, action) + observer.shouldAutoRemove = autoRemove + observer.queue = queue + lock.lock() + observers.append(observer) + lock.unlock() + return observer + } + + /// 移除给定监听 + /// Remove given observer. + /// + /// - Parameter observer: 添加监听方法返回的对象 + /// - Parameter observer: The value returned from the add observer method. + public func remove(observer: AnyObject?) { + guard let observer = observer else { return } + lock.lock() + defer { lock.unlock() } + if let idx = observers.firstIndex(where: { $0 === observer }) { + observers.remove(at: idx) + } + } + + /// 等待状态满足后执行操作 + /// Wait for the satisfied status then perform given action. + /// + /// - Parameters: + /// - flags: 需要满足的状态 + /// - flags: Status to be satisfied + /// - action: 状态满足时执行的操作 + /// - action: Action performed if the status are satisfied. + /// - timeout: 超时,大于 0 时启用,超时操作会被丢弃 + /// - timeout: Enable timeout when it is greater than 0. Observer will be discarded if times out. + public func wait(_ flags: T, action: Action, timeout: TimeInterval = 0) { + let observer = Observer(flags, action) + observer.shouldAutoRemove = true + appendAndCheck(observer: observer) + if timeout > 0 { + queue.asyncAfter(deadline: .now() + timeout) { [weak self, weak observer] in + guard let sf = self, let observer = observer else { return } + sf.remove(observer: observer) + } + } + } + + // MARK: - + + internal var flags = T() + internal var observers = [Observer]() + /// flags 写保护,observers 读写保护 + private let lock = NSLock() + + private lazy var flagsChange = DelayAction(Action({ [weak self] in + self?.onFlagsChange() + }, reference: nil), delay: 0, queue: queue) + + private func onFlagsChange() { + lock.lock() + let observersSnapshot = observers + lock.unlock() + if observersSnapshot.isEmpty { return } + for observer in observersSnapshot { + if meets(observer.flags) { + if !observer.hasCalledWhenMeet { + if let otherQueue = observer.queue { + otherQueue.async { + // 需临时保持实例 + if self.meets(observer.flags) { + self.execute(observer: observer) + } + } + } else { + execute(observer: observer) + } + } + } else { + if observer.hasCalledWhenMeet { + observer.hasCalledWhenMeet = false + } + } + } + } + + internal final class Observer: CustomDebugStringConvertible { + let flags: T + let action: Action + var shouldAutoRemove = false + var hasCalledWhenMeet = false + var queue: DispatchQueue? + + init(_ flags: T, _ action: Action) { + self.flags = flags + self.action = action + } + + internal var debugDescription: String { + let properties: [(String, Any?)] = [("flags", flags), ("queue", queue), ("shouldAutoRemove", shouldAutoRemove), ("action", action)] + let propertyDiscription = properties.compactMap { key, value in + if let value = value { + return "\(key) = \(value)" + } + return nil + }.joined(separator: ", ") + return "" + } + } + + private func execute(observer: Observer) { + assert(meets(observer.flags)) + observer.action.perform(with: nil) + observer.hasCalledWhenMeet = true + if observer.shouldAutoRemove { + remove(observer: observer) + } + } + + private func appendAndCheck(observer: Observer) { + lock.lock() + observers.append(observer) + lock.unlock() + (observer.queue ?? queue).async { [weak self, weak observer] in + guard let sf = self, let observer = observer else { return } + if sf.meets(observer.flags) { + sf.execute(observer: observer) + } + } + } +} + +extension Condition: CustomDebugStringConvertible { + public var debugDescription: String { + let properties: [(String, Any?)] = [("flags", flags), ("queue", queue), ("observers", observers)] + let propertyDiscription = properties.compactMap { key, value in + if let value = value { + return "\(key) = \(value)" + } + return nil + }.joined(separator: ", ") + return "" + } +} diff --git a/Tests/B9ConditionTests/B9ConditionTests.swift b/Tests/B9ConditionTests/B9ConditionTests.swift new file mode 100644 index 0000000..1135e1e --- /dev/null +++ b/Tests/B9ConditionTests/B9ConditionTests.swift @@ -0,0 +1,253 @@ +@testable import B9Condition +import XCTest + +// swiftlint:disable identifier_name + +enum EnumFlag { +case a, b, c, d +} + +struct OptionFlag: OptionSet { + let rawValue: Int + + static let a = OptionFlag(rawValue: 1 << 0) + static let b = OptionFlag(rawValue: 1 << 1) + static let c = OptionFlag(rawValue: 1 << 2) + static let d = OptionFlag(rawValue: 1 << 3) +} + +final class B9ConditionTests: XCTestCase { + + func testBasicSetAndMeets() { + let enumCondition = Condition>() + XCTAssertFalse(enumCondition.meets([.a])) + enumCondition.set(on: [.a]) + XCTAssertTrue(enumCondition.meets([.a])) + enumCondition.set(on: [.b, .c]) + XCTAssertTrue(enumCondition.meets([.a, .b, .c])) + XCTAssertFalse(enumCondition.meets([.a, .b, .c, .d])) + enumCondition.set(off: [.a, .b]) + XCTAssertTrue(enumCondition.meets([.c])) + XCTAssertFalse(enumCondition.meets([.a])) + + let optionCondition = Condition() + XCTAssertFalse(optionCondition.meets([.a])) + optionCondition.set(on: .a) + XCTAssertTrue(optionCondition.meets([.a])) + optionCondition.set(on: [.b, .c]) + XCTAssertTrue(optionCondition.meets([.a, .b, .c])) + XCTAssertFalse(optionCondition.meets([.a, .b, .c, .d])) + optionCondition.set(off: [.a, .b]) + XCTAssertTrue(optionCondition.meets(.c)) + XCTAssertFalse(optionCondition.meets(.a)) + } + + func testHasCalledWhenMeet() { + let condition = Condition>() + let observerQueue = DispatchQueue(label: "Observer", qos: .userInteractive) + var counter = 0 + condition.observe([.a], action: Action { + counter += 1 + if counter == 1 { + print("observer 1st") + } else if counter == 2 { + print("observer 2nd") + } else { + fatalError("Should not hanppen") + } + }, queue: observerQueue, autoRemove: false) + after(0.1) { + print("1. set on a") + condition.set(on: [.a]) + } + after(0.2) { + print("2. set on b") + condition.set(on: [.b]) + } + after(0.21) { + print("2. set off b") + condition.set(off: [.b]) + } + after(0.3) { + print("3. set off a") + condition.set(off: [.a]) + } + after(0.4) { + print("4. set on a") + condition.set(on: [.a]) + } + let testEnd = XCTestExpectation() + after(0.5) { + print("\(#function) end") + testEnd.fulfill() + } + wait(for: [testEnd], timeout: 1) + } + + func testWaitBeforeMeet() { + let condition = Condition() + let testEnd = XCTestExpectation() + condition.wait(.a, action: Action { + testEnd.fulfill() + }) + after(0) { + condition.set(on: .a) + } + XCTAssertEqual(condition.observers.count, 1) + wait(for: [testEnd], timeout: 1) + XCTAssertEqual(condition.observers.count, 0) + } + + func testWaitAfterMeet() { + let condition = Condition() + let testEnd = XCTestExpectation() + condition.set(on: .a) + condition.wait(.a, action: Action { + testEnd.fulfill() + }) + wait(for: [testEnd], timeout: 1) + XCTAssertEqual(condition.observers.count, 0) + } + + func testWaitDelayAfterMeet() { + let condition = Condition() + let testEnd = XCTestExpectation() + condition.set(on: .a) + after(0.1) { + condition.wait(.a, action: Action { + testEnd.fulfill() + }) + } + wait(for: [testEnd], timeout: 1) + XCTAssertEqual(condition.observers.count, 0) + } + + func testWaitTimout() { + let condition = Condition() + condition.wait(.a, action: Action { + fatalError("Never") + }, timeout: 0.1) + let testEnd = XCTestExpectation() + after(0.2) { + testEnd.fulfill() + } + wait(for: [testEnd], timeout: 1) + XCTAssertEqual(condition.observers.count, 0) + } + + func testAddAndRemoveObserver() { + let condition = Condition() + var isCalled = false + weak var observer = condition.observe(.a, action: Action { + guard !isCalled else { + fatalError() + } + isCalled = true + }) + func refill() { + condition.set(off: .a) + after(0) { + condition.set(on: .a) + } + } + + condition.set(on: .a) + XCTAssertFalse(isCalled, "Should called after delay") + after(0) { + XCTAssertTrue(isCalled) + + // Reset + isCalled = false + refill() + } + after(0.1) { + XCTAssertTrue(isCalled, "Should called after reset") + + XCTAssertNotNil(observer, "Observer is keeped by condition") + condition.remove(observer: observer) + XCTAssertNil(observer, "After remove, observer should autoreleased") + + // Remove nil has no effect + condition.remove(observer: nil) + + // Then should not called + refill() + } + + let testEnd = XCTestExpectation() + after(0.2) { + testEnd.fulfill() + } + wait(for: [testEnd], timeout: 1) + } + + func testLowPriorityObservingMayNeverCalled() { + let observerQueue = DispatchQueue(label: "LowPriority", qos: .background) + var observerCalledCounter = 0 + let condition = Condition() + condition.observe(.a, action: Action({ + // 不太好造一个完全能避免执行的场景,调用几率小于一定程度即可 + observerCalledCounter += 1 + debugPrint("LowPriority called") + }, reference: nil), queue: observerQueue) + for _ in 0..<100 { + OperationQueue.main.addOperation { + condition.set(on: .a) + self.after(0) { + condition.set(off: .a) + } + } + } + let testEnd = XCTestExpectation() + OperationQueue.main.addOperation { + testEnd.fulfill() + } + wait(for: [testEnd], timeout: 10) + debugPrint("Called", observerCalledCounter) + XCTAssert(observerCalledCounter < 20) + } + + func testDebugDescription() { + let condition = Condition>() + condition.set(on: [.a, .b, .c]) + condition.wait([.a], action: Action {}, timeout: 1) + condition.observe([.b, .d], action: Action {}) + debugPrint(condition) + } + + func testReleaseWithoutTimeout() { + strongRefrence = Condition() + strongRefrence.wait(.a, action: Action({ + fatalError("Should not executed") + }, reference: nil), timeout: 0) + strongRefrence.set(on: [.a]) + strongRefrence = nil + let testEnd = XCTestExpectation() + after(0.1) { + testEnd.fulfill() + } + wait(for: [testEnd], timeout: 2) + } + + func testReleaseWithTimeout() { + strongRefrence = Condition() + strongRefrence.wait(.a, action: Action({ + fatalError("Should not executed") + }, reference: nil), timeout: 1) + after(0.1) { + self.strongRefrence.set(on: [.a]) + self.strongRefrence = nil + } + let testEnd = XCTestExpectation() + after(0.2) { + testEnd.fulfill() + } + wait(for: [testEnd], timeout: 2) + } + + var strongRefrence: Condition! + + private func after(_ second: TimeInterval, do work: @escaping () -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + second, execute: work) + } +}