はじめに
この記事は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:)
を活用しています。-
waitUntilFinished
にtrue
を設定することで、OperationQueue
内部のOperationの完了を待機してくれます。 -
waitUntilFinished
による待機が終わるたびにOperationPhase
の変更を確認し、フラグに沿ってまた次のOperationを処理していきます。
-
-
処理がキャンセルされた場合は、
OperationPhase
がcancelled
に変化することで、後続の処理をスキップし、キャンセル処理に移ります。- 今回は省いていますが、エラーが発生した場合は、エラー処理を保持したclosureをOperationのサブクラスに保持させても良いですし、キャンセル時同様に、
executeOperationsInOrder
関数内部にエラー時の分岐を設けても良いかと思います。
- 今回は省いていますが、エラーが発生した場合は、エラー処理を保持したclosureをOperationのサブクラスに保持させても良いですし、キャンセル時同様に、
前処理
private func getPreparationOperations() -> [Operation] {
return [DelayOperation()]
}
- 非同期処理を行うために独自定義した
AsynchronousOperation
(後述します)のサブクラスDelayOperation
を渡しています。 -
DelayOperation
は、指定された秒数の間待機し、その後完了するだけのシンプルなタスクを実行するOperation
です。 - 今回、バックグラウンドで時間のかかる処理を行っていることを便宜的に表現するために、用意しています。
- 他にもやり方はありますが、
Operation
とOperation
の間にバッファを設けたく、かつ、それを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
の数の変化を監視しています。 -
OpearationPhase
がinProgress
の場合の進捗を外部へ伝えるために、完了した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の記事もお楽しみに