OnDevice Kit

URLSession・ミラー・SHA-256 で 2 GB のモデルを iOS でバックグラウンドダウンロードする

言語モデルは 1.6 GB のファイルです。ユーザーはそれをホテルの Wi-Fi でダウンロードし始め、そのままアプリを閉じてしまいます。それでもダウンロードを最後まで完了させ、届いたファイルが正しいことを確かめる方法を解説します。

ローカルモデルをアプリバンドルに同梱するという選択肢は、ほとんどありません。4-bit の LFM2 2.6B は 1,563,668,704 バイトあり、使うかどうかもわからない機能のために 1.7 GB のアプリをインストールしたい人はいません。そのため、モデルは必要になった時点でダウンロードします。素の URLSession.shared.download(from:) は理想的な状況では動きますが、よくある状況ではことごとく失敗します。ユーザーが別のアプリに切り替える、画面がロックされる、80 % の時点で接続が切れる、CDN がステータス 200 や 404 で HTML のエラーページを返す、といったケースです。

このガイドでは、バックグラウンド URLSession、ミラーのリスト、チェックサムを使って、こうしたケースに対応するダウンロードマネージャーを作ります。約 150 行で、依存ライブラリはありません。

期待するファイルを定義する

まず、ファイルが正しいことを示す情報、つまり正確なサイズと SHA-256 を用意します。どちらも Mac 上で ls -l と shasum -a 256 を使って一度だけ取得します。

Shell
ls -l LFM2-2.6B-Q4_K_M.gguf          # 1563668704
shasum -a 256 LFM2-2.6B-Q4_K_M.gguf  # 384bc877b6c37064982f96885bef69e4475919f5969218ed4e3b9399ae0340df
ModelSpec.swift
import Foundation

struct ModelSpec: Sendable {
    let id: String
    let urls: [URL]          // tried in order: your mirror first, the original last
    let fileName: String
    let bytes: Int64         // for progress when the server sends no Content-Length
    let sha256: String

    static let lfm2 = ModelSpec(
        id: "lfm2-2.6b-q4_k_m",
        urls: [
            URL(string: "https://cdn.example.com/models/lfm2-2.6b-q4_k_m.gguf")!,
            URL(string: "https://huggingface.co/LiquidAI/LFM2-2.6B-GGUF/resolve/main/LFM2-2.6B-Q4_K_M.gguf")!,
        ],
        fileName: "lfm2-2.6b-q4_k_m.gguf",
        bytes: 1_563_668_704,
        sha256: "384bc877b6c37064982f96885bef69e4475919f5969218ed4e3b9399ae0340df"
    )
}

HTTPS がすでに転送を保護しているのに、なぜチェックサムが必要なのでしょうか。実際に遭遇する失敗は改ざんではないからです。古いバージョンを配信しているミラー、大きなレスポンスを途中で切り捨てるプロキシ、上流で差し替えられたファイルなどです。llama.cpp は途中で切れた GGUF をうまく扱えず、読み込み中にクラッシュすることがあります。1.6 GB の検証には一度だけ数秒かかりますが、そのクラッシュレポートを未然に防げます。

バックグラウンドセッション

バックグラウンドセッションは、転送をシステムのデーモンに委ねます。アプリがサスペンドされている間も転送は続き、システムによってアプリが終了された場合でも、ダウンロードが完了するとアプリはバックグラウンドで再起動されます。重要な設定は 3 つです。

ModelDownloader.swift
import Foundation
import CryptoKit
import Observation

@MainActor
@Observable
final class ModelDownloader: NSObject {
    enum State: Equatable {
        case notDownloaded, downloading(Double), verifying, ready, failed(String)
    }

    let spec: ModelSpec
    private(set) var state: State = .notDownloaded

    @ObservationIgnored private var session: URLSession!
    @ObservationIgnored private var task: URLSessionDownloadTask?
    @ObservationIgnored private var urlIndex = 0
    @ObservationIgnored static var completions: [String: () -> Void] = [:]

    nonisolated var identifier: String { "model.\(spec.id)" }

    nonisolated var file: URL {
        let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("Models", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir.appendingPathComponent(spec.fileName)
    }

    init(_ spec: ModelSpec) {
        self.spec = spec
        super.init()
        let config = URLSessionConfiguration.background(withIdentifier: identifier)
        config.isDiscretionary = false          // start now, not when iOS finds it convenient
        config.sessionSendsLaunchEvents = true  // relaunch the app when the transfer finishes
        session = URLSession(configuration: config, delegate: self, delegateQueue: .main)

        if FileManager.default.fileExists(atPath: file.path) { state = .ready; return }
        reattach()
    }
}

見落とされがちなのが isDiscretionary = false です。true にすると、iOS は Wi-Fi と電源が揃うまで開始すらしないことがあります。プリフェッチならそれで正しいのですが、ユーザーがたった今タップしたダウンロードには不適切です。識別子は起動をまたいで一定でなければなりません。新しいプロセスは、この識別子を手がかりに古いプロセスが開始した転送を見つけるからです。

起動時に実行中のダウンロードを引き継ぐ

ダウンロードの途中でユーザーがアプリを開き直しても、転送はシステム内で続いています。同じ識別子でセッションを作成すれば再接続されるので、実行中のタスクを問い合わせてその進捗を表示するだけで済みます。

Swift
extension ModelDownloader {
    private func reattach() {
        session.getAllTasks { [weak self] tasks in
            guard let running = tasks.first as? URLSessionDownloadTask else { return }
            Task { @MainActor in
                guard let self else { return }
                self.task = running
                self.urlIndex = Int(running.taskDescription ?? "0") ?? 0
                self.state = .downloading(self.fraction(running.countOfBytesReceived,
                                                         running.countOfBytesExpectedToReceive))
            }
        }
    }

    func download() {
        guard task == nil, state != .ready else { return }
        urlIndex = 0
        start()
    }

    private func start() {
        guard urlIndex < spec.urls.count else {
            state = .failed("The download didn't work. Check the connection and try again, ideally on Wi-Fi.")
            return
        }
        state = .downloading(0)
        task = session.downloadTask(with: spec.urls[urlIndex])
        task?.taskDescription = String(urlIndex)   // survives relaunch: which mirror is this?
        task?.resume()
    }

    private func next() {
        task = nil
        urlIndex += 1
        start()
    }

    private func fraction(_ written: Int64, _ expected: Int64) -> Double {
        let total = expected > 0 ? expected : spec.bytes
        return min(1, Double(written) / Double(max(total, 1)))
    }
}

taskDescription はタスク自体に状態を保持できる唯一の場所で、アプリが強制終了されても残ります。ここにミラーのインデックスを保存しておけば、再起動後に最初のミラーが失敗しても、次にどれを試すべきかがわかります。

デリゲート:ステータスコード、ファイルの移動、エラー

バックグラウンドセッションは completion handler をサポートしておらず、すべてがデリゲート経由で届きます。didFinishDownloadingTo には 2 つの落とし穴があります。1 つ目は、404 や 500 でも「完了」扱いになり、エラーページの入ったファイルが渡されることです。2 つ目は、一時ファイルがこのメソッドから戻った時点で削除されることです。そのため、メインアクターに移る前に同期的にファイルを移動する必要があります。

Swift
extension ModelDownloader: URLSessionDownloadDelegate {
    nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                                didWriteData _: Int64, totalBytesWritten written: Int64,
                                totalBytesExpectedToWrite expected: Int64) {
        Task { @MainActor in self.state = .downloading(self.fraction(written, expected)) }
    }

    nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                                didFinishDownloadingTo location: URL) {
        if let http = downloadTask.response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
            Task { @MainActor in self.finished(ok: false) }
            return
        }
        var target = file
        try? FileManager.default.removeItem(at: target)
        let ok = (try? FileManager.default.moveItem(at: location, to: target)) != nil
        if ok {
            var values = URLResourceValues()
            values.isExcludedFromBackup = true    // 1.6 GB does not belong in iCloud Backup
            try? target.setResourceValues(values)
        }
        Task { @MainActor in self.finished(ok: ok) }
    }

    nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        guard let error, (error as NSError).code != NSURLErrorCancelled else { return }
        Task { @MainActor in
            if (error as NSError).code == NSURLErrorNotConnectedToInternet {
                self.task = nil
                self.state = .failed("You're offline. Try again when you're connected.")
            } else {
                self.next()      // timeouts, resets, TLS errors: try the next mirror
            }
        }
    }
}

ファイルをバックアップから除外するのは、単なる気遣いではありません。再ダウンロード可能な大容量データをバックアップ対象の場所に置いたことで App Review でリジェクトされたアプリがありますし、ユーザーの iCloud の容量のためにもなります。

SHA-256 の検証をメインスレッド外で行う

ハッシュを計算するために 1.6 GB を Data に読み込むと、まさにモデルを読み込もうとしているタイミングでメモリ使用量が倍になります。代わりに、バックグラウンドタスクで 8 MB ずつストリーミングして処理します。

Swift
extension ModelDownloader {
    fileprivate func finished(ok: Bool) {
        task = nil
        guard ok else { next(); return }
        state = .verifying
        let file = self.file, expected = spec.sha256.lowercased()
        Task.detached(priority: .utility) {
            let good = Self.sha256(of: file) == expected
            await MainActor.run {
                if good { self.state = .ready }
                else {
                    try? FileManager.default.removeItem(at: file)
                    self.next()   // wrong or broken file from this source: try the next one
                }
            }
        }
    }

    nonisolated static func sha256(of url: URL) -> String? {
        guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
        defer { try? handle.close() }
        var hasher = SHA256()
        while let chunk = try? handle.read(upToCount: 8 << 20), !chunk.isEmpty {
            hasher.update(data: chunk)
        }
        return hasher.finalize().map { String(format: "%02x", $0) }.joined()
    }
}

チェックサムの検証に失敗した場合も、HTTP エラーと同様に次の URL へ進みます。これがミラーを用意する意味です。自前のサーバーを高速な第一候補にしておき、万一そこが壊れたファイルを配信しても、Hugging Face 上のオリジナルからユーザーは正常なモデルを入手できます。

App Delegate からバックグラウンドイベントを転送する

アプリが起動していない間にダウンロードが完了すると、iOS はアプリをバックグラウンドで起動し、App Delegate を呼び出します。completion handler を保持しておき、セッションがすべてのイベントを配信し終えたら呼び出す必要があります。そうしないと、iOS はバックグラウンド実行時間を割り当てなくなります。

App.swift
import SwiftUI

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     handleEventsForBackgroundURLSession identifier: String,
                     completionHandler: @escaping () -> Void) {
        guard identifier.hasPrefix("model.") else { completionHandler(); return }
        ModelDownloader.completions[identifier] = completionHandler
    }
}

extension ModelDownloader {
    nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        let id = session.configuration.identifier ?? ""
        Task { @MainActor in ModelDownloader.completions.removeValue(forKey: id)?() }
    }
}

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
    var body: some Scene { WindowGroup { ContentView() } }
}

再起動されたプロセスがこれらのデリゲート呼び出しを受け取るには、同じ識別子を持つ ModelDownloader が存在している必要があります。設定画面で遅延生成するのではなく、アプリのルートビューや共有のモデルオブジェクトで早い段階に生成してください。

UI

Swift
struct ModelRow: View {
    let downloader: ModelDownloader

    var body: some View {
        switch downloader.state {
        case .notDownloaded:      Button("Download (1.6 GB)") { downloader.download() }
        case .downloading(let p): ProgressView(value: p) { Text("Downloading \(Int(p * 100)) %") }
        case .verifying:          Text("Checking the file …")
        case .ready:              Label("Ready", systemImage: "checkmark.circle")
        case .failed(let reason): Text(reason)
        }
    }
}

ダウンロード開始前にサイズを表示し、ダウンロードするかどうかをユーザーが選べるようにしてください。どちらも良いマナーであり、どちらも App Review で問われる点です。ファイルの準備ができたら、llama.cpp で読み込みます。プロンプトが長くなる場合は、ユーザーが遭遇する前に n_batch クラッシュのガイドを読んでおいてください。

テスト

  • ダウンロードを開始し、Xcode からアプリを停止します。再起動したとき、進捗がゼロからではなく途中から続いていることを確認します。
  • urls の先頭に無効な URL を、その次に小さな誤ったファイルの URL を置きます。どちらの場合も、正常なミラーまでフォールバックすることを確認します。
  • ダウンロード中に機内モードをオンにしてメッセージを確認し、再試行します。
  • バックグラウンドでの再起動は、デバッガを切り離した実機で観察するのが最も簡単です。そうしないと Xcode がアプリを生かし続けます。