안녕하세요. 강남언니에서 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의 영향을 받지 않습니다. 또 네트워크 연결이 끊어졌을 경우 앱을 지속적으로 작동시킬 수 있어 일관성을 유지할 수 있습니다.
Recommended App Architecture
AAC 다이어그램을 살펴보도록 하겠습니다.
Activity/Fragment는 ViewModel을 알고있고 ViewModel에는 LiveData를 가지고 있으며 Repository를 알고 있습니다. 각 구성요소는 하나의 구성요소에만 종속됨을 볼 수 있습니다.
AAC 다이어그램을 참고하여 MVVM을 적용하면 다음과 같이 표현할 수 있습니다.
Example
보다 더 쉽게 이해하기 위하여 간단한 예제를 보면서 설명하도록 하겠습니다.
- View : activity_search.xml
- ViewModel : SearchViewModel
- Model : SearchRepository
input에는 GitHub 사용자 이름을 입력할 수 있고 searchBtn을 누르면 해당 이름을 가진 id가 recyclerView에 출력됩니다.
ViewModel
먼저 ViewModel 부터 살펴보도록 하겠습니다.
ViewModel은 Activity보다 긴 LifeCycle을 가집니다.
ViewModel은 UI에 관련된 데이터를 유지, 관리하기 위한 클래스입니다.
이때 주의해야할 점이 있는데 절대로 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를 왜 사용해야하는가 부터 실제 프로덕션에 적용하였을때 겪었던 부분에 대하여 공유할 예정입니다. 많은 관심 부탁드립니다.