クリーンなアーキテクチャでMVVMを使用したより優れたAndroidアプリ

公開: 2022-03-11

Androidプロジェクトに適切なアーキテクチャを選択しないと、コードベースが拡大し、チームが拡大するにつれて、アーキテクチャを維持するのに苦労することになります。

これはAndroidMVVMチュートリアルだけではありません。 この記事では、MVVM(Model-View-ViewModelまたは場合によっては定型化された「ViewModelパターン」)をクリーンアーキテクチャと組み合わせます。 このアーキテクチャを使用して、分離され、テスト可能で、保守可能なコードを作成する方法を見ていきます。

なぜクリーンなアーキテクチャのMVVMなのか?

MVVMは、ビュー(つまり、 ActivityFragment )をビジネスロジックから分離します。 小さなプロジェクトにはMVVMで十分ですが、コードベースが巨大になると、 ViewModelが肥大化し始めます。 責任の分離は難しくなります。

クリーンなアーキテクチャを備えたMVVMは、このような場合に非常に適しています。 これは、コードベースの責任を分離する上でさらに一歩進んだものです。 アプリで実行できるアクションのロジックを明確に抽象化します。

注:CleanArchitectureをmodel-view-presenter(MVP)アーキテクチャと組み合わせることもできます。 ただし、Androidアーキテクチャコンポーネントにはすでに組み込みのViewModelクラスが用意されているため、MVVMoverMVPを使用します。MVVMフレームワークは必要ありません。

クリーンなアーキテクチャを使用する利点

  • コードは、プレーンMVVMよりもさらに簡単にテストできます。
  • コードはさらに分離されます(最大の利点)。
  • パッケージ構造はさらにナビゲートしやすくなっています。
  • プロジェクトの保守はさらに簡単です。
  • チームは、新しい機能をさらに迅速に追加できます。

クリーンなアーキテクチャのデメリット

  • 少し急な学習曲線があります。 特に単純なMVVMやMVPのようなパターンから来ている場合は、すべてのレイヤーがどのように連携するかを理解するのに時間がかかる場合があります。
  • 多くの余分なクラスが追加されるため、複雑さの低いプロジェクトには理想的ではありません。

データフローは次のようになります。

クリーンアーキテクチャを備えたMVVMのデータフロー。データは、ビューからビューモデル、ドメイン、データリポジトリ、そしてデータソース(ローカルまたはリモート)に流れます。

私たちのビジネスロジックは、UIから完全に切り離されています。 これにより、コードの保守とテストが非常に簡単になります。

これから説明する例は非常に単純です。 これにより、ユーザーは新しい投稿を作成し、自分が作成した投稿のリストを表示できます。 この例では、簡単にするためにサードパーティのライブラリ(Dagger、RxJavaなど)を使用していません。

クリーンなアーキテクチャを備えたMVVMのレイヤー

コードは3つの別々のレイヤーに分割されています。

  1. プレゼンテーション層
  2. ドメインレイヤー
  3. データ層

以下の各レイヤーについて詳しく説明します。 今のところ、結果のパッケージ構造は次のようになります。

クリーンアーキテクチャパッケージ構造のMVVM。

私たちが使用しているAndroidアプリのアーキテクチャ内でも、ファイル/フォルダー階層を構造化する方法はたくさんあります。 機能に基づいてプロジェクトファイルをグループ化するのが好きです。 私はそれがきちんとしていて簡潔だと思います。 自分に合ったプロジェクト構造を自由に選択できます。

プレゼンテーション層

これには、 ActivityFragment 、およびViewModelが含まれます。 Activityはできるだけ馬鹿にする必要があります。 ビジネスロジックをActivityに入れないでください。

ActivityViewModelと通信し、 ViewModelはドメインレイヤーと通信してアクションを実行します。 ViewModelがデータレイヤーと直接通信することはありません。

ここでは、 UseCaseHandlerと2つのUseCaseViewModelに渡します。 これについてはすぐに詳しく説明しますが、このアーキテクチャでは、 UseCaseViewModelがデータレイヤーとどのように相互作用するかを定義するアクションです。

Kotlinコードは次のようになります。

 class PostListViewModel( val useCaseHandler: UseCaseHandler, val getPosts: GetPosts, val savePost: SavePost): ViewModel() { fun getAllPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { val requestValue = GetPosts.RequestValues(userId) useCaseHandler.execute(getPosts, requestValue, object : UseCase.UseCaseCallback<GetPosts.ResponseValue> { override fun onSuccess(response: GetPosts.ResponseValue) { callback.onPostsLoaded(response.posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } fun savePost(post: Post, callback: PostDataSource.SaveTaskCallback) { val requestValues = SavePost.RequestValues(post) useCaseHandler.execute(savePost, requestValues, object : UseCase.UseCaseCallback<SavePost.ResponseValue> { override fun onSuccess(response: SavePost.ResponseValue) { callback.onSaveSuccess() } override fun onError(t: Throwable) { callback.onError(t) } }) } }

ドメインレイヤー

ドメインレイヤーには、アプリケーションのすべてのユースケースが含まれています。 この例では、抽象クラスであるUseCaseがあります。 すべてのUseCaseはこのクラスを拡張します。

 abstract class UseCase<Q : UseCase.RequestValues, P : UseCase.ResponseValue> { var requestValues: Q? = null var useCaseCallback: UseCaseCallback<P>? = null internal fun run() { executeUseCase(requestValues) } protected abstract fun executeUseCase(requestValues: Q?) /** * Data passed to a request. */ interface RequestValues /** * Data received from a request. */ interface ResponseValue interface UseCaseCallback<R> { fun onSuccess(response: R) fun onError(t: Throwable) } }

また、 UseCaseHandlerUseCaseの実行を処理します。 データベースまたはリモートサーバーからデータをフェッチするときに、UIをブロックしないでください。 これは、バックグラウンドスレッドでUseCaseを実行し、メインスレッドで応答を受け取ることにした場所です。

 class UseCaseHandler(private val mUseCaseScheduler: UseCaseScheduler) { fun <T : UseCase.RequestValues, R : UseCase.ResponseValue> execute( useCase: UseCase<T, R>, values: T, callback: UseCase.UseCaseCallback<R>) { useCase.requestValues = values useCase.useCaseCallback = UiCallbackWrapper(callback, this) mUseCaseScheduler.execute(Runnable { useCase.run() }) } private fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mUseCaseScheduler.notifyResponse(response, useCaseCallback) } private fun <V : UseCase.ResponseValue> notifyError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mUseCaseScheduler.onError(useCaseCallback, t) } private class UiCallbackWrapper<V : UseCase.ResponseValue>( private val mCallback: UseCase.UseCaseCallback<V>, private val mUseCaseHandler: UseCaseHandler) : UseCase.UseCaseCallback<V> { override fun onSuccess(response: V) { mUseCaseHandler.notifyResponse(response, mCallback) } override fun onError(t: Throwable) { mUseCaseHandler.notifyError(mCallback, t) } } companion object { private var INSTANCE: UseCaseHandler? = null fun getInstance(): UseCaseHandler { if (INSTANCE == null) { INSTANCE = UseCaseHandler(UseCaseThreadPoolScheduler()) } return INSTANCE!! } } }

その名前が示すように、 GetPostsUseCaseのすべての投稿を取得する責任があります。

 class GetPosts(private val mDataSource: PostDataSource) : UseCase<GetPosts.RequestValues, GetPosts.ResponseValue>() { protected override fun executeUseCase(requestValues: GetPosts.RequestValues?) { mDataSource.getPosts(requestValues?.userId ?: -1, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { val responseValue = ResponseValue(posts) useCaseCallback?.onSuccess(responseValue) } override fun onError(t: Throwable) { // Never use generic exceptions. Create proper exceptions. Since // our use case is different we will go with generic throwable useCaseCallback?.onError(Throwable("Data not found")) } }) } class RequestValues(val userId: Int) : UseCase.RequestValues class ResponseValue(val posts: List<Post>) : UseCase.ResponseValue }

UseCaseの目的は、 ViewModelRepositoryの間の仲介役になることです。

将来、「投稿の編集」機能を追加することにしたとします。 新しいEditPostを追加するUseCaseで、そのすべてのコードが完全に分離され、他のUseCaseから切り離されます。 私たちは皆、何度もそれを見てきました。新しい機能が導入され、それらは既存のコードの何かをうっかり壊してしまいます。 別のUseCaseを作成すると、それを回避するのに非常に役立ちます。

もちろん、その可能性を100%排除することはできませんが、それを最小限に抑えることはできます。 これが、Clean Architectureを他のパターンから区別するものです。コードは非常に分離されているため、すべてのレイヤーをブラックボックスとして扱うことができます。

データレイヤー

これには、ドメインレイヤーが使用できるすべてのリポジトリがあります。 このレイヤーは、データソースAPIを外部クラスに公開します。

 interface PostDataSource { interface LoadPostsCallback { fun onPostsLoaded(posts: List<Post>) fun onError(t: Throwable) } interface SaveTaskCallback { fun onSaveSuccess() fun onError(t: Throwable) } fun getPosts(userId: Int, callback: LoadPostsCallback) fun savePost(post: Post) }

PostDataRepositoryPostDataSourceを実装します。 ローカルデータベースからデータを取得するか、リモートサーバーからデータを取得するかを決定します。

 class PostDataRepository private constructor( private val localDataSource: PostDataSource, private val remoteDataSource: PostDataSource): PostDataSource { companion object { private var INSTANCE: PostDataRepository? = null fun getInstance(localDataSource: PostDataSource, remoteDataSource: PostDataSource): PostDataRepository { if (INSTANCE == null) { INSTANCE = PostDataRepository(localDataSource, remoteDataSource) } return INSTANCE!! } } var isCacheDirty = false override fun getPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { if (isCacheDirty) { getPostsFromServer(userId, callback) } else { localDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { getPostsFromServer(userId, callback) } }) } } override fun savePost(post: Post) { localDataSource.savePost(post) remoteDataSource.savePost(post) } private fun getPostsFromServer(userId: Int, callback: PostDataSource.LoadPostsCallback) { remoteDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() refreshLocalDataSource(posts) callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } private fun refreshLocalDataSource(posts: List<Post>) { posts.forEach { localDataSource.savePost(it) } } private fun refreshCache() { isCacheDirty = false } }

コードはほとんど自明です。 このクラスには、 localDataSourceremoteDataSourceの2つの変数があります。 それらのタイプはPostDataSourceであるため、実際に内部でどのように実装されているかは関係ありません。

私の個人的な経験では、このアーキテクチャは非常に貴重であることが証明されています。 私のアプリの1つでは、アプリをすばやく構築するのに最適なバックエンドのFirebaseから始めました。 最終的には自分のサーバーに移行する必要があることはわかっていました。

私がやったとき、私がしなければならなかったのは、 RemoteDataSourceの実装を変更することだけでした。 このような大きな変化の後でも、他のクラスに触れる必要はありませんでした。 これが、分離されたコードの利点です。 特定のクラスを変更しても、コードの他の部分には影響しません。

私たちが持っている追加のクラスのいくつかは次のとおりです。

 interface UseCaseScheduler { fun execute(runnable: Runnable) fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) } class UseCaseThreadPoolScheduler : UseCaseScheduler { val POOL_SIZE = 2 val MAX_POOL_SIZE = 4 val TIMEOUT = 30 private val mHandler = Handler() internal var mThreadPoolExecutor: ThreadPoolExecutor init { mThreadPoolExecutor = ThreadPoolExecutor(POOL_SIZE, MAX_POOL_SIZE, TIMEOUT.toLong(), TimeUnit.SECONDS, ArrayBlockingQueue(POOL_SIZE)) } override fun execute(runnable: Runnable) { mThreadPoolExecutor.execute(runnable) } override fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mHandler.post { useCaseCallback.onSuccess(response) } } override fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mHandler.post { useCaseCallback.onError(t) } } }

UseCaseThreadPoolSchedulerは、 ThreadPoolExecuterを使用して非同期でタスクを実行する役割を果たします。

 class ViewModelFactory : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { if (modelClass == PostListViewModel::class.java) { return PostListViewModel( Injection.provideUseCaseHandler() , Injection.provideGetPosts(), Injection.provideSavePost()) as T } throw IllegalArgumentException("unknown model class $modelClass") } companion object { private var INSTANCE: ViewModelFactory? = null fun getInstance(): ViewModelFactory { if (INSTANCE == null) { INSTANCE = ViewModelFactory() } return INSTANCE!! } } }

これがViewModelFactoryです。 ViewModelコンストラクターで引数を渡すには、これを作成する必要があります。

依存性注入

依存性注入について例を挙げて説明します。 PostDataRepositoryクラスを見ると、 LocalDataSourceRemoteDataSourceの2つの依存関係があります。 これらの依存関係をPostDataRepositoryクラスに提供するために、 Injectionクラスを使用します。

依存関係の注入には、2つの主な利点があります。 1つは、オブジェクトをコードベース全体に分散させるのではなく、中央の場所からオブジェクトのインスタンス化を制御できることです。 もう1つは、これにより、 PostDataRepositoryの単体テストを作成するのに役立ちます。これは、 LocalDataSourceRemoteDataSourceのモックバージョンを実際の値の代わりにPostDataRepositoryコンストラクターに渡すことができるためです。

 object Injection { fun providePostDataRepository(): PostDataRepository { return PostDataRepository.getInstance(provideLocalDataSource(), provideRemoteDataSource()) } fun provideViewModelFactory() = ViewModelFactory.getInstance() fun provideLocalDataSource(): PostDataSource = LocalDataSource.getInstance() fun provideRemoteDataSource(): PostDataSource = RemoteDataSource.getInstance() fun provideGetPosts() = GetPosts(providePostDataRepository()) fun provideSavePost() = SavePost(providePostDataRepository()) fun provideUseCaseHandler() = UseCaseHandler.getInstance() }

注:複雑なプロジェクトでの依存性注入には、Dagger2を使用することをお勧めします。 しかし、その非常に急な学習曲線で、それはこの記事の範囲を超えています。 したがって、さらに深く掘り下げることに興味がある場合は、HariVigneshJayapalanによるDagger2の紹介を強くお勧めします。

クリーンなアーキテクチャを備えたMVVM:堅実な組み合わせ

このプロジェクトの目的は、クリーンアーキテクチャを備えたMVVMを理解することでした。そのため、さらに改善を試みることができるいくつかのことをスキップしました。

  1. LiveDataまたはRxJavaを使用して、コールバックを削除し、少しすっきりさせます。
  2. 状態を使用してUIを表します。 (そのために、ジェイク・ウォートンによるこの素晴らしい話をチェックしてください。)
  3. Dagger2を使用して依存関係を注入します。

これは、Androidアプリに最適で最もスケーラブルなアーキテクチャの1つです。 この記事を楽しんでいただければ幸いです。また、このアプローチを自分のアプリでどのように使用したかを聞くのを楽しみにしています。

関連: Xamarinフォーム、MVVMCross、SkiaSharp:クロスプラットフォームアプリ開発の聖三位一体