简体   繁体   中英

How to convert DispatchQueue debounce to Swift Concurrency task?

I have an existing debouncer utility using DispatchQueue . It accepts a closure and executes it before the time threshold is met. It can be used like this:

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

Notice it is not calling Fire! multiple times, but just 5 seconds after the last time with the value from the last task. The Debouncer instance is configured to hold the last task in queue for 5 seconds no matter how many times it is called. The closure is passed into the execute(block:) method:

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
        }
    }
}

How can I convert this into a concurrent operation so it can be called like below:

let limit = Debouncer(limit: 5)

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

sendToServer()
sendToServer()
sendToServer()

However, this wouldn't debounce the tasks but suspend them until the next one gets called. Instead it should cancel the previous task and hold the current task until the debounce time. Can this be done with Swift Concurrency or is there a better approach to do this?

Tasks have the ability to use isCancelled or checkCancellation , but for the sake of a debounce routine, where you want to wait for a period of time, you might just use the throwing rendition of Task.sleep(nanoseconds:) , whose documentation says:

If the task is canceled before the time ends, this function throws CancellationError .

Thus, this effectively debounces for 2 seconds.

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)")
        }
    }
}

(Why Apple reverted to nanoseconds is beyond me.)

Note, the non-throwing rendition of sleep(nanoseconds:) does not detect cancelation, so you have to use this throwing rendition.

Based on @Rob's great answer, here's a sample using an actor and Task :

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))
    }
}

The tests are inconsistent in passing so I think my assumption on the order of the tasks firing is incorrect, but the sample is a good start I think:

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))
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM