ViewModel에서 View(Activity, Fragment)로 이벤트를 전달하는 방법

읽기 전에!
먼저 이 글을 읽어서 LiveData의 특성을 알면 글 이해에 도움이 됩니다😊

https://docs.microsoft.com/en-us/xamarin/xamarin-forms/enterprise-application-patterns/mvvm

MVVM 구조에서는 ViewModel이 View를 몰라야 합니다.
ViewModel에 View에 대한 참조가 없어야 한다는 것인데요,

그래서 ViewModel이 View에 이벤트를 전달하기 위해선 View가 ViewModel을 관찰하다가 상태 변화가 일어나면 그에 알맞은 이벤트를 실행하도록 해야 합니다.

앞으로 나올 예제는 ViewModel에서 Activity에 이벤트를 보내서 토스트를 띄우는 코드입니다.

안좋은 방법

// ViewModel (TestViewModel.kt)
class TestViewModel : ViewModel() {
    private val _showErrorToast = MutableLiveData(false)

    val showErrorToast: LiveData<Boolean> = _showErrorToast

    fun onButtonClick() {
        _showErrorToast.value = true
    }

    fun showErrorToastHandled() {
        _showErrorToast.value = false
    }
}

// View (MainActivity.kt)
viewModel.showErrorToast.observe(this, Observer {
    if (it) { // 값이 true로 바뀌면 이벤트를 실행합니다.
        viewModel.showErrorToastHandled() // 뷰모델에게 이벤트를 처리했다고 알려서 값을 다시 false로 바꿔줍니다.
        Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show()
    }
})

이렇게 사용하면 이벤트 하나를 만들 때 마다 LiveData를 다시 false로 돌려놓는 작업을 해야합니다.
코드의 양도 늘어날 뿐만 아니라 이를 실수로 빠뜨리기라도 하면 오작동이 발생하기 쉽습니다.


‘그러면 값을 true로 바꾸자마자 false로 바꿔주면 되는거 아니야..?’

fun onButtonClick() {
    _showErrorToast.value = true
    _showErrorToast.value = false
}

이렇게 생각할 수 있지만 여기엔 큰 문제가 있습니다.
LiveData는 lifecycle에 따라서 항상 observe를 하지 않고 필요한 경우에만 observe하기 때문에 정상적으로 작동하지 않을 수 있습니다.
따라서 이 방법으로는 절대 사용하지 마세요.

괜찮은 방법 (SingleLiveEvent)

괜찮은 방법으로는 SingleLiveEvent를 사용하는 것이 있습니다.

사용법

// ViewModel (TestViewModel.kt)
class TestViewModel : ViewModel() {
    private val _showErrorToast = SingleLiveEvent<Void>()

    val showErrorToast: LiveData<Void> = _showErrorToast

    fun onButtonClick() {
        _showErrorToast.call() // _showErrorToast.value = null
    }
}

// View (MainActivity.kt)
viewModel.showErrorToast.observe(this, Observer {
    Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show()
})

SingleLiveEvent를 사용함으로써 코드도 간결해지고, 사용하기도 쉬워졌습니다.

동작 방식

class SingleLiveEvent<T> : MutableLiveData<T>() {
    // setValue로 새로운 이벤트를 받으면 true로 바뀌고 그 이벤트가 실행되면 false로 돌아갑니다.
    private val isPending = AtomicBoolean(false)

    /**
     * 1. 값이 변경되면 false였던 isPending이 true로 바뀌고, Observer가 호출됩니다.
     */
    @MainThread
    override fun setValue(value: T?) {
        isPending.set(true)
        super.setValue(value)
    }

    /**
     * 2. 내부에 등록된 Observer는 isPending이 true인지 확인하고,
     *    true일 경우 다시 false로 돌려 놓은 후에 이벤트가 호출되었다고 알립니다.
     */
    @MainThread
    override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
        super.observe(owner, Observer {
            if (isPending.compareAndSet(true, false)) {
                observer.onChanged(it)
            }
        })
    }

    /**
     * T가 Void일 경우 호출을 편하게 하기 위해 있는 함수입니다.
     */
    @MainThread
    fun call() {
        value = null
    }
}

문제점

여전히 문제점은 있습니다.
내부에서 여러번 호출되는 것을 막고 있기 때문에, 오직 하나의 옵저버만 사용할 수 있다는 것입니다.
여러개의 옵저버를 등록한다 해도 그중 하나만 호출될 것입니다.

권장되는 방법 (Event wrapper)

지금까지의 문제를 어느정도 해결한 방법인 Event wrapper 입니다.

사용법

// ViewModel (TestViewModel.kt)
class TestViewModel : ViewModel() {
    private val _showErrorToast = MutableLiveData<Event<Boolean>>()

    val showErrorToast: LiveData<Event<Boolean>> = _showErrorToast

    fun onButtonClick() {
        _showErrorToast.value = Event(true)
    }
}

// View (MainActivity.kt)
viewModel.showErrorToast.observe(this, Observer {
    it.getContentIfNotHandled()?.let {
        Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show()
    }
})

MutableLiveData의 Event가 실제 값을 감싸는 형태입니다.

동작 방식

open class Event<out T>(private val content: T) {
    var hasBeenHandled = false
        private set

    fun getContentIfNotHandled(): T? {
        return if (hasBeenHandled) { // 이벤트가 이미 처리 되었다면
            null // null을 반환하고,
        } else { // 그렇지 않다면
            hasBeenHandled = true // 이벤트가 처리되었다고 표시한 후에
            content // 값을 반환합니다.
        }
    }

    /**
     * 이벤트의 처리 여부에 상관 없이 값을 반환합니다.
     */
    fun peekContent(): T = content
}

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

getContentIfNotHandled() 메서드는 하나의 옵저버에서만 사용할 수 있고, 나머지는 peekContent()로 값을 받아야합니다.
사실 getContentIfNotHandled() 메서드를 여러번 사용해야 하는 일이 거의 없기 때문에 아쉬워할 필요는 없습니다😅

추가적인 사용법

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

    // EventObserver.kt
    class EventObserver<T>(private val onEventUnhandledContent: (T) -> Unit) : Observer<Event<T>> {
        override fun onChanged(event: Event<T>?) {
            event?.getContentIfNotHandled()?.let { value ->
                onEventUnhandledContent(value)
            }
        }
    }
    
    // View (MainActivity.kt)
    viewModel.showErrorToast.observe(this, EventObserver {
        Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show()
    })
    
  2. Nullable Content 개발을 하던중 Event에 nullable한 값이 들어가야 하는 상황이 발생했는데 (예를 들어 MutableLiveData<Event<String?>>) Event에 null이 들어가면 getContentIfNotHandled()의 값도 null이 되서 이벤트를 받을 수 없었습니다.
    그래서 content가 null이여도 정상적으로 이벤트를 받을 수 있도록 Event와 EventObserver를 약간 커스텀 했는데 혹시 문제가 있거나 개선점이 있으면 알려주세요😄

결론

여러개의 옵저버를 쓸 필요가 없다면 SingleLiveData로 충분하지만 여러개의 옵저버를 등록하는 실수를 방지하기 위해서 Event wrapper 방식을 사용하는 것을 추천드립니다.

긴 글 읽어주셔서 감사합니다. 피드백은 무엇이든 환영입니다!

참고

LiveData with SnackBar, Navigation and other events (the SingleLiveEvent case)

태그: , ,

카테고리:

업데이트:

블로그에 기여하기

댓글남기기