BGContinuedProcessingTaskでライブ進捗を支えるHeartbeat設計
ボイスメモLMは、音声の文字起こしと要約をローカルで完結させるiOSアプリです。本記事ではそのバッチ処理を支えるバックグラウンド実装を紹介します。
音声ファイルの文字起こしと要約処理をローカルで実行する場合一日分だと60分ほどかかります。バックグラウンドに逃がしつつ、Live Activityで進捗を伝え続けるための実装が必要になりました。ここではiOS 26で追加されたBGContinuedProcessingTaskの仕様と、ハートビート(Heartbeat)で進捗を途切れさせないようにする工夫をまとめました。
ボイスメモLMの概要
ボイスメモLMは、収録した音声を端末内でディクテーションし、生成AIを使って要約まで行うパーソナルAIボイスメモアプリです。App Storeでは「NoteBook LMのように外部記憶のコンテキストを保持したAIチャットアプリ」と「端末完結のプライバシー」を両立するアプリとして紹介しています。
- 録音した音声ファイルをそのままローカル変換。
- Live Activityと連動し、作業の進み具合をロック画面で確認可能。
詳細はApp Storeの紹介ページを参照してください。
ロック画面で示すバックグラウンド実行
ロック画面のLive Activityは、BGContinuedProcessingTaskで得た進捗値を定期的に送り続けることで安定運用できます。疑似ユニットのHeartbeatを忍ばせることで、処理が一時的に停滞してもロック画面の表示がカウントアップを続け、停止扱いによるキャンセルを避けられました。
BGContinuedProcessingTaskとiOS 26の関係
BGContinuedProcessingTaskは、ユーザーがフォアグラウンドで開始した明確なタスクをバックグラウンドで継続し、システム標準の進捗UI(Live Activityなど)へ表示するための仕組みです。iOS 26以降でのみ利用できるため、実装では以下のポイントを押さえました。
- 起動時登録: アプリ起動直後にタスクを一度だけ
BGTaskSchedulerへ登録し、配信されたタスクはメインアクターで処理。 - Live Activity連携:
task.progressに総件数/進捗件数を設定し、サブタイトルはupdateTitle(_:subtitle:)で動的に更新。 - 権限設定:
Info.plistにBGSupportsContinuedProcessing、processingモード、BGTaskSchedulerPermittedIdentifiersを追加し、必要に応じてGPUリソースを宣言。
Info.plistへは以下のキーをまとめて追加し、BGContinuedProcessingTask用の識別子とprocessingモードを宣言します。
<!-- BGContinuedProcessingTask で使用するタスク識別子を登録 -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).processing</string>
</array>
<!-- 継続処理モデルを利用するためのフラグ -->
<key>BGSupportsContinuedProcessing</key>
<true/>
<!-- Processing モードでのバックグラウンド実行を許可 -->
<key>UIBackgroundModes</key>
<array>
<string>processing</string>
</array>
セットアップの基本コード
以下はタスク登録〜進捗ブリッジまでを担う層の実装例です。
1. タスク識別子と登録
import BackgroundTasks
import Foundation
enum BackgroundTaskIdentifier {
static var continuedProcessing: String {
let bundleID = Bundle.main.bundleIdentifier ?? "com.example.app"
return "\(bundleID).processing"
}
}
@available(iOS 26, macCatalyst 18.0, *)
@MainActor
final class BackgroundTaskRegistrar {
static let shared = BackgroundTaskRegistrar()
private var didRegister = false
private init() {}
func ensureRegistered() {
guard !didRegister else { return }
didRegister = true
BGTaskScheduler.shared.register(
forTaskWithIdentifier: BackgroundTaskIdentifier.continuedProcessing,
using: nil
) { task in
guard let continuedTask = task as? BGContinuedProcessingTask else {
task.setTaskCompleted(success: false)
return
}
Task { @MainActor in
BatchContinuedProcessingCoordinator.shared.handle(task: continuedTask)
}
}
}
}
2. 進捗コーディネータ
@available(iOS 26, macCatalyst 18.0, *)
@MainActor
final class BatchContinuedProcessingCoordinator {
static let shared = BatchContinuedProcessingCoordinator()
private var task: BGContinuedProcessingTask?
private var totalUnits: Int = 0
private var completedUnits: Int = 0
private let baseTitle = "バックグラウンド処理を実行中"
private var cancellationRequested = false
private var requestActive = false
private init() {}
func begin(totalUnits: Int) async -> Bool {
self.totalUnits = max(totalUnits, 0)
completedUnits = 0
cancellationRequested = false
var request = BGContinuedProcessingTaskRequest(
identifier: BackgroundTaskIdentifier.continuedProcessing,
title: baseTitle,
subtitle: subtitle()
)
request.strategy = .queue
if BGTaskScheduler.supportedResources.contains(.gpu) {
request.requiredResources.insert(.gpu)
}
do {
try await BGTaskScheduler.shared.submit(request)
requestActive = true
return true
} catch {
requestActive = false
return false
}
}
func handle(task: BGContinuedProcessingTask) {
self.task = task
task.progress.totalUnitCount = Int64(max(totalUnits, 1))
task.progress.completedUnitCount = Int64(completedUnits)
updateLiveActivity()
task.expirationHandler = { [weak self] in
guard let self else { return }
Task { @MainActor in self.handleExpiration() }
}
}
func updateProgress(processedUnits: Int) {
completedUnits = min(max(processedUnits, 0), totalUnits)
task?.progress.completedUnitCount = Int64(completedUnits)
updateLiveActivity()
}
func finish(success: Bool) {
guard requestActive else { return }
if success { task?.progress.completedUnitCount = Int64(totalUnits) }
task?.setTaskCompleted(success: success)
cleanup()
}
var isCancellationRequested: Bool { cancellationRequested }
private func handleExpiration() {
cancellationRequested = true
task?.setTaskCompleted(success: false)
cleanup()
}
private func subtitle() -> String {
guard totalUnits > 0 else { return "準備中" }
let percent = Int(Double(completedUnits) / Double(totalUnits) * 100.0)
return "\(completedUnits) / \(totalUnits) tasks (\(percent)%)"
}
private func updateLiveActivity() {
task?.updateTitle(baseTitle, subtitle: subtitle())
}
private func cleanup() {
task = nil
requestActive = false
totalUnits = 0
completedUnits = 0
}
}
Heartbeatで進捗を滑らかに保つ
当初は「処理済みファイル数 / 総ファイル数」をそのまま報告したかったものの、長時間処理が続くとLive Activityの進捗が止まってしまい、システムがタスクをタイムアウト扱いする問題がありました。そこで疑似進捗を送るHeartbeatアクターを導入し、無信号状態を作らないようにしています。
@available(iOS 26, macCatalyst 18.0, *)
actor BackgroundProgressHeartbeat {
private let boundaryUnits: Int
private let update: @Sendable (Int) -> Void
private var lastUnitsReported: Int
private var finished = false
private var heartbeatTask: Task?
init(startUnits: Int, boundaryUnits: Int, update: @escaping @Sendable (Int) -> Void) {
self.lastUnitsReported = startUnits
self.boundaryUnits = boundaryUnits
self.update = update
}
func start() {
guard heartbeatTask == nil else { return }
heartbeatTask = Task.detached { [weak self] in
guard let self else { return }
while true {
do {
try await Task.sleep(nanoseconds: 2_000_000_000)
} catch {
break
}
let shouldContinue = await self.emitHeartbeatTick()
if !shouldContinue { break }
}
}
}
private func emitHeartbeatTick() -> Bool {
guard !finished else { return false }
guard lastUnitsReported < boundaryUnits - 1 else { return false }
let next = min(boundaryUnits - 1, lastUnitsReported + 1)
guard next > lastUnitsReported else { return false }
lastUnitsReported = next
update(next)
return lastUnitsReported < boundaryUnits - 1 && !finished
}
func reportCandidate(_ units: Int) {
guard !finished else { return }
let limited = min(units, boundaryUnits - 1)
guard limited > lastUnitsReported else { return }
lastUnitsReported = limited
update(limited)
}
func finish() {
guard !finished else { return }
finished = true
heartbeatTask?.cancel()
heartbeatTask = nil
}
}
疑似ユニットを細かく刻み、ロード・変換・サマリ・Git処理までを数百ユニットに分解。進捗が止まりそうなときはHeartbeatが2秒ごとに最小限インクリメントし、実際に進んだ瞬間はreportCandidate()で実測値に追従します。完了時にはfinish()で疑似更新を止め、BatchContinuedProcessingCoordinatorへ境界ユニットを反映させて整合性を保ちます。
詰まったポイントと工夫のまとめ
- 単純に分子・分母だけを報告すると、長い無信号区間がLive Activityにとって「停滞」に見え、システムキャンセルを誘発した。
- 疑似ユニットとHeartbeatを導入することで、常に進捗イベントが発火し、「動いている」ことをシステムに伝えられるようになった。
- 実際の進行があった瞬間は即座にHeartbeatへ報告し、完了時には境界ユニットまで引き上げることで、疑似進捗と実測が乖離しないようにした。
- システムキャンセル検知時は
isCancellationRequestedを確認し残作業を停止、finish(success: false)で後処理を統一。
参考リンク
- Background Tasksフレームワーク入門
- Preparing your UI to run in the background | Apple Developer Documentation
- BGContinuedProcessingTaskRequest | Apple Developer Documentation
- BGContinuedProcessingTask | Apple Developer Documentation
- BGContinuedProcessingTaskRequest.Resources | Apple Developer Documentation
- Performing long-running tasks on iOS and iPadOS | Apple Developer Documentation