깨알 개념/Android

[Android] ViewModel - View에 Event 발생시키기

interfacer_han 2024. 2. 27. 14:20

#1 샘플 앱

 

[Android] ViewModel - View에 객체(ViewModel) 전달

#1 개요 #1-1 Data Binding과 ViewModel [Android] Data Binding - View에 객체 전달 #1 객체 전달의 필요성 #1-1 이전 글 Data Binding - 기초 #1 데이터 바인딩 사용 전 #1-1 예시 앱 위과 같은 간단한 앱이 있다. Button을

kenel.tistory.com

아래에 있는 코드들은 위 게시글의 완성된 앱을 기반으로 수정된 것들이다.
 

#2 목표 (수정할 부분)

// package com.example.eventwithview

import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

class MainActivityViewModel : ViewModel() {
    private var count: MutableLiveData<Int> = MutableLiveData(0)

    fun getCurrentCount(): MutableLiveData<Int> {
        return count
    }

    fun updateCount() {
        count.value = (count.value)?.plus(1)
    }
}

이 코드를 count.value가 10이상이면 Activity에서 Toast 메시지로 count.value 값을 표시하게 만들 것이다. ViewModel 자체에 Toast.makeText(...).show()를 만들면 아래와 같은 코드가 된다.
 

#3 코드 수정 - MVVM 패턴 위배

'더보기'에 있는 코드들은 혹시나 해서 첨부하긴 했으나, 딱히 볼 필요는 없는 코드들이다.
 

#3-1 ViewModelFactory 추가

더보기
// package com.example.eventwithview

import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider

class MainActivityViewModelFactory(private val activityContext: Context) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(MainActivityViewModel::class.java)) {
            return MainActivityViewModel(activityContext) as T
        }
        throw IllegalArgumentException("Unknown ViewModel Class")
    }
}

참조
 

#3-2 Activity 수정

더보기
...

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private lateinit var viewModel: MainActivityViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        val factory = MainActivityViewModelFactory(this)
        viewModel = ViewModelProvider(this, factory)[MainActivityViewModel::class.java]

        binding.myViewModel = viewModel
        viewModel.getCurrentCount().observe(this, Observer {
            binding.countText.text = it.toString()
        })
    }
}

참조
 

#3-3 ViewModel 수정

...

class MainActivityViewModel(private val activityContext: Context) : ViewModel() {
    ...

    fun updateCount() {
        count.value = (count.value)?.plus(1)

        if (10 <= count.value!!) {
            Toast.makeText(activityContext, "${count.value}", Toast.LENGTH_SHORT).show()
        }
    }
}

Toast 메시지 표시에 필요한 Activity의 Context를 매개변수로 받아 실행하는 코드다. 하지만, 이 코드는 MVVM 패턴을 정면으로 위반하고 있다.
 

#3-4 MVVM 패턴의 조건

 

[Android] MVVM 구조 한눈에 보기

#1 안드로이드 앱의 '전통적인' 방식 vs MVVM 패턴 #1-1 도식도와 종속성 화살표는 클래스 간의 종속성을 나타낸다. 예를 들어, View는 ViewModel에 종속된다. 종속의 사전적 의미는 '자주성이 없이 주가

kenel.tistory.com

왜냐하면 구글이 권장하는 MVVM 패턴에서 ViewModel은 Activity를 참조해서는 안 되기 때문이다. ViewModel은 Activity를 몰라야 한다.
 

#3 올바른 수정 방향 (LiveData 이용)

#3-1 개요

허나, Toast 메시지를 표시하려면, Activity의 Context가 필요하다. 이럴 땐 ViewModel에 Toatst 메시지를 실행할 타이밍과 그 메시지의 내용만을 기술한다. 그리고 직접적인 실행은 Activity에게 맡긴다. 타이밍하면 생각나는 것은 LiveData다. Toast 메시지의 내용을 MutableLiveData<String>에 할당하면 그 자체로 토스트 메시지의 내용을 전달하는 것이며 그리고 Activity 속 LiveData.observe()에 해당 최신 메시지의 내용을 표시할 타이밍까지 알려줄 수 있지 않은가.
 
하지만 LiveData<String>에는 문제점이 존재한다. 사용자가 다른 액티비티로 넘어갔다가 다시 돌아오면, LiveDate.observer()가 다시 작동되어 이미 표시했던 Toast 메시지가 다시 표시되기 때문이다. 
 

#3-2 LiveData에 담을 Event 객체

 

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

2021 Update: This guidance is deprecated in favor of the official guidelines.

medium.com

위 블로그 게시글에선 LiveData에 담을 객체에, 그 객체에 대한 이벤트(Toast 메시지 표시 등)가 이미 수행되었는지 체크하는 프로퍼티를 넣음으로써, LiveData<String>을 쓰는 방식의 문제점을 해결했다. 게다가, 여러 스레드에서 동시에 같은 Event에 접근하는 경우까지 고려해 설계했다고 한다.
 
아래 코드는 위 블로그 게시글에 있는 Event 클래스의 코드를 복사/붙여넣기 한 것이다. 안타깝게도(?) 위 블로그 게시글의 코드는 최신화된 안드로이드 공식 가이드라인으로 대체되어 Deprecated되었다고 한다. 하지만, 해당 가이드라인은 StateFlow를 사용한다. StateFlow는 아직 내가 모르는(하지만 곧 알게 될) 개념이기 때문에 여기선 위 블로그 게시글의 코드를 사용하겠다.
 

#3-3 Event.kt

// https://medium.com/androiddevelopers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150

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

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

*/

위 클래스를 프로젝트에 추가한다.
 

#4 코드 수정 - MVVM 패턴 준수

#4-1 ViewModel 수정

...

class MainActivityViewModel : ViewModel() {
    ...

    private val _toastMessage = MutableLiveData<Event<String>>()
    val toastMessage: LiveData<Event<String>>
        get() = _toastMessage

    ...

    fun updateCount() {
        count.value = (count.value)?.plus(1)

        if (10 <= count.value!!) {
            _toastMessage.value = Event("${count.value}")
        }
    }
}

 

#4-2 Activity 수정

...

class MainActivity : AppCompatActivity() {

    ...
    private lateinit var viewModel: MainActivityViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        displayToastEvent()
    }

    private fun displayToastEvent() {
        viewModel.toastMessage.observe(this, Observer {
            it.getContentIfNotHandled()?.let { // Only proceed if the event has never been handled
                Toast.makeText(this, it, Toast.LENGTH_SHORT).show()
            }
        })
    }
}

#3-3의 아래쪽 주석에 있는 사용 례를 그대로 가져왔다. 이 코드 또한 #3-2에 있는 블로그 게시글에서 가져온 것이다.
 

#5 작동 확인

 

#6 요약

본 게시글에서와 같이 MVVM 패턴의 부분적인 무결성을 유지하다보면, 전체적인 그림도 잘 보일 날이 올 것으로 기대한다.
 

#7 완성된 앱

https://github.com/Kanmanemone/android-practice/tree/master/view-model/EventWithView