클린 아키텍처와 함께 MVVM을 사용하는 더 나은 Android 앱
게시 됨: 2022-03-11Android 프로젝트에 적합한 아키텍처를 선택하지 않으면 코드베이스가 커지고 팀이 확장됨에 따라 이를 유지 관리하는 데 어려움을 겪을 것입니다.
이것은 단순한 Android MVVM 튜토리얼이 아닙니다. 이 기사에서는 MVVM(Model-View-ViewModel 또는 때때로 양식화된 "ViewModel 패턴")을 Clean Architecture와 결합할 것입니다. 이 아키텍처를 사용하여 분리되고 테스트 가능하며 유지 관리 가능한 코드를 작성하는 방법을 살펴보겠습니다.
클린 아키텍처를 사용하는 MVVM을 사용해야 하는 이유
MVVM은 보기(예: Activity 및 Fragment )를 비즈니스 논리에서 분리합니다. MVVM은 소규모 프로젝트에 충분하지만 코드베이스가 커지면 ViewModel 이 부풀리기 시작합니다. 책임 분리가 어려워집니다.
Clean Architecture가 포함된 MVVM은 이러한 경우에 매우 좋습니다. 코드 기반의 책임을 분리하는 데 한 단계 더 나아갑니다. 앱에서 수행할 수 있는 작업의 논리를 명확하게 추상화합니다.
참고: 클린 아키텍처를 MVP(모델-뷰-프레젠터) 아키텍처와 결합할 수도 있습니다. 그러나 Android 아키텍처 구성 요소는 이미 내장된 ViewModel 클래스를 제공하므로 MVVM 프레임워크가 필요하지 않은 MVP를 통한 MVVM을 사용할 것입니다!
클린 아키텍처 사용의 장점
- 코드는 일반 MVVM보다 훨씬 더 쉽게 테스트할 수 있습니다.
- 코드가 더 분리됩니다(가장 큰 이점).
- 패키지 구조는 탐색하기가 훨씬 더 쉽습니다.
- 프로젝트를 유지 관리하기가 훨씬 쉽습니다.
- 팀은 새로운 기능을 훨씬 더 빠르게 추가할 수 있습니다.
클린 아키텍처의 단점
- 약간 가파른 학습 곡선이 있습니다. 특히 간단한 MVVM 또는 MVP와 같은 패턴에서 온 경우 모든 레이어가 함께 작동하는 방식을 이해하는 데 시간이 걸릴 수 있습니다.
- 많은 추가 클래스를 추가하므로 복잡성이 낮은 프로젝트에는 적합하지 않습니다.
데이터 흐름은 다음과 같습니다.
우리의 비즈니스 로직은 UI와 완전히 분리되어 있습니다. 코드를 유지 관리하고 테스트하기가 매우 쉽습니다.
우리가 보게 될 예는 아주 간단합니다. 그것은 사용자가 새 게시물을 만들고 그들이 만든 게시물 목록을 볼 수 있습니다. 이 예제에서는 단순성을 위해 타사 라이브러리(Dagger, RxJava 등)를 사용하지 않습니다.
클린 아키텍처의 MVVM 계층
코드는 세 개의 개별 레이어로 나뉩니다.
- 프레젠테이션 레이어
- 도메인 계층
- 데이터 레이어
아래에서 각 레이어에 대해 더 자세히 알아보겠습니다. 현재 결과 패키지 구조는 다음과 같습니다.
우리가 사용하는 Android 앱 아키텍처 내에서도 파일/폴더 계층 구조를 구성하는 많은 방법이 있습니다. 기능을 기반으로 프로젝트 파일을 그룹화하는 것을 좋아합니다. 깔끔하고 간결한 것 같아요. 귀하에게 적합한 프로젝트 구조를 자유롭게 선택할 수 있습니다.
프레젠테이션 레이어
여기에는 Activity , Fragment 및 ViewModel 이 포함됩니다. Activity 은 가능한 한 멍청해야 합니다. 절대로 Activity 에 비즈니스 로직을 넣지 마세요.
Activity 는 ViewModel 과 통신하고 ViewModel 은 작업을 수행하기 위해 도메인 계층과 통신합니다. ViewModel 은 데이터 레이어와 직접 통신하지 않습니다.
여기서 UseCaseHandler 와 두 개의 UseCase 를 ViewModel 에 전달합니다. 곧 자세히 다루겠지만 이 아키텍처에서 UseCase 는 ViewModel 이 데이터 계층과 상호 작용하는 방식을 정의하는 작업입니다.
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) } } 그리고 UseCaseHandler 는 UseCase 의 실행을 처리합니다. 데이터베이스나 원격 서버에서 데이터를 가져올 때 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 의 목적은 ViewModel 과 Repository 사이의 중재자가 되는 것입니다.

나중에 "게시물 편집" 기능을 추가하기로 결정했다고 가정해 보겠습니다. 새로운 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) } PostDataRepository 는 PostDataSource 를 구현합니다. 로컬 데이터베이스 또는 원격 서버에서 데이터를 가져올지 여부를 결정합니다.
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 } } 코드는 대부분 자명합니다. 이 클래스에는 localDataSource 및 remoteDataSource 라는 두 개의 변수가 있습니다. 유형은 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) } } } 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 클래스를 보면 LocalDataSource 및 RemoteDataSource 의 두 가지 종속성이 있습니다. 우리는 PostDataRepository 클래스에 이러한 종속성을 제공하기 위해 Injection 클래스를 사용합니다.
의존성 주입에는 두 가지 주요 이점이 있습니다. 하나는 전체 코드베이스에 걸쳐 개체를 분산시키는 대신 중앙 위치에서 개체의 인스턴스화를 제어할 수 있다는 것입니다. 다른 하나는 이것이 우리가 PostDataRepository 에 대한 단위 테스트를 작성하는 데 도움이 된다는 것입니다. 이제 우리는 실제 값 대신 PostDataRepository 생성자에 LocalDataSource 및 RemoteDataSource 의 모의 버전을 전달할 수 있기 때문입니다.
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을 클린 아키텍처로 이해하는 것이었기 때문에 더 개선할 수 있는 몇 가지 사항은 건너뛰었습니다.
- LiveData 또는 RxJava를 사용하여 콜백을 제거하고 좀 더 깔끔하게 만드십시오.
- 상태를 사용하여 UI를 나타냅니다. (그러려면 Jake Wharton의 이 놀라운 연설을 확인하십시오.)
- Dagger 2를 사용하여 종속성을 주입합니다.
이것은 Android 앱을 위한 가장 훌륭하고 확장 가능한 아키텍처 중 하나입니다. 이 기사가 마음에 드셨기를 바라며 여러분의 앱에서 이 접근 방식을 어떻게 사용했는지 듣고 싶습니다!
