【问题标题】:How to convert DispatchQueue debounce to Swift Concurrency task?如何将 DispatchQueue debounce 转换为 Swift Concurrency 任务?
【发布时间】:2021-12-15 15:10:05
【问题描述】:

我有一个使用DispatchQueue 的现有去抖实用程序。它接受一个闭包并在达到时间阈值之前执行它。可以这样使用:

let limiter = Debouncer(limit: 5)
var value = ""

func sendToServer() {
    limiter.execute {
        print("\(Date.now.timeIntervalSince1970): Fire! \(value)")
    }
}

value.append("h")
sendToServer() // Waits until 5 seconds
value.append("e")
sendToServer() // Waits until 5 seconds
value.append("l")
sendToServer() // Waits until 5 seconds
value.append("l")
sendToServer() // Waits until 5 seconds
value.append("o")
sendToServer() // Waits until 5 seconds
print("\(Date.now.timeIntervalSince1970): Last operation called")

// 1635691696.482115: Last operation called
// 1635691701.859087: Fire! hello

请注意,它不是多次调用Fire!,而是在最后一次使用最后一个任务的值后 5 秒。 Debouncer 实例配置为将队列中的最后一个任务保持 5 秒,无论调用多少次。闭包传入execute(block:)方法:

final class Debouncer {
    private let limit: TimeInterval
    private let queue: DispatchQueue
    private var workItem: DispatchWorkItem?
    private let syncQueue = DispatchQueue(label: "Debouncer", attributes: [])
   
    init(limit: TimeInterval, queue: DispatchQueue = .main) {
        self.limit = limit
        self.queue = queue
    }
    
    @objc func execute(block: @escaping () -> Void) {
        syncQueue.async { [weak self] in
            if let workItem = self?.workItem {
                workItem.cancel()
                self?.workItem = nil
            }
            
            guard let queue = self?.queue, let limit = self?.limit else { return }
            
            let workItem = DispatchWorkItem(block: block)
            queue.asyncAfter(deadline: .now() + limit, execute: workItem)
            
            self?.workItem = workItem
        }
    }
}

如何将其转换为并发操作,以便可以如下调用:

let limit = Debouncer(limit: 5)

func sendToServer() {
    await limiter.waitUntilFinished
    print("\(Date.now.timeIntervalSince1970): Fire! \(value)")
}

sendToServer()
sendToServer()
sendToServer()

但是,这不会使任务去抖动,而是暂停它们直到下一个被调用。相反,它应该取消前一个任务并保持当前任务直到去抖动时间。这可以通过Swift Concurrency 完成还是有更好的方法来做到这一点?

【问题讨论】:

    标签: swift grand-central-dispatch debounce swift-concurrency


    【解决方案1】:

    根据@Rob 的出色回答,这里有一个使用actorTask 的示例:

    actor Limiter {
        enum Policy {
            case throttle
            case debounce
        }
    
        private let policy: Policy
        private let duration: TimeInterval
        private var task: Task<Void, Never>?
    
        init(policy: Policy, duration: TimeInterval) {
            self.policy = policy
            self.duration = duration
        }
    
        nonisolated func callAsFunction(task: @escaping () async -> Void) {
            Task {
                switch policy {
                case .throttle:
                    await throttle(task: task)
                case .debounce:
                    await debounce(task: task)
                }
            }
        }
    
        private func throttle(task: @escaping () async -> Void) {
            guard self.task?.isCancelled ?? true else { return }
    
            Task {
                await task()
            }
    
            self.task = Task {
                try? await sleep()
                self.task?.cancel()
                self.task = nil
            }
        }
    
        private func debounce(task: @escaping () async -> Void) {
            self.task?.cancel()
    
            self.task = Task {
                do {
                    try await sleep()
                    guard !Task.isCancelled else { return }
                    await task()
                } catch {
                    return
                }
            }
        }
    
        private func sleep() async throws {
            try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
        }
    }
    

    测试在通过时不一致,所以我认为我对任务触发顺序的假设是不正确的,但我认为示例是一个好的开始:

    final class LimiterTests: XCTestCase {
        func testThrottler() async throws {
            // Given
            let promise = expectation(description: "Ensure first task fired")
            let throttler = Limiter(policy: .throttle, duration: 1)
            var value = ""
    
            var fulfillmentCount = 0
            promise.expectedFulfillmentCount = 2
    
            func sendToServer(_ input: String) {
                throttler {
                    value += input
    
                    // Then
                    switch fulfillmentCount {
                    case 0:
                        XCTAssertEqual(value, "h")
                    case 1:
                        XCTAssertEqual(value, "hwor")
                    default:
                        XCTFail()
                    }
    
                    promise.fulfill()
                    fulfillmentCount += 1
                }
            }
    
            // When
            sendToServer("h")
            sendToServer("e")
            sendToServer("l")
            sendToServer("l")
            sendToServer("o")
    
            await sleep(2)
    
            sendToServer("wor")
            sendToServer("ld")
    
            wait(for: [promise], timeout: 10)
        }
    
        func testDebouncer() async throws {
            // Given
            let promise = expectation(description: "Ensure last task fired")
            let limiter = Limiter(policy: .debounce, duration: 1)
            var value = ""
    
            var fulfillmentCount = 0
            promise.expectedFulfillmentCount = 2
    
            func sendToServer(_ input: String) {
                limiter {
                    value += input
    
                    // Then
                    switch fulfillmentCount {
                    case 0:
                        XCTAssertEqual(value, "o")
                    case 1:
                        XCTAssertEqual(value, "old")
                    default:
                        XCTFail()
                    }
    
                    promise.fulfill()
                    fulfillmentCount += 1
                }
            }
    
            // When
            sendToServer("h")
            sendToServer("e")
            sendToServer("l")
            sendToServer("l")
            sendToServer("o")
    
            await sleep(2)
    
            sendToServer("wor")
            sendToServer("ld")
    
            wait(for: [promise], timeout: 10)
        }
    
        func testThrottler2() async throws {
            // Given
            let promise = expectation(description: "Ensure throttle before duration")
            let throttler = Limiter(policy: .throttle, duration: 1)
    
            var end = Date.now + 1
            promise.expectedFulfillmentCount = 2
    
            func test() {
                // Then
                XCTAssertLessThan(.now, end)
                promise.fulfill()
            }
    
            // When
            throttler(task: test)
            throttler(task: test)
            throttler(task: test)
            throttler(task: test)
            throttler(task: test)
    
            await sleep(2)
            end = .now + 1
    
            throttler(task: test)
            throttler(task: test)
            throttler(task: test)
    
            await sleep(2)
    
            wait(for: [promise], timeout: 10)
        }
    
        func testDebouncer2() async throws {
            // Given
            let promise = expectation(description: "Ensure debounce after duration")
            let debouncer = Limiter(policy: .debounce, duration: 1)
    
            var end = Date.now + 1
            promise.expectedFulfillmentCount = 2
    
            func test() {
                // Then
                XCTAssertGreaterThan(.now, end)
                promise.fulfill()
            }
    
            // When
            debouncer(task: test)
            debouncer(task: test)
            debouncer(task: test)
            debouncer(task: test)
            debouncer(task: test)
    
            await sleep(2)
            end = .now + 1
    
            debouncer(task: test)
            debouncer(task: test)
            debouncer(task: test)
    
            await sleep(2)
    
            wait(for: [promise], timeout: 10)
        }
    
        private func sleep(_ duration: TimeInterval) async {
            await Task.sleep(UInt64(duration * 1_000_000_000))
        }
    }
    

    【讨论】:

    【解决方案2】:

    任务可以使用isCancelledcheckCancellation,但是为了去抖动例程,您想等待一段时间,您可能只使用Task.sleep(nanoseconds:) 的投掷演绎,其documentation 说:

    如果任务在时间结束前被取消,该函数抛出CancellationError

    因此,这有效地消除了 2 秒的抖动。

    var task: Task<(), Never>?
    
    func debounced(_ string: String) {
        task?.cancel()
    
        task = Task {
            do {
                try await Task.sleep(nanoseconds: 2_000_000_000)
                logger.log("result \(string)")
            } catch {
                logger.log("canceled \(string)")
            }
        }
    }
    

    (为什么 Apple 恢复到纳秒是我无法理解的。)

    注意,sleep(nanoseconds:) 的 non-throwing 演绎版不会检测到取消,所以你必须使用这个 throwing 演绎版。

    【讨论】:

    • 感谢您的信息!我用正在进行的工作和测试更新了我的答案。我希望这是有道理的
    • 有道理,完成。非常感谢!
    猜你喜欢
    • 1970-01-01
    • 2019-09-24
    • 1970-01-01
    • 1970-01-01
    • 2018-11-01
    • 2014-03-15
    • 2017-02-23
    • 2023-03-18
    相关资源
    最近更新 更多