4 Ways To Pass Data Between Operations With Swift
Original Link: https://marcosantadev.com/4-ways-pass-data-operations-swift/
Introduction
In this article, we are going to see some approaches to pass the data between two Operation
s in Swift. To avoid losing the focus on this topic, I will not explain what is and how works an Operation
. For this reason, you need a basic understanding of Operation
to understand the next sections of the article.
I may write another article to explain the Operation
if I see that you would be interested on it.
Happy Reading!
Getting Started
Before diving into these approaches, we need a scenario for our examples. I guess all of us made an application where we had to fetch the data from an API request and then parse the data received. For this reason, I think it’s quite familiar if we use a scenario where we have two Operation
s: one to fetch and one to parse the data.
We can start creating our two Operation
classes:
FetchOperation
final class FetchOperation: Operation { // 1
private(set) var dataFetched: Data? override func main() {
// 2
self.dataFetched = // data received from HTTP request
}
}
- The data fetched to send to
ParseOperation
. - Saves the data received from an HTTP request.
For the sake of explanation, I skipped a real implementation since it would need an asynchronous operation. If you want to learn how to use an asynchronous operation, you can have a look at my gist.
ParseOperation
final class ParseOperation: Operation { // 1
var dataFetched: Data? // 2
private(set) var jsonParsed: [String: Any]? override func main() {
// 3
guard let dataFetched = dataFetched else { return }
jsonParsed = try! JSONSerialization.jsonObject(with: dataFetched, options: []) as? [String: Any]
print(jsonParsed)
}
}
- The data received from
FetchOperation
. - The dictionary created from the parsing of
dataFetched
. - Checks if the data exists and then creates a dictionary from the data fetched.
For the sake of explanation, I kept both Operation
implementations as plain as possible without caring of the lifecycle.
The last step is creating a handler class which will manage these operations with an OperationQueue
:
final class Handler { // 1
private let queue: OperationQueue = OperationQueue() func start() {
// 2
let fetch = FetchOperation()
let parse = ParseOperation()
parse.addDependency(fetch) // 3
queue.addOperations([fetch, parse], waitUntilFinished: true)
}
}
OperationQueue
to run ourOperation
s.- Prepares our
Operation
s setting the dependencies. - Adds the
Operation
s in the queue blocking the queue thread until it's finished.
With these 3 classes, we are ready to start looking at the approaches to pass dataFetched
from FetchOperation
to ParseOperation
.
Approaches
Internal dependency reference
The object Operation
provides an array of its dependencies with the following property:
var dependencies: [Operation] { get }
Thanks to this information, in ParseOperation
we can have access to its dependency FetchOperation
:
let fetchOperation = dependencies.first as? FetchOperation
At this point, we can refactor the method main
of ParseOperation
to read dataFetched
directly from its dependency:
override func main() {
guard let fetchOperation = dependencies.first as? FetchOperation else { return }
self.dataFetched = fetchOperation.dataFetched guard let dataFetched = dataFetched else { return }
jsonParsed = try! JSONSerialization.jsonObject(with: dataFetched, options: []) as? [String: Any]
print(jsonParsed)
}
This approach is the easiest since we don’t need any external helpers to inject dataFetched
. To be honest, I don't like this approach. I would prefer injecting the data from outside because ParseOperation
wouldn't have the responsibility to decide where to get the data.
Reference Wrapper
For this approach, we have to create a new class which will wrap fetchedData
:
final class DataWrapper {
var dataFetched: Data?
}
Then, we can inject this new wrapper in both operations. FetchOperation
will use this wrapper to set the property dataFetched
, whereas ParseOperation
will read the value of dataFetched
—previously set in FetchOperation
.
We can change our FetchOperation
to inject this wrapper and set its property once we receive the HTTP response:
final class FetchOperation: Operation { private let dataWrapper: DataWrapper // 1
init(dataWrapper: DataWrapper) {
self.dataWrapper = dataWrapper
} override func main() {
// 2
dataWrapper.dataFetched = // data received from HTTP request
}
}
- Injects
DataWrapper
and keeps an internal reference to use inmain
. - Sets the wrapper property to be used in
ParseOperation
.
Then, we can change ParseOperation
to read the wrapper property:
final class ParseOperation: Operation { private(set) var jsonParsed: [String: Any]? private let dataWrapper: DataWrapper // 1
init(dataWrapper: DataWrapper) {
self.dataWrapper = dataWrapper
} override func main() {
// 2
guard let dataFetched = dataWrapper.dataFetched else { return }
jsonParsed = try! JSONSerialization.jsonObject(with: dataFetched, options: []) as? [String: Any]
print(jsonParsed)
}
}
- Injects
DataWrapper
and keep an internal reference to use inmain
. - Reads the wrapper property to parse it.
Finally, we can change the method start
of Handler
to use the new wrapper object:
func start() {
let dataWrapper = DataWrapper() let fetch = FetchOperation(dataWrapper: dataWrapper)
let parse = ParseOperation(dataWrapper: dataWrapper)
parse.addDependency(fetch) queue.addOperations([fetch, parse], waitUntilFinished: true)
}
To be honest, I don’t like also this approach. We cannot inject just the data but we must inject this wrapper — which may not have the data ready when we use it in ParseOperation
.
Keep reading to learn better approaches.
Completion block
The object Operation
provides a completion closure which is called once the Operation
completes its task:
var completionBlock: (() -> Swift.Void)?
We can take advantage of this completion to pass the values between the two Operation
s:
fetch.completionBlock = { [unowned parse, unowned fetch] in
parse.dataFetched = fetch.dataFetched
}
Remember to use unowned
for both operation objects otherwise you would create a retain cycle.
At this point, we can refactor the method start
of Handler
like this:
func start() {
queue.maxConcurrentOperationCount = 1 let fetch = FetchOperation()
let parse = ParseOperation()
parse.addDependency(fetch) fetch.completionBlock = { [unowned parse, unowned fetch] in
parse.dataFetched = fetch.dataFetched
} queue.addOperations([fetch, parse], waitUntilFinished: true)
}
If you don’t set maxConcurrentOperationCount
of OperationQueue
to 1
, parse
would start without waiting the completion block of FetchOperation
. It means that we would inject the data too late when the operation is already started. Instead, we must inject it before running ParseOperation
.
I definitely prefer this approach rather than both Internal dependency reference
and Reference Wrapper
since we can inject dataFetched
from outside.
Adapter operation
This approach is very similar to Completion block
. Instead of using the completion block, we add a third operation which is called Adapter
.
This new Operation
has just a plain block where we can inject the data fetched inside ParseOperation
like in Completion block
:
let adapter = BlockOperation(block: { [unowned parse, unowned fetch] in
parse.dataFetched = fetch.dataFetched
})
At this point, we can refactor the method start
of Handler
like this:
func start() {
let fetch = FetchOperation()
let parse = ParseOperation() // 1
let adapter = BlockOperation() { [unowned parse, unowned fetch] in
parse.dataFetched = fetch.dataFetched
} // 2
adapter.addDependency(fetch)
parse.addDependency(adapter) // 3
queue.addOperations([fetch, parse, adapter], waitUntilFinished: true)
}
- Sets new adapter operation with a trailing closure.
- The dependencies have been changed:
adapter
needsfetch
as dependency to start when we fetched the data.parse
needsadapter
as dependency to inject the data fetched.parse
no longer needsfetch
as dependency sinceadapter
is in the middle.
- Adds
adapter
in the queue.
Thanks to this adapter operation, we don’t need to care about maxConcurrentOperationCount
of OperationQueue
like in Completion block
. We can leave its default value—OperationQueue.defaultMaxConcurrentOperationCount
.
Conclusion
Personally, my favorite approach is Adapter operation
since it provides a clean way to inject the data. You may argue that Completion block
provides a clean solution as well without using another Operation
in the middle. I agree with it, but I don't like that we must set maxConcurrentOperationCount
to 1
to avoid unexpected behaviours.
If you have better approaches, feel free to write them in the comments. Thank you!