このサイトはWebfile便で作成しました利用はこちら

BGContinuedProcessingTaskでライブ進捗を支えるHeartbeat設計

ボイスメモLMは、音声の文字起こしと要約をローカルで完結させるiOSアプリです。本記事ではそのバッチ処理を支えるバックグラウンド実装を紹介します。

音声ファイルの文字起こしと要約処理をローカルで実行する場合一日分だと60分ほどかかります。バックグラウンドに逃がしつつ、Live Activityで進捗を伝え続けるための実装が必要になりました。ここではiOS 26で追加されたBGContinuedProcessingTaskの仕様と、ハートビート(Heartbeat)で進捗を途切れさせないようにする工夫をまとめました。

ボイスメモLMの概要

ボイスメモLMは、収録した音声を端末内でディクテーションし、生成AIを使って要約まで行うパーソナルAIボイスメモアプリです。App Storeでは「NoteBook LMのように外部記憶のコンテキストを保持したAIチャットアプリ」と「端末完結のプライバシー」を両立するアプリとして紹介しています。

詳細はApp Storeの紹介ページを参照してください。

ロック画面で示すバックグラウンド実行

ロック画面でLive Activityが音声処理の進捗を表示している様子
Live Activityのバナーで「文字起こしと要約を実行中」と表示され、バックグラウンドでの進捗がユーザーにも伝わる。

ロック画面のLive Activityは、BGContinuedProcessingTaskで得た進捗値を定期的に送り続けることで安定運用できます。疑似ユニットのHeartbeatを忍ばせることで、処理が一時的に停滞してもロック画面の表示がカウントアップを続け、停止扱いによるキャンセルを避けられました。

BGContinuedProcessingTaskとiOS 26の関係

BGContinuedProcessingTaskは、ユーザーがフォアグラウンドで開始した明確なタスクをバックグラウンドで継続し、システム標準の進捗UI(Live Activityなど)へ表示するための仕組みです。iOS 26以降でのみ利用できるため、実装では以下のポイントを押さえました。

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へ境界ユニットを反映させて整合性を保ちます。

詰まったポイントと工夫のまとめ

参考リンク