🫡

Jetpack Compose の @Preview を簡素化するためのモジュール分割アプローチ

2024/12/09に公開

この記事は、GENDA Advent Calendar 2024 9日目の記事です。
https://qiita.com/advent-calendar/2024/genda


はじめに

Jetpack Composeの@Preview機能は、UIのデザインや動作を素早く確認するための便利なツールです。

しかし、階層構造が複雑なアプリケーションでは、@Previewで使用するデータを準備する負担が増え、開発者にとって課題となることがあります。

特に以下のような悩みを抱える方も多いのではないでしょうか。
• 階層構造を持つComposable関数ごとに、テスト用データを準備する手間がかかる
• データクラスに変更が入るたびに、@Preview関数の修正が必要になる
• データクラスを作成するために、本来不要なimport文が増える

こうした課題を解決するため、本記事では@Previewをスリムにする工夫として、パッケージ分割のアプローチを試してみました。

Jetpack Composeを活用した効率的なUI開発を目指している方に、少しでも参考になれば幸いです。


なぜマルチモジュール化だと考えたのか

表示用のデータを準備するだけであれば、単一のモジュールで十分です。
ただ、データクラスが増えてくると、肥大化してしまい、コードの見通しが悪くなりそうですし、
プロダクトコードとも関連がないデータクラスが増えてしまうため、なるべく分離したいと考えました。

そこで、データクラスや関数を別のモジュールに分割することで、以下のようなメリットがあります。
• コードの見通しが良くなる
• データクラスや関数の再利用性が高まる
• @Preview関数の修正がしやすくなる


具体例の紹介

説明のために下記のような画面を作成しました。

また、この画面を作成するために、以下のようなデータクラスを用意しました。

data class Monster(
    val id: Int,
    val name: String,
    val types: List<MonsterType>,
    val imageUrl: String,
    val hp: Int,
    val attack: Int,
    val defense: Int,
    val specialAttack: Int,
    val specialDefense: Int,
    val speed: Int,
    val moveList: List<String>,
    val abilityList: List<String>
)

画面を構成するComposable関数は以下の通りです。
※冗長な部分は省略しています。

@Composable
fun MonsterDetails(monster: Monster) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.White)
            .padding(16.dp)
            .verticalScroll(
                rememberScrollState(0)
            )
    ) {
        // モンスターの画像
        MonsterImage(monster, modifier = Modifier.padding(bottom = 16.dp))
        // モンスターの番号と名前
        NumberAndName(monster.id, monster.name, modifier = Modifier.padding(bottom = 8.dp))
        // タイプ
        TypeRow(monster.types, Modifier.padding(bottom = 16.dp))
        // ステータス
        Stats(monster, modifier = Modifier.padding(bottom = 16.dp))
        // とくせい
        Ability(monster.abilityList, modifier = Modifier.padding(bottom = 16.dp))
        // おぼえるわざ
        MoveList(monster.moveList)
    }
}

そして、本題である@Preview関数は以下のようになります。

@Composable
@Preview
private fun MonsterDetailsPreview() {
    MonsterDetails(
        monster = Monster(
            id = 1,
            name = "イシノツブテ",
            types = listOf(MonsterType.ROCK, MonsterType.GROUND),
            imageUrl = "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhdW2bw5XFelBMMNMSx4_PST670AxRputfVYU_24fkGYmNeXerrd48CYIgE70vrT711Lwa5Xlgk1DqRSGjxfTZgpq26ky_TV5BazG_rhuMGufQgFFTBJE30qt04EHrTMRe2FqemO0Bk-bM/s800/monster11.png",
            hp = 40,
            attack = 80,
            defense = 100,
            specialAttack = 30,
            specialDefense = 30,
            speed = 20,
            moveList = listOf(
                "たいあたる",
                "まるまる",
                "どろをかける",
                "ころころ",
                "いわおとす",
                "ステルスブロック",
                "ロッククラッシュ",
                "じばくする",
                "すなまきこむ",
                "いわふり",
                "だいちのゆれ",
                "ストーンスラッシュ",
                "ばくはつする"
            ),
            abilityList = listOf("いしのあたま", "けんろう", "すなひそみ")
        )
    )
}

実際のアプリでもより複雑なデータを扱う場合は、このようにデータを準備するのは大変です。
(今回はmoveListで行数を稼いでいてちょっとずるい気もしますが、より実際のアプリの表示を再現するためには、このようなデータが必要になることもあるかと思います。)


パッケージの構成

今回は3つのパッケージに分割しました。

  • app

    • 画面の表示に関するコードを格納
    • domain-modelとpreview-dummyに依存
  • preview-dummy

    • @Preview関数で使用するテスト用データを格納
    • domain-modelに依存
  • domain-model

    • データクラスを格納
    • 依存関係なし

実装後のコード

ダミークラスの実装は以下の通りです。
Companion objectを使用して、dummyプロパティを定義しています。

package com.oukoda.preview_dummy

import com.oukoda.domain_model.Monster
import com.oukoda.domain_model.MonsterType

object MonsterDummy {
    val Monster.Companion.dummy: Monster
        get() = Monster(
            id = 1,
            name = "イシノツブテ",
            types = listOf(MonsterType.ROCK, MonsterType.GROUND),
            imageUrl = "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhdW2bw5XFelBMMNMSx4_PST670AxRputfVYU_24fkGYmNeXerrd48CYIgE70vrT711Lwa5Xlgk1DqRSGjxfTZgpq26ky_TV5BazG_rhuMGufQgFFTBJE30qt04EHrTMRe2FqemO0Bk-bM/s800/monster11.png",
            hp = 40,
            attack = 80,
            defense = 100,
            specialAttack = 30,
            specialDefense = 30,
            speed = 20,
            moveList = listOf(
                "たいあたる",
                "まるまる",
                "どろをかける",
                "ころころ",
                "いわおとす",
                "ステルスブロック",
                "ロッククラッシュ",
                "じばくする",
                "ありじごく",
                "いわふらし",
                "だいちのゆれ",
                "ストーンスラッシュ",
                "ばくはつする"
            ),
            abilityList =
            listOf("いしのあたま", "けんろう", "すなひそみ")
        )
}

プレビュー関数の実装は以下の通りです。

import com.oukoda.preview_dummy.MonsterDummy.dummy
@Composable
@Preview
private fun MonsterDetailsPreview() {
    MonsterDetails(monster = Monster.dummy)
}

結論

「はじめに」であげた課題は解消できました。

@Preview関数の中身がスッキリし、データの準備にかかる手間を減らせました。

しかし、このままだとプロダクトコードとの分離ができていません。
preview-dummyモジュールをdebugImplementationで依存させること考えていましたが、
Releaseビルドができなくなってしまうため、切り離せませんでした。

今後は、この問題を解決する方法を模索していきたいと思います。

最後まで読んでいただき、ありがとうございました!

Discussion