Jetpack Compose 画面全体のプレビューを出すにはどうしたら良い?

Jetpack Composeで画面全体のデザインとロジックを作り込むとき、プレビューを表示しつつ開発することで効率が上る可能性があります。しかし、プレビューは、ネットワークアクセスやファイルアクセス、Contextの利用が一部制限されます。そのため、プレビューに表示するデータはFakeデータやViewModelを用意して、プレビュー表示時のみの振る舞いを実現します。

その一方で、FakeViewModelの実装や管理にもコストがかかってしまいそうです。今回の記事はFakeViewModelを使うべきか、使わないべきときはなんなのかをまとめます。

FakeViewModel とは

そもそもFakeViewModelとは?MVVMでアプリを開発している場合を想定します。UIとModelのやりとりを連絡するViewModelを定義して、ビジネスロジックの呼び出しやネットワークや永続化システムなど外部システムとの連携をViewModel経由で行うとUIのコードに業務知識含まれる問題を解消できます。しかし、Jetpack Composeの場合はプレビューでネットワーク通信などはできません。そこで、常に静的なデータをレスポンスするFakeViewModelを用意してプレビュー表示時に活用します。

例えば、芸術家一覧を表示する画面を想定します。ViewModelではWebAPIを介して芸術家一覧を取得、UIで選択動作がが行われたら選択された芸術家を保持します。ViewModelのInterfaceとFakeViewModelは次のようになるでしょう。

interface ArtistListViewModel {
    // 外部システムから取得した芸術家一覧
    val artists: State<List<Artist>>
    // ユーザーがUI操作をして選択をした芸術家
    val selectedArtist: State<Artist?>
    // ユーザーがUIを操作して芸術家を選んだら呼び出す
    fun onSelectArtist(artist: Artist)
}

// プレビュー表示用のViewModel。外部システムと連携しないで、静的データを返す
class FakeArtistListViewModel: ViewModel(), ArtistListViewModel {
    override val artists: State<List<Artist>> = run {
        val artist = (0 until 10).map {
            Artist(
                name = "artist $it",
                icon = "https://placekitten.com/g/720/720",
                article = "https://placekitten.com/g/1024/1024"
            )
        }
        val state = mutableStateOf(artist)
        state
    }

    override val selectedArtist = mutableStateOf<Artist?>(null)

    override fun onSelectArtist(artist: Artist) {
        selectedArtist.value = artist
    }
}

このViewModelを使って画面に表示する関数と、プレビュー用関数を用意します。

@Preview(showSystemUi = true)
@Composable
private fun ArtistListScreenPreviewWithViewModel() {
    // FakeViewModelインスタンスを使って、画面を確認する
    PreviewExampleTheme {
        ArtistListScreen(viewModel = FakeArtistListViewModel())
    }
}

@Composable
fun ArtistListScreen(
    viewModel: ArtistListViewModel
) {
    val selectedArtist by viewModel.selectedArtist
    val artists by viewModel.artists
    val errorMessage by viewModel.errorMessage
    val onClickArtist: (Artist) -> Unit = viewModel::onSelectArtist
    Scaffold {
        Column(
            modifier = Modifier
                .padding(32.dp)
                .verticalScroll(rememberScrollState())
        ) {
            if (errorMessage != null) {
                Text(
                    text = errorMessage,
                    style = Typography.body2.copy(color = Color.Red)
                )
            }
            artists.forEach { artist ->
                Spacer(modifier = Modifier.height(12.dp))
                ArtistCard(
                    artist = artist.toViewData(),
                    isSelected = artist.name == selectedArtist?.name,
                    onClick = {
                        onClickArtist(artist)
                    }
                )
            }
        }
    }
}

FakeViewModelを使って画面にプレビューが表示されました。

f:id:numanuma08:20220414231343p:plain
プレビュー

問題発生

なんの問題も無さそうです。しかし大変なことが起きました。無名芸術家のnuma氏が「ぷにるはかわいいスライム」を無理やり布教したため、芸術界を追放されました。もし、彼を選択肢た場合エラーを表示しなければなりません。

www.corocoro.jp

ViewModelのインターフェースにプロパティを追加します。

interface ArtistListViewModel {
    val errorMessage: State<String?> // 追加

class FakeArtistListViewModel: ViewModel(), ArtistListViewModel {
    override val artists: State<List<Artist>> = run {
        val artist = (0 until 10).map {
            Artist(
                name = "artist $it",
                icon = "https://placekitten.com/g/720/720",
                article = "https://placekitten.com/g/1024/1024"
            )
        } + listOf(
            Artist(
                name = "numa", icon = "https://placekitten.com/g/720/720",
                article = "https://placekitten.com/g/1024/1024"
            )
        )
        val state = mutableStateOf(artist)
        state
    }

    override val errorMessage = mutableStateOf<String?>(null) // 追加

    override fun onSelectArtist(artist: Artist) {
        selectedArtist.value = artist
        // 追加
        if (artist.name == "numa") {
            errorMessage.value = "numa is not artist"
        }
    }

このViewModelを使ってエラーの表示を確認する場合、次の手順を取ります。

  1. プレビューのIntractive Modeを使って、エラーが発生する要素を選択する
  2. プレビューの中で、onSelectArtistを呼び出してエラーを発生させる

1のIntractive Modeを使えば、プレビュー画面を操作できます。しかし、レイアウトの確認でIntractive Modeを使うとなると、プレビューを使うメリットが薄れます。2のように、選択動作を明示的に呼び出す方法もありますが、複雑な操作の場合コードも複雑になりますし、やはりレイアウトのプレビューなのにコード中に動作が入り込むのは、なんだかプレビューのメリットが薄くなっている気がします。

そこで、画面表示用の関数を修正してViewModelに依存しないパターンを作ってみます。

ViewModelに依存しないパターン

ViewModelに依存させないためには、必要な要素をすべて引数に指定します。

data class ArtistsScreenData(
    val selectedArtist: Artist?,
    val artists: List<Artist>,
    val errorMessage: String?,
    val onClickArtist: (Artist) -> Unit
)

class FakeArtistScreenDataProvider : PreviewParameterProvider<ArtistsScreenData> {
    private val artists = (0 until 10).map {
        Artist(
            name = "artist $it",
            icon = "https://placekitten.com/g/720/720",
            article = "https://placekitten.com/g/1024/1024"
        )
    }

    override val values: Sequence<ArtistsScreenData> = sequenceOf(
        ArtistsScreenData(
            selectedArtist = artists[2],
            artists = artists,
            errorMessage = null,
            onClickArtist = {}
        ),
        ArtistsScreenData(
            selectedArtist = null,
            artists = artists,
            errorMessage = null,
            onClickArtist = {}
        ),
        ArtistsScreenData(
            selectedArtist = artists[2],
            artists = artists,
            errorMessage = "error message",
            onClickArtist = {}
        )
    )
}

@Composable
fun ArtistListScreen(
    viewModel: ArtistListViewModel
) {
    val selectedArtist by viewModel.selectedArtist
    val artists by viewModel.artists
    val errorMessage by viewModel.errorMessage
    val onClickArtist: (Artist) -> Unit = viewModel::onSelectArtist
    ArtistListScreen(ArtistsScreenData(selectedArtist, artists, errorMessage, onClickArtist))
}

@Composable
private fun ArtistListScreen(
    artistsScreenData: ArtistsScreenData
) {
    Scaffold {
        Column(
            modifier = Modifier
                .padding(32.dp)
                .verticalScroll(rememberScrollState())
        ) {
            if (artistsScreenData.errorMessage != null) {
                Text(
                    text = artistsScreenData.errorMessage,
                    style = Typography.body2.copy(color = Color.Red)
                )
            }
            artistsScreenData.artists.forEach { artist ->
                Spacer(modifier = Modifier.height(12.dp))
                ArtistCard(
                    artist = artist.toViewData(),
                    isSelected = artist.name == artistsScreenData.selectedArtist?.name,
                    onClick = {
                        artistsScreenData.onClickArtist(artist)
                    }
                )
            }
        }
    }
}

@Preview(showSystemUi = true)
@Composable
private fun ArtistListScreenPreviewWithoutViewModel(
    @PreviewParameter(provider = FakeArtistScreenDataProvider::class) data: ArtistsScreenData
) {
    PreviewExampleTheme {
        ArtistListScreen(data)
    }
}

この方法を使うと、PreviewParameterが使えるためエラーメッセージの表示やリスト画空の場合、選択された状態などのレイアウトをまとめてプレビューで確認できます。

レイアウトを確認するためなら、FakeVewModelは使わないほうが良さそうです。

FakeViewModelを使う場合

FakeViewModelを使わないプレビューの確認方法が分かりました。では、FakeViewModelはもう使わないほうが良いのでしょうか?私は完全に使わないほうが良い、とは思ってません。例えば、Intractive Modeを使えばNavigationによる画面遷移もプレビュー上でテストできます。その場合、各画面にはViewModelが挿入されるため、Fakeを使っていれば偽データを使った画面遷移の確認が可能です。また、エミュレーターにプレビューをデプロイした場合はネットワークアクセスができるため単体での動作確認が困難な画面(たとえばログイン後1回しか表示されないチュートリアルとか、特定の条件を満たさないと開けない入力フォームなど)を開発する場合はプレビューとエミュレーターにデプロイする機能を活用すると、開発中のアプリ操作が減って効率が上がる気がします。

まとめ

FakeViewModelを使わないで画面全体のレイアウトをプレビューする方法を考えました。また、FakeViewModelを使うべきパターンとして、Navigationによる画面遷移やそもそも画面を開くことが難しい画面の実装作業があると分かりました。

適切な場所に適切な選択をして、効率よく開発を行いたいですね。