MVI Pattern With Coroutines

이태훈·2022년 4월 29일
0

안녕하세요. 이번엔 주변 뜨문뜨문 들려오는 MVI Pattern을 Coroutines를 이용해 구현해보겠습니다.

MVI Pattern에 Redux의 개념을 살짝 섞어서 구현했습니다.

전체 코드를 보시려면 해당 링크에 들어가서 보시면 됩니다.

Core Function

먼저, 상태 관리에서 야무지게 사용된 함수부터 보겠습니다.

public fun <T, R> Flow<T>.runningFold(initial: R, @BuilderInference operation: suspend (accumulator: R, value: T) -> R): Flow<R> = flow {
    var accumulator: R = initial
    emit(accumulator)
    collect { value ->
        accumulator = operation(accumulator, value)
        emit(accumulator)
    }
}

runningFold라는 함수를 이용하여 우리는 간결하고 가독성 있께 상태관리하는 코드를 작성할 수 있습니다.

scan이란 함수와 이름만 같고 기능은 같은 함수입니다.

이 함수는 첫 번째 인자인 initial value와 두 번째 인자인 값을 계산해주는 고차함수를 가지고 있습니다.
initial value와 순차적으로 들어오는 값들과 직전 값들을 가지고 계속해서 값들을 뽑아내는 함수입니다.

보통 MVI Pattern을 구현하다 보면 어떤 이벤트를 가지고 뽑아낸 값을 이전 state와 비교하여 state를 저장하게 되는데 이러한 플로우와 잘 맞는 것을 볼 수 있습니다.

Component

Store

interface Store<INTENT, STATE, MESSAGE> {

    val state: StateFlow<STATE>

    suspend fun accept(intent: INTENT)
}

abstract class ViewModelStore<INTENT, STATE, MESSAGE> : Store<INTENT, STATE, MESSAGE>, ViewModel() {

    protected abstract val initialState: STATE

    private val executor = DefaultExecutor<INTENT, MESSAGE>(onIntent = { onIntent(it) })
    private val intentStateMachine: Channel<INTENT> = Channel()
    private val messageStateMachine: Channel<MESSAGE> = Channel()

    init {
        executor.init {
            viewModelScope.launch {
                messageStateMachine.send(it)
            }
        }

        viewModelScope.launch {
            intentStateMachine.consumeEach(executor::executeIntent)
        }
    }

    override val state: StateFlow<STATE> by lazy {
        messageStateMachine
            .receiveAsFlow()
            .runningFold(initialState, ::reduce)
            .stateIn(viewModelScope, SharingStarted.Eagerly, initialState)
    }

    override suspend fun accept(intent: INTENT) = intentStateMachine.send(intent)

    protected abstract fun Executor<INTENT, MESSAGE>.onIntent(intent: INTENT)
    protected abstract fun reduce(state: STATE, message: MESSAGE): STATE
}

AAC ViewModel과 혼용해서 쓰기 위해 ViewModel을 상속받는 ViewModelStore를 만들었습니다.

천천히 하나하나 살펴보겠습니다.

Executor

interface Executor<INTENT, MESSAGE> {

    fun executeIntent(intent: INTENT)

    fun dispatch(message: MESSAGE)

    fun init(output: (MESSAGE) -> Unit)
}

class DefaultExecutor<INTENT, MESSAGE>(
    private val onIntent: Executor<INTENT, MESSAGE>.(INTENT) -> Unit
) : Executor<INTENT, MESSAGE> {

    private var output: ((MESSAGE) -> Unit)? = null

    override fun executeIntent(intent: INTENT) = onIntent(intent)

    override fun dispatch(message: MESSAGE) {
        output?.invoke(message)
    }

    override fun init(output: (MESSAGE) -> Unit) {
        this.output = output
    }
}

유저에게 인텐트를 전달받아 그에 해당하는 로직을 수행하고, 결과를 Reducer에게 전달할 수 있는 Executor입니다.

executeIntent를 통해 Intent에 해당하는 로직을 수행하고, dispatch를 통해 Reducer에게 결과를 전달할 수 있습니다.

IntentStateMachine

유저에게 인텐트를 전달받아 해당하는 비즈니스 로직을 수행하는 함수를 호출해주는 state machine입니다. Coroutines Channel로 구현했습니다.

MessageStateMachine

인텐트를 실행하고 상태를 변경하기 위해 Reducer에게 Message를 전달하게 되는데, 이 Message를 전달받아 상태를 변경해주는 state machine 입니다. 마찬가지로 Coroutines Channel로 구현했습니다.

시나리오

init block에서 executor의 init을 통해 Message를 콜백받아 messageStateMachine에 넣어줌으로써 Executor의 결과를 Reducer에게 전달할 수 있습니다.

executor.init {
	viewModelScope.launch {
		messageStateMachine.send(it)
	}
}

유저에게 Intent를 accept 함수를 통해 intentStateMachine에 전달해 그에 해당하는 로직을 수행하게 합니다.

viewModelScope.launch {
	intentStateMachine.consumeEach(executor::executeIntent)
}

Executor에서 Message를 받을 때마다 상태 변경을 해줍니다. 여기에 블로그 초반부에 설명드렸던 runningFold 함수가 사용됩니다. messageStateMachine을 통해 Message를 전달받을 때마다 이전의 state와 받은 Message를 통해 새로운 상태로 갱신해줍니다.

override val state: StateFlow<STATE> by lazy {
	messageStateMachine
    	.receiveAsFlow()
        .runningFold(initialState, ::reduce)
        .stateIn(viewModelScope, SharingStarted.Eagerly, initialState)
}

뷰모델에서 구성해줄 두 함수입니다.

protected abstract fun Executor<INTENT, MESSAGE>.onIntent(intent: INTENT)
protected abstract fun reduce(state: STATE, message: MESSAGE): STATE

ViewModel

@HiltViewModel
class GalleryViewModel @Inject constructor(
	override val initialState: GalleryState,
	private val getSearchResultUseCase: GetSearchResultUseCase,
) : ViewModelStore<GalleryIntent, GalleryState, GalleryMessage>() {

	init {
		viewModelScope.launch {
			accept(GalleryIntent.FetchPhotos)
		}
	}

	override fun Executor<GalleryIntent, GalleryMessage>.onIntent(intent: GalleryIntent) {
		when (intent) {
			is GalleryIntent.FetchPhotos -> getSearchResultUseCase<PagingData<UnsplashPhoto>>(DEFAULT_QUERY)
				.cachedIn(viewModelScope)
				.onEach { dispatch(GalleryMessage.Fetched(it)) }
				.launchIn(viewModelScope)
		}
	}

	override fun reduce(state: GalleryState, message: GalleryMessage) = when (message) {
		is GalleryMessage.Fetched -> state.copy(data = message.result)
	}

	companion object {
		const val DEFAULT_QUERY = "cats"
	}
}

@HiltViewModel
class ItemViewModel @Inject constructor(
    override val initialState: ItemState,
    private val getItemUseCase: GetItemListUseCase,
    private val insertItemUseCase: InsertItemUseCase,
) : ViewModelStore<ItemIntent, ItemState, ItemMessage>() {

    init {
    	viewModelScope.launch {
    	    accept(ItemIntent.ObserveItems)
        }
    }

    override fun Executor<ItemIntent, ItemMessage>.onIntent(intent: ItemIntent) {
        when (intent) {
            is ItemIntent.ObserveItems -> getItemUseCase()
                .onEach { dispatch(ItemMessage.Fetched(it)) }
                .launchIn(viewModelScope)

            is ItemIntent.InsertItem -> viewModelScope.launch {
                insertItemUseCase(intent.item)
            }
        }
    }

    override fun reduce(state: ItemState, message: ItemMessage): ItemState = when (message) {
        is ItemMessage.Fetched -> state.copy(items = message.data)
    }
}

AAC ViewModel 부분입니다.

state는 ViewModelStore에서 관리를 해주니 ViewModel에서는 Intent를 처리해주는 함수 onIntent와 상태를 변경해주는 reduce 함수만 구성해주면 됩니다.

View


@AndroidEntryPoint
class GalleryFragment : BaseFragment<FragGalleryBinding>(R.layout.frag_gallery) {

    private val galleryViewModel: GalleryViewModel by viewModels()

	...
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

		...
		
        lifecycleScope.launch {
            galleryViewModel.state.flowWithLifecycle(lifecycle).collect {
                photoAdapter.submitData(it.data)
            }
        }
	
	}
}

@AndroidEntryPoint
class ItemFragment : BaseFragment<FragMarketBinding>(R.layout.frag_market) {

	private val itemViewModel: ItemViewModel by viewModels()

	,,,
    	
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

		....
		
        lifecycleScope.launch {
            itemViewModel.state.flowWithLifecycle(lifecycle)
                .collect {
                    itemAdapter.submitList(it.items)
                }
        }
	}
}

유저의 인풋을 받아 인텐트를 ViewModel (Store)에 전달하거나 ViewModel (Store)의 상태를 받아 알맞게 ui를 변경해주는 코드입니다.

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

0개의 댓글