🤖

Androidアプリアーキテクチャの公式見解をまとめた

2023/12/11に公開

この記事は「全国高専_非公式 Advent Calendar 2023」の11日目の記事です。

はじめに

本記事は、「Android Developers」のアーキテクチャにまつわる記事をまとめ、Androidアプリにおけるアーキテクチャの公式見解の主要なポイントを集約したものとなっています(私個人の見解ではありません)。用語や、公式が推奨する事項の整理に役に立てていただけると幸いです😀


推奨するアーキテクチャ

  • 画面にアプリデータを表示するUIレイヤ(UI Layer)
  • UI レイヤとデータレイヤの間に位置するドメインレイヤ(Domain Layer)(オプション)
  • アプリのビジネスロジックを含み、アプリデータを公開するデータレイヤ(Data Layer)

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

また、共通の原則として関心の分離UIをデータモデルで操作すること、信頼できる唯一の情報源(SSOT)は重要である。

UIレイヤ(UI Layer)


UIレイヤより
アプリデータの画面表示、ユーザーインタラクションの主要なポイントとして機能するレイヤ。ボタンの押下やネットワークレスポンスなどによってデータが変更されるたびに、UIを更新して変更を反映させる。UIレイヤは次のステップを実行する。

  1. アプリデータをUIがレンダリングしやすいデータに変換する。
  2. UIがレンダリングできるデータを、ユーザに表示するUI要素(UI Element)に変換する。
  3. UI要素が受け付けたユーザー入力イベントを使用し、その結果をUIデータに反映させる。
  4. ステップ1~3を繰り返す。

単方向データフローの使用

UI状態(UI State)が非常に単純である場合を除き、UIは、UI状態を使用して表示する責任のみを負うべきである。このような責任の健全な分離を実現するため、単方向データフロー(UDF)の使用を推奨する。これは状態が下に流れ、イベントが上に流れるようなパターンであり、以下のようなメリットが存在する。

  • データの整合性。UDFではUIに対して、信頼できる唯一のデータソースが存在する。
  • テスタビリティ。UIから独立してテストを行うことが出来る。
  • メンテナンス性

UI状態(UI State)

UI状態は、UI要素が情報を表示するために用いられるデータのことである。UIは、画面上のUI要素とUI状態を足し合わせたものといえる。

UIレイヤより
また、UI状態を不変にすることは、SSOTを実現し、データの不整合やバグを防ぐ上で重要である。UI状態のソースまたはオーナーのみが、データを更新する責任を負うべきだ。

UI状態の例
data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

命名規則

UI状態クラスは「機能+UiState」(例: NewsUiState, NewsItemUiState)

状態ホルダー(State Holder)

UI状態を生成する責任を負い、そのタスクに必要なロジックを格納するクラスを、状態ホルダー(State Holder)と呼ぶ。状態ホルダーは次の2つに分けられる。

ビジネスロジックの状態ホルダー


UIレイヤより
ユーザーイベントを処理し、データレイヤまたはドメインレイヤのデータをUI状態に変換・保持するクラスを、ビジネスロジック状態ホルダーと呼ぶ。このような状態ホルダーは、一般的に一画面などのデスティネーションレベルの状態を保持するため、ViewModelの利用を推奨する。

ビジネスロジック状態ホルダー
@HiltViewModel
class AuthorViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val authorsRepository: AuthorsRepository,
    newsRepository: NewsRepository
) : ViewModel() {

    val uiState: StateFlow<AuthorScreenUiState> =// Business logic
    fun followAuthor(followed: Boolean) {}
}
ビジネスロジック状態ホルダーとしてViewModelを利用する利点
  • ViewModelによってトリガーされるオペレーションは、Activityの再作成後も保持される
  • Navigationのサポート。画面がバックスタックにある間、ナビゲーションがViewModelをキャッシュに保存する。バックスタックからポップオフされるときに、クリーンアップされる。
  • Hiltなどの他のJetpackライブラリのサポート

UIロジックの状態ホルダー

通常はUI自体がUIロジックを使用する状態ホルダーであるが、UIの外に移動させたいほどUIロジックが複雑な場合は、プレーンなクラスとしてUIロジックの状態ホルダーを切り出す。UIロジック状態ホルダーには、次の特徴がある。

  • UI状態を保存し、UI要素の状態を管理する
  • Activityの再作成後まで保持されない
UIロジック状態ホルダー
@Stable
class NiaAppState(
    val navController: NavHostController,
    val windowSizeClass: WindowSizeClass
) {

    // UI logic
    val shouldShowBottomBar: Boolean
        get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
            windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact

    // UI logic
    val shouldShowNavRail: Boolean
        get() = !shouldShowBottomBar

   // UI State
    val currentDestination: NavDestination?
        @Composable get() = navController
            .currentBackStackEntryAsState().value?.destination

    // UI logic
    fun navigate(destination: NiaNavigationDestination, route: String? = null) { /* ... */ }

     /* ... */
}

状態ホルダーは複合可能

依存する状態ホルダーの存続期間が同じか、自身より短いならば、状態ホルダーは他の状態ホルダーに依存できる。例えば、

  • UIロジック状態ホルダーは別のUIロジック状態ホルダーに依存できる。
  • ビジネスロジック状態ホルダーは別のUIロジック状態ホルダーに依存できる。
UIロジック状態ホルダーの複合
@Stable
class DrawerState(/* ... */) {
  internal val swipeableState = SwipeableState(/* ... */)
  // ...
}

@Stable
class MyAppState(
  private val drawerState: DrawerState,
  private val navController: NavHostController
) { /* ... */ }

@Composable
fun rememberMyAppState(
  drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
  navController: NavHostController = rememberNavController()
): MyAppState = remember(drawerState, navController) {
  MyAppState(drawerState, navController)
}

UIロジック状態ホルダーのような存続期間が短い状態ホルダーがビジネスロジック状態ホルダーに依存するケースでは、インスタンスを直接渡してはならない。ビジネスロジック状態ホルダーから必要な情報のみをパラメータから渡せばよい。

存続期間が短い状態ホルダーの依存
class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Stable
class MyScreenState(
  // DO NOT pass a ViewModel instance to a plain state holder class
  // private val viewModel: MyScreenViewModel,

  // Instead, pass only what it needs as a dependency
  private val someState: StateFlow<SomeState>,
  private val doSomething: () -> Unit,

  // Other UI-scoped types
  private val scaffoldState: ScaffoldState
) {
  /* ... */
}

@Composable
fun rememberMyScreenState(
  someState: StateFlow<SomeState>,
  doSomething: () -> Unit,
  scaffoldState: ScaffoldState = rememberScaffoldState()
): MyScreenState = remember(someState, doSomething, scaffoldState) {
  MyScreenState(someState, doSomething, scaffoldState)
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

UI状態の公開


UIレイヤより

UI状態を定義し、その状態の生成を管理する方法を決定したら、生成された状態をUIに提示する。UDFで状態を管理するため、LiveDataStateFlowStateなどの監視可能なデータホルダーで UI状態を公開する必要がある。可変のストリームを不変のストリームとして公開するのが一般的である。

監視可能データホルダーでUI状態を公開
class NewsViewModel(...) : ViewModel() {
    var uiState by mutableStateOf(NewsUiState())
        private set
    ...
}

スレッド化

ViewModel内で実行されるすべての処理はメインセーフでなければならない。

データレイヤ(Data Layer)


データレイヤ から

データレイヤ(Data Layer)にはアプリデータとビジネスロジックが含まれている。ビジネスロジックは、アプリデータの作成、保存、変更方法を決定する実際のビジネスルールで構成されている。

データレイヤは主に次から構成される。

リポジトリ(Repository)

0~複数のデータソースクラスに依存し、アプリとデータベース、ネットワーク通信などの外部APIとのやり取りを集約したクラス。複雑なビジネス要件が含まれる場合、他のリポジトリに依存することもある。また、データソースにアクセスしたい場合は、必ずこれを参照するViewModelやユースケースがデータソースクラスに依存してはならない。

リポジトリ
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

データソース(Data Source)

単一データソースとのやり取りを記述したクラス。1つのデータソースのみを処理する役割を担う必要がある。メインセーフにするため、CoroutineDispatcherExecutorを利用してタスクを実行する。

データソースクラス
class NewsRemoteDataSource(
  private val ioDispatcher: CoroutineDispatcher
) {
    /**
     * This executes on an IO-optimized thread pool, the function is main-safe.
     */
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        // Move the execution to an IO-optimized thread since the ApiService
        // doesn't support coroutines and makes synchronous requests.
        withContext(ioDispatcher) { ... }
}

データモデル(Data Model)

データモデルを表すクラス。データソースが返す情報がアプリが必要とする情報と一致しない場合は、他のレイヤにとって必要なデータのみを公開するデータモデルクラスを作成することを推奨する。これによりアプリのメモリ節約、関心の分離、アプリに沿ったデータ型に変換されるというメリットが得られる。

データモデルクラス(カット前)
data class ArticleApiModel(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val modifications: Array<ArticleApiModel>,
    val comments: Array<CommentApiModel>,
    val lastModificationDate: Date,
    val authorId: Long,
    val authorName: String,
    val authorDateOfBirth: Date,
    val readTimeMin: Int
)
データモデルクラス(カット後)
data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

命名規則

リポジトリクラスは「データの種類+Repository」。
(例: NewsRepository, MoviesRepository, PaymentsRepository)

データソースクラスは「データの種類+ソースの種類+DataSource」。
(例: NewsRemoteDataSource, NewsLocalDataSource)

信頼できる情報源(SSOT)

各リポジトリは信頼できる唯一の情報源(SSOT)を定義する。リポジトリから公開されるデータは、常に信頼できる情報源から直接取得されたデータでなければならない。これはデータソースの場合もあれば、リポジトリに含まれるメモリ内キャッシュの場合もある。

データレイヤの公開方法

ワンショットオペレーションの場合は、suspend関数を公開する。時間の経過に伴うデータ変更を公開する場合は、Flowを公開する。また、このレイヤで公開されるデータは不変である必要がある。これは、SSOTを実現し、データの不整合やバグを防ぐ上で重要である。

エラーの公開

データソースとのインタラクションで、エラーが発生した場合は例外をスローする。コルーチンとFlowの場合は、Kotlinの組み込みのエラー処理メカニズムを利用する。カスタム例外の定義と公開、Resultクラスを活用するのも有効である。

interfaceの活用

データソースクラスなどをインターフェースに依存させることで、依存関係を簡単に置き換えられる、テスタビリティの高いコードを書くことができる。

class NewsRemoteDataSource(
  private val newsApi: NewsApi,
  private val ioDispatcher: CoroutineDispatcher
) {
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        withContext(ioDispatcher) {
            newsApi.fetchLatestNews()
        }
    }

// Makes news-related network synchronous requests.
interface NewsApi {
    fun fetchLatestNews(): List<ArticleHeadline>
}

スレッド化

データソースクラス、リポジトリクラスのメソッド呼び出しはメインセーフ(メインスレッドから安全に呼び出せる)である必要がある。RoomやRetrofitはメインセーフなsuspendメソッドを提供しているため、ほとんどの場合はこれを活用すればよい。

データオペレーションの種類

UI指向のオペレーション(画面に依存したライフサイクル)

ユーザーが特定の画面にいる場合のみに利用される状態を扱う場合、通常、ViewModelなどのライフサイクルに従いデータを保持する。

アプリ指向のオペレーション(アプリに依存したライフサイクル)

ネットワークリクエストの結果をキャッシュに保存し、後で必要に応じて使用できるようにする場合のような、アプリを開いている間をライフサイクルとして持つようなオペレーションは、通常、Applicationクラスまたはデータレイヤのライフサイクルに従いデータを保持する。

ビジネス指向のオペレーション(バックグラウンド)

ユーザーがプロフィールに投稿する写真のアップロードを完了する場合のような、中断されず、プロセス終了後も存続するビジネス指向のオペレーションでは、WorkManagerを利用することを推奨する。

ドメインレイヤ(Domain Layer)(オプション)


ドメインレイヤより
複雑なビジネスロジック、複数のViewModelで再利用される単純なビジネスロジックをユースケースとしてカプセル化する。全てのアプリに複雑な要件があるわけではないので、このレイヤの実装はオプショナルである。

導入のメリットとして以下が挙げられる。

  1. コードの重複回避
  2. 読みやすさ向上
  3. テスト性向上
  4. クラスの大規模化の回避

ユースケース(Use Case)

ユースケースに記述するもの

再利用可能でシンプルなビジネスロジック

シンプルで繰り返されるビジネスロジックをユースケースとして記述すると、あらゆる場所で変更を簡単に適用できるようになる。

リポジトリを組み合わせたロジック

複数のリポジトリを組み合わせたクラスは複雑になる可能性があるため、ViewModelと分離するためにユースケースを作成する。

ユースケースクラスの呼び出し

operator修飾子を使用し、invoke()メソッドを定義して、インスタンスを関数のように呼び出す機能を使うとよい。

ユースケース
class FormatDateUseCase(userRepository: UserRepository) {

    private val formatter = SimpleDateFormat(
        userRepository.getPreferredDateFormat(),
        userRepository.getPreferredLocale()
    )

    operator fun invoke(date: Date): String {
        return formatter.format(date)
    }
}
利用者
class MyViewModel(formatDateUseCase: FormatDateUseCase) : ViewModel() {
    init {
        val today = Calendar.getInstance()
        val todaysDate = formatDateUseCase(today)
        /* ... */
    }
}

命名規則

動詞の現在形+名詞/対象(省略可)+UseCase」(例: FormatDateUseCase, LogOutUserUseCase)

スレッド化

ユースケースはメインセーフである必要がある。また、リソースを大量に消費するような場合は、ブロッキングオペレーションをデータレイヤに移すことが推奨される。

Util?ユースケース?

ユースケースに存在するようなロジックが、Utilクラスの静的メソッドの一部になることもあるが、探しにくく、機能も見つけにくいことが多いという理由から、推奨されない。

ユースケースの作成を徹底するべきか

ドメインレイヤを作成すると、UIレイヤのデータレイヤへの直接アクセスを防止することができる。これによりドメインロジックが一カ所に集まるという利点もあるが、データレイヤとのやり取りすべてにユースケースを定義するとなれば、データレイヤへの単純な関数呼び出しだけであってもユースケースを追加しなければならないという事態が発生することがある。わずかなメリットのために複雑性が増してしまうのである。

おすすめのアプローチは、必要なときにだけユースケースを追加することである。UIレイヤがほぼ特定のユースケースに限ってデータにアクセスしているならば、データレイヤへの直接アクセスは合理的である。


まとめ

Android Developers公式の見解についてざっくりとまとめてみましたが、いかがだったでしょうか。実際にアプリを制作する場合に完全にこのガイドに乗っかる必要は無いですが、公式推奨のアーキテクチャを確認しておいて損はないと思います。

この記事ではさらに詳細な実装方法や、細かな事項については省略しましたが、Android Developers公式サイト、公式Youtubeチャンネルではさらに詳細に説明されています。一通り目を通しておくと良いかもしれません。また、これらの原則が適用されたAndroid公式リポジトリの「Now in Android」も実例として参考になります。
https://developer.android.com/topic/architecture/intro?hl=ja
https://www.youtube.com/@AndroidDevelopers
https://github.com/android/nowinandroid

Discussion