Stanby Tech Blog

求人検索エンジン「スタンバイ」を運営するスタンバイの開発組織やエンジニアリングについて発信するブログです。

スタンバイアプリの既存UIをXMLからComposeへ

プロダクト部 AppグループでAndroidアプリ開発を担当している山越です。 現在、Appグループでは既存アプリのUIをXMLレイアウトからJetpack Composeへ段階的に移行しています。 本記事では、「Compose化」を進める背景や目的、実際の進め方、そして移行の中で直面した課題・クラッシュ事例についてご紹介します。

「Compose化」とは?

Jetpack Composeとは、Android向けの新しい宣言的UIフレームワークです。

従来のXMLレイアウトでは、UIの見た目と動作を別々のファイルで管理する必要があり状態更新のたびにViewを直接操作する必要がありました。 一方のComposeでは、UIをKotlinコード上で直接定義することとなり、アプリの状態(State)に応じて自動的にUIを再描画することが可能となります。

「Compose化」とは、こうしたComposeの考え方を既存アプリにも取り入れて、XMLやViewBindingを使っていた画面を段階的にComposeへ移行する取り組みを指します。

スタンバイでは、UIの保守性・再利用性・開発効率の向上を目的として、既存画面のCompose化に着手しました。

移行作業の進め方

今回の移行作業では、全画面を一気に置き換えるのではなく、段階的に移行する方針を取りました。 新規機能は最初からComposeで実装して、既存画面は影響度の低いところからCompose化を進めています。 これにより影響範囲を最小限に抑えながらComposeへの知見を積み重ねることができました。

XMLレイアウトのリソースをComposeへ

移行の第一歩として、既存のXMLレイアウトをCompose化しました。 画面全体を一気にCompose化するのではなく、ButtonやCardといった汎用的なUIコンポーネント単位での置き換えから始めています。

Compose化した画面は、Fragment上でComposeViewを利用して組み込む方法をとっています。

class SampleFragment: Fragment() {

    // ViewModel参照
    private val viewModel: SampleViewModel by viewModels()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View = ComposeView(requireContext()).apply {
        setViewCompositionStrategy(
            ViewCompositionStrategy.DisposeOnLifecycleDestroyed(
                viewLifecycleOwner
            )
        )

        setContent {
            // Compose化した画面
            SampleScreen(
                uiState = viewModel.uiState,
                onClickButton = { viewModel.onClickButton() }
            )
        }
    }
}

こうすることで、FragmentやViewModelの構造は活かしつつ部分的にComposeを導入することで移行のリスクを抑えています。

既存Viewとの共存

Compose化の途中では、XMLとComposeが混在する期間は避けられません。

そのため、以下のような点を意識して両者を共存させました。

  • テーマおよびカラー定義をMaterialThemeとXMLスタイルで統一
  • ViewModelは共通で利用して、StateFlowをcollectAsState()で監視

この結果、既存プロダクトの安定性を保ちながらCompose化を進めることができました。

つまづいたところ

汎用コンポーネントの対応を経て画面単位のCompose化を行なっていましたが、スムーズに進まないケースがいくつか発生しました。 共通して直面した課題に対してどのような手法をとって対応してきたかをご紹介します。

テーマ設定やレイアウト崩れ

Compose化を進める中で、既存テーマとの整合性でいくつか課題がありました。

既存アプリではthemes.xmlstyles.xmlで定義したカラーやフォントサイズを利用していました。 一方、Compose側では MaterialTheme を経由する必要があるため、色味や余白が一致しないケースもありました。

スタンバイでは、Figma上にデザイン定義が整理されているため細かい調整は不要でしたが、実装上ではXMLリソースをComposeに寄せる過程で小さなずれが多く発生しました。 特にTextStyleの設定はMaterialTheme配下に再定義し、colorResourceを活用して既存リソースとの不整合を解消しました。

また、再利用可能なUI部品はdesignsystemパッケージにまとめる構成を採用しました。 このパッケージではボタンやテキストフィールドなどの共通UIコンポーネントを定義して、各画面ではそれらを呼び出してUIを構築しています。

この構成によりアプリ全体のデザインを統一しつつ、デザイン修正を一箇所で反映できるようになりました。 また、Figma上のデザイン定義とComposeコードの対応関係も整理され、Compose化によるUIのばらつきを防ぐことができています。

プレビューとの乖離

Jetpack Composeにはプレビュー機能があり、コンポーネント単位で描画されるUIを即座に確認できます。 Composeのプレビュー機能は非常に便利な反面、実際のAndroid端末上の挙動と異なる場合があります。

特にViewModel経由で状態を受け取る画面や、LocalContext・MaterialThemeに依存するComposableでは、 プレビュー上でスタイルが反映されなかったり、クラッシュしたりするケースがありました。

そのためプレビュー用にダミーのUiStateを渡す「@Preview関数」を用意し、テーマも本番と同じAppThemeを適用して確認するよう意識しました。

それでも最終的な見た目や動作はエミュレータおよび実機確認で差分を吸収するようにしています。

思わぬクラッシュの発生

Compose化完了後の画面を実機またはエミュレータで動かすと、クラッシュすることがありました。 Android Studio上では警告やエラーなどの問題は表示されていません。

今回、Appグループが直面したものから2点のクラッシュを抜粋して対処方法も合わせてご紹介します。

Columnの入れ子構造による高さ非制限

とある画面のCompose化を進めていく中で、Columnの入れ子構造を持つ画面表示の際にクラッシュが発生しました。 この画面では親要素がColumn、その中にLazyVerticalGridを配置しておりwrapContentHeight()を指定していました。

Column {
    LazyVerticalGrid(
        columns = GridCells.Fixed(2),
        modifier = Modifier
            .fillMaxWidth()
            .wrapContentHeight(), // コンテンツのサイズに応じた高さにしたい
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(items = targetItems, key = { it.id ?: it.hashCode() }) { item ->
            // 子要素のComposable
        }
    }
}

この場合、Columnは縦方向に無限スクロール可能とみなされます。 そして、内部のLazyVerticalGridも高さを計算しようとして「無限サイズを要求する形」になり、 描画時に下記の例外が発生しました。

IllegalStateException: Vertically scrollable component was measured with an infinite height constraints

これは、親と子の両方がスクロール可能(または高さ非制限)な構成になっていたことが原因です。 対応として、内部のLazyVerticalGridModifier.heightIn(max = 200.dp)を付与して高さの上限を明示することで無限再帰的な計測を防ぎました。

Column {
    LazyVerticalGrid(
        columns = GridCells.Fixed(2),
        modifier = Modifier
            .fillMaxWidth()
            .wrapContentHeight()
            .heightIn(max = 200.dp), // 高さの上限指定を追加
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(items = targetItems, key = { it.id ?: it.hashCode() }) { item ->
            // 子要素のComposable
        }
    }
}

heightIn()を使うことでComposeの測定制約を明確にし、描画処理が安定するようになりました。

バックグラウンド復帰時の例外発生

もうひとつのクラッシュは、アプリのバックグラウンド復帰時に発生したものでした。

とある状態を保持するためにScreen内でrememberSaveableを利用していましたが、保持していたオブジェクトが画面再生成時に正しく復元できず IllegalStateException: MutableState cannot be saved が発生しました。

val type = rememberSaveable { mutableStateOf<SampleType?>(null)}

rememberSaveableでは保存できる型が限られており、ParcelableまたはSerializable、もしくはSaverで明示的に変換できるものだけが対象です。 今回のケースでは、保存対象のデータクラスがParcelableを実装しておらず、復帰時に型不一致として例外がスローされていました。 対応として、Saverを実装して保存対象を明示することで解決しました。

// SampleTypeを保持するためのSaver
private val SampleTypeSaver = Saver<SampleType, Map<String, String>>(
    // 保存するときはMap<String, String>型に変換
    save = { sampleType ->
        when (sampleType) {
            is SampleType.TypeA ->
                mapOf("type" to "A")

            is SampleType.TypeB ->
                mapOf("type" to "B")

            is SampleType.TypeC ->
                mapOf(
                    "type" to "C",
                    "key" to sampleType.value
                )

            else -> mapOf()
        }
    },
    // 復元するときはSampleTypeに変換
    restore = { map ->
        when (map["type"]) {
            "A" -> SampleType.TypeA

            "B" -> SampleType.TypeB

            "C" -> SampleType.TypeC(map["key"].toString())

            else -> null
        }
    }
)

~

// stateSaverに作成したSampleTypeSaverを割り当てる
val type = rememberSaveable(stateSaver = SampleTypeSaver) { mutableStateOf<SampleType?>(null)}

このように、Composeは状態のスコープが明確である一方で、 Activity再生成やプロセスキル時に永続化対象を誤るとクラッシュしやすいため注意が必要です。

完全な「Compose化」までの課題点

Compose化は着実に進んでいますが、完全な移行にはまだいくつかの課題が残っています。

現在の主な課題は「DeepLink経由での画面遷移処理」と「トップ画面におけるボトムナビゲーションによるタブ遷移」です。

現状、これらの部分は既存のFragmentをベースに動作しており、Compose Navigation への置き換えにはさらなる検討が必要です。 特にルーティング管理やデータの受け渡し、DeepLink起動時にの初期タブの制御やバックスタックの再構築など、 既存のナビゲーション構造とCompose側の遷移管理をどう共存させるかが課題となっています。

ボトムナビゲーションについても、画面再生成やタブ切り替え時の状態保持をCompose側でどこまで担うかを整理する必要があります。

今後はこれらの遷移処理をCompose Navigationに統一し、アプリ全体を完全なComposeベースへ移行することを目標としています。

まとめ

Compose化を進めていく中で、開発効率やUI設計の柔軟性といった多くのメリットを実感できました。 特に、UIをKotlinコード上で完結できる点や、状態管理をViewModelと密に連携できる点は大きな強みです。 また、共通UIをdesignsystemというパッケージを作成してまとめることで画面ごとの見た目の統一や再利用性も向上しました。

一方で、導入初期はCompose特有のレイアウト制約や状態保持の仕組みによるクラッシュなど、これまでのXMLベースとは異なる観点でのトラブルシューティングが必要でした。 また、部分的なCompose導入ではXMLとの共存コストも発生し、完全な移行にはまだ時間がかかると感じています。 それでも、UI実装のシンプルさや開発体験の改善は大きく、長期的に見ればCompose化のメリットが明確に上回ると実感しています。

機会があれば今後の改善や完全移行についてもご紹介させていただければと存じます。

スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com