こんにちは!AndroidエンジニアのYukiです!
Wantedly でのブログ投稿が、今回で2回目となります!
以前、「Effective Kotlin から学んだこと」という記事も執筆しているので、興味がある方は是非、ご覧ください!
アーキテクチャ設計の必要性
はじめに、Androidのアーキテクチャ設計の必要性について、お話いたします。
アーキテクチャ設計によるメリットは様々ありますが、個人的には、以下2点だと考えています。
- プログラムの可読性が上がる。
- 機能改善がしやすくなる。
以前、モバイルアプリ開発を行うプロジェクトに参画しておりました。そのプロジェクトでは、アーキテクチャ設計なるものは存在しておらず、ビジネスロジックやUIの更新処理などが、Activity に集中していました。(いわゆる Fat Activity ってやつですね。笑)
つまり、Activity の役割が多岐に渡るため、Pull Request レビュー時には、以下のような課題感を感じていました。
- どの修正が何のためなのかが理解しづらい・・・。
- コード修正による影響範囲の確認に時間を費やしてしまう・・・。
- コンフリクト解消も面倒・・・。(←レビューワーがコンフリクト解消をやってました。)
機能改善の際も、担当のプログラマが苦労していたように感じます。Activity の担う役割が広すぎるが故に、少しコードを変更するだけで、デグレが発生してました。
しかし、プログリットに入社し、実際に開発を行う上で、アーキテクチャ設計のメリットを感じています。クラスごとに責務が分離されるため、特に、Pull Request のレビューは楽になりましたね。笑
また、機能開発もしやすいのでは?と感じています。
(そもそも、アーキテクチャ設計について無知だったので、慣れるのに時間がかかりましたが。笑)
このように、アーキテクチャ設計を取り入れることにより、様々な恩恵を受けることができます。Android開発において、様々なアーキテクチャが存在しますが、プログリットでは、MVVM × Clean Architecture を採用しております。
今回は、我々が採用しているアーキテクチャ設計について、お話します!
MVVM × Clean Architecture によるクラスの責務の分離
MVVM × Clean Architectureを取り入れ、どのようにクラスの責務を分離しているのか、お話しようと思います。なお、今回のブログでは、MVVM、Clean Architecture の説明は割愛するので、詳しくは以下のリンクを参考にしてください。
登場するのが、View、ViewModel、UseCase、Repository です。各クラスの依存関係について、簡単に図でまとめてみました。
View
View の役割は、UIに関連する処理(ユーザからのイベント検知やUI更新など)を行うことです。では、具体的なコードで見ていきましょう!
以下のコードでは、ボタンクリック時のイベント処理を実装しています。
@AndroidEntryPoint
class RegisterAccountFragment : Fragment(R.layout.fragment_register_account) {
private val viewModel: RegisterAccountViewModel by viewModels()
private val binding by viewBinding(FragmentRegisterAccountBinding::bind)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
binding.buttonRegister.setOnClickListener {
viewModel.onButtonRegisterClicked()
}
}
}
ここで重要なのは、ViewModel にイベント処理を委譲している、という点です。
もちろん、以下のように、View から、APIコールなどを行うことは可能です。
binding.buttonRegister.setOnClickListener {
NetworkUtils.instance.registerAccount(email = "XXX", passoword = "XXX") {
override fun onResponse() { ... }
override fun onFailure() { ... }
}
}
しかし、View が担う役割が肥大化し、結果、Fat Fragment となってしまいます。
こうなると、どのクラスに何が実装されているのかが不明確になり、メンテナンスもしづらくなります。また、単一責任の原則 (SRP: Single Responsibility Principle) にも反しますね。
以下のコードでは、UIの更新処理を行っています。プログリットでは、Kotlin Flow を採用しており、ViewModel からUI更新に必要なデータを受信し、UIに反映します。
@AndroidEntryPoint
class RegisterAccountFragment : Fragment(R.layout.fragment_register_account) {
private val binding by viewBinding(FragmentRegisterAccountBinding::bind)
private val viewModel: RegisterAccountViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect {
...
binding.textErrorMessage = it.errorMessage
}
}
}
}
}
ここで、例えば、アカウント登録時に、APIからエラーが返却されると、エラー内容に応じて、画面下部にメッセージを表示させる、実装を行うとします。
View で全てを実装しようとした場合、レスポンスのステータスコードやエラーコードをチェックし、テキストを表示させるロジックが必要です。しかし、これは、View の役割が大きくなり、コードも複雑になってしまいます。どんなメッセージを表示させるかはViewModel に委譲させることで、View はエラーメッセージを表示することを考えるだけで良くなります。
ViewModel
ViewModel の役割は、大きく2つです。
- UI更新に必要な情報を取得 / 加工 / 保持すること。
- View に必要な情報を通知すること。
説明にあたり、アカウント登録画面の実装を考えてみましょう。
ここで、ViewModel で保持すべき情報は、メールアドレスとパスワードです。
また、通信中は、画面上でインジケータを表示する必要があります。よって、View にインジケータを表示させるかどうかを判定するための情報も保持しておきます。
では、実際のコードを確認しましょう!
@HiltViewModel
class RegisterAccountViewModel @Inject constructor(
private val registerAccount: RegisterAccountUseCase,
) : ViewModel() {
data class UiState(
val email: String,
val password: String,
val isProgressVisible: Boolean,
)
sealed interface UiEvent {
object Success : UiEvent
object Failure : UiEvent
}
private val _uiState = MutableStateFlow(
UiState(
email = "", password = "", isProgressVisible = false,
)
)
val uiState = _uiState.asStateFlow()
private val _uiEvent = MutableSharedFlow<UiEvent>()
val uiEvent: SharedFlow<UiEvent> = _uiEvent.asSharedFlow()
fun onEmailChanged(email: String) {
_uiState.update { it.copy(email = email) }
}
fun onPasswordChanged(password: String) {
_uiState.update { it.copy(password = password) }
}
fun onButtonRegisterClicked() {
viewModelScope.launch {
kotlin.runCatching {
_uiState.update { it.copy(isProgressVisible = true) }
registerAccount(_uiState.value.email, _uiState.value.password)
}.onSuccess {
// アカウント登録に成功した場合_uiEvent.emit(UiEvent.Success)
_uiState.update { it.copy(isProgressVisible = false) }
_uiEvent.emit(UiEvent.Success)
}.onFailure {
// アカウント登録に失敗した場合
_uiState.update { it.copy(isProgressVisible = false) }
_uiEvent.emit(UiEvent.Failure)
}
}
}
}
onEmailChanged(...) や onPasswordChanged(...) では、各テキストフィールドに変更があった場合に呼びだされ、変更情報を _uiState に保持します。onButtonRegisterClicked() は、[確定]ボタン押下時、アカウント登録処理を行う関数です。 _uiState に保持している、メールアドレスとパスワードを取得し、アカウント登録処理を行います。ここで、以下のコードに注目してください。
_uiState.update { it.copy(isProgressVisible = XXX) }
登録処理中は、isProgressVisible を true にし、登録処理完了後、false に設定しています。_uiState を更新したタイミングで、View に通知され、View では、isProgressVisible を参照し、インジケータを表示させるかどうかを判断します。
つまり、ViewModel はUI更新に必要な情報をView に通知する役割になっています。
そして、登録時の処理は、以下のコードで実装しております。登録成功の場合は、UiEvent.Success、失敗の場合は、UiEvent.Failure を View に通知します。その後のUI表示は、View の役割です。
kotlin.runCatching {
_uiState.update { it.copy(isProgressVisible = true) }
registerAccount(_uiState.value.email, _uiState.value.password)
}.onSuccess {
// アカウント登録に成功した場合
_uiState.update { it.copy(isProgressVisible = false) }
_uiEvent.emit(UiEvent.Success)
}.onFailure {
// アカウント登録に失敗した場合
_uiState.update { it.copy(isProgressVisible = false) }
_uiEvent.emit(UiEvent.Failure)
}
ここで、登録時に呼び出している registerAccount(...) は、次のセクションで説明する UseCase です。ViewModel では、ビジネスロジックの実装は行いません。ビジネス的な処理を実装すれば、ViewModel が担う役割が広がり、Fat ViewModel になってしまうからです。
UseCase
UseCase は アプリアーキテクチャガイド におけるドメイン層にあたります。UseCase の役割は、複雑なビジネスロジックをカプセル化することです。
例えば、アカウント登録時には、以下のプロセスが必要になります。
- アカウント登録用のWebAPIを呼び出す。
- 呼び出しに成功した場合、レスポンス内に格納されたアクセストークンをストレージに保存する。
このような処理をカプセル化することで、ViewModel は、アカウント登録時のプロセスを知る必要がなくなります。では、実際のコードを見てみましょう!
@Singleton
class RegisterAccountUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(email: String, password: String) {
val account = authRepository.registerAccount(email, password)
authRepository.saveAccessToken(account.accessToken)
}
}
UseCase では、Repository に定義している関数を複数呼び出し、アカウント登録に必要な一連の処理を行っています。
仮に、UseCase が存在しない場合、ViewModel で、Repository の関数を複数呼び出す必要があります。しかし、ViewModel がアカウント登録のプロセスを知る必要があり、ViewModel の役割が大きくなります。結果、ViewModel が肥大化してしまいます。一方、UseCase を介入させることで、複雑なプロセスをカプセル化でき、ViewModel のコードを簡素化できます。
Repository
Repository の役割は、WebAPIや端末のストレージのデータを作成 / 取得 / 更新 / 削除する ことです。
ここで重要なことは、データのアクセス先を隠蔽している、という点です。
では、具体的なコードを見てみましょう!
@Singleton
class AuthRepository @Inject constructor(
private val authApi: AuthApi,
private val authStore: AuthStore,
) {
suspend fun register(email: String, password: String): AuthModel {
return withContext(Dispatchers.IO) {
authApi.register(mail, password)
}
}
suspend fun saveAccessToken(accessToken: String) {
withContext(Dispatchers.IO) {
authStore.saveAccessToken(accessToken)
}
}
}
register(...) は、WebAPIを呼び出し、アカウント登録を行う関数です。
saveAccessToken(...) は、端末のストレージへアクセストークンを保存する関数です。
ここで、上記2つの関数を比べると、データのアクセス先が異なっていますね。これが、Repository のメリットです。データのアクセス先を隠蔽することで、UseCase は、データのアクセス先を知ることなく、必要な処理を、Repository から取捨選択するだけで良くなります。
もし、Repository が存在しない場合、以下の図のように、UseCase を実装する際に、データの取得先を考える必要があります。こうなると、UseCase の役割が大きくなっちゃいますね。
あと、これは、余談ですが、データのアクセス先を、Repository で吸収することで、ライブラリの移行も容易になります。例えば、サーバの通信処理に使用するライブラリを OkHttp から Retrofit に移行しようとします。この際、サーバ通信用のクラスのみ変更するだけで良く、ライブラリ移行の影響範囲を最小限に収めることができます。
UseCaseの必要可否に関して
ここまで、我々が採用している、MVVM × Clean Architecture について、お話しました。
このセクションでは、よく議論に上がる、UseCase の必要可否に関して、個人的な見解をお伝えしようと思います。
アプリアーキテクチャガイドでは、Domain層(つまり UseCase)は Optional として定義されています。
なぜ、 Optional なのかを考えてみると、1つの理由として、 UseCase が Repository のラッパークラスになる場合が数多くあるから、だと個人的に思っています。
@Singleton
class GetAccountInfoUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(): AccountInfo {
return authRepository.getAccountInfo()
}
}
こうなると、わざわざ、UseCase を介さなくてもいい気がしますね。なので、プロジェクトによっては、UseCase を介さず、ViewModel からRepository を参照しているかもしれません。
しかし、個人的な見解としては、ViewModel と Repository の間に、必ず、UseCase を介すべきだと考えています!
なぜなら、UseCase を介すことで、将来的な機能拡張に役立つ可能性があるからです!
ここで、例として、指定したアーティストの楽曲を全て取得し、画面に表示する機能実装を考えてみましょう!以下のコードでは、UseCase が Repository のラッパークラスになっています。
data class MusciModel (
val artistName: String,
val musicName: String,
...
)
@Singleton
class MusicRepository @Inject constructor(
private val musicApi: MusicApi,
) {
suspend fun fetchAll(artistId: Int): MusicModel {
return withContext(Dispatchers.IO) {
musicApi.fetch(artistId)
}
}
}
@Singleton
class GetAllMusicUseCase @Inject constructor(
private val musicRepository: MusicRepository,
) {
suspend operator fun invoke(artistId: Int): MusicModel {
return musicRepository.fetchAll(artistId)
}
}
しかし、あるアーティストから、一部の楽曲をアプリで視聴できないようにしてほしい、と要求が出てきました。そこで、サーバから削除フラグを取得し、アプリ側で表示判定を行うことになりました。
UseCase を介さなかった場合の、元々のコードは以下の通りです。
@Singleton
class MusicRepository @Inject constructor(
private val musicApi: MusicApi,
) {
suspend fun fetchAll(artistId: Int): MusicModel {
return withContext(Dispatchers.IO) {
musicApi.fetch(artistId)
}
}
}
@HiltViewModel
class MusicViewModel @Inject constructor(
private val musicRepository: MusicRepository,
) : ViewModel() {
private var artistId: Int = XXX
init {
...
musicRepository.fetchAll(artistId)
...
}
}
追加実装にあたり、Repository もしくは ViewModel の修正が必要になるわけですが、どちらを修正するにしても問題が表示します。
- ViewModelを修正する場合
- ViewModel にビジネスロジックが入り込んでしまう。
該当の Repository 内の関数が呼び出されている箇所を全て修正する必要がある。
- Repositoryを修正する場合
- Repository にビジネスロジックが入り込んでしまう。
- isDeleted がtrue の楽曲も取得したい場合、新たにRepository に関数を作る必要があり、関数の再利用ができない。
しかし、UseCase を介していれば、修正はたった1クラスで終わります!
@Singleton
class GetAllMusicUseCase @Inject constructor(
private val musicRepository: MusicRepository,
) {
suspend operator fun invoke(artistId: Int): MusicModel {
// 削除フラグがtrueのものを排除する
return musicRepository.fetchAll(artistId).filter { !it.isDeleted }
}
}
また、仮に、 isDeleted = true の楽曲情報も取得したい場合は、新たに UseCase を作るだけで良く、Repository の修正も必要なくなります。
この例では、アプリの仕様変更により、楽曲情報を表示する際の「ユースケース」が変わったと言えます。アプリを運用していく上で、ユースケースは変わる可能性があります。このような仕様変更に対して、堅牢なアプリを作っていくために、UseCase で ユースケース を定義( ≒ 実装)していくべきだと考えています。
現地点では、UseCase がRepository のラッパークラスになったとしても、将来的な機能拡張時に、役に立つ場面が出てくると考えています。しかし、これは個々のプロジェクトにもよると思うので、参考程度にしていただけると幸いです。
最後に
今回は、Android開発におけるアーキテクチャ設計について、お話いたしました。
今後も、Androidにまつわるお話をブログにまとめていこうと思いますので、よろしくお願いします。