Kotlin Jetpack Compose Android移植

iOSアプリ4本をAndroidへ移植して分かった
「同じ機能」の作り方

SwiftUIをComposeへ書き換えるだけでは、同じアプリになりません。
描画、再生、位置監視、保存の責務をAndroidで組み直した記録です。

← Articles 一覧へ戻る

結論: 移すのはAPIではなく、機能の約束

4本を移植して共通していたのは、Appleのクラス名に似たAndroid APIを探すだけでは足りないことでした。先に「入力をどう保存し、いつ処理し、何を画面へ返すか」を分けると、プラットフォーム固有の部品を交換できます。

例えばAeonでは、SwiftUIをJetpack Composeへ置き換える作業と、Core Graphicsの描画を android.graphics.Canvas へ置き換える作業は別です。Mvmtでは再生画面より、バックグラウンド再生を担うサービスの設計が重要です。降り忘れ忘れくんでは、地図の見た目より、アプリを閉じた後も入域を受け取る経路が中心になります。

アプリ守りたい機能Android側の主な構成
Aeon時刻ごとの合成結果とMP4書き出しCompose、Canvas、MediaCodec、MediaMuxer、ML Kit
Mvmt端末内ライブラリとバックグラウンド再生Compose、Room、Media3 / ExoPlayer、MediaSessionService
降り忘れ忘れくん登録地点への入域通知と腕時計からの操作Geofencing、osmdroid、OpenStreetMap、Wearable Data Layer
給与管理勤務入力、給与試算、端末内保存Compose、SharedPreferences、JSON、独立した計算ロジック
確認日: 2026年7月30日。この記事は各リポジトリの既定ブランチにあるREADMEと実装を照合した開発記録です。Android版のストア公開状況や、iOS版との完全な動作一致を示すものではありません。

移植前に三つへ分けた

どのアプリでも、実装を次の三層として見直しました。

  1. 変えたくないデータとルール:レイヤー、曲、地点、勤務記録などのモデルと、並び順・時刻判定・計算方法
  2. OSへ依存する処理:動画エンコード、音楽再生、位置監視、地図、通知、端末内ストレージ
  3. 操作画面:SwiftUIまたはComposeで、同じ目的へ到達できる入力と状態表示

画面から先に写すと、一見よく似たところまでは早く進みます。しかし、アプリがバックグラウンドへ移った瞬間や、動画を書き出す瞬間に差が現れます。そこで、モデルと純粋な判定を先に置き、OS機能をアダプターとして接続してからCompose画面を載せました。

Aeon: SwiftUIの移植だけでは動画は作れない

Aeonは、動画、画像、テキスト、図形をレイヤーとして重ね、位置・拡大率・回転・不透明度へキーフレームを設定するモーショングラフィックスエディタです。iOS版の画面はSwiftUIですが、合成の中心はCore Graphicsベースの CompositionRenderer です。

iOS実装は、同じ render 関数をプレビューの UIGraphicsImageRenderer と、書き出し用 CVPixelBuffer 上の CGContext から呼びます。Android版でもこの境界を残し、Composeの画面とは別に android.graphics.Canvas ベースのコンポジターを作りました。

同じ時刻なら、プレビューと書き出しで同じ描画関数を呼ぶ

Android側のレンダラーは、プロジェクト、時刻、Canvas、動画フレーム、画像供給関数を受け取ります。背景を塗り、最背面からレイヤーをたどり、各レイヤーの位置・回転・拡大率・反転・不透明度を適用します。テキスト、画像、動画、図形の描画に加え、クリッピング、ブレンド、影、マスク、線形・円形グラデーションもこの経路へ集めています。

Composeはエディタの状態と操作UIを担当しますが、完成画像の画素をComposeの部品へ依存させていません。そのため、720pxを上限とするプレビューBitmapと、指定解像度の書き出しフレームが同じレイヤー評価を通ります。

再生はChoreographer、動画フレームはMediaMetadataRetriever

プレビュー時刻は Choreographer のフレームコールバックで進めます。動画レイヤーは MediaMetadataRetriever から対象時刻のフレームを取得し、音声レイヤーと動画音声は MediaPlayer で同期させます。iOS側の再生クラスを一つのAndroid APIへ置換するのではなく、「表示時刻」「動画の画」「聞こえる音」を別々のAndroid機能へ割り当てた形です。

CanvasからH.264 + AACのMP4を作る

書き出しでは、各フレームの時刻を計算し、動画素材の該当フレームを集め、共通レンダラーでARGB_8888のBitmapへ描きます。その画素をBT.601のYUV420 Flexibleプレーンへ変換し、H.264用 MediaCodec の入力バッファへ渡します。

エンコーダーが返した映像サンプルにはフレーム番号から計算したPTSを付け、MediaMuxer のMP4トラックへ書き込みます。音声は複数レイヤーを16bitステレオPCMへ混ぜ、音量を反映してAAC-LCへエンコードした後、同じMP4へ格納します。iOS版の AVAssetWriter とコードは異なっても、「時刻ごとに合成し、映像と音声を一つのファイルへする」という約束は同じです。

VisionはML Kit Subject Segmentationへ

被写体の自動選択は、iOSのVision相当としてML Kit Subject Segmentationを使います。Android実装は enableForegroundBitmap() を指定し、得られた前景Bitmapを透過PNGへ圧縮します。画像を独自サーバーへ送る処理はなく、リポジトリではPlay services経由のオンデバイス処理として構成されています。

同じJSON構造でも、素材デコーダーは同じではない

Android版は filesDir/Projects/<UUID>/project.json、素材、サムネイルを保存し、UUIDを文字列、日時をepoch millisとしてiOS版と同じ構造のモデルを保ちます。一方、Androidの画像デコーダーではPSDを直接読まないため、PNG、JPEG、WebPなどへの変換を前提にしています。「保存形式が似ている」ことと「全素材を同じように開ける」ことは別です。

Mvmt: 再生ボタンより、再生を誰が所有するか

Mvmtは、音声ファイルを取り込み、曲とプレイリストを端末内で管理するローカルファーストの音楽プレイヤーです。iOS版はSwiftDataと AVQueuePlayer、Android版はRoomとMedia3 / ExoPlayerへ役割を分けています。

Roomは曲・プレイリスト・所属関係を別テーブルにする

Android版には songsplaylistsplaylist_songs の三つのEntityがあります。曲にはタイトル、アーティスト、アルバム、ファイル名、再生時間、再生回数などを持たせ、プレイリストとの多対多関係は位置付きの中間テーブルへ保存します。DAOが返す Flow をViewModelが購読するため、データ更新とCompose画面をつなげられます。

音源ファイルとメタデータを分けて保存する

Document Pickerで選んだファイルはアプリ専用の filesDir/media へコピーし、ジャケットは filesDir/artwork、曲情報はRoomへ保存します。アカウントやバックエンドはなく、共有やバックアップを利用者が明示しない限り、ファイルとメタデータはアプリのサンドボックスに残ります。

動画から音声を取り出す処理は、MediaExtractor で最初の音声トラックを選び、その圧縮サンプルを MediaMuxer でM4Aへ詰め直しています。この経路には再エンコード用 MediaCodec がないため、「動画を音声へ変換する」という画面上の機能を、実装上は高速なトラック抽出として成立させています。

MediaSessionServiceが画面の外で再生を持つ

PlaybackServiceMediaSessionService を継承し、その中にExoPlayerとMediaSessionを作ります。Compose画面は MediaController でサービスへ接続し、曲キュー、シーク、シャッフル、リピート、0.5〜2.0倍速を操作します。

再生主体をActivityから分離したことで、画面を閉じてもサービス側が再生を管理し、メディア通知とロック画面操作へつなげられます。iOSのバックグラウンドオーディオを「Composeのライフサイクル」で再現しようとせず、Androidのメディアセッションへ所有権を渡すことが移植の要点でした。

降り忘れ忘れくん: MapKitを地図だけで置き換えない

降り忘れ忘れくんは、駅・バス停・任意地点と反応半径を登録し、入域時に通知するアプリです。iOS版のMapKit、Region Monitoring、Apple Watchを、Androidでは別々の地図・位置監視・Wear OS機能へ割り当てました。

地図表示はosmdroid、検索はAndroid Geocoder

Compose画面の中へosmdroidの MapViewAndroidView として組み込み、OpenStreetMapのMAPNIKタイルを表示します。地点には種類別のMarkerと200〜1,000mの円を重ね、地図タップから任意地点を追加できます。画面上にはOpenStreetMapへの帰属表示も置いています。

地点名検索と逆ジオコーディングはAndroid Geocoderへ委ねます。検索結果を日本の範囲へ絞り、最大20件にしています。公開Nominatimへ検索文字を逐次送るオートコンプリートではなく、端末に構成されたGeocoderバックエンドを使う構成です。したがって、Apple Mapsと検索順位やPOIデータまで同じになるわけではありません。

監視上限20件を、OS設定ではなくアプリのルールにする

GeofenceSelection は、監視ONの地点を作成日時の新しい順に並べ、先頭20件を選びます。この判定をGoogle Play servicesから切り離した純粋な関数にしたため、JVMテストで選択順と上限を確認できます。Android Geofencing自体の上限ではなく、元アプリと同じ運用を保つための20件です。

選ばれた地点は、期限なしの円形GeofenceとしてENTERとEXITの両方を登録します。退出を受け取ることで、次回の入域通知を再び有効にできます。同期時は新しい登録を先に追加し、成功後に不要なIDだけを削除します。追加が失敗した場合は1秒後に一度だけ再試行します。

正確な位置情報とバックグラウンド位置情報が揃わない場合は登録しません。端末再起動とアプリ更新後には保存済み地点から再同期し、入域はBroadcastReceiverで受け取ります。全日、午前、午後、日付をまたぐ任意時間帯の判定と、設定時の30秒後再通知は別の通知ロジックとして扱っています。

Wear OSは状態同期と操作命令を分ける

スマートフォンは全地点をJSONへ変換し、Wearable Data Layerの /stations DataItemとして送ります。腕時計はその一覧を端末内にもキャッシュし、地点ごとの監視ON/OFFを楽観的に更新します。

接続中の切り替えは /toggle_station/{id} Messageとしてスマートフォンへ送り、接続先がない場合や送信失敗時はurgent DataItemへキューします。常に最新状態として同期するデータと、一回の操作命令を分けることで、オフライン時にも操作を捨てない構成です。

給与管理: 計算をCompose画面から外す

4本目は、勤務シフトから給与を試算し、月額・直近12か月・連続超過のリスクを確認するアプリです。Android版はKotlin / Composeで画面を再構築し、勤務先、シフト、支給確定額、テーマ、言語を一つのJSONとしてSharedPreferencesへ保存します。

給与計算は PayrollCalculator へ分離しました。勤務時間、休憩、有給、日曜・深夜・時間外の設定、交通費、手当、控除からシフト単位を計算し、月ごとに集計します。Compose画面やAndroidの保存APIを使わない関数にすることで、日付をまたぐ勤務や集計をJVMテストから確認できます。

このアプリの判定は入力値にもとづく目安です。扶養認定を含む制度上の最終判断を行うものではなく、所属先の共済組合や規程の確認が必要です。この記事でも基準額の正しさや個別ケースへの適用を保証しません。

この移植で残したかったのは特定の画面配置ではなく、「入力した勤務を同じ計算ルールで集計できること」です。端末内保存と計算を分ける設計は、制度や画面が変わったときにも計算部分を検証し直しやすくします。

4本で共通した対応表

責務iOS側Android側そのまま移さなかった理由
宣言的UISwiftUIJetpack Compose状態の持ち方は似ていても、OS部品とライフサイクルが異なる
2D合成Core Graphicsandroid.graphics.Canvas動画の完成フレームはUI階層から独立させる必要がある
動画書き出しAVAssetWriterMediaCodec + MediaMuxer入力バッファ、色形式、トラック作成の手順が異なる
音楽再生AVQueuePlayerMedia3 / ExoPlayer + MediaSessionServiceAndroidではサービスがバックグラウンド再生を所有する
構造化データSwiftDataなどRoom、JSON、SharedPreferences検索・関係が必要なデータと、小さな一括設定を分けて選ぶ
地図MapKitosmdroid + OpenStreetMapタイル、検索、帰属表示を別々に設計する必要がある
入域監視Core Location Region MonitoringGoogle Play services Geofencing権限、再登録、遅延条件がプラットフォームごとに異なる
腕時計連携Apple WatchWear OS Data Layer最新状態の同期と一回の操作命令を分ける必要がある

共通化できたのは、プロジェクトや曲、地点のモデル、時刻や上限を判断する純粋なルール、ローカルファーストという保存方針です。一方、コーデック、バックグラウンド実行、位置権限、地図データはプラットフォームに合わせて作り直しました。

実際に使った移植の順序

  1. 同じであるべき結果を書く:同じ時刻の合成画像、同じ曲順、同じ監視対象、同じ勤務集計などを先に決める
  2. データモデルを移す:ID、日時、列挙値、並び順、ファイルとの関係を明示する
  3. 純粋なルールを分離する:キーフレーム補間、監視地点選択、時間帯判定、給与計算をOS APIの外へ置く
  4. Androidの実行主体を決める:Canvas、MediaSessionService、Geofencing、Data Layerなどへ責務を割り当てる
  5. 最後にComposeで操作をつなぐ:画面が閉じても続く処理をActivityへ抱え込ませない
  6. 差分を仕様として書く:PSD非対応、地図検索差、権限、通知遅延などを「未完成」で隠さない

この順序なら、画面が完成する前からモデルとルールをテストできます。また、Android特有の制約が見つかったときも、製品の中心機能を変えるのか、プラットフォーム差として説明するのかを判断しやすくなります。

限界と、この記事からは言えないこと

  • リポジトリに「完全移植」「同一仕様」とある場合も、バイナリや画素、検索結果、通知時刻がiOS版と完全一致する意味ではありません。
  • Aeonの動画エンコードは端末のMediaCodecを使います。コードには応答待ちのタイムアウトと解像度を下げる案内があり、端末ごとの性能を同一とは扱っていません。
  • ML Kitの被写体選択はPlay servicesを使う構成です。全端末で同じ切り抜き結果になることや、PSDを直接扱えることは保証しません。
  • Mvmtの動画音声抽出は、圧縮済み音声サンプルを別コンテナへ移す実装です。あらゆる動画・音声コーデックを再エンコードして互換化する処理ではありません。
  • Geofencing通知は、Google Play services、バックグラウンド制限、端末メーカーの省電力設定により数分遅れる場合があります。正確な位置情報、常時位置情報、通知の権限も必要です。
  • OpenStreetMapとApple Maps、Android GeocoderとMapKitでは、地図の外観、POI、検索順位が異なります。
  • 給与管理の試算は制度上の認定ではありません。最新の規程や個別条件は、利用者自身が確認する必要があります。
  • この記事はコード構造の確認であり、全対応端末での長期運用試験、ストア審査、一般公開の完了を示しません。