[Circuit] collectAsRetainedState, rememberStableCoroutineScope 함수 분석

이지훈·2024년 12월 16일
3

Circuit

목록 보기
4/9
post-thumbnail

서론

본격적으로 기존에 진행했던 사이드 프로젝트를 Circuit 으로 migration 하기 전에, 알아두면 도움이 될 것 같은 두 함수 collectAsRetainedState, rememberStableCoroutineScope 에 대한 분석을 해보고자 한다.

본론에 등장하는 코드 예제들은 Circuit 이 적용된 대표적인 프로젝트들인 이제는 아카이브된 chrisbanes 님의 tivi 와, ZacSweers 님의 CatchUp 레포에서 가져와보았다.

본론

Circuit 이 적용된 프로젝트들의 코드들을 확인해보던 중, 낯선 함수들이 눈에 띄었다.

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

@Immutable
data class AccountUiState(
  val user: TraktUser? = null,
  val authState: TraktAuthState = TraktAuthState.LOGGED_OUT,
  val eventSink: (AccountUiEvent) -> Unit,
) : CircuitUiState

sealed interface AccountUiEvent : CircuitUiEvent {
  data object Login : AccountUiEvent
  data object Logout : AccountUiEvent
  data object NavigateToSettings : AccountUiEvent
}

OrderServicesPresenter 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) {
        is Reorder -> {
          currentDisplay.apply { add(event.to, removeAt(event.from)) }
        }
        Shuffle -> {
          currentDisplay.shuffle()
        }
        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()
          }
        }
        BackPress -> {
          if (!showConfirmation && isChanged) {
            showConfirmation = true
          } else {
            navigator.pop()
          }
        }
      }
    }
  }
  
 @Parcelize
object OrderServicesScreen : Screen {
  data class State(
    val services: SnapshotStateList<ServiceMeta>?,
    val showSave: Boolean = false,
    val showConfirmation: Boolean = false,
    val eventSink: (Event) -> Unit = {},
  ) : CircuitUiState

  sealed interface Event : CircuitUiEvent {
    data object Shuffle : Event
    data class Reorder(val from: Int, val to: Int) : Event
    data object BackPress : Event
    data object Save : Event
    data class DismissConfirmation(val save: Boolean, val pop: Boolean) : Event
  }
}

collectAsRetainedState 함수와 rememberStableCoroutineScope 함수가 바로 그 낯섦의 이유였고, 각각의 함수가 Compose Runtime 에서 제공하는 collectAsState 함수와 rememberCoroutineScope 함수랑 어떤 차이가 있는지 알아보도록 하겠다.

collectAsRetainedState

결론부터 말하자면, collectRetainedState 함수는 내부적으로 rememberRetained 을 사용하는 collectAsState 함수이다.

이를 확인해보기 위해 collectAsRetainedState 의 내부 구현체를 확인하면 다음과 같다.

StableCoroutineScope.kt

package com.slack.circuit.retained

import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext

/**
 * Collects values from this [StateFlow] and represents its latest value via a retained [State]. The
 * [StateFlow.value] is used as an initial value. Every time there would be new value posted into
 * the [StateFlow] the returned [State] will be updated causing recomposition of every [State.value]
 * usage.
 *
 * @param context [CoroutineContext] to use for collecting.
 */
@Composable
public fun <T> StateFlow<T>.collectAsRetainedState(
  context: CoroutineContext = EmptyCoroutineContext
): State<T> = collectAsRetainedState(value, context)

/**
 * Collects values from this [Flow] and represents its latest value via retained [State]. Every time
 * there would be new value posted into the [Flow] the returned [State] will be updated causing
 * recomposition of every [State.value] usage.
 *
 * @param context [CoroutineContext] to use for collecting.
 */
@Composable
public fun <T : R, R> Flow<T>.collectAsRetainedState(
  initial: R,
  context: CoroutineContext = EmptyCoroutineContext,
): State<R> =
  produceRetainedState(initial, this, context) {
    if (context == EmptyCoroutineContext) {
      collect { value = it }
    } else withContext(context) { collect { value = it } }
  }

produceRetainedState 함수의 구현체는 이전 글에서 확인했었기 때문에, 같은 설명은 생략하도록 하겠다.

기존에 사용해왔던 collectAsState 함수와 어떤 차이가 있는지 비교를 위해 collectAsState 함수의 내부 구현체도 확인해보도록 하자.

/**
 * Collects values from this [StateFlow] and represents its latest value via [State]. The
 * [StateFlow.value] is used as an initial value. Every time there would be new value posted into
 * the [StateFlow] the returned [State] will be updated causing recomposition of every [State.value]
 * usage.
 *
 * @sample androidx.compose.runtime.samples.StateFlowSample
 * @param context [CoroutineContext] to use for collecting.
 */
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun <T> StateFlow<T>.collectAsState(context: CoroutineContext = EmptyCoroutineContext): State<T> =
    collectAsState(value, context)

/**
 * Collects values from this [Flow] and represents its latest value via [State]. Every time there
 * would be new value posted into the [Flow] the returned [State] will be updated causing
 * recomposition of every [State.value] usage.
 *
 * @sample androidx.compose.runtime.samples.FlowWithInitialSample
 * @param initial the value of the state will have until the first flow value is emitted.
 * @param context [CoroutineContext] to use for collecting.
 */
@Composable
fun <T : R, R> Flow<T>.collectAsState(
    initial: R,
    context: CoroutineContext = EmptyCoroutineContext
): State<R> =
    produceState(initial, this, context) {
        if (context == EmptyCoroutineContext) {
            collect { value = it }
        } else withContext(context) { collect { value = it } }
    }

그 구조가 매우 유사한 것을 확인할 수 있으며, 차이는 collectAsRetainedState 에서는 produceRetainedState 를 사용하고, collectAsState 에서는 produceState 를 사용하는 것 뿐이다.

produceState 함수는 다음과 같이 구현되는데,

/** Receiver scope for use with [produceState]. */
interface ProduceStateScope<T> : MutableState<T>, CoroutineScope {
    /**
     * Await the disposal of this producer whether it left the composition, the source changed, or
     * an error occurred. Always runs [onDispose] before resuming.
     *
     * This method is useful when configuring callback-based state producers that do not suspend,
     * for example:
     *
     * @sample androidx.compose.runtime.samples.ProduceStateAwaitDispose
     */
    suspend fun awaitDispose(onDispose: () -> Unit): Nothing
}

private class ProduceStateScopeImpl<T>(
    state: MutableState<T>,
    override val coroutineContext: CoroutineContext
) : ProduceStateScope<T>, MutableState<T> by state {

    override suspend fun awaitDispose(onDispose: () -> Unit): Nothing {
        try {
            suspendCancellableCoroutine<Nothing> {}
        } finally {
            onDispose()
        }
    }
}

@Composable
fun <T> produceState(initialValue: T, producer: suspend ProduceStateScope<T>.() -> Unit): State<T> {
    val result = remember { mutableStateOf(initialValue) } // <- produceRetainedState 함수와 다른 부분
    LaunchedEffect(Unit) { ProduceStateScopeImpl(result, coroutineContext).producer() }
    return result
}

@Composable
fun <T> produceState(
    initialValue: T,
    key1: Any?,
    producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
    val result = remember { mutableStateOf(initialValue) } // <- produceRetainedState 함수와 다른 부분
    LaunchedEffect(key1) { ProduceStateScopeImpl(result, coroutineContext).producer() }
    return result
}

이 또한 produceRetainedState 함수의 구현과 매우 유사하며, 차이점은 collectAsRetainedState 에서는 result 라는 변수를 rememberRetained 를 사용하여 관리하며, collectAsState 에서는 remember 를 사용하여 관리한다는 것이다.

Circuit 의 RetainedState 시리즈 함수들이 Compose Runtime 의 State 시리즈 함수들을 참고하여, 사용자로 하여금 기존의 쓰임과 최대한 같게 사용할 수 있도록 개발했을 것으로 추측된다.

따라서 collectAsRetainedStatecollectAsState 함수의 차이는 rememberRetained 함수와 remember 함수의 차이로 귀결되며 이는 앞서 정리한바 있다.

즉, collectAsRetainedStatecollectAsState 와 같이 recomposition 상황에서 Flow 로 부터 수집한 마지막 값을 유지할 수 있다.

또한, collectAsState 함수와 다르게, configuration changes 상황에서도 Flow 로부터 수집한 마지막 값을 유지할 수 있다.

rememberStableCoroutineScope

마찬가지로 결론부터 말하면, 성능 최적화(Stable)가 적용된 rememberCoroutineScope 이다.

collectAsRetainedState 를 분석해본 것과 같이, rememberStableCoroutineScope 함수의 내부 구현체를 먼저 확인해보도록 하겠다.

CollectRetained.kt

package com.slack.circuit.runtime.internal

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.CoroutineScope

/**
 * Returns a [StableCoroutineScope] around a [rememberCoroutineScope]. This is useful for event
 * callback lambdas that capture a local scope variable to launch new coroutines, as it allows them
 * to be stable.
 */
@Composable
public fun rememberStableCoroutineScope(): StableCoroutineScope {
  val scope = rememberCoroutineScope()
  return remember { StableCoroutineScope(scope) }
}

/** @see rememberStableCoroutineScope */
@Stable public class StableCoroutineScope(scope: CoroutineScope) : CoroutineScope by scope

기존에 Composable 함수내에서 suspend 함수를 호출하기 위해, 코루틴 스코프를 필요로 할 때, 사용해왔던 rememberCoroutineScope 함수를 StableCoroutineScope 라는 클래스의 파라미터로 전달하여, 이를 remember 블록으로 감싸 return 하는 형태를 띄고 있다.

StableCoroutineScope?

@Stable public class StableCoroutineScope(scope: CoroutineScope) : CoroutineScope by scope

StableCoroutineScope 클래스의 구현체를 확인해보면 클래스의 body 부분이 존재하지 않고, by 키워드를 통한 Kotlin 의 위임 패턴(delegation pattern)이 사용되어 구현된 것을 확인할 수 있다.

따라서 위의 코드는 컴파일 과정을 거쳐 아래와 같이 변환된다.

class StableCoroutineScope(scope: CoroutineScope) : CoroutineScope {
    override val coroutineContext: CoroutineContext = scope.coroutineContext
}

즉, StableCoroutineScope 는 CoroutineScope 인터페이스를 구현하되, 실제 구현은 생성자로 받은 scope 객체에 모두 위임한다.(CoroutineScope 의 모든 기능을 scope 객체가 대신 처리)

StableCoroutineScope 를 정리하면 다음과 같다.

  • StableCoroutineScope 는 단순히 래퍼 클래스 역할을 수행
  • @Stable 어노테이션으로 안정성을 보장하면서, 실제 코루틴 관려 동작은 모두 원래의 scope 처리

그래서 언제 사용하면 되는지?

함수 위에 써있는 주석을 번역하면 다음과 같다.

  • rememberCoroutineScope 를 감싸는 StableCoroutineScope 를 반환합니다.
  • 이는 새로운 코루틴을 실행하기 위해 로컬 scope 변수를 캡처하는 이벤트 콜백 람다에 유용합니다.
  • 왜냐하면 이를 통해 해당 람다들이 stable 할 수 있기 때문입니다.

로컬 scope 변수를 캡처?

람다나 클로저가 외부의 변수(여기서는 scope)를 참조하여 사용하는 것을 의미한다.

// 외부에 정의된 scope
val scope = rememberCoroutineScope()
    
// 람다가 외부의 scope 를 "캡처"하여 사용
val onClick = {
	scope.launch {  // 여기서 외부의 scope 를 사용(캡처)
    	// 비동기 작업
    }
}
    
Button(onClick = onClick) {
    Text("Click me")
}

캡처된 scope 가 Unstable 할 경우, recomposition 이 발생할 때마다 람다도 다시 생성될 수 있어, 성능의 영향을 미칠 수 있다.

일반적인 CoroutineScope 의 경우, Compose Compiler 에 의해 Unstable 취급이 될 수 있으므로, StableCoroutineScope 클래스로 이를 래핑하여, Stable 취급되도록 보장하도록 한다.

극한의 최적화를 필요로 하는 경우, 사용할 수 있는 함수로 판단된다.

tivi 레포의 경우, Circuit 에서 지원하는 rememberStableCoroutineScope 를 사용하진 않는 것을 확인할 수 있었다. 다만 compose-stability.conf 파일 내에 kotlinx.coroutines.CoroutineScope 를 추가하여 CoroutineScope 들을 Stable 취급 받도록 설정 해놓았다.

결론

collectAsRetainedState 함수와 rememberStableCoroutineScope 에 대한 분석을 진행해보았다.

본론의 내용을 간단하게 정리해보면 다음과 같다.

collectAsRetainedState

  • collectAsState 함수의 특성을 가지며, 더불어 configuration change 가 발생하여도 flow 로 부터 수집한 마지막 값을 유지할 수 있음
  • Circuit 의 Presenter 에서 사용(AAC ViewModel 을 사용하지 않아도 configuration change 상황에서 상태를 유지할 수 있음)

rememberStableCoroutineScope

  • Composable 함수(ex. Circuit Presenter) 내에서 새로운 코루틴을 실행할 때, CoroutineScope 를 필요로 할 때, rememberCoroutineScope 처럼 사용
  • 이벤트 콜백 람다를 사용할 때, 최적화에 도움을 얻을 수 있음

다만, Compose 최신 버전에서는 Strong Skipping Mode 가 default 로 활성화되기 때문에, 이러한 최적화까지 개발자가 직접 해야할 필요는 없어졌다고 생각하는 입장이다.

끝으로, "Circuit 에서는 collectAsState, rememberCoroutineScope 를 사용하지 말고, collectAsRetainedState, rememberStableCoroutineScope 를 사용해야한다."
는 말은 아니며, 각 함수의 쓰임을 이해하고, 상황에 맞게 필요한 함수를 선택해서 사용하는 것이 중요할 듯 하다.

이제 진짜 migration 만 완료하면 될 것 같다.

Circuit 관련 의문점

migraion 을 진행 해보면서, 현재까지 느낀 몇가지 의문점들에 대해 정리해보도록 하겠다.

1. State 와 Event... Intent 는?

Circuit 의 이벤트 처리의 경우, State 와 Event 간의 단방향의 흐름으로 진행된다. 이는 Android 권장 아키텍처와 같다고 볼 수 있을 것 같다.

다만, 기존의 프로젝트에서 MVI 패턴을 구현하였을 때, UiState(State), UiEvent(SideEffect 에 포함되는 개념) 의외에, UiAction(Intent) 을 도입하여, 사용자의 액션(Intent)과 이에 따라 발생하게되는 이벤트(SideEffect) 를 구분하여 관리하였다.
(ex. UiAction - OnListItemClick, UiEvent - NavigateToDetail)

하지만, Circuit 을 적용하게 될 경우, 이러한 UiAction 을 도입할 수 없는 것으로 현재까진 파악하고 있는데, 이럴 경우 사용자의 액션과 이벤트가 Event 라는 하나의 sealed interface 안에 섞이게 되므로, 명시적으로 구분할 수 없게 된다. 이는 MVI 패턴과는 다른 패턴의 방식이라고 생각이 든다.

공교롭게도, Circuit 의 내부 코드에서도 UiState, UiEvent 라는 표현을 사용하는 것을 확인할 수 있었다.

Circuit 이 적용된 두 프로젝트의 코드들을 확인해봤을 때에도, 사용자 액션과 이벤트를 따로 구분하지 않는 것을 확인할 수 있었는데, 어떻게 이들을 구분할 수 있을지 조금 더 알아보도록 해야겠다.

내용 추가

-> Intent 와 Event 처럼 사용자의 액션, 이에 따라 발생하는 이벤트라고 구분하는 것이 아닌, eventSink, Event 로 구분된다고 생각하면 될 것 같다.

eventSink 에서 sink 는 흡수하다, 받아드리다의 의미로, UI에서 발생한 Event 를 Presenter 가 처리할 수 있도록 연결해주는 콜백 함수로 동작한다.

기존에 정의한 UiAction(Intent) 도 결국에 Event 를 ViewModel 에서 처리할 수 있도록 연결하는 동작을 수행하였으니, 비슷한 역할이라 할 수 있다.

data class State(
    val data: Data,
    val eventSink: (Event) -> Unit  // 이벤트 처리 함수
)

class SomePresenter : Presenter<State> {
    @Composable override fun present(): State {
        return State(data) { event ->
            when (event) {
                is SomeEvent -> handleEvent()
            }
        }
    }
}

달리진 점은 Event 를 처리하는 SharedFlow, Channel 설정을 할 필요가 없어졌다는 점이고, 테스트시 실제 앱처럼 State 내에 포함되어있는 eventSink 로 직접 이벤트를 발생시킬 수 있다.

2. Circuit Navigation 을 적용할 때 feature 모듈간의 의존 관계가 발생..!

presentation 모듈을 각각의 feature 모듈로 분리하여 개발할 경우, 주의 해야할 점 중 하나가 feature 모듈 간의 상호 의존성, 순환 참조가 생기지 않도록 하는 것이다.

그렇기 때문에, 기존의 Compose Navigation 을 사용하는 경우엔, app 모듈 또는 추가적인 feature:main 모듈을 생성하여(bottom navigation 이 존재할 경우 주로 생성, 모든 feature 모듈의 의존성을 가지고 있음) 이 모듈 내에서 navigation 관련한 함수들을 정의하여 사용하였다.(중앙 컨트룰러 역할) 그렇기 때문에 각각의 feature 모듈들은 다른 feature 모듈의 의존성을 가지지 않았다.

위의 방식으로 구현된 프로젝트는 대표적으로 Now In Android, DroidKnights 가 있다.


드로이드나이츠 앱의 feature 모듈 의존 관계

하지만 Circuit 의 경우, 아래의 코드를 보면 알 수 있듯이, feature 모듈간의 의존성을 필요로 한다.


뒤로 가기의 경우, navigator.pop() 함수를 통해 구현하기에, 왔다 ~ 갔다 하는 경우엔 문제가 생기지 않을 것이지만...

화면이 많은 복잡한 앱의 경우 화면 이동에 있어, feature 모듈간의 의존성이 복잡하게 얽히고 설키게 될 문제가 발생할 수 있다. 그러다가 순환 참조가 발생할 수도 있는 것이구...

Now In Android 나 DroidKnights 처럼 중앙 네비게이션 컨트룰러의 방식(feature 모듈 내에 hierarchy 를 만드는 방식)으로 구현할 수 있을지도 생각해봐야겠다.

3. 모든 Screen 을 Scaffold 로 구성해야 하나?(아니다!)

Compose Navigation 을 사용하는 경우엔, 주로 NavHost 를 Scaffold 로 감싸서 구현하다보니, 새로운 NavHost 를 정의(ex. 다른 Activity 를 추가)하지 않는 이상, 최상단에 Scaffold 를 단 한번만 사용하면 되었다. (Scaffold content 의 innerPadding 을 하위 Composable Screen 에 전파)

    Scaffold(
        ...
    ) { innerPadding ->
        BandalartNavHost(
            modifier = Modifier.padding(innerPadding),
            onShowSnackbar = { message ->
                snackbarHostState.showSnackbar(
                    message = message,
                    duration = SnackbarDuration.Short,
                ) == SnackbarResult.ActionPerformed
            },
        )
    }

하지만 Circuit 및 Circuit Navigation 을 사용하는 경우엔, 이러한 구조를 가지지 않는다.

setContent {
	BandalartTheme {
    	val backStack = rememberSaveableBackStack(root = HomeScreen)
        val navigator = rememberCircuitNavigator(backStack)

		CircuitCompositionLocals(circuit) {
        	ContentWithOverlays {
            	NavigableCircuitContent(
                	navigator = navigator,
                    backStack = backStack,
                )
             }
         }
     }
 }

MainActivity 에서 enableEdgeToEdge 함수를 호출하여, WindowInsets 이 활성화 되었다면(Android 15부터는 default 로 활성화), 모든 화면을 Scaffold 를 통해 Composable Screen 을 구성해야만 innerPadding 을 사용하여 화면 내에 컴포넌트가 statusbar, systembar 영역을 침범하는 것을 막을 수 있다.

개인적으로는 이러한 방식은 화면 구성에 있어서 조금 비효율적이라고 생각이 든다.

하지만 이부분에 있어선, 아직 모르는 내용이 많기 때문에, Circuit 의 Navigation 과 UI 컴포넌트에 대한 분석을 이어나가야겠다.

Circuit 의 Navigation 을 분석하는 글을 작성하면서, 모든 화면을 Scaffold 로 구성할 필요없이, 요구조건을 만족할 수 있는 방법을 알아내었다! 의문점 3번은 해결!

또한 BottomSheet 와 관련해서, Circuit 의 Overlay 중 BottomSheetOverlay 라는 컴포넌트가 존재 하는 것을 확인할 수 있었는데, 어떻게 사용하면 되는지, 기존 M3 modalBottomSheet 와 차이점은 무엇인지 학습을 해봐야겠다.

레퍼런스)
https://github.com/ZacSweers/CatchUp/blob/427603e900062b2085210cab7e50d9dce766dd1e/app-scaffold/src/main/kotlin/catchup/app/ui/activity/OrderServicesScreen.kt
https://github.com/chrisbanes/tivi/blob/main/ui/account/src/commonMain/kotlin/app/tivi/account/AccountPresenter.kt
https://developer.android.com/topic/architecture/ui-layer?hl=ko
https://kotlinlang.org/docs/delegation.html
https://github.com/droidknights/DroidKnightsApp
https://github.com/android/nowinandroid
https://developer.android.com/develop/ui/compose/performance/stability/strongskipping?hl=ko

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

0개의 댓글