안녕하세요. 강남언니에서 Android 개발을 맡고 있는 David 입니다. 이번 글에서는 Android Architecture Components를 활용하여 MVVM을 적용하는 방법에 대하여 다뤄보도록 하겠습니다.

먼저 AAC(Android Architecture Components)에 대하여 간략하게 설명하도록 하겠습니다.

  • 테스트와 유지관리가 쉬운 앱을 디자인하도록 돕는 라이브러리 모음
  • ViewModel은 앱 회전시 제거되지 않는 UI 관련 데이터를 저장
  • LiveData를 사용하여 기본 데이터베이스가 변경되면 뷰에 알리는 데이터 개체를 빌드
  • Room은 SQLite 개체 매핑 라이브러리 RxJava, Flowable, LiveData Observable을 반환

Why use Android Architecture Components

개발자들은 AAC를 왜 사용해야할까요?

AAC를 사용하는 이유는 관심사 분리 하기 위해서 입니다.

Activity/Fragment에 모든 코드를 작성하는 경우가 많았고 이러한 문제를 해결하기 위하여 다양한 Architecture을 사용하여 해결해왔습니다.

AAC를 활용하면 View 객체 및 앱 구성요소와 독립되어 있어 LifeCycle의 영향을 받지 않습니다. 또 네트워크 연결이 끊어졌을 경우 앱을 지속적으로 작동시킬 수 있어 일관성을 유지할 수 있습니다.


AAC 다이어그램을 살펴보도록 하겠습니다.

image1_1

Activity/Fragment는 ViewModel을 알고있고 ViewModel에는 LiveData를 가지고 있으며 Repository를 알고 있습니다. 각 구성요소는 하나의 구성요소에만 종속됨을 볼 수 있습니다.

AAC 다이어그램을 참고하여 MVVM을 적용하면 다음과 같이 표현할 수 있습니다.

image1_2


Example

보다 더 쉽게 이해하기 위하여 간단한 예제를 보면서 설명하도록 하겠습니다.

image1_3

  • View : activity_search.xml
  • ViewModel : SearchViewModel
  • Model : SearchRepository

input에는 GitHub 사용자 이름을 입력할 수 있고 searchBtn을 누르면 해당 이름을 가진 id가 recyclerView에 출력됩니다.


ViewModel

먼저 ViewModel 부터 살펴보도록 하겠습니다.

ViewModel은 Activity보다 긴 LifeCycle을 가집니다.

ViewModel은 UI에 관련된 데이터를 유지, 관리하기 위한 클래스입니다.

image1_4

이때 주의해야할 점이 있는데 절대로 context의 레퍼런스를 가지는 객체 (ex : Activity, Fragment, View) 를 ViewModel에 집어넣으면 안됩니다. 집어넣었을 경우 memory leak 현상이 나타납니다.

ViewModel은 Activity/Fragment 보다 더 긴 LifeCycle을 가지고 있기 때문에

configuration이 바뀌었을 경우 Activity/Fragment는 destroy 되고 recreate 됩니다.

이때 생성된 새로운 instance는 ViewModel의 동일한 instance 가지게 됩니다.

따라서 ViewModel에 Activity/Fragment에 대해 참조를 유지하는것은 memory leak 현상이 일어나고 최악의 경우에는 crash가 날 수 있습니다.

SearchViewModel

class SearchViewModel(private val repository: SearchRepository) : BaseViewModel() {
    private var name: String = ""
    val users: MutableLiveData<ArrayList<User>> = MutableLiveData(arrayListOf())
    
    fun search() {
        repository.fetchUsers(name)
            .subscribe({ users.value = it.items },
                { Log.e("search api error", it.toString()) })
            .addTo(compositeDisposable)
    }
    
    fun onTextChanged(text: CharSequence) {
        this.name = text.toString()
    }
}

search 메서드를 보면 Repository를 통하여 사용자 이름을 가져와서 LiveData 타입인 users에 저장하는것을 볼 수 있습니다.

LiveData

RxJava 라이브러리를 이미 사용중이라면 LiveData 대신에 RxJava를 사용하셔도 됩니다.

LiveData도 RxJava와 유사하게 데이터가 변경 됬을 경우 observer에게 데이터 업데이트를 알려줍니다.

하지만 RxJava를 이용했을 경우 앱 수명주기에 맞춰 dispose를 해야하는 불편함이 있습니다.

그와 반대로 LiveData를 사용할 경우 다양한 이점이 있습니다.

  • UI와 데이터 상태의 일치 보장
  • memory leak 현상 없음
  • 항상 최신의 데이터를 받을 수 있음
  • LifeCycle를 수동으로 처리할 필요 없음
  • 리소스 공유

주로 LiveData는 ViewModel 클래스 내에서 사용됩니다.

LiveData에 저장된 데이터를 업데이트 하고 싶을 경우 setValue(T)postValue(T) 메서드를 이용하면 LiveData를 관찰하는 observer에게 업데이트된 데이터를 넘겨줍니다.

 val users: MutableLiveData<ArrayList<User>> = MutableLiveData(arrayListOf())

 fun search() {
        repository.fetchUsers(name)
            .subscribe({ users.value = it.items },
                { Log.e("search api error", it.toString()) })
            .addTo(compositeDisposable)
    }

결국 search 메서드가 호출되면 users의 값이 업데이트되고

users를 관찰하는 observer는 업데이트된 users를 받을 수 있습니다.


View

예전에는 Activity/Fragment에 UI 관련된 코드가 있었지만

DataBinding을 사용하면 UI에 관련된 코드를 xml에 작성할 수 있습니다.

activity_search.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">
    <!-- ViewModel 정의 -->
    <data>
        <variable
            name="searchVm"
            type="com.dhha22.architecture.search.SearchViewModel" />
    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
				...
        >
        ...
        <!-- search 메서드 호출 -->
        <ImageButton
            android:id="@+id/searchBtn"
            ...
            android:onClick="@{() ->searchVm.search()}"/>
      
        <!-- LiveData users -->
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            ...
            app:itemList="@{searchVm.users}"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

layout 루트 태그로 시작하고 data요소 안에는 아까 선언했던 SearchViewModel이 있습니다.

searchBtn을 눌렀을 경우 SearchViewModel에 선언된 search 메서드가 호출되고

recyclerView에서는 app:itemList를 통해 LiveData 타입인 users를 받고 있습니다.

SearchBindingAdapter

@BindingAdapter("app:itemList")
fun RecyclerView.setListItem(users: ArrayList<User>) {
    (adapter as? SearchAdapter)?.apply {
        items = users
        notifyDataSetChanged()
    } ?: run { adapter = SearchAdapter(users) }
}

BindingAdapter를 보면 Adapter가 없을 경우에는 Adapter를 생성하고

Adapter가 있을 경우 SearchViewModel의 users를 받아서 데이터를 업데이트 하는 모습을 볼 수 있습니다.

LiveData를 활용하여 UI에 데이터 변경 알림을 받을 수 있어 복잡한 로직을 줄일 수 있습니다.


Model

ViewModel에 SearchService를 직접 호출하여 데이터를 가져오고 해당 데이터를 ViewModel에 있는 LiveData 객체에 할당할 수 있지만

이렇게 개발했을 경우 기능이 추가됬을 때 유지관리가 어려워질 수 있기 때문에 Repository를 만들고

Repository에 데이터를 가져오는 작업을 처리하면 좀 더 깔끔한 코드를 짤 수 있습니다.

SearchRepository

class SearchRepository(private val searchService: SearchService) {
    fun fetchUsers(name: String): Single<UsersResponse> {
        val param = HashMap<String, String>().apply {
            this["q"] = "$name in:name"
        }
        return searchService.getUsers(param)
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
    }
}

현재는 서버에서 데이터를 가져오는 방법밖에 없지만

추후 캐싱에 대한 기능을 추가하고 싶으면 Repository 클래스 안에서 기능을 구현하고

Repository는 다양한 데이터 소스 중재자 역할을 할 수 있습니다.

Repository 객체를 만들면 SearchService의 객체도 만들어야하는데 DI를 이용하면 이러한 문제점을 쉽게 해결할 수 있습니다.

상황에 따라 Dagger2 또는 Koin을 이용하시면 됩니다.


Review

지금까지 개발하면서 MVP + Clean Architecture + DDD 방식을 사용해왔는데

MVP의 가장 큰 문제점인 Presenter 안에 중복된 코드가 나타나는 현상이 나타났습니다.

이러한 문제점을 해결하기 위하여 AOP(Aspect Oriented Programming) 방식도 해보고

다양한 방법으로 개발을 진행하였으나 보다 더 깔끔한 코드를 짜는데 한계를 느꼈습니다.

그 후 AAC에 대하여 학습을 진행하고 AAC + MVVM + DDD 방식을 사용해보니 전보다 복잡성이 낮아지고 구조도 간결해졌습니다.

추후 드로이드 나이츠 2020에서 DDD를 적용하여 복잡한 도메인을 단순하게 만드는 방법에 대하여 발표할 예정입니다. DDD를 왜 사용해야하는가 부터 실제 프로덕션에 적용하였을때 겪었던 부분에 대하여 공유할 예정입니다. 많은 관심 부탁드립니다.