본문 바로가기
안드로이드

[안드로이드] ViewModel 에서 이벤트 처리하는 방법 (SingleLiveEvent)

by algosketch 2022. 1. 14.

MVVM 에서 View 는 xml, Activity, Fragment 등이 해당된다. ViewModel 에서는 View 에 필요한 데이터들을 갖고 있고, 데이터들을 조작하는 로직들을 갖고 있다. View 에서는 데이터들을 구독하고 있다가 변경이 감지되면 UI 를 업데이트 한다. (이 글에서는 xml, Activity, Fragment 를 모두 View 라고 부르겠다.)

UI 관련된 코드가 View 에 들어간다고 해서 ViewModel 에서는 View 와 전혀 관련 없는 일을 하는 게 아니다. View 와 매우 관련있는 것이 ViewModel 이지만, View 에 의존하고 있지 않을 뿐이다. ViewModel 은 View 의 코드가 변경되어도 전혀 영향을 받지 않는다.

onClick 이벤트는 어디서 구현할까? ViewModel 에서 구현한다. 클릭 이벤트가 발생했을 때 돌아가는 로직은 UI 의 영역이 아니다. View 는 UI 와 관련된 것만 처리해야 한다. 로직이 끝나고 난 결과에 따라서 UI 가 변경될 수는 있다. 그럼 이 부분만 View 에서 처리해 주면 된다. 그래서 하나의 이벤트를 처리하기 위해 ViewModel 과 View 코드 모두를 손대야할 수도 있다.

단순히 LiveData(구독 가능한 자료형) 만 조작하고 이 데이터를 View 에서 구독하는 형태면 어려움이 없을 것 같다. MVVM 패턴을 처음 접하게 되면 마주하게 되는 문제는 "이 코드를 대체 어디에 두어야 하지?" 혹은  "ViewModel 에 만들어야 할 것 같은데 ViewModel 에서 처리할 수 없는 것 같아" 와 같다.

 

어떻게 ViewModel 에서 이벤트를 처리할 수 있을까?

ViewModel 에서 구현하기 어려운 이유는 뭘까? 새 화면을 띄우거나 UI 에 접근하려면 결국 View 나 Context 가 필요한 경우가 많다. 다르게 말하면 startActivity, findViewById 등을 사용하기 어렵다. ViewModel 에서는 이와 같은 상황에서도 LiveData 를 이용해 처리할 수 있다. View 에서는 LiveData 를 구독하여, 데이터가 변경될 때마다 정해진 로직을 실행한다.

// ViewModel
val shouldStartActivity = MutableLiveData<Boolean>(false)

fun onClickEvent() {
    shouldStartActivity.postValue(true)
}
// Activity (혹은 Fragment) 
// 생명주기 메소드에서 구독한다.
viewModel.shouldStartActivity.observe(
    this,
    {
    	val intent = Intent(this, SubActivity::class.java)
        startActivity(intent)
    }
)

View 에서는 shouldStartActivity 의 변경을 감지하여 새로운 액티비티를 실행한다. (사실 위 코드는 예시를 위한 것일 뿐 개선의 여지가 있다. 개선 방법은 연습 문제로 남겨 놓는다. 절대 귀찮아서 안 쓴 게 아니다...) 이렇게 코드를 작성하면 .xml 파일에서 @{viewModel.onClickEvent} 와 같은 형태로 이벤트 리스너를 연결할 수 있어 setOnClickListener 와 같은 코드를 쓰지 않아도 된다. (참고로 같은 값을 postValue 를 통해 대입해도 이벤트가 발생한다.)

 

완벽한 해결 방법은 아니다.

앞선 예시에는 두 가지 문제점이 있다. 하나는 사소한 문제로 Boolean 이라는 자료형이 별로 중요하지 않다는 점이다. String 이나 다른 값이 들어가도 상관 없다. 마찬가지로 postValue 할 때도 어떤 값을 넣어도 상관 없다. 큰 문제는 없지만 필요 없는 값을 사용하게 되어 좋아 보이진 않는다. 두 번째로 구독하는 시점에 이벤트 로직이 한 번 발생한다는 점이다. (이 부분은 사실 MutableLiveData(Boolean)() 과 같이 아무 값도 주지 않으면 발생하지 않는다. 하지만 이 또한 해결책은 아니다.)

두 번째 문제는 View 가 onDestroy 될 경우 다시 구독을 신청하게 되어 원치 않는 이벤트가 발생할 위험이 있다. 이게 문제가 되는 이유는 ViewModel 과 View 의 생명주기가 다르기 때문이다. 화면 회전만 되어도 onDestroy 가 발생하고, Jetpack 의 Navigation 을 이용하면 다음 화면으로 넘어갈 때 메모리 관리를 위해 이전 Fragment 를 파괴한다. 하지만 ViewModel 의 데이터는 계속 살아있다. 다음 화면으로 갔다가 뒤로가기를 누르면 View 생명주기 함수가 다시 실행되어 구독하게 되고, 여기서 이벤트가 발생하여 다시 다음 화면으로 넘어간다.

 

ViewModel 이 View 를 알고 있으면?

// ViewModel
TestViewModel(activity: Activity) : Fragment {
	fun nextAcitivity() {
    	activity.startAcivity( ... )
    }
}

위 코드처럼 ViewModel 에서 View 를 알고 있으면 어떨까? 더 쉽지 않을까?

 

절대 안 된다.

아키텍처 관점에서 봤을 때 참조 관계에 순환이 생기면 안 된다. 이렇게될 경우 관리하기 위해 두 개의 파일로 나눈 의미도 사라진다. 안드로이드에서는 더 큰 문제가 있는데, 메모리 누수가 발생한다는 점이다. Activity 나 Fragment 같은 것들은 사용하는 메모리가 크기 때문에 필요할 때가 아니면 메모리에서 해제 되어야 한다. 가비지 컬렉터(이하 GC)는 일정 주기마다 스위핑하여 참조 카운트가 0인 객체를 메모리에서 해제한다. View 는 ViewModel 을 참조하고, ViewModel 은 Activity 를 참조하는 관계가 생겨버리면 둘은 영원히 메모리에서 해제되지 않는다. 꼭 상호 참조가 아니더라도 ViewModel 의 생명주기는 Activity 보다 훨씬 길기 때문에 ViewModel 이 Activity 를 참조하는 상황이 만들어지면 안 된다.

 

파라미터는 괜찮다.

// ViewModel
fun startNext(view: View) {
    view.context.startActivity( ... )
}

위처럼 파라미터로 넘겨서 처리하는 게 적절한 상황이라면 파라미터로 필요한 변수를 받는 것은 허용된다. 함수 파라미터는 실제로 참조 관계나 (전역 변수)스코프 문제를 해결하는 좋은 도구이다. 파라미터 개수가 너무 많아지지 않는 한도 내에서 사용할 수 있다. (관련된 내용은 마틴 파울러의 리팩터링 책을 추천한다.)

 

Single Live Event

당연히 나만 이런 문제를 겪는 것은 아니다. 해결 방법은 SingleLiveEvent 이다.

class SingleLiveEvent<T> : MutableLiveData<T>() {
    private val pending = AtomicBoolean(false)

    @MainThread
    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.")
        }
        // Observe the internal MutableLiveData
        super.observe(owner, Observer { t ->
            if (pending.compareAndSet(true, false)) {
                observer.onChanged(t)
            }
        })
    }

    @MainThread
    override fun setValue(t: T?) {
        pending.set(true)
        super.setValue(t)
    }

    @MainThread
    fun call() {
        value = null
    }
    companion object {
        private val TAG = "SingleLiveEvent"
    }
}

내부적으로는 MutableLiveEvent 를 상속받아 구현하므로 위에서 봤던 예제와 같은 원리로 동작한다. (코드를 보면 thread unsafe 문제도 해결하고 있다.) 다음과 같이 사용할 수 있다.

class HomeViewModel : BaseViewModel() {
    val startNextFragment = SingleLiveEvent<Any>() // single live event

    fun startNextWithLocalData() {
        Store.repository = inject<LocalRepository>()
        startNextFragment.call() // 이벤트 발생
    }

    fun startNextWithServerData() {
        Store.repository = inject<RemoteRepository>()
        startNextFragment.call()
    }
}
class HomeFragment : BaseFragment<FragmentHomeBinding>() {
    override val layoutResourceId = R.layout.fragment_home
    private val viewModel: HomeViewModel by viewModels()

    override fun initDataBinding() {
        binding.viewModel = viewModel

        viewModel.startNextFragment.observe(this, Observer {
        	// 새로운 프레그먼트 실행
            // findNavController 는 View 에서만 호출이 가능하다.
            findNavController().navigate(R.id.action_home_to_memo) // 새로운 프레그먼트 실행
        })
    }
}

https://github.com/HamBP/android-template

 

GitHub - HamBP/android-template

Contribute to HamBP/android-template development by creating an account on GitHub.

github.com

여기서 가져온 코드이다. 절대로 내가 만든 오픈소스 프로젝트라서 홍보하는 게 맞다.