기본적으로 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)
}
}
}
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)
}
}
}
val storeModule = module {
factory<StoreFactory> { DefaultStoreFactory() }
factory { TmdStoreProvider(get(), get()).provide() }
}
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을 구현할 수 있습니다.
이제 비즈니스 로직은 다 짰으니 뷰를 그려보도록 하겠습니다.
먼저 안드로이드입니다.
@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에서는 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를 통해 라이프사이클을 처리해줘야 하는 것입니다.
재밌게 잘봤습니다~