[Circuit] rememberRetained, produceRetainedState 함수 분석(2)

이지훈·2024년 12월 3일
2

Circuit

목록 보기
3/8
post-thumbnail

서론

딥다이브 글은 언제 작성해도 정말 쉽지 않다.

이전 글을 작성하였을 당시엔, Circuit 의 함수들을 처음 분석해본 것이기 때문에, 솔직히 글을 작성하면서도 이해가 되지 않았던 부분들이 다수 존재하였다. 반성

추가적인 학습을 진행한 현 시점에서, 해당 글의 내용을 보완하기에는 많은 내용이 추가되어 분량 조절 실패가 예상되기에, 새로운 글을 하나 더 작성하는 것이 나을 거라 판단하였다.

글을 읽는 사람에 입장에서 봤을 때, 설명없이 넘어간 부분이 많았다고 생각이 들어, 의문이 들 수 있는 부분들을 선정하여, Q&A 형식으로 글을 써보도록 하겠다.

본론

Q1.

rememberRetained 이 내부적으로 숨겨진 ViewModel 로 구현된다는게 어떤 의미인가요?


  1. rememberRetained - Circuit 에서 제공하는 custom 함수로, recomposition, backstack, configuration change 과정에서 값을 유지, 어느 타입이든 저장 가능하지만, Navigator 나 Context 같이 Memory Leak 을 발생 시킬 수 있는 것들은 저장하면 안됨. Android 에서는 내부적으로 숨겨진 ViewModel 을 통해 구현됨 ..(??)

->

Android 파트의 구현체의 경우, AAC ViewModel 을 상속한, RetainedStateRegistry interface 를 구현한 ContinuityViewModel 을 사용하여 state 에 대한 저장, 복원, 삭제 처리를 구현한다.

AndroidContinuity.kt

internal class ContinuityViewModel : ViewModel(), RetainedStateRegistry {
  private val delegate = RetainedStateRegistryImpl(null)

  override fun consumeValue(key: String): Any? {
    return delegate.consumeValue(key)
  }

  override fun registerValue(
    key: String,
    valueProvider: RetainedValueProvider,
  ): RetainedStateRegistry.Entry {
    return delegate.registerValue(key, valueProvider)
  }

  override fun saveAll() {
    delegate.saveAll()
  }

  override fun saveValue(key: String) {
    delegate.saveValue(key)
  }

  override fun forgetUnclaimedValues() {
    delegate.forgetUnclaimedValues()
  }

  override fun onCleared() {
    delegate.retained.clear()
    delegate.valueProviders.clear()
  }
  
  ...

  object Factory : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
      @Suppress("UNCHECKED_CAST")
      return ContinuityViewModel() as T
    }

    override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
      return create(modelClass)
    }
  }
}

이렇게 구현된 ContinuityViewModel 을 이용하여, RetainedStateRegistry 를 생성하고, 생성된 RetainedStateRegistry 를 통해 rememberRetained 함수가 구현된다.(rememberRetained 함수 내에서 State 를 저장하고 및 복원하는 등의 핵심적인 역할을 수행)

/**
 * Provides a [RetainedStateRegistry]. 
 -> RetainedStateRegistry 를 제공(반환)
 *
 * @param key the key to use when creating the [Continuity] instance.
 * @param factory an optional [ViewModelProvider.Factory] to use when creating the [Continuity]
 *   instance.
 * @param canRetainChecker an optional [CanRetainChecker] to use when determining whether to retain.
 */
@Composable
public fun continuityRetainedStateRegistry(
  key: String = Continuity.KEY,
  factory: ViewModelProvider.Factory = ContinuityViewModel.Factory,
  canRetainChecker: CanRetainChecker = LocalCanRetainChecker.current ?: rememberCanRetainChecker(),
): RetainedStateRegistry {
  @Suppress("ComposeViewModelInjection")
  val vm = viewModel<ContinuityViewModel>(key = key, factory = factory)

  remember(vm, canRetainChecker) {
    object : RememberObserver {
      override fun onAbandoned() = saveIfRetainable()

      override fun onForgotten() = saveIfRetainable()

      override fun onRemembered() {
        // Do nothing
      }

      fun saveIfRetainable() {
        if (canRetainChecker.canRetain(vm)) {
          vm.saveAll()
        }
      }
    }
  }
  
  ...

  return vm
}

Q2.

rememberRetained 이 Configuration Change 발생 시, State 를 유지 하는 방식은 어떻게 동작하나요?(코드 레벨로 설명해주세요)


Configuration change 발생 시
1. onForgotten() 호출
2. Activity 및 Fragment 재생성
3. 새 Composition 에서 onRemembered() 호출
4. RetainedStateRegistry 에서 값 복원


-> 직접적인 Configuration 관련 처리를 확인해보기 위해선, retain(State 를 유지, 복원)할 수 있는지를 확인하는 역할을 수행하는 CanRetainChecker 함수의 구현체를 확인해보면 된다.

RememberRetained.kt

  ... 
  
  // RememberRetained 내에서 canRetainChecker 가 사용되는 부분 
  val canRetainChecker = LocalCanRetainChecker.current ?: rememberCanRetainChecker()
  // remember 의 key 로써 사용됨 
  val holder =
    remember(canRetainChecker) {
      // value is restored using the retained registry first, the saveable registry second, or
      // created via [init] lambda third
      @Suppress("UNCHECKED_CAST")
      val retainedRestored =
        retainedStateRegistry.consumeValue(finalKey) as? RetainableSaveableHolder.Value<T>
      val saveableRestored =
        saveableStateRegistry?.consumeRestored(finalKey)?.let { saver.restore(it) }
      val finalValue = retainedRestored?.value ?: saveableRestored ?: init()
      val finalInputs = retainedRestored?.inputs ?: inputs
      // RetainedSaveableHolder 객체를 생성
      RetainableSaveableHolder(
        retainedStateRegistry = retainedStateRegistry,
        canRetainChecker = canRetainChecker,
        saveableStateRegistry = saveableStateRegistry,
        saver = saver,
        key = finalKey,
        value = finalValue,
        inputs = finalInputs,
        hasBeenRestoredFromRetained = retainedRestored != null,
      )
    }
  val value = holder.getValueIfInputsAreEqual(inputs) ?: init()
  SideEffect {
    holder.update(retainedStateRegistry, saveableStateRegistry, saver, finalKey, value, inputs)
  }

CanRetainChecker.android.kt

// interface 

/** Checks whether or not we can retain, usually derived from the current composable context. */
@Stable
public fun interface CanRetainChecker {
  public fun canRetain(registry: RetainedStateRegistry): Boolean

  public companion object {
    public val Always: CanRetainChecker = CanRetainChecker { _ -> true }
  }
}

public val LocalCanRetainChecker: ProvidableCompositionLocal<CanRetainChecker?> =
  staticCompositionLocalOf {
    null
  }

@Composable public expect fun rememberCanRetainChecker(): CanRetainChecker
// impl

/** On Android, we retain only if the activity is changing configurations. */
@Composable
public actual fun rememberCanRetainChecker(): CanRetainChecker {
  val context = LocalContext.current
  val activity = remember(context) { context.findActivity() }
  return remember {
    CanRetainChecker { registry ->
      if (registry is ContinuityViewModel) {
        // If this is the root Continuity registry, only retain if the Activity is changing
        // configuration
        activity?.isChangingConfigurations == true
      } else {
        // Otherwise we always allow retaining for 'child' registries
        true
      }
    }
  }
}

private tailrec fun Context.findActivity(): Activity? {
  return when (this) {
    is Activity -> this
    is ContextWrapper -> baseContext.findActivity()
    else -> null
  }
}

configuration change 가 발생했는지를 확인하기 위해서, context 를 통해, 현재 컴포저블이 속해있는 activity 를 가져온다.

activity 의 isChangeConfigurations 프로퍼티를 통해 configuration change 가 발생했는지 판단할 수 있다.
누가 isChangeConfigrations 프로퍼티의 flag 를 변경하는지는 아래의 글을 확인해보면 그 해답을 알 수 있다.
viewmodel이 구성변경에도 인스턴스를 유지하는 이유

if (registry is ContinuityViewModel) {
	// Root registry 인지 확인 (configuration change 가 발생했을 때만 true (State 유지), 그 외에 일반적인 화면 종료에서는 false(State 제거)
    activity?.isChangingConfigurations == true
} else {
	// 아니면(child registry) 전부 true (State 유지)
    true
}

위의 if 문과 주석을 통해 알 수 있는 정보로는
registry, 즉 RetainedStateRegistry 는 부모 자식 관계(hierarchy)가 존재한다는 것이다.

RetainedSaveableHolder 클래스의 함수에서도 이를 확인해볼 수 있었는데,

RememberRetained.kt

private class RetainableSaveableHolder<T>(
  private var retainedStateRegistry: RetainedStateRegistry?, // 생성자로 retainedStateRegistry 를 받음
  private var canRetainChecker: CanRetainChecker,
  private var saveableStateRegistry: SaveableStateRegistry?,
  private var saver: Saver<T, Any>,
  private var key: String,
  private var value: T,
  private var inputs: Array<out Any?>,
  private var hasBeenRestoredFromRetained: Boolean = false,
) : RetainedValueProvider, RememberObserver, SaverScope {
	...
    
	fun saveIfRetainable() {
    	val v = value ?: return
    	val reg = retainedStateRegistry ?: return // reg -> 부모 registry

    	if (!canRetainChecker.canRetain(reg)) {
        	retainedStateEntry?.unregister()
        	when (v) {
            	is RememberObserver -> v.onForgotten()
            	is RetainedStateRegistry -> {  // 자식 registry
                	// 자식 registry 의 값들을 먼저 저장
                	v.saveAll()
                	// 그 후 unclaimed 값들 정리
                	v.forgetUnclaimedValues()
            	}
        	}
    	} else if (v is RetainedStateRegistry) { // 자식 registry
        	// 자식 RetainedStateRegistry 의 값들을 저장하고
        	v.saveAll()
        	// 부모 registry 에 저장
        	reg.saveValue(key)
    	}
	}
    ...
}

각 Composable 이 rememberRetained 를 호출할 때 생성되는 RetainableSaveableHolder 가 생성자로 받는(주입된) RetainedStateRegistry 를 통해 State 를 관리하며, 이 State 값들은 부모 RetainedStateRegistry 에 저장된다.

자식 registry 들의 경우, 부모 registry 가 살아있는 한 State 를 유지할 필요가 있기 때문에
(Root registry 가 Lifecycle 를 관리-> 자식 registry 들의 경우 별도의 Lifecycle 체크 없이 부모 Lifecycle 를 따르면 됨)
if 문에서 true 를 반환한다고(State 유지) 파악하였다.

Root registry(ContinuityViewModel) 은 Activity(정확힌 ComponentActivity) 의 ViewModelStore 에 저장되어 관리된다.

ComponentActivity 는 ViewModelStoreOwner 인터페이스를 구현하고 있어, ViewModelStore 를 소유하며, 따라서 ContinuityViewModel 은 CompoenentActivity 의 Lifecycle 을 따른다.

Q3.

rememberRetained, rememberRetainedSaveable 의 neverSave, autoSaver 파라미터는 어떤 차이가 있나요?


@OptIn(DelicateCircuitRetainedApi::class)
@Composable
public fun <T : Any> rememberRetained(vararg inputs: Any?, key: String? = null, init: () -> T): T =
  rememberRetained(inputs = inputs, saver = neverSave(), key = key, init = init)

/**
 * A simple proxy to [rememberRetained] that uses the default [autoSaver] for [saver] and a more
 * explicit name.
 *
 * @see rememberRetained
 */
@OptIn(DelicateCircuitRetainedApi::class)
@Composable
public fun <T : Any> rememberRetainedSaveable(
  vararg inputs: Any?,
  saver: Saver<T, out Any> = autoSaver(), 
  key: String? = null,
  init: () -> T,
): T = rememberRetained(inputs = inputs, saver = saver, key = key, init = init)

rememberRetained 구현체 내에 rememberRetainedSaveable 이라는 함수가 존재하는 것을 확인할 수 있었는데, 이 함수의 역할과 동작원리도 살펴도록 하겠다.

위에 코드를 통해 알 수 있듯, rememberRetained 은 saver 자리에 neverSave() 가 들어가고, rememberRetainedSaveable 에서는 autoSaver() 가 들어가는 차이점이 존재한다.


-> neverSave 와 autoSaver 의 실제 구현체를 확인해보고 나서, 동작이 어떻게 진행되는지, 어떤 차이가 발생하는지 알아보자.

그 전에 Compose runtime 의 Saver 의 대한 설명은 공식 문서나, 아래 글을 참고해보면 좋을 것 같다.
Compose 의 Remember, RememberSaveable 정복하기

neverSave 구현체(circuit)

RememberRetained.kt

import androidx.compose.runtime.saveable.Saver

...
private val NoOpSaver = Saver<Any?, Any>({ null }, { null })

// neverSave
@Suppress("UNCHECKED_CAST")
private fun <Original, Saveable : Any> neverSave() = NoOpSaver as Saver<Original, Saveable>

neverSave() 함수는 NoOpSaver 를 반환한다
NoOpSaver 는 save 와 restore 모두 항상 null 을 반환하는 Saver 이다.

autoSaver 구현체(compose runtime)

Saver.kt

fun <Original, Saveable : Any> Saver(
    save: SaverScope.(value: Original) -> Saveable?,
    restore: (value: Saveable) -> Original?
): Saver<Original, Saveable> {
    return object : Saver<Original, Saveable> {
        override fun SaverScope.save(value: Original) = save.invoke(this, value)

        override fun restore(value: Saveable) = restore.invoke(value)
    }
}

fun interface SaverScope {
    /**
     * What types can be saved is defined by [SaveableStateRegistry], by default everything which can
     * be stored in the Bundle class can be saved.
     */
    fun canBeSaved(value: Any): Boolean
}

/**
 * The default implementation of [Saver] which does not perform any conversion.
 *
 * It is used by [rememberSaveable] by default.
 *
 * @see Saver
 */
fun <T> autoSaver(): Saver<T, Any> =
    @Suppress("UNCHECKED_CAST")
    (AutoSaver as Saver<T, Any>)

private val AutoSaver = Saver<Any?, Any>(
    save = { it },
    restore = { it }
)

autoSaver() 함수는 AutoSaver 를 반환한다.
AutoSaver 는 값을 변환 없이 그대로 저장/복원하는 Saver 이다.
save 와 restore 모두 입력값을 그대로 반환한다.

이제, rememberRetained 내에서의 동작을 확인해보면

RememberRetained.kt

  // RetainableSaveableHolder 내 에서
private var retainedStateEntry: RetainedStateRegistry.Entry? = null
private var saveableStateEntry: SaveableStateRegistry.Entry? = null
  
private val valueProvider = {
   with(saver) { save(requireNotNull(value) { "Value should be initialized" }) }
}

...

private fun registerSaveable() {
   val registry = saveableStateRegistry
   require(saveableStateEntry == null) { "entry($saveableStateEntry) is not null" }
   if (registry != null) {
       // valueProvider가 null을 반환하므로 실제 저장되지 않음
       registry.requireCanBeSaved(valueProvider())
       saveableStateEntry = registry.registerProvider(key, valueProvider)
   }
}

즉 rememberRetained 의 경우 saver 로 NoOpSaver 를 사용하므로, saveableStateEntry 가 null 이 되어 SaveableStateRegistry 관련 동작이 무시된다.

따라서 rememberRetainedSaveable 에서만 SaveableStateRegistry 를 사용하고, rememberRetained 에서는 사용되지 않는다.

Q4.

takahirom 님께서 작성하신 Circuit rememberRetained 의 포인트 정리 글 번역 부분에서 이해가 안되는 부분이 있어요


Screen A-> Screen B 로의 화면 이동이 있는 경우를 기준으로 설명

1. A -> B 화면 이동할 때, 왜 A 의 State 가 사라지지 않나요?

remember 의 onForgotton 이 호출되지만, 사라지지 않는 이유는,
Circuit 의 backstack 에 있는 경우 State 를 제거하지 않는 처리가 있기 때문입니다.(??)


-> Circuit 의 backstack 관련 코드를 확인해보도록 하자.

ViewModelBackStackRecordLocalProvider.kt

package com.slack.circuit.backstack

// BackStackRecordLocalProvider.android.kt
internal actual val defaultBackStackRecordLocalProviders:
List<BackStackRecordLocalProvider<BackStack.Record>> =
listOf(ViewModelBackStackRecordLocalProvider)  

// ViewModelBackStackRecordLocalProvider.kt
internal class BackStackRecordLocalProviderViewModel : ViewModel() {
private val owners = mutableMapOf<String, ViewModelStore>()

internal fun viewModelStoreForKey(key: String): ViewModelStore =
  owners.getOrPut(key) { ViewModelStore() }
}

@Composable
override fun providedValuesFor(record: BackStack.Record): ProvidedValues {
val containerViewModel = viewModel<BackStackRecordLocalProviderViewModel>()
val viewModelStore = containerViewModel.viewModelStoreForKey(record.key)
val activity = LocalContext.current.findActivity()

// configuration change 가 아닐 때만 ViewModelStore 제거
val observer = remember(record, viewModelStore) {
  NestedRememberObserver {
    if (activity?.isChangingConfigurations != true) {
      containerViewModel.removeViewModelStoreOwnerForKey(record.key)?.clear()
    }
  }
}
...

}

우선 Record 라는 키워드가 모호하므로, 무엇을 의미하는 것인지 확인해보았다.

BackStack.kt

  @Stable
  public interface Record {
    /**
     * A value that identifies this record uniquely, even if it shares the same [screen] with
     * another record. This key may be used by [BackStackRecordLocalProvider]s to associate
     * presentation data with a record across composition recreation.
     *
     * [key] MUST NOT change for the life of the record.
     */
    public val key: String

    /** The [Screen] that should present this record. */
    public val screen: Screen

    /**
     * Awaits a [PopResult] produced by the record that previously sat on top of the stack above
     * this one. Returns null if no result was produced.
     *
     * @param key The key that was used to tag the result. This ensures that only the caller that
     *   requested a result when pushing the previous record can receive it.
     */
    public suspend fun awaitResult(key: String): PopResult?
  }

The [Screen] that should present this record.

Record 는 각각의 화면(+ 고유한 key)을 의미 한다.


Circuit 은 BackStack의 각 Record(화면) 마다 별도의 ViewModelStore 를 관리한다.
이를 통해 각각의 BackStackEntry 는 자신만의 ViewModelStore 를 가지며, Cofiguration change 발생시 ViewModelStore 를 유지한다.
그 외에 일반적인 BackStack 의 pop 발생시 ViewModeStore 를 제거한다.

그 결과 rememberRetained 로 저장된 State 들이 BackStack 의 Lifecycle 에 맞춰 관리된다.

ViewModel 을 사용하지 않는 아키텍처지만, Android 구현체의 내부에선 정말 알뜰살뜰하게 AAC ViewModel 을 사용하는 것을 확인할 수 있다.
암시적으로 뷰모델을 사용한다는 표현이 더 적절할 것 같다.

2. configuration change 시 왜 State 가 사라지지 않나요?

remember 의 onForgotton 이 호출되지만 사라지지 않는 이유는,
activity 를 사용하여, isChangingConfigurations 를 확인하고, configuration changing 중이면 State 를 제거하지 않기 때문입니다.


-> 바로 위에서 이미 설명한 부분이기 때문에 Pass.


3. B 에서 A 로 뒤로가기 버튼 등으로 돌아왔을 때, B 의 State 는 왜 사라지나요?

remember 의 onForgotton 이 호출되고, backstack 에도 없으며, configuration change 상황도 아니기 때문에 State 는 제거됩니다.

-> backstack 에도 더 이상 남아있지 않기 때문에, 제거 ㅇㅇ


4. 화면 회전 시 다른 Composable 이 설정되었을 때 왜 State 가 사라지나요?

remember 의 onForgotton 이 호출되고, configuration change 중 이기 때문에 일단 State 가 그대로 남아있습니다.
하지만, LaunchedEffect 에서 frame 이후에(??) 현재 Composition 에 없는 state 를 제거하는 처리가 있어서 결국 지워집니다.


-> 해당 설명에 대응되는 코드를 확인해보면 알 수 있다.

AndroidContinuity.kt

import androidx.compose.runtime.withFrameNanos

@Composable
public actual fun continuityRetainedStateRegistry(
	key: String,
	canRetainChecker: CanRetainChecker,
): RetainedStateRegistry =
	continuityRetainedStateRegistry(key, ContinuityViewModel.Factory, canRetainChecker)

/**
* Provides a [RetainedStateRegistry].
*
* @param key the key to use when creating the [Continuity] instance.
* @param factory an optional [ViewModelProvider.Factory] to use when creating the [Continuity]
*   instance.
* @param canRetainChecker an optional [CanRetainChecker] to use when determining whether to retain.
*/
@Composable
public fun continuityRetainedStateRegistry(
	key: String = Continuity.KEY,
    factory: ViewModelProvider.Factory = ContinuityViewModel.Factory,
	canRetainChecker: CanRetainChecker = LocalCanRetainChecker.current ?: rememberCanRetainChecker(),
): RetainedStateRegistry {
	@Suppress("ComposeViewModelInjection")
	val vm = viewModel<ContinuityViewModel>(key = key, factory = factory)

	...

	// 이 부분!!!
	LaunchedEffect(vm) {
  		withFrameNanos {} // 프레임이 완료될 때까지 대기 
  
  		// 프레임 그리기가 완료된 후에 실행 
  		// This resumes after the just-composed frame completes drawing. Any unclaimed values at this
  		// point can be assumed to be no longer used
  		vm.forgetUnclaimedValues()
	}

	return vm
}

withFrameNanos 함수를 사용하여, Composition 이 적용되고, UI 가 그려졌을 때, 더 이상 사용되지 않는 값들을 정리하는 함수가 실행되는 것을 확인할 수 있다.

withFrameNanos 함수는 새로운 프레임이 요청될 때까지 대기했다가, 프레임이 준비되면 해당 프레임의 시간을 나노초 단위로 제공받아 작업을 수행하는 함수이다. Compose Runtime 에서 제공하는 함수로, Coroutine 을 통해 구현되어있다.(Android 플랫폼 의존성을 가지지 않는다.)

package androidx.compose.runtime

import kotlin.coroutines.coroutineContext

@OptIn(ExperimentalComposeApi::class)
suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R =
    coroutineContext.monotonicFrameClock.withFrameNanos(onFrame)

결론

이전 글에서 설명이 부족했다고 생각된 부분들에 대해 보충을 해보았다. Circuit 을 학습하고, 프로젝트에 적용하는데에 있어 조금이나마 도움이 되길 바란다.

잘못된 내용이나, 추가적인 질문 사항이 있다면 덧글 부탁드립니다.
확인 후 내용 수정 및 답변 작성해보도록 하겠습니다.

P.S

  1. 이전 글에서 rememberRetained 를 이해하는데 도움을 받았던, takahirom 님의 글을 읽었을 때, 인상 깊었던 파트가 있다.

    Circuit 레포지토리에 작성된 테스트 코드를 먼저 읽어보며, 확인하고자 하는 함수의 동작 방식을 파악하는 파트였다.

    테스트 코드를 잘 작성해둘 경우, 테스트 코드의 본래 목적인 기능 검증의 역할 뿐만 아니라, 동장 방식에 대한 문서(문서화), 어떻게 사용해야하는지에 대한 예제의 역할까지 할 수 있다는 것을 알게되었다. 세마리의 토끼

    라이브러리의 경우 해당 라이브러리를 사용하려는 사람들에게 유용한 학습 자료로 쓰일 수 있기에, 다시 한번 테스트 코드 작성의 중요성을 깨달을 수 있었다.

    또한, 앞으로 라이브러리를 사용할 일이 생길 경우, 테스트 코드가 작성되어있다면, 어떤 식으로 동작하는지 먼저 확인해봐도 좋을 것 같다.

  2. Circuit 의 rememberRetained 함수의 동작 방식을 파악하기 위해선, ViewModel 이 어떻게 Activity 의 configuration change 에도 살아남을 수 있는지 그 내부 동작 방식을 먼저 알아야할 필요가 있었다. 라이브러리의 내부 동작 방식을 이해하기 위해선, Android 프레임워크의 기본 동작 방식들의 대한 학습이 선행되어야 함을 뼈저리게 느꼈다.

레퍼런스)
https://github.com/slackhq/circuit
https://qiita.com/takahirom/items/5b18d5d9f310e1bd1957
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/MonotonicFrameClock.kt;bpv=1;bpt=0
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/runtime/runtime-saveable/src/commonMain/kotlin/androidx/compose/runtime/saveable/Saver.kt?q=file:androidx%2Fcompose%2Fruntime%2Fsaveable%2FSaver.kt%20class:androidx.compose.runtime.saveable.Saver
https://everyday-develop-myself.tistory.com/357
https://proandroiddev.com/composable-scoped-viewmodel-an-interesting-experiment-b982b86d84cd
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/ComponentActivity.kt?q=file:androidx%2Factivity%2FComponentActivity.kt%20class:androidx.activity.ComponentActivity

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

0개의 댓글