約29分で読めます
Kotlin
CMP で動画再生とカメラを実装する——3 プラットフォーム 3 通りの Interop と若いエコシステムの乗りこなし方

この記事でわかること
- Compose Multiplatform(CMP)の動画再生は、Android では
AndroidView+ Media3PlayerView、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 を選んだ
ただし 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 は必ず動画より上に載る
公平を期すために書いておくと、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 か所で済みます。
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。
AudioRecorder11 行、MicrophoneDeviceProvider12 行の空実装で、録音フローはモバイル専用に割り切った - 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 プラットフォームに出荷できる水準にあります。この記事の数字とコードが、同じ判断を迫られている方の材料になれば幸いです。
最後まで読んでいただき、ありがとうございました!


