ホーム 記事一覧 ブック DH週間トピックス 検索 このサイトについて
English

Firebase不要:Apple標準ツールだけでiOSアプリを運用する

はじめに iOSアプリの運用を始めると、すぐに以下のような課題に直面します。 クラッシュしているのか、どこで落ちているのか知りたい ダウンロード数やセッション数を把握したい 古いバージョンのユーザーにアップデートを促したい レビューを書いてもらいたい 多くの記事が Firebase Crashlytics + Firebase Analytics の導入を勧めますが、これはGoogleのサーバーにユーザーのデバイス情報・使用状況・クラッシュログなどを送信することを意味します。App Storeへの提出時にも「データ収集・追跡」の申告が必要になります。 個人開発や学術目的のアプリでは、そこまでのデータ収集が本当に必要でしょうか? 本記事では、ユーザーデータを外部に送信せず、Apple標準ツールとOSSだけで運用する構成を、実際のApp Store公開アプリ KotenOCR での経験をもとに紹介します。 構成の全体像 目的 Firebase構成 本記事の構成 外部送信 クラッシュ計測 Crashlytics MetricKit なし アナリティクス Firebase Analytics App Store Connect Analytics API なし アップデート促進 Remote Config Siren(iTunes Lookup API) なし※ レビュー促進 In-App Messaging SKStoreReviewRequest なし クラッシュログ閲覧 Crashlytics Console Xcode Organizer なし ※ Sirenは iTunes Lookup API(Apple公式)にバージョン確認のリクエストを送りますが、ユーザーの個人情報は含まれません。 1. MetricKit — クラッシュ計測 MetricKit はiOS 13+で利用可能なApple標準フレームワークです。クラッシュレポート、ハング(応答なし)、起動時間などのパフォーマンスデータをアプリ内で受け取れます。 実装 import MetricKit class MetricKitManager: NSObject, MXMetricManagerSubscriber { static let shared = MetricKitManager() func start() { MXMetricManager.shared.add(self) } // iOS 15+: クラッシュ・ハングの即時通知 func didReceive(_ payloads: [MXDiagnosticPayload]) { for payload in payloads { if let crashDiagnostics = payload.crashDiagnostics { for crash in crashDiagnostics { // クラッシュのスタックトレースを処理 let description = crash.callStackTree.jsonRepresentation() // ログに記録、またはローカルに保存 } } } } // iOS 14+: 24時間ごとのパフォーマンスメトリクス func didReceive(_ payloads: [MXMetricPayload]) { for payload in payloads { // 起動時間、メモリ使用量、ディスクI/Oなど } } } ```text アプリの起動時に `MetricKitManager.shared.start()` を呼ぶだけです。 ### MetricKitの特徴 - **外部送信なし** — データはアプリ内で完結。Appleのサーバーにも送らない - **即時通知** — iOS 15以降、クラッシュ直後に `didReceive(_: [MXDiagnosticPayload])` が呼ばれる - **バッテリー・パフォーマンス** — クラッシュだけでなく、起動時間やメモリ使用量も計測できる ### Xcode Organizer との違い Xcode Organizer(Xcode → Window → Organizer → Crashes)も追加実装なしでクラッシュログを表示しますが、以下の制限があります。 - ユーザーが「診断データ共有」をオンにしている必要がある(オプトイン) - ある程度のユーザー数がないとデータが集まらない - 蓄積に数日〜数週間かかる ユーザー数が少ない段階では MetricKit の方が実用的です。 ## 2. App Store Connect Analytics API — アナリティクス App Store Connect のWebコンソールでもアナリティクスは見られますが、APIで取得すると自動化や詳細な分析が可能になります。 ### 準備:JWT認証 App Store Connect APIは JWT(JSON Web Token)で認証します。 ```python import jwt import time import requests KEY_ID = "YOUR_KEY_ID" ISSUER_ID = "YOUR_ISSUER_ID" with open("AuthKey.p8", "r") as f: private_key = f.read() payload = { "iss": ISSUER_ID, "iat": int(time.time()), "exp": int(time.time()) + 1200, "aud": "appstoreconnect-v1" } token = jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": KEY_ID}) headers = {"Authorization": f"Bearer {token}"} ```text ### レポートリクエストの作成 Analytics APIを使うには、まずレポートリクエストを作成(または既存のものを取得)します。 ```python APP_ID = "YOUR_APP_ID" # レポートリクエストの確認 r = requests.get( f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/analyticsReportRequests", headers=headers ) report_requests = r.json()["data"] # なければ作成(ONGOINGで継続的にレポート生成) if not report_requests: r = requests.post( f"https://api.appstoreconnect.apple.com/v1/apps/{APP_ID}/analyticsReportRequests", headers=headers, json={ "data": { "type": "analyticsReportRequests", "attributes": {"accessType": "ONGOING"}, "relationships": { "app": {"data": {"type": "apps", "id": APP_ID}} } } } ) ```text ### レポートの取得 ```python import gzip import csv import io from collections import defaultdict REPORT_REQUEST_ID = report_requests[0]["id"] def fetch_report(report_name): """指定したレポートの全インスタンスを取得して結合""" # レポート一覧を取得 r = requests.get( f"https://api.appstoreconnect.apple.com/v1/analyticsReportRequests/" f"{REPORT_REQUEST_ID}/reports", headers=headers, params={"limit": 200} ) for report in r.json()["data"]: if report["attributes"]["name"] == report_name: # インスタンス(日付ごとのデータ)を取得 r2 = requests.get( f"https://api.appstoreconnect.apple.com/v1/analyticsReports/" f"{report['id']}/instances", headers=headers, params={"limit": 10} ) all_data = "" for inst in r2.json().get("data", []): # セグメント(実際のデータファイル)を取得 r3 = requests.get( f"https://api.appstoreconnect.apple.com/v1/" f"analyticsReportInstances/{inst['id']}/segments", headers=headers ) for seg in r3.json().get("data", []): url = seg["attributes"].get("url") if url: r4 = requests.get(url) text = gzip.decompress(r4.content).decode("utf-8") lines = text.strip().split("\n") if not all_data: all_data = text.strip() else: all_data += "\n" + "\n".join(lines[1:]) return all_data return None ```text ### 利用可能なレポートの例 | レポート名 | カテゴリ | 内容 | |-----------|---------|------| | App Downloads Standard | COMMERCE | ダウンロード数(新規/再DL、デバイス、地域別) | | App Sessions Standard | APP_USAGE | セッション数、平均時間 | | App Store Discovery and Engagement Standard | APP_STORE_ENGAGEMENT | インプレッション、ページ閲覧、タップ | | App Store Installation and Deletion Standard | APP_USAGE | インストール・削除数 | | App Crashes | APP_USAGE | クラッシュ数 | ### 実際の出力例 KotenOCRの公開直後のデータです。 ```text === ダウンロード数 === 2026-03-20: 新規 95 / 再DL 2 / 計 97 2026-03-21: 新規 348 / 再DL 14 / 計 362 2026-03-22: 新規 104 / 再DL 188 / 計 292 2026-03-23: 新規 23 / 再DL 231 / 計 254 合計: 新規 570 / 再DL 435 / 計 1,005 === セッション数 === 2026-03-20: 159 セッション / 平均 81秒 2026-03-21: 345 セッション / 平均 85秒 2026-03-22: 126 セッション / 平均 84秒 2026-03-23: 50 セッション / 平均 115秒 === App Store エンゲージメント === 2026-03-19: Impression: 79 / Page view: 14 / Tap: 19 2026-03-20: Impression: 172 / Page view: 315 / Tap: 336 2026-03-21: Impression: 596 / Page view: 576 / Tap: 600 2026-03-22: Impression: 193 / Page view: 106 / Tap: 101 === インストール / 削除 === 2026-03-20: インストール 63 / 削除 0 2026-03-21: インストール 249 / 削除 0 2026-03-22: インストール 66 / 削除 0 2026-03-23: インストール 56 / 削除 0 ```text データの遅延は約1〜2日です。Web UIではほぼ前日分まで閲覧できます。 ## 3. Siren — アップデート促進 [Siren](https://github.com/ArtSabintsev/Siren) は、App Storeの最新バージョンとアプリのバージョンを比較し、更新を促すダイアログを表示するOSSライブラリです。 ### 導入 Swift Package Manager で追加します。 ```text https://github.com/ArtSabintsev/Siren (6.1.0+) ```text ### 実装 ```swift import Siren // SwiftUIの場合、onAppear内で呼ぶこと(init()では動かない) private func configureSiren() { let siren = Siren.shared siren.rulesManager = RulesManager( majorUpdateRules: .critical, // メジャー: スキップ不可 minorUpdateRules: .annoying, // マイナー: スキップ不可(後で通知) patchUpdateRules: .default // パッチ: スキップ可能 ) siren.wail() } ```text ### 注意点 - **SwiftUIでは `onAppear` で呼ぶ** — `App.init()` の段階ではUIWindowが存在しないため、Sirenが正しく動作しません - **テスト方法** — Sirenは iTunes Lookup API で App Store上のバージョンと比較するため、テスト時は Info.plist の `CFBundleShortVersionString` を意図的に古いバージョンに設定する必要があります - **リリース直後の遅延** — デフォルトでリリースから1日経過しないとダイアログが表示されません(`showAlertAfterCurrentVersionHasBeenReleasedForDays` で変更可能) ### ダイアログの種類 | ルール | 動作 | |--------|------| | `.critical` | 「Update」ボタンのみ。スキップ不可 | | `.annoying` | 「Update」「Next time」の2ボタン。スキップ不可だが後回し可 | | `.default` | 「Update」「Next time」「Skip」の3ボタン | | `.relaxed` | `.default` と同じだが、表示頻度が低い | ## 4. SKStoreReviewRequest — レビュー促進 Apple標準のレビュー促進APIです。追加のフレームワークは不要です。 ```swift import StoreKit // OCR成功後など、ユーザーが価値を感じたタイミングで呼ぶ func requestReviewIfAppropriate() { let count = UserDefaults.standard.integer(forKey: "ocrSuccessCount") // 3回目のOCR成功後にレビューを依頼 if count == 3 { if let scene = UIApplication.shared.connectedScenes .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { SKStoreReviewController.requestReview(in: scene) } } } ```text ### ポイント - Appleがダイアログの表示を制御するため、呼び出しても必ず表示されるとは限らない - **年3回まで**の制限がある - ユーザーが「成功体験」を感じた直後に呼ぶのが効果的 ## 5. 所感と限界 ### この構成でカバーできること - 基本的なダウンロード数・セッション数の把握 - クラッシュの検知と原因調査 - ユーザーへのアップデート促進 - レビュー促進 実際に KotenOCR をこの構成で運用していますが、個人開発アプリとしては十分な情報が得られています。 ### この構成の限界 - **画面単位・機能単位の詳細な計測ができない** — 「どの画面が最も使われているか」「翻訳機能の利用率は?」といった分析にはアプリ内のイベント計測が必要 - **リアルタイム性がない** — Analytics APIのデータには1〜2日の遅延がある - **MetricKitのクラッシュ情報は構造化されていない** — スタックトレースのJSON解析は自前で実装する必要がある - **A/Bテストや段階的ロールアウトは不可能** — これらが必要な場合はFirebase Remote Configなどが必要 ### Firebaseが必要になるケース - ユーザー行動の詳細な分析が必要な場合 - リアルタイムのクラッシュ通知が必要な場合 - Feature Flagsやリモート設定が必要な場合 - プッシュ通知を実装する場合 ## まとめ | ツール | 用途 | 実装コスト | |--------|------|-----------| | MetricKit | クラッシュ・パフォーマンス計測 | 小 | | App Store Connect Analytics API | DL数・セッション・エンゲージメント | 中(Python) | | Siren | アップデート促進ダイアログ | 小 | | SKStoreReviewRequest | レビュー促進 | 最小 | | Xcode Organizer | クラッシュログ閲覧 | なし | Firebase不要でも、個人開発アプリの運用に必要な情報はほぼ揃います。プライバシーポリシーの記載義務も最小限で済み、ユーザーに対して「データを収集していません」と言い切れるのは大きなメリットです。 まずはこの構成で始めて、必要に応じてFirebaseを追加する、という段階的なアプローチがおすすめです。 ## 関連記事 - [KotenOCR:くずし字をオフラインで認識するiOSアプリの開発と公開](/posts/kotenocr-ios-app/) - [App Store Connect APIだけでiOSアプリを審査提出する手順](/posts/appstore-connect-api-guide/) - [iOSアプリのメモリ最適化とクラッシュ修正の実践記録](/posts/ios-memory-crash-fixes/)

iOS OCRアプリのメモリ起因クラッシュの調査と対策

KotenOCRは古典籍のOCR処理にONNX Runtimeを使用しているiOSアプリです。6つのONNXモデル(合計約230MB)を搭載しています。ダウンロード数が300に達した時点で、クラッシュ率が6.7%(20件)に上昇していることが確認されました。Xcode Organizerにクラッシュログが表示されなかったため、原因の特定には別のアプローチが必要でした。 調査体制 4つの調査方針を並行して進めました。 メモリ・モデルサイズの調査 画像処理パイプラインの調査 ONNX Runtimeのスレッドセーフティの調査 カメラ・UIライフサイクルの調査 原因の特定 調査の結果、以下の4つの問題が確認されました。影響度の大きい順に記載します。 1. 起動時の全モデル一括ロード 6つのONNXモデルをアプリ起動時にすべてメモリに展開していました。ディスク上では約230MBですが、ONNX Runtimeがモデルをメモリに展開すると1.5〜2.5倍程度に膨張するため、RAM上では推定350〜550MB程度を占有していたと考えられます。 iOSにはjetsam(カーネルレベルのメモリ監視機構)があり、アプリ単位のメモリ上限を超えるとプロセスが強制終了されます。デバイスごとのRAMとアプリあたりのメモリ上限の目安は以下のとおりです。 デバイス RAM リスク iPhone 8 2GB CRITICAL — iOS 16対応機種で最もメモリが少ない iPhone X / XR / XS 3GB HIGH iPhone 11 4GB MEDIUM iPhone 12以降 4〜6GB LOW iPhone 8やiPhone Xでは、アプリあたりのメモリ上限が200〜300MB程度とされており、全モデル一括ロードだけで上限を超過する状況でした。 2. DEIMDetectorの画像前処理 DEIMDetectorが入力画像をリサイズせずにフルサイズのまま正方形パディング用のCGContextを生成していました。12MPの写真(4032×3024)の場合、4032×4032ピクセルのCGContextが作成され、これだけで約63MBのメモリを消費します。 3. 無制限の並列認識タスク withThrowingTaskGroupで最大100件の認識タスクを同時に実行可能な状態でした。各タスクがモデル推論時にメモリを確保するため、並列数に比例してメモリ使用量が増加します。 4. メモリ警告のハンドリング不在 UIApplication.didReceiveMemoryWarningNotificationを購読しておらず、iOSからのメモリ警告を受け取っても何も対処していませんでした。jetsam発動前の最後の解放機会を逃していたことになります。 実施した修正 Lazy Model Loading 起動時にすべてのモデルをロードする方式から、選択中のモードに必要なモデルのみをロードする方式に変更しました。初期メモリ使用量が約350MBから約150MBに減少しました。 func loadModelsForMode(_ mode: OCRMode) { // 選択中のモードに必要なモデルのみロード let requiredModels = mode.requiredModelKeys for key in requiredModels { if sessionCache[key] == nil { sessionCache[key] = try? createSession(for: key) } } } 画像前処理の改善 DEIMDetectorの前処理で、パディング前にリサイズを行うように変更しました。メモリ使用量が63MBから約2.4MBに削減されました。 ...

KotenOCR: 近代OCRの検出重複を解消する(NMS追加とクラスフィルタリング)

はじめに KotenOCRは、国立国会図書館(NDL)が公開したOCRモデルをiOS上で動作させ、くずし字や近代活字をオフラインで認識するアプリです。 近代OCRモード(NDLモード)では、NDLのDEIMv2-Sモデルでレイアウト検出を行い、PARSeQで文字認識を行います。しかし、iOS実装の検出結果が本家のndlocr-liteと比べて明らかに多く、テキストが重複して認識されるという問題がありました。 本記事では、原因の調査から修正までの過程を記録します。 症状:検出数が多すぎる テスト画像として、NDLデジタルコレクションから取得した「校異源氏物語」の序文ページを使いました。 本家ndlocr-liteでは17件の行検出(line_main、line_captionなど)が得られるのに対し、iOS実装では28件の検出が返り、OCR結果にも重複テキストや文字化けが混入していました。 検出数が多い原因は大きく3つありました。 原因1:NMS(Non-Maximum Suppression)が未実装だった DEIMv2モデルのONNX出力は、内部でNMSを完了した結果を返すと想定していました。しかし本家ndlocr-liteのコードを読むと、モデル推論後に追加のNMS(IoU閾値=0.2)を適用していることがわかりました。 つまり、モデル出力にはまだ重複する矩形が含まれており、後処理でそれを除去する必要があったのです。 修正:NMSの追加 DEIMDetector.swiftにNMS処理を追加しました。 // DEIMDetector.swift — postprocess() の末尾 // Sort by score descending let sorted = detections.sorted { $0.score > $1.score } // Apply NMS to remove overlapping detections let nmsResult = applyNMS(sorted, iouThreshold: iouThreshold) return Array(nmsResult.prefix(maxDetections)) NMS本体の実装は以下のとおりです。スコア降順にソートされた検出結果を走査し、既に採用した矩形とのIoUが閾値を超える場合は除外します。 // DEIMDetector.swift /// Apply Non-Maximum Suppression to remove overlapping detections. private func applyNMS(_ detections: [Detection], iouThreshold: Float) -> [Detection] { var kept: [Detection] = [] for det in detections { var shouldKeep = true for existing in kept { if computeIoU(det.box, existing.box) > iouThreshold { shouldKeep = false break } } if shouldKeep { kept.append(det) } } return kept } /// Compute Intersection over Union between two boxes [x1, y1, x2, y2]. private func computeIoU(_ a: [Int], _ b: [Int]) -> Float { let x1 = max(a[0], b[0]) let y1 = max(a[1], b[1]) let x2 = min(a[2], b[2]) let y2 = min(a[3], b[3]) let interW = max(0, x2 - x1) let interH = max(0, y2 - y1) let interArea = Float(interW * interH) let areaA = Float((a[2] - a[0]) * (a[3] - a[1])) let areaB = Float((b[2] - b[0]) * (b[3] - b[1])) let unionArea = areaA + areaB - interArea guard unionArea > 0 else { return 0 } return interArea / unionArea } 原因2:全クラスをOCRに送っていた DEIMv2モデルは17クラスのレイアウト要素を検出します。 ...

Swift ConcurrencyでOCR認識処理を並列化し最大6.7倍高速化する

Swift ConcurrencyでOCR認識処理を並列化し最大6.7倍高速化する

OCRパイプラインの構造 iOSでONNX Runtimeを使ったOCRパイプラインは、大まかに以下の流れで動作します。 画像全体に対する文字領域検出(Detection) 検出された各領域に対する文字認識(Recognition) 読み順の推定と結合 この中で、検出は画像全体に対して1回だけ実行されます。一方、認識は検出された領域の数だけ繰り返されます。領域数が多い画像では、認識処理が全体の処理時間の大部分を占めることになります。 逐次処理の問題 認識処理を単純なforループで逐次実行すると、処理時間は領域数にほぼ比例します。 for det in detections { let rect = CGRect(x: max(0, det.box[0]), y: max(0, det.box[1]), width: max(1, det.box[2] - det.box[0]), height: max(1, det.box[3] - det.box[1])) if let cropped = image.cropping(to: rect) { _ = try recognizer.recognize(image: cropped) } } たとえば98領域が検出された画像の場合、1領域あたり約80msとすると合計で約8秒かかります。 withThrowingTaskGroupによる並列化 Swift ConcurrencyのwithThrowingTaskGroupを使うと、各領域の認識処理を並列に実行できます。 let results = try await withThrowingTaskGroup(of: (Int, String).self) { group in for i in 0..<detections.count { let box = detections[i].box let cropRect = CGRect( x: max(0, box[0]), y: max(0, box[1]), width: max(1, box[2] - box[0]), height: max(1, box[3] - box[1]) ) guard let cropped = image.cropping(to: cropRect) else { continue } group.addTask { let text = try recognizer.recognize(image: cropped) return (i, text) } } var texts: [(Int, String)] = [] for try await result in group { texts.append(result) } return texts } for (i, text) in results { recognized[i].text = text } 各タスクはインデックスとともに結果を返すため、完了順序に関係なく元の順序を保持できます。 ベンチマーク結果 iOSシミュレータ上で、逐次処理と並列処理の実行時間を比較しました。 古典籍(源氏物語、6642x4990px、21領域) 出典: 東京大学デジタルアーカイブ 源氏物語 方式 処理時間 逐次 4.55s 並列 3.24s 高速化率 1.4x 近代活字(NDLデジタルコレクション、6890x4706px、98領域) 出典: 国立国会図書館デジタルコレクション 方式 処理時間 逐次 7.59s 並列 1.13s 高速化率 6.7x 高速化率が異なる理由 21領域の場合と98領域の場合で高速化率に差が出ています。 ...

KotenOCR:くずし字をオフラインで認識するiOSアプリの開発と公開

KotenOCR:くずし字をオフラインで認識するiOSアプリの開発と公開

はじめに 古典籍に書かれたくずし字(変体仮名・草書体の漢字)を読むのは、専門家でも容易ではありません。近年はAI-OCRによって機械的な認識が可能になってきましたが、調査した限り、スマートフォンでオフライン利用できるツールは見当たりませんでした。 KotenOCRは、国立国会図書館(NDL)が公開した軽量くずし字OCRモデル「NDL古典籍OCR-Lite」をiOS上で動作させ、写真を撮るだけでくずし字を認識できるアプリです。 App Store(無料): https://apps.apple.com/jp/app/kotenocr/id6760045646 背景:既存ツールの状況 NDLが「NDL古典籍OCR-Lite」を公開したことで、くずし字OCRの敷居は下がりました。既存ツールを見渡すと以下のような状況でした。 ツール 形態 インターネット接続 NDL古典籍OCR-Lite デスクトップ / Web / CLI 不要(デスクトップ版) miwo(CODH) モバイルアプリ 必要 古文書カメラ(TOPPAN) モバイルアプリ 必要 モバイルアプリは存在するものの、いずれもクラウド通信が必要です。一方、NDL古典籍OCR-LiteはPC環境でしか動作しません。 そこで、NDL古典籍OCR-Liteのモデルをスマートフォンに載せて、オフラインで動くiOSアプリを作ることにしました。 KotenOCRの特徴 完全オフライン — すべての処理がデバイス上で完結。通信不要 iPhone / iPad対応 — iOS 16.0以上 無料 — App Storeから無料でダウンロード可能 スキャン履歴 — 認識結果を保存・管理 TXT / PDFエクスポート — 認識テキストをファイルとして出力 範囲トリミング — 認識する領域を指定可能 使い方 古典籍の写真を撮影する(またはライブラリから選択) AIがくずし字を自動認識 認識されたテキストをコピー・エクスポート OCRパイプライン 写真からテキストが認識されるまでの処理フローは以下の通りです。 写真 → トリミング → テキスト領域検出 → 文字認識 → 読み順決定 → 表示 テキスト領域検出: RTMDetモデルにより、画像内の文字領域を検出 文字認識: PARSeqモデルにより、検出領域内の文字を認識(7,141文字、NDLmojiの文字集合に対応) 読み順: 日本語の縦書き・右から左への読み順を考慮して並べ替え ...