OperationQueueを活用して、前処理,後処理,キャンセル処理が必要なタスクをバックグラウンドで行う

# はじめに
この記事はand factory.inc Advent Calendar 2022 23日目の記事です。
昨日は @twumo さんの 「[【JetpackCompose】テキスト入力された最後の文字だけマスクを外したい](https://qiita.com/twumo/items/155a7f1cc53aa55dc739)」でした。

開発中の機能で`OperationQueue`について調べていた時期があり、大変便利だったので備忘録がてら共有です。

今回は表題の通り、
- 時間のかかる複雑な非同期処理をバックグラウンドで行いたい(ex: データダウンロード)
かつ
- 前処理(ex: 実行前に事前準備となる処理), 後処理が必要
- キャンセル処理(前処理~後処理のいずれかのポイントで問題が発生した時に、必ず実行したい処理)
があるようなケースを想定し、`OperationQueue`を活用したデモを作成しました。


# デモ
今回実装した[簡易デモ](https://github.com/ykkd/OperationQueueDemo)の画面収録が下記になります。

- パターン１
  - スタートボタンタップ → ローディング → 前処理 → 本処理 → 完了
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605495/012e5c7d-e337-a20c-16e5-000b729ed9ae.gif" width=25%>

- パターン2
  - スタートボタンタップ → ローディング → 前処理 → 本処理 → キャンセルボタンタップ → キャンセル
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605495/11902c8e-9515-a2a8-f381-7f91597782bc.gif" width=25%>


UIの更新はメインスレッドですが、その他の処理はバックグラウンドでやっています。

## 処理の流れ
①開始ボタンを押すと、くるくるローディングアニメーションが開始されます。
- この間、バックグラウンドでは、実際に行いたいメインの処理の前処理にあたる`Operation`が実行されています
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605495/9aa324c9-c72f-0bd8-f783-f0bbc579bb90.png" width=25%>


②進捗ゲージが表示され、進捗が更新されていきます
- 本来行いたい時間のかかる処理が実行されている想定です
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605495/cb4b799a-02f7-f973-bfd9-a5a6d86b9e89.png" width=25%>


③進捗ゲージが100%に満ちた場合、表示が完了マークに変更されます
- 完了マークが表示されるのは、バックグラウンドで後処理が完了した後になります
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605495/7e21c93c-6bfb-2f61-f585-615240a26a8e.png" width=25%>


④処理中にキャンセルボタンを押すと、処理はキャンセルされ、キャンセル処理時完了後、表示はキャンセルマークに変更されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605495/33f25f33-27ed-44c9-59dd-79a668e9a129.png" width=25%>

# 実装のポイント
## 前処理〜後処理の流れを司る関数
```swift

private func executeOperationsInOrder() {
    DispatchQueue.global(qos: .userInitiated).async {
        // 前処理
        if !self.phase.shouldAvoidOperations {
            OSLogger.shared.log("preparation start")
            self.phase = .preparation
            let preparationOperations = self.getPreparationOperations()
            self.queue.addOperations(preparationOperations, waitUntilFinished: true)
        }
        // 本処理
        if !self.phase.shouldAvoidOperations {
            OSLogger.shared.log("in progress")
            self.phase = .inProgress
            let inProgressOperations = self.getInProgressOperations()
            self.totalOperationCount = inProgressOperations.count
            self.queue.addOperations(inProgressOperations, waitUntilFinished: true)
        }
        // 完了処理
        if !self.phase.shouldAvoidOperations {
            OSLogger.shared.log("will finish")
            self.phase = .willFinish
            let willFinishOperations = self.getWillFinishOperations()
            self.queue.addOperations(willFinishOperations, waitUntilFinished: true)
            self.delegate?.willFinish()
        }
        // キャンセルされた際の処理
        if self.phase.isCancelled {
            OSLogger.shared.log("cancelled")
            let cancelOperations = self.getCancelOperations()
            self.queue.addOperations(cancelOperations, waitUntilFinished: true)
            self.delegate?.didCancel()
        } else {
            OSLogger.shared.log("did finish")
            self.phase = .didFinish
            self.delegate?.didFinish()
        }
    }
}
```
- やっていることとしては、以下のステップの繰り返しです。
  - ①`OperationQueue`にタスクを追加し
  - ②タスクの終了を待ち
  - ③次のタスクを新たに追加して良いかチェックする

- 上記繰り返し処理を実現するために、`OperationQueue.addOperations(waitUntilFinished:)`を活用しています。
    - `waitUntilFinished`に`true`を設定することで、`OperationQueue`内部のOperationの完了を待機してくれます。
    - `waitUntilFinished`による待機が終わるたびに`OperationPhase`の変更を確認し、フラグに沿ってまた次のOperationを処理していきます。
- 処理がキャンセルされた場合は、`OperationPhase`が`cancelled`に変化することで、後続の処理をスキップし、キャンセル処理に移ります。
   - 今回は省いていますが、エラーが発生した場合は、エラー処理を保持したclosureをOperationのサブクラスに保持させても良いですし、キャンセル時同様に、`executeOperationsInOrder`関数内部にエラー時の分岐を設けても良いかと思います。


## 前処理
```swift
private func getPreparationOperations() -> [Operation] {
    return [DelayOperation()]
}
```
- 非同期処理を行うために独自定義した`AsynchronousOperation`(後述します)のサブクラス`DelayOperation`を渡しています。
- `DelayOperation`は、指定された秒数の間待機し、その後完了するだけのシンプルなタスクを実行する`Operation`です。
- 今回、バックグラウンドで時間のかかる処理を行っていることを便宜的に表現するために、用意しています。
  - 他にもやり方はありますが、`Operation`と`Operation`の間にバッファを設けたく、かつ、それを`Operation`型で表現したい場合は使えそうです。

## 本処理
```swift
private func getInProgressOperations() -> [Operation] {
    let count = Int.random(in: 10...20)

    var operations = [DelayOperation]()
    for _ in 0...count {
        let interval = TimeInterval.random(in: 3...5)
        operations.append(DelayOperation(interval: interval))
    }
    return operations
}
```
- こちらも同じく、時間のかかる非同期処理の代替として`DelayOperation`の配列を渡しています。

```swift
private func startProgressObservation() {
    self.progressObservation = self.queue.observe(
        \.operationCount,
         options: [.new, .old],
         changeHandler: { [weak self] _, value in
            // pageDownload中にoperationCountが減少した場合進捗計算へ進む
            guard let self,
                  self.phase == .inProgress,
                  let latestOperationCount = value.newValue,
                  let previousOperationCount = value.oldValue,
                  latestOperationCount < previousOperationCount else {
                return
            }

            // 総Operation数は通知されるOperatonCountの最大値
            if latestOperationCount > self.totalOperationCount {
                self.totalOperationCount = latestOperationCount
            }
            // 完了済みOperation数は 総数 - 残数
            let numberOfFinishedOperations = self.totalOperationCount - latestOperationCount

             self.publishProgressIfNeeded(numberOfFinishedOperations)
         }
    )
}
```
- 上記処理によって、`OperationQueue`が保持している`Operation`の数の変化を監視しています。
- `OpearationPhase`が`inProgress`の場合の進捗を外部へ伝えるために、完了した`Operation`の個数を渡しています。

## 後処理 & キャンセル処理
```swift
private func getWillFinishOperations() -> [Operation] {
    let willFinishOperation = DelayOperation()
    willFinishOperation.completionBlock = {
        Thread.sleep(forTimeInterval: 1.0)
        OSLogger.shared.log("willFinish operation completed")
    }
    return [willFinishOperation]
}

private func getCancelOperations() -> [Operation] {
    let cancelOperation = DelayOperation()
    cancelOperation.completionBlock = {
        Thread.sleep(forTimeInterval: 1.0)
        OSLogger.shared.log("cancel operation completed")
    }
    return [cancelOperation]
}
```
- どちらも行なっていることは同じです。
- `DelayOperation`のc`ompletionBlock`内でログを出力しています。
- 実際には、外部から`closure`を渡して完了時、キャンセル時に実行したい処理を行うイメージです。

# 備考
## AsynchronousOpearationについて
```swift
open class AsynchronousOperation: Operation {

    /// Operationの進捗を表現したenum
    @objc
    private enum OperationState: Int {
        case ready
        case executing
        case finished
    }

    /// スレッドセーフにstateにアクセス可能にする
    private let stateQueue = DispatchQueue(label: "asynchronous.operation.state", attributes: .concurrent)

    private var rawState: OperationState = .ready

    @objc private dynamic var state: OperationState {
        get { return self.stateQueue.sync { self.rawState } }
        set { self.stateQueue.sync(flags: .barrier) { self.rawState = newValue } }
    }
}

extension AsynchronousOperation {

    override open var isReady: Bool {
        return self.state == .ready && super.isReady
    }

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

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

// MARK: KVO for Operation State
extension AsynchronousOperation {

    override open class func keyPathsForValuesAffectingValue(forKey key: String) -> Set<String> {
        if ["isReady", "isFinished", "isExecuting"].contains(key) {
            return [#keyPath(state)]
        }
        return super.keyPathsForValuesAffectingValue(forKey: key)
    }
}

// MARK: Execution
extension AsynchronousOperation {

    override public final func start() {
        if self.isCancelled {
            self.finish()
            return
        }

        self.state = .executing
        self.main()
    }

    /// Subclasses must implement this to perform their work and they must not call `super`. The default implementation of this function throws an exception.
    override open func main() {
        fatalError("Subclasses must implement `main`.")
    }

    /// 非同期処理の完了時に実行することでOperationを完了扱いにする
    /// この関数を実行しない限り完了扱いにならないので注意
    public final func finish() {
        if !self.isFinished {
            let semaphore = DispatchSemaphore(value: 0)
            let finishBlock = { [weak self] in
                self?.completionBlock?()
                self?.completionBlock = nil
                semaphore.signal()
            }
            finishBlock()
            semaphore.wait()
            self.state = .finished
        }
    }
}
```
- このクラスをサブクラスした`Operation`を定義することで、非同期処理の完了を待ち合わせてから完了状態になる`Operation`を作成することができます。
- [こちら](https://stackoverflow.com/questions/43561169/trying-to-understand-asynchronous-operation-subclass)の記事を参考に実装しています。

## waitUntilFinishedはOperation.completionBlockの完了を待ち合わせてくれない
- 今回繰り返し活用している`OpearationQueue.addOperations(waitUntilFinished:)`ですが、`Operation.completionBlock`の完了を待機せずに次の処理へ移行してしまいます。
  - 同期処理なら特に困ることもないのですが、今回のように時間のかかる非同期処理を全て完了させてから次の処理に進みたい場合不便です。
- 今回は`AsynchrounouOperation`の内部で`DispatchSemaphore`を用いてクロージャの完了を待ち合わせることで対処しました。

```swift
public final func finish() {
    if !self.isFinished {
        let semaphore = DispatchSemaphore(value: 0)
        let finishBlock = { [weak self] in
            self?.completionBlock?()
            self?.completionBlock = nil
            semaphore.signal()
        }
        finishBlock()
        semaphore.wait()
        self.state = .finished
    }
}
```

# おわりに
少し複雑なバックグラウンド処理であっても、
それぞれ`Operation`クラスを定義して`OperationQueue`に渡せば順番に実行してくれるのは大変有難いですね。

今回のソースコードは[こちら](https://github.com/ykkd/OperationQueueDemo)にPushしています！
最後まで読んでいただきありがとうございます！

明日の[Advent Calendar](https://qiita.com/advent-calendar/2022/andfactory)の記事もお楽しみに:santa: