この記事は Manuel Vivo による Android Developers - Medium の記事 " Migrating Architecture Blueprints to Jetpack Compose " を元に翻訳・加筆したものです。詳しくは元記事をご覧ください。
アプリ アーキテクチャ ガイドを最新化する作業の一環として、異なる UI パターンを使って実験し、最適に動作する方式や各方式間の類似点や相違点を確認して、最終的にはそこから得られた教訓をベスト プラクティスとしてまとめたいと考えています。
そこで発見したことをできる限り容易に理解していただくには、おなじみのビジネスケースを扱う複雑すぎないサンプルが必要でした。そう考えると、最適な題材は TODO アプリでしょう。そこで、アーキテクチャ ブループリントを選択しました。これまで、このブループリントは、アーキテクチャを選択する際の実験場として使われてきました。この点でも、まさにうってつけだと言えるでしょう。
実際のアーキテクチャ ブループリント アプリ
今回試したいパターンは、現在利用できるさまざまな API から明らかな影響を受けています。今回の新入りは、Jetpack Compose の State API です。Compose は、どんな単方向データフローともシームレスに連携できるので、UI のレンダリングに Compose を使うことで、公正な比較ができるようにします。
このブログ投稿では、どのようにしてアーキテクチャ ブループリントを Jetpack Compose に移行したかをお伝えします。LiveData もこの実験の代替手段と考えることができるので、このサンプルは移行時の状態のままにしています。今回のリファクタリングでは、ViewModel クラスとデータレイヤーには手を加えませんでした。
⚠️ この LiveData ベースのコードベースで使ったアーキテクチャは、最新のアーキテクチャ ベスト プラクティスに完全に従ってはいません。特に、LiveData はデータレイヤーやドメイン レイヤーで使うべきではありません。代わりにフローやコルーチンを使うようにしてください。
背景が明らかになったので、ブループリントを Jetpack Compose にリファクタリングした手法の説明に入りましょう。完全なコードは、dev-compose ブランチで確認できます。
提案された変更点が全員の共通認識となるように、実際 のコーディング作業を始める前に、移行計画を作成しました。最終的な目的は、ブループリントを単一アクティビティ アプリにすることです。その際に、画面はコンポーズ可能な関数にして、画面間の移動には推奨の Compose Navigation ライブラリを使います。
ありがたいことに、ブループリントはすでに単一アクティビティ アプリになっており、Jetpack Navigation を使ってフラグメントで実装された画面間を移動しています。これを、Navigation の相互運用性ガイドに従いながら、Compose に移行します。このガイドで推奨されているのは、フラグメントベースの Navigation コンポーネントを使うハイブリッド アプリです。そこでフラグメントを使い、ビューベースの画面、Compose の画面、ビューと Compose の両方を使う画面を格納します。残念ながら、同じナビゲーション グラフでフラグメントと Compose の遷移先を混在することはできません。
段階的移行の目的は、コードレビューを簡単にし、移行の全段階でプロダクトが公開できる状態を維持することです。移行計画は、3 つのステップに分かれています。
これで完了です。🧑💻 ここで 2 週間早送り ⏩ します。この段階で、 統計 画面(PR)、 タスクの追加と編集 画面(PR)、 タスク詳細 画面(PR)、そして タスク一覧 画面(PR)の移行と、最終 PR のマージが完了しています。最終 PR は、未使用のビューシステムへの依存関係の削除を含め、ナビゲーションとアクティビティのロジックを Compose に移行したものです。
ブループリントを Compose に段階的に移行する方法
移行を進めるにあたり、Compose に特有ないくつかの動作に対応する必要がありました。その点について説明します。
アプリに Compose を追加すると、Compose UI を確認するテストには Compose テスト API が必要になります。
画面レベルの UI テストでは、launchFragmentInContainer<FragmentType> API の代わりに、テストの文字列リソースを取得できる createAndroidComposeRule<ComponentActivity> API を使いました。このようなテストは、Espresso でも Robolectric でも実行できます。Compose はすでにこの両方をサポートしているので、追加の変更は必要ありません。それを確認してみたい方は、移行前の AddEditTaskFragmentTest と移行後の AddEditTaskScreenTest のコードを比較してみてください。ComponentActivity を使う場合は、androidx.compose.ui:ui-test-manifest アーティファクトへの依存関係を追加する必要がある点に注意しましょう。
エンドツーエンド テストや結合テストには、何の問題もありませんでした。Espresso と Compose の相互運用性のおかげで、Espresso のアサーションでビューをチェックし、Compose API で Compose UI をチェックすることができます。Compose への移行作業中の AppNavigationTest はこちらです。
問題になったのは、ブループリントが ViewModel イベントを扱う方法でした。ブループリントはイベント ラッパー ソリューションを実装しており、それを使って ViewModel から UI に コマンド を送信していました。しかし、Compose ではこの仕組みは動作しません。最新のガイドでは、このような「イベント」を状態としてモデリングすることが推奨されています。そこで、移行の際に実施しました。
例として、 画面にメッセージを表示する というイベントのユースケースを取り上げます。ここでは、LiveData の Event<Int> 型を Int? に置き換えました。これは、ユーザーに表示するメッセージがない場合をモデリングすることにもなります。この特定のユースケースの ViewModel では、メッセージが表示される場合、必ず UI での確認操作も必要になります。次のコードは、両方の実装の差分です。
/* Copyright 2022 Google LLC. SPDX-License-Identifier: Apache-2.0 */class AddEditTaskViewModel( private val tasksRepository: TasksRepository) : ViewModel() {- private val _snackbarText = MutableLiveData<Event<Int>>()- val snackbarText: LiveData<Event<Int>> = _snackbarText+ private val _snackbarText = MutableLiveData<Int?>()+ val snackbarText: LiveData<Int?> = _snackbarText+ fun snackbarMessageShown() {+ _snackbarText.value = null+ }}
SPDX-License-Identifier: Apache-2.0 */
class AddEditTaskViewModel(
private val tasksRepository: TasksRepository
) : ViewModel() {
- private val _snackbarText = MutableLiveData<Event<Int>>()
- val snackbarText: LiveData<Event<Int>> = _snackbarText
+ private val _snackbarText = MutableLiveData<Int?>()
+ val snackbarText: LiveData<Int?> = _snackbarText
+ fun snackbarMessageShown() {
+ _snackbarText.value = null
+ }}
UI のコードでイベントが一度だけ処理されることを保証するには、event.getContentIfNotHandled() を呼び出します。このアプローチは、フラグメントでは うまくいきそう ですが、Compose では動作しません。Compose では、再コンポーズがいつ起きるかわからないので、イベント ラッパーは有効なソリューションではありません。イベントが処理されて関数が再コンポーズされると(このアプローチをテストする際に、よく起きる現象です)、スナックバーがキャンセルされるので、ユーザーはメッセージを見逃してしまうかもしれません。これは許容できない UX の問題です。Compose アプリでは、イベント ラッパー ソリューションを使うべきではありません。
次の 変更前 (イベント ラッパー)と 変更後 (状態としてのイベント)のコード スニペットをご覧ください。画面にメッセージを表示するのは UI ロジック であることと、この画面コンポーザブルが複雑になってきたことから、単純な状態ホルダークラスを使ってその複雑さに対処することにしました。その例として、次の AddEditTaskState をご覧ください。
Copyright 2022 Google LLC. SPDX-License-Identifier: Apache-2.0 */// FRAGMENTS CODE CONSUMING THE EVENT WRAPPER SOLUTION- class AddEditTaskFragment : Fragment() {- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {- …- viewModel.snackbarText.observe(- lifecycleOwner,- Observer { event ->- event.getContentIfNotHandled()?.let {- showSnackbar(context.getString(it), Snackbar.LENGTH_SHORT)- }- }- )- }- } // COMPOSE CODE CONSUMING USER MESSAGES AS STATE// State holder for the AddEditTask composable.// This class handles AddEditTask's UI elements' state and UI logic.+ class AddEditTaskState(...) {+ init {+ // Listen for snackbar messages+ viewModel.snackbarText.observe(viewLifecycleOwner) { snackbarMessage ->+ if (snackbarMessage != null) {+ // If there's a previous message showing on the screen+ // stop showing it in favor of the new one to be displayed+ currentSnackbarJob?.cancel()+ val snackbarText = context.getString(snackbarMessage)+ currentSnackbarJob = coroutineScope.launch {+ scaffoldState.snackbarHostState.showSnackbar(snackbarText)+ viewModel.snackbarMessageShown()+ }+ }+ }+ }
// FRAGMENTS CODE CONSUMING THE EVENT WRAPPER SOLUTION
- class AddEditTaskFragment : Fragment() {
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- …
- viewModel.snackbarText.observe(
- lifecycleOwner,
- Observer { event ->
- event.getContentIfNotHandled()?.let {
- showSnackbar(context.getString(it), Snackbar.LENGTH_SHORT)
- }
- )
// COMPOSE CODE CONSUMING USER MESSAGES AS STATE
// State holder for the AddEditTask composable.
// This class handles AddEditTask's UI elements' state and UI logic.
+ class AddEditTaskState(...) {
+ init {
+ // Listen for snackbar messages
+ viewModel.snackbarText.observe(viewLifecycleOwner) { snackbarMessage ->
+ if (snackbarMessage != null) {
+ // If there's a previous message showing on the screen
+ // stop showing it in favor of the new one to be displayed
+ currentSnackbarJob?.cancel()
+ val snackbarText = context.getString(snackbarMessage)
+ currentSnackbarJob = coroutineScope.launch {
+ scaffoldState.snackbarHostState.showSnackbar(snackbarText)
+ viewModel.snackbarMessageShown()
+ }
リファクタリングをしていると、そこにあるものを すべて Compose に移行したくなってしまうかもしれません。それでもまったく問題はありませんが、アプリのユーザー エクスペリエンスや「正しさ」を犠牲にすべきではありません。段階的に移行する重要な理由は、アプリを公開できる状態を保ち続けることにあります。
私たちのチームでそれが起きたのは、一部の画面を Compose に移行しているときです。同時にたくさんの移行をしたくはなかったので、一部の画面を Compose に移行 してから 、イベント ラッパーを移行しました。Compose でイベント ラッパーを扱って最善でないエクスペリエンスを提供することは避け、他の画面のコードを Compose に移した後も、このメッセージはフラグメントで処理し続けました。例として、移行中の TasksFragment の状態をご覧ください。
すべてが想定どおり順調に進んだわけではありません。🫤 フラグメントの内容を Compose に変換するのは簡単でしたが、ナビゲーション フラグメントを Navigation Compose に移行する作業には、多少の時間と検討が必要でした。
今後の Compose への移行を簡単にするさまざまな側面について、ガイドの拡張や改善が必要となります。今回の作業をきっかけに議論が始まったので、近日中に新しいガイドができることを期待しています!🎊
私はナビゲーションについてあまり詳しくない ✋ 状態で Navigation Compose への移行を担当しましたが、次のような問題点に直面することになりました。
ナビゲーション フラグメントから Navigation Compose への移行は楽しい作業でした。おかしなことに、プロジェクトの移行自体よりも、ピアレビューの待ち時間の方が長くなりました。移行計画を作成したことと、全員の共通認識を合わせたことで、早い段階で期待する内容を定め、長いレビューが待っていることを知らせることもできました。
今回紹介した Compose への移行の手法が皆さんの役に立つことを期待しています。また、アーキテクチャ ブループリントで今後行う予定の実験や改善についても、さらにお伝えしたいと思っていますので、ご期待ください。
Compose コードを含むブループリントを見てみたい方は、dev-compose ブランチをご覧ください。また、段階的移行の PR を順番に確認したい方のために、一覧を記載します。
👋