클린 아키텍처와 함께 MVVM을 사용하는 더 나은 Android 앱

게시 됨: 2022-03-11

Android 프로젝트에 적합한 아키텍처를 선택하지 않으면 코드베이스가 커지고 팀이 확장됨에 따라 이를 유지 관리하는 데 어려움을 겪을 것입니다.

이것은 단순한 Android MVVM 튜토리얼이 아닙니다. 이 기사에서는 MVVM(Model-View-ViewModel 또는 때때로 양식화된 "ViewModel 패턴")을 Clean Architecture와 결합할 것입니다. 이 아키텍처를 사용하여 분리되고 테스트 가능하며 유지 관리 가능한 코드를 작성하는 방법을 살펴보겠습니다.

클린 아키텍처를 사용하는 MVVM을 사용해야 하는 이유

MVVM은 보기(예: ActivityFragment )를 비즈니스 논리에서 분리합니다. MVVM은 소규모 프로젝트에 충분하지만 코드베이스가 커지면 ViewModel 이 부풀리기 시작합니다. 책임 분리가 어려워집니다.

Clean Architecture가 포함된 MVVM은 이러한 경우에 매우 좋습니다. 코드 기반의 책임을 분리하는 데 한 단계 더 나아갑니다. 앱에서 수행할 수 있는 작업의 논리를 명확하게 추상화합니다.

참고: 클린 아키텍처를 MVP(모델-뷰-프레젠터) 아키텍처와 결합할 수도 있습니다. 그러나 Android 아키텍처 구성 요소는 이미 내장된 ViewModel 클래스를 제공하므로 MVVM 프레임워크가 필요하지 않은 MVP를 통한 MVVM을 사용할 것입니다!

클린 아키텍처 사용의 장점

  • 코드는 일반 MVVM보다 훨씬 더 쉽게 테스트할 수 있습니다.
  • 코드가 더 분리됩니다(가장 큰 이점).
  • 패키지 구조는 탐색하기가 훨씬 더 쉽습니다.
  • 프로젝트를 유지 관리하기가 훨씬 쉽습니다.
  • 팀은 새로운 기능을 훨씬 더 빠르게 추가할 수 있습니다.

클린 아키텍처의 단점

  • 약간 가파른 학습 곡선이 있습니다. 특히 간단한 MVVM 또는 MVP와 같은 패턴에서 온 경우 모든 레이어가 함께 작동하는 방식을 이해하는 데 시간이 걸릴 수 있습니다.
  • 많은 추가 클래스를 추가하므로 복잡성이 낮은 프로젝트에는 적합하지 않습니다.

데이터 흐름은 다음과 같습니다.

Clean Architecture를 사용하는 MVVM의 데이터 흐름. 데이터는 View에서 ViewModel, Domain, Data Repository, 데이터 소스(로컬 또는 원격)로 흐릅니다.

우리의 비즈니스 로직은 UI와 완전히 분리되어 있습니다. 코드를 유지 관리하고 테스트하기가 매우 쉽습니다.

우리가 보게 될 예는 아주 간단합니다. 그것은 사용자가 새 게시물을 만들고 그들이 만든 게시물 목록을 볼 수 있습니다. 이 예제에서는 단순성을 위해 타사 라이브러리(Dagger, RxJava 등)를 사용하지 않습니다.

클린 아키텍처의 MVVM 계층

코드는 세 개의 개별 레이어로 나뉩니다.

  1. 프레젠테이션 레이어
  2. 도메인 계층
  3. 데이터 레이어

아래에서 각 레이어에 대해 더 자세히 알아보겠습니다. 현재 결과 패키지 구조는 다음과 같습니다.

클린 아키텍처 패키지 구조의 MVVM.

우리가 사용하는 Android 앱 아키텍처 내에서도 파일/폴더 계층 구조를 구성하는 많은 방법이 있습니다. 기능을 기반으로 프로젝트 파일을 그룹화하는 것을 좋아합니다. 깔끔하고 간결한 것 같아요. 귀하에게 적합한 프로젝트 구조를 자유롭게 선택할 수 있습니다.

프레젠테이션 레이어

여기에는 Activity , FragmentViewModel 이 포함됩니다. Activity 은 가능한 한 멍청해야 합니다. 절대로 Activity 에 비즈니스 로직을 넣지 마세요.

ActivityViewModel 과 통신하고 ViewModel 은 작업을 수행하기 위해 도메인 계층과 통신합니다. ViewModel 은 데이터 레이어와 직접 통신하지 않습니다.

여기서 UseCaseHandler 와 두 개의 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!! } } }

이름에서 알 수 있듯이 GetPosts UseCase 는 사용자의 모든 게시물을 가져오는 역할을 합니다.

 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 라는 두 개의 변수가 있습니다. 유형은 PostDataSource 이므로 실제로 내부적으로 구현되는 방식은 신경 쓰지 않습니다.

내 개인적인 경험에 따르면 이 아키텍처는 매우 귀중한 것으로 판명되었습니다. 내 앱 중 하나에서 백엔드에서 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) } } }

UseCaseThreadPoolSchedulerThreadPoolExecuter 를 사용하여 비동기적으로 작업을 실행하는 역할을 합니다.

 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 의 두 가지 종속성이 있습니다. 우리는 PostDataRepository 클래스에 이러한 종속성을 제공하기 위해 Injection 클래스를 사용합니다.

의존성 주입에는 두 가지 주요 이점이 있습니다. 하나는 전체 코드베이스에 걸쳐 개체를 분산시키는 대신 중앙 위치에서 개체의 인스턴스화를 제어할 수 있다는 것입니다. 다른 하나는 이것이 우리가 PostDataRepository 에 대한 단위 테스트를 작성하는 데 도움이 된다는 것입니다. 이제 우리는 실제 값 대신 PostDataRepository 생성자에 LocalDataSourceRemoteDataSource 의 모의 버전을 전달할 수 있기 때문입니다.

 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() }

참고: 복잡한 프로젝트에서 종속성 주입을 위해 Dagger 2를 사용하는 것을 선호합니다. 그러나 매우 가파른 학습 곡선으로 인해 이 기사의 범위를 벗어납니다. 따라서 더 깊이 들어가는 데 관심이 있다면 Dagger 2에 대한 Hari Vignesh Jayapalan의 소개를 적극 권장합니다.

클린 아키텍처의 MVVM: 견고한 조합

이 프로젝트의 목적은 MVVM을 클린 아키텍처로 이해하는 것이었기 때문에 더 개선할 수 있는 몇 가지 사항은 건너뛰었습니다.

  1. LiveData 또는 RxJava를 사용하여 콜백을 제거하고 좀 더 깔끔하게 만드십시오.
  2. 상태를 사용하여 UI를 나타냅니다. (그러려면 Jake Wharton의 이 놀라운 연설을 확인하십시오.)
  3. Dagger 2를 사용하여 종속성을 주입합니다.

이것은 Android 앱을 위한 가장 훌륭하고 확장 가능한 아키텍처 중 하나입니다. 이 기사가 마음에 드셨기를 바라며 여러분의 앱에서 이 접근 방식을 어떻게 사용했는지 듣고 싶습니다!

관련 항목: Xamarin Forms, MVVMCross 및 SkiaSharp: 플랫폼 간 앱 개발의 삼위일체