시배's Android

Design Patterns | Top Android MVI libraries in 2021 번역 본문

Android/Design Patterns

Design Patterns | Top Android MVI libraries in 2021 번역

si8ae 2023. 7. 27. 00:20

MVI에 대해 생각할 때, 프로세스는 세 가지 핵심 개념으로 요약됩니다:

  • 단방향 데이터 흐름 - 즉, 데이터가 한 방향으로 흐릅니다.
  • Intent(의도) 처리는 논블로킹(non-blocking)입니다.
  • 상태는 불변(immutable)합니다 - Model 외부에서는 변경할 수 없습니다(일반적으로 Android에서는 ViewModel 내부에서 처리됩니다).

개념적으로 MVI 복잡하지 않으므로, 직접 구현하는 대신 프레임워크를 사용해야 할까요?

 

MVI 스타일의 프레임워크를 작성하는 것은 보다 복잡한 일입니다:

- 기기 회전 및 프로세스 종료와 같은 라이프사이클 문제를 처리해야 합니다.
- 다양한 쓰레딩 문제가 발생할 수 있습니다.
- 멀티쓰레딩 코드와 함께 프레임워크에 대한 테스트를 작성해야 합니다.
- Espresso 테스트를 위한 아이들링 리소스 지원, 로깅, 타임 트래블 디버깅, 스크린샷 및 상호작용 테스트 지원과 같은 고급 기능을 지원해야 합니다.
- 멀티 플랫폼 지원이 필요할 수 있습니다.

저는 2018년부터 상업용 프로젝트에서 MVI를 사용하고 있으며, 그동안 Kotlin Multiplatform Mobile과 코루틴 기반의 Orbit Multiplatform의 버전을 Mikołaj Leszczyński와 함께 공동으로 개발하였습니다.

하지만 최근까지 저는 Android용 MVI 라이브러리의 시장을 정확히 파악하고 Orbit이 여전히 의미 있는지, 그리고 어떻게 개선할 수 있는지를 완전히 고려한 적이 없었습니다.

이 글은 가장 인기 있는 MVI 라이브러리들에 점수를 부여하여 이러한 문제를 다루고자 합니다.

 

Evaluating MVI Labraries

MVI 라이브러리를 평가할 때, 몇 가지 중요한 요소를 고려합니다. 저는 이 목록을 사용하여 Android 플랫폼에 대한 새로운 팀원이나 Android 경험이 없는 사람들의 쉬운 참여와 적은 보일러플레이트 코드를 요구하는 것을 평가하는 데 사용했습니다.

다음은 주요 MVI 라이브러리들을 평가하기 위해 고려하는 몇 가지 사항입니다:

1. 사용 편의성 - 필요한 최소한의 보일러플레이트 코드; 명확한 문서화; 새로운 팀원들과 Android 플랫폼 초심자들의 쉬운 참여를 위한 쉬운 온보딩
2. DI(의존성 주입) 지원 - 의존성 주입 프레임워크와 쉽게 통합할 수 있는 능력; 원하는 어떤 DI 프레임워크든 사용할 수 있는 유연성
3. Side effect(부작용) 처리 - 네비게이션 또는 토스트 표시와 같은 일회성 이벤트 처리에 대한 지침
4. Coroutine 지원 - 코루틴 기반 리포지토리 호출이 쉬운 것
5. Kotlin 멀티플랫폼 지원 - Android와 iOS 모두에서 라이브러리를 사용할 수 있는 능력
6. 테스팅 지원 - 코드를 쉽게 테스트할 수 있는 능력
7. 추가적인 기능 - 아이들 리소스 지원, 저장된 상태 지원, 타임 트래벌 디버깅, 스크린샷/상호작용 테스트 지원 등

이러한 기준을 사용하여 MVI 라이브러리를 평가하면, 선택한 라이브러리가 프로젝트의 요구사항을 충족시키는지 평가할 수 있습니다. 따라서 개발자들은 적합한 MVI 라이브러리를 선택하여 생산성을 향상시키고 팀원들과의 협업을 용이하게 할 수 있습니다.

Comparing MVI libraries

안드로이드의 주요 MVI 라이브러리를 조사하기 위해, 저는 Orbit에서 제공하는 포스트 샘플을 가져와서 이를 이식하였습니다. 이 앱은 간단한 목록과 상세 화면으로 구성되어 있으며, 두 화면 모두 코루틴 기반의 네트워크 호출을 사용하여 데이터를 가져옵니다.

앱에서는 목록 화면에서 항목을 클릭하면 이벤트가 뷰 모델로 전달되고 처리됩니다. 이 이벤트는 UI에서 네비게이션을 트리거하기 위한 사이드 이펙트로 변환됩니다. 이벤트를 뷰 모델을 통해 전달하는 것은 일반적인 패턴입니다. 이는 뷰 모델에 다른 비즈니스 로직(예: 분석)을 쉽게 삽입할 수 있도록 하며, 이를 UI에 직접 구현하는 것보다 더 편리하게 만들어줍니다.

이렇게 포팅된 앱은 MVI 패턴을 적용하여 구현된 것으로, UI와 비즈니스 로직을 더욱 체계적으로 분리하고 관리할 수 있게 해주며, 코드의 가독성과 유지 보수성을 높여줍니다. 이제 이 앱을 사용하여 여러 MVI 라이브러리를 조사하여 어떤 라이브러리가 프로젝트에 가장 적합한지 평가할 수 있을 것입니다.

https://github.com/orbit-mvi/orbit-mvi/tree/main/samples/orbit-posts

A note on styles of MVI

MVI는 처음으로 2016년 Hannes Dorfmann의 "Model-View-Intent"라는 기사와 그 다음 해에 Jake Wharton의 "Managing State with RxJava"라는 발표를 통해 Android에서 인기를 얻었습니다.

 

이후 RxJava와 Reaktive와 같은 반응형 스트림 프레임워크를 사용하여 이들을 기반으로 많은 MVI 라이브러리가 개발되었습니다. 그러나 그 후 많은 변화가 있었으며, 특히 코루틴의 도입이 가장 큰 변화 중 하나입니다.

 

코루틴은 반응형 스트림을 사용할 때 복잡한 부분을 단순하게 만들어줄 수 있습니다. 이와 관련하여 2020년에 Garima Jain의 "Flowing Things, Not So Strange In The MVI World"라는 발표는 RxJava를 사용한 MVI에서 코루틴으로 전환하는 방법을 훌륭하게 설명합니다.

 

MVIKotlin, Redux-Kotlin, Roxie 같은 라이브러리들은 redux 스타일을 사용하며, 이는 반응형 스트림을 기반으로 합니다. 여기서 Intent(의도) 객체로 변환되어 스트림을 통해 전달되고, 변환기와 리듀서를 거쳐 객체가 다시 처리되는 방식으로 동작합니다.

 

http://hannesdorfmann.com/android/model-view-intent/

https://www.youtube.com/watch?v=0IKHxjkgop4 

https://www.youtube.com/watch?v=e_r4d-33GDY 

최근에는 Mavericks, Orbit, 그리고 Uniflow 라이브러리와 같이 새로운 스타일이 등장하여, Intent(의도)들이 사실상 자체적인 변환기와 리듀서를 가지고 있고, 후에 하나의 상태 출력으로 결합되는 방식으로 동작합니다. 이러한 스타일은 전통적인 MVI 구현보다 MVVM 가까운 코드 구조를 갖고 있으며, MVVM+라고도 부릅니다. IDE에서 함수 이름을 ⌘-클릭하여 즉시 관련 코드로 이동할 있어서 상당한 이점이 있습니다.

Mavericks (previously known as MvRx) — MVVM+ style

 

GitHub - airbnb/mavericks: Mavericks: Android on Autopilot

Mavericks: Android on Autopilot. Contribute to airbnb/mavericks development by creating an account on GitHub.

github.com

Mavericks는 MVVM+ 스타일의 MVI를 사용하므로, 대부분의 코드가 MavericksViewModel에서 관리되어 비교적 이해하기 쉽습니다. 그러나 이 MavericksViewModel은 문제가 발생할 수 있습니다.

 

Jetpack ViewModel에서 강제적인 상속 때문에 이를 확장할 수 없기 때문에 의존성 주입이 약간 더 어려워집니다. 라이브러리 작성자들은 Jetpack의 `viewModels()`와 유사하게 동작하도록 `by fragmentViewModel()`을 제공하여 작업을 쉽게 만들려고 노력했습니다. 그러나 나는 Koin과 샘플 프로젝트를 작업하는 데 어려움을 겪고, Dagger Hilt로 프로젝트를 이식하는 것이 더 쉬웠습니다.

 

프로젝트를 변환하는 데 시간이 걸리기 때문에, MavericksViewModel마다 필요한 팩토리를 생성하는 보일러플레이트 코드가 여전히 남아 있어서 조금 화가 났습니다. 또한, 'hellohilt' 샘플 코드로부터 다섯 개의 클래스를 복사하여 붙여넣기 해야 했습니다. 문서화가 없어서 도움을 받을 수 없었습니다. 또한, Jetpack을 사용하지 않기 때문에 MavericksViewModels는 @Assisted 주석이 필요합니다.

 

의존성 주입과 관련하여, DI 프레임워크가 제공하는 인자로 MavericksViewModel을 생성하고 싶었습니다. 그러나 이를 올바르게 처리하는 것이 까다로웠고, 결국 포기하기로 결정했습니다.

 

다음으로 마주한 문제는 Mavericks의 사이드 이펙트 지원이나 사용법에 대한 문서화가 부족했습니다. `uniqueOnly()`를 사용한다는 애매한 언급은 있지만 그 사용법에 대한 설명이 없었습니다. 결국, UI로 네비게이션 이벤트를 전달하기 위해 자체적으로 코루틴 Channel을 만드는 것이 더 쉬웠습니다. 채널을 사용하는 이유에 대해 더 알고 싶다면 "Android SingleLiveEvent Redux with Kotlin Flow"를 읽어보세요.

 

Mavericks 내에서 suspend 함수를 호출하는 것도 조금 어색하게 느껴졌습니다. 권장 방법은 suspend 블록을 생성하고 안에서 `execute` 함수를 호출하여 Loading, Success, Fail 상태를 갖는 Async 객체로 변환하는 것입니다. 올바르게 하든, 그렇지 않든, 나는 Async 객체에 대해 바로 `invoke` 호출하여 해당 값을 얻었습니다.

class PostListViewModel @AssistedInject constructor(
    @Assisted initialState: PostListState,
    private val postRepository: PostRepository
) : MavericksViewModel<PostListState>(initialState) {

    private val _sideEffect =
        Channel<NavigationEvent>(Channel.BUFFERED)
    val sideEffect: Flow<NavigationEvent> =
        _sideEffect.receiveAsFlow()

    init {
        loadOverviews()
    }

    private fun loadOverviews() = withState { state ->
        suspend {
            postRepository.getOverviews()
        }.execute { async ->
            async()?.let { copy(overviews = it) } ?: state
        }
    }

    @Suppress("UNUSED_PARAMETER")
    fun onPostClicked(post: PostOverview) {
        _sideEffect.sendBlocking(OpenPostNavigationEvent(post))
    }

    @AssistedFactory
    interface Factory : AssistedViewModelFactory<PostListViewModel, PostListState> {
        override fun create(state: PostListState): PostListViewModel
    }

    companion object : MavericksViewModelFactory<PostListViewModel, PostListState> by hiltMavericksViewModelFactory()
}

Mavericks는 강력한 도구이지만, 그 가장 큰 장점을 활용하기 위해서는 새로운 방식을 배워야 합니다. 이는 새로운 개발자들이 Mavericks를 사용하여 프로젝트에 참여하는 데 더 높은 비용이 발생한다는 것을 의미합니다. 비슷한 스타일의 Orbit와 Uniflow보다 Mavericks로 새로운 개발자를 온보딩하는 데 더 많은 노력이 필요합니다.

 

Mavericks에서 가장 놀라운 기능 중 하나는 모킹 모듈입니다. 이 모듈을 사용하면 화면에 대한 미리 정의된 상태를 정의하고, 해당 상태로 앱을 실행하여 AirBnb가 스크린샷 테스팅을 구현하는 데 도움이 됩니다.

 

요약하면, 주로 깔끔한 API 가지고 있지만, 의존성 주입 측면에서 미흡합니다. Mavericks 제대로 활용하려면 새로운 개발 방식을 배워야 하며, 이로 인해 새로운 개발자들과의 온보딩에 비용이 발생할 있습니다. 또한, Mavericks 가장 놀라운 기능 하나는 모킹 모듈로, 스크린샷 테스팅을 지원하여 AirBnb 구현하는 도움이 되었습니다. 그러나 의존성 주입 측면에서는 약간의 한계가 있습니다.

Mosby MVI — Redux style

 

GitHub - sockeqwe/mosby: A Model-View-Presenter / Model-View-Intent library for modern Android apps

A Model-View-Presenter / Model-View-Intent library for modern Android apps - GitHub - sockeqwe/mosby: A Model-View-Presenter / Model-View-Intent library for modern Android apps

github.com

Mosby는 RxJava 기반으로 구현되었으며, 라이브러리의 최종 업데이트는 2018년 12월에 이루어졌습니다.

Hannes Dorfmann에 따르면, 향후 업데이트가 있더라도 해당 라이브러리는 폐기 표시(deprecated)될 가능성이 높습니다.

정보를 고려하면, Mosby 검토할 가치가 없어보입니다. 하지만 Mosby 작동 방식을 알아보고 싶다면 "How to Implement MVI Architecture with Mosby in Android App"이라는 기사를 확인해보세요.

https://expertise.jetruby.com/how-to-implement-mvi-architecture-with-mosby-in-android-app-bc3d64e04739?gi=3b32fae7c2fe

MVIKotlin (previously known as MVIDroid) — Redux style

 

GitHub - arkivanov/MVIKotlin: Extendable MVI framework for Kotlin Multiplatform with powerful debugging tools (logging and time

Extendable MVI framework for Kotlin Multiplatform with powerful debugging tools (logging and time travel) - GitHub - arkivanov/MVIKotlin: Extendable MVI framework for Kotlin Multiplatform with powe...

github.com

MVIKotlin은 MVICore 라이브러리에서 영감을 받은 것으로, 시간 여행 디버깅(time travel debugging)과 같은 다양한 기능을 갖춘 풍부한 기능 세트를 가지고 있습니다. 또한 Kotlin Multiplatform과 호환되어 Android와 iOS 간에 코드를 공유하는 데 도움이 됩니다.

 

MVIKotlin은 redux 스타일의 MVI를 사용하는데, 이로 인해 때로는 더 복잡한 코드가 발생할 수 있습니다. 그러나 MVIKotlin은 관심사의 분리를 극단적으로 추구합니다.

 

뷰 이벤트는 인텐트로 매핑되고, 컨트롤러에서 정의된 바인딩을 통해 스토어에 연결됩니다. 스토어는 스토어 팩토리에 의해 생성되며, 인텐트를 입력으로 받아 실행자를 통해 결과를 생성하거나 사이드 이펙트(라벨)를 생성합니다. 리듀서는 결과를 입력으로 받아 새로운 상태를 생성합니다. 부트스트래퍼는 종종 생성 시에 트리거되는 액션을 생성합니다. 실행자는 인텐트와 비슷한 방식으로 액션을 처리합니다. 헷갈리시나요?

결론적으로, 하나의 인텐트와 하나의 액션을 갖는 목록 화면에 대해 다음과 같은 클래스들이 생성되었습니다:

  • PostListLabel: 상세 화면을 표시하기 위한 네비게이션 이벤트를 나타내는 클래스
  • PostListController: PostListStoreFactory를 사용하며 바인딩을 제공하는 클래스
  • PostListIntent: 사용자가 포스트를 클릭하는 동작을 나타내는 클래스
  • PostListState: 화면에 렌더링되는 데이터를 보유하는 클래스
  • PostListStoreFactory: Store를 생성하고 리듀서와 실행자를 제공하는 클래스
  • PostListView: Model State 매핑하여 렌더링하는 ViewRenderer<PostListState> 클래스
internal class PostListStoreFactory(
    private val storeFactory: StoreFactory,
    private val postRepository: PostRepository,
    val stateKeeper: StateKeeper<PostListState>
) {

    fun create() = object : Store<PostListIntent, PostListState, NavigationEvent> by storeFactory.create(
        name = "PostListStore",
        initialState = stateKeeper.consume() ?: PostListState(),
        bootstrapper = SimpleBootstrapper(Unit),
        executorFactory = ::createExecutorFactory,
        reducer = ReducerImpl
    ) {}

    private fun createExecutorFactory() = ExecutorImpl(postRepository)

    private class ExecutorImpl(
        private val postRepository: PostRepository
    ) : SuspendExecutor<PostListIntent, Unit, PostListState, List<PostOverview>, NavigationEvent>() {

        // Only one Action triggered on create so using Unit
        override suspend fun executeAction(action: Unit, getState: () -> PostListState) = loadOverviews()

        // Split the intent object back out again
        override suspend fun executeIntent(intent: PostListIntent, getState: () -> PostListState) = when (intent) {
            is PostListIntent.PostClicked -> postClicked(intent.post)
        }

        private fun postClicked(post: PostOverview) {
            // Triggering a side effect
            publish(OpenPostNavigationEvent(post))
        }

        // Load the overviews
        private suspend fun loadOverviews() {
            val overviews = withContext(Dispatchers.Default) {
                postRepository.getOverviews()
            }
            // Dispatch the Result
            dispatch(overviews)
        }
    }

    private object ReducerImpl : Reducer<PostListState, List<PostOverview>> {
        // Combine State and Result
        override fun PostListState.reduce(result: List<PostOverview>): PostListState = copy(overviews = result)
    }
}

복잡성을 감안할 , 의존성 주입(Dependency Injection) 동작하는 것으로 보입니다. 당신은 Koin 사용하며 가지 문제가 발생하지 않았던 것으로 알고 있습니다. 그러나 컨트롤러의 라이프사이클 때문인지, 리스트 화면이 다시 시작될 바인딩에 문제가 발생하여 해당 바인딩이 중단되고 재시작되어야 하는 상황이 있었습니다.

 

fun onViewCreated(view: PostListView) {
    binder?.stop()
    binder = bind(lifecycle, BinderLifecycleMode.CREATE_DESTROY) {
        store.states bindTo view
        store.labels bindTo view::sideEffect
    }
}

Controller가 Jetpack ViewModel을 확장하는 방법을 사용하면 라이프사이클 문제를 해결하고 UI에서 상태와 라벨 스트림에 직접 접근할 수 있을 수도 있습니다. 그러나 이와 관련하여 문서화된 내용을 본 적이 없습니다.

 

MVIKotlin이 제공하는 유연성 덕분에 많은 것을 직접 처리해야 합니다. 큰 규모의 코드베이스에서는 규칙을 엄격하게 지정하지 않으면 모든 화면이 완전히 다른 방식으로 구현될 수 있다고 상상할 수 있습니다.

 

요약하면, MVIKotlin 최고 수준의 기능을 제공하지만, 복잡성과 보일러플레이트 코드로 인해 제대로 활용하기가 어려울 있습니다. 화면마다 구현이 완전히 다르게 이루어지는 상황이 발생할 있으며, 이는 규모의 코드베이스에서 통일성을 유지하기 어렵게 만들 있습니다.

Orbit Multiplatform — MVVM+ style

 

GitHub - orbit-mvi/orbit-mvi: A simple MVI framework for Kotlin Multiplatform and Android

A simple MVI framework for Kotlin Multiplatform and Android - GitHub - orbit-mvi/orbit-mvi: A simple MVI framework for Kotlin Multiplatform and Android

github.com

Orbit는 처음에는 RxJava를 기반으로 한 Babylon Health의 내부 프레임워크로 시작되었습니다. Kotlin과 코루틴이 인기를 얻으면서, 우리는 이를 기회로 삼아 2020년 초에 v2 작업을 시작했습니다. 그러나 처음의 재작성은 여전히 복잡했으며, 대부분 원본 코드를 직접 변환한 것이었습니다. 그리고 우리는 2020년 중반에야 스트림에 대한 중대한 의존성을 갖는 redux 스타일에서 현재의 MVVM+ 스타일로 전환했습니다.

 

우리는 Orbit의 초기 버전을 개발하고 지원하면서 얻은 경험을 토대로 라이브러리의 내부 구조를 형성하고, 일반적인 함정과 우리에게 중요한 요소들을 인식하게 되었습니다. 그 결과로 우리는 다음과 같은 목표를 달성했습니다:

 

- 코루틴에 대한 일급 지원

- 다른 코드의 실행을 차단하지 않고 상태를 순차적으로 수정하는 기능

- 뷰가 연결되지 않은 경우에도 사이드 이펙트를 지원하고 캐싱하는 기능

- 인텐트가 트리거된 순서대로 실행되는 기능; 드래그 이벤트 처리에 관련한 이슈를 이전에 겪었습니다.

- 상속 대신 조합(composition)을 사용하여 어떤 클래스에서든 시스템을 생성할 수 있는 기능

- Jetpack ViewModels와 SavedStateHandle을 내장 지원하여 프로세스 종료를 처리하는 기능

- 단위 테스트를 쉽게 할 수 있는 기능

- Kotlin Multiplatform 지원

 

하지만 Orbit는 아직 우리가 원하는 모든 기능을 지원하지는 않습니다. 미래의 로드맵에는 문서 개선, 스크린샷 및 상호작용 테스트, 그리고 시간 여행 디버깅을 포함하고 있습니다.

 

Orbit는 ViewModel을 사용할 수 있으므로 의존성 주입은 문제가 되지 않습니다. 그러나 라이브러리가 상속을 사용하지 않기 때문에, 약간의 보일러플레이트 코드로 Container를 정의해야 합니다. Container는 상태와 사이드 이펙트 스트림을 저장하고 관리하는 역할을 합니다.

 

Orbit stateFlow sideEffectFlow 직접 노출시키기 때문에, Activity Fragment마다 이를 관찰하는 보일러플레이트 코드가 있습니다. LiveData 사용하는 일반적인 MVVM 크게 다르지 않지만, 다른 라이브러리들과 같이 줄로 정리되게끔 수정될 있을 것입니다.

lifecycleScope.launch {
    // See https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda    
    lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
        launch { viewModel.container.stateFlow.collect(::reduce)
        launch { viewModel.container.sideEffectFlow.collect(::sideEffect)
    }
}

표면적으로 , Orbit 사용하는 블록의 이름 이외에도 Uniflow 차이점은 보이지 않습니다. 그러나 상태를 수정하는 접근 방식은 우리가 사용하는 reduce 블록들이 다른 기능을 차단하지 않으면서 순차적으로 실행되도록 합니다. 마치 Mavericks 같이 해당 블록은 수정을 위해 현재의 변동 가능한 상태를 제공합니다. 또한 우리는 상태와 사이드 이펙트를 지정하는 제네릭 타입을 사용하여 코드에 형변환이 없도록 합니다.

class PostListViewModel(
    savedStateHandle: SavedStateHandle,
    private val postRepository: PostRepository
) : ViewModel(), ContainerHost<PostListState, NavigationEvent> {

    override val container = container<PostListState, NavigationEvent>(
        initialState = PostListState(),
        savedStateHandle = savedStateHandle
    ) { state ->
        // Executed on creation
        // state is either initialState or provided by savedStateHandle
        if (state.overviews.isEmpty()) {
            loadOverviews()
        }
    }

    private fun loadOverviews() = intent {
        val overviews = postRepository.getOverviews()

        reduce {
            state.copy(overviews = overviews)
        }
    }

    fun onPostClicked(post: PostOverview) = intent {
        postSideEffect(OpenPostNavigationEvent(post))
    }
}

Orbit의 API는 미묘한 개선으로 인해 Uniflow나 Mavericks보다 코드 작성이 더 깔끔하고 쉬워집니다.

 

기능 측면에서, 앞서 언급한 대로 SavedStateHandle을 지원하므로 Orbit는 프로세스 종료를 처리하지만, MVIKotlin이 가진 로깅(logging) 및 시간 여행 디버깅과 같은 고급 기능은 지원하지 않습니다. 또한 Orbit는 상태의 변경 (전체 객체가 아닌)과 사이드 이펙트에 대한 단언을 간소화하는 테스트 프레임워크를 제공합니다.

 

Kotlin Multiplatform도 지원하지만, 현재 라이브러리에는 좋은 문서나 공유 코드베이스에서 어떻게 사용하는지에 대한 샘플 앱이 부족합니다.

 

요약하면, 가장 깔끔한 API 가지고 있지만 MVIKotlin 강력한 기능들을 놓치며, Kotlin Multiplatform 사용에 대한 나은 문서가 필요합니다.

Redux-Kotlin — Redux style

 

GitHub - reduxkotlin/redux-kotlin: Redux implementation for Kotlin (supports multiplatform JVM, native, JS, WASM)

Redux implementation for Kotlin (supports multiplatform JVM, native, JS, WASM) - GitHub - reduxkotlin/redux-kotlin: Redux implementation for Kotlin (supports multiplatform JVM, native, JS, WASM)

github.com

Redux-Kotlin은 Redux 패턴에 익숙하고 이 패턴을 좋아하는 경우라면 여러분을 위한 라이브러리일 수 있습니다. 그러나 JavaScript 세계에서 오지 않았다면, Android에서는 어색하게 느껴질 수 있습니다.

 

Redux-Kotlin을 시작할 때 가장 실망스러운 점은 문서의 부재입니다. React Native를 조사할 때도 Redux에서 동일한 생각을 했던 기억이 납니다. 보통 네트워크 호출과 같은 일반적인 경우조차 고급 주제로 간주되고 간략하게 설명되는 경우가 많습니다.

 

비동기 코드를 작성하기 위해 thunk 종속성을 포함해야하지만, 문서에서 이에 대해 언급하지 않습니다. 대신, 2019년에 업데이트된 샘플 앱을 참조하라고 설명하고 있습니다. 그 결과, 복사하여 붙여넣은 코드가 컴파일되지 않는 이유에 대해 혼란스러워질 수 있습니다. 모바일 앱에서 네트워크 호출이 많이 발생하는데도 불구하고, 비동기 코드 실행이 핵심 기능으로 더 쉽게 구현되지 않는 것은 아쉽게 생각됩니다.

 

라이브러리의 좋은 하나는 원하는 어떤 클래스에서든 사용할 있다는 점입니다. 따라서 Jetpack ViewModel 내에 구현하고 Redux-Kotlin 스토어 객체를 UI 레이어에 노출하는 방식으로 사용했습니다. ViewModel 사용하는 것은 네트워크 thunk viewModelScope 사용하여 네트워크 요청을 관리하는 도움이 되었으며, 물론 의존성 주입도 원활하게 이루어집니다.

class PostDetailsViewModel(
    private val postRepository: PostRepository,
    private val postOverview: PostOverview
) : ViewModel() {

    private val detailsMiddleware = middleware<PostDetailState> { store, next, action ->
        when (action) {
            is ActionTypes.INIT -> store.dispatch(loadDetails(postOverview.id))
            else -> next(action)
        }
    }

    private val detailsReducer: Reducer<PostDetailState> = { state, action ->
        when (action) {
            is PostDetailAction.DataSuccess -> PostDetailState.Details(state.postOverview, action.post)
            PostDetailAction.DataFailure -> PostDetailState.NoDetailsAvailable(state.postOverview)
            else -> state
        }
    }

    fun loadDetails(id: Int): Thunk<PostDetailState> = { dispatch, _, _ ->
        viewModelScope.launch {
            when (val status = postRepository.getDetail(id)) {
                is Status.Success -> dispatch(PostDetailAction.DataSuccess(status.data))
                is Status.Failure -> dispatch(PostDetailAction.DataFailure)
            }
        }
    }

    val store = createThreadSafeStore(
        reducer = detailsReducer,
        preloadedState = PostDetailState.NoDetailsAvailable(postOverview),
        enhancer = applyMiddleware(createThunkMiddleware(), detailsMiddleware)
    )

    init {
        // supposed to trigger automatically
        store.dispatch(ActionTypes.INIT)
    }
}

다음으로 마주한 문제는 상태 스트림을 관찰하는 방법이었습니다. 다시 말하지만, 문서에서는 이에 대해 애매모호하게 설명되었으며, 그러나 API 참조에서 발견할 수 있었습니다:

"Redux는 상태 관리에만 관심이 있습니다. 실제 앱에서는 상태를 UI에 바인딩하고자 할 것입니다. 상태를 UI에 바인딩하는 방법은 여러 가지가 있으며, 다중 플랫폼에 대한 패턴이 나타날 때 문서도 계속 발전할 것입니다. 하나의 접근 방법은 Presenter-middleware입니다."

나는 Presenter-middleware를 발견한 것은 너무 늦었습니다. 그때까지 이미 Fragment에서 store.subscribe(listener) 함수를 코루틴으로 감싸버린 상태였습니다.

lifecycleScope.launchWhenCreated {
    suspendCancellableCoroutine { continuation ->
        val unsubscribe = viewModel.store.subscribe {
            render(viewModel.store.state)
        }

        continuation.invokeOnCancellation {
            unsubscribe()
        }
    }
}

물론, 여기서 논의된 다른 라이브러리들과 마찬가지로, Redux-Kotlin도 상태 관리만 다루기 때문에 사이드 이펙트에 대해 언급되지 않습니다. 따라서 사이드 이펙트에 대해서는 실제로 혼자서 처리해야 하며, 유일한 조언은 사이드 이펙트는 thunks에서 발생해야 한다는 것입니다.

 

다른 프레임워크들에 대해 테스트에 대해 많이 쓴 것은 아니지만, 여기서도 문서 부재로 인해 최선의 접근 방법을 알기 어렵습니다. 그러나 일반적으로 모든 것이 분리되어 있기 때문에 독립적으로 쉽게 테스트할 수 있어야 합니다.

 

요약하면, 알려진 패턴이며, JavaScript에서 왔다면 훌륭한 라이브러리입니다. 그러나 혼란스러운 문서와 오래된 샘플 때문에 실망할 있습니다.

Roxie — Redux style

 

GitHub - ww-tech/roxie: Lightweight Android library for building reactive apps.

Lightweight Android library for building reactive apps. - GitHub - ww-tech/roxie: Lightweight Android library for building reactive apps.

github.com

Roxie는 가장 가볍고, 벨과 서경이 없는 프레임워크로 구성되어 있습니다. 단 5개의 파일로 이루어져 있습니다.

 

현재 버전은 RxJava를 기반으로 하고 있지만, PR에서 코루틴 버전도 제안되었습니다. 하지만 글을 작성하는 시점에서 2020년 8월 이후로 레포지토리에 커밋이 없어서 PR이 언제 합쳐질지 알 수 없습니다. 그래서 나는 현재 버전을 사용하고 필요한 경우 kotlinx-coroutines-rx2를 사용했습니다.

 

Roxie는 BaseViewModel 클래스를 상속하는 것을 기반으로 하고 있습니다. 다행히도 이는 Jetpack ViewModel이기 때문에 의존성 주입은 매우 간단하지만, 사용자의 고유한 베이스 클래스가 있는 경우 문제가 될 수 있습니다.

 

단일 액션 스트림을 기반으로 하기 때문에, 관심 있는 액션들을 분리하고 다시 합치는 디스패처를 생성해야 합니다. 이로 인해 RxJava 버전 또는 코루틴 버전을 사용하더라도 확장성이 좋지 않은 보일러플레이트 코드가 많이 생성됩니다. 아래는 코루틴 PR에서 가져온 코드입니다. 이것이 무엇을 의미하는지 보여주기 위해 포함했습니다.

class NoteListViewModel(
    initialState: State?,
    private val loadNoteListUseCase: GetNoteListUseCase
) : CoroutineViewModel<Action, State>() {
    override val initialState = initialState ?: State(isIdle = true)
    private val reducer: Reducer<State, Change> = { state, change ->
        when (change) {
            is Change.Loading -> state.copy(
                isIdle = false,
                isLoading = true,
                notes = emptyList(),
                isError = false
            )
            is Change.Notes -> state.copy(
                isLoading = false,
                notes = change.notes
            )
            is Change.Error -> state.copy(
                isLoading = false,
                isError = true
            )
        }
    }
    init {
        bindActions()
    }
    private fun bindActions() {
        val loadNotesChange: Flow<Change> = actions.asFlow()
            .ofType<Action.LoadNotes>()
            .flatMapLatest {
                loadNoteListUseCase.loadAll()
                    .flowOn(Dispatchers.IO)
                    .map { Change.Notes(it) }
                    .defaultOnEmpty(Change.Notes(emptyList()))
                    .catch<Change> {
                        emit(Change.Error(it))
                    }
                    .onStart { emit(Change.Loading) }
            }
        // to handle multiple Changes, use Observable.merge to merge them into a single stream:
        // val allChanges = Observable.merge(loadNotesChange, ...)
        loadNotesChange
            .scan(initialState) { state, change ->
                reducer.invoke(state, change)
            }
            .filter { !it.isIdle }
            .distinctUntilChanged()
            .flowOn(Dispatchers.Main)
            .observeState()
            .catch {
                Timber.e(it)
            }
            .launchHere()
    }
}

요약하면, RxJava 기반, 보일러플레이트 코드가 많으며, 지원되지 않고 고급 기능이 없습니다

Uniflow — MVVM+ style

 

GitHub - uniflow-kt/uniflow-kt: Uniflow 🦄 - Simple Unidirectional Data Flow for Android & Kotlin, using Kotlin coroutines and

Uniflow 🦄 - Simple Unidirectional Data Flow for Android & Kotlin, using Kotlin coroutines and open to functional programming - GitHub - uniflow-kt/uniflow-kt: Uniflow 🦄 - Simple Unidirectional ...

github.com

Uniflow에는 깔끔한 API와 잘 작성된 문서와 같은 많은 장점이 있습니다.

 

라이브러리는 상속을 전반적으로 사용합니다. ViewModels AndroidDataFlow 상속하며, 상태는 UIState 확장하고, 사이드 이펙트는 UIEvent 확장합니다. 대부분의 경우 이러한 접근 방식은 괜찮을 있지만, 고유한 베이스 클래스를 사용하기가 어려울 있습니다.

class PostListViewModel(
    savedStateHandle: SavedStateHandle,
    private val postRepository: PostRepository
) : AndroidDataFlow(defaultState = PostListState(), savedStateHandle) {

    init {
        actionOn<PostListState> {
            if (it.overviews.isEmpty()) {
                loadOverviews()
            }
        }
    }

    private fun loadOverviews() = actionOn<PostListState> { state ->
        val overviews = postRepository.getOverviews()

        setState {
            state.copy(overviews = overviews)
        }
    }

    fun onPostClicked(post: PostOverview) = action {
        sendEvent(OpenPostNavigationEvent(post))
    }
}

하나 이상한 점은 라이브러리가 제네릭을 사용하지 않는다는 것입니다. 따라서 UIState UIEvent 객체를 필요에 따라 타입 캐스트해야 합니다.

onStates(viewModel) {
    reduce(adapter, it as PostListState)
}
onEvents(viewModel) {
    sideEffect(it as NavigationEvent)
}

배경에서, 이 라이브러리는 UIState의 유형을 확인하고 무언가를 오도하는 것으로 판단되면 BadOrWrongStateException을 발생시킵니다. 불행하게도, 이 예외는 상태 객체가 sealed 클래스인 경우에도 발생할 수 있으며, 이로 인해 ViewModel에서 typed actionOn를 사용할 수 없게 되며 수동으로 타입 캐스트를 해야 합니다 (참조: https://github.com/uniflow-kt/uniflow-kt/issues/71).

 

API 살펴보면, 상태가 action 블록의 위에서 캡처되는 것을 있었습니다. 여기에 버그가 있을 가능성이 있습니다. 개의 액션이 동시에 실행되면 이전 상태를 수정할 있을까요? 그래서 시나리오를 테스트하고 초기화 개의 액션을 트리거하는 방식으로 ViewModel 다시 작성했습니다.

class PostListViewModel : AndroidDataFlow(defaultState = PostListState()) {

    init {
        action1()
        action2()
    }

    private fun action1() = actionOn<PostListState> { state ->
        delay(5000)

        setState {
            state.copy(action1 = true)
        }
    }

    private fun action2() = actionOn<PostListState> { state ->
        delay(2500)

        setState {
            state.copy(action2 = true)
        }
    }
}

상태에 문제가 있었다면, 우리는 하나의 action1 또는 action2만이 true이고 다른 값은 false일 것으로 예상했습니다.

 

제 놀라운 점은 상태가 원하는 대로 수정되었습니다. 먼저 PostListState(action1=true, action2=false)이고 그 다음에 PostListState(action1=true, action2=true)이 되었습니다.

 

이는 라이브러리가 상태를 보호하기 위해 액션을 순차적으로 실행하기 때문에 작동합니다. 따라서 우리는 action2가 먼저 완료될 것으로 기대하지만, 먼저 예약된 action1이 완료될 때까지 action2는 실행되지 않습니다. 최선으로 보이지만, 이로 인해 지속적으로 느린 네트워크 호출로 인해 다른 액션이 처리되지 않아 UI가 응답하지 않을 수 있습니다.

 

기능적인 측면에서 라이브러리는 로깅과 SavedStateHandle을 지원하며 Arrow와 같은 함수형 프로그래밍과의 통합도 가능합니다. 이는 다른 라이브러리에서 보지 못한 기능 중 하나입니다. 불행히도 가장 큰 빠진 기능은 다중 플랫폼 지원이며, 라이브러리가 순수한 Kotlin로 구성되어 있음에도 불구하고 아쉽습니다.

 

요약하면, 좋은 문서와 최소한의 보일러플레이트 코드가 있지만, 어색한 타입 시스템과 UI 응답하지 않을 있는 가능성이 있습니다.

Conclusion

Android에서 사용 가능한 다양한 MVI 프레임워크를 살펴보면 두 가지 주요 접근 방식으로 나눌 수 있습니다:

 

1. MVIKotlin, Redux-Kotlin 및 Roxie와 같은 전통적인 redux 스타일로, 인텐트를 객체로 변환하는 방식입니다.

2. Mavericks, Orbit 및 Uniflow와 같은 MVVM+ 스타일로, 인텐트가 함수 호출로 처리되는 방식입니다.

 

redux 스타일은 코드를 작은 단위로 분할하도록 강제하는 데 일부 장점이 있지만, 또한 몇 가지 문제점이 있습니다:

 

1. 이해하기 더 복잡한 코드

2. 인텐트가 트리거되면 정확히 무엇이 발생하는지 추적하기가 훨씬 어렵습니다. 코드베이스에서 객체의 사용을 찾아보아야 하므로 단순히 함수 이름을 클릭하여 따라가는 것만큼 쉽지 않습니다.

3. 분리된 구성 요소들 사이에 연결이 끊어질 수 있습니다.

4. 인텐트와 액션은 제네릭 유형을 사용할 수 없습니다. (타입 소거 때문)

 

이러한 이유로 나는 주로 MVVM+ 스타일을 선호합니다. 먼저, 프로젝트가 이미 MVP 또는 MVVM을 사용한다면 코드 스타일이 훨씬 익숙하므로 학습 곡선이 크지 않습니다.

 

MVVM+ 스타일 중에서도 Kotlin Multiplatform Mobile을 지원하는 것은 Orbit뿐입니다. 하지만 이전에 언급했듯이 Orbit는 여전히 해당 영역의 더 나은 문서와 샘플이 필요합니다.

 

그래서 MVVM+ 스타일의 경우, 나는 Orbit 선호합니다. 그러나 redux 스타일을 선호한다면, MVIKotlin Redux-Kotlin 달리 강력한 타입을 가지고 있으며 최고의 고급 기능을 제공합니다.

 

 

 

 

 

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