LoginSignup
7
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-12-22

はじめに

この記事はand factory.inc Advent Calendar 2022 23日目の記事です。
昨日は @twumo さんの 「【JetpackCompose】テキスト入力された最後の文字だけマスクを外したい」でした。

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

今回は表題の通り、

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

デモ

今回実装した簡易デモの画面収録が下記になります。

  • パターン1
    • スタートボタンタップ → ローディング → 前処理 → 本処理 → 完了
  • パターン2
    • スタートボタンタップ → ローディング → 前処理 → 本処理 → キャンセルボタンタップ → キャンセル

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

処理の流れ

①開始ボタンを押すと、くるくるローディングアニメーションが開始されます。

  • この間、バックグラウンドでは、実際に行いたいメインの処理の前処理にあたるOperationが実行されています

②進捗ゲージが表示され、進捗が更新されていきます

  • 本来行いたい時間のかかる処理が実行されている想定です

③進捗ゲージが100%に満ちた場合、表示が完了マークに変更されます

  • 完了マークが表示されるのは、バックグラウンドで後処理が完了した後になります

④処理中にキャンセルボタンを押すと、処理はキャンセルされ、キャンセル処理時完了後、表示はキャンセルマークに変更されます。

実装のポイント

前処理〜後処理の流れを司る関数


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:)を活用しています。

    • waitUntilFinishedtrueを設定することで、OperationQueue内部のOperationの完了を待機してくれます。
    • waitUntilFinishedによる待機が終わるたびにOperationPhaseの変更を確認し、フラグに沿ってまた次のOperationを処理していきます。
  • 処理がキャンセルされた場合は、OperationPhasecancelledに変化することで、後続の処理をスキップし、キャンセル処理に移ります。

    • 今回は省いていますが、エラーが発生した場合は、エラー処理を保持したclosureをOperationのサブクラスに保持させても良いですし、キャンセル時同様に、executeOperationsInOrder関数内部にエラー時の分岐を設けても良いかと思います。

前処理

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

本処理

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の配列を渡しています。
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の数の変化を監視しています。
  • OpearationPhaseinProgressの場合の進捗を外部へ伝えるために、完了したOperationの個数を渡しています。

後処理 & キャンセル処理

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のcompletionBlock内でログを出力しています。
  • 実際には、外部からclosureを渡して完了時、キャンセル時に実行したい処理を行うイメージです。

備考

AsynchronousOpearationについて

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を作成することができます。
  • こちらの記事を参考に実装しています。

waitUntilFinishedはOperation.completionBlockの完了を待ち合わせてくれない

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

今回のソースコードはこちらにPushしています!
最後まで読んでいただきありがとうございます!

明日のAdvent Calendarの記事もお楽しみに:santa:

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2