柔軟で最新の Android アプリケーション アーキテクチャ
Android アーキテクチャの原則を学ぶ: ルールを盲目的に従うのではなく、原則を学びましょう。
この記事は、Android アーキテクチャをデモンストレーションすることによって、実際のアプリケーションを例によって説明することを目的としています。最も重要なことは、これはさまざまなアーキテクチャ上の決定がどのように行われるかを示すことを意味します。状況によっては、考えられる答えが複数あることもありますが、そのいずれの場合も、一連のルールを機械的に暗記するのではなく、原則に依存します。
それでは、一緒にアプリを構築しましょう。
これから作るアプリの紹介
惑星観測者向けのアプリを構築します。おおよそ次のようになります。
私たちのアプリケーションには次の機能があります。
- 発見されたすべての惑星のリスト
- 発見されたばかりの新しい惑星を追加する方法
- 惑星を削除する方法 (発見が実際には望遠鏡のレンズの汚れにすぎないと気付いた場合に備えて)
- いくつかのサンプル惑星を追加して、アプリがどのように動作するかをユーザーに理解してもらいます。
データベースへのオンライン アクセスだけでなく、オフライン データ キャッシュも備えています。
いつものように、私のステップバイステップ ガイドでは、標準から逸脱することをお勧めします。機能を追加し、将来の仕様変更の可能性を検討し、自分自身に挑戦してください。ここでの学習では、コード自体だけではなく、コードの背後にある思考プロセスに焦点を当てます。したがって、このチュートリアルを最大限に活用したい場合は、ただ闇雲にコードをコピーしないでください。
最終的に得られるリポジトリ リンクは次のとおりです。
https://github.com/tdcolvin/PlanetSpotters
使用するアーキテクチャ原則を紹介します
私たちは、SOLID 原則、明確なアーキテクチャ原則、Google のモダン アプリケーション アーキテクチャ原則からインスピレーションを受けます。
私たちはアプリ (特に予想されるアプリの成長) に適したものを構築するのに十分賢明であるため、これらの原則を厳格なルールとして扱いません。たとえば、宗教のような明確なアーキテクチャに従えば、堅牢で信頼性が高く、スケーラブルなソフトウェアを作成できますが、単一目的のアプリケーションにはコードが複雑すぎる可能性があります。Google の原則によりコードは単純になりますが、アプリがいつか複数の大規模な開発チームによって保守される可能性がある場合には、あまり適切ではありません。
Google のトポロジから始めて、途中で明確なアーキテクチャからインスピレーションを得ます。
Google のトポロジは次のとおりです。
このアーキテクチャを段階的に実装し、私の最近の記事で各部分をさらに詳しく見てみましょう。ただし、簡単な概要としては次のとおりです。
UI层(UI Layer)
UI 層はユーザー インターフェイスを実装します。それは次のように分けられます。
- UI 要素。これらはすべて、画面上に描画するために使用される独自のコードです。Android では、主な選択肢は Jetpack Compose (この場合は
@Composables
ここに配置) または XML (この場合は XML ファイルとリソースをここに含めます) です。 - 状態保持者の場合、ここで優先する MVVM/MVC/MVP などのトポロジを実装します。このアプリケーションでは、ビュー モデルを使用します。
ドメイン層
ドメイン層は、高レベルのビジネス ロジックを含むユースケースに使用されます。たとえば、惑星を追加したい場合、AddPlanetUseCase
それに必要な手順が説明されています。これは、「どのように」ではなく「何を」するかの連続です。たとえば、「惑星オブジェクトのデータを保存する」とします。これは高度なコマンドです。「ローカル キャッシュに保存する」とも言いませんし、ましてや「Room データベースを使用してローカル キャッシュに保存する」とも言いません。これらの下位レベルの実装の詳細は別のところで説明します。
データ層
Google は、アプリ内のデータの信頼できる唯一の情報源、つまり、完全に「正しい」バージョンのデータを取得する手段を用意することを推奨しています。これがデータ層によって提供されるものです (ユーザーが入力した内容を記述するデータ構造を除くすべて)。それは次のように分けられます。
- データ型を管理するリポジトリ。たとえば、発見された惑星に対して CRUD (作成、読み取り、更新、削除) 操作を提供する惑星データのリポジトリがあります。また、データがローカル キャッシュに保存されてリモートでアクセスされるケースも処理し、さまざまな種類の操作を実行するための適切なソースを選択し、2 つのソースにデータの異なるコピーが含まれるケースも管理します。ここでは、ローカル キャッシュの状況について説明しますが、それを実現するためにどのようなサードパーティ テクノロジを使用するかについてはまだ説明しません。
- データの保存方法を管理するデータ ソース。リポジトリが「リモート ストア X」を要求すると、データ ソースにそれを要求します。データ ソースには、Firebase や HTTP API など、独自のテクノロジーを駆動するために必要なコードのみが含まれています。
優れたアーキテクチャにより意思決定の遅れが可能になる
この段階では、アプリケーションの機能がどのようなものになるのか、またアプリケーションがデータをどのように管理するのかについての基本的な考え方がわかります。
まだ決まっていないことがいくつかあります。UI がどのようなものになるのか、あるいはそれを構築するためにどのようなテクノロジ (Jetpack Compose、XML など) を使用するのかはわかりません。ローカル キャッシュがどのような形式になるかはわかりません。オンライン データにアクセスするためにどのような独自のソリューションを使用するかはわかりません。電話、タブレット、またはその他のフォームファクターをサポートするかどうかはわかりません。
質問: アーキテクチャを定式化するには、上記のいずれかを知る必要がありますか?
答え: 必要ありません!
これらはすべて低レベルの考慮事項です (それらのコードは、明確なアーキテクチャでは最も外側になります)。これらは実装の詳細であり、ロジックではありません。SOLID の依存関係逆転原則は、それらに依存するコードを記述すべきではないことを示しています。
言い換えれば、上記のことを何も知らなくても、アプリケーション コードの残りの部分を作成 (そしてテスト!) できるはずです。上記の質問に対する答えが正確にわかったら、すでに書いたものを変更する必要はありません。
これは、設計者が設計を完成させ、関係者が使用するサードパーティ テクノロジを決定するずっと前にコード作成フェーズを開始できることを意味します。したがって、優れたアーキテクチャでは、意思決定の遅れが許容されます。(そして、コードを大量に乱雑にすることなく、そのような決定を取り消すことができる柔軟性を備えています)。
私たちのプロジェクトのアーキテクチャ図
以下は、Planet Spotter アプリを Google Topology に組み込む最初の試みです。
データ層 惑星
データのリポジトリと、ローカル キャッシュ用とリモート データ用の 2 つのデータ ソースがあります。
UI レイヤーには
2 つの状態ホルダーがあり、1 つは惑星リスト ページ用、もう 1 つは惑星追加ページ用です。各ページには独自の UI 要素のセットも含まれますが、使用されるテクノロジは今のところ未定のままです。
ドメイン層
ドメイン層を構造化する完全に有効な方法が 2 つあります。
ビジネス ロジックが繰り返されるユース ケースのみを追加します。私たちのアプリで繰り返される唯一のロジックは、惑星が追加される場所です。ユーザーは、サンプルの惑星リストを追加するときだけでなく、自分の惑星の詳細を手動で入力するときにもこのロジックを必要とします。したがって、ユースケースを 1 つだけ作成しますAddPlanetUseCase
。他のケース (惑星の削除など) では、状態所有者はリポジトリと直接対話します。
リポジトリとのあらゆるやり取りをユースケースとして追加し、状態保持者とリポジトリの間に直接的な接続が存在しないようにします。この場合、惑星の追加、惑星の削除、惑星のリストの使用例があります。
オプション #2 の利点は、明確なアーキテクチャのルールに従っていることです。しかし、個人的には、これはほとんどのアプリケーションにとって少し重いと思うので、オプション #1 を選択することが多いです。ここでもそれをやります。
これにより、次のアーキテクチャ図が得られます。
コードをどこから書き始めるか
どのようなコードから始めるべきでしょうか?
ルールは、
高レベルのコードから始めて、下に向かって進んでいくというものです。
これは、最初にユースケースを書くことを意味します。そうすることで、リポジトリ層の要件が何かがわかるからです。リポジトリに何が必要かがわかれば、データ ソースが動作するために満たす必要がある要件を記述することができます。
同様に、ユースケースはユーザーが実行できるすべてのアクションを示しているため、すべての入力と出力が UI から行われることがわかります。この情報から、状態ホルダー (ビュー モデル) を作成できるように、UI に何を含める必要があるかを理解します。次に、状態ホルダーを使用すると、どの UI 要素を記述する必要があるかがわかります。
もちろん、上級エンジニアとプロジェクト関係者が使用するテクノロジーに同意するまで、UI 要素とデータ ソース (つまり、すべての低レベル コード) の作成を無期限に遅らせることができます。
これで理論的な部分は終わりです。それでは、アプリケーションの構築を始めましょう。あなたが決断を下す際には、私があなたを導きます。
ステップ 1: プロジェクトの作成
Android Studio を開き、「アクティビティなし」プロジェクトを作成します。
次の画面で名前を付けPlanetSpotters
、他のすべては同じままにします。 依存関係注入の追加
SOLID の依存関係反転原則の適用に役立つ依存関係注入フレームワークが必要になります。ここで、私の一番の選択はHilt
、幸運なことに、Google が特別に推奨しているものでもあります。
Hilt を追加するには、次の行をルート Gradle ファイルに追加してから、これをファイルに追加しますapp/build.gradle
(ここでは Java 17 との互換性を設定しています。これは Kapt に必要で Hilt で使用されます。Android Studio Flamingo 以降が必要です)。
最後に、アノテーションを追加してクラス@HiltAndroidApp
を書き直します。Application
つまり、アプリのパッケージ フォルダー (ここ) に次の内容のファイルを作成します: ... そして、それをマニフェストに追加してインスタンス化するように OS に指示します: ... メイン アクティビティを作成したら、それに追加する必要がありcom.tdcolvin.planetspotters
ます。しかし今のところ、これで Hilt のセットアップは完了です。PlanetSpottersApplication
@AndroidEntryPoint
最後に、次の行をapp/build.gradle
追加して。 ステップ 1: ユーザーが実行できるすべてをリストし、参照
する このステップは、ユースケースとリポジトリを作成する前に必要です。ユースケースとは、ユーザーが実行できる単一のタスクであり、高レベル (方法ではなく何を) で記述されることを思い出してください。
それでは、タスクの作成を開始しましょう。これは、ユーザーがアプリケーションで実行および表示できるすべてのタスクの完全なリストです。
これらのタスクの一部は、最終的にはユースケースとしてコーディングされる予定です。(実際、クリーン アーキテクチャでは、これらすべてのタスクをユース ケースとして記述する必要があります)。他のタスクは、リポジトリ層と直接対話する UI 層によって処理されます。
ここでは書面による仕様書が必要です。UI デザインは必要ありませんが、もちろん、あれば視覚化に役立ちます。
リストは次のとおりです。
-
自動的に更新される、発見された惑星のリストを取得します。 入力
: なし
出力 :Flow<List<Planet>>
アクション : 変更が発生したときに最新の状態に保つために、リポジトリから発見された惑星の現在のリストを要求します。 -
発見された 1 つの惑星の詳細を取得します。これは自動的に更新されます。
入力: 文字列 - 取得する惑星の ID 出力
:Flow<Planet>
アクション: 指定された ID を持つ惑星をリポジトリからリクエストし、変更が発生したときに常に最新情報を得るように依頼します。 -
新しく発見された惑星
タイプを追加/編集するには:planetId:String?
- null 以外の場合、編集する惑星 ID。空の場合は、新しい惑星を追加します。name:String
- 惑星の名前distanceLy:Float
- 惑星から地球までの距離 (光年)discovered:Date
-検出日
出力: なし (例外なく完了することで成功が決定されます)
アクション: 入力から Planet オブジェクトを作成し、それをリポジトリに渡します (データ ソースに追加します)。
-
いくつかの惑星の例を追加する
入力: なし
出力: なし
アクション: リポジトリに、発見日現在時刻の 3 つの惑星の例を追加するよう依頼します: トレンザロア (300 暦年)、スカロ (0.5 暦年)、ガリフレイ (40 暦年)。 -
プラネットを削除する
入力: 文字列 - 削除するプラネットの ID 出力
: なし
アクション: 指定された ID を持つプラネットを削除するようにリポジトリに依頼します。
このリストができたので、ユースケースとリポジトリのコーディングを開始できます。
ステップ 2: ユースケースを書く
最初のステップによれば、ユーザーが実行できるタスクのリストが得られます。先ほど、ユースケースとしてタスク「惑星の追加」をコード化することにしました。(タスクがアプリケーションの異なる領域で繰り返される場合にのみユースケースを追加することにしました)。
これによりユースケースが得られます
val addPlanetUseCase: AddPlanetUseCase = …
//Use our instance as if it were a function:
addPlanetUseCase(…)
以下はAddPlanetUseCase
実装コードです。
class AddPlanetUseCase @Inject constructor(private val planetsRepository: PlanetsRepository) {
suspend operator fun invoke(planet: Planet) {
if (planet.name.isEmpty()) {
throw Exception("Please specify a planet name")
}
if (planet.distanceLy < 0) {
throw Exception("Please enter a positive distance")
}
if (planet.discovered.after(Date())) {
throw Exception("Please enter a discovery date in the past")
}
planetsRepository.addPlanet(planet)
}
}
以下は、PlanetsRepository
リポジトリが持つメソッドをリストしたインターフェースです。これについては後で詳しく説明します (特にクラスではなくインターフェイスを作成する理由)。ただし、コードがコンパイルされるように、今すぐ作成してみましょう。
interface PlanetsRepository {
suspend fun addPlanet(planet: Planet)
}
Planet
データ型は次のように定義されます。
data class Planet(
val planetId: String?,
val name: String,
val distanceLy: Float,
val discovered: Date
)
addPlanet
このメソッドは (ユースケースの関数と同様にinvoke
) として宣言されています。suspend
これは、バックグラウンド作業が含まれることがわかっているためです。将来的にはこのインターフェイスにさらにメソッドを追加する予定ですが、現時点ではこれで十分です。
ところで、なぜこのような単純なユースケースをわざわざ作成したのかと疑問に思われるかもしれません。答えは、将来的にはさらに複雑になる可能性があり、外部コードをその複雑さから隔離できるということです。
ステップ 2.1: ユースケースをテストする
ユースケースを作成しましたが、実行できません。まず、これはPlanetsRepository
インターフェイスに依存しますが、そのインターフェイスはまだ実装されていません。ヒルトはそれをどうすればいいのか分かりません。
ただし、テスト コードを作成し、偽のPlanetsRepository
インスタンスを提供し、テスト フレームワークを使用して実行することはできます。それが今あなたがすべきことです。
これはアーキテクチャに関するチュートリアルであるため、テストの詳細は範囲外であるため、このステップは演習として残されています。ただし、優れたアーキテクチャ設計により、コンポーネントを簡単にテスト可能な部分に分割できることに注意してください。
ステップ 3: データ層、書き込みPlanetsRepository
ウェアハウスの仕事は、異種のデータ ソースを統合し、それらの間の相違点を処理し、CRUD 操作を提供することであることに注意してください。
依存関係の反転と依存関係の注入の使用
クリーン アーキテクチャと依存性反転の原則 (これについては前回の投稿で詳しく説明します) に従って、内部コードのリポジトリに依存する外部コードを避けたいと考えています。こうすることで、ユース ケースやビュー モデル (たとえば) は、リポジトリ コードの変更による影響を受けません。
これは、以前にPlanetsRepository
(クラスではなく) インターフェイスとして作成した理由を説明しています。呼び出し元のコードはインターフェイスにのみ依存しますが、依存関係の注入を通じて実装を受け取ります。そこで、インターフェイスにさらにメソッドを追加し、その実装を作成します。これを と呼びますDefaultPlanetsRepository
。
<interface name>Impl
(また: 一部の開発チームは、たとえば として実装を呼び出す規則に従っていますPlanetsRepositoryImpl
。この規則は読み物としては良くないと思います。クラス名でインターフェイスが実装されている理由がわかるはずです。そのため、私はこの規則を避けています。しかし、広く使用されているので言及します。)
Kotlin Flows でデータを利用できるようにする
Kotlin Flows に触れたことがない場合は、作業を中止して、今すぐ関連資料を読んでください。彼らはあなたの人生を変えるでしょう。
https://developer.android.com/kotlin/flow
これらは、新しい結果が利用可能になると変更されるデータの「パイプライン」を提供します。呼び出し元がパイプラインをサブスクライブしている限り、変更があったときに更新を受信します。これで、少しの追加作業を行うだけで、データが更新されたときに UI が自動的に更新されるようになりました。以前と比較すると、データが変更されたことを UI に手動で通知する必要があります。
RxJava
同様のことを行うやなど、他の同様のソリューションも存在しますがMutableLiveData
、それらはフローほど柔軟で使いやすいものではありません。
よく使用されるWorkResult
クラスを追加する
WorkResult
クラスは、データ レイヤーの一般的な戻り値の型です。これにより、特定のリクエストが成功したかどうかを記述することができ、次のように定義されます。
//WorkResult.kt
package com.tdcolvin.planetspotters.data.repository
sealed class WorkResult<out R> {
data class Success<out T>(val data: T) : WorkResult<T>()
data class Error(val exception: Exception) : WorkResult<Nothing>()
object Loading : WorkResult<Nothing>()
}
呼び出し側のコードは、指定されたものが、またはobject (後者はまだ完了していないことを示します) であるかどうかをWorkResult
チェックして、リクエストが成功したかどうかを判断できます。Success
Error
Loading
ステップ 4: リポジトリ インターフェイスを実装する
上記をまとめて、PlanetsRepository
を構成するメソッドとプロパティの仕様を作成しましょう。
惑星を取得するには2つの方法があります。最初のメソッドは、ID によって単一の惑星を取得します。
fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
2 番目のメソッドは、惑星のリストを表すフローを取得します。
fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
これらの各メソッドは、それぞれ単一のデータ ソースです。毎回、ローカル キャッシュに保存されているデータを返します。これは、これらのメソッドが頻繁に実行されるケースに対処する必要があり、ローカル データの方がリモート データ ソースにアクセスするよりも高速かつ安価であるためです。ただし、ローカル キャッシュを更新する方法も必要です。これにより、リモート データ ソースからローカル データ ソースが更新されます。
suspend fun refreshPlanets()
次に、惑星を追加、更新、削除するメソッドが必要です。
suspend fun addPlanet(planet: Planet)
suspend fun deletePlanet(planetId: String)
したがって、インターフェースは次のようになります。
interface PlanetsRepository {
fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
suspend fun refreshPlanets()
suspend fun addPlanet(planet: Planet)
suspend fun deletePlanet(planetId: String)
}
データソースインターフェースを書きながらコードを書く
このインターフェイスを実装するクラスを作成するには、データ ソースがどのメソッドを必要とするかに注意を払う必要があります。LocalDataSource
と の2 つのデータ ソースがあることを思い出してくださいRemoteDataSource
。これらを実装するためにどのサードパーティ テクノロジを使用するかはまだ決定していません。また、現時点ではその必要はありません。
ここでインターフェイス定義を作成して、必要に応じてメソッド シグネチャを追加できるようにしましょう。
//LocalDataSource.kt
package com.tdcolvin.planetspotters.data.source.local
interface LocalDataSource {
//Ready to add method signatures here...
}
//RemoteDataSource.kt
package com.tdcolvin.planetspotters.data.source.remote
interface RemoteDataSource {
//Ready to add method signatures here...
}
これらのインターフェースを設定する準備ができたので、 を書くことができますDefaultPlanetsRepository
。それぞれの方法を 1 つずつ見てみましょう。
どちらのメソッドも簡単に作成できgetPlanetFlow()
、ローカル ソースからデータを返します。getPlanetsFlow()
(なぜリモート ソースではないのでしょうか? ローカル ソースはデータへの高速かつリソースの少ないアクセスのために存在します。リモート ソースは常に最新である可能性がありますが、速度が遅くなります。最新のデータが厳密に必要な場合は、 を呼び出す前に以下を使用できますgetPlanetsFlow()
。refreshPlanets()
)
//DefaultPlanetsRepository.kt
override fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>> {
return localDataSource.getPlanetsFlow()
}
override fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>> {
return localDataSource.getPlanetFlow(planetId)
}
したがって、それはLocalDataSource
のgetPlanetFlow()
sum関数に依存しますgetPlanetsFlow()
。コードをコンパイルできるように、これらをインターフェイスに追加します。
//LocalDataSource.kt
interface LocalDataSource {
fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
}
書き方refreshPlanets()
_
ローカル キャッシュを更新するには、リモート データ ソースから惑星の現在のリストを取得し、それをローカル データ ソースに保存します。(その後、ローカル データ ソースは変更を「感知」し、getPlanetsFlow()
返された をFlow
介して惑星の新しいリストを発行できます。)
//DefaultPlanetsRepository.kt
override suspend fun refreshPlanets() {
val planets = remoteDataSource.getPlanets()
localDataSource.setPlanets(planets)
}
これには、各データ ソース インターフェイスに新しいメソッドを追加する必要がありました。現在は次のようになります。
//LocalDataSource.kt
interface LocalDataSource {
fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
suspend fun setPlanets(planets: List<Planet>)
}
//RemoteDataSource.kt
interface RemoteDataSource {
suspend fun getPlanets(): List<Planet>
}
addPlanet()
関数と関数を書き込むdeletePlanet()
とき、これらはすべて同じパターンに従います。つまり、リモート データ ソースで書き込み操作を実行し、成功した場合は変更をローカル キャッシュに反映します。
リモート データ ソースは、Planet オブジェクトに一意の ID を割り当てることが期待されており、データベースに格納されると、RemoteDataSource のaddPlanet()
関数は、null 以外の ID を持つ更新された Planet オブジェクトを返します。
//PlanetsRepository.kt
override suspend fun addPlanet(planet: Planet) {
val planetWithId = remoteDataSource.addPlanet(planet)
localDataSource.addPlanet(planetWithId)
}
override suspend fun deletePlanet(planetId: String) {
remoteDataSource.deletePlanet(planetId)
localDataSource.deletePlanet(planetId)
}
最終的なデータ ソース インターフェイスは次のとおりです。
//LocalDataSource.kt
interface LocalDataSource {
fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
suspend fun setPlanets(planets: List<Planet>)
suspend fun addPlanet(planet: Planet)
suspend fun deletePlanet(planetId: String)
}
//RemoteDataSource.kt
interface RemoteDataSource {
suspend fun getPlanets(): List<Planet>
suspend fun addPlanet(planet: Planet): Planet
suspend fun deletePlanet(planetId: String)
}
ステップ 5: 状態ホルダー、PlanetsListViewModel の作成
UI レイヤーは UI 要素と状態ホルダー レイヤーで構成されていることを思い出してください。
現時点では、UI の描画にどのようなテクノロジを使用するかまだわかっていないため、UI 要素レイヤーをまだ作成できません。しかし、それは問題ではありません。一度決定したら変更する必要はないという確信を持って、ステートホルダーを書き続けることができます。これも優れたアーキテクチャの利点の 1 つです。
PlanetsListViewModel の正規
UI を作成するには 2 つのページがあり、1 つは惑星のリストと削除用、もう 1 つは惑星の追加または編集用です。PlanetsListViewModel は前者を処理します。これは、惑星リスト画面の UI 要素にデータを公開する必要があり、ユーザーがアクションを実行するために UI 要素からイベントを受け取る準備ができている必要があることを意味します。
具体的には、PlanetsListViewModel は以下を公開する必要があります。
- ページの現在の状態を説明するフロー (重要なのは惑星のリストを含めることです)
- リストを更新する方法
- 惑星を削除する方法
- ユーザーがアプリの機能を理解できるようにサンプルの惑星を追加する方法
PlanetsListUiState
オブジェクト: ページの現在の状態
ページの状態全体を 1 つのデータ クラスにカプセル化すると便利だと思います。
//PlanetsListViewModel.kt
data class PlanetsListUiState(
val planets: List<Planet> = emptyList(),
val isLoading: Boolean = false,
val isError: Boolean = false
)
このクラスをビュー モデルと同じファイルに定義していることに注意してください。これには単純なオブジェクトのみが含まれます。フローなどは含まれず、プリミティブ型、配列、単純なデータクラスのみです。すべてのフィールドにはデフォルト値があることに注意してください。これは後で役に立ちます。
(上記のクラスに Planet オブジェクトさえ必要としない理由がいくつかあります。クリーン アーキテクチャの純粋主義者は、Planet が定義される場所とそれが使用される場所の間に階層的なジャンプが多すぎることを指摘するでしょう。State Hoist の原則は、必要な正確なデータのみを提供するように指示します。たとえば、現時点では、Planet の名前と距離だけが必要なので、Planet オブジェクト全体ではなく、それのみを持つ必要があります。個人的には、これはコードを不必要に複雑にし、将来の変更をより困難にするだろうと思います。反対するのも自由です!)
したがって、このクラスを定義すると、ビュー モデル内に状態変数を作成して公開できるようになります。
//PlanetsListViewModel.kt
package com.tdcolvin.planetspotters.ui.planetslist
...
@HiltViewModel
class PlanetsListViewModel @Inject constructor(
planetsRepository: PlanetsRepository
): ViewModel() {
private val planets = planetsRepository.getPlanetsFlow()
val uiState = planets.map {
planets ->
when (planets) {
is WorkResult.Error -> PlanetsListUiState(isError = true)
is WorkResult.Loading -> PlanetsListUiState(isLoading = true)
is WorkResult.Success -> PlanetsListUiState(planets = planets.data)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = PlanetsListUiState(isLoading = true)
)
}
.stateIn(...)
で使用されるscope
およびパラメータはstarted
、この StateFlow の存続期間を安全に制限できることに注意してください。
惑星の例を追加
3 つの惑星の例を追加するには、この目的のために作成されたユースケースを繰り返し呼び出します。
//PlanetsListViewModel.kt
fun addSamplePlanets() {
viewModelScope.launch {
val planets = arrayOf(
Planet(name = "Skaro", distanceLy = 0.5F, discovered = Date()),
Planet(name = "Trenzalore", distanceLy = 5F, discovered = Date()),
Planet(name = "Galifrey", distanceLy = 80F, discovered = Date()),
)
planets.forEach {
addPlanetUseCase(it) }
}
}
更新して削除する
更新関数と削除関数は非常に似ており、対応するリポジトリ関数を呼び出すだけです。
//PlanetsListViewModel.kt
fun deletePlanet(planetId: String) {
viewModelScope.launch {
planetsRepository.deletePlanet(planetId)
}
}
fun refreshPlanetsList() {
viewModelScope.launch {
planetsRepository.refreshPlanets()
}
}
ステップ6: 書くAddEditPlanetViewModel
AddEditPlanetViewModel
新しい惑星を追加したり、既存の惑星を編集したりするための画面を管理するために使用されます。
以前と同様に、そして実際、これはどの View Model にとっても良い習慣です。UI が表示するすべてのデータ クラスを定義し、それに対する信頼できる単一のソースを作成します。
//AddEditPlanetViewModel.kt
data class AddEditPlanetUiState(
val planetName: String = "",
val planetDistanceLy: Float = 1.0F,
val planetDiscovered: Date = Date(),
val isLoading: Boolean = false,
val isPlanetSaved: Boolean = false
)
@HiltViewModel
class AddEditPlanetViewModel @Inject constructor(): ViewModel() {
private val _uiState = MutableStateFlow(AddEditPlanetUiState())
val uiState: StateFlow<AddEditPlanetUiState> = _uiState.asStateFlow()
}
(新しい惑星を追加するのではなく) 惑星を編集している場合は、ビューの初期状態が惑星の現在の状態を反映するようにしたいと考えます。
良い習慣として、この画面では編集したい惑星の ID のみを渡します。(惑星オブジェクト全体を渡すわけではありません。大きくなりすぎて複雑になる可能性があります)。Android のライフサイクル コンポーネントは を提供しておりSavedStateHandle
、そこから惑星 ID を取得し、惑星オブジェクトをロードできます。
//AddEditPlanetViewModel.kt
@HiltViewModel
class AddEditPlanetViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val planetsRepository: PlanetsRepository
): ViewModel() {
private val planetId: String? = savedStateHandle[PlanetsDestinationsArgs.PLANET_ID_ARG]
private val _uiState = MutableStateFlow(AddEditPlanetUiState())
val uiState: StateFlow<AddEditPlanetUiState> = _uiState.asStateFlow()
init {
if (planetId != null) {
loadPlanet(planetId)
}
}
private fun loadPlanet(planetId: String) {
_uiState.update {
it.copy(isLoading = true) }
viewModelScope.launch {
val result = planetsRepository.getPlanetFlow(planetId).first()
if (result !is WorkResult.Success || result.data == null) {
_uiState.update {
it.copy(isLoading = false) }
}
else {
val planet = result.data
_uiState.update {
it.copy(
isLoading = false,
planetName = planet.name,
planetDistanceLy = planet.distanceLy,
planetDiscovered = planet.discovered
)
}
}
}
}
}
次のパターンを使用して UI 状態を更新する方法に注目してください。
_uiState.update {
it.copy( ... ) }
AddEditPlanetUiState
単純な 1 行のコードで、値が前の状態からコピーされた新しい値を作成し、uiState
それをフロー経由で送信します。
この手法で地球のさまざまなプロパティを更新するために使用する関数を次に示します。
//AddEditPlanetViewModel.kt
fun setPlanetName(name: String) {
_uiState.update {
it.copy(planetName = name) }
}
fun setPlanetDistanceLy(distanceLy: Float) {
_uiState.update {
it.copy(planetDistanceLy = distanceLy) }
}
最後に、AddPlanetUseCase を使用して惑星オブジェクトを保存します。
//AddEditPlanetViewModel.kt
class AddEditPlanetViewModel @Inject constructor(
private val addPlanetUseCase: AddPlanetUseCase,
...
): ViewModel() {
...
fun savePlanet() {
viewModelScope.launch {
addPlanetUseCase(
Planet(
planetId = planetId,
name = _uiState.value.planetName,
distanceLy = uiState.value.planetDistanceLy,
discovered = uiState.value.planetDiscovered
)
)
_uiState.update {
it.copy(isPlanetSaved = true) }
}
}
...
}
ステップ 7: データ ソースと UI 要素を作成する
アーキテクチャ全体が設定されたので、最下位レベルの UI 要素とデータ ソースでコードを作成できます。UI 要素については、Jetpack Compose を使用して電話やタブレットをサポートするオプションがあります。ローカル データ ソースの場合は、Room データベースを使用するキャッシュを作成できます。また、リモート データ ソースの場合は、リモート API へのアクセスをシミュレートできます。
これらの層はできるだけ薄く保つ必要があります。たとえば、UI 要素のコードには計算やロジックが含まれておらず、ビュー モデルによって提供される状態が画面上に表示されるだけです。ロジックはビューモデルに配置する必要があります。
データ ソースの場合、インターフェイスで実装しLocalDataSource
て機能するために最小限のコードを記述するだけで済みます。RemoteDataSource
Compose や Room などの特定のサードパーティ テクノロジはこのチュートリアルの範囲外ですが、コード リポジトリでこれらのレイヤーの実装例を確認できます。
低レベルの部分を最後に残します
これらのアプリの最下位レベルの部分を最後に保存できたことに注意してください。これは、利害関係者がどのサードパーティ テクノロジを使用するか、アプリケーションをどのように表示するかを決定するのに十分な時間を確保できるため、非常に有益です。このコードを作成した後でも、アプリの残りの部分に影響を与えることなく、これらの決定を変更できます。
Githubアドレス
完全なコード リポジトリは次の場所にあります。
https://github.com/tdcolvin/PlanetSpotters。