Skip to content

Commit

Permalink
Debouncers
Browse files Browse the repository at this point in the history
  • Loading branch information
plajdo committed Oct 25, 2024
1 parent d0be030 commit 75a7200
Show file tree
Hide file tree
Showing 14 changed files with 323 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Alamofire/Alamofire.git",
"state" : {
"revision" : "f455c2975872ccd2d9c81594c658af65716e9b9a",
"version" : "5.9.1"
"revision" : "e16d3481f5ed35f0472cb93350085853d754913f",
"version" : "5.10.1"
}
},
{
Expand Down Expand Up @@ -42,8 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/GoodRequest/GoodLogger.git",
"state" : {
"revision" : "4c5761a062fd2a98c9b81078a029a14b67ccab2a",
"version" : "1.1.0"
"revision" : "8203802fcff9163a309bd5edd44cc6f649404050",
"version" : "1.2.4"
}
},
{
Expand Down
12 changes: 12 additions & 0 deletions Sources/GoodReactor/Identifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,15 @@ public extension Identifier {
}

}

// MARK: - Code location identifier

public final class CodeLocationIdentifier: Identifier {

public let id: String

public init(_ file: StaticString = #file, _ line: UInt = #line, _ column: UInt = #column) {
self.id = "\(file):\(line):\(column)"
}

}
18 changes: 17 additions & 1 deletion Sources/GoodReactor/MapTables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,31 @@ internal enum MapTables {

typealias AnyReactor = AnyObject

// State of a reactor
static let state = WeakMapTable<AnyReactor, Any>()

// Initial state of a reactor
static let initialState = WeakMapTable<AnyReactor, Any>()
static let destinations = WeakMapTable<AnyReactor, Any?>()

// Number of currently running asynchronous tasks for an event of a reactor
static let runningEvents = WeakMapTable<AnyReactor, Set<EventIdentifier>>()

// Subscriptions of a reactor (new way)
static let subscriptions = WeakMapTable<AnyReactor, Set<AnyTask>>()

// Debouncers of a reactor
static let debouncers = WeakMapTable<AnyReactor, Dictionary<DebouncerIdentifier, Any>>()

// State stream cancellable (Combine)
static let stateStreams = WeakMapTable<AnyReactor, Any>()

// Event stream cancellable (Combine)
static let eventStreams = WeakMapTable<AnyReactor, Any>()

// Logger of a reactor
static let loggers = WeakMapTable<AnyReactor, GoodLogger>()

// Semaphore lock of an event (does not matter which reactor it's running on)
static let eventLocks = WeakMapTable<EventIdentifier, AsyncSemaphore>()

}
74 changes: 71 additions & 3 deletions Sources/GoodReactor/Reactor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,9 @@ public extension Reactor {
// MARK: - Public

public extension Reactor {


typealias DebouncerResultHandler = (@Sendable () async -> Mutation?)

/// Send an action to this Reactor. The Reactor will decide how to modify
/// the ``State`` according to the implementation of ``reduce(state:event:)``
/// function.
Expand Down Expand Up @@ -305,7 +307,7 @@ public extension Reactor {
}

/// Asynchronously runs a handler associated with an event. If handler returns a mutation,
/// handler waits until the mutation is reduced before returning.
/// the entire event waits until the mutation is reduced before continuing.
///
/// - Parameters:
/// - event: Event responsible for starting the asynchronous task.
Expand All @@ -316,7 +318,12 @@ public extension Reactor {
/// This function doesn't block.
///
/// - important: Start async events only from ``reduce(state:event:)`` to ensure correct behaviour.
func run(_ event: Event, _ eventHandler: @autoclosure @escaping () -> @Sendable () async -> Mutation?) {
/// - warning: This function is unavailable from asynchronous contexts. If you need to run multiple tasks
/// concurrently, create a `TaskGroup` with ``_Concurrency/Task``s, use `async let` or consider using
/// an external helper struct.
/// - note: If you need to return multiple mutations from an asynchronous event, create a helper struct
/// and supply mutations using a ``Publisher`` and ``subscribe(to:map:)``
@available(*, noasync) func run(_ event: Event, _ eventHandler: @autoclosure @escaping () -> @Sendable () async -> Mutation?) {
let semaphore = MapTables.eventLocks[key: event.id, default: AsyncSemaphore(value: 0)]
MapTables.runningEvents[key: self, default: []].insert(event.id)

Expand All @@ -341,6 +348,67 @@ public extension Reactor {
}
}
}

/// Debounces calls to a function by ignoring repeated successive calls. If handler returns a mutation,
/// the mutation will be executed once when debouncing is
///
/// ## Usage
/// ```swift
/// debounce(duration: .seconds(1)) {
/// await sendDebouncedNetworkRequest()
/// }
/// ```
///
/// - Parameters:
/// - duration: Time interval the debouncer will wait for any repeated calls.
/// - resultHandler: Function that will be called when there are no more
/// events to debounce and enough time has passed.
/// - file: Internal debouncer identifier macro, do not pass any value.
/// - line: Internal debouncer identifier macro, do not pass any value.
/// - column: Internal debouncer identifier macro, do not pass any value.
///
/// This function doesn't block.
///
/// - note: Each debouncer is identified by its location in source code. If you need to
/// supply events to a debouncer from multiple locations, use an instance of a ``Debouncer``
/// directly.
func debounce(
duration: DispatchTimeInterval,
resultHandler: @escaping DebouncerResultHandler,
_ file: StaticString = #file,
_ line: UInt = #line,
_ column: UInt = #column
) {
let debouncerIdentifier = DebouncerIdentifier(file, line, column)

let localDebouncer: Debouncer<DebouncerResultHandler>
if let debouncer = MapTables.debouncers[key: self, default: [:]][debouncerIdentifier] as? Debouncer<DebouncerResultHandler> {
localDebouncer = debouncer
} else {
// create new debouncer
localDebouncer = Debouncer<DebouncerResultHandler>(delay: duration, outputHandler: { @MainActor [weak self] resultHandler in
guard let self else { return }

let mutation = await Task.detached { await resultHandler() }.value

guard !Task.isCancelled else {
_debugLog(message: "Debounce cancelled")
return
}

if let mutation {
let mutationEvent = Event(kind: .mutation(mutation))
_send(event: mutationEvent)
}
})

MapTables.debouncers[key: self, default: [:]][debouncerIdentifier] = localDebouncer
}

Task {
await localDebouncer.push(resultHandler)
}
}

/// Create a new subscription to external event publisher for current reactor.
///
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
167 changes: 167 additions & 0 deletions Sources/GoodReactor/Utilities/Debouncer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//
// Debouncer.swift
// GoodReactor
//
// Created by Filip Šašala on 25/10/2024.
//

import Foundation

/// Executes an output only after a specified time interval elapses between events
///
/// ```swift
/// let debouncer = Debouncer<Int>(dueTime: .seconds(2), output: { print($0) })
///
/// for index in (0...99) {
/// DispatchQueue.global().asyncAfter(deadline: .now().advanced(by: .milliseconds(100 * index))) {
/// // pushes a value every 100 ms
/// debouncer.push(index)
/// }
/// }
///
/// // will only print "99" 2 seconds after the last call to `push(_:)`
/// ```
public actor Debouncer<Value: Sendable> {

// MARK: - Enums

typealias DueValue = (value: Value, dueTime: DispatchTime)

enum State {

case idle
case debouncing(value: DueValue, nextValue: DueValue?)

}

enum DebouncingResult {

case continueDebouncing(DueValue)
case finishDebouncing

}

// MARK: - Properties

public var outputHandler: (@isolated(any) (Value) async -> Void)?
public var timeInterval: DispatchTimeInterval

private var state: State
private var task: Task<Void, Never>?

// MARK: - Initialization

/// A debouncer that executes output only after a specified time has elapsed between events.
/// - Parameters:
/// - delay: How long the debouncer should wait before executing the output
/// - outputHandler: Handler to execute once the debouncing is done
public init(
delay timeInterval: DispatchTimeInterval = .never,
@_implicitSelfCapture outputHandler: (@isolated(any) @Sendable (Value) async -> Void)? = nil
) {
self.state = .idle
self.timeInterval = timeInterval
self.outputHandler = outputHandler
}

deinit {
print("cancelling task")
task?.cancel()
}

}

// MARK: - Public

public extension Debouncer {

/// Send an updated value to debouncer.
func push(_ value: Value) {
let newValue = DueValue(value: value, dueTime: DispatchTime.now().advanced(by: timeInterval))

switch self.state {
case .idle:
self.state = .debouncing(value: newValue, nextValue: nil)
self.task = makeNewDebouncingTask(value)

case .debouncing(let current, _):
self.state = .debouncing(value: current, nextValue: newValue)
}
}

}

// MARK: - Private

private extension Debouncer {

private func makeNewDebouncingTask(_ value: Value) -> Task<Void, Never> {
return Task<Void, Never> {
var timeToSleep = timeInterval.nanoseconds
var currentValue = value

loop: while true {
try? await Task.sleep(nanoseconds: timeToSleep)

let result: DebouncingResult
switch self.state {
case .idle:
assertionFailure("inconsistent state, a value was being debounced")
result = .finishDebouncing

case .debouncing(_, nextValue: .some(let nextValue)):
state = .debouncing(value: nextValue, nextValue: nil)
result = .continueDebouncing(nextValue)

case .debouncing(_, nextValue: .none):
state = .idle
result = .finishDebouncing
}

switch result {
case .finishDebouncing:
break loop

case .continueDebouncing(let value):
timeToSleep = DispatchTime.now().distance(to: value.dueTime).nanoseconds
currentValue = value.value
}
}

await outputHandler?(currentValue)
}
}

}

// MARK: - Extensions

fileprivate extension DispatchTimeInterval {

var nanoseconds: UInt64 {
switch self {
case .nanoseconds(let value) where value >= 0:
return UInt64(value)

case .microseconds(let value) where value >= 0:
return UInt64(value) * 1000

case .milliseconds(let value) where value >= 0:
return UInt64(value) * 1_000_000

case .seconds(let value) where value >= 0:
return UInt64(value) * 1_000_000_000

case .never:
return .zero

default:
return .zero
}
}

}

// MARK: - Identifier

internal typealias DebouncerIdentifier = CodeLocationIdentifier
File renamed without changes.
File renamed without changes.
File renamed without changes.
17 changes: 17 additions & 0 deletions Tests/GoodReactorTests/GoodReactorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,21 @@ final class GoodReactorTests: XCTestCase {
withExtendedLifetime(cancellable, {})
}

@MainActor func testDebounce() async {
let model = ObservableModel()

XCTAssertEqual(model.state.counter, 9)

for _ in 0..<10 {
await model.send(action: .debounceTest) // send event 10x in a second
try? await Task.sleep(for: .milliseconds(100))
}

XCTAssertEqual(model.state.counter, 9) // event should be waiting in debouncer

try? await Task.sleep(for: .seconds(1))

XCTAssertEqual(model.state.counter, 10) // event should be debounced by now
}

}
Loading

0 comments on commit 75a7200

Please sign in to comment.