
Learn how to use Operations in your app!
Preliminary Model
Classic photos, running slowly
lazy var photos = NSDictionary(contentsOf:dataSourceURL)!
Xcode’s Gauges view, showing heavy lifting on the main thread
Process, Thread and Task
Improved model
Control Flow
import UIKit
// This enum contains all the possible states a photo record can be in
enum PhotoRecordState {
case new, downloaded, filtered, failed
}
class PhotoRecord {
let name: String
let url: URL
var state = PhotoRecordState.new
var image = UIImage(named: "Placeholder")
init(name:String, url:URL) {
self.name = name
self.url = url
}
}
class PendingOperations {
lazy var downloadsInProgress: [IndexPath: Operation] = [:]
lazy var downloadQueue: OperationQueue = {
var queue = OperationQueue()
queue.name = "Download queue"
queue.maxConcurrentOperationCount = 1
return queue
}()
lazy var filtrationsInProgress: [IndexPath: Operation] = [:]
lazy var filtrationQueue: OperationQueue = {
var queue = OperationQueue()
queue.name = "Image Filtration queue"
queue.maxConcurrentOperationCount = 1
return queue
}()
}
class ImageDownloader: Operation {
//1
let photoRecord: PhotoRecord
//2
init(_ photoRecord: PhotoRecord) {
self.photoRecord = photoRecord
}
//3
override func main() {
//4
if isCancelled {
return
}
//5
guard let imageData = try? Data(contentsOf: photoRecord.url) else { return }
//6
if isCancelled {
return
}
//7
if !imageData.isEmpty {
photoRecord.image = UIImage(data:imageData)
photoRecord.state = .downloaded
} else {
photoRecord.state = .failed
photoRecord.image = UIImage(named: "Failed")
}
}
}
class ImageFiltration: Operation {
let photoRecord: PhotoRecord
init(_ photoRecord: PhotoRecord) {
self.photoRecord = photoRecord
}
override func main () {
if isCancelled {
return
}
guard self.photoRecord.state == .downloaded else {
return
}
if let image = photoRecord.image,
let filteredImage = applySepiaFilter(image) {
photoRecord.image = filteredImage
photoRecord.state = .filtered
}
}
}
func applySepiaFilter(_ image: UIImage) -> UIImage? {
guard let data = UIImagePNGRepresentation(image) else { return nil }
let inputImage = CIImage(data: data)
if isCancelled {
return nil
}
let context = CIContext(options: nil)
guard let filter = CIFilter(name: "CISepiaTone") else { return nil }
filter.setValue(inputImage, forKey: kCIInputImageKey)
filter.setValue(0.8, forKey: "inputIntensity")
if isCancelled {
return nil
}
guard
let outputImage = filter.outputImage,
let outImage = context.createCGImage(outputImage, from: outputImage.extent)
else {
return nil
}
return UIImage(cgImage: outImage)
}
var photos: [PhotoRecord] = []
let pendingOperations = PendingOperations()
func fetchPhotoDetails() {
let request = URLRequest(url: dataSourceURL)
UIApplication.shared.isNetworkActivityIndicatorVisible = true
// 1
let task = URLSession(configuration: .default).dataTask(with: request) { data, response, error in
// 2
let alertController = UIAlertController(title: "Oops!",
message: "There was an error fetching photo details.",
preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default)
alertController.addAction(okAction)
if let data = data {
do {
// 3
let datasourceDictionary =
try PropertyListSerialization.propertyList(from: data,
options: [],
format: nil) as! [String: String]
// 4
for (name, value) in datasourceDictionary {
let url = URL(string: value)
if let url = url {
let photoRecord = PhotoRecord(name: name, url: url)
self.photos.append(photoRecord)
}
}
// 5
DispatchQueue.main.async {
UIApplication.shared.isNetworkActivityIndicatorVisible = false
self.tableView.reloadData()
}
// 6
} catch {
DispatchQueue.main.async {
self.present(alertController, animated: true, completion: nil)
}
}
}
// 6
if error != nil {
DispatchQueue.main.async {
UIApplication.shared.isNetworkActivityIndicatorVisible = false
self.present(alertController, animated: true, completion: nil)
}
}
}
// 7
task.resume()
}
fetchPhotoDetails()
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier", for: indexPath)
//1
if cell.accessoryView == nil {
let indicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
cell.accessoryView = indicator
}
let indicator = cell.accessoryView as! UIActivityIndicatorView
//2
let photoDetails = photos[indexPath.row]
//3
cell.textLabel?.text = photoDetails.name
cell.imageView?.image = photoDetails.image
//4
switch (photoDetails.state) {
case .filtered:
indicator.stopAnimating()
case .failed:
indicator.stopAnimating()
cell.textLabel?.text = "Failed to load"
case .new, .downloaded:
indicator.startAnimating()
startOperations(for: photoDetails, at: indexPath)
}
return cell
}
func startOperations(for photoRecord: PhotoRecord, at indexPath: IndexPath) {
switch (photoRecord.state) {
case .new:
startDownload(for: photoRecord, at: indexPath)
case .downloaded:
startFiltration(for: photoRecord, at: indexPath)
default:
NSLog("do nothing")
}
}
func startDownload(for photoRecord: PhotoRecord, at indexPath: IndexPath) {
//1
guard pendingOperations.downloadsInProgress[indexPath] == nil else {
return
}
//2
let downloader = ImageDownloader(photoRecord)
//3
downloader.completionBlock = {
if downloader.isCancelled {
return
}
DispatchQueue.main.async {
self.pendingOperations.downloadsInProgress.removeValue(forKey: indexPath)
self.tableView.reloadRows(at: [indexPath], with: .fade)
}
}
//4
pendingOperations.downloadsInProgress[indexPath] = downloader
//5
pendingOperations.downloadQueue.addOperation(downloader)
}
func startFiltration(for photoRecord: PhotoRecord, at indexPath: IndexPath) {
guard pendingOperations.filtrationsInProgress[indexPath] == nil else {
return
}
let filterer = ImageFiltration(photoRecord)
filterer.completionBlock = {
if filterer.isCancelled {
return
}
DispatchQueue.main.async {
self.pendingOperations.filtrationsInProgress.removeValue(forKey: indexPath)
self.tableView.reloadRows(at: [indexPath], with: .fade)
}
}
pendingOperations.filtrationsInProgress[indexPath] = filterer
pendingOperations.filtrationQueue.addOperation(filterer)
}
Classic photos, now with actual scrolling!
if !tableView.isDragging && !tableView.isDecelerating {
startOperations(for: photoDetails, at: indexPath)
}
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
//1
suspendAllOperations()
}
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
// 2
if !decelerate {
loadImagesForOnscreenCells()
resumeAllOperations()
}
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// 3
loadImagesForOnscreenCells()
resumeAllOperations()
}
func suspendAllOperations() {
pendingOperations.downloadQueue.isSuspended = true
pendingOperations.filtrationQueue.isSuspended = true
}
func resumeAllOperations() {
pendingOperations.downloadQueue.isSuspended = false
pendingOperations.filtrationQueue.isSuspended = false
}
func loadImagesForOnscreenCells() {
//1
if let pathsArray = tableView.indexPathsForVisibleRows {
//2
var allPendingOperations = Set(pendingOperations.downloadsInProgress.keys)
allPendingOperations.formUnion(pendingOperations.filtrationsInProgress.keys)
//3
var toBeCancelled = allPendingOperations
let visiblePaths = Set(pathsArray)
toBeCancelled.subtract(visiblePaths)
//4
var toBeStarted = visiblePaths
toBeStarted.subtract(allPendingOperations)
// 5
for indexPath in toBeCancelled {
if let pendingDownload = pendingOperations.downloadsInProgress[indexPath] {
pendingDownload.cancel()
}
pendingOperations.downloadsInProgress.removeValue(forKey: indexPath)
if let pendingFiltration = pendingOperations.filtrationsInProgress[indexPath] {
pendingFiltration.cancel()
}
pendingOperations.filtrationsInProgress.removeValue(forKey: indexPath)
}
// 6
for indexPath in toBeStarted {
let recordToProcess = photos[indexPath.row]
startOperations(for: recordToProcess, at: indexPath)
}
}
}
Classic photos, loading things one step at a time!
// MyDownloadOperation is a subclass of Operation
let downloadOperation = MyDownloadOperation()
// MyFilterOperation is a subclass of Operation
let filterOperation = MyFilterOperation()
filterOperation.addDependency(downloadOperation)
filterOperation.removeDependency(downloadOperation)