ChickensDontClap ChickensDontClap - 5 months ago 64
iOS Question

Resetting NSOperation

My network requests need to happen in a FIFO serial fashion. If one fails due to a network issue (i.e. offline, timeout) I need the failed request to retry before continuing the queue. How can I accomplish this?

When the operation fails, I set executing and finished both to - assuming that would work as a 'reset' for the

NSOperation
. But the queue never resumes.

Is there something I'm missing?

Rob Rob
Answer

You could build a network operation that performs retries, and only sets isFinished when the network completion block is called, and it determines that no retry is needed:

class NetworkOperationWithRetry: AsynchronousOperation {
    var session: NSURLSession
    var request: NSURLRequest
    var task: NSURLSessionTask?
    var networkCompletionHandler: ((NSData?, NSURLResponse?, NSError?) -> ())?

    init(session: NSURLSession = NSURLSession.sharedSession(), request: NSURLRequest, networkCompletionHandler: (NSData?, NSURLResponse?, NSError?) -> ()) {
        self.session = session
        self.request = request
        self.networkCompletionHandler = networkCompletionHandler
    }

    override func main() {
        attemptRequest()
    }

    func attemptRequest() {
        print("attempting \(request.URL!.lastPathComponent)")

        task = session.dataTaskWithRequest(request) { data, response, error in
            if error?.domain == NSURLErrorDomain && error?.code == NSURLErrorNotConnectedToInternet {
                print("will retry \(self.request.URL!.lastPathComponent)")
                dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * Int64(NSEC_PER_SEC)), dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0)) {
                    self.attemptRequest()
                }
                return
            }

            print("finished \(self.request.URL!.lastPathComponent)")

            self.networkCompletionHandler?(data, response, error)
            self.networkCompletionHandler = nil
            self.completeOperation()
        }
        task?.resume()
    }

    override func cancel() {
        task?.cancel()
        super.cancel()
    }
}

And you could call it like so:

let queue = NSOperationQueue()
queue.maxConcurrentOperationCount = 1     // I wouldn't generally do this, but just to illustrate that it's honoring operation queue dependencies/concurrency settings

let requests = urlStrings.map { NSURLRequest(URL: NSURL(string: $0)!) }
requests.forEach { request in
    queue.addOperation(NetworkOperationWithRetry(request: request) { data, response, error in
        // do something with the `data`, `response`, and `error`
    })
}

Now, that's only retrying on NSURLErrorNotConnectedToInternet, but you could change that logic to be whatever you want. Likewise, I'd probably be inclined to add some "max retries" logic. Also, if the lack of Internet connectivity is really the issue, rather than retrying until Internet connectivity is achieved, I'd probably be inclined to set the operation's isReady notification tied into Reachability.

By the way, the above uses the following AsynchronousOperation:

/// Asynchronous Operation base class
///
/// This class performs all of the necessary KVN of `isFinished` and
/// `isExecuting` for a concurrent `NSOperation` subclass. So, to developer
/// a concurrent NSOperation subclass, you instead subclass this class which:
///
/// - must override `main()` with the tasks that initiate the asynchronous task;
///
/// - must call `completeOperation()` function when the asynchronous task is done;
///
/// - optionally, periodically check `self.cancelled` status, performing any clean-up
///   necessary and then ensuring that `completeOperation()` is called; or
///   override `cancel` method, calling `super.cancel()` and then cleaning-up
///   and ensuring `completeOperation()` is called.

public class AsynchronousOperation : NSOperation {

    override public var asynchronous: Bool { return true }

    private let stateLock = NSLock()

    private var _executing: Bool = false
    override private(set) public var executing: Bool {
        get {
            return stateLock.withCriticalScope { _executing }
        }
        set {
            willChangeValueForKey("isExecuting")
            stateLock.withCriticalScope { _executing = newValue }
            didChangeValueForKey("isExecuting")
        }
    }

    private var _finished: Bool = false
    override private(set) public var finished: Bool {
        get {
            return stateLock.withCriticalScope { _finished }
        }
        set {
            willChangeValueForKey("isFinished")
            stateLock.withCriticalScope { _finished = newValue }
            didChangeValueForKey("isFinished")
        }
    }

    /// Complete the operation
    ///
    /// This will result in the appropriate KVN of isFinished and isExecuting

    public func completeOperation() {
        if executing {
            executing = false
        }

        if !finished {
            finished = true
        }
    }

    override public func start() {
        if cancelled {
            finished = true
            return
        }

        executing = true

        main()
    }

    override public func main() {
        fatalError("subclasses must override `main`")
    }
}

extension NSLock {

    /// Perform closure within lock.
    ///
    /// An extension to `NSLock` to simplify executing critical code.
    ///
    /// - parameter block: The closure to be performed.

    func withCriticalScope<T>(@noescape block: Void -> T) -> T {
        lock()
        let value = block()
        unlock()
        return value
    }
}