Skip to content

Instantly share code, notes, and snippets.

@parrots
Last active May 17, 2021 10:44
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save parrots/f1a6ca9c9924905fd1bd12cfb640337a to your computer and use it in GitHub Desktop.
Save parrots/f1a6ca9c9924905fd1bd12cfb640337a to your computer and use it in GitHub Desktop.
A Swifty version of an AsyncOperation which solves two common problems I ran into. (see comment below for issues addressed)
//
// AsyncOperation.swift
// Slopes
//
import Foundation
class AsyncOperation: Operation {
private let stateLock = NSLock()
private var observers: [NSKeyValueObservation] = [NSKeyValueObservation]()
override func addDependency(_ op: Operation) {
isReady = false
super.addDependency(op)
let readyChecker: (Any, Any) -> Void = { [weak self] _, _ in
guard let self = self else {
return
}
if self.dependencies.filter({ !$0.isFinished && !$0.isCancelled }).isEmpty {
self.isReady = true
}
}
observers.append(op.observe(\.isFinished, options: [], changeHandler: readyChecker))
observers.append(op.observe(\.isCancelled, options: [], changeHandler: readyChecker))
}
deinit {
for observer in observers {
observer.invalidate()
}
}
private var _ready: Bool = true
override var isReady: Bool {
get {
return stateLock.withCriticalScope { _ready }
}
set {
willChangeValue(forKey: "isReady")
stateLock.withCriticalScope {
if _ready != newValue {
_ready = newValue
}
}
didChangeValue(forKey: "isReady")
}
}
private var _executing: Bool = false
override var isExecuting: Bool {
get {
return stateLock.withCriticalScope { _executing }
}
set {
willChangeValue(forKey: "isExecuting")
stateLock.withCriticalScope {
if _executing != newValue {
_executing = newValue
}
}
didChangeValue(forKey: "isExecuting")
}
}
private var _finished: Bool = false
override var isFinished: Bool {
get {
return stateLock.withCriticalScope { _finished }
}
set {
willChangeValue(forKey: "isFinished")
stateLock.withCriticalScope {
if _finished != newValue {
_finished = newValue
}
}
didChangeValue(forKey: "isFinished")
}
}
override func cancel() {
super.cancel()
if isExecuting {
isExecuting = false
}
}
override var isAsynchronous: Bool {
return true
}
}
extension NSLock {
func withCriticalScope<T>(block: () -> T) -> T {
lock()
let value = block()
unlock()
return value
}
}
@parrots
Copy link
Author

parrots commented Dec 21, 2018

Problems addressed:

  1. It uses an NSLock around state changes. Without this, I experienced a non-trivial amount of crashes in production via Crashlytics during state changes.
  2. Allows for adding dependencies after the operation has already been added to the queue. Normally, the isReady state is not updated if you add a dependency after it is already on the queue, so this way you can inject last-minute dependencies (like realizing the user's JWT is expired and all server tasks need to wait on a token refresh).

@parrots
Copy link
Author

parrots commented Dec 21, 2018

This code is MIT license, BTW. Feel free to use it in your own project as you see fit!

@parrots
Copy link
Author

parrots commented Feb 18, 2020

This async operation ran into issues with a new race condition in the bowls of NSOperationQueue in iOS 13 (was rock-solid on 12). Changed the recommended code to the following, which has been crash-free. It also formalizes a use of finish / execute setup.

//
//  AsyncOperation.swift
//  Slopes
//
/* References:
https://github.com/richardtin/Advanced-NSOperations/blob/672023bc066eae6c3a9b2ed985b8c40a4e604a77/Earthquakes/Operations/Operation.swift
https://stackoverflow.com/a/48104095
*/
import Foundation
open class AsyncOperation: Operation {
    @objc private enum OperationState: Int {
        case ready
        case executing
        case finished
    }
    private let stateQueue = DispatchQueue(label: Bundle.main.bundleIdentifier! + ".rw.state", attributes: .concurrent)
    private var _state: OperationState = .ready
    @objc private dynamic var state: OperationState {
        get { return stateQueue.sync { _state } }
        set { stateQueue.async(flags: .barrier) { self._state = newValue } }
    }
    // MARK: - Various async-needed `NSOperation` properties
    open         override var isReady:        Bool { return state == .ready && super.isReady }
    public final override var isExecuting:    Bool { return state == .executing }
    public final override var isFinished:     Bool { return state == .finished }
    public final override var isAsynchronous: Bool { return true }
    open override class func keyPathsForValuesAffectingValue(forKey key: String) -> Set<String> {
        //iOS 10 uses ready, finished, and executing. Older versions observed the is- preixed ones
        if ["ready", "finished", "executing"].contains(key) {
            return [#keyPath(state)]
        }
        return super.keyPathsForValuesAffectingValue(forKey: key)
    }
    // MARK: - Execution-related methods
    public final override func start() {
        super.start()
        if isCancelled {
            state = .finished
            return
        }
        state = .executing
        execute()
    }
    open func execute() {
        fatalError("Subclasses must implement `execute`.")
    }
    public final func finish() {
        if !isFinished { state = .finished }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment