💡 MVI란?
MVI는 Model - View - Intent
여기서 말하는 Intent는 Android Intent가 아니라 !!!
👉 “사용자의 의도(Intent)부터 상태(State) 변경까지 이어지는 흐름 전체”를 뜻하는 개념적인 용어,,
❗️그러니까 MVI의 Intent ≠ UIEvent ≠ 안드로이드 Intent → 절대 헷갈리면 안된다..
MVI 흐름
[사용자 행동 (UIEvent)]
→ ViewModel이 받음
→ 상태(UIState) 변경 or 효과(UIEffect) 발생
→ View가 변화됨
- 이 흐름의 출발점은 항상 UIEvent
- 화면은 항상 UIState 하나로만 관리
🧩 자주 사용되는 타입들
🧠 UIState | 현재 화면의 상태 (ex. 키워드, 로딩 중, 결과 등) | data class |
👆 UIEvent | 사용자 액션 (ex. 버튼 클릭, 키 입력 등) | sealed class |
⚡ UIEffect | Toast, 화면 이동 등 일회성 처리 | sealed class |
MVI의 구성요소는 아니고, MVI를 Android에서 구현할 때 자주 등장하는 타입들
📦 왜 타입이 다를까?
- UIState → data class
- 현재 화면의 상태를 나타내는 데이터 덩어리 라서 → data class로 관리
- 일부만 바꿀 땐 copy()로 간편하게 처리 가능
val newState = oldState.copy(keyword = "아몬드")
- 이렇게 하면 상태도 immutable하게 유지되고, Compose에서 recomposition도 정확히 됨
- UIEvent, UIEffect → sealed class
- 사용자 액션이나, 그에 따른 동작이 다양하니 → sealed class로 묶어 관리
sealed class BookSearchEvent {
data class SearchKeywordChanged(val keyword: String) : BookSearchEvent()
object SearchButtonClicked : BookSearchEvent()
data class BookClicked(val bookId: String) : BookSearchEvent()
}
sealed class BookSearchEffect {
data class ShowToast(val message: String) : BookSearchEffect()
data class NavigateToDetail(val bookId: String) : BookSearchEffect()
}
✅
- MVI는 구조이고, Intent는 흐름 전체를 의미함
- 실제 구현할 때는 이 3가지 단위를 다룸:
- UIEvent → 사용자 입력
- UIState → 화면 상태
- UIEffect → 일회성 작업 (토스트 같은..)
- UIEvent에서 시작해서 ViewModel이 처리 → State or Effect로 변환 → View 변화
예시 코드
// UIEvent
sealed class BookSearchEvent {
data class SearchKeywordChanged(val keyword: String) : BookSearchEvent()
object SearchButtonClicked : BookSearchEvent()
data class BookClicked(val bookId: String) : BookSearchEvent()
}
// UIState
data class BookSearchState(
val keyword: String = "",
val isLoading: Boolean = false,
val results: List<Book> = emptyList(),
val errorMessage: String? = null
)
// UIEffect
sealed class BookSearchEffect {
data class ShowToast(val message: String) : BookSearchEffect()
data class NavigateToDetail(val bookId: String) : BookSearchEffect()
}
[viewModel]
@HiltViewModel
class BookSearchViewModel @Inject constructor() : ViewModel() {
private val _uiState = MutableStateFlow(BookSearchState())
val uiState: StateFlow<BookSearchState> = _uiState
private val _effect = MutableSharedFlow<BookSearchEffect>()
val effect = _effect.asSharedFlow()
fun onEvent(event: BookSearchEvent) {
when (event) {
is BookSearchEvent.SearchKeywordChanged -> {
_uiState.update { it.copy(keyword = event.keyword) }
}
BookSearchEvent.SearchButtonClicked -> {
_uiState.update { it.copy(isLoading = true) }
// 가짜 API 호출 예시
viewModelScope.launch {
delay(1000)
val results = getFakeBooks(event = _uiState.value.keyword)
_uiState.update { it.copy(isLoading = false, results = results) }
if (results.isEmpty()) {
_effect.emit(BookSearchEffect.ShowToast("검색 결과가 없습니다"))
}
}
}
is BookSearchEvent.BookClicked -> {
viewModelScope.launch {
_effect.emit(BookSearchEffect.NavigateToDetail(event.bookId))
}
}
}
}
private fun getFakeBooks(event: String): List<Book> {
return if (event.contains("아몬드")) listOf(Book("1", "아몬드")) else emptyList()
}
}
[Screen]
@Composable
fun BookSearchScreen(
navController: NavController,
viewModel: BookSearchViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
// 상태 반영
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = uiState.keyword,
onValueChange = {
viewModel.onEvent(BookSearchEvent.SearchKeywordChanged(it))
},
label = { Text("검색어 입력") }
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = {
viewModel.onEvent(BookSearchEvent.SearchButtonClicked)
}) {
Text("검색")
}
Spacer(modifier = Modifier.height(16.dp))
if (uiState.isLoading) {
CircularProgressIndicator()
} else {
LazyColumn {
items(uiState.results) { book ->
Text(
text = book.title,
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.onEvent(BookSearchEvent.BookClicked(book.id))
}
.padding(8.dp)
)
}
}
}
}
// 일회성 Effect 처리
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is BookSearchEffect.ShowToast -> {
Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
is BookSearchEffect.NavigateToDetail -> {
navController.navigate("detail/${effect.bookId}")
}
}
}
}
}
'📱 Android' 카테고리의 다른 글
[Android] WorkManager (0) | 2025.04.18 |
---|---|
[Android] 클린 아키텍처 적용 시 고민했던 3가지 의문점 (0) | 2025.04.12 |
[Android / RecyclerView] onCreateViewHolder vs onBindViewHolder: 클릭 리스너는 어디에 둘까? (0) | 2025.04.07 |
[Android] LiveData - observeForever (0) | 2025.03.21 |
[Android] ViewPager2 감도 조절하기 (0) | 2025.02.08 |