コネヒト開発者ブログ

コネヒト開発者ブログ

Jetpack ComposeのPreview、どう管理と活用してる?

この記事はコネヒトAdvent Calendar 2024 07日目の記事です。

コネヒトのエンジニアやデザイナーやPdMがお送りするアドベントカレンダーです。

コネヒトは「家族像」というテーマを取りまく様々な課題の解決を目指す会社で、
ママの一歩を支えるアプリ「ママリ」などを運営しています。

adventar.org


こんにちは、コネヒトAndroidエンジニアの中島(id:nacatl)です。 早いものでコネヒトにジョインしてもう1年半近くなりました。 今回はママリAndroidアプリにおけるJetpack ComposeのPreviewについて紹介いたします。

背景

ママリAndroidアプリでは、昨今のAndroid開発事情に漏れずJetpack Composeを取り入れ始めています。 新しく開発する箇所は、画面単位でなくても、例えばRecyclerViewのListItem単位などからも置き換えているところです。

さらにごく最近ではNavigation Composeの採用も始めています。 詳しくは同Advent Calendarの04日目の記事をご覧ください。

tech.connehito.com

さて、Composeといえば、開発中にPreviewを活用されている方が多いと思います。

xmlによるPreviewに比べ、Columnなどで並べることでStateの違いにおける表示を比較しやすくなっています。 さらに、ライト/ダーク設定や文字サイズ設定をはじめとして、Previewアノテーションの引数によってさまざまな設定が扱える点もあります。 総合的に取り回しが向上し、整備することでUIにおける一種のユニットテストのように扱えると感じています。

一方で、PreviewもKotlinで書く以上、Previewコードの量と置き場に課題を感じています。 手軽さで言えば、実態のコードと同じファイルに置きがちです。 しかし、そうするとPreviewのビルドの影響か、増えすぎるとAndroid Studioのパフォーマンスに影響が出ることがあります。 加えて上述のようにユニットテストのように扱おうとすると、サンプルにするStateも増えていくのでより顕著になります。

この記事では、これらの課題に対してのママリAndroidでのComposeのアプローチ、およびその先の活用をご紹介します。

ママリAndroidでの現状

前提として、執筆時点で Android Studio Ladybug | 2024.2.1 Patch 2Kotlin 2.0.21を利用しています。

Previewコードの管理

1. ファイルを分ける

まずは基本という感じで、ファイルを分割します。 ママリAndroidでは、.previewとsuffixを追加することでどのComposeのPreviewかを示しています。

  • FeatureFooItem.kt
  • FeatureFooItem.preview.kt

最初はこれだけでも十分効果を発揮します。 同階層に置けばComposeのvisibilityもinternalなどに絞りやすくなります。 しかし、Compose置換が進みファイルが増えるにつれ、プロジェクトのファイルツリーが長くなり煩わしさも出てきます。

2. File Nestingを活用する

IntelliJ IDEAには、ルールを指定してファイルをまとめてネスティングできる機能があります。

www.jetbrains.com

IntelliJ IDEAにある機能ということは、つまりAndroid Studioでも使える機能でもあります(もちろん基になったバージョンは確認必要ですが)。

この機能は、筆者がFlutterを開発しているときによく利用していました。 当時、DartにおいてKotlin data class相当の機能を扱うためにfreezedを利用しておりました。 freezedはAnnotationProcessorのように、data class的な機能やJsonパーサー相当の機能などの部分をコード生成してくれるパッケージです。 この生成されたコードは foo.dart に対して foo.freezed.dart foo.g.dartなどといった別ファイルで形成され、自動生成であるため開発者自身が編集することはありません。 普段は参照しない、名前の似たファイルがツリーに常駐してしまう煩わしさがどうしてもあったため、これらをまとめるために重宝していました。

これをComposeのPreviewファイル管理に応用します。

QuestionShow.preview.ktをQuestionShow.ktに、QuestionShowAnswer.preview.ktをQuestionShowAnswer.ktにそれぞれネスティングした結果のファイルツリーのスクリーンショット
File Nesting によるファイルツリー管理

これでPreviewファイルを折りたためるようになり、同階層に並べて一覧性を維持しつつ、ツリーの圧迫も緩和されました。 インデントが付くことで見分けやすくもなります。

Previewコードの活用

管理がひと段落したところで、活用の方に移ります。

最初に前提説明ですが、ママリAndroidは接続環境の分岐を Product Flavor の dev prod で管理しています。 社内にテスト配布する dev には、variant専用のコードとして開発用画面のActivityを追加しています。

結論から言うと、この開発用ActivityとしてPreviewコードをそのまま流用したサンプルコンポーネント画面の整備を進めています。 目的としては以下の二つです。

  1. いわゆるモックデータでのUI実装を、リリースコードのUI層と切り離しつつ、サーバー側の開発と並行して行える
    • 表示だけなら素のPreviewだけで十二分ですが、実機上の確認をしたい場合に既存UIと隔離して表示できるのは便利です
  2. 非エンジニアメンバーが配布されたアプリでPreviewを確認できる
    • そもそもこの拡張をしようと思ったきっかけです。実際の操作では作りにくいパターンなどの表示も確認できるため、開発中の連携を強められます

縦向きライトモードのスクリーンショット縦向きダークモードのスクリーンショット
コンポーネントサンプル(縦向き)

横向きライトモードのスクリーンショット横向きダークモードのスクリーンショット
コンポーネントサンプル(横向き)

PreviewComponentActivity ~ ComponentPreviewRoute

ベースとなるActivity ~ Route Composeでは、あまり特殊なことはしていません。 表示したいPreviewを選択表示できる、ダークモードなどの設定を変更できるように、よしなにStateおよびUIを組み立てるだけです。 強いて言えばNow in Androidを参考に、リリースコードに先行してEdgeToEdgeやアダプティブレイアウト対応を軽く取り込んでいる程度です。

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            // ダーク設定remember
            val defaultDark = isSystemInDarkTheme()
            var isDarkTheme by rememberSaveable { mutableStateOf(defaultDark) }

            DisposableEffect(isDarkTheme) {
                enableEdgeToEdge(
                    ~~~~,
                )
                onDispose {}
            }
            ComponentPreviewRoute(
                isDarkTheme = isDarkTheme,
                windowAdaptiveInfo = currentWindowAdaptiveInfo(),
                onDarkThemeChanged = { isDarkTheme = it },
                onBackPressed = { onBackPressedDispatcher.onBackPressed() },
            )
        }
    }

各Previewの工夫

基本的にはそのまま呼び出せると言っても、多少手を加える必要はありました。

1. MaterialThemeで括りなおしてしまうとDarkThemeが切り替わらない

普通にPreviewを作る場合、Previewアノテーションを付けたComposableにそのままThemeから全部書くことが多いと思います。 ですが、Preview用ビルドだけでなく外からも呼び出せるようにする場合、Themeで括ってしまっていると呼び出し側のTheme設定を上書きしてしまいます。 これを回避するにはPreview用の本体と、アノテーションの付いたTheme呼び出しを切り分けます。

// FeatureFooItem.preview.kt

internal val sampleFeatureFoo = FeatureFoo(id = 1, ~~)

/**
 * Preview表示用ビルドはこれを参照する
 */
@Preview(
    heightDp = PreviewHeight, // LazyColumnにできるようにした関係もあり、この辺もアプリ全体で少し整備しています
    ~~,
)
@Composable
private fun FeatureFooItemPreviewBase() {
    MaterialTheme(
        isDarkTheme = isSystemInDarkTheme(),
    ) {
        FeatureFooItemPreview(
            modifier = Modifier.height(PreviewHeight),
            baseFeatureFoo = sampleFeatureFoo,
        )
    }
}

/**
 * プレビュー描画の実態。開発用Activityからの呼び出しではこちらを使う
 */
@Composable
internal fun FeatureFooItemPreview(
    modifier: Modifier = Modifier,
    baseFeatureFoo: FeatureFoo,
) {
    val sampleList = listOf(
        sampleFeatureFoo,
        sampleFeatureFoo.copy( /* 並べて見たい別パターンになるようフィールドを変更 */ ),
        ~~,
    )
    LazyColumn(
        modifier = modifier,
    ) {
        items(sampleList.size) { index ->
            FeatureFooItem( ~~ )
        }
    }
}
2. 基本のサンプルデータを編集できるようにする

前セクションで書いたように、サンプルとなるデータはPreviewコードのファイルに用意しています。

これらのデータを開発用ActivityのUiStateに持たせますが、さらにViewModelの持つStateFlowの形で保持しています。 Flowにする理由としては、実機上でサンプルデータを変更可能にする目的です。 これは我々Androidエンジニアの開発というよりは、特にPdMやデザイナー、非エンジニアの方々の検証目的に役立ちます。

サンプルデータの編集UIのスクリーンショット
コンポーネントサンプル(編集UI)

// FeatureFooPreviewViewModel.kt

    val uiState: StateFlow<FeatureFooPreviewUiState>
        field = MutableStateFlow(FeatureFooPreviewUiState())

    // 表示するPreviewの切り替え
    fun changeComponent(component: FeatureFooPreviewComponent) =
        uiState.update { it.changeComponent(component) }

    // ベースとなる基本サンプルの編集
    fun updateBaseFeatureFoo(featureFoo: FeatureFoo) =
        uiState.update { it.updateBaseFeatureFoo(featureFoo) }

    // リセット
    fun initBaseFeatureFoo() =
        uiState.update { it.initBaseFeatureFoo() }
@Stable
data class FeatureFooPreviewUiState(
    val currentComponent: FeatureFooPreviewComponent = FeatureFooPreviewComponent.ITEM_A,
    val baseFeatureFoo: FeatureFoo = defaultFeatureFoo,
) {
    fun changeComponent(component: FeatureFooPreviewComponent) = copy(
        currentComponent = component,
    )

    fun updateBaseFeatureFoo(featureFoo: FeatureFoo) = copy(
        baseFeatureFoo = FeatureFoo,
    )

    fun initBaseFeatureFoo() = updateBaseFeatureFoo(defaultFeatureFoo)
}

// Compose側でPreview funの呼び出しを切り替えるためのenum
enum class FeatureFooPreviewComponent : PreviewComponent {
    ITEM_A,
    ITEM_B,
    ITEM_C,
    ;
}

利点

このPreview表示の開発用Activityを運用して良かった点を紹介します。

  • 開発中のUIテスト的利用
    • Previewの領域を超えるような数でも、LazyColumnなどでスクロールしながら確認できる
    • ボタンなどユーザーアクションも合わせてテストできる(適当にToast表示などをするだけでも配置ミス確認になります)
    • アダプティブレイアウト対応の実機テストにも転用できる
    • 画面が存在するようになるため、MagicPod等のUIテストサービスにも転用できる
  • 非エンジニアとのコミュニケーション
    • Firebase App Distributionでの配布を通じて、サーバーに依存せず、実機で手軽にPdM / デザイナーへの表示別確認などが行える
    • サンプルデータの編集まで実装できればより確認が捗る

前者については特に複雑な表示分岐仕様があるパーツに対してAndroidView→Composeに置換する際の利点が大きかったと感じます。 まだ始めて日が浅い事もあり後者についてはまだ周知し始めているところですが、「確認しやすくて便利」という声を頂き始めています。今後も浸透させていきたいです。

欠点

はっきり言ってしまえば相応のコーディング工数はあります。

表示に関する工数

Preview自体はそもそもこの活用がなくても作るでしょうし、後からの追加はenumへの追加程度で済みます。 なので、この段階では最初の画面作成 / Previewへの変更周りが主な工数となります。

編集に関する工数

各サンプルデータそれぞれについて編集UIを作る必要があるので、こちらに関してはそれなりに見込む必要があります。 ママリAndroidでも、現状では隙間を縫って実装を進めているような状況です。

if ルート

半分おまけで、現状では採用していませんが「こういうのもいいのでは?」と考えている草案も軽く紹介します。

previewコードの管理

File Nestingの欠点

File Nestingの活用によってファイル分割で起きる問題はほぼ解決します。 ですが、Android Studioのインターフェース面で地味に影響があります。

ダブルクリックで親ファイルが開かない

通常のディレクトリと同じように、ダブルクリックはツリーの collapse / expand に消費されます。 回避策はワンクリックでフォーカスを当てた後にenterです、もしくはファイル検索など経由で開けばいつも通りです。 ただやはり、咄嗟にダブルクリックする癖がいまだに抜けていません。

ネストの子ファイルに対し、Select Opened Fileが効かない

正直、これ不具合では??と思っていますが、効かないものはしょうがありません。 これについては、親ファイルに対して実行するしかないかと思われます。

3. devビルドのディレクトリに置く

File Nestingは確かに便利なのですが、上述のようにちょっとクセがあります。 代替案として現状ぼんやり考えているのは、「開発用Activity用に書いたComposeと同様にdev配下に置いてしまう」ことです。 そもそもリリースビルドには影響しないファイルですし、妥当性は感じます。

ただこの手法では、package上は同一になりますが実際の置き場は離れてしまいますのでツリー上での一覧性は劣ります。

どうするにしろ、トレードオフは何かしら発生するかなとも感じています。

おわりに

今回は、ママリAndroidにおけるCompose Previewの管理方法と活用について紹介させていただきました。

紹介した方法が正解かどうかははっきり言って「わかりません」。 今後も色々思いついたらチーム内で相談し、検証していきたいと思っています。 この記事が皆様のComposeライフをより豊かにする一助となれば幸いです。