들어가기 전에
본격적으로 프로젝트를 시작하기 전에 적절한 UI 갱신 및 리컴포지션 관리에 대해 관심이 생겼다.
이는 Compose에서 성능을 위해 필수적 요소라고 생각한다..
나는 상태관리를 엉망으로 하고 있다는 걸 평소에 느꼈기 때문에 상태관리에 집중하고자 MVI 패턴을 공부하려 한다. (우연히 본 깃허브와 유튜브가 흥미를 돋게 한 것도 있다)
MVI 패턴
MVI 패턴은 Model, View, Intent로 크게 3가지 구성요소로 이루어져 있다.
- Model: UI에 반영될 상태
- View: UI
- Intent : 사용자 액션, 시스템 이벤트에 따른 결과
- (`android.content.Intent` 아님)
M-V-I의 관계는 순수함수 형식으로 표현 가능한데, 단방향 흐름을 나타내고 있다.
View에서 버튼 클릭과 같은 이벤트가 발생하면 그 버튼 클릭을 의도(Intent)로 보고 Model을 업데이트한다. 그럼 변경된 상태가 View에 반영되는 구조이다.
코드 작성
`orbit` 등 MVI를 위한 위대한 라이브러리가 존재하지만, 이해를 위해 차근차근 코드를 작성해서 공부해보려 한다.
State, Intent, SideEffect
일단 상태를 위한 클래스를 작성한다.
이 부분을 `interface` 로 작성하기도 하는데, 필자는 다중 상속이 불가능하다고 생각하여 `추상 클래스`로 다음과 같은 의미로 정의했다.
- `UiState`: 현재 상태
- `UiIntent` : 사용자의 의도(행동)
- `UiSideEffect` : 화면 이동, 로깅, 에러메시지 등과 같은 SideEffect
abstract class UiState
abstract class UiIntent
abstract class UiSideEffect
BaseViewModel
그리고 `BaseViewModel` 을 작성한다.
앞서 정의한 `UiState` ,`UiIntent` , `UiSideEffect` 를 상속한 타입만 오도록 제네릭 타입 파라미터로 선언해 준다. 그리고 상태는 초기 값이 필요하기 때문에 초기값도 함께 받아준다. 이는 뷰모델이기 때문에 당연히 뷰모델을 상속받는다.
abstract class BaseViewModel<State: UiState, Intent: UiIntent, SideEffect: UiSideEffect>(
initialState: State
):ViewModel() { .. }
여기서는 각각의 특징을 고려하여 다음과 같이 선언했다.
`State` - `StateFlow` : 초기값 설정 가능, 최신값을 필요로 함.
`Intent` - `SharedFlow` : 이벤트를 처리해야 하는 구독자가 존재하지 않는다면 무시될 필요가 있음.
`SideEffect` - `Channel` : 단발성 이벤트 처리, 각각의 이벤트가 오직 하나의 구독자에게만 전달.
val currentState: State
get() = uiState.value
private val _uiState: MutableStateFlow<State> = MutableStateFlow(initialState)
val uiState: StateFlow<State>
get() = _uiState.asStateFlow()
private val _intent: MutableSharedFlow<Intent> = MutableSharedFlow()
val intent = _intent.asSharedFlow()
private val _sideEffect: Channel<SideEffect> = Channel()
val sideEffect = _sideEffect.receiveAsFlow()
차례대로 `UiState` , `UiIntent` , `UiEffect` 의 `setter` 를 선언해 준다.
/**
* State 설정
*/
fun reduce(reducer: State.() -> State) { _uiState.value = currentState.reducer() }
/**
* Intent 설정
*/
fun postIntent(intent: Intent) = viewModelScope.launch { _intent.emit(intent) }
/**
* SideEffect 설정
*/
fun postSideEffect(builder: () -> SideEffect) = viewModelScope.launch { _sideEffect.send(builder()) }
그리고 `Intent` 를 처리하기 위해서 Flow를 collect 해줘야 한다. 그리고 Intent를 핸들링하는 부분은 모든 뷰모델에서 각각의 상황에 맞게 처리해줘야 하기 때문에 추상 함수로 정의해준다.
/**
* Intent 구독
*/
private fun subscribeIntent() = viewModelScope.launch {
intent.collect { handleIntent(it) }
}
/**
* Intent 핸들러
*/
abstract fun handleIntent(intent: Intent)
BaseViewModel 코드 전체 보기
/**
* State - StateFlow : 초기값을 가져야하고 항상 최신값을 필요로 함
* Intent - SharedFlow : 이벤트를 처리해야하는 구독자가 존재하지 않으면 무시될 필요가 있음
* Effect - Channel : 이벤트 공유 X
*
*/
abstract class UiState
abstract class UiIntent
abstract class UiSideEffect
abstract class BaseViewModel<State: UiState, Intent: UiIntent, SideEffect: UiSideEffect>(
initialState: State
):ViewModel() {
val currentState: State
get() = uiState.value
private val _uiState: MutableStateFlow<State> = MutableStateFlow(initialState)
val uiState: StateFlow<State>
get() = _uiState.asStateFlow()
private val _intent: MutableSharedFlow<Intent> = MutableSharedFlow()
val intent = _intent.asSharedFlow()
private val _sideEffect: Channel<SideEffect> = Channel()
val sideEffect = _sideEffect.receiveAsFlow()
init {
subscribeIntent()
}
/**
* Intent 구독
*/
private fun subscribeIntent() = viewModelScope.launch {
intent.collect { handleIntent(it) }
}
/**
* Intent 핸들러
*/
abstract fun handleIntent(intent: Intent)
/**
* State 설정
*/
fun reduce(reducer: State.() -> State) { _uiState.value = currentState.reducer() }
/**
* Intent 설정
*/
fun postIntent(intent: Intent) = viewModelScope.launch { _intent.emit(intent) }
/**
* SideEffect 설정
*/
fun postSideEffect(builder: () -> SideEffect) = viewModelScope.launch { _sideEffect.send(builder()) }
}
이로써 MVI의 적용의 준비의 준비의 준비가 끝났다.
준비의 준비 (ViewModel 구현)
필자는 예제로 간단한 화면을 구상하려는데,
- api로 post 받아오기 (로딩)
- refresh 버튼을 통해 새로고침하기
- 토스트 메시지 띄우기
이 세 가지 기능을 추가했다.
이 화면에 필요한 `State` , `Intent` , `SideEffect` 를 선언해 준다.
이렇게 되면 `상태`에는 데이터와 로딩이 존재하고, `의도`에는 클릭과 새로고침, `SideEffect`에는 토스트 띄우기가 있게 된다.
사실 `PostState`에서 기본값을 미리 선언해 줘도 되긴 하는데 유연한 대응을 위해 초기화 부분을 따로 만들었다.
data class PostState(
val post: List<ExamplePost>,
val isLoading: Boolean
): UiState() {
companion object {
fun init() = PostState(post = emptyList(), isLoading = false)
}
}
sealed class PostIntent : UiIntent() {
data class OnClick(val post: ExamplePost): PostIntent()
data object Refresh: PostIntent()
}
sealed class PostSideSideEffect: UiSideEffect() {
data object ShowErrorToast: PostSideSideEffect()
data class ShowToast(val message: String) : PostSideSideEffect()
}
그리고 이를 바탕으로 화면과 연동할 뷰모델을 구현해 준다. 그리고 추상 함수였던 `handleIntent` 를 `Intent`에 맞게 구현해 준다.
@HiltViewModel
class PostViewModel @Inject constructor(
private val repository: PostRepository
):BaseViewModel<PostState, PostIntent, PostSideSideEffect>(
PostState.init()
) {
..생략..
override fun handleIntent(intent: PostIntent) {
when(intent) {
is PostIntent.OnClick -> {
onClick(intent.post.title)
}
is PostIntent.Refresh -> {
onClick("Refresh")
fetchPost()
}
}
}
..생략..
}
그리고 생략된 부분에는 post를 가져오는 부분, 클릭이벤트를 처리하는 부분 등이 있다.
앞서 `BaseViewModel` 에서 선언한 함수들이 쓰인다. (`reduce` , `postSideEffect` 등..)
init {
fetchPost()
}
..생략..
private fun fetchPost() = viewModelScope.launch {
reduce { copy(isLoading = true) }
delay(1000)
repository.getPost()
.onSuccess {
reduce { copy(post = it) }
}.onFailure {
postSideEffect { PostSideSideEffect.ShowErrorToast }
}.also {
reduce { copy(isLoading = false) }
}
}
private fun onClick(message: String) {
println("onclick :$message")
postSideEffect { PostSideSideEffect.ShowToast(message) }
}
PostViewModel 전체 코드
data class PostState(
val post: List<ExamplePost>,
val isLoading: Boolean
): UiState() {
companion object {
fun init() = PostState(post = emptyList(), isLoading = false)
}
}
sealed class PostIntent : UiIntent() {
data class OnClick(val post: ExamplePost): PostIntent()
data object Refresh: PostIntent()
}
sealed class PostSideSideEffect: UiSideEffect() {
data object ShowErrorToast: PostSideSideEffect()
data class ShowToast(val message: String) : PostSideSideEffect()
}
@HiltViewModel
class PostViewModel @Inject constructor(
private val repository: PostRepository
):BaseViewModel<PostState, PostIntent, PostSideSideEffect>(
PostState.init()
) {
init {
fetchPost()
}
override fun handleIntent(intent: PostIntent) {
when(intent) {
is PostIntent.OnClick -> {
onClick(intent.post.title)
}
is PostIntent.Refresh -> {
onClick("Refresh")
fetchPost()
}
}
}
private fun fetchPost() = viewModelScope.launch {
reduce { copy(isLoading = true) }
delay(1000)
repository.getPost()
.onSuccess {
reduce { copy(post = it) }
}.onFailure {
postSideEffect { PostSideSideEffect.ShowErrorToast }
}.also {
reduce { copy(isLoading = false) }
}
}
private fun onClick(message: String) {
println("onclick :$message")
postSideEffect { PostSideSideEffect.ShowToast(message) }
}
}
준비
UI에 적용하면 MVI 적용 준비는 끝난 것이다.
`state` 는 다음과 같이 collect 했다
val uiState by viewModel.uiState.collectAsStateWithLifecycle(
lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
)
그리고`intent`호출이 필요한 부분은 다음과 같이 해줬다.
viewModel.postIntent(PostIntent.OnClick(it))
`sideEffect` 는 다음과 같이 collect 했다
LaunchedEffect(key1 = viewModel) {
viewModel.effect.collect {sideEffect ->
when (sideEffect) {
is PostSideEffect.ShowToast -> {
showToast(context, sideEffect.message)
}
is PostSideEffect.ShowErrorToast -> {
showToast(context, "알 수 없는 에러입니다.")
}
}
}
}
`sideEffect` 를 저런 식으로 항상 호출하는 게 번거로울 것 같아 collect 하는 확장함수를 참고했다.
`collectAsStateWithLifecycle` 를 참고하여 Flow 확장함수를 작성했다.
- Flow(`flow`), 라이프사이클(`lifecycle`), 최소 활성 상태(`minActiveState`), 그리고 키(`keys`)에 따라 `LaunchedEffect` 가 실행된다.
- `repeatOnLifecycle` 을 사용하여 라이프사이클이 주어진 최소 활성 상태에 있는 동안 수집 작업을 반복한다.
- Flow에서 데이터를 수집하고, 수집된 각 항목에 대해 `currentCollector` 함수를 호출하여 데이터를 처리한다.
@Composable
fun <T> Flow<T>.collectInLaunchedEffectWithLifecycle(
vararg keys: Any?,
lifecycle: Lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
collector: suspend CoroutineScope.(sideEffect: T) -> Unit,
) {
val currentCollector by rememberUpdatedState(collector)
LaunchedEffect(this, lifecycle, minActiveState, *keys) {
withContext(Dispatchers.Main.immediate) {
lifecycle.repeatOnLifecycle(minActiveState) {
this@collectInLaunchedEffectWithLifecycle.collect { currentCollector(it) }
}
}
}
}
출처
그럼 다음과 같이 간단하게 사용 가능하다
viewModel.sideEffect.collectInLaunchedEffectWithLifecycle { sideEffect ->
when (sideEffect) {
is PostSideSideEffect.ShowToast -> {
showToast(context, sideEffect.message)
}
is PostSideSideEffect.ShowErrorToast -> {
showToast(context, "알 수 없는 에러입니다.")
}
}
}
PostScreen 전체 코드
@Composable
fun PostScreen() {
val viewModel: PostViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle(
lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
)
val context = LocalContext.current
Column {
Button(onClick = { viewModel.postIntent(PostIntent.Refresh) }) {
Text(text = "Refresh")
}
LazyColumn {
items(uiState.post) { post ->
PostCard(post = post) {
viewModel.postIntent(PostIntent.OnClick(it))
}
}
}
}
if (uiState.isLoading) {
Loading()
}
viewModel.sideEffect.collectInLaunchedEffectWithLifecycle { sideEffect ->
when (sideEffect) {
is PostSideSideEffect.ShowToast -> {
showToast(context, sideEffect.message)
}
is PostSideSideEffect.ShowErrorToast -> {
showToast(context, "알 수 없는 에러입니다.")
}
}
}
// LaunchedEffect(key1 = viewModel) {
// viewModel.effect.collect {sideEffect ->
// when (sideEffect) {
// is PostSideEffect.ShowToast -> {
// showToast(context, sideEffect.message)
// }
// is PostSideEffect.ShowErrorToast -> {
// showToast(context, "알 수 없는 에러입니다.")
// }
// }
//
// }
// }
}
@Composable
private fun PostCard(
post: ExamplePost,
onClick: (ExamplePost) -> Unit
) {
Column(
modifier = Modifier
.border(width = 1.dp, color = Color.Gray)
.clickable { onClick(post) }
) {
Text(text = "id: ${post.id}")
Text(text = "title: ${post.title}")
Text(text = "content: ${post.body}")
Text(text = "userId: ${post.userId}")
}
}
fun showToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@SuppressLint("ComposableNaming")
@Composable
fun <T> Flow<T>.collectInLaunchedEffectWithLifecycle(
vararg keys: Any?,
lifecycle: Lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
collector: suspend CoroutineScope.(sideEffect: T) -> Unit,
) {
val currentCollector by rememberUpdatedState(collector)
LaunchedEffect(this, lifecycle, minActiveState, *keys) {
withContext(Dispatchers.Main.immediate) {
lifecycle.repeatOnLifecycle(minActiveState) {
this@collectInLaunchedEffectWithLifecycle.collect { currentCollector(it) }
}
}
}
}
결론
이렇게 MVI를 적용하고 공부해 보았는데.. 장단점이 명확한 것 같다.
- 장점
- `State`, `Intent`, `SideEffect` 등의 모든 액션이 같은 파일에 있어 화면에서 일어나는 일을 쉽게 이해 가능
- 상태 관리가 쉬움
- 단방향으로 데이터가 흘러가기 때문에 추적이 쉬움
- 상태 객체는 불변해서 스레드로부터 안전함.
- 단점
- 진입 장벽이 높다..
- 아주 작은 변경도 intent를 통한 사이클이 필요하다..
- 상태 관리를 위해 비교적 많은 코드를 작성해야 한다.. → 코드 양 증가
- 많은 객체를 생성해야 하기 때문에 높은 메모리 관리가 필요하다..
‘MVI는 신이야’ 이러한 태도는 지양해야 한다. 각 상황에 맞는 아키텍처들이 존재하고, 프로젝트에 적합한 아키텍처를 사용하는 것이 좋다.
프로젝트에 적용해 보면서 이해도를 높여야겠다.
위에서 작성한 코드는 깃허브에서 볼 수 있다.
https://github.com/EASYhz/MVVM-vs-MVI
틀린 부분이나, 저의 생각과 다른 부분, 개선점 등이 있다면 공유 부탁드립니다!
참고자료
https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow
https://proandroiddev.com/mvi-architecture-with-kotlin-flows-and-channels-d36820b2028d
'안드로이드' 카테고리의 다른 글
[Android] 전략 패턴(Strategy Pattern)을 적용하여 소셜 로그인 리팩토링 하기 (0) | 2024.08.12 |
---|---|
[Android] 네트워크 예외 처리 (에러 핸들링)- Retrofit CallAdapter (0) | 2024.06.16 |
[Android] 메인 화면 성능 개선하기 대작전 (1) | 2024.04.19 |
[Android] Animation Transition 시 이미지 로딩 개선하기 (0) | 2024.04.14 |
[Android] Intent, Content URI 와 File Provider 를 사용하여 외부(크롬, 갤러리 등)에서 이미지 가져오기 (이미지 공유 받기) (0) | 2024.04.11 |