Managing Sync and Async Tasks in iOS

Melvin John
6 min readJun 3, 2018

--

If you worked on an iOS app you may have come across asynchronous tasks such as API requests, image process, data download etc. If so then you know it can be a painful process especially when your need to chain these async tasks in series. In this article i hope to explain how one can make this process simpler.

I have been doing some JSON parsing for an iOS app I am working on. The setup required multiple API requests and some data processing and parsing before results can be populated and presented appropriately to users. Even though with the use of techniques such as delegates, completion blocks, event handlers etc. I found my setup very crude, hard to maintain and difficult to test. Lets look at an example to illustrate my frustration.

func fetchSomeJSON(completion: @escaping Completion) {

let URLPath = "https://...."

do {
let request = try makeGETRequestJob(witURLPath: URLPath)

performNetworkRequestJob(request: request) { [weak self] data, response, error in

guard let responseData = data else {
completion(.failure(error))
return
}

do {
let comments = try self?.parseResponse(fromData: responseData) ?? []
completion(.success(comments))
} catch let error {
completion(.failure(error))
}

}

} catch let error {
completion(.failure(error))
}

}

}

Above is a very simple function responsible for performing a network request to fetch some JSON. The function takes a completion block which gives a collection of Comment Models.

Although the above implementation looks reasonable, its not great but we can do better! First lets look at some of the issue with the above setup.

  • Lack of control - Yes the above implementation is out of control :D. The caller of this class has no control over the request i.e cannot resume, cancel or suspend the request when required.
  • Not Maintainable - If for some reason another task/job such as performing data transformation or calling another JSON API then the function fetchSomeJSON as well as tests (if there were any!) would need to change greatly.
  • Difficult to test - The code structure above makes testing very difficult as mocks become very difficult to apply and you many end up writing workarounds to test your production code.

Introducing Waterfall

You may recall this name from Javascript. You are correct, but don’t turn aways just yet because i am not going to propose a javascript solution but a Swift solution (hopefully with more control and type safety).

You can think of Waterfall as a series of tasks that runs one after another. These tasks can be synchronous or asynchronous, an example of a synchronous task can be constructing a URLRequest object and for asynchronous it can be perform a network request.

The Waterfall takes an array of tasks and on run, it execute these tasks in series, each passing their results to the next task in the array. However, if any of the task pass an error to the completion block, the next function is not executed and the main completion block is immediately called with the error. When processing these tasks we always start with the first task and finish it before we begin the next. It behaves the same way as a FIFO Queue (First In, First Out).

By now you may have guessed the meaning behind Waterfall, if not i am sure this will be more meaningful towards the end. So lets a take a look at how we can write this in code.

Implementing Waterfall

Lets start with the protocols.

Protocol to define Control.

This protocol defines control over our tasks i.e start, cancel or resume a task when needed. Our Waterfall will conform to this protocol and by doing so will allow us to have control over the execution of our tasks. A good example can be, on viewDidDisappear you can tell your Waterfall task to cancel any running tasks so that any async issues are prevented.

public protocol Task {

/// Determines whether the task is running.
var isRunning: Bool { get }

/// Boolean stating if the task was cancelled.
var isCancelled: Bool { get }

/// Start the task
func start()

/// Resume a currently suspended or non-started task.
func resume()

/// Cancels the task.
func cancel()

}

Protocol to define Jobs.

This protocol defines the capability of adding multiple jobs to our Waterfall. By conforming our Waterfall to this improves two issues we talked about earlier, Maintainable and testability as multiple jobs can be added and in any order. These jobs can also be mocked which makes testing easier.

public protocol Tasker: class {

typealias JobType = (TaskResult) throws -> Task

/// Adds a task to to be executed.
///
/// - parameter job: The function to execute.
func add(job: @escaping JobType)
/// Adds all tasks to be executed.
///
/// - parameter jobs: The list of functions to execute.
func add(jobs: [JobType])

}

Struct to aid handshake between jobs.

As you saw in the Tasker protocol the type JobType is a block that takes an TaskResult. The TaskResult object contains properties that are utilised during the handshake from previous and next task. The userInfo property in this object is the response from the previous task which for example be a Foundation.Data type from a network request or a URLRequest object.

TaskResult also contains a continueWithResults block which is responsible for performing the handover. This logic will become apparent when we define the concrete implementation of Waterfall but in a nutshell the block gets a Result type with the success case associated with response or a failure case with error from a current run task.

Lets look at the TaskResult.

public struct TaskResult {

/// A block to define handshake from previous and next function
public typealias ContinueWithResultsType = (Result<Any>) -> Void

/// The data object of the previous function
public var userInfo: Any?

/// This is executed to let the waterfall know it has finished its task
public var continueWithResults: ContinueWithResultsType
public init(userInfo: Any?, continueWithResults: @escaping ContinueWithResultsType) {
self.currentTask = currentTask
self.userInfo = userInfo
self.continueWithResults = continueWithResults
}

}

Waterfall time.

With the setup of Waterfall explained we can finally look at the Waterfall implementation.

The Waterfall class conforms to the Task and Tasker protocols which we talked about earlier. The class is initialised with a userInfo and a completion block. The userInfo is passed to the first task, optionally this can be omitted which means that the first task gets no userInfo however with the power of blocks in Swift we can pass additional arguments without going through the Waterfall chain (evident in the example below). The completion block as you guessed it, is called on completion of all the Jobs or when an error is thrown. The block passes a Result enum with success and failure cases.

public class Waterfall<T>: Task {

//********** HELPERS *********

public enum TaskError: Error {
case noResult
}

public typealias JobType = Tasker.JobType

public typealias CompletionType = (Result<T>) -> Void

//*****************************


public var isRunning: Bool = false

public var isCancelled: Bool = false

private lazy var jobs: [JobType] = []

private var currentJobTask: Task?

private let userInfo: Any?

private var completionBlock: CompletionType

/// Initialise with a userInfo and completion block.
public init(with userInfo: Any? = nil,
completionBlock: @escaping CompletionType) {

self.userInfo = userInfo
self.completionBlock = completionBlock

}
......
}

Below are the implementation details of the start, resume and cancel functions in our Waterfall class. The implementation details of these functions are self explanatory and are called accordingly when required.

public func start() {
guard currentJobTask == nil else {
assertionFailure("Waterfall is already executing, suspended or cancelled")
return
}

isRunning = true

continueBlock(userInfo)
}
public func resume() {

if let current = currentJobTask {
current.resume()
} else {
start()
}
}
public func cancel() {
isRunning = false
isCancelled = true
currentJobTask?.cancel()
}

The functions continueBlock (below) is the beating heart of our Waterfall. The function is called on start and recursively called when the next job is ready to be executed.

private func continueBlock(_ userInfo: Any?) -> Void {     let result = TaskResult(currentTask: self,
userInfo: userInfo) { [weak self] currentTaskResult in

switch currentTaskResult {
case .success(let result):

self?.continueBlock(result)
case .failure(let error):

self?.finish(error: error)
}

}

do {

self.currentJobTask = try self.jobs.removeFirst()(result)
self.currentJobTask?.resume()
} catch let error {

self.finish(error: error)
}

}

Example

Now that we are done with the implementation details lets look at how we can use it.

func fetchSomeJSON(completion: @escaping Completion) -> Task {

let URLPath = "https://...."

let waterfallTask = Waterfall(completionBlock: completion)

waterfallTask.add(jobs: [
self.makeGetRequestTask(withURLPath: URLPath),
self.performNetworkRequestTask(),
self.parseResponseTask()
])
return waterfallTask}

Our fetchSomeJSON function we saw earlier already looks simpler and clearer to understand. Maintaining the function is also easier because new tasks can be added and existing tasks can be removed or ordered appropriately. Testing is now easier as functions added to the block can be mocked to make sure that they take the appropriate results and is called in the appropriate order.

By adopting this techniques to more complex tasks such as image processing, downloading or performing any series of async or sync tasks your project can benefit greatly in-terms of code readability and maintainability. Give this approach a go and if you have any suggestions please let me know.

Full implementation detail with example can be found here.

--

--

Melvin John
Melvin John

Written by Melvin John

A Software Engineer with a passion for technology. Working as an iOS Developer @BBC

Responses (2)