Dart - TECH PLAY - TECH PLAY

TECH PLAY

Dart

イベント

該当するコンテンツが見つかりませんでした

マガジン

該当するコンテンツが見つかりませんでした

技術ブログ

この記事でわかること Compose Multiplatform(CMP)の動画再生は、Android では AndroidView + Media3 PlayerView 、iOS では UIKitView + AVPlayerLayer 、Web では canvas の裏に置いた <video> 要素と、プラットフォームごとにまったく異なる Interop 機構で動いている 「どのフレームワークがネイティブか」は見せかけの問いで、デコードと撮像の層は Flutter も React Native も CMP もすべて OS の同じネイティブ API を使っている。本当の分岐点は「ネイティブが描いた 1 フレームを、フレームワーク自身の UI とどう合成するか」にある Android の SurfaceView / TextureView を画面ごとに選べる自由は CMP(と React Native)にはあり、Flutter の video_player には長らくなかった。一方、実際のプロジェクトでは角丸プレビューのために、あえて Flutter と同じテクスチャコピー経路( TextureView )を選んだ CameraK 0.4 やコミュニティ製の動画プレイヤーといった「若いエコシステム」の現実と、その不足を expect/actual で各プラットフォームのネイティブ API に直接下りて補うという対処法を、実プロジェクトのコードで示す はじめに こんにちは、モバイルアプリ開発部の Yao です。 私は以前から Kotlin Multiplatform(KMP)/ Compose Multiplatform を継続的に研究していて、このテーマで何本か記事を書いてきました。興味のある方は 過去の記事一覧 もご覧ください。本記事では、 CMP の弱点として真っ先に名前が挙がる「動画再生」と「カメラ」を、Android・iOS・Web の 3 プラットフォームでどう実装したか を書きます。 題材は、社内で開発した 動画アバター生成アプリ です。自分の顔を動画で録画してデジタル分身(アバター)を作り、テキストを入力するとその分身が自分の声で話す動画を生成する、というアプリです。録画・録音・再生というメディア機能がプロダクトの根幹にあり、「CMP でメディアをやるとどうなるか」を検証するには格好の題材でした。 生成された動画を確認・承認する VideoDetailScreen (本記事のアプリ画面はいずれもデザインモックで、人物はすべてダミーです) CMP のエコシステムはまだ若いです。この記事では、その若さがどこでどう痛むのか、そしてその痛みが expect/actual というアーキテクチャでどこまで緩和できるのかを、実際の行数・バージョン番号・コードで示します。形容詞ではなく数字で語ることを目標にします。 このアプリのアーキテクチャはどうなっているか 本アプリは 1 つの Kotlin コードベースから Android / iOS / Web(wasmJs)の 3 プラットフォームに配信しています。モジュール構成は次のとおりです。 androidApp ┐ iosApp ├─→ sharedUI ─→ sharedLogic ─→ apiClient webApp ┘ モジュール Kotlin ファイル数 役割 sharedLogic 113 Compose 依存ゼロ の純 Kotlin。Ktor / Serialization / Coroutines / Koin-core sharedUI 154 モバイル向け共有 CMP 画面 + 3 プラットフォームの actual 実装 webApp 35 モバイルのレイアウトは再利用せず、デスクトップ向けに再構築 apiClient 191 OpenAPI Generator 7.22.0 で生成しリポジトリにコミットした自社バックエンドのクライアント androidApp / iosApp 6 / 4(Swift) エントリポイントのみ 設計は Clean Architecture + MVI です。UseCase が 32 個(1 クラス 1 操作)、Repository が 7 個、ViewModel が 12 個あり、各 ViewModel は MutableStateFlow<XxxUiState> と不変 data class による単一の状態ソース(Single Source of Truth)を持ちます。プラットフォーム能力は 20 個の expect 宣言 に集約しています。 技術スタックはかなり新しめです。Kotlin 2.3.10 / CMP 1.10.1 / AGP 9.0.1 / Ktor 3.4.0 / Koin 4.1.1 / Navigation 3 1.0.0-alpha06 / Media3 1.9.0 / Coil3 3.3.0 / CameraK 0.4 / FileKit 0.13.0、そして wasmJs ターゲット。エコシステムの最前線に立つと何が起きるかは、後半で具体的に書きます。 プロジェクトの特徴を 1 つだけ挙げると、 ロジックは 100% 共有、UI は形態ごとに分岐 という方針です。Web はモバイルのレイアウトを再利用せず 33 個の独立した Web*Screen をデスクトップ向けに構築していますが、ViewModel の状態機械はモバイルとまったく同じものを使います。この「ロジックとプレゼンテーションの分離」が、後述するメディア実装の 3 プラットフォーム分岐を支える土台になっています。 なぜ Compose Multiplatform を選んだのか Flutter や React Native ではなく CMP を選んだ理由は 3 つあります。 1 つ目は、ロジックの複雑さが UI ではなく状態機械にあったこと。 本アプリのビジネスロジックには、本人確認(consent)のブラウザ連携の状態遷移、アバター生成状態の 3 値導出( status × generationRetryable × retryCount → GENERATING / RETRYABLE / TERMINAL )、動画生成とアバター訓練それぞれの 60 秒間隔ポーリングといった、型で守りたい遷移が多くあります。この種のロジックは、sealed interface と data class を持つ Kotlin で書くのが Dart や TypeScript より安全だと判断しました。 2 つ目は、チームのスキルセット。 メンバーは Kotlin / Android 出身で、UseCase 32 個 + StateFlow の MVI という構成は学習コストゼロで書き始められました。 3 つ目が最も重要で、「漸進的にネイティブへ戻れる」こと。 sharedLogic は Compose 依存ゼロの純 Kotlin なので、 Swift から直接呼べます 。実際 iOS はすでに SwiftUI のネイティブシェル + CMP コンテンツ画面というハイブリッド構成です。将来 UI をすべて SwiftUI / Jetpack Compose に置き換えることになっても、ロジック側は 1 行も変わりません。Flutter や React Native では、UI フレームワークを捨てる = ほぼ全部書き直しですが、KMP ではロジック資産がそのまま残ります。この退路の存在が、以前の記事 「Kotlin Multiplatform ハイブリッドモード」 で書いたハイブリッドモードの実利です。 「動画再生」と「カメラ」——若いエコシステムで最初にぶつかる壁 CMP でアプリを作ると宣言したとき、最初に聞かれるのが「動画とカメラはどうするの?」です。もっともな質問です。Flutter には flutter.dev 公式チームが保守する video_player と camera があり、React Native には無数のプロダクションで数年動いている react-native-video と vision-camera があります。CMP にはそれに相当する「公式の定番」がまだありません。 エコシステムの現実を数字で見る 本アプリの動画再生は io.github.kdroidfilter.composemediaplayer (ComposeMediaPlayer)というコミュニティ製ライブラリをベースにしています。Flutter の video_player のような公式チーム保守のプラグインではないので、挙動の細部を確かめたいときはライブラリの公開ソースを読み解くことになります。この記事の Interop 解説も、そのソースリーディングの成果です。 カメラは CameraK を使いました。バージョンは 0.4 です。Flutter の camera が公式チームの保守下にあり、 vision-camera が成熟していることと比べると、この数字がエコシステムの若さを端的に表しています。 それでもこの構成で 3 プラットフォームのアプリは出荷できました。なぜ成立したのかを説明するために、まず前提となる誤解を 1 つ解きます。 そもそも「ネイティブかどうか」は論点ではない クロスプラットフォームの比較記事では「Flutter は独自レンダリングだから」「RN はネイティブだから」という議論をよく見ますが、動画とカメラに関しては、 デコードと撮像の層は 3 者とも完全に同じ です。自前で H.264 デコーダやカメラドライバを書いているフレームワークは 1 つもありません。 フレームワーク / ライブラリ 再生のデコード カメラの撮像 Flutter: video_player / camera (いずれも flutter.dev 公式プラグイン) ExoPlayer / AVPlayer CameraX・Camera2 / AVCaptureSession React Native: react-native-video / vision-camera ExoPlayer / AVPlayer CameraX / AVCaptureSession 本アプリ(CMP) Media3 ExoPlayer / AVPlayer / <video> CameraK 経由の CameraX / AVCaptureSession、Web は getUserMedia を直接 どのフレームワークを選んでも、動画をデコードするのは ExoPlayer と AVPlayer で、カメラを制御するのは CameraX と AVCaptureSession です。つまり「画質」「デコード性能」「対応コーデック」でフレームワーク間に差は出ません。 では何が違うのか。 ネイティブ API がデコードした 1 フレームを、フレームワーク自身が描いた UI(ボタン、テキスト、角丸カード)とどう合成するか 。ここがすべての分岐点です。 本当の分岐点:ネイティブのフレームをどう合成するか 合成戦略は大きく 2 路線に分かれます。 路線 A:テクスチャ搬送。 ネイティブプレイヤーの出力を GPU テクスチャとしてフレームワーク側にコピーし、フレームワークが自分のシーングラフの中で 1 枚の画像として描く。Flutter の video_player は従来この方式が既定でした。 路線 B:ネイティブ view の埋め込み。 ネイティブの view( SurfaceView / UIView / DOM 要素)をそのまま画面に重ね、フレームワークの描画面と OS のコンポジタで合成する。React Native と、CMP の Interop( AndroidView / UIKitView )はこちらです。 路線 A は毎フレームのコピーと引き換えに自由な変換を、路線 B はゼロコピー直結と引き換えに手動のレイヤー管理を受け入れる 両者の得失は明確に非対称です。 観点 路線 A:テクスチャ搬送(Flutter) 路線 B:ネイティブ view 埋め込み(RN / CMP) 角丸・クリップ・回転・拡縮 ✅ 動画はただの画像なので自由に変換できる ⚠️ ネイティブ層は親の clip を受けない( SurfaceView の場合。 TextureView なら可) 他 UI との z 順の重なり ✅ 常に正しい ⚠️ 手動管理。Web ではほぼ不可能 リスト内スクロール ✅ フレーム同期が自然に取れる ⚠️ ネイティブ層がずれて「浮く」ことがある GPU 負荷 / 遅延 ⚠️ コピーと同期で約 1 フレーム分のコスト ✅ ゼロコピーでハードウェア合成に直結( SurfaceView の場合) PiP(iOS)/ AirPlay / ロック画面操作 / ネイティブ字幕 ⚠️ 失われる。追加プラグインが必要 ✅ ネイティブプレイヤーの機能がそのまま使える DRM のセキュアパス(Widevine L1) ❌ テクスチャ経路では取得できない ✅ 可能( SurfaceView が必要) この表を頭に入れたうえで、CMP が 3 プラットフォームでそれぞれどう実装しているかを見ていきます。本アプリでは ComposeMediaPlayer の上に 272 行のラッパー VideoPlayer.kt を被せ、成果物動画の再生( VideoDetailScreen )、アバターのプレビュー再生( AvatarDetailScreen )、録画素材の見直し( AvatarCreateVideoReviewScreen )とその Web 版、計 6 画面で使っています。 3 つのプラットフォーム、3 つの Interop Android:AndroidView と、SurfaceView / TextureView の選択権 Android 実装は AndroidView で Media3 の PlayerView を Compose ツリーに埋め込みます。興味深いのは、このライブラリが 動画を描く面(surface)を 2 種類用意していて、XML レイアウトで切り替えられる ことです。 <androidx.media3.ui.PlayerView android:layout_width="match_parent" android:layout_height="match_parent" app:surface_type="surface_view" app:use_controller="false" /> <androidx.media3.ui.PlayerView android:layout_width="match_parent" android:layout_height="match_parent" app:surface_type="texture_view" app:use_controller="false" /> SurfaceView は OS のハードウェアコンポジタに直結した独立レイヤーで、ゼロコピーかつ低遅延、DRM のセキュアパスもここを通ります。ただし 独立レイヤーであるがゆえに、親 View のクリップに参加しません 。一方 TextureView は動画フレームを GPU テクスチャとして View ヒエラルキーの描画に合流させるため、1 コピー分のコストと引き換えに、普通の View と同じように角丸クリップや変換が効きます。 本アプリのデフォルトは TextureView です。 @Composable actual fun VideoPlayerSurface( playerState: VideoPlayerState, modifier: Modifier, contentScale: ContentScale, overlay: @Composable () -> Unit ) { VideoPlayerSurfaceInternal( playerState = playerState, modifier = modifier, contentScale = contentScale, overlay = overlay, surfaceType = SurfaceType.TextureView ) } 理由は具体的です。アバター詳細画面( AvatarDetailScreen )のプレビューは 350×600 の角丸カードで、 SurfaceView を使うと角丸の内側から四角い動画がはみ出して、カードの角を突き破ります。デザインを守るには TextureView しかありませんでした。 問題の角丸プレビューカード。この 4 つの角を守るために TextureView を選んだ :::message ここに直感に反する事実があります。前節の表のとおり、 TextureView の「GPU テクスチャとしてコピーする」経路は、 まさに Flutter の video_player が従来常に通ってきた路線 A そのもの です。つまり本アプリは Android では、角丸というデザイン要件のために、Flutter と同じ性能特性を自ら受け入れています。 ::: ただし Flutter との違いは「選べること」にあります。 video_player も現在は VideoViewType.platformView で platform view を選べますが、この選択肢が入ったのは登場から長い年月が経ってからでした。CMP の路線 B では、 surfaceType 引数 1 つで 画面ごとに SurfaceView (性能・DRM 優先)と TextureView (クリップ・変換優先)を選び分けられます。角丸カードには TextureView 、全画面再生には SurfaceView 、という使い分けが同じアプリの中でできます。この選択権こそが路線 B の実利で、本アプリの角丸プレビューはそれを行使した実例です。 iOS:UIKitView と layerClass = AVPlayerLayer iOS 実装は 3 プラットフォームの中で最も素直です。CMP の UIKitView でネイティブ UIView を埋め込みますが、その UIView の backing layer 自体を AVPlayerLayer に差し替える という UIKit の古典的なテクニックを使っています。 private class PlayerUIView : UIView { companion object : UIViewMeta() { override fun layerClass(): ObjCClass = AVPlayerLayer } @OverrideInit constructor(frame: CValue<CGRect>) : super(frame) var player: AVPlayer? get() = (layer as? AVPlayerLayer)?.player set(value) { (layer as? AVPlayerLayer)?.player = value } } Objective-C の +layerClass オーバーライドを Kotlin/Native の UIViewMeta でそのまま書けている点に注目してください。view の layer が最初から AVPlayerLayer なので、余計なサブレイヤー管理が不要で、AVPlayer のデコード結果は Core Animation のコンポジタにゼロコピーで渡ります。あとは UIKitView(factory = { PlayerUIView(frame = cValue<CGRect>()) }, ...) で Compose に埋め込むだけです。 きれいな構図ですが、路線 B の代償はここにもあります。CMP の UIKitView はネイティブ view を Compose のシーングラフの中に描くのではなく、 Compose が描画している MTKView の兄弟としてその上に重ねます 。つまり動画 view は Compose の描画世界の外にいて、タッチイベントの透過、レイヤーの前後関係、クリップはすべて Interop の境界で個別に面倒を見ることになります。ComposeMediaPlayer がこの境界を吸収するために、再生コントロールなどを載せるための overlay スロットを API として用意しているのは、その裏返しです。 Web:canvas の裏の video 要素と、BlendMode.Clear で開ける穴 Web(wasmJs)実装は、この記事でいちばん劇的な部分です。CMP の Web ターゲットは画面全体を 1 枚の canvas(Skia)に描画します。では <video> 要素はどこに置くのか。答えは「 canvas の裏 」です。 internal fun HTMLVideoElement.applyInteropBehindCanvas() { val wrapper = parentElement as? HTMLElement ?: return wrapper.style.apply { zIndex = "-1" // <video> を Compose の canvas の背後へ setProperty("pointer-events", "none") backgroundColor = "transparent" } } z-index: -1 で動画をページの最背面に沈めます。当然このままでは canvas に隠れて見えません。そこでアプリ側のコードで、Compose の canvas に 文字どおり穴を開けます 。 // BlendMode.Clear zeroes every pixel (including alpha) in the Compose canvas // over this area, punching a transparent hole so the video element behind // the canvas shows through. Box(modifier = Modifier.fillMaxSize().drawBehind { drawRect(color = Color.Transparent, size = size, blendMode = BlendMode.Clear) }) BlendMode.Clear はその領域のピクセルをアルファ値ごとゼロにするので、canvas のその部分が完全に透明になり、背後の <video> が透けて見える、という仕掛けです。カメラのライブプレビューも同じ機構で、 getUserMedia() で取得した MediaStream を WebElementView 経由で HTMLVideoElement に接続し、canvas の穴から覗かせています。 動画は常に最背面。見えるのは canvas に開けた穴の領域だけで、UI は必ず動画より上に載る :::message これは 2 つ目の、直感に反する事実につながります。この方式の帰結として、 Web では動画は構造的に、すべての Compose コンテンツより下にしか存在できません 。「動画の上に UI を重ねる」ことは(canvas 側に描く限り)可能ですが、「UI の下に動画、そのさらに下に別の UI」のような自由な z 順は原理的に組めません。録画中のタイマー表示や停止ボタンは、この制約を前提に「穴の上に Compose で描く」設計になっています。これはバグや実装の手抜きではなく、canvas レンダリング + DOM 動画という構成の構造的な限界です。 ::: 公平を期すために書いておくと、 Web の動画は 3 フレームワークとも体面を保てていません 。Flutter Web の video_player も HtmlElementView で DOM の <video> を重ねる同類の方式で、canvas と DOM の合成に相応の制約とコストを抱えます。React Native には公式の動画プレイヤーがそもそもなく、 react-native-video の Web 対応もまだ発展途上です。ブラウザでは動画デコードとページ合成の主導権がブラウザ自身にあるため、どのフレームワークも <video> 要素と「同居」する方法を探すしかないのです。 カメラと録音:expect/actual が第一級市民である強み 動画再生はコミュニティ製ライブラリで戦いましたが、カメラと録音はもっと直接的に、 expect/actual で各プラットフォームのネイティブ API に下りて実装しました。まず実装量の実態を見てください。 能力 Android iOS Web 動画録画画面 AvatarCreateVideoRecordingScreen.mobile.kt 467 行 (Android / iOS 共有) 同左 AvatarCreateVideoRecordingScreen.wasmJs.kt 425 行 カメラ実装 CameraK 0.4(CameraX ベース) CameraK 0.4(AVCaptureSession ベース) getUserMedia() + MediaRecorder + WebElementView AudioRecorder 83 行( MediaRecorder ) 108 行( AVAudioRecorder ) 11 行の stub MicrophoneDeviceProvider 70 行 17 行 12 行の stub AudioPlayer 53 行 63 行 57 行( HTMLAudioElement ) モバイル側の録画は CameraK に任せました。フロントカメラ固定・HD 画質・録画プラグインという構成が、Android / iOS 共通の 1 ファイルで書けます。 val cameraConfig = remember { CameraConfiguration( flashMode = FlashMode.OFF, cameraLens = CameraLens.FRONT, ) } val cameraState = rememberCameraKState( config = cameraConfig, setupPlugins = { holder -> holder.attachPlugin(plugin) }, // VideoRecorderPlugin ) CameraKScreen( modifier = Modifier.fillMaxSize(), cameraState = cameraState.value, content = { _ -> RecordingOverlay(/* 録画 UI */) }, ) 録画中の画面。カメラプレビュー(ネイティブ層)の上に、タイマー・スクリプト・停止ボタンを Compose の overlay として重ねている Web はライブラリなしで、 getUserMedia() と MediaRecorder を wasmJs から直接呼びます。mimeType のネゴシエーション(ブラウザごとに対応コーデックが違う)もブラウザ API の流儀のまま書きました。 表の中の「11 行の stub」は隠さず書いておきます。Web 版の AudioRecorder と MicrophoneDeviceProvider は中身のない空実装で、音声クローン用の録音フローはモバイル専用と割り切りました。3 プラットフォーム対応とは「全機能 × 全プラットフォーム」ではなく、プラットフォームごとに提供範囲を選ぶことでもあります。 expect/actual はこの割り切りも型として明示できます。 同じ状態機械、違うトリガー:ConsentReturnFromBrowserEffect expect/actual の使いどころとして、私が最も気に入っている例を挙げます。アバター訓練には本人確認(consent)があり、外部ブラウザで確認を完了してからアプリに戻ると、アプリ側が「戻ってきた」ことを検知してステータスの確認を始めます。この「外部ブラウザから戻ってきた」というイベントは、プラットフォームによって観測手段がまったく違います。 @Composable expect fun ConsentReturnFromBrowserEffect(onReturned: () -> Unit) Android / iOS (mobileMain・36 行):Compose の lifecycle を観測し、 ON_PAUSE → ON_RESUME の順で来たときだけ発火。ブラウザを開けば必ずアプリが pause するので、初回 composition や無関係な resume を誤検知しません Web (wasmJs・36 行): window の blur → focus を監視。wasmJs では lifecycle の ON_RESUME が確実には発火しないため、専用の経路を用意しました consent の状態機械そのもの(待機 → 確認中 → 完了)は ViewModel にあり、3 プラットフォームで完全に同一です。 違うのはトリガーの観測方法だけで、そこだけが actual として差し替わる 。状態機械のテスト・修正・拡張は 1 か所で済みます。 :::message ここが 3 つ目の、そしてこの記事で最も伝えたい、直感に反する洞察です。「CMP はエコシステムが若く、公式プラグインがない」という弱点は事実ですが、そのダメージは KMP のアーキテクチャによって 構造的にかなりの部分まで相殺されています 。Flutter で MethodChannel や PlatformView を書くのは「プラグインがないときの例外的な脱出路」という位置づけですが、KMP の actual は日常の文法そのものです。同じ AudioRecorder クラスを Android 83 行 / iOS 108 行 / Web 11 行で書き分ける作業は、型安全で、IDE 補完が効き、ビジネスコードと同じモジュールの中で完結します。「ライブラリがなければネイティブ API を直接呼べばいい」が、儀式なしで成立するのです。 ::: Flutter / React Native と比べたときの CMP の強みは何か 先に明確にしておきます。 「CMP の動画プレイヤーやカメラは Flutter より優れている」という主張は成立しません。 成熟度では公式保守の video_player / camera や実績豊富な vision-camera に及びません。そのうえで、このプロジェクトを通して実感した CMP 固有の強みは次の 3 点です。 1. Interop 路線の選択権がある。 CMP は路線 B(ネイティブ view 埋め込み)ですが、Android では SurfaceView / TextureView を画面単位で選べます。角丸が要る画面は TextureView 、性能や DRM が要る画面は SurfaceView 。Flutter の video_player には、このスイッチに相当する選択肢が長らく存在しませんでした。 AvatarDetailScreen の角丸プレビューカードは、この選択権の行使例です。 2. 「ネイティブに下りる」ことが第一級市民である。 前節のとおり、 expect/actual は例外処理ではなく日常の文法です。エコシステムに欠けている部品は、待つのではなく 83 行書けば埋まります。 3. ネイティブ API サーフェス全体に直接触れられる。 本アプリが使っているのは、Media3 PlayerView の app:surface_type 、UIView の layerClass 差し替え、 MediaRecorder の mimeType ネゴシエーションといった、各プラットフォームの 素の API です。プラグイン作者が切り出した「最大公約数的な抽象」を経由しないので、プラグインが公開していない機能のために上流(upstream)のリリースを待ったり PR を出したりする必要がありません。 引き換えに何を受け入れたか 強みだけ書いて終わる記事は信用できないので、CMP を選んだ引き換えに受け入れたデメリットも正直に書きます。 動画再生をコミュニティ製ライブラリ 1 本に依存している。 公式チーム保守という後ろ盾がなく、挙動の細部を確かめたり問題を切り分けたりするたびに、ライブラリ内部の Interop 実装まで読み解く理解コストがかかる カメラライブラリのバージョンは 0.4。 公式チームが保守する Flutter camera や、長年プロダクションで検証されてきた vision-camera と比べ、実績の厚みが違う Web の録音まわりは stub。 AudioRecorder 11 行、 MicrophoneDeviceProvider 12 行の空実装で、録音フローはモバイル専用に割り切った Web の動画は構造的に最背面。 BlendMode.Clear の穴あけ方式である限り、動画と Compose UI の z 順は「動画が常に下」で固定される これらを踏まえた私の判断はこうです。 プロダクトの重心がメディア能力そのものにあるなら、 vision-camera を持つ React Native がおそらく最も成熟した実用的な選択肢でしょう。 一方、重心が複雑なビジネス状態機械にあり、長期的にネイティブ UI へ回帰する可能性を残したいなら、CMP は正しい選択です。メディア層で余分に書いたコードは、「 sharedLogic を Swift から直接呼べる」「いつでもネイティブ UI に戻れる」という自由への入場料だったと総括しています。 まとめ 動画・カメラのデコードと撮像の層は、Flutter / React Native / CMP のどれを選んでも同じネイティブ API に行き着く。差が出るのは「ネイティブのフレームと自前 UI の合成方法」で、テクスチャ搬送(路線 A)とネイティブ view 埋め込み(路線 B)は得失が非対称 CMP は路線 B に立ちつつ、Android では SurfaceView / TextureView を画面ごとに選べる。本アプリは角丸プレビューのために、あえて Flutter と同じテクスチャ経路( TextureView )をデフォルトにした iOS は UIKitView + layerClass = AVPlayerLayer でゼロコピー再生。Web は canvas 背面の <video> に BlendMode.Clear で穴を開けて見せる方式で、動画が常に UI の下という構造的制約を受け入れた エコシステムの若さ(コミュニティ製の動画プレイヤー、CameraK 0.4、Web 録音の stub)は実在するコストである。同時に、 expect/actual によって「ネイティブ API に直接下りる」ことが日常の文法になっているため、そのダメージはアーキテクチャで大きく緩和できる CMP のメディアエコシステムは、あと数年は「自分の手を動かして埋める」フェーズが続くと思います。それでも、埋めるための道具立て—— AndroidView / UIKitView / WebElementView 、そして expect/actual ——は既に揃っていて、実プロダクトを 3 プラットフォームに出荷できる水準にあります。この記事の数字とコードが、同じ判断を迫られている方の材料になれば幸いです。 最後まで読んでいただき、ありがとうございました!
はじめに こんにちは。リテールハブ小売アプリ開発チームの池です。 小売アプリチームでは、昨年から CodeRabbit を利用して PR レビューを行っています。日々活用してはいるものの、アップデートの内容を追いきれていませんでした。そこで本記事では、2026 年にアップデートされた内容を整理し、気になった機能をピックアップして紹介します。 なお、本記事の内容は 2026 年 8 月 12 日時点の情報をもとにしています。CodeRabbit はアップデートの頻度が高いため、機能の挙動やプラン要件は変わっている可能性があります。最新の情報は公式ドキュメントをご確認ください。 また、私たちのチームでは Pro プランを利用しているため、Pro プランで利用できる範囲を中心に、Pro+ プラン限定の機能はその旨を明記しながら紹介します。 2026 年アップデートの全体像 CodeRabbit の Changelog および 公式ドキュメント をもとにアップデートの全体像を整理します。2026 年 1〜8 月の changelog は約 100 件あり、主なアップデート内容を独自に抜粋して整理すると次のようになります。 カテゴライズ 主なアップデート(月) 概要 レビューUX ・Multi-Repo Analysis(2 月、6 月拡充) ・Review auto-pause(2 月) ・Slop PR 検出(3 月) ・Quiet プロファイル(7 月) マルチリポ分析の登場とレビューノイズの削減 Finishing Touches ・Autofix(2 月) ・Simplify(3 月) ・Resolve Merge Conflicts(3 月) ・Custom Recipes(3 月) ・Fix CI(7 月) レビュー後の修正作業をエージェントに委譲 CLI・コーディングエージェント連携 ・Claude Code Plugin(2 月) ・CodeRabbit Skills(2 月) ・CLI --agent モード(3 月) ・Codex plugin(4 月) Claude Code などのコーディングエージェントにレビューを統合 静的解析ツールの追加 ・Trivy / TFLint(2 月) ・zizmor(5 月) ・oasdiff(6 月) ・ast-grep の Dart 対応(6 月) ・e18e(7 月) 対応言語・領域の拡大(今年だけで 10 種超) Analytics・ダッシュボード ・Learnings ダッシュボード(2 月) ・ダッシュボード再編(3 月) ・利用メトリクス(6 月) ・レートリミット可視化(7 月) 運用状況の可視化が大幅に強化 外部ツール連携・CodeRabbit Agent ・Slack 向け Agent(4 月) ・メッセージトリガー自動化(4 月) ・Post-Merge Actions(7 月) ・Agent Skills(7 月) Slack を入口にした汎用エージェントと SaaS 接続の拡大 Plan ・Issue Planner(2 月) ・VS Code での Plan(6 月) Issue からの実装計画生成 セキュリティ ・Betterleaks(3 月) ・Security Agent(7 月) ・Secrets 検証ステータス(7 月) ・Git 履歴スキャン(7 月) PR レビューからコードベース全体のスキャンへ ナレッジ・Learnings ・承認フロー(6 月) ・Learnings API(6 月) ・Code Guidelines 管理 UI(6 月) 学習内容の可視化と統制 組織管理・ガバナンス ・Custom Roles(2 月) ・Audit Logs(3 月) ・Pro+ プラン導入(4 月) ・Global Overrides(4 月) エンタープライズ向け統制と料金体系の再編 Change Stack ・semantic diff / Code Peek(5 月) ・Change Stack 各プラットフォーム展開(5〜7 月) ・アプリ内レビュー会話(7 月) 意味的に構造化された PR レビュー専用UI と各プラットフォーム展開 ここからは、この中から気になった 5 つのテーマをピックアップして紹介します。 ① レビューUX 2 月: Multi-Repo Analysis が登場 3 月: Pro プランでリンク可能リポジトリ数が 1→2 に増加 6 月: レビュー対象 ref の固定(Multi-Repo Ref Selection)、自動リポジトリリンクとその管理 UI(Pro+ プラン) Multi-Repo Analysis は、PR のレビュー時に関連リポジトリのコードも合わせて参照して分析する機能です。単一リポジトリの diff だけでは見つけられない、リポジトリをまたぐ問題(API の破壊的変更、型の不一致、依存のドリフトなど)を検出できます。たとえばモバイルアプリと API サーバーが別リポジトリに分かれている場合、サーバー側の API 変更に対して「アプリ側の呼び出しが壊れないか」という観点をレビューに加えられます。 Pro プランでは自動リンク機能が使えないので、管理画面から手動で関連するリポジトリを設定しないと本機能は適用されないようです。設定方法を見ていきます。 設定方法 リンク設定は、管理画面の Review > Repositories > Settings > Knowledge base にあります。手動でのリンクと自動リンクの 2 通りがあります。なお、リンクできるリポジトリ数について、changelog には 3 月に Pro プランで 1→2 に増加したと記載がありますが、執筆時点の管理画面では 1 リポジトリまででした。 手動でリンクする リポジトリ設定はデフォルトで Organization 設定を継承しており、この状態ではリポジトリ単位のカスタマイズができません。Review > Repositories > Settings > General にある「Use Organization Settings」のトグルを一度クリックすると、設定の継承は on のまま、リポジトリ単位で編集できるようになります。 Linked repositories でリンクするリポジトリを選択し、Instruction にリポジトリ間の関係を記述できます。 保存すると Linked repositories の一覧に表示され、リンク設定は完了です。 自動リンク(Automatic repository linking) Automatic repository linking を有効にすると、Organization 内の関連するリポジトリが自動的に検出されリンクされます。なお、自動リンクは Pro+ プラン以上の機能です。 Multi-Repo Analysis のレビューでの見え方 Multi-Repo Analysis の結果は、PR Walkthrough の Review details > Additional context used に、リンクされたリポジトリ名ごとにグループ化されて表示されます。自動リンクされたリポジトリが含まれる場合は、Review info セクションに CodeRabbit が参照したリポジトリの一覧が表示され、それぞれに手動リンクか自動検出かのラベルが付きます。また、関連がある場合はインラインのレビューコメントやコメント返信にも指摘として現れます。 なお、Review details セクションを表示するには、 .coderabbit.yaml で review_details を有効にしておく必要があります。 # .coderabbit.yaml reviews : review_details : true ② Finishing Touches Finishing Touches は、レビュー後の仕上げ作業(指摘の修正、docstring や単体テストの作成など)を、PR コメントや PR Walkthrough のチェックボックスから CodeRabbit のエージェントに任せられる機能群です。 2 月: Autofix(未解決の指摘への修正を自動適用)、Chat code editing(Early Access) 3 月: Simplify、Resolve Merge Conflicts、Custom Recipes 7 月: Fix CI(CI 失敗の調査から修正・PR 作成までを行う) Finishing Touches の一覧 執筆時点で利用できる Finishing Touches は以下の 7 種類です。一部は Pro+ プラン限定となっています。 Autofix(指摘の自動修正) Docstrings(docstring の生成) Fix CI(CI 失敗の修正、Pro+ プラン) Resolve Merge Conflicts(コンフリクトの解決、Pro+ プラン) Unit Tests(単体テストの生成、Pro+ プラン) Simplify(コードの簡素化、Pro+ プラン) Custom Recipes(カスタム定義による修正、Pro+ プラン) トリガー方法は 2 種類あります。 PR コメント: @coderabbitai generate docstrings のようなコメントを投稿する チェックボックス: PR Walkthrough のコメント上のチェックボックスにチェックを入れる Autofix Autofix は、PR に残っている未解決のレビュー指摘を対象に、サンドボックス内で修正を作成する機能です。修正の反映先は、現在のブランチへの直接コミットか、stacked PR かを選べます。 以下のようにコメントすることで利用できます。 @coderabbitai autofix # 修正を現在のブランチにコミット @coderabbitai autofix stacked pr # 修正を stacked PR として作成 Autofix 機能はデフォルトで有効になっています。リポジトリ単位で無効にしたい場合は、 .coderabbit.yaml で設定します。 # .coderabbit.yaml reviews : finishing_touches : autofix : enabled : false # Autofix を無効化 実際に試したところ、人間のレビュアーのコメントに対して @coderabbitai autofix stacked pr を実行しても、以下のようにスキップされました。 Autofix の対象は、CodeRabbit 自身が投稿した修正手順つきの未解決レビューコメントのみで、人によるレビューコメントの修正は任せられないようです。 次に、CodeRabbit の修正手順つき未解決コメントが残っている PR で実行したところ、今回は受け付けられました。特定の 1 件の指摘スレッドへの返信として実行しましたが、PR 上の未解決指摘 17 件すべてが修正対象になりました。コメントを投稿した場所に関係なく、PR 単位で一括適用される挙動のようです。 実行から約 6 分半で、12 ファイルの修正を含む stacked PR が作成されました。 ③ CLI・コーディングエージェント連携 CodeRabbit CLI は、ターミナルから手元のコード変更に対して CodeRabbit のレビューを実行できるツールです。PR を作成する前に、ローカルで指摘の検出と修正を済ませられます。2026 年は、この CLI を土台にした Claude Code などのコーディングエージェントとの統合が進みました。 2 月: Claude Code Plugin 対応、CodeRabbit Skills(open agent skills 標準) 3 月: CLI --agent モード(構造化 JSON 出力)、CLI 用 Usage-based アドオン(従量課金オプション) 4 月: CLI ブラウザ完結のサインイン、Codex plugin 対応 5〜8 月: coderabbit doctor 、 --light モード、レビュー信頼性の改善 CLI は無料プランでも利用できます(基本的な静的解析、1 時間あたり 3 回まで)。Pro プランにすることで、組織の Learnings を活用した強化レビューになり、レートリミットは 1 時間あたり 5 回に引き上げられます。 コーディングエージェントに CLI を統合するメリット CodeRabbit は、Claude Code などのコーディングエージェントと CLI を組み合わせる意義を、次のような役割分担として説明しています。 専門家による問題検出 : 一般的なリンターでは見逃しやすい競合状態・メモリリーク・論理エラーを CodeRabbit が検出する AI を活用した修正 : Claude Code が CodeRabbit の分析結果に基づいて修正を実装する コンテキストの保持 : 問題の発生場所・深刻度・推奨される対処法が簡潔に Claude Code へ渡される 継続的なワークフロー : ツールを切り替えることなく、レビュー → 修正 → 反復のループを開発の流れの中で回せる Claude Code への導入方法 導入は「CLI のインストール・認証」と「Claude Code へのプラグイン追加」の 2 段階です。 まず CodeRabbit CLI をインストールします。 curl -fsSL https://cli.coderabbit.ai/install.sh | sh # または brew install coderabbit 次に認証を行います。4 月の CLI v0.4.0 からサインインはブラウザで完結するようになりました。 coderabbit auth login Claude Code 側では、公式プラグインマーケットプレイスに CodeRabbit が用意されているので、選択するだけで使えるようになります。 プラグインの内容 導入すると、Claude Code 内で coderabbit-review コマンドと、code-review・autofix の 2 つのスキルが利用できるようになります。 /coderabbit:coderabbit-review : 手元の変更に対して CodeRabbit のレビューを 1 回実行し、結果を表示するコマンド /coderabbit:code-review : CodeRabbit のレビューをワークフローとして扱うスキル。エージェントがレビューを必要と判断した場面でも自動起動する /coderabbit:autofix : GitHub PR のレビュースレッドにある CodeRabbit の指摘を、変更ごとに承認を挟みながら安全に適用する また、プラグインとは別に、open agent skills 標準に対応した CodeRabbit Skills も提供されています。Claude Code の場合はプラグインの導入でスキルも一緒に入るため追加の作業は不要ですが、Cursor / Codex / Gemini CLI など他のエージェントでも使いたい場合は、以下のコマンドで各エージェントのスキルディレクトリに導入できます。 coderabbit skills code-review と autofix の 2 つのスキルの実体は、ワークフローを記述した Markdown ファイル(SKILL.md)です。それぞれ次の内容がワークフローとして明記されています。 code-review : 「実装 → レビュー → Critical/Warning の修正 → 再レビュー → クリーンになるまで繰り返す」という自律ループ autofix : エージェント自身が指摘の妥当性をローカルコードで検証し、1 件ずつユーザーの承認を取りながら修正を適用する手順 ループが前提として設計されている点、必要な箇所で人の承認を挟んでいる点から、AI を使った開発ワークフローに CodeRabbit を自然に組み込める仕組みだと感じました。 ④ 静的解析ツールの追加 静的解析ツールの追加は活発に行われており、1〜8 月だけで 10 種を超えるツールが追加・更新されています。対応しているツールの一覧は 公式ドキュメントの Tools 一覧 を参照してください。 2 月: Trivy(IaC のセキュリティスキャン)、TFLint(Terraform)、Stylelint(スタイルシート)、TruffleHog(シークレットスキャン)、OpenGrep(Semgrep 互換の静的解析エンジン)など 9 種 3 月: Betterleaks(シークレットスキャナを Gitleaks から Betterleaks へ置き換え。既存の gitleaks 設定キーがそのまま使える) 5 月: zizmor(GitHub Actions ワークフローのセキュリティ解析) 6 月: Infer(C / C++ / Java の null 参照・リソースリーク・並行処理バグ検出)、oasdiff(OpenAPI の破壊的変更検出)、React Doctor(React のセキュリティ・パフォーマンス・アクセシビリティの問題を検出)、ast-grep の対応ファイルタイプ拡大(Dart、TOML、Markdown など) 7 月: e18e ESLint plugin(モダンな代替がある依存や、メンテナンスされていないパッケージを指摘) これらの静的解析ツールは CodeRabbit ではほぼすべてデフォルトで有効になっており、該当するファイルタイプの変更を検出すると自動で実行されます。 ルールの詳細を制御したい場合は、各ツールの設定ファイルを使います。リポジトリに .eslintrc.js や pyproject.toml があれば CodeRabbit はそれを尊重するため、既存の設定をそのまま生かせます。 また、対象領域は、シークレット・IaC・CI 設定といったアプリケーションコードの周辺へと拡大しています。PR レビューの対象が、コードそのものからリポジトリ全体の安全性へ広がっていると感じました。 複数のリポジトリを運用していると、リポジトリごとに静的解析ツールを選定して管理していくのはそれなりのコストになります。ツールの追加・実行・アップデートを CodeRabbit 側が担ってくれることで、この導入・運用コストを下げられるのはメリットの一つだと思いました。 ⑤ Analytics Analytics は、CodeRabbit の管理画面で組織のレビュー活動を可視化するダッシュボード機能です。レビューされた PR の数や指摘への対応状況、レビューにかかった時間、Learnings などナレッジの活用状況といったメトリクスを、リポジトリ・ユーザー・期間で絞り込みながら確認できます。 2 月: Learnings ダッシュボード(KPI カード、利用回数・最終利用日などのメタデータ) 3 月: ダッシュボード再編(Git プラットフォームレビューと IDE/CLI レビューの分離、Knowledge Base / Pre-merge Checks / Reporting などの新ページ) 6 月: Path 指示・Finishing Touches の利用メトリクス 7 月: レートリミット影響の可視化(Usage Rate-Limit Insights)、Explore ページのレコメンデーション 3 月の再編により、Analytics は大きく「PR reviews」と「IDE/CLI reviews」の 2 つに分かれています。それぞれ見ていきます。 PR レビューのメトリクス PR レビュー側は Summary / Quality Metrics / Time Metrics / Knowledge Base / Organization Trends / Pre-merge Checks / Reporting / Data Metrics のページで構成されており、すべては書ききれないほど多くのメトリクスがあります。以下は Summary ページの画面です。 ここでは代表的な項目を挙げます。 レビュー成果 : レビュー済みでマージされた PR 数、投稿コメント数、Acceptance Rate(開発者が指摘に対応した割合)、Reviewer Time Saved(AI 推定のレビュー工数削減時間) 時間系 : レビュー可能になってから「マージ」「最初の人間レビュー」などまでの時間(平均・中央値・P75・P90)と週次トレンド ナレッジ系 : Learnings の作成数・適用率、Path-based Instructions の利用率、MCP サーバー別の PR カバレッジ 運用系 : Pre-merge Checks や Finishing Touches の実行数と結果、レートリミットの影響 これらのメトリクスは、開発プロセスの改善、効果の定量的な説明、CodeRabbit 自体のチューニングなど、幅広く活用できそうです。 IDE/CLI レビューのメトリクス IDE / CLI がチームでどれだけ使われているかを示すメトリクスです。 アクティブユーザー数・レビュー数・レビューコメント数を、IDE 拡張 / CLI の種別ごとに集計 Learnings や Path-based Instructions がローカルレビューに適用された割合 SAST・リンターによるツール検出の内訳(ツール別・重要度別) ユーザー別の明細(拡張の種別、導入日、最終利用日時、レビュー数) 従来見えづらかった PR に到達する前の品質活動を追えるのは、開発フローの改善を測るうえで示唆がありそうです。 おわりに 本記事では、CodeRabbit の 2026 年 1〜8 月のアップデートを整理し、気になった 5 つのテーマをピックアップして紹介しました。 アップデートの内容を追うと、CodeRabbit が PR レビューのツールにとどまらず、開発ライフサイクル全体を支えるエージェントプラットフォームへと広がっていると感じました。 AI によりコードの生成量が増大する中で、いかに効率良く品質を担保するかは、AI 活用における課題の一つだと思います。CodeRabbit を適切に活用することは、この課題を解決する方法の一つになると感じています。 私たちのチームでも、今回整理したアップデート内容を実際の開発フローに組み込みながら、活用を広げていければと思います。 本記事が少しでも参考になれば幸いです。最後まで読んでいただきありがとうございました。
はじめに こんにちは、新規事業部フロントエンドブロックの 大野純平 です。2025年度に新卒入社し、現在のチームに配属されました。チームでFlutter製モバイルアプリを開発する中で、新規プロジェクトの立ち上げを効率化するための社内テンプレートリポジトリの整備を進めています。 このテンプレートを育てる中で、 Flutter 3.44 へのアップデートに伴うCocoaPodsからSPMへの移行対応が積み残しになっていました。あわせて、このタイミングでdev / stg / prdの3環境対応も新規に追加しました(3 flavor化自体はSPM移行の必須要件ではなく、テンプレート整備の一環で同時に着手したものです)。本記事では、 CocoaPodsからSPMへの移行 と、 3 flavorを mise run setup で一括整備する仕組み を紹介します。あわせて、Flutterにおけるビルド構成ファイルを解説します。 目次 はじめに 目次 Flutterプロジェクトのビルド構成 iOS側のしくみ Android側のしくみ Dart側のしくみ 3層を貫く全体像 背景・課題 3環境の設定整合が壊れやすい 解決策の全体像 実装内容と具体的な解決策 1. CocoaPodsを撤去してSPMに移行する CocoaPodsの統合を解除する Crashlytics dSYMのパスを更新する 2. iOS:xcconfigでBuild Configurationを9パターンに整える Firebase plistをBuild Phaseで差し替える 3. Android:productFlavorsとgoogle-services.jsonをflavorごとに配置する 4. Dart:firebase_optionsをセレクタshim化する main.dartでduplicate-appを処理する 5. mise run setupで全flavorを一括生成する まとめ Flutterプロジェクトのビルド構成 Flutterアプリは iOSネイティブ層・Androidネイティブ層・Dart層 の3つが組み合わさっています。環境(flavor)の切り替えとは「3層すべてに、同じflavor用の設定を選ばせること」です。 iOS側のしくみ Xcodeは以下の単位を組み合わせてビルドを定義します。 単位 役割 Flutter デフォルト Project ( .xcodeproj ) プロジェクト全体の定義。実体は project.pbxproj Runner.xcodeproj Target ビルド成果物の単位 Runner Build Configuration ビルド設定値の集合 Debug / Profile / Release の 3 種 Scheme Run / Test / Profile / Analyze / Archive の各アクションで使う Target と Configuration を定義する起動設定 Runner 1 つ xcconfig はBuild Configurationの値をテキストで宣言するファイルです。別のxcconfigを #include で取り込んだとき、同じ変数があれば 後から読み込んだ値が優先 されます。共通値を書いたxcconfigを先に、flavor固有値を書いたxcconfigを後に重ねれば、最終的な値が組み上がります。 Info.plist の CFBundleIdentifier は $(PRODUCT_BUNDLE_IDENTIFIER) を参照しています。そのため、xcconfig側で PRODUCT_BUNDLE_IDENTIFIER を決めればBundle IDが自動的に解決されます。 iOS・macOSで外部ライブラリを使うときの依存管理ツールには、 CocoaPods と Swift Package Manager(SPM) の2つがあります。Flutterプロジェクトでは長らくCocoaPodsが標準でしたが、Flutter 3.44からiOS・macOSの両方でSPMがデフォルトに切り替わりました。両者の違いは以下のとおりです。 CocoaPods Swift Package Manager (SPM) 提供元 OSS(Ruby 製) Apple 公式 設定ファイル Podfile Package.swift (Xcode では Package Dependencies UI から編集) 成果物 Pods/ + .xcworkspace を別管理 Xcode が直接管理 ロックファイル Podfile.lock Package.resolved Flutter での状況 長年の標準 3.44 でデフォルト有効化 Android側のしくみ AndroidのビルドはGradleが android/app/build.gradle.kts を読んで実行します。 概念 役割 buildTypes ビルド種別( debug / release ) productFlavors 同じアプリのバリアント定義。 applicationId やリソースを分岐できる applicationId / applicationIdSuffix アプリの一意な識別子。iOS の Bundle ID 相当 resValue ビルド時に文字列リソースを生成する Gradle の機能 AndroidManifest.xml アプリのメタデータ宣言。 android:label でアプリ名を指定 Gradleは、flavor別のリソース( AndroidManifest.xml や res/ 配下など)を source set という仕組みでディレクトリ単位に分けます。Gradleがflavorを選んだときに src/main/ と自動マージします。なお google-services.json は厳密にはsource set mergeの対象ではありません。google-servicesプラグインが src/<flavor>/google-services.json などのパスを優先度順に探索し、1ファイルを選びます。 Dart側のしくみ Dartでflavorを識別するにはコンパイル時定数を使います。 API / オプション 役割 --dart-define-from-file=path/to/x.json .json または .env ( KEY=VALUE 形式)で書いた値をまとめて Dart に渡す flutter コマンドのオプション String.fromEnvironment('KEY') 渡された値をコンパイル時定数として参照する API。 switch の case 値にもできる firebase_options.dart flutterfire configure が生成する Firebase 設定ファイル。通常は 1 ファイル = 1 Firebase プロジェクト 3層を貫く全体像 3層を1枚にまとめると、 app_config.yaml → mise run setup → 各層のファイル群 → flutter run --flavor X という流れです。以降、iOS・Android・Dartの順に、各層内で mise run setup と flutter run --flavor X がどのファイルへつながるかを示します。 iOS層では、xcconfigチェーンが Info.plist を解決します。 project.pbxproj のRun Scriptがflavor別の GoogleService-Info.plist をコピーします。 Android層では、 build.gradle.kts がflavorディレクトリの google-services.json と AndroidManifest.xml を結びつけます。 Dart層では、 flavor/{dev,stg,prd}.json の FLAVOR が起点になります。 firebase_options.dart が FLAVOR に応じて firebase_options_{dev,stg,prd}.dart を切り替えて返すコード( セレクタ shim )になります。 flutter run --flavor dev --dart-define-from-file=flavor/dev.json を1回実行したときに、各層で何が起きるかを並べると以下のようになります。 iOS :Scheme dev → Debug-dev.xcconfig で PRODUCT_BUNDLE_IDENTIFIER が .dev 付きに解決される。Build PhaseのRun Scriptが ios/firebase/dev/ のplistをコピーする。 Android :productFlavor dev が選ばれて applicationIdSuffix=".dev" が適用される。 src/dev/google-services.json がsource setにマージされる。 Dart : flavor/dev.json の FLAVOR=dev がコンパイル時定数になる。 firebase_options.dart のセレクタshimが firebase_options_dev.dart を返す。 ここまでがFlutterプロジェクトのビルド構成の概要です。これを踏まえて、本記事で取り組んだ課題と解決策を見ていきます。 背景・課題 3環境の設定整合が壊れやすい dev / stg / prdの3環境を切り替えるには、iOS・Android・Dartの3層を整合させる必要があります。各層の対象は次のとおりです。 iOS:Bundle ID、 GoogleService-Info.plist Android: applicationId 、 google-services.json Dart:Firebase初期化オプション 3層が独立しているため設定漏れによるビルド失敗やFirebase接続不可が繰り返し発生し、原因特定にも時間がかかっていました。 解決策の全体像 以下の5つの取り組みで課題を解決しました。 # 層 取り組み 1 iOS / 基盤 CocoaPods 撤去 → SPM 移行 2 iOS / flavor xcconfig × 9 Build Configuration + flavor 別 Firebase plist 3 Android / flavor productFlavors + flavor 別 google-services.json 4 Dart firebase_options.dart のセレクタ shim 化 5 setup app_config.yaml の per-flavor 化と mise 自動化 1つのflavorを選んで flutter run すると、iOS・Android・Dartの3層でそれぞれ対応する設定が適用されます。 実装内容と具体的な解決策 1. CocoaPodsを撤去してSPMに移行する CocoaPodsはRuby製のAppleプラットフォーム向けパッケージマネージャーで、Swift Package Manager(SPM)はApple公式の代替です。Flutter 3.44では新規プロジェクトでiOS・macOSともにSPMがデフォルトで有効化され、Firebaseをはじめとする主要プラグインもSPM対応を完了しています。 pod install が不要になるため、CIのセットアップも大幅に高速化されます。 CocoaPodsの統合を解除する 既存プロジェクトでは、 Podfile をいきなり削除するのではなく、まず pod deintegrate でXcodeプロジェクトからCocoaPodsの統合を解除します。 cd app/ios pod deintegrate # project.pbxproj から Pods 統合を除去 rm Podfile Podfile.lock # Podfile 本体を削除 rm だけで済ませてはいけません。 Podfile を削除しただけでは project.pbxproj にCocoaPods統合の痕跡が残ります。たとえば [CP] Check Pods Manifest.lock などのBuild Phaseや、 Pods-Runner xcconfigの #include 参照などです。 pod deintegrate はこれらを正しく除去します。 .gitignore も忘れず更新します。 # app/ios/.gitignore -.symlinks/ -Pods/ +# SPM +.build/ SPMが管理する Package.resolved はバージョンを固定するためリポジトリにコミットします。 Crashlytics dSYMのパスを更新する dSYM(Debug Symbols)はクラッシュレポートのスタックトレースを人間が読めるシンボルに復元するためのファイルです。Crashlyticsを使っている場合、そのdSYMをアップロードするBuild PhaseスクリプトをSPM構成のパスに合わせて更新します。このRun Scriptは setup 系スクリプトが自動生成するものではなく、Crashlyticsを利用する場合はプロジェクトごとに手動で追加する手順です。 Firebase公式のApple向けガイド は現在SPM構成を前提としており、Run Scriptに以下を追加するよう記載されています。 # Build Phases > Run Script " ${BUILD_DIR % /Build/* } /SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run " CocoaPods時の ${PODS_ROOT}/FirebaseCrashlytics/run に相当します。Xcode 15以降は User Script Sandboxing がデフォルトで有効になっています。有効な場合はRun ScriptのInput Filesに以下を列挙する必要があります( Firebase公式ドキュメント )。 ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME} ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME} ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist $(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist $(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH) # Debug Dylib(DEBUG_DYLIB)が有効な場合は追加で必要 ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}.debug.dylib 2. iOS:xcconfigでBuild Configurationを9パターンに整える iOSのXcodeプロジェクトは「Build Configuration」というビルド設定の単位を持ちます。各Configurationにはxcconfigファイル(テキストでビルド設定を記述する仕組み)を1つ紐付けられます。本テンプレートは、以下の9通りのBuild Configurationを用意しています。 dev stg prd Debug Debug-dev Debug-stg Debug-prd Profile Profile-dev Profile-stg Profile-prd Release Release-dev Release-stg Release-prd これらはあらかじめ project.pbxproj にコミット済みです。あわせて、それらを参照する dev / stg / prd の3 Schemeもコミット済みです。各Schemeは、Run/Test/Analyzeで Debug-<flavor> を参照します。Profileでは Profile-<flavor> 、Archiveでは Release-<flavor> を参照します。3つのSchemeには、Flutter標準の Run Prepare Flutter Framework Script pre-actionも含めて完全な状態で用意されています。このpre-actionは xcode_backend.sh prepare を実行します。これによりSPM統合に必要な FlutterGeneratedPluginSwiftPackage が解決されます。 mise run setup はこのScheme⇔Configurationのマッピングやpre-actionには一切触れません。書き換えるのは、各Configurationに紐づくxcconfigの値(Bundle IDやアプリ名など)だけです。 Flutter/Shared.xcconfig ← 全 Configuration 共通の値 Flutter/flavor-dev.xcconfig ← dev 固有の値(BUNDLE_ID_SUFFIX など) Flutter/flavor-stg.xcconfig Flutter/flavor-prd.xcconfig Flutter/Debug-dev.xcconfig ← #include で連結(9ファイル) Flutter/Debug-stg.xcconfig ... Flutter/Release-prd.xcconfig 後ろの #include が前の値を上書きするため、共通値( Shared )→ flavor固有値( flavor-dev )の順に重ねることで最終的な値が決まります。 Debug-dev.xcconfig は3行の #include のみで構成されます。 #include "Debug.xcconfig" #include "Shared.xcconfig" #include "flavor-dev.xcconfig" これでビルド時に対応するxcconfigが PRODUCT_BUNDLE_IDENTIFIER を解決します。その値が Info.plist 経由でflavor付きBundle ID(例: jp.testapp.dev )として適用されます。 Firebase plistをBuild Phaseで差し替える flavor別の GoogleService-Info.plist を使い分けるため、Build PhaseにRun Scriptを追加します。Run Script内の ${FLAVOR} は実行直前にxcconfigの値( dev / stg / prd )に置き換えられます。これによりコピー元ディレクトリがflavorごとに切り替わり、該当するplistだけが .app バンドルに入ります。 # Build Phases > Run Script SRC = " ${SRCROOT} /firebase/ ${FLAVOR} /GoogleService-Info.plist " DST = " ${BUILT_PRODUCTS_DIR} / ${PRODUCT_NAME} .app/GoogleService-Info.plist " if [ -f " $SRC " ]; then cp -f " $SRC " " $DST " else echo " warning: $SRC not found, Firebase native init will be skipped " fi plistが存在しないflavorでもビルドが失敗しないよう、存在チェックを入れています。 firebase_project_id を空にしてFirebase設定をスキップした場合がこれに該当します。このRun Scriptは読み書き対象のパスをInput Files / Output Filesとしてあらかじめ宣言しています。そのためUser Script Sandboxingが有効な環境でも安全に動作します。 plistは以下のスロットに配置します( setup:firebase が生成)。 app/ios/firebase/ ├── dev/GoogleService-Info.plist ├── stg/GoogleService-Info.plist └── prd/GoogleService-Info.plist 3. Android:productFlavorsとgoogle-services.jsonをflavorごとに配置する Androidでは build.gradle.kts に productFlavors を追加し、flavorごとに applicationIdSuffix とアプリ名を設定します。 // app/android/app/build.gradle.kts flavorDimensions + = "env" productFlavors { create( "dev" ) { dimension = "env" applicationIdSuffix = ".dev" resValue( "string" , "app_name" , "Flutter Template App Dev" ) } create( "stg" ) { dimension = "env" applicationIdSuffix = ".stg" resValue( "string" , "app_name" , "Flutter Template App Stg" ) } create( "prd" ) { dimension = "env" resValue( "string" , "app_name" , "Flutter Template App" ) } } google-services.json はAndroid Gradleのflavorソースセット( src/<flavor>/ )に配置します。flavor用の設定ファイルはgoogle-servicesプラグインが自動で選択します。 android/app/src/ ├── dev/google-services.json # setup:firebase が生成 ├── stg/google-services.json └── prd/google-services.json 注意点が2つあります。1つ目は google-services.json の package_name です。suffix込みの最終applicationIdに合わせます(例: com.example.app.dev )。suffixなしの com.example.app だけFirebaseに登録していると、dev / stgビルド時にプラグインが例外を投げてビルドが通りません。Firebase側のアプリ登録もsuffix込みのapplicationIdで行う必要があります。 AndroidManifest.xmlで android:label="@string/app_name" のように参照します。 productFlavors の resValue がflavorごとに生成した文字列が、そのままアプリ名として使われます。 <!-- app/android/app/src/main/AndroidManifest.xml --> <application android : label = "@string/app_name" ...> 2つ目は、 strings.xml との重複です。 resValue("string", "app_name", ...) はビルド時に文字列リソースを生成します。そのため、 res/values/strings.xml に同名の app_name が定義されていると Duplicate resources エラーになります。 app_name は strings.xml には置かず、各flavorの resValue に一本化してください。 4. Dart:firebase_optionsをセレクタshim化する flutterfire configure が生成する firebase_options.dart は単一の設定しか返しません。そこでファイル名とAPIを保ったまま、 セレクタ shim に置き換えます。 まず app/flavor/dev.json に FLAVOR キーを定義し、 --dart-define-from-file で読み込みます。 { " FLAVOR ": " dev " } FLAVOR はDartコード側で String.fromEnvironment('FLAVOR') を使って、コンパイル時定数として参照できます。 // app/lib/firebase_options.dart import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; import 'firebase_options_dev.dart' as dev; import 'firebase_options_prd.dart' as prd; import 'firebase_options_stg.dart' as stg; const _flavor = String .fromEnvironment( 'FLAVOR' ); class DefaultFirebaseOptions { static FirebaseOptions get currentPlatform { switch (_flavor) { case 'dev' : return dev.DefaultFirebaseOptions.currentPlatform; case 'stg' : return stg.DefaultFirebaseOptions.currentPlatform; case 'prd' : return prd.DefaultFirebaseOptions.currentPlatform; default : throw StateError( 'Unknown FLAVOR: "$_flavor". ' 'Run with --dart-define-from-file=flavor/<flavor>.json' , ); } } } 呼び出し側( main.dart )は DefaultFirebaseOptions.currentPlatform をそのまま使うだけで、flavorの切り替えを意識する必要がありません。 setup:firebase はこのファイルを上書きせず、本物の firebase_options_<flavor>.dart だけを再生成します。 注意:flavor指定は必須 _flavor が空( --dart-define-from-file 未指定)だとshimの default が StateError を投げます。 _initializeFirebase はこれをcatchしないため、flavorなしの flutter run は起動時に落ちます。VS Codeのlaunch.jsonや mise run タスクで各flavorを必ず渡す構成にしておくと安全です。 main.dartでduplicate-appを処理する Firebaseには2つの初期化経路があります。ネイティブの自動初期化と、Dart側の Firebase.initializeApp() です。両方が呼ばれると duplicate-app の FirebaseException が発生します。ただし duplicate-app はflavor不整合(ネイティブのplist/jsonが別のFirebaseプロジェクトを指している等)でも発生しうるため、無条件で成功扱いにはできません。ネイティブ初期化済みの options と比較し、一致した場合のみ成功扱いにします。不一致の場合は例外を再throwし、アプリを起動時にクラッシュさせます。例外を握りつぶしてしまうと、不正なAPIキーや設定不整合といった検知したいバグを見逃してしまう可能性があるためです。 // app/lib/main.dart(抜粋) Future< bool > _initializeFirebase() async { try { await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); return true ; } on UnimplementedError { // placeholder(setup:firebase 未実行)の場合は Firebase を無効化して続行 return false ; } on FirebaseException catch (e) { if (e.code == 'duplicate-app' ) { final existing = Firebase.app().options; final expected = DefaultFirebaseOptions.currentPlatform; final matchesNativeConfig = existing.appId == expected.appId && existing.projectId == expected.projectId && existing.apiKey == expected.apiKey; if (matchesNativeConfig) return true ; } rethrow ; } } 5. mise run setupで全flavorを一括生成する mise は、タスク実行とツールのバージョン管理を兼ねるCLIツールです。本テンプレートではセットアップスクリプトの実行に利用しています。手作業の設定漏れを防ぐため、セットアップの入力ファイル app_config.yaml にFirebaseのproject_idをflavorごとに設定できる構造を追加しました。これにより、 mise run setup:firebase で全flavorの設定ファイルを一括生成できます。 flavor : dev : app_name_suffix : " Dev" bundle_id_suffix : ".dev" firebase_project_id : "my-project-dev" # 空文字なら Firebase 設定をスキップ stg : app_name_suffix : " Stg" bundle_id_suffix : ".stg" firebase_project_id : "my-project-stg" prd : app_name_suffix : "" bundle_id_suffix : "" firebase_project_id : "my-project-prd" mise run setup:firebase は app_config.yaml の各flavorの firebase_project_id を読み取ります。読み取った値で flutterfire configure をflavorごとに実行します。 iOSは --ios-out でパスを直接指定すると、plistは出力されるもののproject.pbxprojへの参照は Runner/ 配下を前提に追加されます。配置と参照のズレでビルドが落ちる事例もありました。そこで setup:firebase は flutterfire configure のデフォルト出力( Runner/ )を利用し、後処理で配置を整えます。 Build PhaseのRun Scriptがflavorに応じてplistを動的にコピーするので、 project.pbxproj の静的参照は不要です。 setup:firebase は実行のたびに追記される参照を毎回除去し、pbxprojが肥大化しないようにしています。 # クリーンアップ確認(0 であれば OK) grep -c " GoogleService-Info.plist .*PBXBuildFile " \ app/ios/Runner.xcodeproj/project.pbxproj mise run setup:flavor はxcconfigやflavor JSONを app_config.yaml から再生成します。Firebaseの設定ファイルは別途 setup:firebase で更新します。何度実行しても同じ出力になるため、設定変更後に再実行すればxcconfigとflavor JSONが同期されます。 この仕組みにより、新規プロジェクトでは app_config.yaml を編集して mise run setup を実行するだけで済みます。iOS xcconfig・Android productFlavors・Dartセレクタshim・Firebase設定ファイルのすべてが一括でできあがります。 まとめ 本記事では、FlutterプロジェクトにおけるCocoaPodsからSPMへの移行を紹介しました。あわせてdev / stg / prdの3 flavorをiOS・Android・Dartの3層で整合させる仕組みの構築も紹介しました。 CocoaPodsからSPMへの移行自体は pod deintegrate → Podfile削除で思ったよりシンプルです。手間がかかるのはflavorの3層整合で、それぞれが独立した設定体系を持つため一つひとつ繋ぎ込む必要があります。 app_config.yaml → mise run setup という自動化の仕組みを整えました。これにより手動作業による設定漏れのリスクを排除し、チームが本来の開発に集中できる環境を作れました。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com

動画

該当するコンテンツが見つかりませんでした

書籍