์ด์ ์ปดํฌ์ฆ์ ํ๋ฆ์ ํผํด๊ฐ ์ ์๋ค. ์ ๋๋ก ์ ๋ฌธํ๊ธฐ ์ํด MVI ํจํด๋จผ์ ๊ณต๋ถํ๊ณ , ์์ํ๋ ค๊ณ ํ๋ค.
# MVI?
MVI๋ Model-View-Intent๋ก,
Model์ UI์ ์ํ, View๋ UI, Intent๋ ์ฌ์ฉ์์์ ์ํธ์์ฉ์ด๋ ๋ค๋ฅธ ์์์ ์ํ ์ด๋ฒคํธ(์๋)๋ฅผ ์๋ฏธํ๋ค.
๋๋ ์ฒ์์ Intent๊ฐ ์๋๋ก์ด๋์ Intent์ธ์ค์๊ณ ์ด๊ฑธ๋ก ์ด๋ป๊ฒ ํจํด์ ๊ตฌ์ฑํ์ง ๋ผ๊ณ ์๊ฐํ์๋ค. ๊ทผ๋ฐ ViewModel๊ณผ ACC ViewModel์ด ๋ค๋ฅด๋ฏ์ด ์ฌ๊ธฐ์ ๋งํ๋ Intent๋ ์์ ํ ๋ค๋ฅธ ๊ฐ๋ ์ด๋ค.
MVI์ ๊ฐ์ฅ ํฐ ํน์ง์ ํจ์์ ์ ๋ ฅ๋ง์ด ํจ์์ ๊ฒฐ๊ณผ์ ์ํฅ์ ์ฃผ๋ ์์ํจ์ ์ธ์ดํด๋ก ์ด๋ฃจ์ด์ ธ์๋ค๋ ๊ฒ์ด๋ค. ์์ฐ์ค๋ฝ๊ฒ ๋จ๋ฐฉํฅ ๋ฐ์ดํฐ ํ๋ฆ์ผ๋ก ์ด๋ฃจ์ด์ง๋ค.
์ํ๊ฐ ๊ทธ๋ฌ๋ฉด model์ด ๋๊ฒ ๋ค. intentํจ์์ ๊ฒฐ๊ณผ๊ฐ model ํจ์์ ์ธ์๋ก ์ ๋ฌ๋๊ณ , ๋ชจ๋ธ์ ๊ฒฐ๊ณผ๊ฐ ๋ทฐ๋ก ์ ๋ฌ๋๋ค.
์ธ๋ถ์ ์ํฅ์ผ๋ก ์ํ๊ฐ ๋ฐ๋์ง์๊ณ ์ ์ด๋ฒคํธ๊ฐ ๊ธฐ์กด ์ํ์ ๋๋ถ์ด ์ ์ํ๋ฅผ ๋ง๋ ๋ค.
๊ทธ๋์ ์ํ ๋ณํ๋ฅผ ๊ด๋ฆฌํด์ฃผ๋ ์ด๋ค ์ง์ ์ด ํ์ํ๋ฐ, `State Reducer Pattern`์ผ๋ก ์ด ๊ณผ์ ์ ์ฒ๋ฆฌํ๋ค. ์ด๊ฒ React์์ Redux๋ ๋น์ทํ๋ค. reducer, transformer, ๋ค์ํ ์ด๋ฆ์ผ๋ก ๋ถ๋ฆฐ๋ค.
์์ํจ์๋ผ๊ณ ํ๋ฉด ๋ฐ๋ผ์ค๋ ๊ฐ๋ ์ค์ Side Effect๊ฐ ์๋ค. ํจ์ ์ธ๋ถ์ ์์๋ ์ํ๋ฅผ ๋ฐ๊พธ๋ ๊ฑด๋ฐ, Intent์ Model ์ฌ์ด์์ ๋ฐ์ํ๋ ๊ฒ์ด๋ผ๊ณ ๋ณด๋ฉด๋๋ค. ๋ทฐ์๋ ์ํฅ์ ๋ผ์น์ง์์ง๋ง, Model์๋ ์ํฅ์ ๋ผ์น ์ ์๋ค.
๋ค๋น๊ฒ์ด์ , ๋ก๊น , ํ ์คํธ(์ค๋ต๋ฐ), analytics๊ฐ์ ์ผํ์ฑ ์ด๋ฒคํธ๋ค์ Side Effect๋ก ์ฒ๋ฆฌํด์ผ๋๋ ๋ถ๋ถ์ด๋ค. ์ฆ UI์ ๋ฐ์ดํธ๊ฐ ํ์๊ฐ ์๋ ๊ฒ๋ค์ด๋ผ๊ณ ์ดํดํ๋ฉด ๋๋ค. Jetpack Compose ์์ฒด์๋ SideEffect๋ก ๋ถ๋ฅ๋๋ ๊ฒ๋ค์ด ์๊ธฐ๋๋ฌธ์ ์ด๊ฑฐ๋ ํ๋ฒ ์ดํด๋ด์ผ๋๋ค.
# ๊ฐ๋ณ๊ฒ ์ค์ต
๊ตญ๋ฃฐ์ธ ์นด์ดํฐ ์ฑ์ผ๋ก ํ๋ฒ ์์๋ณด์.
๋จผ์ UI๊ฐ ํ์ํ ๋ฐ์ดํฐ๋ฅผ ์ ์ํ๊ฒ ๋ค.
data class CounterState(
val count: Int = 0
)
์ ์ํ๋ง ๊ฐ๊ณ ์ด์ ui์์ ์ฒ๋ฆฌํ ๊ฒ์ด๋ค. ์ด ์ํ๋ ์นด์ดํธ ๊ฐ์ ๊ด๋ฆฌํ๋ค.
์ด์ Intent์ ํด๋นํ๋ ์ก์ (์๋)์ ์ ์ํ๋ฉด ์๋์ ๊ฐ๋ค. ๊ฐ์ ์ฆ๊ฐ์ํค๋ ์ก์ , ๊ฐ์์ํค๋ ์ก์ ์ผ๋ก ๋๋๋ค.
sealed class CounterIntent {
object Increment : CounterIntent()
object Decrement : CounterIntent()
}
์ด์ ๋ทฐ๋ชจ๋ธ์์ ์ก์ ์ ๋ฐ์์ ์ฒ๋ฆฌํด์ผ๋๋ค.
class CounterViewModel : ViewModel() {
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState> = _state.asStateFlow()
fun processIntent(intent: CounterIntent) {
when (intent) {
is CounterIntent.Increment -> {
_state.update { currentState ->
currentState.copy(count = currentState.count + 1)
}
}
is CounterIntent.Decrement -> {
_state.update { currentState ->
currentState.copy(count = currentState.count - 1)
}
}
}
}
}
์ํ๊ฐ์ผ๋ก ๊ณ์ flow๋ฅผ ๊ตฌ๋ ํ๊ณ ์๊ณ , ์ก์ ์ด ๋ค์ด์ค๋ฉด CounterState์ count๊ฐ๋ง updateํด์ค๋ค.
์๋์ ๋ง๊ฒ ์ํ ์ ๋ฐ์ดํธ๋ฅผ ์ํํ๋ ๊ฒ ์ด ํจํด์ ํต์ฌ์ด๋ค.
update๊ฐ ์์ํด์, ์์ธํ ์ดํด๋ดค๋ค.
public inline fun <T> MutableStateFlow<T>.update(function: (T) -> T)
ํ์ฌ ์ํ๋ฅผ ์ธ์๋ก ๋ฐ๊ณ , ์๋ก์ด ์ํ๋ฅผ ๋ฐํํ๋ ํจ์๋ก, MVI์ ๋ชฉ์ ์ธ ์ํ์ ๋ถ๋ณ์ฑ์ ์ ์งํ์ฑ๋ก ์ํ๊ฐฑ์ ์ ํด์ค๋ค๊ณ ๋ณด๋ฉด ๋ ๊ฒ ๊ฐ๋ค. ์ด update ๊ณผ์ ์์ ํ์ฌ ์ํ๋ฅผ ๋ฐ์, ๊ทธ ์ํ ์์ ์๋ ๊ฐ์ dataclass์ ์ ์ฉํ ๋ฉ์๋์ธ copy๋ก ์์ ํด์ ์ ์ํ๋ฅผ ๋ฐํํด์ค๋ค.
Row {
Button(onClick = { viewModel.processIntent(CounterIntent.Decrement) }) {
Text(text = "-")
}
Spacer(modifier = Modifier.width(16.dp))
Button(onClick = { viewModel.processIntent(CounterIntent.Increment) }) {
Text(text = "+")
}
}
UI๋ ๊ทธ๋ฅ ๋ณ๊ฑฐ์๋ค. ui๋ก ์ก์ ์ ์ ๋ฌํ๋ค. ๊ทผ๋ฐ ๊ตฌํ ์ํ๋ฅผ ๋ณด๋ฉด, ์ฒ๋ฆฌ ์์๋ ๋ฐ๋ก ๊ณ ๋ คํ๊ณ ์์ง ์๋ค.
๊ทธ๋์ flow๋ ์ฐ๊ณ ์์ผ๋, channel์ ์ฌ์ฉํด์ ๊ด๋ฆฌ๋ฅผ ํด๋ณด์. Channel์ ์์ฐ์-์๋น์ ํจํด์ ๋ฐ๋ฅด๋ฉฐ, ์ด๋ฒคํธ๊ฐ ๋ค์ด์ค๋ฉด ํ์ ์์ด๊ณ ์์ฐจ์ ์ผ๋ก ์๋น๋๋ค.
๊ธฐ์กด์ processIntent์์ ๋ฐ๋ก ์ฒ๋ฆฌํ๋๊ฑธ channel๋ก ๋ณด๋ด์ ์์๋ฅผ ๋ง์ถฐ์คฌ๋ค.
class CounterViewModel : ViewModel() {
private val events = Channel<CounterIntent>(Channel.UNLIMITED)
val state = MutableStateFlow(CounterState()) // ์ธ๋ถ์์ ์ํ๋ฅผ ๊ตฌ๋
๊ฐ๋ฅ
init {
events.receiveAsFlow()
.onEach { intent ->
updateState(intent) // handleIntent ๋์ updateState๋ก ๋ณ๊ฒฝ
}
.launchIn(viewModelScope) // viewModelScope์์ ์คํ
}
// ์ด๋ฒคํธ ์ฒ๋ฆฌ ํจ์ (์ธ๋ถ์์ ํธ์ถ ๊ฐ๋ฅ)
fun handleEvent(intent: CounterIntent) {
events.trySend(intent) // Channel์ ์ด๋ฒคํธ๋ฅผ ์ ์ก
}
// ์ํ ์
๋ฐ์ดํธ ํจ์ (๋ด๋ถ์ ์ผ๋ก๋ง ์ฌ์ฉ)
private fun updateState(intent: CounterIntent) {
when (intent) {
is CounterIntent.Increment -> {
state.update { currentState ->
currentState.copy(count = currentState.count + 1)
}
}
is CounterIntent.Decrement -> {
state.update { currentState ->
currentState.copy(count = currentState.count - 1)
}
}
}
}
}
events.receiveAsFlow()๋ Channel์์ ์ด๋ฒคํธ๋ฅผ Flow๋ก ๋ณํํ์ฌ ์ฑ๋์์ ๋ฐ์ํ ์ด๋ฒคํธ๋ค์ด ์์ฐจ์ ์ผ๋ก ํ๋ฆ์ ์ํด ์ฒ๋ฆฌํด ์ฌ์ฉํ ์ ์๊ฒ ํด์ค๋ค. events.trySend(intent)๋ Channel์ ์ด๋ฒคํธ๋ฅผ ๋น๋๊ธฐ์ ์ผ๋ก ์ ๋ฌํ๋ค.
public fun trySend(element: E): ChannelResult<Unit>
์ด๋ฒคํธ๊ฐ ์ฑ๋๋ก ๋์ฐฉํ์ง ์์๋ ์์ธ๋ฅผ ๋ฐ์์ํค์ง์๋ safeํ ๋ฉ์๋๋ค.
# ์์ง ์๋๋จ - ์ธ๋ถ ์ํ ๋ณ๊ฒฝ๋ฌธ์
๋ฌธ์ ๊ฐ ํ๋ ์๋ค. state๊ฐ MutableStateFlow๋ผ์ ์ธ๋ถ์์ ๋ฐ๋ก ์ ๊ทผ ๊ฐ๋ฅํ๋ค๋ ์ ์ด๋ค.
// ์ํ๋ฅผ ์ ์ฅํ๋ Flow, runningFold๋ก ์ด๋ฒคํธ์ ๋ฐ๋ผ ์ํ๋ฅผ ๋์ ํด์ ๊ฐฑ์
val state: StateFlow<CounterState> = events.receiveAsFlow()
.runningFold(CounterState(), ::reduceState) // ๋์ ํ์ฌ ์ํ๋ฅผ ๊ฐฑ์
.stateIn(viewModelScope, SharingStarted.Eagerly, CounterState()) // ์ํ๋ฅผ StateFlow๋ก ๋ณํํ์ฌ ๊ตฌ๋
๊ฐ๋ฅํ๊ฒ ํจ
// ๊ธฐ์กด์ ์๋ processIntent๋์ reduce๋ก ๋ฐ๊พผ๋ค
// ์ํ ๊ฐฑ์ ์ ์ฒ๋ฆฌํ๋ ๋ฆฌ๋์ ํจ์
private fun reduceState(currentState: CounterState, intent: CounterIntent): CounterState {
return when (intent) {
is CounterIntent.Increment -> currentState.copy(count = currentState.count + 1)
is CounterIntent.Decrement -> currentState.copy(count = currentState.count - 1)
}
}
kotlin์์ ์ ๊ณตํ๋ state reducer๊ฐ runningFold์ธ๋ฐ, ์ฃผ์ด์ง ์ด๋ฒคํธ์ ์ํ๋ฅผ ํตํด์ ์๋ก์ด ์ํ๋ฅผ ๋ง๋ค์ด๋ธ๋ค.
runningFold๋ Flow์ ์ฐ์ฐ ํจ์ ์ค ํ๋๋ก, ๋์ ๋ ๊ฐ์ ๊ณ์ฐํ๋ฉด์ ํ์ฌ ๊ฐ๊ณผ ์๋ก์ด ๊ฐ์ ๊ฒฐํฉํด ๋๊ฐ๋ค. reduce๋ ๊ฒฐ๊ณผ๋ฅผ ๋จ์ผ ๊ฐ์ผ๋ก ๋ฐํํ๋ ๋ฐ๋ฉด, runningFold๋ ๋งค๋ฒ ์๋ก์ด ๊ฐ์ ๋ฐํํ๋ฉด์ ์ค๊ฐ ์ํ๋ฅผ ๋ชจ๋ ๋ฐํํ๋ค.
๋์ ํจ์๋ก reduceState๊ฐ ์ฐ์ธ๊ฒ์ด๋ค.
public fun <T, R> Flow<T>.runningFold(initial: R, @BuilderInference operation: suspend (accumulator: R, value: T) -> R): Flow<R> = flow {
var accumulator: R = initial
emit(accumulator)
collect { value ->
accumulator = operation(accumulator, value)
emit(accumulator)
}
}
์ด๊ธฐ ์ํ์ ์ด๋ฒคํธ ๋ณ๊ฒฝ๋๋ ์ด๋ฒคํธ๋ฅผ ๋ฐ์์ ์ฒ๋ฆฌํ๋ ๊ฑธ ๋ณผ ์ ์๋ค.
์ ์ฒด์ ์ธ ํ๋ฆ์ ๋ค์ ์ง์๋ฉด,
- ์ด๋ฒคํธ๊ฐ ์ฑ๋์ ๋ค์ด์ค๋ฉด
- reduceState๋ฅผ ํธ์ถํด์ ์๋ก์ด ์ํ๋ฅผ ๋ฐํํ๋ค(์ด๋ runningFold์์ ์ํ๋ฅผ ๋ฐ์ ๊ทธ๊ฑธ reduceStateํธ์ถํ ๋ ๋ฃ๋๋ค.)
- ๊ทธ๋ฌ๋ฉด state๋ฅผ ๊ตฌ๋ ํ๊ณ ์๋ UI ๋จ์์ ์ ๋ฐ์ดํธ๊ฐ ์ด๋ฃจ์ด์ง๊ฒ ๋๋ค.
MVI... ์ด๋ ต๋ค.
::์ถ์ฒ::
๋์์ด ๋๋ค๋ฉด ๋๊ธ์ด๋ ๊ณต๊ฐ ๋ฒํผ ํ ๋ฒ์ฉ ๋๋ฅด๊ณ ๊ฐ์ฃผ์ธ์!
'Android ๐ฅ๏ธ > ์ํคํ ์ณ๐' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
MVI, MVVM ํจํด (0) | 2024.12.17 |
---|