Beeeam

Orbit 본문

Kotlin

Orbit

Beamjun 2024. 1. 29. 19:11

Orbit 라이브러리를 사용하면 MVI 패턴을 더 쉽게 적용할 수 있다. 물론 다른 라이브러리들을 사용해도 MVI 패턴을 적용하는데 도움을 받을 수 있다.

위의 이미지는 다른 라이브러리들과 orbit을 비교한 표이다. 위의 표를 통해서 orbit 라이브러리가 다양한 면에서 장점을 가지고 있음을 알 수 있다.

밑에서 더 자세히 볼 수 있음

https://appmattus.medium.com/top-android-mvi-libraries-in-2021-de1afe890f27

 

Top Android MVI libraries in 2021

Comparing redux and MVVM+ style MVI libraries

appmattus.medium.com

 

Use Orbit

Orbit 라이브러리는 상태(state)와 부수효과(side effect)를 담는 Container 라는 개념을 사용한다. 그리고 ViewModel을 ContainerHost로 사용하여 ViewModel 에서 상태와 부수효과를 관리하는데 이 때문에 상태관리가 편해진다.

상태와 부수효과는 DSL을 사용하여 변경하는데 의미는 다음과 같다.

  • intent: Container 내의 상태 및 부수효과를 변경하기 위한 빌드 함수
  • reduce: 현재 상태와 들어온 이벤트를 기반으로 상태를 생성
  • postSideEffect: 상태와 관련 없는 부수효과를 발생

다음은 Orbit Github에 있는 예제 코드이다.

Define contract

data class CalculatorState(
    val total: Int = 0
)

sealed class CalculatorSideEffect {
    data class Toast(val text: String) : CalculatorSideEffect()
}

상태와 부수효과를 위와 같이 정의한다.

 

ViewModel

class CalculatorViewModel: ContainerHost<CalculatorState, CalculatorSideEffect>, ViewModel() {

    // Include `orbit-viewmodel` for the factory function
    override val container = container<CalculatorState, CalculatorSideEffect>(CalculatorState())

    fun add(number: Int) = intent {
        postSideEffect(CalculatorSideEffect.Toast("Adding $number to ${state.total}!"))

        reduce {
            state.copy(total = state.total + number)
        }
    }
}

ViewModel을 ContainerHost로 구현한다.

상태나 부수효과는 intent, reduce, postSideEffect를 사용하여 변경한다.

 

Connect ViewModel to Activity

@Composable
fun CalculatorScreen(viewModel: CalculatorViewModel) {

    val state = viewModel.collectAsState().value

    viewModel.collectSideEffect { handleSideEffect(it) }

    // render UI using data from 'state'
    ...
}

private fun handleSideEffect(sideEffect: CalculatorSideEffect) {
    when (sideEffect) {
        is CalculatorSideEffect.Toast -> toast(sideEffect.text)
    }
}

 

Orbit Example

다음 코드는 본인이 Orbit 라이브러리를 사용하여 구현한 간단한 카운트 앱이다.

Contract

data class MainState(
    val isLoading: Boolean = true,
    val count: Int = 0,
)

sealed interface MainSideEffect {
    data class ToastMsg(val msg: String) : MainSideEffect
}

위에서 살펴본 예제의 Contract와 큰 차이는 없다.

 

ViewModel

class MainViewModel @Inject constructor() : ContainerHost<MainState, MainSideEffect>, ViewModel() {
    override val container: Container<MainState, MainSideEffect> = container(MainState())

    fun loading() = intent {
        showLoadingScreen()
        delay(2000L)
        hideLoadingScreen()
    }

    fun updateCount(count: Int) = intent {
        if (state.count > count) {
            toastMsg("Count Minus, Count: $count")
        } else {
            toastMsg("Count Add, Count: $count")
        }
        reduce { state.copy(count = count) }
    }

    private fun toastMsg(msg: String) = intent {
        postSideEffect(MainSideEffect.ToastMsg(msg))
    }

    private fun showLoadingScreen() = intent { reduce { state.copy(isLoading = true) } }
    private fun hideLoadingScreen() = intent { reduce { state.copy(isLoading = false) } }
}

위에서 설명했던 것처럼 ViewModel에서 intent, reduce, postSideEffect를 사용하여 상태 및 부수효과를 변경하면 된다.

 

Activity

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MVIPracticeTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background,
                ) {
                    MainRoute()
                }
            }
        }
    }
}

@Composable
fun MainRoute(
    viewModel: MainViewModel = hiltViewModel(),
) {
    val context = LocalContext.current
    val uiState = viewModel.collectAsState().value
    viewModel.collectSideEffect { sideEffect ->
        when (sideEffect) {
            is MainSideEffect.ToastMsg -> Toast.makeText(context, sideEffect.msg, Toast.LENGTH_SHORT).show()
        }
    }

    LaunchedEffect(key1 = Unit) {
        viewModel.loading()
    }

    MainScreen(
        uiState = uiState,
        updateCount = viewModel::updateCount,
    )
}

@Composable
fun MainScreen(
    uiState: MainState = MainState(),
    updateCount: (Int) -> Unit = {},
) {
    Box(
        modifier = Modifier.fillMaxSize(),
    ) {
        Text(
            modifier = Modifier.align(Alignment.Center),
            text = uiState.count.toString(),
            fontSize = 30.sp,
        )
        Row(
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .padding(bottom = 40.dp),
            horizontalArrangement = Arrangement.spacedBy(40.dp),
        ) {
            UpdateButton(
                title = "Add",
                onClick = { updateCount(uiState.count + 1) },
            )

            UpdateButton(
                title = "Minus",
                onClick = { updateCount(uiState.count - 1) },
            )
        }

        if (uiState.isLoading) {
            LoadingScreen()
        }
    }
}

@Composable
fun LoadingScreen() {
    Box(
        modifier = Modifier
            .fillMaxSize(),
        contentAlignment = Alignment.Center,
    ) {
        CircularProgressIndicator(
            modifier = Modifier.size(48.dp),
            strokeWidth = 6.dp,
            color = Color.Black,
        )
    }
}

@Composable
fun UpdateButton(
    modifier: Modifier = Modifier,
    title: String,
    onClick: () -> Unit,
) {
    Box(
        modifier = modifier
            .clip(RoundedCornerShape(10.dp))
            .background(Color.Black)
            .clickable(onClick = onClick),
    ) {
        Text(
            modifier = Modifier
                .align(Alignment.Center)
                .padding(20.dp),
            text = title,
            fontSize = 20.sp,
            fontWeight = FontWeight.ExtraBold,
            color = Color.White,
        )
    }
}

Activity는 예제 코드와 차이가 있다.

제일 큰 차이점은 Route의 존재이다. Screen 컴포저블만 사용해도 충분히 구현 가능하지만 Screen 컴포저블은 이름처럼 화면 즉 UI의 역할만을 하도록 하기 위해서 분리하였다. 그래서 Route에서는 상태 및 부수효과에 대한 처리를 하고 Screen에서는 뷰를 정의하도록 했다.

 

viewModel.collectAsState() 함수를 사용하여 ViewModel로 부터 상태 값들을 수집하고

viewModel.collectSideEffect 함수를 사용하여 부수효과들을 수집하고 이에 대한 처리를 진행한다.

 

위의 프로젝트는 밑에서 확인할 수 있다.

https://github.com/BEEEAM-J/MVI_Practice

 

GitHub - BEEEAM-J/MVI_Practice

Contribute to BEEEAM-J/MVI_Practice development by creating an account on GitHub.

github.com