Circuit 의 핵심인 Presenter 에 대해 알아보고, 기존의 AAC ViewModel 을 Circuit 의 Presenter 로 대체하기 위한 방안들을 소개해보고자 한다.
Circuit 의 Presenter 에 대한 설명을 먼저 진행하고, 기존에 AAC ViewModel 에서 데이터 처리 및 상태 관리를 수행할때 주로 사용했던 방식들을 Circuit Presenter 에서는 어떻게 구현하면 되는지 알아보도록 하겠다.
Circuit 의 Presenter 는 MVP 패턴의 Presenter 와는 다른 개념으로, present
라는 이름의 Composable 함수를 통해 UI 의 상태를 관리하고, 비즈니스 로직을 처리하는 역할을 수행한다.
UI 와 같은 생명주기를 가지고 있어, 기존의 AAC ViewModel 에서 사용했던 collectAsStateWithLifecycle
과 같은 UI 의 생명주기에 맞춰 Flow 의 수집을 제어하는 처리를 해줄 필요가 없다.
또한, UDF(단방향 데이터 흐름)를 따라, State 객체를 통해 UI 에 필요한 상태를 전달하며, Event 를 통해 사용자 입력을 받아 처리한다.
View(UI) 에 대한 직접적인 참조는 가지지 않는 형태이며, Screen 을 통해 연결되는 구조이다.
Presenter 와 UI 는 오직 State 와 Event 를 통해 커뮤니케이션 할 수 있으며, Screen 은 Presenter 와 UI 를 연결하는 Key 로 사용된다.
Circuit Flow(단방향 데이터 흐름)
UI 에서 이벤트 발생(eventSink) -> Presenter 에서 처리 -> State 업데이트 -> 새로운 State 방출(반환) -> UI 반영
한 화면내에 모든 상태를 하나의 불변 객체인 State 로 관리하며, 모든 UI 변경 사항을 상태의 변화로 표현한다.
Circuit 의 Presenter 에 관한 추가적인 설명은 이전에 작성했던 글을 참고하면 도움이 될 것 같다.
Presenter 에 대한 설명은 이쯤에서 마무리하고, AAC ViewModel 에서 자주 사용하던 구현 방법들을 Circuit Presenter 에선 어떻게 구현하면 되는지, 그 방법들을 소개해보도록 하겠다.
화면에 진입 시 화면에 데이터를 표시하기 위해 주로 ViewModel 의 init 블럭이나, Composable Screen 내에 LaunchedEffect 블럭 내에서 데이터를 로드했었다.
둘 중의 더 나은 방식은 무엇인지, 아니면 더 좋은 방식이 있는지는 skydoves 님의 아티클을 참고 해보면 좋을 듯 하다.
-> Composable 함수이므로 LaunchedEffect 블럭 내에서 원하는 동작을 수행해주면 된다.
LaunchedEffect 내에 key 값을 Unit 등으로 지정해주면, present
함수(Composable 함수)가 호출될 때 블럭 내부의 함수가 호출되고, 이후 Recomposition 이 발생하더라도, 다시 블럭 내에 함수가 호출되지 않게 된다.
@Composable
override fun present(): State {
LaunchedEffect(Unit) {
// 단순히 초기화 작업만 필요한 경우
repository.loadData()
}
return state
}
또한 Composable 함수가 파괴되면, LaunchedEffect 블럭 내에서 작업이 진행중인 코루틴은 자동으로 취소된다.
@Inject
class AccountPresenter(
@Assisted private val navigator: Navigator,
private val loginTrakt: Lazy<LoginTrakt>,
private val logoutTrakt: Lazy<LogoutTrakt>,
private val observeTraktAuthState: Lazy<ObserveTraktAuthState>,
private val observeUserDetails: Lazy<ObserveUserDetails>,
) : Presenter<AccountUiState> {
@Composable
override fun present(): AccountUiState {
val user by observeUserDetails.value.flow.collectAsRetainedState(null)
val authState by observeTraktAuthState.value.flow
.collectAsRetainedState(TraktAuthState.LOGGED_OUT)
LaunchedEffect(Unit) {
observeTraktAuthState.value.invoke(Unit)
observeUserDetails.value.invoke(ObserveUserDetails.Params("me"))
}
val eventSink: CoroutineScope.(AccountUiEvent) -> Unit = { event ->
when (event) {
AccountUiEvent.NavigateToSettings -> {
navigator.pop() // dismiss ourselves
navigator.goTo(SettingsScreen)
}
AccountUiEvent.Login -> launchOrThrow { loginTrakt.value.invoke() }
AccountUiEvent.Logout -> launchOrThrow { logoutTrakt.value.invoke() }
}
}
return AccountUiState(
user = user,
authState = authState,
eventSink = wrapEventSink(eventSink),
)
}
}
-> produceRetainedState 함수를 사용하면 된다.
@Composable
override fun present(): State {
// 초기화 결과를 상태로 관리해야 하는 경우
val list by produceRetainedState(initialValue = emptyList()) {
value = repository.loadData()
}
return State(
list = list,
// ...
)
}
화면에 진입할 때, 로드해온 데이터를 상태로 관리해야하는 경우(데이터의 변경이 가능한 경우)엔 위와 같이 처리해주면 된다.
produceRetainedState
함수를 통해 받아온 데이터는 configuration change 로 부터 그 상태를 유지할 수 있고 key 를 넣어줌으로써, key 가 변했을 때, 다시 블럭내에 함수를 실행시킬 수도 있다.
// SummarizerScreen
@Parcelize
data class SummarizerScreen(val title: String, val url: String) : Screen {
sealed interface State : CircuitUiState {
val title: String
data class Loading(override val title: String) : State
data class Error(override val title: String, val url: String, val message: String) : State
data class Success(override val title: String, val summary: String) : State
}
}
private fun SummarizerResult.toState(title: String, url: String): State {
return when (this) {
is Success -> State.Success(title, summary)
is NotFound -> State.Error(title, url, "Unable to summarize this.")
is Unavailable -> State.Error(title, url, "Summarization not available.")
is Error -> State.Error(title, url, message)
}
}
// SummarizerPresenter
@Composable
override fun present(): State {
val summary by
produceState<State>(Loading(screen.title)) {
value = repository.getSummarization(screen.url).toState(screen.title, screen.url)
}
return summary
}
그외에 다크모드, 온보딩 종료 여부 등 Presenter 에서 flow 의 형태로 값을 구독하는 경우, collectAsRetainedState 를 통해 처리할 수 있다.
@Composable
override fun present(): State {
val scope = rememberCoroutineScope()
val isOnboardingCompleted by repository.flowIsOnboardingCompleted().collectAsRetainedState(false)
return State(
isOnboardingCompleted = isOnboardingCompleted,
) { event ->
when (event) {
is Event.CheckOnboardingStatus -> {
scope.launch {
if (isOnboardingCompleted) {
navigator.resetRoot(HomeScreen)
} else {
navigator.resetRoot(OnboardingScreen)
}
}
}
}
}
}
viewModelScope
는 ViewModel 이 제공하는 코루틴 스코프로, 비즈니스 로직을 처리할 때 코루틴 스코프가 필요한 경우 사용했었다.
ViewModel 의 Lifecycle 과 연동되어, ViewModel 이 파괴되면 viewModelScope
내에 진행중인 작업들이 취소되기에, 메모리 누수와 불필요한 리소스 소비를 걱정하지 않고 비동기 작업을 간편하고 안전하게 관리할 수 있어, 유용하게 활용되어 왔다.
-> rememberCoroutineScope
또는 rememberStableCoroutineScope
함수를 사용하면 된다.
rememberCoroutineScope
와 Stable 특성이 추가된 rememberStableCoroutineScope 의 경우, Composable 함수 내에서 코루틴 스코프를 제공하는 역할을 하며, Composable 이 파괴되는 경우, 이 코루틴 스코프내에서 진행중인 작업들이 자동으로 취소된다.
화면이 복잡해짐에 따라, present 함수 내에서 모든 로직을 처리하게 되면, present 함수만 몇백, 몇천 줄이 넘어갈 수도 있다. 함수의 책임을 분리하기 위해 함수를 분리하게 될 경우, present
함수 영역(Composable 함수 영역)을 벗어나게 되는데, 이러면 rememberCoroutineScope 를 사용할 수 없게된다!
-> suspend 함수로 만들어 scope.launch {} 블럭 내부에서 함수를 호출해주도록 하자.
코루틴 스코프를 Presenter 내에 모든 함수들에게 파라미터로 전달해줘야 하나? 아니면 모든 함수를 Composable 함수로 만들어줘야하나? 라는 고민을 잠시 했었는데, 레퍼런스를 통해 더 나은 방법을 확인해볼 수 있었다.
OrderServicesScreen in CatchUp
class OrderServicesPresenter
@AssistedInject
constructor(
@Assisted private val navigator: Navigator,
private val serviceMetas: Map<String, ServiceMeta>,
private val catchUpPreferences: CatchUpPreferences,
) : Presenter<State> {
...
@Composable
override fun present(): State {
val storedOrder by
remember {
catchUpPreferences.servicesOrder.mapToStateFlow {
it ?: serviceMetas.keys.toImmutableList()
}
}
.collectAsState()
val initialOrderedServices =
remember(storedOrder) {
serviceMetas.values.sortedBy { storedOrder.indexOf(it.id) }.toImmutableList()
}
val currentDisplay =
remember(storedOrder) {
serviceMetas.values.sortedBy { storedOrder.indexOf(it.id) }.toMutableStateList()
}
val isChanged by remember {
derivedStateOf {
val initial = initialOrderedServices.joinToString { it.id }
val current = currentDisplay.joinToString { it.id }
initial != current
}
}
var showConfirmation by remember { mutableStateOf(false) }
BackHandler(enabled = isChanged && !showConfirmation) { showConfirmation = true }
val scope = rememberStableCoroutineScope()
return State(
services = currentDisplay,
showSave = isChanged,
showConfirmation = showConfirmation,
) { event ->
when (event) {
...
Save -> {
scope.launch {
save(currentDisplay)
navigator.pop()
}
}
is DismissConfirmation -> {
showConfirmation = false
if (event.save) {
scope.launch {
save(currentDisplay)
navigator.pop()
}
} else if (event.pop) {
navigator.pop()
}
}
...
}
}
}
present 함수에 선언된 State 들은 Presenter 의 클래스 변수가 아니기에, present 함수 바깥에서 state 에 접근할 수 없다.
따라서, State 에 접근할 수 있으면서, 코드를 분리(함수화) 하길 원한다면, present 함수 내에 중첩 함수(Local Function)로 구현해줘야 한다.
클래스내에서 선언된 함수들이 선언된 순서와 상관없이 자유롭게 서로를 참조할 수 있는 것과 달리, 함수 내에 중첩 함수들의 경우 선언 순서가 중요하다.
참조하려는 함수가 이미 먼저(위에서) 선언되어 있어야 참조가 가능하다는 것을 유념하도록 하자.
State 를 변경하지 않는 함수는 present 함수 밖, Presenter 클래스의 함수로 선언해주면 된다.
정리
State 변경이 수반되는 함수 -> present 함수 내의 중첩 함수로 선언
그 외의 함수 -> Presenter 클래스 함수로 선언
예시는 아래 레포에서 확인할 수 있다.
HomePresenter in Bandalart
기존에 AAC ViewModel 을 사용할 때, 주로 사용했던 방식들을 Presenter 에서는 어떤 식으로 구현할 수 있는지 그 방법들을 알아보았다.
...
이렇게 모든 문제를 해결할 수 있었으면 좋았겠으나, 아직 해결하지 못한 문제들이 존재한다.
Circuit 으로 migration 을 진행하면서 마주한 의문점들을 몇가지 적어보도록 하겠다.
기존의 프로젝트에선 UiEvent 를 Route Composable 함수에서 처리해주었고, 이러한 방식이 쉽게 구현이 가능했다.
// CompleteViewModel
private val _uiEvent = Channel<CompleteUiEvent>()
val uiEvent = _uiEvent.receiveAsFlow()
fun onAction(action: CompleteUiAction) {
when (action) {
is CompleteUiAction.OnBackButtonClick -> navigateBack()
is CompleteUiAction.OnSaveButtonClick -> saveBandalart()
is CompleteUiAction.OnShareButtonClick -> shareBandalart()
}
}
private fun navigateBack() {
viewModelScope.launch {
_uiEvent.send(CompleteUiEvent.NavigateBack)
}
}
private fun saveBandalart() {
viewModelScope.launch {
_uiEvent.send(CompleteUiEvent.SaveBandalart(Uri.parse(bandalartChartImageUri)))
}
}
private fun shareBandalart() {
viewModelScope.launch {
_uiEvent.send(CompleteUiEvent.ShareBandalart(Uri.parse(bandalartChartImageUri)))
}
}
sealed interface CompleteUiEvent {
data object NavigateBack : CompleteUiEvent
data class SaveBandalart(val imageUri: Uri) : CompleteUiEvent
data class ShareBandalart(val imageUri: Uri) : CompleteUiEvent
}
// CompleteScreen
@Composable
internal fun CompleteRoute(
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: CompleteViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
ObserveAsEvents(flow = viewModel.uiEvent) { event ->
when (event) {
is CompleteUiEvent.NavigateBack -> {
onNavigateBack()
}
is CompleteUiEvent.SaveBandalart -> {
context.saveUriToGallery(event.imageUri)
Toast.makeText(context, context.getString(R.string.save_bandalart_image), Toast.LENGTH_SHORT).show()
}
is CompleteUiEvent.ShareBandalart -> {
context.shareImage(event.imageUri)
}
}
}
CompleteScreen(
uiState = uiState,
onAction = viewModel::onAction,
modifier = modifier,
)
}
하지만 Circuit Presenter 를 사용하는 경우 그렇지 않는데, Presenter 는 UI 에서 발생한 이벤트(eventSink)를 처리하고, State 를 변경하는 역할만 수행하기 때문이다. 그렇다... Presenter 는 Event 를 발생시킬 수 없다!
구현에 있어 상당히 제약이 걸려있다고 생각이 드는데, Presenter 와 UI 의 역할을 명확히 구분하고, 팀 단위 개발 환경에서 일관된 코드 스타일을 유지하기 위한 설계라고 생각된다.
Application Context 를 Presenter 에 주입받을 수 있고, 이게 잘못된 방법은 아니라는 것은 이전 글에서 확인할 수 있었는데, Event 를 처리하는 부분에서 Android 플랫폼 의존성이 있는 경우는 어떻게 해야할까?
그 중 가장 간단한 예인 Toast 또는 Snackbar 출력을 생각해보면 이를 Presenter 에서 호출하는 것 의외엔 별다른 방법이 없어보여 레퍼런스를 뒤져보았다.
class SettingsPresenter
@AssistedInject
constructor(
@Assisted private val screen: SettingsScreen,
@Assisted private val navigator: Navigator,
@ApplicationContext private val appContext: Context,
private val catchUpPreferences: CatchUpPreferences,
...
) : Presenter<State> {
...
@Composable
override fun present(): State {
// TODO blerg this isn't good in Circuit. Need an ActivityStarter instead on DI
val view = LocalView.current
LaunchedEffect(view) {
catchUpPreferences.reports
.drop(1) // Drop the initial true emission
.collect {
// If we change reports to false, restart
// TODO circuit-ify this
Snackbar.make(
view,
appContext.getString(AppScaffoldR.string.settings_reset),
Snackbar.LENGTH_INDEFINITE,
)
.setAction(AppScaffoldR.string.restart) { appContext.restartApp() }
.show()
}
}
val scope = rememberStableCoroutineScope()
return State(screen.showTopAppBar) { event ->
when (event) {
ClearCache -> {
scope.launch {
...
// TODO circuit-ify this
Snackbar.make(view, message, Snackbar.LENGTH_INDEFINITE)
.setAction(AppScaffoldR.string.restart) { appContext.restartApp() }
.show()
}
}
...
}
}
}
Snackbar 를 사용하는 코드는 찾아볼 수 있었으나, 위와 같이 Circuit 스러운 방식으로 대체해야 한다는 주석이 적혀있었는 것을 확인할 수 있었다. 그럼 어떻게 대체해야하는지 알려주셔야 하는거 아닌가요???
이를 통해 추론해볼 수 있는 Event 처리 방식으로는
가장 쉽고, 생각할 것이 없는 해결책이다. 동작도 Toast 의 경우 정상적으로 이루어진다. 하지만 Snackbar 의 경우, snackbarHostState 를 화면(UI) 내에 위치한 SnackbarHost Composable 함수에 연결해주는 작업이 필요하기 때문에, Presenter 내에서 직접 호출하는 것은 불가능했다. 또한 위에서도 언급되었듯, Circuit 스럽지 않은 코드인지라. 어쩔 수 없이 다른 방법을 강구해야할듯 하다.
마치 ViewModel 에 ApplicationContext 를 주입해서 뷰모델 내에서 직접 Toast 를 호출하는 듯한 느낌이다.
State 와 Event 외에 UiEvent 만을 다루는 SideEffect 를 도입하는 것이다.
개인적으론 UiEvent 라는 네이밍을 그동안 사용해왔으나, Circuit 의 Event 와 너무 네이밍이 유사하기에 아예 다른 이름을 선정해보았다.
@Parcelize
data class CompleteScreen(
val bandalartId: Long,
val title: String,
val profileEmoji: String,
val bandalartChartImageUri: String,
) : Screen {
data class State(
val id: Long,
val title: String,
val profileEmoji: String,
val bandalartChartImageUri: String,
val sideEffect: SideEffect? = null, // UI 레이어에서 처리할 효과
val eventSink: (Event) -> Unit,
) : CircuitUiState
// Hi! SideEffect here :)
sealed interface SideEffect {
data class SaveImage(val uri: Uri) : SideEffect
data class ShareImage(val uri: Uri) : SideEffect
data class ShowToast(val messageResId: Int) : SideEffect
}
sealed interface Event {
data object NavigateBack : Event
data object OnSaveClick : Event
data object OnShareClick : Event
}
}
class CompletePresenter @AssistedInject constructor(
@Assisted private val screen: CompleteScreen,
@Assisted private val navigator: Navigator,
) : Presenter<CompleteScreen.State> {
@Composable
override fun present(): CompleteScreen.State {
var sideEffect by rememberRetained { mutableStateOf<CompleteScreen.SideEffect?>(null) }
...
fun handleEvent(event: CompleteScreen.Event) {
when (event) {
CompleteScreen.Event.NavigateBack -> {
navigator.pop()
}
CompleteScreen.Event.OnSaveClick -> {
sideEffect = CompleteScreen.SideEffect.SaveImage(
Uri.parse(screen.bandalartChartImageUri)
)
}
CompleteScreen.Event.OnShareClick -> {
sideEffect = CompleteScreen.SideEffect.ShareImage(
Uri.parse(screen.bandalartChartImageUri)
)
}
}
}
return CompleteScreen.State(
id = screen.bandalartId,
title = screen.title,
profileEmoji = screen.profileEmoji,
bandalartChartImageUri = screen.bandalartChartImageUri,
sideEffect = sideEffect, // <- SideEffect
eventSink = { event -> handleEvent(event) }
)
}
}
@Composable
fun Complete(state: State) {
val context = LocalContext.current
// UI 레이어에서 SideEffect 처리
LaunchedEffect(state.sideEffect) {
when (val effect = state.sideEffect) {
is SideEffect.SaveImage -> {
context.saveUriToGallery(effect.uri)
}
is SideEffect.ShareImage -> {
context.shareImage(effect.uri)
}
is SideEffect.ShowToast -> {
Toast.makeText(
context,
context.getString(effect.messageResId),
Toast.LENGTH_SHORT
).show()
}
}
}
// UI 구현
...
}
현재 Snackbar 의 경우 이 방식을 통해 처리해주었고 정상적으로 동작하는 것을 확인할 수 있었다.
다만, SideEffect 를 Channel 이나 SharedFlow 처럼 Event 를 발행하고 소비하는 개념이 아닌, SideEffect 라는 State 를 변경하는 방식으로 처리하고 있기 때문에, SideEffect 를 변경 이후, 이를 다시 초기화(null) 해주는 작업을 개발자가 직접 해줘야하는 단점이 존재한다.
eventSink(Event.InitSideEffect)
이는 StateFlow 로 이벤트를 처리하는 경우와 비슷해지는데, 이벤트에 대한 소비 처리를 직접 해줘야하는 문제 때문에, 이를 깜빡하고 해주지 않는 등의 휴먼 에러가 발생할 수 있어, StateFlow 로 이벤트를 처리하는 방법을 별로 선호하진 않았었다.
그밖에 Snackbar 같은 경우엔 Circuit Overlay 를 통해 Snackbar 모양의 Custom Dialog 를 직접 구현하여 호출하는 방법도 있을 것 같은데, 이는 Snackbar 에 국한된 해결책인지라, 다른 케이스(Toast 나 그 외에 Android 플랫폼 의존성이 존재하는 처리)는 해결할 수 없을 듯 하다.
예외적으로 Android 플랫폼 의존성이 있는
startActivity
함수 호출의 경우AndroidScreenAwareNavigator
를 통해 우회해서 호출할 수 있는 방법을 지원하는데, Circuit 상호운용성 관련 글에서 사용 방법을 정리해두었다.
어떻게 구현해야 Circuit 스러운 방식인 것인지 궁금해서 Circuit Github 레포지토리의 Discussion 에 질문을 올려봤는데, 메인테이너이신 Zac Sweers 님 또는, Circuit 을 사용하고 계시는 선배 개발자분들께서 답변을 달아주셨으면 좋겠다.
Circuit 레포내에 Discussion 에 이번 기회에 처음 들어가봤는데, 그동안 여러 사람들이 Circuit 을 적용하면서 궁금했던 부분들에 대한 질문을 올리고, 답변을 받아 해결한 케이스가 제법 많이 존재하였다.
Circuit 에 대한 공부를 진행 중이라면, discussion 을 정독해보면서 많은 도움을 받을 수 있을 것 같다.
AAC ViewModel 의 viewModelScope
는 단순히 ViewModel 의 Lifecycle 과 함께 종료되는 코루틴 스코프를 제공하는 것 이상의 기능을 가지고 있다.
public val ViewModel.viewModelScope: CoroutineScope
get() = synchronized(VIEW_MODEL_SCOPE_LOCK) {
getCloseable(VIEW_MODEL_SCOPE_KEY)
?: createViewModelScope().also { scope -> addCloseable(VIEW_MODEL_SCOPE_KEY, scope) }
}
internal fun createViewModelScope(): CloseableCoroutineScope {
val dispatcher = try {
// In platforms where `Dispatchers.Main` is not available, Kotlin Multiplatform will throw
// an exception (the specific exception type may depend on the platform). Since there's no
// direct functional alternative, we use `EmptyCoroutineContext` to ensure that a coroutine
// launched within this scope will run in the same context as the caller.
Dispatchers.Main.immediate
} catch (_: NotImplementedError) {
// In Native environments where `Dispatchers.Main` might not exist (e.g., Linux):
EmptyCoroutineContext
} catch (_: IllegalStateException) {
// In JVM Desktop environments where `Dispatchers.Main` might not exist (e.g., Swing):
EmptyCoroutineContext
}
return CloseableCoroutineScope(coroutineContext = dispatcher + SupervisorJob())
}
viewModelScope
는 SupervisorJob 을 통해 코루틴 스코프를 생성하여, 하위의 코루틴에서 예외가 발생하거나 취소되더라도, 다른 작업(코루틴)에 영향을 주지 않도록 설계되어있다.
반면, Circuit Presenter 에서 사용할 수 있는 rememberCoroutineScope
나 rememberStableCoroutineScope
는 단순히 Composable 의 생명주기와 연동되어 자동으로 취소되는 기능만을 제공하는 것으로 알고있어, 이는 에러 핸들링 측면에서 viewModelScope 보다 제한적일 수 있다.
@Composable
inline fun rememberCoroutineScope(
crossinline getContext: @DisallowComposableCalls () -> CoroutineContext =
{ EmptyCoroutineContext }
): CoroutineScope {
val composer = currentComposer
val wrapper = remember {
CompositionScopedCoroutineScopeCanceller(
createCompositionCoroutineScope(getContext(), composer)
)
}
return wrapper.coroutineScope
}
@PublishedApi
internal class CompositionScopedCoroutineScopeCanceller(
val coroutineScope: CoroutineScope
) : RememberObserver {
override fun onRemembered() {
// Nothing to do
}
override fun onForgotten() {
coroutineScope.cancel(LeftCompositionCancellationException())
}
override fun onAbandoned() {
coroutineScope.cancel(LeftCompositionCancellationException())
}
}
따라서 Circuit에서 viewModelScope
와 동일한 수준의 안정성을 원한다면, rememberCoroutineScope
사용시 SupervisorJob 을 적용하여, 코루틴 스코프를 생성하는 것을 고려해볼 수 있을 것 같은데, 이에 대해서 조금 더 학습해보고 결론을 내봐야겠다. 마찬가지로 질문은 올려놓았다.
// 이게 맞나?...
val scope = rememberCoroutineScope {
SupervisorJob() + Dispatchers.Main.immediate
}
여태 분석을 대략 2달 조금 넘게 진행 해보면서 궁금한 부분이 많이 생겼는데, Circuit 을 사용해보셨거나, 실제 현업 프로덕트에 사용하는 분과 만나서 이것저것 많이 여쭤보고 싶다...
이번 글은 여기서 마무리하고, discussion 에 올린 질문에 대한 답변이 달리거나, 위에 작성한 내용들 말고도 추가적으로 언급 할만한 내용이 생긴다면, 이어서 작성해보도록 하겠다.
Circuit 으로 migration 을 완료한 프로젝트는 아래 링크에서 확인할 수 있다.
https://github.com/Nexters/BandalArt-Android
기존의 프로젝트의 ViewModel 이 이미 갓 뷰모델화 되어있었기 때문에, migration 된 Presenter 역시, 갓 프레젠터가 되버린 느낌이 없지않아 있는데, 이러한 문제의 원인이 되는 빈혈 도메인 모델에 대해선 계속해서 리팩토링을 통해 개선해봐야겠다.
레퍼런스)
https://www.youtube.com/watch?v=ZIr_uuN8FEw&t=366s
https://github.com/ZacSweers/CatchUp
https://proandroiddev.com/loading-initial-data-in-launchedeffect-vs-viewmodel-f1747c20ce62
https://proandroiddev.com/loading-initial-data-part-2-clear-all-your-doubts-0f621bfd06a0
https://slackhq.github.io/circuit/presenter/
https://proandroiddev.com/exploring-viewmodel-internals-4ca414b4080b
https://github.com/jisungbin/dog-browser-circuit
https://github.com/android/socialite/issues/19
https://velog.io/@jeongminji4490/Coroutine-supervisorJob%EC%9C%BC%EB%A1%9C-Coroutine-Scope-%EC%A0%9C%EC%96%B4%ED%95%98%EA%B8%B0
https://chrisbanes.me/posts/retaining-beyond-viewmodels/