Android Dev Summit, October 23-24: two days of technical content, directly from the Android team. Sign-up for livestream updates.

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

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

このページは、読者が Android フレームワークの基本について熟知していることを前提としています。Android アプリの開発経験がない場合は、デベロッパー ガイドでアプリの開発の基本を確認してから、このガイドで取り上げられている概念について詳細をご覧ください。

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

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

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

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

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

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

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

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

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

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

関心の分離

最も重要な原則は関心の分離です。すべてのコードを 1 つの ActivityFragment 内に記述するのはよくある間違いです。これらの 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() {
       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 がフラグメントの引数にアクセスする必要があります。フラグメントから引数を渡すことができますが、それよりよいのは、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}")
       fun getUser(@Path("user") userId: String): Call<User>
    }
    

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

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

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

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

UserRepository

class UserRepository {
       private val webservice: Webservice = TODO()
       // ...
       fun getUser(userId: String): LiveData<User> {
           // This isn't an optimal implementation. We'll fix it later.
           val data = MutableLiveData<User>()
           webservice.getUser(userId).enqueue(object : Callback<User> {
               override fun onResponse(call: Call<User>, response: Response<User>) {
                   data.value = response.body()
               }
               // Error case is left out for brevity.
               override fun onFailure(call: Call<User>, t: Throwable) {
                   TODO()
               }
           })
           return data
       }
    }
    

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

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

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

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

  • 依存性の注入(DI): 依存性の注入を利用すると、クラスの依存関係を構築することなく定義できます。ランタイムには、別のクラスがこの依存関係を提供します。Android アプリで依存性の注入を実装する場合は、Dagger 2 ライブラリを使用することをおすすめします。Dagger 2 では依存関係ツリーをたどって自動的にオブジェクトが構築され、コンパイル時の依存関係が保証されます。
  • サービス ロケータ: サービス ロケータ パターンでは、クラスが依存関係を作成せずに取得できるレジストリが提供されます。

サービス レジストリの実装は DI を使用するよりも簡単なので、DI に詳しくない場合はサービス ロケータ パターンを使用してください。

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

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

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

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

UserProfileViewModel

class UserProfileViewModel @Inject constructor(
       savedStateHandle: SavedStateHandle,
       userRepository: UserRepository
    ) : ViewModel() {
       val userId : String = savedStateHandle["uid"] ?:
              throw IllegalArgumentException("missing user id")
       val user : LiveData<User> = userRepository.getUser(userId)
    }
    

データをキャッシュする

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

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

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

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

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

UserRepository

// Informs Dagger that this class should be constructed only once.
    @Singleton
    class UserRepository @Inject constructor(
       private val webservice: Webservice,
       // Simple in-memory cache. Details omitted for brevity.
       private val userCache: UserCache
    ) {
       fun getUser(userId: String): LiveData<User> {
           val cached = userCache.get(userId)
           if (cached != null) {
               return cached
           }
           val data = MutableLiveData<User>()
           userCache.put(userId, data)
           // This implementation is still suboptimal but better than before.
           // A complete implementation also handles error cases.
           webservice.getUser(userId).enqueue(object : Callback<User> {
               override fun onResponse(call: Call<User>, response: Response<User>) {
                   data.value = response.body()
               }

               // Error case is left out for brevity.
               override fun onFailure(call: Call<User>, t: Throwable) {
                   TODO()
               }
           })
           return data
       }
    }
    

データを永続化する

現在の実装では、ユーザーがデバイスを回転させた場合や、ユーザーがアプリから離れてすぐに戻ってきた場合には、既存の 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): LiveData<User>
    }
    

load メソッドはタイプ LiveData<User> のオブジェクトを返すことに注意してください。Room はデータベースの変更を検知し、データが変更されたときにすべてのアクティブなオブザーバーに自動的に通知します。Room は LiveData を使用するため、処理が効率的になり、データが更新されるのはアクティブなオブザーバーが 1 つ以上存在する場合に限られます。

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

UserDatabase

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

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

// Informs Dagger that this class should be constructed only once.
    @Singleton
    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): LiveData<User> {
           refreshUser(userId)
           // Returns a LiveData object directly from the database.
           return userDao.load(userId)
       }

       private fun refreshUser(userId: String) {
           // Runs in a background thread.
           executor.execute {
               // Check if user data was fetched recently.
               val userExists = userDao.hasUser(FRESH_TIMEOUT)
               if (!userExists) {
                   // Refreshes the data.
                   val response = webservice.getUser(userId).execute()

                   // Check for errors here.

                   // Updates the database. The LiveData object automatically
                   // refreshes, so we don't need to do anything else here.
                   userDao.save(response.body()!!)
               }
           }
       }

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

UserRepository でデータの取得元を変更したとしても、UserProfileViewModelUserProfileFragment を変更する必要がないことにお気づきでしょうか。こうした限定的な変更作業は、アプリのアーキテクチャがもたらす柔軟性を示しています。さらに、偽の 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 の実装サンプルを示します。

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 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 fun loadFromDb(): LiveData<ResultType>

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

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

       // Returns a LiveData object that represents the resource that's implemented
       // in the base class.
       fun asLiveData(): LiveData<ResultType> = TODO()
    }

    

クラスの定義に関する以下の重要な情報に注意してください。

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

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

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

UserRepository

// Informs Dagger that this class should be constructed only once.
    @Singleton
    class UserRepository @Inject constructor(
       private val webservice: Webservice,
       private val userDao: UserDao
    ) {
       fun getUser(userId: String): LiveData<User> {
           return object : NetworkBoundResource<User, User>() {
               override fun saveCallResult(item: User) {
                   userDao.save(item)
               }

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

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

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