はじめに

iOSアプリの運用を始めると、すぐに以下のような課題に直面します。

  • クラッシュしているのか、どこで落ちているのか知りたい
  • ダウンロード数やセッション数を把握したい
  • 古いバージョンのユーザーにアップデートを促したい
  • レビューを書いてもらいたい

多くの記事が Firebase Crashlytics + Firebase Analytics の導入を勧めますが、これはGoogleのサーバーにユーザーのデバイス情報・使用状況・クラッシュログなどを送信することを意味します。App Storeへの提出時にも「データ収集・追跡」の申告が必要になります。

個人開発や学術目的のアプリでは、そこまでのデータ収集が本当に必要でしょうか?

本記事では、ユーザーデータを外部に送信せず、Apple標準ツールとOSSだけで運用する構成を、実際のApp Store公開アプリ KotenOCR での経験をもとに紹介します。

構成の全体像

目的Firebase構成本記事の構成外部送信
クラッシュ計測CrashlyticsMetricKitなし
アナリティクスFirebase AnalyticsApp Store Connect Analytics APIなし
アップデート促進Remote ConfigSiren(iTunes Lookup API)なし※
レビュー促進In-App MessagingSKStoreReviewRequestなし
クラッシュログ閲覧Crashlytics ConsoleXcode 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 OrganizerXcode  Window  Organizer  Crashes)も追加実装なしでクラッシュログを表示しますが、以下の制限があります。

- ユーザーが「診断データ共有」をオンにしている必要がある(オプトイン)
- ある程度のユーザー数がないとデータが集まらない
- 蓄積に数日〜数週間かかる

ユーザー数が少ない段階では MetricKit の方が実用的です。

## 2. App Store Connect Analytics API  アナリティクス

App Store Connect Webコンソールでもアナリティクスは見られますがAPIで取得すると自動化や詳細な分析が可能になります

### 準備:JWT認証

App Store Connect APIは JWTJSON 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

データの遅延は約12日です。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のデータには12日の遅延がある
- **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/)