의역 및 오역이 있을 수 있습니다.
원본 글: https://medium.com/androiddevelopers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150

 

서론

View(Activity 또는 Fragment)와 ViewModel이 데이터를 주고받기 위한 방식으로 LiveData를 주로 이용합니다. View에서 LiveData를 subscribe하여 LiveData에 변화가 일어나면 반응합니다. 이런 방식은 화면에 지속적으로 표시되는 데이터들에서는 잘 동작합니다.

하지만 Snackbar 메세지나 네비게이션 이벤트, dialog 표시 등과 같이 한번만 발생되는 이벤트들도 있습니다.

이러한 이벤트를 처리하는 여러가지 방식에대해서 알아보고 어디가 문제이며 잘못되었는지 알아보겠습니다.

 

❌ Bad 1: LiveData를 이용한다.

아래의 코드는 navigate의 signal을 LiveData에 직접 삽입합니다. 이런 방식은 일반적인 LiveData를 이용하는 것 처럼 쓸 수 있을 것 처럼 보이지만 몇몇 문제점이 있습니다.

코드

ViewModel

// 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

myViewModel.navigateToDetails.observe(this, Observer {
    if (it) startActivity(DetailsActivity...)
})

 

문제점

 

여기서 발생할 수 있는 문제는, _navigateToDetails가 계속 true로 설정되어있어 초기 화면으로 돌아갈 수 없는 상황이 일어날 수 있다는 것이다. 이 경우를 순서대로 알아보자.

  1. 유저가 버튼을 눌러서 Details 액티비티가 시작된다.
  2. 유저가 back버튼을 눌러 list 액티비티로 돌아간다.
  3. observer가 활성화된다. 
  4. _navigateToDetails는 여전히 true이므로 Details 액티비티가 다시 시작된다.

(observe는 일반적으로 값의 변화가 일어날 때, 콜백이 실행되지만 비활성화상태에서 활성화 되었을 때도 실행된다.)

이를 해결하기 위해서 _navigateToDetails값을 true로 바꾼 뒤, 바로 false로 설정해서 이벤트는 트리거 하되, 값은 false로 유지하는 방법이 있을 수 있다.

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

하지만 LiveData는 값을 저장할 뿐, 매 저장시마다 값 변경을 알리는 것을 보장하지는 않는다. 예를들어 현재 활성화된 observer가 없을 때, 값을 변경하면, observer는 값 변경을 모를 수 밖에 없다. 그리고 여러 스레드에서 값을 변경하게 되면 race condition이 발생할 수도 있다.

❌ Better 2: LiveData를 이용하되, observer에서 값을 초기화.

 

코드

ViewModel

class ListViewModel : ViewModel {
    private val _navigateToDetails = MutableLiveData<Boolean>()

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


    fun userClicksOnButton() {
        _navigateToDetails.value = true
    }

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

 

View

listViewModel.navigateToDetails.observe(this, Observer {
    if (it) {
        myViewModel.navigateToDetailsHandled()
        startActivity(DetailsActivity...)
    }
})

 

문제점

이 방식의 문제점은 코드 재사용이 불가능 하다는 점이다. 예를들어 details, setting, main, mypage등 여러 메뉴를 navigate해야한다면, 각각 메뉴에 따른 핸들러를 따로 만들어야한다. 또한 View에서 핸들러를 호출하는것을 까먹어 에러를 일으킬 수도 있다.

 

✔️ OK: SingleLiveEvent이용하기

SingleLiveEvent는 한번 업데이트되는 LiveData이다.

 

GitHub - android/architecture-samples: A collection of samples to discuss and showcase different architectural tools and pattern

A collection of samples to discuss and showcase different architectural tools and patterns for Android apps. - GitHub - android/architecture-samples: A collection of samples to discuss and showcase...

github.com

코드

ViewModel

class ListViewModel : ViewModel {
    private val _navigateToDetails = SingleLiveEvent<Any>()

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


    fun userClicksOnButton() {
        _navigateToDetails.call()
    }
}

 

View

myViewModel.navigateToDetails.observe(this, Observer {
    startActivity(DetailsActivity...)
})

 

문제점

SingleLiveEvent는 하나의 Observer가 있을 때만 사용할 수 있다. 여러 Observer가 있다면 어떤곳에서 호출 되었는지 알 수 없을 것이다.

 

✔️ Recommended: Event wrapper 이용하기

코드

Event

/**
 * 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
        } else {
            hasBeenHandled = true
            content
        }
    }

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

ViewModel

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

myViewModel.navigateToDetails.observe(this, Observer {
    it.getContentIfNotHandled()?.let { // Only proceed if the event has never been handled
        startActivity(DetailsActivity...)
    }
})

 

EventWrapper를 이용하면 getContentIfNotHandled로 해당 이벤트가 handle되었는지 체크 할 수 있고, 하나의 event가 여러 Observer를 가질 수 있습니다.

 

복사했습니다!