
이전 글에는 초기 셋팅을 해보았다.
이제 기존 base를 만들차례이다. 나는 MVI를 사용 할 예정이고 라이브러리고 orbit을 사용할 것이다.
Orbit이란 Android에서 MVI 패턴을 간단하게 구현할 수 있도록 도와주는 Kotlin 기반 라이브러리다.
Redux나 MVI의 핵심 개념을 가조오되, 러닝커브와 보일러플레이트를 최소화한 것이다.
기존의 MVI랑 같은 작업인 것 같다.
우선 UI에서 쓰일 Model을 정의 해야된다.
data class MainState(
val query : String = "",
val isLoading : Boolean = false,
val results : List<Book> = emptyList()
)
UI에서 상태를 가진 값들을 정의 해준 것이다.
이제 SideEffect를 정의 해보자
sealed interface MainSideEffect {
data class ShowToast(val message : String) : MainSideEffect
data class NavigateToDetail(val bookId : Long) : MainSideEffect
}
이렇게 정의를 한 뒤
viewModel에서 ContainerHost라는 interface를 상속만 해주면 된다.
class MainViewModel(
private val repository : SearchRepository
) : ViewModel(), Containter<MainState, MainSideEffect> {
// 컨테이너 생성 : 상태 초기화
override val container = container<MainState, MainSideEffect>(MainState())
// ② 입력창 텍스트 변경
fun onQueryChange(query: String) = intent {
reduce { state.copy(query = query) }
}
fun search() = intent {
if(state.query.isBlank()) {
postSideEffect(MainSideEffect.ShowToast("검색어를 입력하세요"))
return@intent
}
reduce { state.copy(isLoading = true)}
runCatching { repository.search(state.query) }
.onSuccess { books ->
reduce { state.copy(isLoading = false, results = books )}
}
.onFailure {
reduce { state.copy(isLoading = false) }
postSideEffect(MainSideEffect.ShowToast("검색에 실패했어요."))
}
}
fun onBookClick(bookId : Long) = intent {
postSideEffect(MainSideEffect.NavigateToDetail(bookId))
}
}
기존 MVI랑 거의 비슷하지만 intent{} 괄호가 보일 것 이다.
intent 괄호안에서는 기본적으로 coroutineScope으기 때문에 suspend 함수를 호출 할 수 있다.
그리고 intent{}안에서만 reduce와 postSideEffect를 사용할 수 있다.
reduce는 기존 MVI에서 update의 역할과 비슷하다. state의 상태를 변경할 때 copy보단 update를 권장하는 이유는 CAS(compare-and-set)로 원자성을 보장해주기 때문이다. orbit에서도 reduce가 그런 역할이다.
postSideEffect는 내부에 channel로 사이드이펙트를 소비해주는 역할이다.
channel인 이유는 크게 3가지 인데
1. 딱 한번만 소비되어야 된다.
2. 유실되면 안된다. (SharedFlow는 백그라운드에서 발생한 이벤트를 유실한다)
3. 최신값 재발충이 없어야 된다. (StateFlow는 값을 가지고 있으면 현재 값을 재방출한다)
이 3가지를 만족하기 때문에 SharedFlow가 아닌 Channel을 권장하는 이유이다.
UI(구독하는 쪽)에서의 사용법을 알아보자
@Composable
fun MainScreen(
viewModel: MainViewModel = koinViewModel(),
onNavigatgeToDetail : (Long) -> Unit
) {
val state by viewModel.collectAsState() // State가 바뀔 때마다 리컴포지션
viewModel.collectSideEffect { effect ->
when(effect) {
is MainSideEffect.ShowToast ->
Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
is MainSideEffect.NavigateToDetail ->
onNavigateToDetail(effect.bookId)
}
}
Column(Modifier.padding(16.dp)) {
OutlinedTextField(
value = state.query,
onValueChange = viewModel::onQueryChange, // 이벤트는 ViewModel 함수 호출
label = { Text("책 제목 검색") }
)
Button(
onClick = viewModel::search,
enabled = !state.isLoading
) {
Text("검색")
}
if (state.isLoading) {
CircularProgressIndicator()
}
LazyColumn {
items(state.results, key = { it.id }) { book ->
BookItem(
book = book,
onClick = { viewModel.onBookClick(book.id) }
)
}
}
}
}
collectedAsState()는 orbit에서 제공해주는 확장함수이다. 컨테이너의 StateFlow를 Compose State로 변환해주고 라이프사이클도 알아서 처리해준다.
collectSideEffect{} 내부적으로 Channel 기반이라서 이벤트가 딱 한번만 소비된다.
동작 방식 자체는 기존에 사용하던 MVI와 완전히 동일하다. 결국 Orbit도 "단일 State + 단방향 흐름 + reduce를 통한 상태 변경"이라는 MVI의 원칙 위에서 움직이기 때문이다.
다만 Intent 클래스 정의, 스트림 배선 같은 보일러플레이트 코드가 확실히 줄어들었다.Orbit은 프레임워크가 아니라 라이브러리에 가깝다는 점도 마음에 들었다.
ViewModel, Compose 등 기존 Jetpack 라이브러리 위에 자연스럽게 얹어 쓸 수 있어서, MVI 패턴 그 자체에만 집중할 수 있었다.기존 방식과 달랐던 점은 UI → ViewModel 이벤트 전달 방식이다.
이전에는 Event sealed interface를 정의하고 onEvent 함수 하나로 모든 이벤트를 받아 처리했지만, Orbit에서는 ViewModel의 함수를 직접 호출하는 방식을 권장하는 것으로 보인다.
직접 호출해보니 Event 클래스와 when 분기가 사라지면서 코드가 확실히 더 깔끔해졌다. 아직 뚜렷한 단점은 찾지 못했는데, 화면이 커져서 액션이 많아지면 함수 직접 호출 방식의 트레이드오프가 보일지 좀 더 써봐야 알 것 같다.