OperationQueue — Pass throughSubject — Unit Testing — iOS
Hey Guys!, This is one of my favorite concepts in Swift, and I’m excited to share it with you! In the world of Swift development, managing concurrent tasks, observing changes, and ensuring code reliability are crucial aspects. In this blog post, we’ll dive into three powerful components of Swift that can significantly enhance your development skills and make your code more efficient: OperationQueue, Passthrough Subject, and Unit Testing. By the end of this journey, you’ll have a deeper understanding of how to manage asynchronous operations, communicate between components, and ensure the reliability of your code through comprehensive testing. Let’s get started on this exciting exploration!
Introduction
Welcome to an exploration of some of Swift’s most potent tools for managing asynchronous operations and ensuring the reliability of your code. In this blog, we will embark on a journey that delves deep into the heart of concurrent programming and Swift’s elegant solutions for it. Our three key players in this narrative are OperationQueue, Passthrough Subject, and Unit Testing.
OperationQueue: As iOS developers, we often find ourselves juggling multiple tasks simultaneously, whether it’s downloading files, processing data, or managing a complex workflow. This is where OperationQueue comes into play. It’s like an orchestral conductor for your asynchronous tasks, ensuring they run smoothly in the background. We’ll not only learn what OperationQueue is but also how to harness its power to execute tasks concurrently, efficiently, and with impeccable organization.
Passthrough Subject: While OperationQueue helps us manage the flow of tasks, Passthrough Subject serves as our communication bridge between different parts of our code. It’s like a messenger that can deliver updates, events, and data changes in real-time. We’ll explore how Passthrough Subject works seamlessly with OperationQueue to notify components of your app about task progress, completion, and more.
Unit Testing: No software journey is complete without testing. We’ll discuss the importance of unit testing and how it ensures the robustness and reliability of your code. By writing unit tests for our asynchronous operations, we can be confident that our code behaves as expected, even as it manages complex tasks asynchronously.
What is operation Queue?
OperationQueue
is a powerful mechanism in Swift for managing and executing tasks concurrently. It's built on top of the Operation
and OperationQueue
classes, which provide a high-level abstraction for managing concurrent operations.
For creating operation queue task, we need operation task and queue to execute.
Operation: An Operation
is an abstract class that represents a single unit of work. We can subclass Operation
to create our own custom operations, encapsulating tasks we want to perform concurrently.
OperationQueue: An OperationQueue
is a queue that manages the execution of Operation
objects. It controls the order and concurrency of operations, making it easier to handle tasks concurrently.
Here i’ll show you how to create a queue and operations with simple example and we will explore how to do it in real time scenario.
Create Custom Operations:
- Subclass
Operation
to create your own custom operations. Override themain
method to define the task you want to perform concurrently within the operation.
import Foundation
class MyCustomOperation: Operation {
override func main() {
// Your task goes here
}
}
Initialise and Configure OperationQueue:
- Create an instance of
OperationQueue
to manage your operations. - Configure properties like
maxConcurrentOperationCount
to control the concurrency level.
let operationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 4 // Adjust as needed
Add Operations to the Queue:
- Create instances of your custom operations and add them to the operation queue.
- The queue automatically manages the execution of these operations concurrently.
let operation1 = MyCustomOperation()
let operation2 = MyCustomOperation()
operationQueue.addOperation(operation1)//am adding 2 same operation to the queue
operationQueue.addOperation(operation2)
// you can customise with different operation for single queue
TADA!💁🏽♀️
But Whats so special about queue and why hype for this!
Wait for Completion (Optional): — we can use this feature if we want
- If we need to wait for all operations to finish before proceeding, we can use
operationQueue.waitUntilAllOperationsAreFinished()
operationQueue.waitUntilAllOperationsAreFinished()
Handle Dependencies (Optional):
- We can set dependencies between operations to control their execution order. An operation won’t start until all its dependencies are finished.
operation2.addDependency(operation1)
Cancel Operations (Optional):
- If we need to cancel an operation or a set of operations, we can call
cancel()
on them.
operation1.cancel()
Pause Operation (Optional):
- If we need to pause an operation or a set of operations, we can set
isSuspended
bool to handle pause and resume. - For example, in case of connectivity issues, we can prevent re-downloading whole data or creating new request to server, we can use this technique in OperationQueue
if reachability.connection != .unavailable {
// Network is available, resume the operation queue
operationQueue.isSuspended = false
} else {
// Network is unavailable, pause the operation queue
operationQueue.isSuspended = true
}
Okay this is just intro. Lets delve into real time example🏃🏽♀️.
Here I am trying to download image from array of URLs and Want to show the progress. I prefer Operation queue to done it asynchronously.
Firstly I want to create a operation where the URL request created and downloading process of single image.
ImageDownload Operation:
The ImageDownloadOperation
class you provided is an Operation
subclass designed for downloading images from a given URL. This class incorporates several features to facilitate image downloading, progress reporting, and publishing the local URL of the downloaded image.
Here I wanna introduce one more concept. Will see this later.
class ImageDownloadOperation: Operation, Identifiable {
let imageURL: URL
var image: UIImage?
var progress: Double = 0.0
enum OperationState {
case ready
case executing
case finished
}
private var _state: OperationState = .ready
init(imageURL: URL) {
self.imageURL = imageURL
super.init()
}
override var isAsynchronous: Bool {
return true
}
override var isExecuting: Bool {
return _state == .executing
}
override var isFinished: Bool {
return _state == .finished
}
override func start() {
if isCancelled {
_state = .finished
return
}
_state = .executing
main()
}
override func main() {
if isCancelled { return }
URLSession.shared.dataTask(with: imageURL) { [weak self] (data, response, _) in
guard let self = self else { return }
if let data = data, let img = UIImage(data: data) {
self.image = img
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let localURL = documentsURL.appendingPathComponent(self.imageURL.lastPathComponent)
do {
try data.write(to: localURL)
//self.localURLSubject.send(localURL) // Notify view model
} catch {
print("Error saving image: \(error)")
//self.localURLSubject.send(nil) // Notify view model of error
}
}
// Now Progress Value
let totalBytesExpected = response?.expectedContentLength ?? 0
let totalBytesReceived = Double(data.count )
self.progress = totalBytesReceived / Double(totalBytesExpected)
//self.progressSubject.send(self.progress)
}
self.progress = 1.0
self._state = .finished
}.resume()
}
}
Properties and State:
imageURL
: A property that stores the URL of the image to be downloaded.image
: A property to store the downloaded image.progress
: A property to represent the progress of the download, ranging from 0.0 (not started) to 1.0 (completed).
Operation State:
OperationState
: An enumeration that defines three states for the operation:.ready
,.executing
, and.finished
._state
: A private property that keeps track of the current state of the operation.
Initializer:
init(imageURL:)
: An initializer that accepts the URL of the image to be downloaded and sets theimageURL
property.
Overriding Operation Properties and Methods:
isAsynchronous
: Overrides the property to indicate that the operation is asynchronous.isExecuting
: Overrides the property to check whether the operation is executing.isFinished
: Overrides the property to check whether the operation is finished.start()
: Overrides thestart()
method to initiate the image download. It checks for cancellation and sets the state to.executing
before callingmain()
.
Image Download and Progress Reporting:
main()
: Overrides themain()
method where the actual image download occurs using URLSession'sdataTask(with:)
. It handles the download completion, saving the image to the local file system, and reporting progress updates.
Publishers
The above image download operation, download the image and save it in local. But we have to notify the image status an image in View.
We have multiple ways to get image to viewModel to View. But as a normal case, we prefer completion handlers most and sometimes delegates and observers.
Here am gonna use PassthroughSubject from combine.
Using Combine’s PassthroughSubject
(or other publishers) for asynchronous communication and data flow can offer several advantages over using closures
- Declarative and Reactive: Combine is built around a declarative and reactive programming model. It allows you to describe what should happen when events occur rather than imperatively defining how to handle those events. This can lead to more concise and expressive code.
- Composition: Combine publishers can be easily composed and combined to create complex data flows. You can merge, filter, transform, and combine publishers to handle different scenarios. With closures, handling complex data flows can become unwieldy and hard to maintain.
- Error Handling: Combine provides robust error handling mechanisms, allowing you to propagate and handle errors consistently throughout your data flow. Closures, on the other hand, might require more manual error handling and can lead to error-handling boilerplate code.
- Cancellable: Combine publishers can be easily canceled or retained as cancellable objects. This is particularly useful for managing resource cleanup and avoiding strong reference cycles. Closures do not offer this level of resource management out of the box.
- Integration with SwiftUI: If you’re working with SwiftUI, Combine and publishers are a natural fit. Many SwiftUI components, such as
@Published
properties andObservableObject
, work seamlessly with Combine publishers. - Extensibility: Combine is a framework, which means it can be extended with custom publishers and operators. You can create custom publishers to encapsulate complex logic and reuse it throughout your codebase.
Now, i declare two passthrough subject variables. One for image and another for progress.
private var localURLSubject = PassthroughSubject<URL?, Never>()
var localURLPublisher: AnyPublisher<URL?, Never> {
return localURLSubject.eraseToAnyPublisher()
}
private var progressSubject = PassthroughSubject<Double, Never>()
var progressPublisher: AnyPublisher<Double, Never>{
return progressSubject.eraseToAnyPublisher()
}
Declare this in ImageDownloadOperation class.
Now assign image once downloaded.
do {
try data.write(to: localURL)
self.localURLSubject.send(localURL) // Notify view model
} catch {
print("Error saving image: \(error)")
self.localURLSubject.send(nil) // Notify view model of error
}
Also same for progress data
let totalBytesExpected = response?.expectedContentLength ?? 0
let totalBytesReceived = Double(data.count )
self.progress = totalBytesReceived / Double(totalBytesExpected)
self.progressSubject.send(self.progress)
Now we gonna create viewModel.
The ImageDownloadViewModel
class , a SwiftUI ViewModel responsible for managing a collection of ImageDownloadOperation
instances, downloading images, and updating the UI with download progress and local image URLs.
class ImageDownloadViewModel: ObservableObject {
@Published var imageTasks: [ImageDownloadOperation] = []
@Published var localImageURLs: [URL?] = []
@Published var downloadProgress: [Double] = []
private var cancellables: Set<AnyCancellable> = []
init(imageURLs: [URL]) {
for item in imageURLs{
// self.imageTasks.append(ImageDownloadOperation(imageURL: item))
let operation = ImageDownloadOperation(imageURL: item)
imageTasks.append(operation)
// Subscribe to the localURLPublisher of each operation
operation.localURLPublisher
.sink { [weak self] localURL in
guard let self = self else { return }
DispatchQueue.main.async {
if let index = self.imageTasks.firstIndex(of: operation) {
self.localImageURLs[index] = localURL
}
}
}
.store(in: &cancellables)
// Subscribe to the progressPublisher of each operation
operation.progressPublisher
.sink { [weak self] progress in
guard let self = self else { return }
DispatchQueue.main.async {
if let index = self.imageTasks.firstIndex(of: operation) {
self.downloadProgress[index] = progress
}
}
}
.store(in: &cancellables)
}
downloadProgress = Array(repeating: 0.0, count: imageTasks.count)
localImageURLs = Array(repeating: nil, count: imageURLs.count)
}
func downloadAction(){
let operationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 7
for item in imageTasks{
operationQueue.addOperation(item)
}
}
}
Properties:
imageTasks
: An array ofImageDownloadOperation
instances representing the image download operations.localImageURLs
: An array of optionalURL
values representing the local file URLs where downloaded images are stored.downloadProgress
: An array ofDouble
values representing the progress of image downloads.cancellables
: A set ofAnyCancellable
objects to manage Combine subscriptions.
Initializer (init(imageURLs:)
):
- The initializer takes an array of
URL
objects representing the URLs of images to be downloaded. - It creates an
ImageDownloadOperation
for each URL and adds them to theimageTasks
array. - For each operation, it subscribes to its
localURLPublisher
andprogressPublisher
to receive updates about the local URL and download progress. - It initializes the
downloadProgress
andlocalImageURLs
arrays with default values, setting the progress to 0.0 and local URLs tonil
for each image.
downloadAction()
:
- This method initiates the image download by adding the
ImageDownloadOperation
instances to anOperationQueue
. The queue is configured with a maximum concurrent operation count of 7, meaning up to seven download operations can run simultaneously. - You can uncomment the
operationQueue.isSuspended = true
line to initially pause the operation queue if you want to control when the downloads start.
Combine Subscriptions:
- Inside the initializer, subscriptions to
localURLPublisher
andprogressPublisher
of eachImageDownloadOperation
are set up. When a download operation completes and emits updates, the view model updates thelocalImageURLs
anddownloadProgress
arrays accordingly.
DispatchQueue for UI Updates:
- Since Combine publishers emit events on a background queue by default, the view model uses
DispatchQueue.main.async
to update the UI elements, ensuring that UI updates happen on the main thread.
Now its time for ContentView(UI):
The ContentView
struct, a SwiftUI view that displays a list of images along with progress bars, representing the download progress of those images. This view relies on the ImageDownloadViewModel
to manage the image download operations and track their progress.
struct ContentView: View {
@StateObject var viewModel = ImageDownloadViewModel(imageURLs: [URL(string: "https://picsum.photos/1000")!, URL(string: "https://picsum.photos/3000")!, URL(string: "https://picsum.photos/2000")! ])
var body: some View {
List(0..<viewModel.localImageURLs.count, id: \.self) { index in
if let localURL = viewModel.localImageURLs[index] {
HStack{
Image(uiImage: (UIImage(contentsOfFile: localURL.path) ?? UIImage(systemName: "square.and.arrow.up"))!)
.resizable()
.frame(width: 50, height: 50)
ProgressView(value: viewModel.downloadProgress[index]) // Display progress bar
.frame(width: 250, height: 25)
}
} else {
ProgressView(value: viewModel.downloadProgress[index]) // Display progress bar
.frame(width: 250, height: 25)
}
}
.onAppear(){
viewModel.downloadAction()
}
}
}
@StateObject
and View Model Initialization:
- The
@StateObject
property wrapper is used to create an instance ofImageDownloadViewModel
as part of the view's state. This ensures that the view model is retained and available as long as the view is alive. - The view model is initialized with an array of
URL
objects, which represent the URLs of the images to be downloaded.
List View:
- The main content of the view is a SwiftUI
List
that displays a dynamic list of items. - The list is created with a range of indices, from 0 to the count of
viewModel.localImageURLs
, to generate a list item for each downloaded image. - Each list item corresponds to an image at a specific index.
Image Display and Progress Bar:
- Within each list item, there’s a conditional check to determine whether a local URL exists for the image at the current index (
viewModel.localImageURLs[index]
). - If a local URL exists, an
Image
view is created to display the downloaded image. If the local URL doesn't exist, a placeholder image (system image "square.and.arrow.up") is used. - A
ProgressView
is also displayed, showing the download progress. This progress bar's value is determined byviewModel.downloadProgress[index]
.
onAppear
Event:
- When the view appears on the screen (
onAppear
event), theviewModel.downloadAction()
method is called. This initiates the download of the images.
Dynamic Updates:
- As the image download operations progress and complete, the view model updates the
localImageURLs
anddownloadProgress
arrays accordingly. - SwiftUI’s data binding and reactive nature ensure that any changes in these arrays trigger updates to the view, causing it to re-render and reflect the updated image and progress information.
Now its time for unit testing:
These unit tests are designed to verify the functionality of the ImageDownloadOperation
and ImageDownloadViewModel
classes in our code.
func testImageDownloadOperationInitialization() {
let imageURL = URL(string: "https://www.example.com/image.jpg")!
let operation = ImageDownloadOperation(imageURL: imageURL)
XCTAssertEqual(operation.imageURL, imageURL)
}
func testImageDownloadViewModelInitialization() {
let imageURLs = [URL(string: "https://www.example.com/image1.jpg")!, URL(string: "https://www.example.com/image2.jpg")!]
let viewModel = ImageDownloadViewModel(imageURLs: imageURLs)
XCTAssertEqual(viewModel.imageTasks.count, 2)
XCTAssertEqual(viewModel.localImageURLs.count, 2)
}
func testImageDownloadViewModelDownloadAction() {
let imageURL = URL(string: "https://www.example.com/image.jpg")!
let viewModel = ImageDownloadViewModel(imageURLs: [imageURL])
let expectation = XCTestExpectation(description: "Image download completed")
viewModel.downloadAction()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
// Assert that the download action has completed successfully
XCTAssertNotNil(viewModel.localImageURLs.first)
expectation.fulfill()
}
wait(for: [expectation], timeout: 5)
}
testImageDownloadOperationInitialization()
- This test checks the initialization of an
ImageDownloadOperation
instance. - It creates an
imageURL
representing a URL of an image and then creates anImageDownloadOperation
with that URL. - Finally, it uses
XCTAssertEqual
to verify that theimageURL
property of the operation matches the URL provided during initialization.
testImageDownloadViewModelInitialization()
- This test verifies the initialization of an
ImageDownloadViewModel
instance. - It creates an array of
imageURLs
, representing URLs of images, and then initializes aImageDownloadViewModel
with those URLs. - The test checks two aspects:
- It ensures that the
imageTasks
array in the view model contains the expected number ofImageDownloadOperation
instances, which is 2 in this case. - It also checks that the
localImageURLs
array in the view model contains the expected number of elements, which is again 2.
testImageDownloadViewModelDownloadAction()
- This test validates the
downloadAction()
method of theImageDownloadViewModel
. - It starts by creating a
ImageDownloadViewModel
instance with an array containing oneimageURL
. - Then, it creates an expectation using
XCTestExpectation
to wait for the image download to complete. - The test invokes the
downloadAction()
method, which initiates the download of the image. - It uses
DispatchQueue.main.asyncAfter
to introduce a delay (3 seconds) to allow time for the download to complete. - Within the closure passed to
asyncAfter
, it asserts that thelocalImageURLs
array in the view model has a non-nil value for the first image. - Finally, the test fulfills the expectation to signal that the test has completed.
Follow me for more exciting concepts in iOS
Thank you!