Prologue

이번 포스팅은 LiveData with SnackBar, Navigation and other events (the SingleLiveEvent case) 글을 번역하여 참고했습니다.

Subject

View(Activity, Fragment)가 ViewModel과 통신하는 편한 방법은 LiveData와 같은 Observable을 사용하는 것이다. View는 LiveData의 변경 사항을 구독하고 이에 반응한다. 따라서 화면에 지속적으로 표시되는 데이터에 적합하다.

하지만, Snackbar 메시지, navigation(ex. 화면 전환 등) 이벤트, 다이얼로그 같은 일부 데이터는 한 번만 사용해야 한다.

라이브러리나 Architecture Components를 사용해 이 문제를 해결하는 대신, 디자인 문제에 직면하게 된다. 우리는 이벤트를 상태의 일부로 취급하는 것을 권장한다. 아래에서는 일반적인 실수와 권장되는 접근 방식을 보여준다.

❌ Bad: 1. Using LiveData for events

이러한 방법은 LiveData 객체 내부에 Snackbar 메세지 또는 Navigation 신호를 보유한다. 원칙적으로 LiveData 객체를 이러한 방식으로 사용하는 것은 일반적인 것처럼 보이나, 몇 가지 문제를 가지고 있다.

아래의 코드를 확인해보자.

[ViewModel]

1
2
3
4
5
6
7
8
9
10
11
12
// Don't use this for events
class ListViewModel : ViewModel {
private val _navigateToDetails = MutableLiveData<Boolean>()

val navigateToDetails : LiveData<Boolean>
get() = _navigateToDetails


fun userClicksOnButton() {
_navigateToDetails.value = true
}
}

[View(Activity or Fragment)]

1
2
3
myViewModel.navigateToDetails.observe(this, Observer {
if (it) startActivity(DetailsActivity...)
})
  • 위 방법의 문제점은 _navigateToDetails의 값이 오랫동안 true인 상태로 유지되고 첫 화면으로 돌아갈 수 없다는 것이다.
  • 아래의 단계를 보자.
  1. 사용자가 버튼을 클릭하면 Detail Activity를 시작한다.
  2. 사용자가 back 버튼을 클릭하면 Master Activity로 돌아온다.
  3. 옵저버는 Activity가 백스택에 있는 동안 비활성된 후, 다시 활성화 된다.
  4. value는 여전히 true이므로 Detail Activity가 부적절하게 다시 시작된다.

즉, 버튼을 눌러서 이동하고 back 버튼을 눌러서 원래의 화면으로 돌아와도 다시 화면 이동이 발생하는 무한 루프같은 현상이 일어난다.해결책은 ViewModel에서 value를 true로 설정한 뒤, 즉시 false로 설정하는 것이다.

1
2
3
4
fun userClicksOnButton() {
_navigateToDetails.value = true
_navigateToDetails.value = false // Don't do this
}

하지만, 우리가 기억해야 할 중요한 사항은 LiveData에 값이 있지만 수신하는 모든 값을 방출한다고 보장하지는 않는다. 예를 들어, 옵저버가 활성화되지 않은 경우, 값을 설정할 수 있으므로 새로운 값으로 대체된다. 또한, 다른 스레드에서 값을 설정하면 race condition(경쟁조건)이 발생하여 옵저버에게 한 번만 호출할 수 있다.

즉, lifecycle에 따라 항상 observe를 하지 않고 필요한 경우에만 observe하기 때문에 정상적으로 동작하지 않을 수 있으며, 이해하기 어렵고 가독성이 떨어진다. 이 방법은 절대 사용하면 안된다.

그렇다면 이와 같은 Navigation Event(탐색 ex. 화면 전환 및 클릭 등)가 발생한 후 값이 재설정되도록 하려면 어떻게 해야 할까??

❌ Better: 2. Using LiveData for events, resetting event values in observer

이 방법을 사용하면 View에서 이벤트를 이미 처리했으며, 재설정해야 함을 나타내는 방법을 추가한다.

[Usage]

  • 옵저버에 약간의 변화를 주면 이에 대한 해결책을 얻을 수 있다.
1
2
3
4
5
6
listViewModel.navigateToDetails.observe(this, Observer {
if (it) {
myViewModel.navigateToDetailsHandled() // 뷰모델에게 이벤트를 처리했다고 알려줌으로써 값을 다시 false로 바꿔준다.
startActivity(DetailsActivity...)
}
})
  • ViewModel에 아래와 같은 메소드를 추가해주자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ListViewModel : ViewModel {
private val _navigateToDetails = MutableLiveData<Boolean>()

val navigateToDetails : LiveData<Boolean>
get() = _navigateToDetails


fun userClicksOnButton() {
_navigateToDetails.value = true
}

// Added
fun navigateToDetailsHandled() {
_navigateToDetails.value = false
}
}

[Issues]

  • 보일러 플레이트 코드(이벤트마다 ViewModel에 새로운 메소드가 하나씩 있다.)가 있다는 점이다.
  • 옵저버에서 이벤트를 처리하고 ViewModel의 메소드를 호출하는 작업을 잊어버리기 쉽다.
  • 오류가 발생하기 쉬우며, 위에서 살펴본 방법과 유사하므로 절대 사용해서는 안된다.

✔️ OK: Use SingleLiveEvent

SingleLiveEvent 클래스는 특정 시나리오에 적합한 솔루션으로 Sample로 작성되었다. 한 번만 업데이트를 보내는 LiveData이다.

[Usage]

1
2
3
4
5
6
7
8
9
10
11
class ListViewModel : ViewModel {
private val _navigateToDetails = SingleLiveEvent<Any>()

val navigateToDetails : LiveData<Any>
get() = _navigateToDetails


fun userClicksOnButton() {
_navigateToDetails.call()
}
}
1
2
3
myViewModel.navigateToDetails.observe(this, Observer {
startActivity(DetailsActivity...)
})

[동작 방식]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class SingleLiveEvent<T> : MutableLiveData<T>() {

private val pending = AtomicBoolean(false)
private val tag = "VictoryWoo"

// 2.
// 내부에 등록된 Observer가 변경에 대한 알림을 받고 observe 함수를 호출한다.
// pending이 true일 경우, pending을 false로 바꿔준다.
// 그리고 이벤트가 호출되었다고 알려준다.
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
if (hasActiveObservers()) {
Log.w(tag, "Multiple observers registered but only one will be notified of changes.")
}

super.observe(owner, Observer { t ->
if (pending.compareAndSet(true, false)) {
observer.onChanged(t)
}
})
}

// 1.
// LiveData로써 들고 있는 데이터의 값을 변경하는 함수.
// 값이 변경되면 false였던 pending 변수는 true로 바뀌어
// observer가 알아차리고 observe를 호출하여 if문을 처리할 수 있도록 하였다.
@MainThread
override fun setValue(t: T?) {
pending.set(true)
super.setValue(t)
}

@MainThread
fun call() {
value = null
}
}

[Issues]

  • SingleLiveEvent의 문제점은 내부에서 여러번 호출되는 것을 막고 있기 때문에, 하나의 관찰자로 제한된다는 것이다. 실수로 둘 이상을 추가하면 하나만 호출되며 어떤 것을 보장하지 않는다.

이 방법은 이벤트 처리 여부를 명시적으로 관리하여 실수를 줄인다.

[Usage]

  • Event
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* Used as a wrapper for data that is exposed via a LiveData that represents an event.
*/
open class Event<out T>(private val content: T) {

var hasBeenHandled = false
private set // Allow external read but not write

/**
* Returns the content and prevents its use again.
*/
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) { // 이벤트가 이미 처리되었다면
null // null을 반환한다.
} else { // 그렇지 않다면
hasBeenHandled = true // 이벤트가 처리되었다고 표시한 후에
content // 값을 반환한다.
}
}

/**
* Returns the content, even if it's already been handled.
*/
fun peekContent(): T = content
}

SingleLiveEvent는 Observing하는 과정에서 일회성으로 만들기 때문에 하나의 옵저버만 값의 변경을 받을 수 있지만 Event Wrapper 방식은 Event가 이를 제어하기 때문에 여러 개의 옵저버를 등록해도 모두 값의 변경을 받을 수 있다.

단, getContentIfNotHandled() 메소드는 하나의 옵저버에서만 사용할 수 있고, 나머지는 peekContent()로 값을 받아야 한다.

  • ViewModel
1
2
3
4
5
6
7
8
9
10
11
class ListViewModel : ViewModel {
private val _navigateToDetails = MutableLiveData<Event<String>>()

val navigateToDetails : LiveData<Event<String>>
get() = _navigateToDetails


fun userClicksOnButton(itemId: String) {
_navigateToDetails.value = Event(itemId) // Trigger the event by setting a new Event as a new value
}
}
  • View
1
2
3
4
5
myViewModel.navigateToDetails.observe(this, Observer {
it.getContentIfNotHandled()?.let { // Only proceed if the event has never been handled
startActivity(DetailsActivity...)
}
})

장점은 사용자가 getContentIfNotHandled() 또는 peekContent()를 사용하여 의도를 지정해야 한다는 것이다. 이벤트를 상태의 일부로 모델링한다. 이제는 단순히 소비되었거나 사용하지 않은 메시지이다.

추가적인 사용법

[EventObserver]
매번 사용할 때마다 생기는 it.getContentIfNotHandled()?.let{} 코드를 줄이기 위해 EventObserver를 사용할 수 있다.

1
2
3
4
5
6
7
class EventObserver<T>(private val onEventUnhandledContent: (T) -> Unit) : Observer<Event<T>> {
override fun onChanged(event: Event<T>?) {
event?.getContentIfNotHandled()?.let { value ->
onEventUnhandledContent(value)
}
}
}

[View]

1
2
3
myViewModel.navigateToDetails.observe(this, EventObserver {
startActivity(DetailsActivity...)
})

In Summary

  • 이벤트를 상태의 일부로 디자인해라.
  • LiveData Observable에서 자체 이벤트 래퍼를 사용하고 필요에 맞게 사용자 정의하라.
  • 여러 개의 옵저버를 사용할 필요가 없다면 SingleLiveEvnet로 충분하지만, 여러 개의 옵저버를 등록하는 실수를 방지하기 위해서 Event Wrapper 방식을 사용하는 것이 좋다.

Bonus!

  • 많은 이벤트가 발생하면 EventObserver를 사용하여 반복적인 코드를 제거하라.

Reference