최근 진행 중인 사이드 프로젝트에서 Jetpack Compose를 다루기 시작하면서 Compose에 대해 이것 저것 알아보는 시간이 늘어나고 있다.
Compose로 개발을 하면서 느낀 건 바로 State
관리의 중요성이다. Compose는 기존의 안드로이드 xml처럼 명령형 UI가 아닌 선언형 UI다. 이미 선언된 UI에 표시되는 데이터를 변경하기 위해서는 State
를 변경하여 Recomposition (재구현)
을 진행해야한다.
즉, Compose를 사용하면서 State
를 다루지 않겠다는 건 화면 UI에 초기 데이터 이후 어떤 데이터 변경도 표시하지 않겠다는 이야기다.
이렇게 State
관리의 중요성을 알다보니 생각보다 꽤 머리가 복잡해졌다. 여러가지 이유가 있지만 그 중 하나는 기존의 가장 보편적인 안드로이드 디자인 패턴인 MVVM 패턴이 Compose에 과연 잘 맞나? 하는 의문이 들기 시작한 것이다.
내가 가장 크게 느낀 부분은 바로 생명주기의 차이이다.
Composable
구성요소들은 State
가 변경되는 Recomposition
이 일어나면서 재구성이 일어나면서 새로 생성된다. 하지만 ViewModel
은 종속된 Activity나 Fragment가 완전히 종료될때까지 동일한 인스턴스를 호출한다.
그렇기때문에 만약 ViewModel
에서 특정 Composable
의 생명주기에 의존하는 값이나 메소드를 가지고 있을 경우 의도치 않은 다른 결과를 불러일으킬 수도 있다.
실제로 안드로이드 공식 홈페이지에서도 이 점에 대해서 주의할 것을 명시하고 있다.
class GreetingActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Column {
GreetingScreen("user1")
GreetingScreen("user2")
}
}
}
}
}
@Composable
fun GreetingScreen(
userId: String,
viewModel: GreetingViewModel = viewModel(
factory = GreetingViewModelFactory(userId)
)
) {
val messageUser by viewModel.message.observeAsState("")
Text(messageUser)
}
class GreetingViewModel(private val userId: String) : ViewModel() {
private val _message = MutableLiveData("Hi $userId")
val message: LiveData<String> = _message
}
class GreetingViewModelFactory(private val userId: String) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return GreetingViewModel(userId) as T
}
}
위처럼 GreetingScreen
이 서로 다른 userId
값을 값고 2번 호출되어도 GreetingViewModel
은 GreetingActivity
가 완전히 종료되기 전까지 동일한 인스턴스를 반환하기 때문에 2번의 GreetingScreen
은 모두 "user1"에 대한 인사말을 표시하게된다.
이런저런 이유로 Compose에 대해서 좀 더 알아보고 있을 때 우연히 MVI 패턴에 대한 이야기를 들었다. 찾아보니 대충 MVI 패턴을 사용하면 State(상태)
관리를 좀 더 쉽게 다룰 수 있다는 이야기를 들었고 Compose로 UI를 구성한 앱에서 효과적인 디자인 패턴이라는 아닐까하는 생각이 들었다.
MVI 패턴은 Model, View, Intent 크게 3가지 구성요소로 이루어져있다.
쉽게 설명하자면, User(사용자)가 View를 클릭하거나 하는 등의 행동(Action)을 취하면 이 행동이 Intent(의도)
가 되어서 Model
에 전달된다. 이 Intent가 Model, 즉 상태를 업데이트한다. 그리고 이 변경된 상태가 다시 View에 반영된다.
MVI의 핵심은 단방향 흐름(Uni-directional Flow) 구조이다. 그래서 아래 그림처럼 표현하기도 한다.
기존의 MVVM 관점에서 MVI 패턴을 계층 구조로 나누어 보자면 아래 그림처럼 표시할 수 있다.
그렇다! 나는 MVVM과 MVI를 완전히 다른 새로운 개념이라고 인식했는데 그게 아니었다. MVI에서도 여전히 상태 관리를 위해 ViewModel
을 사용하며 기존의 MVVM에서 Compose의 상태 관리를 좀 더 수월하게 하기 위해 나온 개념이라고 생각하는 게 맞는 거 같다.
먼저 3개의 인터페이스가 필요하다.
interface UiState
interface UiEvent
interface UiEffect
UiState
: View의 상태(State)를 나타낸다.UiEvent
: 사용자(User)의 행동(Action)을 나타낸다.UiEffect
: 에러 메세지 표시와 같이 단 한번만 보여주고자 하는 Side Effect
를 나타낸다.다음은 ViewModel
의 Base 클래스를 구성한다.
abstract class BaseViewModel<Event : UiEvent, State : UiState, Effect : UiEffect> : ViewModel() {
// Create Initial State of View
private val initialState : State by lazy { createInitialState() }
abstract fun createInitialState() : State
// Get Current State
val currentState: State
get() = uiState.value
private val _uiState : MutableStateFlow<State> = MutableStateFlow(initialState)
val uiState = _uiState.asStateFlow()
private val _event : MutableSharedFlow<Event> = MutableSharedFlow()
val event = _event.asSharedFlow()
private val _effect : Channel<Effect> = Channel()
val effect = _effect.receiveAsFlow()
}
특이한 점은 State
, Event
, Effect
를 각각 StateFlow
, SharedFlow
, Channel
로 다르게 관리한다는 것이다.
StateFlow
는 초기값을 가지고 있다는 점만 제외하면 기존의 LiveData
와 크게 다르지 않다. 초기값을 가져야하고 항상 최신 값을 필요로 하는 UiState에 적절하다.
SharedFlow
는 발생하는 이벤트 구독자(Subscribers)가 0명일수도 있고 여러명일수도 있다.(이벤트 공유) 만약 구독자가 한명도 없다면, 이벤트는 그대로 무시된다(dropped). 이벤트를 처리해야하는 구독자(subscriber)가 존재하지 않는다면 무시될 필요가 있는 UiEvent에 적절하다.
반면에 Channel
은 각각의 이벤트가 오직 하나의 구독자에게만 전달된다.(이벤트 공유X) 만약 구독자가 없을 때 이벤트가 발생했다면 채널 버퍼가 가득차자마자 구독자가 나타날때까지 일시중지된다(suspend). 따라서 이벤트가 무시되지 않는다.
Channel
은 Hot Stream이기도 하고 방향이 변경되거나 UI가 다시 표시될 때 Side Effect를 다시 표시할 필요가 없다. 단순하게 SingleLiveEvent
동작을 복제하고 싶기 때문에 Channel
을 사용한다.
다음은 UiState
, UiEvent
, UiEffect
각각에 대한 setter 메소드를 구성해준다.
/**
* Set new Event
*/
fun setEvent(event : Event) {
val newEvent = event
viewModelScope.launch { _event.emit(newEvent) }
}
/**
* Set new Ui State
*/
protected fun setState(reduce: State.() -> State) {
val newState = currentState.reduce()
_uiState.value = newState
}
/**
* Set new Effect
*/
protected fun setEffect(builder: () -> Effect) {
val effectValue = builder()
viewModelScope.launch { _effect.send(effectValue) }
}
Events
를 처리하기 위해서 event
Flow를 수집(collect)해야 한다. 이는 ViewModel
의 init
블럭에서 처리한다.
init {
subscribeEvents()
}
/**
* Start listening to Event
*/
private fun subscribeEvents() {
viewModelScope.launch {
event.collect {
handleEvent(it)
}
}
}
/**
* Handle each event
*/
abstract fun handleEvent(event : Event)
이렇게 기본적인 BaseViewModel
구현은 끝났다. 이제 MainActivity
와 MainViewModel
사이를 이어줄 MainContract
구현을 할 차례다.
class MainContract {
// Events that user performed
sealed class Event : UiEvent {
object OnRandomNumberClicked : Event()
object OnShowToastClicked : Event()
}
// Ui View States
data class State(
val randomNumberState: RandomNumberState
) : UiState
// View State that related to Random Number
sealed class RandomNumberState {
object Idle : RandomNumberState()
object Loading : RandomNumberState()
data class Success(val number : Int) : RandomNumberState()
}
// Side effects
sealed class Effect : UiEffect {
object ShowToast : Effect()
}
}
이 예제에서는 2개의 이벤트만이 존재한다. OnRandomNumberClicked
는 사용자가 임의의 숫자 버튼을 클릭했을 때 호출된다. 물론 토스트 메세지를 호출하고 SingleLiveEvent
동작을 시뮬레이트하는 토스트 버튼도 있다.
RandomNumberState
는 Idle, Loading 그리고 Success
라는 각기 다른 State를 갖는 StateHolder 클래스이다.
State
는 UI 상태를 따르는 간단한 데이터 클래스이다.
Effect
는 Event
결과에 따라 한번만 보여주고 싶은 Side Effect들이 있는 클래스이다.
RandomNumberState
처럼 View State를 sealed 클래스로 사용하지 않고 State
데이터 클래스에 변수로써 표현해도 된다.
date class State(
val isLoading: Boolean = false,
val randomNumber: Int = -1,
val error: String? = null
): UiState
MainContract
도 구현 완료됐으니 이제 MainViewModel
에서 실제 로직에 적용할 차례다.
class MainViewModel<E: Event, S: State, E: Effect> : BaseViewModel() {
/**
* Create initial State of Views
*/
override fun createInitialState(): MainContract.State {
return MainContract.State(
MainContract.RandomNumberState.Idle
)
}
/**
* Handle each event
*/
override fun handleEvent(event: MainContract.Event) {
when (event) {
is MainContract.Event.OnRandomNumberClicked -> {
generateRandomNumber()
}
is MainContract.Event.OnShowToastClicked -> {
setEffect { MainContract.Effect.ShowToast }
}
}
}
/**
* Generate a random number
*/
private fun generateRandomNumber() {
viewModelScope.launch {
// Set Loading
setState { copy(randomNumberState = MainContract.RandomNumberState.Loading) }
try {
// Add delay for simulate network call
delay(5000)
val random = (0..10).random()
if (random % 2 == 0) {
// If error happens set state to Idle
// If you want create a Error State and use it
setState { copy(randomNumberState = MainContract.RandomNumberState.Idle) }
throw RuntimeException("Number is even")
}
// Update state
setState { copy(randomNumberState = MainContract.RandomNumberState.Success(number = random)) }
} catch (exception : Exception) {
// Show error
setEffect { MainContract.Effect.ShowToast }
}
}
}
}
handleEvent
에서 이벤트에 대한 처리를 진행한다. 만약 Contract
에 새로운 이벤트가 추가되면 handleEvent
에도 해당 이벤트에 대한 처리 코드를 추가해줘야한다.
OnRadomNumberClicked
이벤트가 발생하면 generateRandomNumber
메소드를 호출한다. 이 메소드에서는 먼저 State를 Loading
으로 바꾼 다음에 결과값에 따라서 State를 다시 Success
또는 Idle
로 변경한다.
만약 에러가 발생했을 경우 토스트 메세지를 띄우는 Effect
를 설정할 수도 있다.
이제 마지막으로 UI 단계에 적용하는 일만 남았다.
binding.generateNumber.setOnClickListener {
viewModel.setEvent(MainContract.Event.OnRandomNumberClicked)
}
binding.showToast.setOnClickListener {
viewModel.setEvent(MainContract.Event.OnShowToastClicked)
}
버튼을 클릭할때마다 해당하는 이벤트를 발생시킨다.
// Collect Ui State
lifecycleScope.launchWhenStarted {
viewModel.uiState.collect {
when (it.randomNumberState) {
is MainContract.RandomNumberState.Idle -> { binding.progressBar.isVisible = false }
is MainContract.RandomNumberState.Loading -> { binding.progressBar.isVisible = true }
is MainContract.RandomNumberState.Success -> {
binding.progressBar.isVisible = false
binding.number.text = it.randomNumberState.number.toString()
}
}
}
}
// Collect Side Effects
lifecycleScope.launchWhenStarted {
viewModel.effect.collect {
when (it) {
is MainContract.Effect.ShowToast -> {
binding.progressBar.isVisible = false
// Simple method that shows a toast
showToast("Error, number is even")
}
}
}
}
UI를 업데이트하기 위해서 uiState
를 구독해 최신 State를 collect하고 이에 맞춰 View를 업데이트 한다.
LiveData
처럼 동작하기 위해서 launchWhenStarted
메소드를 사용한다. 이를 통해 Flow는 생명주기가 최소 STARTED
상태가 되고 나서야 State를 collect할 것이다.
위에 구현된 예제 앱을 요약해보자.
먼저, 버튼 클릭과 같은 사용자 액션 Event
를 발생시킨다. 그러면 그 이벤트의 결과로 변경되지않는 새로운 State를 설정한다. 이 State는 Idle, Loading, Success가 될 수 있다. StateFlow
를 사용했기 때문에 새로운 State가 설정되자마자 UI를 이에 맞춰 업데이트한다.
만약 에러가 발생하거나 Toast 메세지 등을 띄워야한다면 새로운 Effect
를 설정해 처리한다.
장점
1. 상태 객체는 불변이므로 스레드로부터 안전하다.
2. State, Event, Effect 등의 모든 액션이 같은 파일에 있어 화면에서 일어나는 일을 한눈에 쉽게 이해할 수 있다.
3. 상태를 유지하는 것이 쉽다.
4. 데이터 흐름이 단방향으로 흐르기 때문에 추적이 쉽다.
단점
1. 많은 보일러플레이트 코드가 발생한다.
2. 많은 객체를 생성해야 하기 때문에 높은 메모리 관리가 필요하다.
3. 하나의 화면에서 많은 뷰와 복잡한 로직을 가지게 될 경우, State는 거대해지고 이 State를 단지 하나를 사용하는 대신 StateFlow를 추가 사용하여 더 작은 것으로 분할할 수 있다.
MVI 패턴은 MVC, MVVM과 같은 MVx 패턴에 가장 최근에 추가된 패턴이다. MVVM과 공통점이 많지만 상태 관리 측면에서 좀 더 구조화된 방법을 갖고 있다. 전체 예제 소스 코드는 링크에서 확인할 수 있다.
추가로 MVI 패턴을 쉽게 다룰 수 있게 해주는 라이브러리도 있다니 시간나면 한번 살펴보자
라이브러리 Github 링크
레퍼런스
1. Compose를 기존 앱 아키텍처와 통합
2. Compose 및 기타 라이브러리
3. Android 프로젝트에 MVI 도입하기
4. MVI Architecture with Kotlin Flows and Channels