[iOS] GCD (11) - Operation Dependencies

전성훈·2023년 11월 6일
0

iOS/Concurrency

목록 보기
11/11
post-thumbnail

주제: SRP(Single Responsibility Principle: 단일책임의 원칙)을 지키면서 Operation을 작성 방법


  • Operation 사이에서 의존성을 활용함으로서 여러 이점들이 존재한다.
    • 사전 operation이 완료되기 전까지는 dependent operation은 작동하지 않는다.
    • 첫 번째 operation에서 두 번째 operation으로 데이터를 보낼 때 자동적으로 깔끔하게 데이터를 보낼 수 있다.
  • Operation 간의 의존성을 설정하는 것은 Operation을 GCD 대신 사용하게 되는 주요 이유 중 하나이다.

Modular design

  • 지금까지의 operation을 활용한 tilt shift project를 보면 image download operation과 그 이미지를 tilt shift를 수행하는 operation 두 개를 만들어야 하는 걸 알 수 있다.
  • 두 개의 operation을 만드는 것 보다 하나의 operation을 만들어서 두 기능을 한 번에 수행할 수 있지만, 이는 좋지 않은 architectural design이다.
  • class는 이상적인 수행은 하나의 작업을 수행하는 것이며, project내부에서 재사용이 가능해야 한다.
  • 만약, 네트워크 코드를 tilt shift operation 내부에 구현을 하게 된다면 이미 만들어진 bundle image에서는 활용이 불가능해진다. 그렇게되면 많은 initialization parameters를 구현해야 되고 image가 제공되는지 아닌지, downloaded from the network 인지 아닌지 등을 고려해야 되서 기존 클래스가 메우 부풀려 진다.
  • 유지보수 관련뿐만 아니라 이는 또한 수많은 test case를 만들게 된다.

Specifying dependencies

  • 의존성을 더하는 방법은 addDependency(op:) 메서드를 활용해서 간단한게 이용할 수 있다.
  • 아래의 코드를 확인하면, networkOp가 작동이 완료되면, decryptOP가 작동되고 그 후 tiltShiftOp가 작동된다.
let networkOp = NetworkImageOperation()
let decryptOp = DecryptOperation()
let tiltShiftOp = TiltShiftOperation() 

decryptOp.addDependency(op: networkOp)
tiltShiftOp.addDependency(op: decryptOp)
  • 어떠한 이유로 dependency를 제거하려고 하면 간단하게 removeDependency(op:) 메서드를 호출하면 된다.
tiltShiftOp.removeDependency(op: decryptOp)
  • operation 클래스는 read-only 속성인 dependencies를 제공하며, 이는 주어진 작업에 대해 의존성으로 표시된 operations 배열을 반환한다.
tiltShiftOp.dependencies
// -> dependency Operation array 반환

Avoiding the pyramid of doom

  • 의존성은 코드를 좀 더 읽기 쉬운 부수적인 효과를 낳는다. 만약 위의 3개의 operation을 GCD로 사용하게 된다면, 결국 pyramid of doom에 빠지게 될 것이다.
let network = NetworkClass()
network.onDownloaded { raw in 
	guard let raw = raw else { return }
	
	let decrypt = DecryptClass(raw) 
	decypt.onDecrypted { decrypted in 
		guard let decrypted = decryped else { return }
		
		let tilt = TiltShiftClass(decrypted)
		tilt.onTiltShifted { tilted in 
			guard let tilted = tilted else { return }
		}
	}
}

Watch out for deadlock

  • 의존성 체인(dependency chain)이 straight line이면 이때는 deadlock이 발생할 가능성은 없다.
  • 또한 두 개의 operation queue가 있고 하나의 operation queue의 임의의 operation이 다른 operation queue의 operation을 의존하는 것도 가능하며, loop가 형성되지 않는다면 deadlock에서 안전하다.
  • 하지만 loop가 확인되어 진다면, 거의 deadlock 상황에 빠지게 될 것이다.

DeadLock Warning

  • Operation2는 Operation5가 끝나기 전까지는 시작하지 않는다.
  • Operation5는 Operation3이 끝나기 전까지는 시작하지 않는다.
  • Operation3은 Operation2가 끝나기 전까지는 시작하지 않는다.
  • 위의 operation들은 deadlock 상황이 발생되었으므로, 실행되지 않을 것이다. deadlock 상황을 해결할 수 있는 "sliver-bullet solution"(만능 해결책)은 없으며, 의존성을 정리하지 않으면 찾기 어려울 수 있다.
  • 이러한 상황에 직면하게 되면, 설계한 솔루션을 재구성할 수밖에 없다.

Passing data between operations

  • 두 개의 operation상에서 데이터를 전송하면서 안전하게 operation의 의존성을 유지하는 방법은 protocol을 활용하는 것이다.
  • NetworkImageOperation은 output property image 을 호출한다.
  • operation의 이점 중 하나는 encapsulation(캡슐화)와 reusability(재사용성)을 제공하는 것이다. operatoin을 작성하는 모든 사람이 output property을 image로 호출할 것이라고 기대할 수 없다. 예를들어, 클래스 내부에서는 foodImage로 호출하는 것이 좋을 수도 있다.

Using protocols

  • 프로토콜을 활용하는 것은 이는 "operation이 끝날 때, 모든것이 잘 작동되었다면, UIImage type의 image 속성을 제공할께."라는 의미이다.
import UIKit

protocol ImageDataProvider { 
	var image: UIImage? { get }
}
  • UIImage를 output data로 하는 Any Operation은 위의 프로토콜을 채택하면 된다.

Adding extensions

  • NetworkImageOperation.swift에 아래의 코드를 더한다.
extension NetworkImageOperaton: ImageDataProvider { 
	var image: UIImage? { return outputIamge }
}
  • TiltShiftOperation또한 수정한다
extension TiltShiftOperation: ImageDataProvider { 
	var image: UIImage? { return outputImage }
}

Searching for the protocol

  • TiltShiftOperationUIImage를 input으로 필요로한다. 그냥 inputImage 속성을 설정하는 것이 아니라, dependency가 UIImage를 제공하는 것이 있는지 확인해야 한다.
let dependencyImage = dependencides 
	.compactMap { ($0 as? ImageDataProvider)?.image}
	.first 
guard let inputImage = inputImage ?? dependecyImage else { 
	return
}
  • 위 코드는 operation에 직접 제공되는 input image를 풀거나 dependency chain이 제공하는 image를 푼다. non-nil image가 제공되었는지 확인한다.
  • 둘 다 작동하지 않으면, 어떤 작업도 수행하지 않고 그냥 반환한다.
  • dependency chain에서 image를 확인하는 것은 TiltShiftOperation를 input Image없이 초기화 해야한다.
  • 그러므로 아래와 같이 TiltShiftOperationinit 함수를 수정한다.
init(image: UIImage? = nil) { 
	inputImage = image
	super.init() 
}

Updating the table view Controller

  • 이제 image을 다운로드하고, 이미지를 tilt shift 하고 마지막로 table view cell에 할당해야한다.
  • tiltShiftOperation에 의존성으로 download operation을 추가해야 한다.
  • tableView(_: cellForRowAt: ) 을 수정한다.
let downloadOp = NetworkImageOperation(url: urls[indexPath.row])
let tiltShiftOp = TiltShiftOperation() 
tiltShiftOp.addDependency(downloadOp)

tiltShiftOp.completionBlock = { 
	DispatchQueue.main.async { 
		guard let cell = tableView.cellForRow(at: indexPath) as? PhotoCell else { return }
		
		cell.isLoading = false
		cell.display(image: tiltShiftOp.image)
	}
}

queue.addOperations([downloadOp, tiltShiftOp], waitUntilFinished: false)

Custom completion handler

  • 위의 코드는 default completionBlock 을 제공한다. 이미지를 가져오고 메인 큐로 다시 dispatch 하는 작업에 약간의 추가 작업을 수행해야 한다. 이러한 경우에는 custom completion block을 고려할 수 있다.
var onImageProcessed: ((UIImage?) -> Void)? 
  • 그런다음 main() 메소드 안에서 outputImage 다음으로 아래의 completion handler 을 작성한다.
if let onImageProcessed = onImageProcessed { 
	DispatchQueue.main.async { [weak self] in 
		onImageProcessed(self?.outputImage)
	}
}
  • tableVIew(_: cellForRowAt:)에 completion block을 수정한다.
tiltShiftOp.onImageProcessed = { image in 
	guard let cell = tableView.cellForRow(at: indexPath) as? PhotoCell else { return }
	
	cell.isLoading = false
	cell.display(image: image)
}
  • 이러한 변경으로 인해 기능적이나 성능적인 차이는 없지만, 호출자가 작업을 수행하는데 조금 더 편리하게 만들어 준다.
  • 가능한 retain cycle에 대한 혼란을 제거하고 자동으로 메인 UI 스레드에서 올바르게 작동하도록 보장받는다.
  • 완료 핸들러가 작업 큐의 제공된 스레드 대신 메인 UI 스레드에서 실행되고 있다는 사실을 문서화하는 것이 매우 중요하다.

Full Code

AsyncOperation

import Foundation

class AsyncOperation: Operation {
  // Create state management
  enum State: String {
    case ready, executing, finished

    fileprivate var keyPath: String {
      return "is\(rawValue.capitalized)"
    }
  }

  var state = State.ready {
    willSet {
      willChangeValue(forKey: newValue.keyPath)
      willChangeValue(forKey: state.keyPath)
    }
    didSet {
      didChangeValue(forKey: oldValue.keyPath)
      didChangeValue(forKey: state.keyPath)
    }
  }

  // Override properties
  override var isReady: Bool {
    return super.isReady && state == .ready
  }

  override var isExecuting: Bool {
    return state == .executing
  }

  override var isFinished: Bool {
    return state == .finished
  }

  override var isAsynchronous: Bool {
    return true
  }

  // Override start
  override func start() {
    main()
    state = .executing
  }
}

NetworkImageOperaton

import UIKit

protocol ImageDataProvier {
  var image: UIImage? { get }
}

final class NetworkImageOperation: AsyncOperation {
  var outputImage: UIImage?

  private let url: URL
  private let completionHandler: ((Data?, URLResponse?, Error?) -> Void)?

  init(url: URL, completionHandler: ((Data?, URLResponse?, Error?) -> Void)? = nil) {
    self.url = url
    self.completionHandler = completionHandler

    super.init()
  }

  convenience init?(string: String, completionHandler: ((Data?, URLResponse?, Error?) -> Void)? = nil) {
    guard let url = URL(string: string) else { return nil }
    self.init(url: url, completionHandler: completionHandler)
  }

  override func main() {
    URLSession.shared.dataTask(with: url) { [weak self]
      data, response, error in

      guard let self = self else { return }

      defer { self.state = .finished }

      if let completionHandler = self.completionHandler {
        completionHandler(data, response, error)
        return
      }

      guard error == nil, let data = data else { return }

      self.outputImage = UIImage(data: data)
    }.resume()
  }
}

extension NetworkImageOperation: ImageDataProvier {
  var image: UIImage? { return outputImage }
}

TiltShiftOperation

import UIKit

final class TiltShiftOperation: Operation {
  private static let context = CIContext()

  var outputImage: UIImage?

  var onImageProcessed: ((UIImage?) -> Void)?
  
  private let inputImage: UIImage?

  init(image: UIImage? = nil, completion: ((UIImage?) -> Void)? = nil) {
    onImageProcessed = completion
    inputImage = image
    super.init()
  }

  override func main() {
//    guard let inputImage = inputImage else { return }
    let dependencyImage = dependencies
      .compactMap { ($0 as? ImageDataProvier)?.image }
      .first
    
    guard let inputImage = inputImage ?? dependencyImage else { return }

    guard let filter = TiltShiftFilter(image: inputImage, radius:3),
      let output = filter.outputImage else {
        print("Failed to generate tilt shift image")
        return
    }

    let fromRect = CGRect(origin: .zero, size: inputImage.size)
    guard
      let cgImage = TiltShiftOperation.context.createCGImage(output, from: fromRect),
      let rendered = cgImage.rendered()
      else {
        print("No image generated")
        return
    }

    outputImage = UIImage(cgImage: rendered)
    
    if let onImageProcessed = onImageProcessed {
      DispatchQueue.main.async { [weak self] in
        onImageProcessed(self?.outputImage)
      }
    }
  }
}

extension TiltShiftOperation: ImageDataProvier {
  var image: UIImage? { return outputImage }
}

TiltShiftTableViewController

import UIKit

class TiltShiftTableViewController: UITableViewController {
  private let queue = OperationQueue()
  private var urls: [URL] = []

  override func viewDidLoad() {
    super.viewDidLoad()

    guard let plist = Bundle.main.url(forResource: "Photos", withExtension: "plist"),
      let contents = try? Data(contentsOf: plist),
      let serial = try? PropertyListSerialization.propertyList(from: contents, format: nil),
      let serialUrls = serial as? [String] else {
        print("Something went horribly wrong!")
        return
    }

    urls = serialUrls.compactMap { URL(string: $0) }
  }

  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return urls.count
  }

  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "normal", for: indexPath) as! PhotoCell
    cell.display(image: nil)
    
    let downloadOp = NetworkImageOperation(url: urls[indexPath.row])
    let tiltShiftOp = TiltShiftOperation { image in
      cell.isLoading = false
      cell.display(image: image)
    }
    
    tiltShiftOp.addDependency(downloadOp)


    queue.addOperations([downloadOp, tiltShiftOp], waitUntilFinished: false)

    return cell
  }
}

출처(참고문헌)

제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.

감사합니다

0개의 댓글