Kotlin Multi Platform Mobile에서 Decompose, MviKotlin 적용기 (3)

이태훈·2022년 2월 12일
0

Shared Business Logic

기본적으로 Root Componet로부터 파생되어 Nested Component 구조로 이루어집니다.

따라서, RootComponent를 만든 후 MainComponent를 만들어줬습니다.

interface TmdbRoot {

    val routerState: Value<RouterState<*, Child>>

    sealed class Child {
        data class Main(val component: TmdbMain) : Child()
    }
}
interface TmdbMain {

	val model: Value<TmdbStore.State>

    fun onGetMovies(page: Int)
}
interface TmdbStore : Store<Intent, State, Nothing> {

    sealed class Intent {
        class FetchMovies(val page: Int) : Intent()
    }

    data class State(
        val movies: Result<Movies>? = null
    )
}

TmdbMain에서 View에서 발생하는 Event를 처리해줄 Store를 만들어줍니다.

이 Store는 MviKotlin Framework에 있습니다. MviKotlin을 사용하지 않으실 분들은 구현하지 않으셔도 됩니다.

다음으로 구현체를 보겠습니다.

class TmdbRootComponent(
    componentContext: ComponentContext
) : TmdbRoot, ComponentContext by componentContext {

    private val router = router<Configuration, TmdbRoot.Child>(
        initialConfiguration = Configuration.Main,
        handleBackButton = true,
        childFactory = ::resolveChild
    )
    override val routerState: Value<RouterState<*, TmdbRoot.Child>> = router.state

    private fun resolveChild(
        configuration: Configuration,
        componentContext: ComponentContext
    ): TmdbRoot.Child = when (configuration) {
        is Configuration.Main -> TmdbRoot.Child.Main(TmdbMainComponent(componentContext))
    }

    private sealed class Configuration : Parcelable {
        @Parcelize
        object Main : Configuration()
    }
}

먼저, RootComponent의 구현체입니다.

Router를 정의하기 위해 Configuration Sealed Class를 만들어주고, 각 Router의 Configuration마다 만들어줄 Component를 지정해줍니다.

class TmdbMainComponent(
    componentContext: ComponentContext
) : TmdbMain, KoinComponent, ComponentContext by componentContext {

    private val store: TmdbStore = instanceKeeper.getStore(::get)

    override val model = store.asValue()

    override fun onGetMovies(page: Int) {
        store.accept(TmdbStore.Intent.FetchMovies(page))
    }
}

TmdbMain의 구현체입니다.

fun <T: Any> Store<*, T, *>.asValue() = object : Value<T>() {
    private var job: Job? = null
    override val value: T = state

    override fun subscribe(observer: ValueObserver<T>) {
        job?.cancel()
        job = CoroutineScope(Dispatchers.Main).launch {
            states.collect(observer)
        }
    }

    override fun unsubscribe(observer: ValueObserver<T>) {
        job?.cancel()
    }
}

Store의 states라는 Flow로 Value로 wrapping 해주는 함수입니다.

Official example에서는 rx로 구현을 해놓았으니 참고하시면 되겠습니다.

일반 버전

internal class TmdbStoreProvider(
    private val storeFactory: StoreFactory,
    private val getMovieList: GetMovieListUseCase
) {

    fun provide(): TmdbStore = object : TmdbStore, Store<Intent, State, Nothing> by storeFactory.create(
        name = this::class.simpleName,
        initialState = State(),
        bootstrapper = SimpleBootstrapper(Action.FetchMovies(1)),
        executorFactory = ::ExecutorImpl,
        reducer = ReducerImpl()
    ) { }

    private sealed class Action {
        class FetchMovies(val page: Int) : Action()
    }

    private sealed class Message {
        data class MoviesFetched(
            val movies: Result<Movies>
        ) : Message()
    }

    private inner class ExecutorImpl :
        CoroutineExecutor<Intent, Action, State, Message, Nothing>()
    {
        override fun executeAction(action: Action, getState: () -> State) {
            when(action) {
                is Action.FetchMovies -> scope.launch {
                    dispatch(Message.MoviesFetched(getMovieList(action.page)))
                }
            }
        }

        override fun executeIntent(intent: Intent, getState: () -> State) {
            when(intent) {
                is Intent.FetchMovies -> scope.launch {
                    dispatch(Message.MoviesFetched(getMovieList(intent.page)))
                }
            }
        }
    }

    private class ReducerImpl : Reducer<State, Message> {
        override fun State.reduce(msg: Message): State = when (msg) {
            is Message.MoviesFetched -> copy(movies = msg.movies)
        }
    }
}

DSL 버전

internal class TmdbStoreProvider(
    private val storeFactory: StoreFactory,
    private val getMovieList: GetMovieListUseCase
) {

    fun provide(): TmdbStore =
        object : TmdbStore, Store<Intent, State, Nothing> by storeFactory.create(
            name = this::class.simpleName,
            initialState = State(),
            bootstrapper = SimpleBootstrapper(Action.FetchMovies(1)),
            executorFactory = executor,
            reducer = reducer
        ) {}

    private sealed class Action {
        class FetchMovies(val page: Int) : Action()
    }

    private sealed class Message {
        data class MoviesFetched(
            val movies: Result<Movies>
        ) : Message()
    }

    private val executor = coroutineExecutorFactory<Intent, Action, Message, State, Nothing> {
        onAction<Action.FetchMovies> { action ->
            launch {
                dispatch(Message.MoviesFetched(getMovieList(action.page)))
            }
        }

        onIntent<Intent.FetchMovies> {
            launch {
                dispatch(Message.MoviesFetched(getMovieList(it.page)))
            }
        }
    }

    private val reducer = Reducer<State, Message> { msg: Message ->
        when(msg) {
            is Message.MoviesFetched -> copy(movies = msg.movies)
        }
    }
}

Provider Injection

koin v2
val storeModule = module {
	factory<StoreFactory> { DefaultStoreFactory() }
    factory { TmdStoreProvider(get(), get()).provide() }
}
koin v3
val storeModule = module {
	factoryOf(::DefaultStoreFactory) { bind<StoreFactory>() }
	factoryOf(::TmdbStoreProvider)
	factoryOf(TmdbStoreProvider::provide)
}

마지막으로 Store를 제공해주는 클래스입니다.

Store는 MviKotlin에서 제공해주며, Redux 형태로 구현됩니다.
View에서 Event를 발생시켜 Intent 형태로 Store에 넘겨주고 Store에서 Intent에 따른 결과로 State를 변경시켜줍니다.

이러한 방식으로 단방향 데이터 흐름을 구성할 수 있게 됩니다.

bootsstrapper는 Store가 처음 생성되었을 때 일련의 행동을 해야할 때 정의해줍니다.

저같은 경우는 데이터가 잘 들어오는 지 보기 위해 임의로 첫 번째 페이지 데이터를 받기 위해 Action.FetchMovies(1)을 넘겨주었습니다.

그리고, 다음 포스트에 작성할 예정인 GetMovieListUseCase를 사용하기 위해 KoinComponent를 상속하여 주입해줍니다.

ExecutorImpl를 통해 Intent를 받았을 때 할 행동을 정의해주고 결과를 dispatch 해주면 ReducerImpl에서 받아서 State를 변경해주는 형태입니다.

이런 식으로 MviKotlin Framework를 사용하면 손쉽게 MVI Pattern을 구현할 수 있습니다.

View

이제 비즈니스 로직은 다 짰으니 뷰를 그려보도록 하겠습니다.

Android

먼저 안드로이드입니다.

@Composable
fun TmdbRootContent(component: TmdbRoot) {
    Children(routerState = component.routerState) {
        when(val child = it.instance) {
            is TmdbRoot.Child.Main -> TmdbMainContent(child.component)
        }
    }
}

Root Component에서 라우터를 받아서 상태에 따른 뷰를 그려줄 수 있습니다.

@Composable
fun TmdbMainContent(component: TmdbMain) {
    val model by component.model.subscribeAsState()

    // model의 데이터를 이용해 뷰를 그려줌
}
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val tmdbRoot = TmdbRootComponent(defaultComponentContext())
        
        setContent { 
            TmdbRootContent(component = tmdbRoot)
        }
    }
}

iOS

iOS에서는 Decompose에서 사용한 Component와 Value Data Holder를 Wrapping 해줘야 합니다.

class ComponentHolder<T> {
    let lifecycle: LifecycleRegistry
    let component: T
    
    init(factory: (ComponentContext) -> T) {
        let lifecycle = LifecycleRegistryKt.LifecycleRegistry()
        let component = factory(DefaultComponentContext(lifecycle: lifecycle))
        self.lifecycle = lifecycle
        self.component = component
        
        lifecycle.onCreate()
    }
    
    deinit {
        lifecycle.onDestroy()
    }
}
public class ObservableValue<T: AnyObject> : ObservableObject {
    
    private let observableValue: Value<T>
    
    @Published
    var value: T
    
    private var observer : ((T) -> Void)?
    
    init(_ value: Value<T>) {
        self.observableValue = value
        self.value = observableValue.value
        self.observer = { self.value = $0 }
        
        observableValue.subscribe(observer: self.observer!)
    }
    
    deinit {
        observableValue.unsubscribe(observer: self.observer!)
    }
}

Decompose의 Component는 Lifecycle이 일치하도록 ComponentHolder 클래스에서 처리를 해주고 있습니다.
Value는 Combine의 ObservableObject를 상속하여 처리를 해줬습니다. 값이 변경될 때마다 value에 넣어주는 방식입니다.

위 코드는 Decompose Example과 일치합니다.

shared/iosApp/IosModule.kt

fun startKoin() = org.koin.core.context.startKoin {
    modules(repositoryModule, interactorModule, networkModule)
}
@main
struct iOSApp: App {
    
    init() {
        IosModuleKt.startKoin()
    }

	var body: some Scene {
		WindowGroup {
			ContentView()
		}
	}
}

iOS에서는 Koin을 적용하기 위해 따로 shared module에서 처리를 해주어야 합니다.

shared module에서 작성한 코드를 iOS의 APP에서 호출해주면 Koin을 적용할 수 있습니다.

struct ContentView: View {
	
    @State
    private var componentHolder = ComponentHolder {
        TmdbRootComponent(componentContext: $0)
    }

	var body: some View {
        RootView(componentHolder.component)
            .onAppear {
                LifecycleRegistryExtKt.resume(self.componentHolder.lifecycle)
            }.onDisappear {
                LifecycleRegistryExtKt.stop(self.componentHolder.lifecycle)
            }
	}
}

struct RootView : View {
    
    @ObservedObject
    private var routerState: ObservableValue<RouterState<AnyObject, TmdbRootChild>>
                                                
    init(_ component: TmdbRoot) {
        self.routerState = ObservableValue(component.routerState)
    }
    
    var body: some View {
        let child = routerState.value.activeChild.instance
        
        switch child {
        case let main as TmdbRootChild.Main:
            MainView(main.component)
        default:
            EmptyView()
        }
    }
}

struct MainView : View {
    
    private let component: TmdbMain
    
    @ObservedObject
    private var model: ObservableValue<TmdbStoreState>
    
    init(_ component: TmdbMain) {
        self.component = component
        self.model = ObservableValue(component.model)
    }
    
    var body: some View {
        ... 뷰 그리는 부분 ...
    }
}

본격적으로 뷰를 그려주는 코드입니다.

안드로이드와 유사하게 Root Component에서 라우터를 받아 어떤 컴포넌트가 들어오는 지 받고, 받은 컴포넌트에 따라 뷰를 그려주면 됩니다.

다만 차이점이 있는 것은 LifecycleRegistryExt를 통해 라이프사이클을 처리해줘야 하는 것입니다.

profile
https://www.linkedin.com/in/%ED%83%9C%ED%9B%88-%EC%9D%B4-7b9563237

1개의 댓글

comment-user-thumbnail
2024년 1월 8일

재밌게 잘봤습니다~

답글 달기