iOS の進捗を検証する:重み付け、キャンセル、完了状態
ファイルを一括処理していると、進捗バーが 100% に達した後でエラーが表示されたり、ユーザーがキャンセルしたのにバックグラウンドで結果が確定されたりすることがあります。多くの場合、原因は描画ではなく、作業量、キャンセル信号、処理の最終状態を混同していることにあります。GNUMac のクラウド Mac では、まずネットワークやシミュレータに依存しないユニットテストでこれらの契約を固め、その後で iOS の UI を検証できます。
進捗が何を表すかを先に決める
ここでは「4 件の入力を変換し、2 件の出力を検証する」というテスト用の処理を使います。変換には全作業量の 60%、検証には 40% を割り当てます。これはテストで定めた重みであり、性能測定の結果でも、残り時間の割合でもありません。
親の進捗の総単位数を 100 とし、子の進捗はそれぞれファイル数で計測します。変換するファイル数とアップロードするバイト数をそのまま足してはいけません。単位が異なる場合は、まず親に割り当てる重みに換算する必要があります。
処理状態は別に、待機中、実行中、キャンセル中、キャンセル済み、失敗、成功として管理します。進捗オブジェクトは量を表すためのものであり、エラーの分類を担うものではありません。
パーセント表示が答えるのは「定義した作業量のうち、どれだけ完了したか」です。処理状態が答えるのは「結果が確実に引き渡されたか」です。両者を互いの代わりに使うことはできません。
実装前に、次の 3 つの制約を明文化します。各子進捗を親に追加するのは一度だけとすること。完了単位数が総単位数を超えないこと。結果のコミット前に必ずキャンセルを確認することです。親子の進捗オブジェクトは個々のタスクが保持し、全タスクで共有するグローバル変数には置きません。
決定的なテストで重み付けを検証する
既存プロジェクトのユニットテストターゲットにテストファイルを作成し、次のコードを追加します。使用するのは Foundation と XCTest だけです。実際のファイル変換を待つ必要はなく、スリープで実行タイミングを推測することもありません。
import Foundation
import XCTest
final class WeightedProgressTests: XCTestCase {
func testWeightedStages() {
let root = Progress(totalUnitCount: 100)
let convert = Progress(totalUnitCount: 4)
let verify = Progress(totalUnitCount: 2)
root.addChild(convert, withPendingUnitCount: 60)
root.addChild(verify, withPendingUnitCount: 40)
XCTAssertEqual(root.fractionCompleted, 0, accuracy: 0.000001)
convert.completedUnitCount = 2
XCTAssertEqual(root.fractionCompleted, 0.3, accuracy: 0.000001)
verify.completedUnitCount = 1
XCTAssertEqual(root.fractionCompleted, 0.5, accuracy: 0.000001)
convert.completedUnitCount = 4
verify.completedUnitCount = 2
XCTAssertEqual(root.fractionCompleted, 1, accuracy: 0.000001)
}
}
ここでは意図的に 2 つの段階を交互に進め、親の進捗が現在の段階だけを反映していないことを確認しています。浮動小数点数の結果は許容誤差を指定して比較し、文字列に整形したパーセント値でアサーションを行わないようにします。
クラウド Mac のテストナビゲータでこのクラスを実行し、コミット ID、テスト結果、使用したツールチェーンの情報を保存します。コマンドラインからテストを実行する仕組みがすでにある場合は、このテストターゲットだけを追加します。進捗テストのために別のシミュレータマトリクスを用意する必要はありません。
キャンセルには実行側の協調が必要
Progress.cancel() は信号を送るだけで、実行中のループを強制終了するものではありません。各作業単位の処理前にキャンセル状態を確認し、実際に完了してからカウントを増やします。最終的な出力ファイルを置き換える直前にも、再度確認が必要です。
次のテストでは、「キャンセル後は次の作業単位を開始しない」ことを検証します。並行実行時の競合状態まで網羅するものではありません。先ほどのテストクラスにそのまま追加できます。
func testCancellationStopsNextUnit() {
let progress = Progress(totalUnitCount: 4)
progress.isCancellable = true
var processed = 0
func runNextUnit() {
guard !progress.isCancelled else { return }
processed += 1
progress.completedUnitCount += 1
}
runNextUnit()
progress.cancel()
runNextUnit()
XCTAssertTrue(progress.isCancelled)
XCTAssertEqual(processed, 1)
XCTAssertEqual(progress.completedUnitCount, 1)
}
非同期の実行処理では、時間のかかる操作を開始する直前と、結果をコミットする直前の 2 か所に、テストから制御できる一時停止ポイントも必要です。テストでタスクを停止させ、キャンセルを通知してから再開を許可し、新しい作業が始まらず、最終結果も公開されないことを確認します。同期にはテスト用のゲートを使い、固定秒数の待機には頼りません。
下位の処理にキャンセル用のインターフェースがある場合は、同時にキャンセル信号を転送します。処理を中断できない場合は、戻ってくるのを待ってから結果を破棄し、一時ファイルを削除します。その間、UI は「キャンセル中」のままにし、実際に停止する前に停止済みと表示してはいけません。
総量が不明な処理と再試行を取り繕わない
ファイル総数の走査が終わっていない間は、「集計中」と表示します。総単位数をゼロにして無理にパーセントを算出してはいけません。走査が完了したらタスク一覧を確定し、親子関係をすべて構築してから処理を開始します。
実行中に分母を増やし続けると、進捗が後戻りします。タスクを動的に検出する必要がある場合は、処理段階や処理済み件数を表示し、現在のパーセントが確定済みの段階だけを対象としていることを明示します。
再試行のたびに同じ子進捗をゼロに戻すことも避けます。処理ロジック側では「何回目の試行か」を保持して構いませんが、進捗側では正常に完了した論理的な作業単位を積み上げます。試行が 1 回失敗しても、ファイルをもう 1 件処理し終えたことにはなりません。
入力がゼロ件の場合も、別途ルールを定めます。空の入力が有効なら、「処理不要」として直ちに成功を返します。呼び出し側に入力の指定が必須なら、開始前にエラーを返し、開始位置から永遠に進まないタスクを作らないようにします。
UI への反映と継続的な検証につなげる
進捗の変化を監視したら、UI の状態更新はメインエグゼキュータ上で行います。実行ごとに独立したタスク識別子を割り当て、更新を反映する際に照合します。これにより、前回のタスクから遅れて届いた通知が、新しい進捗を上書きするのを防ぎます。
表示するパーセントは整数に丸めても構いませんが、丸めた値で成功を判定してはいけません。最後のファイルの処理後にも検証やアトミックなコミットが残るなら、それらの手順にも作業量を割り当てます。失敗時はエラー状態を保持し、カウントを強制的に総量まで埋めないようにします。
マージ前に、次の項目を一つずつ確認します。
- 重み付けの途中経過と、段階を交互に進める場合のアサーションが成功し、結果が実行速度に依存しない。
- 開始前、処理中、コミット直前のキャンセルに、それぞれ独立したテストケースがある。
- エラー経路が成功として扱われず、一時出力を削除できる。
- 新しいタスクの開始後は、古いタスクの通知が現在の UI を変更できない。
- 空の入力、総量が不明な処理、再試行について、表示ルールが明確に定められている。
最終的に届けるべきものは、滑らかに見える進捗バーではなく、検証可能な状態の契約です。まずカウントを信頼できるものにし、次にキャンセルを制御可能にし、最後に、結果が本当に利用できるときだけ成功とする仕組みを整えます。
よくある質問
進捗が百パーセントなら成功と表示できますか?
できません。進捗は完了した作業量を示します。最終検証と結果の受け渡しが成功したかは、別の処理状態で管理してください。
cancel() を呼ぶと実行中の処理も自動で止まりますか?
止まりません。実行側がキャンセル状態を確認し、実際の処理へキャンセルを伝える必要があります。結果を確定する直前にも再確認します。
ワークフローに合わせて、専用の Mac mini を用意しましょう
プロジェクトに合わせて、モデル・ノード・利用期間をお選びください。ご注文前にメモリ、ストレージ、接続方法をご確認ください。実際の利用状況は、コンソールに表示される最新情報をご確認ください。