AAC ViewModel event 처리 공부

이창민·2022년 6월 16일
0

EVENT 처리하는 공부 TODO 앱.

MVVM을 공부하며 맨날천날 LIVEDATA만 사용하니 이거 맞나?.. 라는 생각이 들어 찾아보다
역시 여러 방법들이 있는 것을 알았다…

완성 코드.

공부하는 겸 ROOM, Firebase RealTime DB를 사용해 todo 를 추가하고 받아왔다.

LIVEDATA + EVENT

흔히 처음 MVVM을 시작할 때 접할 수 있는 이벤트 처리 방법이다. 나도 그랬다

우선 처음 시작하는 부분이니 나중에 room, remote data 를 추출할 것을 생각해 json file 을 읽어 todo를 뽑아내 리사이클러뷰 어댑터에 submit 했다..

우선 EVENT class를 만든다.

import androidx.lifecycle.Observer

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

class EventObserver<T>(private val onEventUnhandledContent: (T) -> Unit) : Observer<Event<T>> {
    override fun onChanged(event: Event<T>?) {
        event?.getContentIfNotHandled()?.let {
            onEventUnhandledContent(it)
        }
    }
}

why? EVENT class
LiveData 만 사용하면 되는거 아냐? 라는 질문을 할 수 있다. 예를 들어보자.
1. LOGIN ACTIVITY에서 TOAST를 띄우려는 event가 발생했다.
2. 나는 그 사이를 못참고 MAIN ACTIVITY로 이동했고, 다시 LOGIN ACTIVITY로 돌아왔다.
3. LIVEDATA를 observe하고 있던 observer는 inactive에서 active되며 observe를 다시 시작한다.
4. 1.에서 TOAST를 띄우려는 event가 날라온다.(LiveData는 항상 마지막 값을 emit)
5. 의도치 않게 Toast가 발생하는 문제가 생긴다.

위 문제 때문에 event를 한번만 emit하고 consume하는 EventWrapper개념을 만들어 해결한다.

SingleLiveData

LiveData + Event를 사용하면 매번 LiveData<Event<AAA>>를 사용하긴 너무 귀찮으니
LiveData와 Event Wrapper를조합한 SingleLiveData를 만들게 된다.


abstract class SingleLiveData<T> {

    private val liveData = MutableLiveData<Event<T>>()

    protected constructor()

    protected constructor(value: T) {
        liveData.value = Event(value)
    }

    protected open fun setValue(value: T) {
        liveData.value = Event(value)
    }

    protected open fun postValue(value: T) {
        liveData.postValue(Event(value))
    }

    fun getValue() = liveData.value?.peekContent()

    fun observe(owner: LifecycleOwner, onResult: (T) -> Unit) {
        liveData.observe(owner) { it.getContentIfNotHandled()?.let(onResult) }
    }

    fun observePeek(owner: LifecycleOwner, onResult: (T) -> Unit) {
        liveData.observe(owner) { onResult(it.peekContent()) }
    }

}

요로케 합치니까 viewModel에서 매일 사용하던 코드가 좀 더 간결해지는 마법이 발생한다.

    private val _addTodoEvent = MutableSingleLiveData<Unit>()
    val addTodoEvent: SingleLiveData<Unit> = _addTodoEvent

SharedFlow

이렇게 편안하게 잘 지내다 clean architecture 가 발에 걸립니다.
ViewModel은 presentation layer에 위치하므로 특정 플랫폼과 관계가 없어야 한다.

그러니까 import android.xxx없이 유지해야 한다고 한다.
LiveData는 android 패키지에 해당해 쓰기싫지만 대안이 없기에 그냥 사용하다
StateFlow, SharedFlow를 만났습니다.

얘들은 코루틴 Flow라서 대체됩니다..

기존 SingleLiveData를 SharedFlow로 변경하고 observe대신 collect하는 코드로 변경함니다

	// VieWModel
    private val _addTodoEvent = MutableSharedFlow<Unit>()
    val addTodoEvent = _addTodoEvent.asSharedFlow()

    // UI
    lifecycleScope.launch{
		viewModel.addTodoEvent.observe { text ->
    		//...
        }
	}

SharedFlow + Sealed class

SharedFlow만 쓸 때는 처리할 Event가 3개면 각각 3개, 4개면 4개의 SharedFlow를 만들었습니다.
각각 Event를 collect하는 코드도 3개, 4개.. 이것도 귀찮으니..

Event를 전파하는 1개의 eventFlow만 만들고
Event를 Sealed class로 만들어 상황에 맞개 처리하도록 개선합니다.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.todo.app.data.model.Todo
import com.todo.app.data.repository.remote.TodoRemoteRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class Step4ViewModel @Inject constructor(
    private val todoRemoteRepository: TodoRemoteRepository
) : ViewModel() {

    private val _eventflow = MutableSharedFlow<Event>()
    val eventflow = _eventflow.asSharedFlow()

    init {
        loadTodos()
    }

    fun loadTodos() {
        viewModelScope.launch {
            val items = todoRemoteRepository.getTodos().values.toList()

            if (!items.isNullOrEmpty()) {
                event(Event.TodoList(items))
            }
        }
    }

    fun showToast() {
        event(Event.ShowToast("TOAST"))
    }

    fun openAddFragment() {
        event(Event.OpenAddFragment)
    }

    private fun event(event: Event) {
        viewModelScope.launch {
            _eventflow.emit(event)
        }
    }

    sealed class Event {
        data class TodoList(val todos: List<Todo>) : Event()
        data class ShowToast(val text: String) : Event()
        object OpenAddFragment : Event()
    }
}

// UI
        lifecycleScope.launch {
            viewModel.eventflow.collect { event -> handleEvent(event) }
        }
        
        private fun handleEvent(event: Step4ViewModel.Event) = when (event) {
        is Step4ViewModel.Event.TodoList -> {
            todoAdapter.submitList(event.todos)
        }
        is Step4ViewModel.Event.ShowToast -> {
            Toast.makeText(context, event.text, Toast.LENGTH_SHORT).show()
        }
        is Step4ViewModel.Event.OpenAddFragment -> {
            val action =
                Step4FragmentDirections.actionStep4ToAdd()
            findNavController().navigate(action)
        }
    }

SharedFlow + Sealed class + Lifecycle

이전 방법에서는 ui가 안 보일 때, flow에서 collect를 할 필요가 없다.
그래서 onStart에서 collect 하고, onStop에서 cancel해왔다.
그러다가 이후에, lifecycle에서 repeatOnLifecycle()함수가 추가되었다.
얘를 사용하면 onStart, onStop에서 코드를 작성하지 않아도 알아서 onStart에서 collect하고 onStop에서 cancel된다.

EventFlow + Sealed class + Lifecycle

이전 방법에선, 특정 event가 발생하고 앱이 백그라운드로 내려갔다면
onStop 상태이기 때문에 이벤트가 실행되지 않는다.

그러니까 event가 발생해도 observe하는 곳이 없으니까 event가 유실된다.
그래서 event를 캐시하다 event의 consume 여부에 따라 new observer에게 event를 전파를 정하는 EventFlow를 만든다.

회고

이거 하면서 flow 공부도 좀 하고 했는데..
ui 그니까.. todo item같은 애들은 ui state로 빼서 StateFlow로 하는게 맞는 거 같다..

참고자료

  1. https://medium.com/prnd/mvvm%EC%9D%98-viewmodel%EC%97%90%EC%84%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8%EB%A5%BC-%EC%B2%98%EB%A6%AC%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-6%EA%B0%80%EC%A7%80-31bb183a88ce

  2. https://sungbin.land/%EC%BD%94%ED%8B%80%EB%A6%B0-flow-%EB%BF%8C%EC%8B%9C%EA%B8%B0-36fbb53300b9?gi=9766001db92c

profile
android 를 공부해보아요

0개의 댓글