https://superohinsung.tistory.com/428
[Android] Orbit Multiplatform 개요와 Core 탐구하기
https://superohinsung.tistory.com/148 [Android] MVI 패턴이란?MVI 패턴이란 Android MVI 패턴은 Model-View-Intent의 약자로, 안드로이드 앱 개발에서 사용되는 아키텍처 패턴 중 하나입니다. MVI 패턴은 기존의 MVP, MVVM
superohinsung.tistory.com
위 포스팅에서 이어지는 글입니다.

1. ViewModel (Multiplatform) 모듈
Android의 ViewModel과 공통(Common) ViewModel 환경에서 Orbit Container를 편리하게 생성·관리하고, Saved State를 자동으로 처리해 주는 확장 기능을 제공합니다.
1.1 모듈 포함
implementation("org.orbit-mvi:orbit-viewmodel:10.0.0")
1.2 ViewModel에서 Container 생성
ViewModelScope에 자동으로 바인딩되어, ViewModel이 clear될 때 Container도 함께 취소됩니다.
class ExampleViewModel : ContainerHost<ExampleState, Nothing>, ViewModel() {
override val container = container<ExampleState, Nothing>(ExampleState())
// … 추가 Intent / Reducer 정의 …
}
1.3 Saved State 기능
1.3.1 Kotlinx Serialization (멀티플랫폼)
- 조건
- 상태(State) 데이터 클래스에 @Serializable 적용
- SavedStateHandle과 Serializer 인스턴스를 container()에 전달
@Serializable
data class ExampleState(
// … 필드 정의 …
)
// ViewModel 생성자에서 SavedStateHandle, serializer 전달
class ExampleViewModel(
savedStateHandle: SavedStateHandle
) : ContainerHost<ExampleState, Nothing>, ViewModel() {
override val container = container<ExampleState, Nothing>(
initialState = ExampleState(),
savedStateHandle = savedStateHandle,
serializer = ExampleState.serializer()
)
// … 추가 Intent / Reducer 정의 …
}
1.3.2 Parcelable (Android 전용)
- 조건
- 상태(State) 데이터 클래스에 @Parcelize 적용 (kotlin-parcelize 플러그인 필요)
- SavedStateHandle을 container()에 전달
@Parcelize
data class ExampleState(
// … 필드 정의 …
) : Parcelable
// ViewModel 생성자에서 SavedStateHandle 전달
class ExampleViewModel(
savedStateHandle: SavedStateHandle
) : ContainerHost<ExampleState, Nothing>, ViewModel() {
override val container = container<ExampleState, Nothing>(
initialState = ExampleState(),
savedStateHandle = savedStateHandle
)
// … 추가 Intent / Reducer 정의 …
}
1.4 SavedStateHandle의 제약 사항
- 프로세스 종료 시 신뢰도 낮음
- 구성 변경(Configuration change)에는 유지되지만, 프로세스가 강제로 종료되면 데이터가 사라질 수 있음
- 권장: 중요한 데이터는 Room 또는 DataStore 등 영속 저장소에 보관
- Bundle 크기 제한
- 너무 큰 객체를 저장하면 TransactionTooLargeException 발생 가능
- 권장: 간단한 UI 상태(ID, Enum 등)만 저장
- 작업 스택(Task Stack) 의존
- 앱이 최근 목록에서 제거되면 데이터 손실
⚙️ 베스트 프랙티스
작은 UI 상태만 저장
큰/복잡한 데이터는 사용 금지
장기 보관이 아닌, 일시적(transient) UI 상태 관리 용도로만 활용
2. Compose (Multiplatform) 모듈
Compose UI 및 Compose Multiplatform 환경에서 ContainerHost를 안전하고 간편하게 구독하기 위한 확장 기능을 제공합니다.
2.1 모듈 포함
implementation("org.orbit-mvi:orbit-compose:10.0.0")
2.2 Composable에서 ContainerHost 구독
- collectAsState()로 현재 상태(State)를 수집
- collectSideEffect { … }로 사이드 이펙트 처리
- 뷰가 STARTED 상태일 때만 자동 구독
@Composable
fun SomeScreen(viewModel: SomeViewModel) {
// 상태 수집
val state by viewModel.collectAsState()
// 사이드 이펙트 처리
viewModel.collectSideEffect { effect ->
when (effect) {
// … 사이드 이펙트별 분기 처리 …
}
}
// 실제 UI 렌더링
SomeContent(state = state)
}
2.3 TextField 상태 호이스팅
Compose TextField에 직접 Intent를 연결하면 스레딩 이슈로 입력이 일부 손실될 수 있습니다.
대신 TextFieldState를 사용하고, snapshotFlow로 텍스트 변화를 수집하여 ViewModel 내부에서 유효성 검사나 자동완성 로직을 처리하세요.
class TextViewModel : ViewModel(), ContainerHost<TextViewModel.State, Nothing> {
override val container: Container<State, Nothing> = container(State()) {
coroutineScope {
launch {
snapshotFlow { state.textFieldState.text }
.collectLatest { text ->
reduce { state.copy(isValid = text.isValid()) }
}
}
}
}
data class State(
val textFieldState: TextFieldState = TextFieldState(""),
val isValid: Boolean = false,
)
companion object {
fun CharSequence.isValid(): Boolean {
return this.isNotBlank() && this.length <= 10
}
}
}
2.4 Compose UI 테스트
- ViewModel과 UI 렌더링 로직을 분리
- UI 컴포저블(예: SomeContent)을 순수 함수로 두어 단위 테스트 수행
- Intent 호출은 SomeScreen에서만, SomeContent에는 상태와 콜백만 전달
// UI 컴포저블에 콜백 전달 예시
SomeContent(
state = state,
onSubmit = { viewModel.submit() }
)
// 또는 인터페이스 형태로
interface SomeCallbacks {
fun onSubmit()
}
SomeContent(
state = state,
callbacks = object : SomeCallbacks {
override fun onSubmit() = viewModel.submit()
}
)
3. Test 모듈
Turbine 라이브러리 기반으로, Orbit ContainerHost를 예측 가능한 코루틴 스코프와 컨텍스트 하에서 테스트할 수 있도록 해 줍니다.
3.1 모듈 포함
testImplementation("org.orbit-mvi:orbit-test:10.0.0")
3.2 테스트 기본 절차
- test()모드로 ContainerHost 진입
- (옵션) runOnCreate() 실행 → Container 생성 시 onCreate 블록 실행
- (옵션) containerHost.foo() → 특정 Intent 실행
- awaitState(), awaitSideEffect() 또는 expectState { … }, expectSideEffect(...)로 결과 검증
- 남은 미소비 아이템 처리 → skip(n) 또는 cancelAndIgnoreRemainingItems()
data class State(val count: Int = 0)
@Test
fun exampleTest() = runTest {
// 1. test 모드 진입 (초기 상태 주입 가능)
ExampleViewModel().test(this, State()) {
// 2. Intent 호출
containerHost.countToFour()
// 3. 상태·사이드 이펙트 검증
expectState { copy(count = 1) }
expectState { copy(count = 2) }
expectState { copy(count = 3) }
expectState { copy(count = 4) }
}
}
3.3 runOnCreate()
- Container 생성 시 등록된 onCreate { … } 블록을 테스트에서 수동 실행
- 테스트 대상 Intent만 검증하려면 호출하지 않아도 됩니다.
- 주의: 테스트 내에서 한 번만 호출 가능
@Test
fun exampleTest() = runTest {
ExampleViewModel().test(this) {
runOnCreate() // onCreate 블록 실행
containerHost.countToFour()
expectState { copy(count = 1) }
// …
}
}
3.4 상태·사이드 이펙트 검증
- expectState { … } 혹은 assertEquals(awaitState(), expectedState)
- expectSideEffect(...)로 사이드 이펙트 순서 검증
- Sealed 상태 타입은 expectStateOn<T> { … } 사용 시 타입 캐스팅 불필요
// 일반 상태 검증
expectState { copy(count = 1) }
// Sealed 상태 검증 예시
expectStateOn<State.Ready> { copy(count = 2) }
// 사이드 이펙트 검증
expectSideEffect(Toast(1))
3.5 미소비(Unconsumed) 아이템 처리
- 마지막에 남은 상태나 사이드 이펙트가 있으면 테스트 실패
- 필요 시 skip(n) 또는 cancelAndIgnoreRemainingItems()로 명시적 무시 가능
@Test
fun exampleTest() = runTest {
ExampleViewModel().test(this) {
runOnCreate()
containerHost.countToFour()
expectState { copy(count = 1) }
expectSideEffect(Toast(1))
// 남은 4개 아이템 스킵
skip(4)
// 또는 모두 무시
cancelAndIgnoreRemainingItems()
}
}
3.6 Intent가 반환하는 Job
- Intent 내부에서 외부 의존성을 호출하거나, 별도 작업이 있을 때
- 반환된 Job에 join()을 호출해 완료를 보장
@Test
fun exampleTest() = runTest {
val dependency = SomeDependency()
ExampleViewModel(dependency).test(this) {
val job = containerHost.doSomeWorkOnDependency()
job.join() // Intent 완료 대기
assertEquals(dependency.counter, 42)
}
}
3.7 무한(Hot) Flow 구독 테스트
- 가능하면 테스트용 유한(Cold) Flow로 대체
- 불가피할 때는 job.join() 또는 cancelAndIgnoreRemainingItems()로 처리
@Test
fun exampleTest() = runTest {
val fakeLocationService = FakeLocationService()
ExampleViewModel(fakeLocationService).test(this) {
val job = runOnCreate()
expectState { copy(lng = 1, lat = 1) }
expectState { copy(lng = 2, lat = 2) }
expectState { copy(lng = 3, lat = 3) }
job.join() // 또는 cancelAndIgnoreRemainingItems()
}
}
3.8 가상 시간 제어
- 기본: runTest 내에서는 모든 delay()가 자동으로 건너뛰어짐
- 지연 제어가 필요할 때는 별도 TestScope() 생성 후 advanceTimeBy(ms) 사용
@Test
fun noDelaySkipping() = runTest {
val scope = TestScope()
InfiniteFlowMiddleware().test(scope) {
val job = containerHost.incrementForever()
scope.advanceTimeBy(30_001)
expectState(listOf(42, 43))
scope.advanceTimeBy(30_001)
expectState(listOf(42, 43, 44))
scope.advanceTimeBy(30_001)
expectState(listOf(42, 43, 44, 45))
job.join()
}
}
'Android > Study' 카테고리의 다른 글
| [Android] Compose Navigation은 어떻게 하면 좋을까? (0) | 2025.08.19 |
|---|---|
| [Android] Orbit으로 MVI 구현기 (0) | 2025.08.19 |
| [Android] Orbit Multiplatform 개요와 Core 탐구하기 (0) | 2025.07.03 |
| [Android] Android 앱 아키텍처 가이드 정리 (2) | 2025.07.03 |
| [Jetpack Compose로 개발하는 안드로이드 UI] 4장. UI 요소 배치 (0) | 2025.05.13 |