Androidアプリアーキテクチャの公式見解をまとめた
この記事は「全国高専_非公式 Advent Calendar 2023」の11日目の記事です。
はじめに
本記事は、「Android Developers」のアーキテクチャにまつわる記事をまとめ、Androidアプリにおけるアーキテクチャの公式見解の主要なポイントを集約したものとなっています(私個人の見解ではありません)。用語や、公式が推奨する事項の整理に役に立てていただけると幸いです😀
推奨するアーキテクチャ
- 画面にアプリデータを表示するUIレイヤ(UI Layer)
- UI レイヤとデータレイヤの間に位置するドメインレイヤ(Domain Layer)(オプション)
- アプリのビジネスロジックを含み、アプリデータを公開するデータレイヤ(Data Layer)
アプリ アーキテクチャ ガイド から
また、共通の原則として関心の分離、UIをデータモデルで操作すること、信頼できる唯一の情報源(SSOT)は重要である。
UIレイヤ(UI Layer)
UIレイヤより
アプリデータの画面表示、ユーザーインタラクションの主要なポイントとして機能するレイヤ。ボタンの押下やネットワークレスポンスなどによってデータが変更されるたびに、UIを更新して変更を反映させる。UIレイヤは次のステップを実行する。
- アプリデータをUIがレンダリングしやすいデータに変換する。
- UIがレンダリングできるデータを、ユーザに表示するUI要素(UI Element)に変換する。
- UI要素が受け付けたユーザー入力イベントを使用し、その結果をUIデータに反映させる。
- ステップ1~3を繰り返す。
単方向データフローの使用
UI状態(UI State)が非常に単純である場合を除き、UIは、UI状態を使用して表示する責任のみを負うべきである。このような責任の健全な分離を実現するため、単方向データフロー(UDF)の使用を推奨する。これは状態が下に流れ、イベントが上に流れるようなパターンであり、以下のようなメリットが存在する。
- データの整合性。UDFではUIに対して、信頼できる唯一のデータソースが存在する。
- テスタビリティ。UIから独立してテストを行うことが出来る。
- メンテナンス性。
UI状態(UI State)
UI状態は、UI要素が情報を表示するために用いられるデータのことである。UIは、画面上のUI要素とUI状態を足し合わせたものといえる。
UIレイヤより
また、UI状態を不変にすることは、SSOTを実現し、データの不整合やバグを防ぐ上で重要である。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
の再作成後まで保持されない。
@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ロジック状態ホルダーに依存できる。
@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で状態を管理するため、LiveData
やStateFlow
、State
などの監視可能なデータホルダーで 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つのデータソースのみを処理する役割を担う必要がある。メインセーフにするため、CoroutineDispatcher
やExecutor
を利用してタスクを実行する。
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で再利用される単純なビジネスロジックをユースケースとしてカプセル化する。全てのアプリに複雑な要件があるわけではないので、このレイヤの実装はオプショナルである。
導入のメリットとして以下が挙げられる。
- コードの重複回避
- 読みやすさ向上
- テスト性向上
- クラスの大規模化の回避
ユースケース(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」も実例として参考になります。
Discussion