해당 포스팅은 When to load data in ViewModels을 번역한 포스팅입니다.
개요
안드로이드에서 AAC의 ViewModel과 LiveData는 핵심적인 컴포넌트이다. 이를 통해 라이프 사이클을 인지하여 보다 더 안전하고 효율적인 코드를 작성할 수 있게 된다. 이 과정에서 ViewModel 내부에 선언된 LiveData를 Activity가 사용한다. 이 때, ViewModel은 데이터를 로딩하여 LiveData에 세팅하는 적절한 시점이 필요하다. 언제, 어떻게 이를 효율적으로 관리할 수 있을까?
Use Case
해당 포스팅에서 사용할 Use Case는 다음과 같다. 책 리스트를 ViewModel에서 사용하고 LiveData를 이용하여 발행한다.
class Books(val names: List<String>)
data class Parameters(val namePrefix: String = "")
class GetBooksUseCase {
fun loadBooks(parameters: Parameters, onLoad: (Books) -> Unit) {
/* Implementation detail */
}
}
class BooksViewModel(val getBooksUseCase: GetBooksUseCase) : ViewModel() {
fun Books(parameters: Parameters): LiveData<Books> {
TODO("What to return here?")
}
}
필요한 조건
효과적으로 데이터를 로딩하기 위해 몇 가지 조건을 만족해야 한다.
- ViewModel을 활용하여 로딩하여 구성 변경에서 분리한다.
- 클린 코드를 통해 이해하고 구현하기 쉬워야 한다.
- Small API를 통해 ViewModel을 사용하는 데 필요한 지식을 줄일 수 있어야 한다.
- 파라미터를 제공할 수 있어야 한다. ViewModel은 데이터를 로딩할 때 파라미터가 필요한 경우가 많다.
❌ Bad: 메소드 호출
가장 쉽지만 문제점이 많은 방법이다. 결국 메소드는 라이프 사이클이 엮여 있는 액티비티나 프래그먼트에서 호출하게 된다.
class BooksViewModel(val getBooksUseCase: GetBooksUseCase) : ViewModel() {
private val booksLiveData = MutableLiveData<Books>()
fun loadBooks(parameters: Parameters): LiveData<Books> {
getBooksUseCase.loadBooks(parameters) { booksLiveData.value = it }
}
fun books(): LiveData<Books> = booksLiveData
}
장점
- 구현이 쉽다.
- 파라미터 제공이 쉽다
단점
- 구성 변화 마다 데이터를 다시 로딩해야 한다. 액티비티/프래그먼트의 라이프 사이클로 부터 결합도가 높아진다.
- 매개변수가 동일한 인스턴스에 대해 항상 동일하다는 암시적 조건이 부여된다. loadContacts() 및 contacts() 메소드가 결합된다.
❌ Bad: 뷰모델 생성자에서 로딩
데이터를 오직 한 번만 로딩하도록 하는 방법은 생성자에서 호출하는 것이다.
class BooksViewModel(val getBooksUseCase: GetBooksUseCase) : ViewModel() {
private val booksLiveData = MutableLiveData<Books>()
init{
getBooksUseCase.loadBooks(Parameters()) { booksLiveData.value = it }
}
fun books(): LiveData<Books> = booksLiveData
}
장점
- 데이터를 한 번만 로딩한다.
- 구현하기 쉽다.
- public 메소드는 books() 하나이다.
단점
- 매개변수를 전달할 수 없다.
- 생성자에서 일을 하는 것은 적합하지 않다. (다소 논란의 여지가 있음)
👌 Better: Lazy field
다음과 같이 Kotlin의 지연 위임을 활용한다.
class BooksViewModel(val getBooksUseCase: GetBooksUseCase) : ViewModel() {
private val booksLiveData by lazy {
val liveData = MutableLiveData<Books>()
getBooksUseCase.loadBooks(Parameters()) { liveData.value = it }
return@lazy liveData
}
fun books(): LiveData<Books> = booksLiveData
}
장점
- 데이터를 한 번만 로딩한다.
- 구현하기 쉽다.
- public 메소드는 books() 하나이다.
단점
- 매개변수를 전달할 수 없다.
👍 Good: Lazy Map
Lazy Map을 활용하여 매개변수를 제공할 수 있다. 매개변수가 문자열 혹은 다른 불변 클래스일 때, 이를 맵의 키로 활용하여 상응하는 LiveData를 획득하게 한다.
class BooksViewModel(val getBooksUseCase: GetBooksUseCase) : ViewModel() {
private val booksLiveData : Map<Parameters, LiveData<Books>> = lazyMap { parameters ->
val liveData = MutableLiveData<Books>()
getBooksUseCase.loadBooks(Parameters()) { liveData.value = it }
return@lazy liveData
}
fun books(parameters: Parameters): LiveData<Books>
= booksLiveData[parameters]
}
fun <K, V> lazyMap(initializer: (K) -> V): Map<K, V> {
val map = mutableMapOf<K, V>()
return map.withDefault { key ->
val newValue = initializer(key)
map[key] = newValue
return@withDefault newValue
}
}
장점
- 데이터는 LiveData를 처음 접근할 때만 로딩된다.
- 구현하고 이해하기 비교적 쉽다.
- public 메소드는 books() 하나이다.
- 매개변수를 제공할 수 있고, 매개변수가 다수인 상황도 제어할 수 있다.
단점
- 여전히 ViewModel에 일부 변경 가능한 상태가 있다.
👍 Good: Library method = Lazy onActive() case
Room이나 RxJava를 활용할때, Publisher.toLiveData()와 같은 확장 함수를 사용하여 @Dao 객체에서 직접 LiveData를 생성할 수 있는 어답터가 있다.
두 라이브러리의 구현체인 ComputableLiveData와 PublisherLiveData는 LiveData.onActive()가 호출될 때 작업을 수행한 다는 점에서 lazy하다.
class GetBooksUseCase {
fun loadBooks(parameters: Parameters): Flowable<Books> { /* Implementation detail */ }
}
class BooksViewModel(val getBooksUseCase: GetBooksUseCase) : ViewModel() {
fun books(parameters: Parameters): LiveData<Books> {
return getBooksUseCase.loadBooks(parameters).toLiveData()
}
}
장점
- 데이터는 라이프 사이클이 액티브한 상태일 때 한 번만 로딩된다.
- 라이브러리를 통해 구현하기 쉽다.
- public 메소드는 books() 하나이다.
- 매개변수를 제공할 수 있고, 매개변수가 다수인 상황도 제어할 수 있다.
단점
- 로딩 과정이 라이프 사이클에 결합되어 있다. (onStart()의 호출과 관찰자가 있음)
- 메소드 호출마다 새로운 LiveData를 생성한다.
👍 Good: 생성자에 매개변수를 전달한다.
LazyMap을 활용하는 단계에서 Map을 파라미터를 전달하기 위한 용도로 사용했다. 하지만, ViewModel의 인스턴스는 대부분의 경우 동일한 매개변수를 갖는다.
따라서, 매개변수를 생성자에 전달하고 생성자에서 로딩하는 것이 훨씬 좋다. 이를 ViewModelProvider.Factory로 가능하다.
class BooksViewModel(
val getBooksUseCase: GetBooksUseCase,
parameters: Parameters
) : ViewModel() {
private val booksLiveData by lazy {
val liveData = MutableLiveData<Books>()
getBooksUseCase.loadBooks(parameters) { liveData.value = it }
return@lazy liveData
}
fun books(): LiveData<Books> = booksLiveData
}
class BooksViewModelFactory(val getBooksUseCase: GetContactsUseCase, val parameters: Parameters)
: ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return BooksViewModel(getBooksUseCase, parameters) as T
}
}
장점
- 데이터는 한 번만 로딩된다.
- ViewModel의 생성자로 부터 매개변수를 받아 테스트하기 쉽다.
- public 메소드는 books() 하나이다.
단점
- 구현과 이해가 쉽지 않고, 보일러 플레이트가 늘어난다.
이 방법의 경우 매개변수를 전달하는 방식으로 ViewModelFactory에 연결하기 위해 추가적인 코드가 필요하다. 동시에 다른 의존성 문제가 생기고, 매개변수와 함께 Factory에 전달하여 더 많은 보일러 플레이트 생성이 필요하다.
Jake Wartor의 Assisted Injection이 이를 해결하기 위해 노력하고 있다. 하지만 여전히 보일러 플레이트가 존재하고, 다른 여러 옵션이 더 적합할 수도 있다.
어떤 방법을 선택해야 하는가
해당 포스팅의 저자인 Josef Raska는 Lazy Map 접근법이 장단점의 밸런스가 맞아 사용하기 좋았다고 서술했다. 그리고 보다시피 완벽한 솔루션은 없고 각 방법을 비교하여 때에 따라 적절한 솔루션을 사용하면 될 것으로 보인다고 덧붙였다.
나의 생각
저자가 각 방법에 대해 good/bad/better 등으로 평가했다. 하지만, 메소드 호출 상황을 제외하고는 모두 다 괜찮은 방법으로 보인다. 물론, 해당 포스팅의 평가 중 '매개변수 제공이 가능해야 한다'라는 조건에 의해 Bad 평가를 받은 접근법이 꽤 있다. 실제 구현환경에서 매개변수를 받지 않는 경우도 많다. 해당하는 경우에는 'Lazy field' 접근법도 좋아 보인다.
또한, '생성자에서 일을 하는 것은 좋지 않다'라는 의견에는 각기 다른 의견이 존재한다. java - Is doing a lot in constructors bad? - Stack Overflow 해당 스택오버플로우 게시글만 봐도 다양하게 토론이 이루어지고 있다. 이에 대한 토론의 자세한 내용은 다음 포스팅에 남기겠다. 생성자에서 일을 해도 적합하다면 매개변수가 없는 경우 생성자에서 데이터를 불러오는 것도 괜찮은 방법일 것이라 생각한다.
'안드로이드' 카테고리의 다른 글
[Android] Fragment 잘 사용하기 (0) | 2022.08.14 |
---|---|
[안드로이드] 태스크(Task)와 백 스택(Back Stack)의 개념과 Launch Mode, Intent Flag (1) | 2021.07.26 |
[안드로이드] PendingIntent란? (0) | 2021.07.26 |
[안드로이드] Retrofit으로 가져온 데이터를 디바이스에 캐싱하기 (0) | 2021.07.24 |
[Android] Coroutine, Retrofit을 활용한 비동기 네트워킹 처리 중 에러 핸들링 (0) | 2021.07.17 |