[Circuit] AAC ViewModel -> Circuit Presenter

이지훈·2025년 1월 6일
0

Circuit

목록 보기
9/9
post-thumbnail

서론

Circuit 의 핵심인 Presenter 에 대해 알아보고, 기존의 AAC ViewModel 을 Circuit 의 Presenter 로 대체하기 위한 방안들을 소개해보고자 한다.

본론

Circuit 의 Presenter 에 대한 설명을 먼저 진행하고, 기존에 AAC ViewModel 에서 데이터 처리 및 상태 관리를 수행할때 주로 사용했던 방식들을 Circuit Presenter 에서는 어떻게 구현하면 되는지 알아보도록 하겠다.

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 에선 어떻게 구현하면 되는지, 그 방법들을 소개해보도록 하겠다.

Loading Initial Data

화면에 진입 시 화면에 데이터를 표시하기 위해 주로 ViewModel 의 init 블럭이나, Composable Screen 내에 LaunchedEffect 블럭 내에서 데이터를 로드했었다.

둘 중의 더 나은 방식은 무엇인지, 아니면 더 좋은 방식이 있는지는 skydoves 님의 아티클을 참고 해보면 좋을 듯 하다.

이를 Circuit 의 Presenter 에선 어떻게 구현하면 될까?

-> Composable 함수이므로 LaunchedEffect 블럭 내에서 원하는 동작을 수행해주면 된다.

LaunchedEffect 내에 key 값을 Unit 등으로 지정해주면, present 함수(Composable 함수)가 호출될 때 블럭 내부의 함수가 호출되고, 이후 Recomposition 이 발생하더라도, 다시 블럭 내에 함수가 호출되지 않게 된다.

@Composable
override fun present(): State {
    LaunchedEffect(Unit) {
        // 단순히 초기화 작업만 필요한 경우
        repository.loadData()
    }
    
    return state
}

또한 Composable 함수가 파괴되면, LaunchedEffect 블럭 내에서 작업이 진행중인 코루틴은 자동으로 취소된다.

실제 사용 예시

AccountPresenter in tivi

@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 In CatchUp

// 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 를 통해 처리할 수 있다.

SplashPresenter in Bandalart

@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

viewModelScope 는 ViewModel 이 제공하는 코루틴 스코프로, 비즈니스 로직을 처리할 때 코루틴 스코프가 필요한 경우 사용했었다.

ViewModel 의 Lifecycle 과 연동되어, ViewModel 이 파괴되면 viewModelScope 내에 진행중인 작업들이 취소되기에, 메모리 누수와 불필요한 리소스 소비를 걱정하지 않고 비동기 작업을 간편하고 안전하게 관리할 수 있어, 유용하게 활용되어 왔다.

이를 Circuit 의 Presenter 에선 어떻게 대체할 수 있을까?

-> rememberCoroutineScope 또는 rememberStableCoroutineScope 함수를 사용하면 된다.

rememberCoroutineScopeStable 특성이 추가된 rememberStableCoroutineScope 의 경우, Composable 함수 내에서 코루틴 스코프를 제공하는 역할을 하며, Composable 이 파괴되는 경우, 이 코루틴 스코프내에서 진행중인 작업들이 자동으로 취소된다.

present 함수 밖에서 코루틴 스코프가 필요하다면?

화면이 복잡해짐에 따라, 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 를 변경해줘야 한다면?

present 함수에 선언된 State 들은 Presenter 의 클래스 변수가 아니기에, present 함수 바깥에서 state 에 접근할 수 없다.

따라서, State 에 접근할 수 있으면서, 코드를 분리(함수화) 하길 원한다면, present 함수 내에 중첩 함수(Local Function)로 구현해줘야 한다.

클래스내에서 선언된 함수들이 선언된 순서와 상관없이 자유롭게 서로를 참조할 수 있는 것과 달리, 함수 내에 중첩 함수들의 경우 선언 순서가 중요하다.
참조하려는 함수가 이미 먼저(위에서) 선언되어 있어야 참조가 가능하다는 것을 유념하도록 하자.

State 를 변경하지 않는 함수는 present 함수 밖, Presenter 클래스의 함수로 선언해주면 된다.

정리

State 변경이 수반되는 함수 -> present 함수 내의 중첩 함수로 선언
그 외의 함수 -> Presenter 클래스 함수로 선언
예시는 아래 레포에서 확인할 수 있다.
HomePresenter in Bandalart

결론

기존에 AAC ViewModel 을 사용할 때, 주로 사용했던 방식들을 Presenter 에서는 어떤 식으로 구현할 수 있는지 그 방법들을 알아보았다.
...
이렇게 모든 문제를 해결할 수 있었으면 좋았겠으나, 아직 해결하지 못한 문제들이 존재한다.
Circuit 으로 migration 을 진행하면서 마주한 의문점들을 몇가지 적어보도록 하겠다.

Toast 또는 Snackbar 는 어디에서 호출하면 되는 것인가? Presenter? UI?

기존의 프로젝트에선 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 에서 호출하는 것 의외엔 별다른 방법이 없어보여 레퍼런스를 뒤져보았다.

SettingsPresenter in CatchUp

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 처리 방식으로는

1. 그냥 Presenter 에서 호출한다.

가장 쉽고, 생각할 것이 없는 해결책이다. 동작도 Toast 의 경우 정상적으로 이루어진다. 하지만 Snackbar 의 경우, snackbarHostState 를 화면(UI) 내에 위치한 SnackbarHost Composable 함수에 연결해주는 작업이 필요하기 때문에, Presenter 내에서 직접 호출하는 것은 불가능했다. 또한 위에서도 언급되었듯, Circuit 스럽지 않은 코드인지라. 어쩔 수 없이 다른 방법을 강구해야할듯 하다.

마치 ViewModel 에 ApplicationContext 를 주입해서 뷰모델 내에서 직접 Toast 를 호출하는 듯한 느낌이다.

2. 별도의 SideEffect 를 도입한다.

State 와 Event 외에 UiEvent 만을 다루는 SideEffect 를 도입하는 것이다.

개인적으론 UiEvent 라는 네이밍을 그동안 사용해왔으나, Circuit 의 Event 와 너무 네이밍이 유사하기에 아예 다른 이름을 선정해보았다.

Screen

@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
   }
}

Presenter

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) }
        )
    }
}

UI

@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 을 정독해보면서 많은 도움을 받을 수 있을 것 같다.

정말로 rememberCoroutineScope 로 viewModelScope 를 대체할 수 있을까?

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 에서 사용할 수 있는 rememberCoroutineScoperememberStableCoroutineScope 는 단순히 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/

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글