アプリ アーキテクチャ ガイド

このガイドでは、製品版の品質を備えた堅牢なアプリを作成するためのおすすめの方法と推奨アーキテクチャを紹介します。

このページは、読者が Android フレームワークの基本について熟知していることを前提としています。Android アプリの開発経験がない場合は、デベロッパー ガイドでアプリの開発の基本を学習したうえで、このガイドで取り上げているコンセプトの詳細を確認してください。

アプリのアーキテクチャに興味があり、Kotlin プログラミングの観点からこのガイドの資料を確認したい場合は、Udacity コースの Kotlin による Android アプリの開発をご利用ください。

モバイルアプリのユーザー エクスペリエンス

デスクトップ アプリは、ほとんどの場合、パソコンまたはプログラムのランチャーのみをエントリ ポイントとし、単一のモノリシック プロセスとして実行されます。一方、Android アプリはデスクトップ アプリとは異なり、構造が非常に複雑です。標準的な Android アプリは、さまざまなアプリ コンポーネントアクティビティフラグメントサービスコンテンツ プロバイダブロードキャスト レシーバなど)で構成されています。

これらのアプリ コンポーネントのほとんどを、アプリ マニフェストで宣言しておきます。Android OS ではこのアプリ マニフェスト ファイルを基に、デバイスの全体的なユーザー エクスペリエンスにアプリをどのように組み込むかを決定します。適切に作成された Android アプリにはさまざまなコンポーネントが含まれており、ユーザーは多くの場合、短時間のうちに複数のアプリを操作することから、アプリにはユーザー主導のさまざまなワークフローやタスクに適応することが求められます。

たとえば、お気に入りのソーシャル ネットワーク アプリで写真を共有する場合に何が起こるかを考えてみましょう。

  1. アプリがカメラ インテントをトリガーし、そのリクエストを処理するために、Android OS がカメラアプリを起動します。このとき、ユーザーはソーシャル ネットワーク アプリから離れますが、この処理はユーザーから見てシームレスに行われます。
  2. カメラアプリが他のインテントをトリガーし(ファイル選択機能を起動するなど)、そのインテントがさらに別のアプリを起動する場合もあります。
  3. 最終的に、ユーザーはソーシャル ネットワーク アプリに戻り、写真を共有します。

途中で電話がかかってきたり通知を受信したりして、処理が中断されることもあり得ます。ユーザーは、こうした中断に対処した後、写真共有プロセスに戻って処理を再開できることを期待します。モバイル デバイスでは、このようなアプリの頻繁な切り替えはよくあることです。そのため、こうしたフローをアプリで正しく処理する必要があります。

また、モバイル デバイスはリソースに限りがあるので、オペレーティング システムが新たなアプリのリソースを確保するために、アプリプロセスを強制終了する場合があります。

このような環境の条件を考慮すると、アプリ コンポーネントは個別に順不同で起動され、オペレーティング システムやユーザーによって随時破棄される可能性があります。デベロッパーはこうしたイベントを制御できないため、アプリのデータや状態をアプリ コンポーネント内に保存しないでください。また、アプリ コンポーネントは互いに依存してはなりません。

アーキテクチャに関する一般的な原則

アプリのデータや状態の保存にアプリ コンポーネントを使用できないとなると、アプリをどのように設計すればよいのでしょうか。

関心の分離

最も重要な原則は、関心の分離です。すべてのコードを 1 つの Activity または Fragment に記述するのはよくある間違いです。これらの UI ベースのクラスには、UI やオペレーティング システムとのやり取りを処理するロジックのみを含めます。これらのクラスをできる限りシンプルに保つことで、ライフサイクルに関連する多くの問題を回避することができます。

ActivityFragment の実装はデベロッパーが管理するものではないことにご注意ください。これらのクラスは、Android OS とアプリ間のコントラクトを体現する単なる結合クラスです。Android OS は、ユーザーの操作に基づいて、またはシステムの状態(メモリ不足など)を理由として、いつでもこれらのクラスを破棄することができます。ユーザーの便宜を十分に図り、アプリを管理しやすくするため、こうしたクラスへの依存を最小限に抑えることをおすすめします。

UI をモデルで操作する

もう 1 つの重要な原則は、UI をモデルで操作することです。おすすめは永続モデルです。モデルとは、アプリのデータを処理するコンポーネントです。モデルはアプリの View オブジェクトやアプリ コンポーネントから独立しているため、アプリのライフサイクルや関連する問題の影響を受けません。

永続モデルが望ましい理由として、次の点が挙げられます。

  • Android OS がアプリを破棄してリソースを解放しても、ユーザーのデータが失われない。
  • ネットワーク接続が不安定または利用不可の場合でもアプリが動作し続ける。

データの管理責任が明確に定義されたモデルクラスを基盤としてアプリを作成することで、アプリのテストのしやすさと一貫性を向上させることができます。

このセクションでは、エンドツーエンドのユースケースを通じて、アーキテクチャ コンポーネントを使用したアプリの構築方法について説明します。

たとえば、ユーザー プロフィールを表示する UI を作成するとします。特定のプロフィールのデータを取得するには、プライベート バックエンドと REST API を使用します。

概要

まず、次の図をご覧ください。この図は、設計したアプリにおいて各モジュールが相互にどのようなやり取りをするかを示しています。

各コンポーネントがその 1 レベル下のコンポーネントにのみ依存することに注目してください。たとえば、アクティビティとフラグメントはビューモデルにのみ依存します。他の複数のクラスに依存するクラスはリポジトリのみです。この例のリポジトリは、永続データモデルとリモートのバックエンド データソースに依存します。

この設計により、一貫性のある優れたユーザー エクスペリエンスが実現します。ユーザーがアプリを終了してから数分後にそのアプリに戻っても、数日後に戻っても、アプリがローカルに保持しているユーザーの情報が即座に表示されます。その情報が最新でない場合、アプリのリポジトリ モジュールがバックグラウンドでデータの更新を開始します。

ユーザー インターフェースを構築する

UI は、フラグメント(UserProfileFragment)とそれに対応するレイアウト ファイル(user_profile_layout.xml)で構成されます。

UI を操作するには、データモデルで次のデータ要素を保持する必要があります。

  • ユーザー ID: ユーザーの識別子。フラグメントの引数を使用して、この情報をフラグメントに渡すことをおすすめします。Android OS がプロセスを破棄してもこの情報は保持されるため、アプリが再開されたときに同じユーザー ID を使用することができます。
  • ユーザー オブジェクト: ユーザーの詳細情報を保持するデータクラス。

これらの情報を保持するために、ViewModel アーキテクチャ コンポーネントに基づく UserProfileViewModel を使用します。

ViewModel オブジェクトは、特定の UI コンポーネント(フラグメントやアクティビティなど)のデータを提供します。また、モデルとやり取りするデータ処理のビジネス ロジックを含んでいます。たとえば ViewModel は、他のコンポーネントを呼び出してデータを読み込んだり、ユーザーからのデータ変更リクエストを転送したりできます。ViewModel は UI コンポーネントの情報を認識しないため、設定の変更(デバイスを回転させたときのアクティビティの再作成など)による影響を受けません。

現在、次のファイルが定義されています。

  • user_profile.xml: 画面の UI レイアウトの定義。
  • UserProfileFragment: データを表示する UI コントローラ。
  • UserProfileViewModel: UserProfileFragment に表示用のデータを準備し、ユーザーの操作に対応するクラス。

次のコード スニペットに、上記のファイルの初期内容を示します(簡素化のために、レイアウト ファイルは省略しています)。

UserProfileViewModel

class UserProfileViewModel : ViewModel() {
   val userId : String = TODO()
   val user : User = TODO()
}

UserProfileFragment

class UserProfileFragment : Fragment() {
   // To use the viewModels() extension function, include
   // "androidx.fragment:fragment-ktx:latest-version" in your app
   // module's build.gradle file.
   private val viewModel: UserProfileViewModel by viewModels()

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View {
       return inflater.inflate(R.layout.main_fragment, container, false)
   }
}

では、これらのコード モジュールを結び付けるにはどうすればよいでしょうか。結局のところ、UserProfileViewModel クラスの user フィールドを設定する際に、UI に通知する方法が必要になります。

user を取得するために、ViewModel が Fragment の引数にアクセスする必要があります。Fragment から引数を渡すことができますが、それよりよいのは、SavedState モジュールを使って、ViewModel が引数を直接読み取るようにすることです。

// UserProfileViewModel
class UserProfileViewModel(
   savedStateHandle: SavedStateHandle
) : ViewModel() {
   val userId : String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")
   val user : User = TODO()
}

// UserProfileFragment
private val viewModel: UserProfileViewModel by viewModels(
   factoryProducer = { SavedStateVMFactory(this) }
   ...
)

ここで、ユーザー オブジェクトが取得されたときにフラグメントに通知する必要があります。そこで出番となるのが LiveData アーキテクチャ コンポーネントです。

LiveData は監視可能なデータホルダーです。このデータホルダーを使用すると、アプリ内の他のコンポーネントでオブジェクトの変更を監視できます。しかも、それらの間の明示的かつ厳密な依存関係のパスを作成する必要がありません。また、LiveData コンポーネントはアプリのコンポーネント(アクティビティ、フラグメント、サービスなど)のライフサイクルの状態を考慮します。さらに、オブジェクト リークや過度なメモリ消費を防止するためのクリーンアップ ロジックも備えています。

LiveData コンポーネントをアプリに組み込むには、UserProfileViewModel 内のフィールド タイプを LiveData<User> に変更します。これで、データが更新されたときに UserProfileFragment に通知があります。さらに、この LiveData フィールドはライフサイクルを認識するため、不要になった参照は自動的にクリーンアップされます。

UserProfileViewModel

class UserProfileViewModel(
   savedStateHandle: SavedStateHandle
) : ViewModel() {
   val userId : String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")
   val user : LiveData<User> = TODO()
}

次に、データの監視と UI の更新を行うように UserProfileFragment を変更します。

UserProfileFragment

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)
   viewModel.user.observe(viewLifecycleOwner) {
       // update UI
   }
}

ユーザー プロフィールのデータが更新されるたびに onChanged() コールバックが呼び出され、UI が更新されます。

監視可能なコールバックを使用するその他のライブラリに詳しい方なら、フラグメントの onStop() メソッドのオーバーライドでデータの監視を停止していないことに気付いたかもしれません。LiveData ではライフサイクルを認識するため、こうした処理は必要ありません。つまり、フラグメントがアクティブな状態(onStart() を受け取り、onStop() を受け取る前の状態)でなければ、onChanged() コールバックは呼び出されません。また、フラグメントの onDestroy() メソッドが呼び出されると、LiveData によってオブザーバーが自動的に削除されます。

また、設定の変更(ユーザーによるデバイスの画面の回転など)に対処するためのロジックは何も追加していません。UserProfileViewModel は設定の変更時に自動的に復元されるため、新しいフラグメントは作成されるとすぐに ViewModel の同じインスタンスを受け取ります。また、現在のデータを使用してコールバックが直ちに呼び出されます。ViewModel オブジェクトは、更新対象の対応する View オブジェクトよりも長く生存するよう設計されているので、ViewModel の実装内で View オブジェクトを直接参照しないでください。UI コンポーネントのライフサイクルに対応する ViewModel の全期間について詳しくは、ViewModel のライフサイクルに関する説明をご覧ください。

データを取得する

ここまでで、LiveData を使用して UserProfileViewModelUserProfileFragment に関連付けました。では、ユーザー プロフィールのデータを取得するにはどうすればよいでしょうか。

この例では、バックエンドが REST API を提供することを前提としています。バックエンドへのアクセスには Retrofit ライブラリを使用しますが、同じ目的を果たす別のライブラリを使用しても構いません。

以下に、バックエンドとやり取りする Webservice の定義を示します。

Webservice

interface Webservice {
   /**
    * @GET declares an HTTP GET request
    * @Path("user") annotation on the userId parameter marks it as a
    * replacement for the {user} placeholder in the @GET path
    */
   @GET("/users/{user}")
   suspend fun getUser(@Path("user") userId: String): User
}

ViewModel を実装する場合まず思い浮かべるのは、Webservice を直接呼び出してデータを取得し、そのデータを LiveData オブジェクトに割り当てる方法かもしれません。その方法を使用することもできますが、アプリが拡大するにつれて管理が困難になります。その方法では UserProfileViewModel クラスの責任が非常に大きくなり、関心の分離の原則に反します。また、ViewModel のスコープが Activity または Fragment のライフサイクルに関連付けられます。つまり、Webservice から取得したデータは、関連付けられた UI オブジェクトのライフサイクルの終了時に失われてしまいます。その動作は、ユーザー エクスペリエンスを損ねます。

サンプルの ViewModel の実装では、上記の方法を使用する代わりに、データ取得プロセスを新しいモジュールであるリポジトリに任せています。

リポジトリ モジュールは、データ操作を行いますが、アプリの他の部分がデータを簡単に取得できるようクリーンな API を提供します。また、データをどこから取得し、データが更新されたときにどの API を呼び出すかを把握しています。リポジトリは各種のデータソース(永続モデル、ウェブサービス、キャッシュなど)間の仲介役と見なすことができます。

次のコード スニペットに示す UserRepository クラスでは、WebService のインスタンスを使用してユーザーのデータを取得します。

UserRepository

class UserRepository {
   private val webservice: Webservice = TODO()
   // ...
   suspend fun getUser(userId: String) =
       // This isn't an optimal implementation because it doesn't take into
       // account caching. We'll look at how to improve upon this in the next
       // sections.
       webservice.getUser(userId)
}

リポジトリ モジュールは不要に見えますが、アプリの他の部分からデータソースを抽象化するという重要な目的を果たしています。サンプルの UserProfileViewModel はデータの取得方法を認識しないので、複数の異なるデータ取得実装から取得されたデータをビューモデルに提供できます。

コンポーネント間の依存関係を管理する

上記の UserRepository クラスでは、ユーザーのデータを取得するために Webservice のインスタンスが必要になります。このインスタンスは簡単に作成できますが、そのためには Webservice クラスの依存関係について把握する必要もあります。また、Webservice が必要なのはおそらく UserRepository だけではありません。このような場合、コードの複製が必要になります。これは、Webservice への参照を必要とする各クラスがインスタンスの作成方法や依存関係を把握する必要があるためです。各クラスで新しい WebService を作成すると、アプリで大量のリソースを消費することになるおそれがあります。

この問題に対処するために、次のデザイン パターンを使用できます。

  • 依存性の注入(DI): 依存性の注入を利用すると、クラスの依存関係を構築することなく定義できます。ランタイムには、別のクラスがこの依存関係を提供します。
  • サービス ロケータ: サービス ロケータ パターンでは、クラスが依存関係を作成せずに取得できるレジストリが提供されます。

上記のパターンでは、コードが重複することも煩雑になることもなく依存関係を明確に管理できるので、コードの拡張が可能になります。さらに、テスト版と製品版のデータ取得プロセスの実装を簡単に切り替えることができます。

依存性の注入のパターンに従い、Android アプリで Hilt ライブラリを使用することをおすすめします。Hilt では、依存関係ツリーをたどって自動的にオブジェクトが構築され、コンパイル時の依存関係が保証されます。そして、Android フレームワーク クラスの依存関係コンテナが作成されます。

サンプルのアプリでは、Hilt を使用して Webservice オブジェクトの依存関係を管理します。

ViewModel とリポジトリを関連付ける

次に、UserRepository オブジェクトを使用するように UserProfileViewModel を変更します。

UserProfileViewModel

@HiltViewModel
class UserProfileViewModel @Inject constructor(
   savedStateHandle: SavedStateHandle,
   userRepository: UserRepository
) : ViewModel() {
   val userId: String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")

   private val _user = MutableLiveData<User>()
   val user: LiveData<User> = _user

   init {
       viewModelScope.launch {
           _user.value = userRepository.getUser(userId)
       }
   }
}

データをキャッシュする

UserRepository の実装によって Webservice オブジェクトの呼び出しを抽象化しますが、1 つのデータソースのみに依存するため、あまり柔軟ではありません。

UserRepository の実装に関する主な問題は、バックエンドから取得したデータをどこにも保存しないことです。そのため、ユーザーが UserProfileFragment から離れた後に戻ってきたときに、データが変更されていなくてもアプリで再び取得する必要があります。

次の理由により、この設計は最適とは言えません。

  • 貴重なネットワーク帯域幅を浪費する。
  • 新しいクエリが完了するまでユーザーが待機せざるを得ない。

こうした問題に対処するために、新しいデータソースを UserRepository に追加して、User オブジェクトをメモリにキャッシュします。

UserRepository

// @Inject tells Hilt how to create instances of this type
// and the dependencies it has.
class UserRepository @Inject constructor(
   private val webservice: Webservice,
   // Simple in-memory cache. Details omitted for brevity.
   private val userCache: UserCache
) {
   suspend fun getUser(userId: String): User {
       val cached: User = userCache.get(userId)
       if (cached != null) {
           return cached
       }
       // This implementation is still suboptimal but better than before.
       // A complete implementation also handles error cases.
       val freshUser = webservice.getUser(userId)
       userCache.put(userId, freshUser)
       return freshUser
   }
}

データを永続化する

現在の実装では、ユーザーがデバイスを回転させた場合や、ユーザーがアプリから離れてすぐに戻ってきた場合には、既存の UI が直ちに表示されます。これは、リポジトリがメモリ内キャッシュからデータを取得するためです。

しかし、ユーザーがアプリから離れてから数時間後(Android OS がプロセスを強制終了した後)に戻ってきた場合はどうなるでしょうか。現在の実装では、ネットワークからデータを再取得する必要があります。この再取得プロセスは、ユーザー エクスペリエンスに悪影響を及ぼすだけでなく、貴重なモバイルデータを消費するため、無駄が多くなります。

この問題はウェブ リクエストをキャッシュすることによって解決できますが、重要な問題が新たに発生します。たとえば、同じユーザーデータが別のタイプのリクエスト(友だちリストの取得など)から表示される場合はどうなるでしょうか。その場合、アプリで矛盾するデータが表示されることになり、混乱が生じます。たとえば、ユーザーが友だちリストのリクエストと単一ユーザーのリクエストを異なるタイミングで実行した場合、アプリでは同じユーザーデータの 2 つの異なるバージョンが表示される可能性があります。この矛盾するデータを統合する方法を見つける必要があります。

この問題に適切に対処するには、永続モデルを使用します。そこで出番となるのが Room 永続ライブラリです。

Room は、最小限のボイラープレート コードを使用してローカルデータを永続化するオブジェクト マッピング ライブラリです。コンパイル時に各クエリをデータスキーマに照らして検証することで、不完全な SQL クエリがランタイム エラーではなくコンパイル時エラーになるようにします。Room は、raw SQL のテーブルやクエリを扱う場合の基盤となる実装の詳細の一部を抽象化します。また、Room を使用してデータベースのデータ(コレクションや結合クエリを含む)の変更を監視し、それらの変更を LiveData オブジェクトを使用して公開することができます。スレッド化に関する一般的な問題(メインスレッドでのストレージへのアクセスなど)に対処するための実行の制約を明示的に定義することもできます。

Room を使用するには、ローカル スキーマを定義する必要があります。まず、@Entity アノテーションを User データモデル クラスに追加して、@PrimaryKey アノテーションをそのクラスの id フィールドに追加します。これらのアノテーションにより、User がデータベース内のテーブルとしてマークされ、id がそのテーブルの主キーとしてマークされます。

User

@Entity
data class User(
   @PrimaryKey private val id: String,
   private val name: String,
   private val lastName: String
)

次に、アプリの RoomDatabase を実装してデータベース クラスを作成します。

UserDatabase

@Database(entities = [User::class], version = 1)
abstract class UserDatabase : RoomDatabase()

UserDatabase は抽象クラスであることに注意してください。Room はその実装を自動的に提供します。詳しくは、Room に関するドキュメントをご覧ください。

次に、ユーザーデータをデータベースに挿入する方法が必要になります。そのために、データアクセス オブジェクト(DAO)を作成します。

UserDao

@Dao
interface UserDao {
   @Insert(onConflict = REPLACE)
   fun save(user: User)

   @Query("SELECT * FROM user WHERE id = :userId")
   fun load(userId: String): Flow<User>
}

load メソッドはタイプ Flow<User> のオブジェクトを返すことに注意してください。Room でフローを使用すると、ライブの最新情報を入手できます。つまり、user テーブルが変更されるたびに、新しい User が生成されます。

次に、定義した UserDao クラスを使用して、データベース クラスから DAO を参照します。

UserDatabase

@Database(entities = [User::class], version = 1)
abstract class UserDatabase : RoomDatabase() {
   abstract fun userDao(): UserDao
}

これで、Room データソースを組み込むように UserRepository を変更できます。

class UserRepository @Inject constructor(
   private val webservice: Webservice,
   // Simple in-memory cache. Details omitted for brevity.
   private val executor: Executor,
   private val userDao: UserDao
) {
   fun getUser(userId: String): Flow<User> {
       refreshUser(userId)
       // Returns a Flow object directly from the database.
       return userDao.load(userId)
   }

   private suspend fun refreshUser(userId: String) {
       // Check if user data was fetched recently.
       val userExists = userDao.hasUser(FRESH_TIMEOUT)
       if (!userExists) {
           // Refreshes the data.
           val response = webservice.getUser(userId)

           // Check for errors here.

           // Updates the database. Since `userDao.load()` returns an object of
           // `Flow<User>`, a new `User` object is emitted every time there's a
           // change in the `User`  table.
           userDao.save(response.body()!!)
       }
   }

   companion object {
       val FRESH_TIMEOUT = TimeUnit.DAYS.toMillis(1)
   }
}

getUserFlow<User> のオブジェクトを返すようになったため、新しい戻り値の型である Flow<User> を処理するように UserProfileViewModel を更新する必要があります。

@HiltViewModel
class UserProfileViewModel @Inject constructor(
   savedStateHandle: SavedStateHandle,
   userRepository: UserRepository
) : ViewModel() {
   val userId : String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")

   // asLiveData() is part of lifecycle-livedata-ktx
   // https://developer.android.com/kotlin/ktx#livedata
   val user = userRepository.getUser(userId).asLiveData()
}

UserRepository でデータの取得元を変更しても、UserProfileFragment を変更する必要はありません。このような変更作業の範囲の縮小は、このアプリ アーキテクチャがもたらす柔軟性を示しています。また、UserRepository のモック インスタンスを提供し、それと同時に製品版の UserProfileViewModel をテストできるので、テストにも最適です。

このアーキテクチャに基づくアプリをユーザーが数日ぶりに使用した場合、リポジトリが最新の情報を取得できるようになるまで、古い情報が表示される可能性があります。しかし、ユースケースによっては古い情報を表示したくない場合もあります。そこで、古い情報を表示する代わりに、プレースホルダ データを表示することもできます。その場合、サンプルの値が表示され、アプリが最新の情報の取得と読み込みを現在行っていることがわかります。

信頼できる唯一の情報源

異なる REST API エンドポイントが同じデータを返すのはよくあることです。たとえば、友だちリストを返す別のエンドポイントがバックエンドにある場合、同じユーザー オブジェクトが、2 つの異なる API エンドポイントから異なる粒度で取得される可能性があります。UserRepository が整合性を確認せずに Webservice のリクエストの応答をそのまま返した場合、紛らわしい情報が UI に表示される可能性があります。これは、リポジトリから取得するデータのバージョンと形式が最近呼び出されたエンドポイントによって決まるためです。

このため、サンプルの UserRepository の実装では、ウェブサービスの応答をデータベースに保存します。さらに、データベースが変更されると、アクティブな LiveData オブジェクトでコールバックがトリガーされます。このモデルでは、データベースが信頼できる唯一の情報源として機能し、アプリの他の部分は UserRepository を使用してデータベースにアクセスします。ディスク キャッシュを使用するかどうかにかかわらず、アプリの他の部分にとって信頼できる唯一の情報源としてのデータソースを、リポジトリで指定することをおすすめします。

進行中の操作を表示する

一部のユースケース(下にスワイプして更新など)では、ネットワーク操作が現在進行中であることを UI でユーザーに知らせることが重要です。データが更新される理由はさまざまであるため、UI 操作を実際のデータから切り離すことをおすすめします。たとえば、友だちリストを取得した場合、プログラムによって同じユーザーが再び取得され、LiveData<User> の更新がトリガーされる可能性があります。UI の観点からすると、転送中のリクエストが存在するということは、User オブジェクト自体に含まれる他のすべてのデータと同様に、単にデータポイントがもう 1 つあるということです。

データの更新リクエストの送信元に関係なく、一貫性のあるデータ更新ステータスが UI に表示されるようにするには、次のいずれかの戦略を使用します。

  • タイプ LiveData のオブジェクトを返すように getUser() を変更します。このオブジェクトに、ネットワーク操作のステータスが含まれます。
    具体例については、android-architecture-components GitHub プロジェクトの NetworkBoundResource 実装をご覧ください。
  • User の更新ステータスを返すことができる別のパブリック関数を UserRepository クラスで提供します。この方法は、データ取得プロセスが明示的なユーザー操作(下にスワイプして更新など)によって開始されたときにだけ UI でネットワーク ステータスを表示する場合に適しています。

各コンポーネントをテストする

関心の分離のセクションで、この原則に従うことの主なメリットの 1 つは、テストのしやすさであることを説明しました。

以下に、拡張したサンプルを使用して各コード モジュールをテストする方法を示します。

  • ユーザー インターフェースとユーザー操作: Android UI のインストゥルメンテーション テストを使用します。このテストを作成する場合は、Espresso ライブラリを使用することをおすすめします。フラグメントを作成し、モックの UserProfileViewModel を使用できます。フラグメントは、UserProfileViewModel とのみやり取りするため、このクラスをモックするだけでアプリの UI を十分にテストできます。
  • ViewModel: UserProfileViewModel クラスは JUnit テストを使用してテストできます。UserRepository クラスをモックするだけで済みます。
  • UserRepository: UserRepository も JUnit テストを使用してテストできます。ただし、WebserviceUserDao をモックする必要があります。これらのテストでは、以下の動作を確認します。
    • リポジトリがウェブサービスの呼び出しを適切に行っている。
    • リポジトリが結果をデータベースに保存している。
    • データがキャッシュされて最新の状態である場合、リポジトリが不要なリクエストを行わない。
  • WebserviceUserDao はどちらもインターフェースなので、これらをモックするか、より複雑なテストケースではフェイク実装を作成します。
  • UserDao: DAO クラスのテストにはインストゥルメンテーション テストを使用します。インストゥルメンテーション テストは、UI コンポーネントを必要としないため、すぐに実施できます。テストごとにインメモリ データベースを作成して、テストによる副作用(ディスク上のデータベース ファイルの変更など)が発生しないようにしてください。

    注意: Room を使用するとデータベース実装を指定できるので、SupportSQLiteOpenHelper の JUnit 実装を指定して DAO をテストすることが可能です。しかし、デバイスで実行されている SQLite のバージョンが開発マシンの SQLite のバージョンと異なる可能性があるため、この方法はおすすめしません。

  • Webservice: このテストでは、バックエンドへのネットワーク呼び出しを行わないようにします。すべてのテスト(特にウェブベースのテスト)を外部から切り離して行うことが重要です。MockWebServer などのライブラリを使用して、このテスト用の偽のローカル サーバーを作成できます。

  • アーティファクトのテスト: バックグラウンド スレッドを制御するための Maven アーティファクトがアーキテクチャ コンポーネントから提供されます。androidx.arch.core:core-testing アーティファクトには以下の JUnit ルールが含まれます。

    • InstantTaskExecutorRule: このルールを使用すると、呼び出し元のスレッドに対するバックグラウンド処理を即座に実行できます。
    • CountingTaskExecutorRule: このルールを使用すると、アーキテクチャ コンポーネントのバックグラウンド処理を待つことができます。また、このルールをアイドリング リソースとして Espresso に関連付けることもできます。

おすすめの方法

プログラミングは創造的な活動であり、Android アプリの作成も例外ではありません。問題の解決方法は数多くあります。複数のアクティビティやフラグメント間でデータをやり取りする、リモートデータを取得してオフライン モード用にローカルで永続化するなど、重要なアプリで対処する一般的なシナリオにはさまざまなものがあります。

以下の推奨事項は必須ではありませんが、経験上これに沿うことで、コードベースの堅牢性を高め、テストとメンテナンスを長期にわたって容易に実施できるようになります。

アプリのエントリ ポイント(アクティビティ、サービス、ブロードキャスト レシーバなど)をデータソースとして指定しないでください。

その代わり、そのエントリ ポイントに関連するデータのサブセットを取得する他のコンポーネントとの調整のみを行う必要があります。ユーザーによるデバイスの操作や、システムの現在の全体的な稼働状態によっては、各アプリ コンポーネントの生存期間がかなり短くなります。

アプリの各種モジュール間の責任の境界を明確に定義します。

たとえば、ネットワークからデータを読み込むコードを、コードベース内の複数のクラスやパッケージに散在させないでください。同様に、関連のない複数の処理(データ キャッシングとデータ バインディングなど)を同じクラスで定義しないでください。

各モジュールからの公開はできるだけ行わないでください。

あるモジュールの内部実装の詳細を公開する「ここだけ」のショートカットを作成しようとしないでください。短期的には時間を少し節約できるかもしれませんが、コードベースが発展するにつれて何倍もの技術的負債を負うことになります。

各モジュールを個別にテストできるようにする方法を検討します。

たとえば、ネットワークからデータを取得するための明確に定義された API を用意することで、そのデータをローカル データベースに永続化するモジュールを簡単にテストできるようになります。そうせずに、2 つのモジュールのロジックを 1 か所に混在させたり、ネットワーク用のコードをコードベース全体に分散させたりすると、不可能ではないにしても、テストが極めて困難になります。

アプリの特別な部分に焦点を当てて、他のアプリとの差別化を図ります。

同じボイラープレート コードを何度も書いてすでにあるものを作り直すのではなく、アプリを特別なものにすることに時間とエネルギーを集中させましょう。繰り返しのボイラープレート コードの記述には Android アーキテクチャ コンポーネントやその他の推奨ライブラリを利用してください。

データの関連性と新鮮さをできる限り維持します。

こうすることで、デバイスがオフライン モードのときでも、ユーザーがアプリの機能を利用できるようになります。すべてのユーザーが常時高速接続を利用できるわけではないことを忘れないでください。

信頼できる唯一の情報源としてのデータソースを 1 つ指定します。

アプリがアクセスする必要があるデータは常に、信頼できる唯一の情報源から取得されるものにする必要があります。

付録: ネットワーク ステータスの公開

上記のアプリの推奨アーキテクチャでは、コード スニペットを簡素化するためにネットワーク エラーと状態の読み込みを省略しました。

このセクションでは、データとその状態の両方をカプセル化する Resource クラスを使用してネットワーク ステータスを公開する方法を説明します。

次のコード スニペットは、Resource の実装サンプルを示します。

リソース

// A generic class that contains data and status about loading this data.
sealed class Resource<T>(
   val data: T? = null,
   val message: String? = null
) {
   class Success<T>(data: T) : Resource<T>(data)
   class Loading<T>(data: T? = null) : Resource<T>(data)
   class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
}

ネットワークからのデータの読み込みとそのデータのディスクコピーの表示を同時に行うことはよくあるので、さまざまな場面で再利用可能なヘルパークラスを作成することをおすすめします。この例では、NetworkBoundResource というクラスを作成します。

次の図に NetworkBoundResource の意思決定ツリーを示します。

まず、リソース用のデータベースの監視から始めます。エントリがデータベースから初めて読み込まれると、NetworkBoundResource が、送信するのに十分な結果であるか、あるいはネットワークから再び取得する必要があるかを確認します。キャッシュ データを表示する一方で、ネットワークからデータを更新したい場合、この両方が同時に行われる可能性があります。

ネットワークの呼び出しが正常に完了したら、応答をデータベースに保存してストリームを再初期化します。ネットワーク リクエストが失敗した場合は、NetworkBoundResource がエラーを直接送信します。

注: 新しいデータをディスクに保存した後、データベースからのストリームを再初期化します。ただし、変更の送信はデータベース自体が行うため、通常はこの操作を行う必要はありません。

変更の送信をデータベースに頼るということは、関連する副作用に頼ることを意味します。これは適切ではありません。なぜなら、データが変更されなかったという理由でデータベースが変更の送信を行わなかった場合、その副作用に基づく未定義の動作が行われる可能性があるためです。

また、信頼できる唯一の情報源の原則に反するため、ネットワークから届いた結果は送信しないでください。結局のところ、データベースには、「保存」処理中にデータの値を変更するトリガーが含まれている可能性があります。同様に、新しいデータなしで「SUCCESS」を送信しないでください。クライアントが誤ったバージョンのデータを受け取ることになるからです。

次のコード スニペットは、NetworkBoundResource クラスで提供される、そのサブクラス用のパブリック API を示しています。

NetworkBoundResource.kt

// ResultType: Type for the Resource data.
// RequestType: Type for the API response.
abstract class NetworkBoundResource<ResultType, RequestType> {
   // Called to save the result of the API response into the database
   @WorkerThread
   protected abstract suspend fun saveCallResult(item: RequestType)

   // Called with the data in the database to decide whether to fetch
   // potentially updated data from the network.
   @MainThread
   protected abstract fun shouldFetch(data: ResultType?): Boolean

   // Called to get the cached data from the database.
   @MainThread
   protected abstract suspend fun loadFromDb(): Flow<ResultType>

   // Called to create the API call.
   @MainThread
   protected abstract fun createCall(): Flow<ApiResponse<RequestType>>

   // Called when the fetch fails. The child class may want to reset components
   // like rate limiter.
   protected open fun onFetchFailed() {}
}

クラスの定義について、以下の重要な点に注意してください。

  • クラスでは 2 種類のパラメータ(ResultTypeRequestType)を定義しています。これは、API から返されるデータ型がローカルで使用されているデータ型と一致しない可能性があるためです。
  • ネットワーク リクエスト用に ApiResponse というクラスを使用しています。ApiResponse は、応答を LiveData のインスタンスに変換する Retrofit2.Call クラスの単純なラッパーです。

NetworkBoundResource クラスの完全な実装は、android-architecture-components GitHub プロジェクトの一部として示されています。

NetworkBoundResource を作成した後、それを使用して、ディスクとネットワークにバインドされた User の実装を UserRepository クラス内に作成できます。

UserRepository

class UserRepository @Inject constructor(
   private val webservice: Webservice,
   private val userDao: UserDao
) {
   fun getUser(userId: String) =
       object : NetworkBoundResource<User, User>() {
           override suspend fun saveCallResult(item: User) {
               userDao.save(item)
           }

           override fun shouldFetch(data: User?): Boolean {
               return rateLimiter.canFetch(userId) && (data == null || !isFresh(data))
           }

           override suspend fun loadFromDb(): Flow<User> {
               return userDao.load(userId)
           }

           override fun createCall(): Flow<ApiResponse<User>> {
               return webservice.getUser(userId)
           }
       }
}