株式会社タップルで内定者アルバイトをしている及川寛太 (@kanta_cky) です。

本記事では、2027年以降を見据えて、タップルの iOS アプリのメトリクス収集基盤を新しい MetricKit で再設計した取り組みを紹介します。

本記事は、執筆時点の以下の環境を前提としています。

  • Xcode 27 はベータ版
  • タップルの iOS アプリの最低サポート OS は iOS 17

MetricKit とは

MetricKit は、アプリの品質に直結するメトリクスを扱うフレームワークです。起動時間(Time to First Draw)・ハング時間(Hang Time)・ヒッチ発生率(Hitch Time Ratio)などを収集できます。OS はこれらのメトリクスをバックグラウンドで収集し、過去24時間分のレポートとして最大1日1回アプリへ配信します。Instruments のように開発者が能動的に計測しなくても、実際のユーザー端末上でのパフォーマンスを継続的に観測できます。

WWDC26 では、この MetricKit に大きなアップデートが発表されました。

  • DelegateAsyncStream: 従来は MXMetricManagerSubscriber による Delegate ベースの受信方式でした。iOS 27 では MetricKit.MetricManagermetricReports を介した AsyncSequence ベースの受信方式が追加され、Swift Concurrency ネイティブに扱えます。
  • StateReporting フレームワークの追加: 新設された StateReporting フレームワークにより、「どの画面・どのタブを表示している間に発生したメトリクスか」という状態(State)単位での収集が可能になりました。従来はアプリ全体の集計値しか得られなかったため、この粒度の向上は原因調査の観点で非常に価値のある変更です。
  • 今後のアップデートは新しい API が中心になる見込み: セッションでは、今後は新 API を中心に機能追加していく方針が示されています。MXMetricManager / MXMetricManagerSubscriber による従来の Delegate 方式の API は、拡張の主軸ではなくなります。

MetricKit | Apple Developer Documentation

新しいMetricKitについて – WWDC26 – ビデオ – Apple Developer

タップルの iOS アプリにおける MetricKit

タップルの iOS アプリでは MetricKit を既に導入していたものの、実装は2024年6月を最後に約2年間、大きな更新がない状態でした。その間に Swift Concurrency への移行やアーキテクチャの刷新が進むなか、MetricKit 周辺だけが取り残されていました。

一方で、起動時間やハング時間などのメトリクスは、段階リリースを100%へ進める判断材料として使っています。MetricKit から受け取ったレポートは Datadog にログとして送信し、ダッシュボードでバージョンごとの推移を確認できるようにしています。そのため、タップルの iOS アプリにおける MetricKit は、リリース判断に関わる重要なメトリクス収集基盤です。今回はこの実運用を止めずに、iOS 27 以降を見据えた新しい設計へと作り替えました。

要件

再設計にあたり、以下の要件を前提としました。

  • iOS 27 以降の API と既存の API を共存させること
    • 現在、アプリの最低サポート OS は iOS 17 であり、当面は新 API のみに乗り換えることができない
    • 呼び出し側であるアプリの各機能が、新旧 API の差異を意識せずに利用できるようにする
  • Xcode 27 が正式版になるまで、Xcode 26 でもビルドを通せること
    • 新 API は Xcode 27(= iOS 27 SDK)でのみ利用できるため、Xcode 27 がベータ版の間は、安定版の Xcode 26 でもコンパイルエラーが発生しないようにする必要がある
  • 既存の Datadog ログ送信パイプラインに流すこと
    • 既存のログフォーマット・ダッシュボードは維持したまま、必要な項目を追加する

設計

設計の全体像

設計では、新旧 API の差分を MetricManager に閉じ込め、アプリ側の利用箇所を MetricService に集約することを重視しました。

  • MetricService: MetricManager を利用し、レポートの受け取りや Datadog へのログ送信を担います。
  • MetricManager: 新 API(MetricKit.MetricManager)と旧 API(MXMetricManager)の差分を隠蔽し、統一されたデータ構造と API を上位に提供します。
  • MetricReport: 新レポート(MetricKit.MetricReport)と旧レポート(MXMetricPayload)の形式差異を吸収し、必要な値のみを保持する統一された構造体です。

呼び出し側は MetricService のみを参照すればよく、新旧 API の違いは MetricManager 以下に閉じ込める設計にしました。

MetricManagerMetricService はいずれも、依存性注入ライブラリである swift-dependencies を使用して実装しています。MetricKit の実オブジェクト(MetricKit.MetricReportMXMetricPayload)は、テストコードから直接生成することが難しいです。そのため、MetricReport を返す MetricManager を Dependency として差し替え可能にしています。

実装

新旧 API の切り替え:条件付きコンパイル

if #available#if canImport

新旧 API を切り替えるうえで、「実行時の OS バージョン分岐だけでは不十分」という問題がありました。#available(iOS 27.0, *)実行時の OS バージョンを見る仕組みであり、コンパイラは分岐の両方のコードをコンパイルします。新 API の型は iOS 27 SDK(Xcode 27)にしか存在しません。そのため Xcode 27 がベータ版の間に安定版の Xcode 26 でビルドすると、#available の中に書いたコードはコンパイルエラーになります。

つまり、実行時ではなくビルド時に「今使っている SDK に新 API が含まれているか」を判定する仕組みが別途必要です。この判定に使えるのが #if canImport(...) です。

#if canImport の使い方は新 API がどう追加されたかによって決まります。

  • 新しいフレームワークとして追加された場合#if canImport(StateReporting)
    • StateReporting は iOS 27 で新設されたフレームワークであり、Xcode 26 の SDK にはフレームワーク自体が存在しません。したがってモジュールの存在判定だけで SDK バージョンを見分けられます。バージョン番号を気にする必要はありません。
  • 既存フレームワークへの API 追加の場合#if canImport(MetricKit, _version: 353)
    • MetricKit は iOS 13 から存在するフレームワークで、MetricKit.MetricManagerMetricKit.StateReportingDomain はそこに追加された API です。モジュール自体は Xcode 26 の SDK にも存在するため、#if canImport(MetricKit) はどちらの環境でも true になり、判定に使えません。そこで _version: を指定してモジュールのバージョンまで見る必要があります。

ケースA:新規フレームワーク(StateReporting)

StateReporting は新規のフレームワークであるため、ビルド時にモジュールが存在するかどうかを判定するだけでガードできます。なお、報告対象のドメインは後述の StateReportercase として定義しています。

#if canImport(StateReporting)
import StateReporting
#endif

enum StateReporter {
    case tabs
    case screens

    #if canImport(StateReporting)
    @available(iOS 27.0, *)
    var domain: StateReporting.StateReportingDomain {
        ...
    }
    #endif

    #if canImport(StateReporting)
    @available(iOS 27.0, *)
    private static let tabsReporter = StateReporting.StateReporter.reporter(for: tabs.domain)
    #endif
}

ケースB:既存フレームワークへの API 追加(MetricKit)

MetricKit は既存フレームワークへの API 追加であるため、モジュールのバージョンで判定します。

import MetricKit

...

extension MetricManager: DependencyKey {
    static let liveValue = MetricManager(
        metricReports: {
            #if canImport(MetricKit, _version: 353)
            if #available(iOS 27.0, *) {
                return MetricManagerImpl.shared.metricReports()
            }
            #endif
            return LegacyMetricManagerImpl.shared.metricReports()
        },
        ...

_version に渡す番号の調べ方と運用上の注意

先述のコード例で指定した 353 は、Xcode 27.0 beta 1 の MetricKit SDK に対応するモジュールバージョンです。この番号は、SDK 内の .swiftinterface ファイルに記載されている user-module-version を確認することで調べられます。

sed -nE 's/.*-user-module-version ([0-9]+).*/\1/p' \
    $(xcrun --sdk iphoneos --show-sdk-path)/System/Library/Frameworks/MetricKit.framework/Modules/MetricKit.swiftmodule/*.swiftinterface | head -1

条件分岐をいつ削除するか

これらの条件分岐はあくまで暫定的な対応です。今後、適切なタイミングで削除していきます。

タイミング 削除できるもの
Xcode 27 が正式版になったとき #if canImport(...) による SDK 有無の判定
最低サポート OS が iOS 27 になったとき #available 分岐と旧 API 依存コード

Xcode 27 が正式版になったとき

MetricKitStateReporting#if canImport(...) による SDK 有無の判定を削除できます。ビルドに使う SDK が常に iOS 27 以降になるため、「SDK に新 API が存在するか」というビルド時判定自体が不要になります。

最低サポート OS が iOS 27 になったとき

#available / @available(iOS 27.0, *) の分岐と、LegacyMetricManagerImpl をはじめとする旧 API 依存コードを削除できます。

削除対象の型には、@available(iOS, deprecated: 27.0, message: ...) をつけており、最低サポート OS が iOS 27 になったタイミングで警告が出るようにしています。

@available(iOS, deprecated: 27.0, message: "Use MetricManagerImpl instead")
final class LegacyMetricManagerImpl: NSObject, MXMetricManagerSubscriber, @unchecked Sendable {
    ...
}

レポートを受け取る

呼び出し側が新旧 API の差異を意識せずに扱えるよう、レポートの受け取りは AsyncStream<MetricReport> に統一しました。新 API(iOS 27 以降)は MetricKit.MetricManagermetricReportsAsyncSequence として提供するため、これを AsyncStream でラップします。

@available(iOS 27.0, *)
final class MetricManagerImpl: Sendable {
    static let shared = MetricManagerImpl()

    private let manager = MetricKit.MetricManager(
        enabledStateReportingDomains: StateReporter.enabledStateReportingDomains
    )

    func metricReports() -> AsyncStream<TappleUtility.MetricReport> {
        AsyncStream { continuation in
            let task = Task {
                for await report in manager.metricReports {
                    continuation.yield(TappleUtility.MetricReport(metricReport: report))
                }
            }
            continuation.onTermination = { _ in
                task.cancel()
            }
        }
    }
}

一方、旧 API(iOS 26 以前)は MXMetricManagerSubscriber の Delegate メソッド didReceive でレポートを受け取ります。受信結果を AsyncStream の continuation に橋渡しします。

final class LegacyMetricManagerImpl: NSObject, MXMetricManagerSubscriber, @unchecked Sendable {
    static let shared = LegacyMetricManagerImpl()

    private let manager = MetricKit.MXMetricManager.shared
    private let stream: AsyncStream<TappleUtility.MetricReport>
    private let continuation: AsyncStream<TappleUtility.MetricReport>.Continuation

    override private init() {
        (stream, continuation) = AsyncStream.makeStream()
        super.init()
        manager.add(self)
    }

    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            continuation.yield(TappleUtility.MetricReport(metricPayload: payload))
        }
    }
}

アプリ起動直後に MetricServicestart メソッドを呼び出し、レポートの購読と Datadog への送信タスクを開始します。

@Dependency(\.metricService) var metricService
Task {
    do {
        try await metricService.start()
    } catch {
        Logger.error(error)
    }
}

状態を報告する(StateReporting / iOS 27 以降の新機能)

先述のとおり、iOS 27 で追加された StateReporting フレームワークを使うと、「どの画面・どのタブを表示している間にハングやヒッチが発生していたか」が分かります。アプリ全体の集計値しか得られなかった従来と比べ、原因調査のスコープを大幅に絞り込めます。

StateReporter という列挙型で、報告対象のドメインを tabs(タブ単位)と screens(画面単位)の2種類として設計しました。StateReporting.StateReporter を直接扱わないのは、iOS 26 以前で StateReporting フレームワークを使用できないためです。

enum StateReporter {
    case tabs
    case screens
}

タブ切り替え時に MetricServicereportStateTransition メソッドを呼び出し、切り替え先のタブを状態として報告します。

metricService.reportStateTransition(
    reporter: StateReporter.tabs,
    state: "home",
    stableMetadata: nil,
    volatileMetadata: nil
)

reportStateTransition では、状態遷移に付随する追加情報を stableMetadatavolatileMetadata として渡せます。これは、StateReporting フレームワークにおける2種類のメタデータの違いを反映したものです。

  • stableMetadata: 状態が有効な間、値が変化しないメタデータです。たとえばタブ遷移では、「どの導線から遷移したか」などを渡します。遷移時点で確定し、以後変わらない情報が対象です。
  • volatileMetadata: 状態が有効な間、複数回更新されうるメタデータです。たとえば同じ画面を表示している間にリスト件数やページ数が変化するケースで、最新の値で上書きして報告できます。

タップルでは現状、tabs / screens いずれのドメインもメタデータを必要としないため、両方とも nil を渡しています。将来「特定の状態でのみ発生するハングの原因をさらに絞り込みたい」というケースが出てきた際には、詳細情報を付加できます。

カスタム処理を計測する(Signpost)

MetricKit は標準のメトリクスに加え、アプリ独自の処理区間を計測する機能も提供しています。関数 mxSignpost で処理区間の開始・終了を記録すると、その所要時間をヒストグラムとして収集できます。タップルでは、アプリ独自の処理も同じメトリクス収集基盤で扱えるようにするため、Signpost の計測対象を SignpostMetricsType という列挙型で管理しています。計測対象ごとにカテゴリ名・Signpost 名を定義しています。

enum SignpostMetricsType {
    case flickCardImage

    var category: String { "performance_telemetry" }
    var staticName: StaticString { "flick_card_image_rendering" }
}

既存実装では、グローバル関数 mxSignpost の begin/end を各所から直接呼び出していました。今回はこれを MetricManager@DependencyClient のメソッド(signpostBegin / signpostEnd)として切り出しました。

signpostBegin: { type in
    #if canImport(MetricKit, _version: 353)
    let log: OSLog =
        if #available(iOS 27.0, *) {
            MetricKit.MetricManager.logHandle(category: type.category)
        } else {
            MXMetricManager.makeLogHandle(category: type.category)
        }
    #else
    let log = MXMetricManager.makeLogHandle(category: type.category)
    #endif
    mxSignpost(.begin, log: log, name: type.staticName)
    return log
},
signpostEnd: { type, log in
    mxSignpost(.end, log: log, name: type.staticName)
}

計測したい処理の前後で MetricManagersignpostBegin / signpostEnd を呼び出します。

@Dependency(\.metricManager) var metricManager

func onFlickCardImageWillAppear() {
    let log = metricManager.signpostBegin(.flickCardImage)
    renderFlickCardImage()
    metricManager.signpostEnd(.flickCardImage, log)
}

Datadog へのログ送信

集約したメトリクスを Datadog へ送信します。

ヒストグラムを加重平均へ変換: MetricKit のメトリクスの多くは、ヒストグラム(区間ごとの発生回数)の形式です。一方で Datadog 側のダッシュボードは単一の代表値(平均)を前提にしています。そのため、各区間の中点を代表値とし、発生件数を重みとした加重平均へ変換しています。

func averageDuration(buckets: [HistogramBucket]) -> Measurement<UnitDuration>? {
    let totalCount = buckets.reduce(into: 0) { $0 += $1.count }
    guard totalCount > 0 else { return nil }
    let totalDuration = buckets.reduce(into: Double.zero) { totalDuration, bucket in
        totalDuration += Double(bucket.count) * (bucket.lowerBound + bucket.upperBound) / 2
    }
    return Measurement(value: totalDuration / Double(totalCount), unit: .milliseconds)
}

fullDayEntry のみを採用: 新 API の intervalEntries は、1日を細分化したエントリと1日全体を集計したエントリの両方を含みます。細分化されたエントリと fullDayEntry は期間が重複しているため、1日全体を表す fullDayEntry のみを採用しています。

アプリバージョンの一致確認: 計測期間中にアプリのバージョンアップが発生すると、現在起動しているアプリのバージョンと、レポートに含まれるアプリバージョンが一致しないことがあります。レポートの applicationVersion は、集計期間の開始時点のバージョンを表します。このようなレポートをそのまま送信すると、どのバージョンの計測値か判断できません。そのため、送信時にレポート内のアプリバージョンと現在のアプリバージョンを比較し、一致しない場合はログ送信をスキップしています。

func sendDatadogLog(for report: MetricReport) {
    guard
        let currentAppVersion = appVersionClient.current(),
        currentAppVersion == report.applicationVersion
    else {
        return
    }
    if let averageTimeToFirstDraw = report.timeToFirstDraw {
        datadogLogClient.log(.launch(value: averageTimeToFirstDraw.value), "launch", nil)
    }
    for stateMetric in report.stateMetrics {
        if let hangTime = stateMetric.hangTime {
            datadogLogClient.log(
                .hangTime(value: hangTime.value, domain: stateMetric.domain, state: stateMetric.state),
                "hangTime",
                nil
            )
        }
    }
}

送信するログの種類は、既存のダッシュボードを維持するため従来のものをそのまま残しつつ、StateReporting 由来のステート別メトリクスを新たに追加する形にしました。既存のダッシュボードへの影響を出さずに、より粒度の細かい情報を追加できています。

StateReporting 由来のメトリクスも同様に、状態(domain / state)を attributes に含めた上で送信しています。

extension DatadogLogClient.LogType {
    static func hangTime(value: Double, domain: String, state: String) -> Self {
        .init(
            message: "hang_time",
            attributes: [
                "value": value,
                "domain": domain,
                "state": state,
            ]
        )
    }
}

こうすることで、既存の「アプリ全体のハング時間」ダッシュボードに影響を与えずに、「特定のタブ・画面でのハング時間」を新たな軸として同じダッシュボード基盤上で追えるようになっています。

デバッグ

MetricKit のレポートは通常1日1回程度しか配信されません。そのため、実装したロジックの動作確認を毎回、実機での日次配信を待って行うのは現実的ではありません。Xcode の「Simulate MetricKit Payloads」を使うことで、任意のタイミングでダミーのペイロードを配信させ、実装した受信・変換・送信ロジックをその場で検証できます。

ただし今回、Xcode 27 の環境では、Apple のドキュメント に記載されている Debug > Simulate MetricKit Payloads に該当する項目が見当たらない問題に遭遇しました。Apple Developer Forums のスレッドで質問したところ、UI が変更されたとの回答を得ました。

執筆時点の Xcode 27 beta 環境で確認すると、メニューの位置がドキュメントの記載と異なり、Debug > Simulate MetricKit Payloads ではなく Debug > MetricKit > Simulate MetricKit Payloads に移動していました。また、公式ドキュメントでは MetricKit のレポートはシミュレータでは配信されないとされています。今回確認した Xcode 27 beta 環境では、実機でアプリを実行している場合にのみメニュー項目を確認できました。

動作確認の手順は次のとおりです。

  • 実機でアプリを実行する
  • Xcode からアプリを起動する
  • Debug > MetricKit > Simulate MetricKit Payloads を選ぶ
  • Datadog 側でログを確認する

Xcode の Debug メニュー

無事に、iOS 27 をインストールした実機でアプリを実行し、Datadog からログを確認できました。

まとめ

約2年間ほとんど手が入っていなかった MetricKit の実装を、iOS 27 以降を見据えた設計へ作り替えました。ポイントは以下の4点です。

  • 新しい MetricKit の API を基準に設計し、新旧 API の差異は MetricManager 以下に閉じ込め、呼び出し側には影響を与えないようにした
  • #if canImport(...) の判定方法を、新規フレームワーク(StateReporting)と既存フレームワークへの API 追加(MetricKit_version:)とで使い分けた。これにより、執筆時点の Xcode 26 と Xcode 27 beta の両方でビルドでき、iOS 17〜27 のいずれでも動作する
  • @available(iOS, deprecated:) を削除対象のコードに付与することで、最低サポート OS の引き上げ時に削除箇所が警告として可視化されるようにした
  • 既存の Datadog ログ送信パイプラインやダッシュボードを維持したまま、StateReporting 由来のステート別メトリクスを新たな軸として追加した

StateReporting のメタデータや Signpost 計測の拡充など、今回の再設計を土台にしてさらに拡張できる余地があります。今後も段階的にアップデートします。