クリーンアーキテクチャ
from Android開発
Googleが推奨するアーキテクチャを参考にしている。クリーンアーキテクチャとは、「画面・データ取得・アプリのルールを分けて、変更に強くする考え方」のことである。クリーンなアーキテクチャくらいの意味らしく、Googleが推奨するアーキテクチャを守るための指標と考えて良さそう。関心の分離、SOLIDの原則を守るための指標らしい。
https://scrapbox.io/files/6a2f9cfa74d67ad5aeffc4e5.png
階層型アーキテクチャ
アプリアーキテクチャガイド によると、Androidアプリ開発では少なくともUIレイヤとデータレイヤの2つのレイヤを明確に分ける必要がある。関心の分離により、UIとビジネスロジックのやり取りを簡素化でき、再利用できる。以下、Googleが定める設計指標ルールを理解していく。
1. 明確に定義されたデータレイヤを使用します
データレイヤはデータ管理に関するロジック全般であり、具体的にはrepository、DataSourceAPI、DB、DataStoreである。先程の記事にあるように、 DataSourceは1つのデータだけ担当することを定めている。
2. 明確に定義された UI レイヤを使用します。
UIレイヤはUIに関するコードであり、具体的にはscreen/composable、Activity、ユーザー操作、ViewModel、UIステートである。ViewModel や UseCase から DataSource を直接呼ばない。UI → ViewModel → Repository → DataSourceのように、必ずRepositoryを介す事で、データの単方向性を守る。
4. ドメインレイヤを使用します。
※ドメインレイヤについては一旦放置
UIレイヤ
1. ViewModelを使用します。
画面単位の状態ホルダーである。UI に状態を公開し、関連するロジックをカプセル化し、構成変更などをまたいで状態を保持できる。
例えばUIが以下のような状態を持っているとする。
code:kotlin
data class UserUiState(
val isLoading: Boolean = false,
val userName: String = "",
val errorMessage: String? = null
)
これを公開する
code:kotlin
@HiltViewModel
class UserViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UserUiState())
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
}
UIはこれを読むだけで良い
code:kotlin
@Composable
fun UserRoute(
viewModel: UserViewModel = hiltViewModel()
) {
// reactのuseStateみたいなもん?
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
UserScreen(
uiState = uiState,
onRetryClick = viewModel::loadUsers
)
}
@Composable
fun UserScreen(
uiState: UserUiState,
onRetryClick: () -> Unit
) {
// uiStateが更新される度に再レンダリングされる
// 各状態毎にcomposableを切り替える
when {
uiState.isLoading -> {
CircularProgressIndicator()
}
uiState.errorMessage != null -> {
ErrorContent(
message = uiState.errorMessage,
onRetryClick = onRetryClick
)
}
else -> {
LazyColumn {
items(uiState.users) { user ->
Text(user.name)
}
}
}
}
}
2. ViewModel から UI にイベントを送信しないようにします。
UI がイベントを受け取り、必要なら ViewModel の関数を呼ぶ
code:kotlin
@Composable
fun LoginRoute(
viewModel: LoginViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
TextField(
value = uiState.email,
onValueChange = viewModel::onEmailChange
)
Button(
onClick = viewModel::onLoginClick
) {
Text("ログイン")
}
}
3. 単一アクティビティのアプリケーションを使用します。
アプリに複数の画面がある場合、Navigation 3 を使用して画面間を移動し、アプリへのディープリンクを設定します。
Navigation 3 は、Compose と連携するように設計された新しいナビゲーション ライブラリです。Navigation 3 を使用すると、バックスタック(「戻る」の画面制御)を完全に制御でき、複数のデスティネーション(画面)を起点または終点とするナビゲーションがリストアイテムの追加や削除と同じくらい簡単になります。
笑ってしまうくらい複雑な文章だが、要するにNavigation 3 は、Composeアプリで画面遷移を管理するためのライブラリで、「今どの画面が積まれているか」を自分でリストとして管理できるもの。またナビゲーションとは、画面遷移のことである。
このルールでは、1つのActivityに対してNavigation3によって画面を切り替えろ、という意味だ。
code:text
MainActivity
└ App()
└ NavHost
├ LoginScreen
├ HomeScreen
├ DetailScreen
└ SettingsScreen
code:kotlin
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp()
}
}
}
code:kotlin
@Composable
fun MyApp() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "login"
) {
composable("login") {
LoginRoute(
onLoginSuccess = {
navController.navigate("home")
}
)
}
composable("home") {
HomeRoute(
onUserClick = { userId ->
navController.navigate("detail/$userId")
}
)
}
composable("detail/{userId}") {
DetailRoute()
}
}
}
composable の中では明示的に渡していないが、例えば DetailRoute() の内部で hiltViewModel() を呼ぶとHilt経由でViewModelが作られる。
code:kotlin
@Composable
fun DetailRoute(
viewModel: DetailViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DetailScreen(uiState = uiState)
}
この時、 DetailRoute() 呼び出し時にViewModelを渡していないが、default引数の
code:kotiln
viewModel: DetailViewModel = hiltViewModel()
によって取得される。
ViewModel
1.ViewModel が Android のライフサイクルに依存しないようにします。
Lifecycle は、Android の部品が今どの状態にいるかを表す仕組みである。Activityには onCreate、onStart、onResume、onPause、onStop、onDestroyのようなコールバックがあり、状態によってこれらがOSなどに呼び出される。Activityやcomposableは再生成されることがあり、ViewModel が古いActivityやcomposableを握ると、存在しないUIにアクセスすることになる。メモリリークの原因になる。
以下はアンチパターン
code:kotlin
// メモリリークの危険
// ViewModelがActivityを握らない
@HiltViewModel
class UserViewModel @Inject constructor(
private val activity: Activity
) : ViewModel()
// ViewModelの責務がUIに近すぎる
// viewModelは状態だけをUIに渡し、UIがその意味を解釈してUIに反映させる
@HiltViewModel
class UserViewModel @Inject constructor(
private val context: Context
) : ViewModel()
2. コルーチンと Flow を使用します。
※非同期処理やマルチスレッドが絡みそうなので一旦放置。
3. 画面レベルでViewModelを使用します
code:kotlin
@Composable
fun UserCard(
userId: String,
viewModel: UserCardViewModel = hiltViewModel()
) {
...
}
composableパーツであるUserCardにviewModelを持たせない。パーツは状態とイベントハンドラだけを引数でもらう。
code:kotlin
@Composable
fun UserCard(
userName: String,
iconUrl: String,
isFavorite: Boolean,
onClick: () -> Unit,
onFavoriteClick: () -> Unit
) {
...
}
または、composableパーツが状態を欲するときは、mutableStateなピュアKotlinクラスを使って状態を受け取る。
code:Kotlin
// 状態を意味するクラス
class SearchBarState {
var query by mutableStateOf("")
private set
var isFocused by mutableStateOf(false)
private set
}
// 状態を画面側のComposableでrememberする
@Composable
fun rememberSearchBarState(): SearchBarState {
return remember {
SearchBarState()
}
}
// composableパーツは状態を受け取る
@Composable
fun SearchBar(
state: SearchBarState = rememberSearchBarState()
) {
...
}
ライフサイクル
Activity ライフサイクル コールバックをオーバーライドする代わりに、コンポーザブルでライフサイクル対応エフェクトを使用します。
画面ごとの副作用はComposableを使う。
※addObserver / addListener / registerの違いを調べる
code:kotlin
@Composable
fun HomeRoute(
viewModel: HomeViewModel = hiltViewModel()
) {
val lifecycleOwner = LocalLifecycleOwner.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
// このComposableが画面に出たタイミングで一度だけ実行される
}
LaunchedEffect(uiState.flag){
// 引数の値が変わったタイミングでcoroutineを起動する
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME){
// 引数で受け取ったライフサイクルのタイミングで実行される
}
DisposableEffect(lifecycleOwner) {
// 何かを登録して、Composable が消えるときに必ず解除する。
// observerを登録
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
viewModel.onVisible()
}
Lifecycle.Event.ON_STOP -> {
viewModel.onNotVisible()
}
else -> Unit
}
}
// addObserver
lifecycleOwner.lifecycle.addObserver(observer)
// 解除
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
依存関係
依存関係のベストプラクティスは以下の3つ。
依存関係挿入を使用します。
必要に応じてコンポーネントにスコープを設定します。
Hilt を使用します。
依存性注入の基礎
Car クラスが Engine クラスへの参照を必要とする場合、このようなクラスの関係を「依存関係」と呼ぶ。この関係を実装する際に、クラス自身が必要な依存関係を構築する場合は、以下のようになる。
https://scrapbox.io/files/6a2fbbe374d67ad5ae001681.png
code:kotlin
class Car {
private val engine = Engine()
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.start()
}
この実装は単純だが、以下のような問題がある。
Car と Engine が緊密に結び付けられている。Car のインスタンスは 1 種類の Engine を使用し、サブクラスや代替実装を簡単に使用することができない。
Engine への依存関係が固定されていると、テストが困難になる。
一方、依存性注入を行うと以下のようになる。
https://scrapbox.io/files/6a2fbbe774d67ad5ae00168f.png
code:kotlin
class Car(private val engine: Engine) {
fun start() {
engine.start()
}
}
fun main(args: Array) {
val engine = Engine()
val car = Car(engine)
car.start()
}
Hiltを使った依存性注入の例
code:Kotlin
// アンチパターン
// - ViewModel が Retrofit の作り方を知っている
// - ViewModel が Repository の作り方を知っている
// - テストで fake repository に差し替えにくい
class HomeViewModel : ViewModel() {
private val api = Retrofit.Builder()
.baseUrl("https://example.com")
.build()
.create(UserApi::class.java)
private val repository = UserRepository(api)
fun load() {
repository.getUsers()
}
}
// Hiltバインディングパターン
// - ViewModelはUserRepositoryが必要なことしか知らない
// - 作り方はHiltが担当する
@HiltViewModel
class HomeViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
fun load() {
viewModelScope.launch {
userRepository.getUsers()
}
}
}
スコープ
Hiltは依存関係を作ってくれる。でも、毎回新しく作るのか、同じものを使い回すのかを決める必要がある。
@Singleton
アプリ全体で1つだけ使い回したい場合に使う。Repository はアプリ内で共通のデータ窓口だから、代替シングルトンでOK。
@ViewModelScoped
同じ ViewModel の中では同じインスタンスを使いたいときに使う。この ViewModel が生きている間は同じインスタンスを使い、ViewModelが破棄されたら一緒に捨てられる。
スコープなし
必要になるたびに新しく作られる
軽い、状態を持たない、毎回作っても重くないならOK
テスト
テストのベストプラクティスは以下の通り。
テストする内容を把握する。
モックよりフェイクを優先する
StateFlowをテストする
composableのテスト
UIテストではRepositoryは使わずに、Coposableに状態とコールバックを渡して確認する。
code:Kotlin
@get:Rule
val composeRule = createComposeRule()
@Test
fun showErrorMessage() {
composeRule.setContent {
LoginScreen(
uiState = LoginUiState(
errorMessage = "ログインに失敗しました"
),
onEmailChange = {},
onLoginClick = {}
)
}
composeRule
.onNodeWithText("ログインに失敗しました")
.assertIsDisplayed()
}
ViewModel
イベントを受けたときにUiStateが正しく変わるかをテストする
code:kotlin
@Test
fun loginSuccess_updatesUiState() = runTest {
val repository = FakeAuthRepository(
loginResult = Result.success(Unit)
)
val viewModel = LoginViewModel(repository)
viewModel.onEmailChange("test@example.com")
viewModel.onLoginClick()
val state = viewModel.uiState.value
assertThat(state.isLoggedIn).isTrue()
assertThat(state.isLoading).isFalse()
assertThat(state.errorMessage).isNull()
}