$30 off During Our Annual Pro Sale. View Details »

Jetpack Compose 상태 및 사이드 효과

TaeHwan
July 14, 2023

Jetpack Compose 상태 및 사이드 효과

Resources:
State in Jetpack Compose codelab → https://goo.gle/compose-state-codelab
Advanced State and Side Effects in Jetpack Compose codelab → https://goo.gle/3Gdhyn5
Side-effects in Compose → https://goo.gle/3KN54oY
UI state production pipeline → https://goo.gle/architecture-uistate-...
Architecting your Compose UI → https://goo.gle/3UonhMN
Consuming flows safely in Jetpack Compose → https://goo.gle/3GwleAS
Other supported types of state → https://goo.gle/compose-supported-types
Compose and other libraries → https://goo.gle/3ZUToor
Save UI state in Compose → https://goo.gle/40Uw2kj
Lifecycle of composable functions → https://goo.gle/3KsEPTB
Thinking in Compose → https://goo.gle/3o2Ie40
Splash screen API → https://goo.gle/3KKXZ8C
Maps Compose → https://goo.gle/3GUfGAn
When should I use derivedStateOf? → https://goo.gle/3GwY11i
Now in Android → https://goo.gle/nia

TaeHwan

July 14, 2023
Tweet

More Decks by TaeHwan

Other Decks in Programming

Transcript

  1. Mobile | Android Jetpack Compose 상태 및 사이드 효과 권태환

    Android Developer / 레몬트리 안녕하세요. 레몬트리에서 퍼핀이란 안드로이드 서비스를 만들고 있는 권태환입니다. 오늘은 Jetpack Compose의 상태 및 사이드 효과에 대한 내용으로 발표를 진행합니다.
  2. Placeholder text. Please replace. A class is like a blueprint,

    which contains instructions for how to create something. An object instance is an actual Dice that’s created from the blueprint. Jetpack Compose의 고급 상태 및 사이드 효과 세션 참고 본 세션은 구글 IO 세션 중 Jetpack Compose의 고급 상태 및 사이드 효과 2023년 버전을 참고한 발표입니다. 상태 관리가 처음이라면 2022년 상태 관리 영상도 있으니 이 영상을 먼저 보시는 걸 추천하고, 구글 문서와 코드랩도 있으니 참고해 주세요.
  3. Placeholder text. Please replace. A class is like a blueprint,

    which contains instructions for how to create something. An object instance is an actual Dice that’s created from the blueprint. Jetpack Compose의 고급 상태 및 사이드 효과 주요 내용 • collectAsStateWithLifecycle • LaunchedEffect • rememberUpdateState • StateHolder • produceState • derivedStateOf 세션 참고 주요 내용을 기반으로 상세 코를 기반으로 설명합니다.
  4. 시작하기 전에 ➔ 컴포즈는 증분으로 동작한다. ➔ remember ◆ key에

    따른 변화 ➔ 리컴포지션? 상태 관리? ◆ 상태 관리 ◆ 리컴포지션 체크 ➔ AAC-ViewModel 기반의 코드 작성 ◆ 상태 관리에서 중요한 정보 컴포즈는 증분으로 동작함을 기억하고 있으시면 도움 됩니다. 기본적으로 증분 처리되므로 기존 UI 처리 형태와는 다르게 달라진 부분을 draw 처리합니다. 이때 상태 관리를 위한 remebmer를 이용하여 처리합니다. 그러면 무엇을 먼저 알아야 할까요? 상태 관리를 이해하고 나야 리컴포지션 카운트도 줄일 수 있습니다.
  5. “collectAsStateWithLifecycle” 먼저 CollectAsStateWithLifeycle. 이름으로 알 수 있지만 라이프 사이클과 관련이

    있다는 걸 바로 직감할 수 있는데요. 컴포즈를 사용하면 라이프 사이클과 코루틴의 연결점이 없는 부분은 없습니다. 이런 부분을 직접 알 필요 없이 자연스럽게 쓸 수 있다는 점이 장점이죠.
  6. collectAsStateWithLifecycle • ViewModel에서 Flow를 통해 데이터 전달 시 활용 ◦

    androidx.lifecycle:lifecycle-runtime-compose:$ lifecycle_version collectAsStateWithLifecycle은 ViewModel을 통해 데이터를 collect 받는 부분을 대신 처리를 도와줍니다.
  7. private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList()) 사용법은 이미 많은 분들이 flow를

    활용하고 있으니 간단합니다. 상태를 보관하는 StateFlow의 시작에 emptyList를 지정합니다.
  8. private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList()) val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()

    그리고 저희는 외부에서 값을 바꾸지 않는 방식으로 View에 전달하니 StateFlow로 변환해서 전달합니다.
  9. private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList()) val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()

    val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle() 힐트를 활용하는 경우는 viewModel을 바로 접근해서 활용할 수 있으니 viewModel에 flow 함수명을 추가하고, collectAsStateWithLifecycle()만 붙여주면 아이템을 지속적으로 관리받을 수 있습니다.
  10. @Composable fun <T> StateFlow<T>.collectAsStateWithLifecycle( lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, minActiveState: Lifecycle.State

    = Lifecycle.State.STARTED, context: CoroutineContext = EmptyCoroutineContext ): State<T> = collectAsStateWithLifecycle( initialValue = this.value, lifecycle = lifecycleOwner.lifecycle, minActiveState = minActiveState, context = context ) collectAsStateWithLifecycle 함수의 시작점엔 모두 기본값으로 채워져있습니다 . 외부에서 주입할 수도 있고, 내부에 기본값을 활용할 수도 있습니다.
  11. @Composable fun <T> StateFlow<T>.collectAsStateWithLifecycle( lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, minActiveState: Lifecycle.State

    = Lifecycle.State.STARTED, context: CoroutineContext = EmptyCoroutineContext ): State<T> = collectAsStateWithLifecycle( initialValue = this.value, lifecycle = lifecycleOwner.lifecycle, minActiveState = minActiveState, context = context ) 여기서 알 수 있는 부분은 라이프 사이클 중 start 시점에 활성화한다는 것입니다.
  12. @Composable fun <T> Flow<T>.collectAsStateWithLifecycle( initialValue: T, lifecycle: Lifecycle, minActiveState: Lifecycle.State

    = Lifecycle.State.STARTED, context: CoroutineContext = EmptyCoroutineContext ): State<T> { return produceState(initialValue, this, lifecycle, minActiveState, context) { lifecycle.repeatOnLifecycle(minActiveState) { if (context == EmptyCoroutineContext) { [email protected] { [email protected] = it } } else withContext(context) { [email protected] { [email protected] = it } } } } } 라이프 사이클과 시점을 설정 받고, 코루틴 컨텍스트를 함께 넘겨받습니다.
  13. @Composable fun <T> Flow<T>.collectAsStateWithLifecycle( initialValue: T, lifecycle: Lifecycle, minActiveState: Lifecycle.State

    = Lifecycle.State.STARTED, context: CoroutineContext = EmptyCoroutineContext ): State<T> { return produceState(initialValue, this, lifecycle, minActiveState, context) { lifecycle.repeatOnLifecycle(minActiveState) { if (context == EmptyCoroutineContext) { [email protected] { [email protected] = it } } else withContext(context) { [email protected] { [email protected] = it } } } } } 코드를 좀 더 집중해서 보면 produceState와 lifecycle repeatOnLifecycle을 통해 라이프사이클 시점이 started인 경우 활성화하고, 실시간으로 값을 pdocudeState를 통해 전달 처리함을 알 수 있습니다. 이 코드 중 repeatOnLifecycle은 이미 라이프 사이클과 flow 활용 시에 흔하게 사용하던 코드죠.
  14. @Composable fun <T> produceState( initialValue: T, vararg keys: Any?, producer:

    suspend ProduceStateScope<T>.() -> Unit ): State<T> { val result = remember { mutableStateOf(initialValue) } @Suppress("CHANGING_ARGUMENTS_EXECUTION_ORDER_FOR_NAMED_VARARGS") LaunchedEffect(keys = keys) { ProduceStateScopeImpl(result, coroutineContext).producer() } return result } 한 단계 더 들어가 보면 LaunchedEffect와 rememeber가 눈에 띕니다.
  15. @Composable fun <T> produceState( initialValue: T, vararg keys: Any?, producer:

    suspend ProduceStateScope<T>.() -> Unit ): State<T> { val result = remember { mutableStateOf(initialValue) } @Suppress("CHANGING_ARGUMENTS_EXECUTION_ORDER_FOR_NAMED_VARARGS") LaunchedEffect(keys = keys) { ProduceStateScopeImpl(result, coroutineContext).producer() } return result } 결국 LaunchedEffect를 활용해 실시간으로 값을 처리하고 있고, 이때의 조건은 keys를 활용합니다. 그리고 저희는 rememeber를 감싸주지 않더라도, produceState를 통해 rememeber가 처리됨을 알 수 있습니다.
  16. LaunchedEffect • 컴포저블 내에서 suspend(코루틴) 함수를 호출하기 위해 사용 •

    LaunchedEffect가 컴포지션을 종료하면 코루틴 취소 • key를 활용한 코루틴 재실행 가능 LaunchedEffect는 앞선 코드에서도 확인할 수 있었는데, 코루틴 함수를 호출하기 위한 블록 형태로 제공합니다. 라이프 사이클에 따라 동작하며, key가 필수 값으로 활용됩니다. 이 키를 통해 코루틴을 다시 시작합니다.
  17. @Composable fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier)

    { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LaunchedEffect(onTimeout) { delay(SplashWaitTime) onTimeout() } } } 이 코드는 영상에 나온 코드의 일부인데 LaunchedEffect 안에 키값을 주입해 줘야 합니다. LaunchedEffect에서는 key에 따라 재시작에 대한 동작을 처리합니다. ViewModel을 사용하니 사실 이런 식의 코드는 작성하지 않겠지만 일단 현재 코드 상태로 보면 이 코드에서는 onTimeout이라는 콜백 함수가 있고, SplashWaitTime을 지정하는데 이 부분이 서버 통신을 가상으로 꾸린 부분입니다. 리컴포지션이 일어나면 키로 등록된 onTimeout의 값에 따라 내부가 실행됩니다. 그리고 일정 시간 후 onTimeout이 호출됩니다.
  18. 문제점을 찾아보자 • onTimeout 람다가 변경되면 LaunchedEffect 재시작. 이 코드는

    문제점이 하나 있는데 LaunchedEffect의 key인 onTimeout 람다를 적용하는 경우 onTimeout 람다가 변경이 일어나면 해당 코드는 재실행됩니다. 구글에서 설명하는 코드는 splash, 단 한 번만 실행되어야 한다는 점을 이야기하면서 onTimeout 람다가 다른 형태로 변경되어도 동작에 문제가 없어야 한다고 합니다.
  19. @Composable fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier)

    { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LaunchedEffect(onTimeout) { delay(SplashWaitTime) onTimeout() } val timeout by rememberUpdatedState(onTimeout) LaunchedEffect(Unit) { delay(SplashWaitTime) // Simulates loading things timeout() } } } LaunchedEffect가 재시작 될 수 있다는건 앱 사용 중 갑자기 Splash 이후의 동작을 할 수 있다는 이야기가 됩니다. 그래서 이걸 해결하려면 onTimeout을 별도의 rememeberUpdateState()를 활용해 처리하고, LaunchedEffect에는 Unit으로 변경하는 작업을 하면 됩니다. 이러면 LaunchedEffect의 key가 Unit으로 고정되니 값이 변경이 되어도 LaunchedEffect는 재실행하지 않게 됩니다.
  20. @Composable @NonRestartableComposable @OptIn(InternalComposeApi::class) fun LaunchedEffect( key1: Any?, block: suspend CoroutineScope.()

    -> Unit ) { val applyContext = currentComposer.applyCoroutineContext remember(key1) { LaunchedEffectImpl(applyContext, block) } } LaunchedEffect 함수에는 최소 1개의 key를 받도록 구성되어 있습니다. 이 키에 따라 코루틴이 다시 시작되는데, rememeber에 key가 달라지면 remember가 새로 동작하는 걸 알 수 있습니다. 이때 LauncedEffect 구현체에 coroutineContext를 함께 넘겨줍니다. 결국 현재 컴포저의 코루틴이 변경될 수 있겠죠.
  21. internal class LaunchedEffectImpl( parentCoroutineContext: CoroutineContext, private val task: suspend CoroutineScope.()

    -> Unit ) : RememberObserver { private val scope = CoroutineScope(parentCoroutineContext) private var job: Job? = null override fun onRemembered() { job?.cancel("Old job was still running!") job = scope.launch(block = task) } override fun onForgotten() { job?.cancel() job = null } override fun onAbandoned() { job?.cancel() job = null } } LaunchedEffect 구현체인데요, 익숙한 캔슬 가능한 코드들이 보입니다.
  22. internal class LaunchedEffectImpl( parentCoroutineContext: CoroutineContext, private val task: suspend CoroutineScope.()

    -> Unit ) : RememberObserver { private val scope = CoroutineScope(parentCoroutineContext) private var job: Job? = null override fun onRemembered() { job?.cancel("Old job was still running!") job = scope.launch(block = task) } override fun onForgotten() { job?.cancel() job = null } override fun onAbandoned() { job?.cancel() job = null } } interface RememberObserver { fun onRemembered() fun onForgotten() fun onAbandoned() } 컴포지션에서 remember가 성공적으로 호출되면 onRemembered가 불러지고, 나머지 두 개는 cancel과 초기화를 위해 사용되는 코드입니다. 내부 코드를 더 추적하면 알 순 있지만 컴포지션 상태에 따라 취소되고, 초기화 되는 부분을 확인해 볼 수 있습니다.
  23. val timeout by rememberUpdatedState(onTimeout) @Composable fun <T> rememberUpdatedState(newValue: T): State<T>

    = remember { mutableStateOf(newValue) }.apply { value = newValue } 바로 내부 코드를 보겠습니다. rememberUpdateState 함수는 newValue를 받고, State를 리턴합니다. 이 코드든 매우 익숙할 수 있는데요.
  24. val timeout by rememberUpdatedState(onTimeout) @Composable fun <T> rememberUpdatedState(newValue: T): State<T>

    = remember { mutableStateOf(newValue) }.apply { value = newValue } // 다른 형태 var timeout by remember { mutableStateOf(onTimeout) } timeout = onTimeout 직접 remember를 활용해 만들 수 있습니다. timeout의 val 대신 var로 바꿔서 사용합니다. 이 두 가지 방법은 사용법에 따라 다르게 사용됩니다. Composable 파라터를 통해 전달 받은 값을 update해야 한다면 rememberUpdateState()를 사용하는편이 좋고, 내부에서의 값의 변화를 탐지하고 싶다면 아래 형태로 작성되어지는게 더 좋겠죠.
  25. val scope = rememberCoroutineScope() scope.launch { scaffoldState.drawerState.open() } 사용법은 아주

    간단합니다. 코루틴이 필요하면 별도로 생성하는 게 아닌 이미 제공되는 rememberCoroutineScope을 사용하면 됩니다.
  26. @Composable inline fun rememberCoroutineScope( crossinline getContext: @DisallowComposableCalls () -> CoroutineContext

    = { EmptyCoroutineContext } ): CoroutineScope { val composer = currentComposer val wrapper = remember { CompositionScopedCoroutineScopeCanceller( createCompositionCoroutineScope(getContext(), composer) ) } return wrapper.coroutineScope } 이 rememberCoroutineScope의 내부도 조금 살펴보면 currentComposer을 사용해서 코루틴 스쿱을 생성하고 이를 사용합니다. 이렇게 생성된 CoroutineScope은 Composer의 라이프 사이클에 따라 동작이 됩니다.
  27. LaunchedEffect vs rememeberCoroutineScope • 두 개 모두 Composer Lifecycle에 따라

    동작 • LaunchedEffect ◦ key를 이용해 하나의 suspend 함수를 처리 • rememberCoroutineScope ◦ click event와 같이 suspend 함수를 실행해야 할 부분에서 활용 두 가지 모두 코루틴을 실행하는 데 사용한다는 공통점이 있지만 LaunchedEffect는 key의 상태 변화에 따라 코루틴을 재실행할 수 있고, 버튼 클릭 등의 이벤트를 통해 코루틴이 실행되어야 한다면 rememberCoroutineScope을 사용하면 됩니다.
  28. State holders • 관리의 용의성을 위해 state holder를 만들어 사용

    ◦ state holder를 감싸는 remember 구현 • Configuration changes에 따른 데이터 유지를 위해 Saver 추가 구현 가능 ◦ 문서 : Saver | Android Developers ◦ save, restore 상속 구현 State holder와 Saver를 함께 사용하는 부분을 영상에서 소개하는데 Configuration changes가 발생했을 때 데이터 유지하고, 코드 관리의 용의 성과 테스트 가능성을 위함이라고 설명합니다.(코드랩도 동일) saver는 savableStateRegistry를 이용하고 있습니다.
  29. “하지만 AAC-ViewModel 사용 중이라면?” 하지만 AAC-ViewModel을 사용하는 경우라면 굳이 직접

    만들어 관리할 필요는 없지만 코드 재사용성을 높일 수 있는 방향성의 코드 작성은 필요합니다.
  30. “AAC-ViewModel 자체가 StateHolder의 역할을 하고 있기에 StateHolder 구현이 필요한지 고민

    필요” 단순 상태 관리라는 점으로 본다면 AAC-ViewModel을 사용하기 때문에 굳이 State holders를 만들 필요는 없습니다. 하지만 값에 대한 처리와 컴포즈 테스트 관점이라면 어느 정도 필요한 부분은 존재합니다. 코드의 관리 관점과 확장성 있는 코드 작성은 구글의 TextField 머트리얼 부분 코드를 살펴보는 걸 추천하는데, 가장 쉬운 방법은 상태를 내부에서 관리하는 것이 아닌 파라미터 형태로 분리하는 것입니다. 이 역시도 모든 걸 파라미터로 분리해야 할 이유는 없는데, 결국 둘 다 고민할 필요는 있습니다.
  31. derivedStateOf • 다른 상태 값의 변화에 따라 특정 상태가 계산되거나

    파생되는 경우 활용 ◦ if 문으로 할 수 있지만 결국 리컴포즈 카운트를 줄이고 싶다면 꼭 사용하세요. • 값이 이전과 동일하다면 이벤트가 발생하지 않음 derivedStateOf는 다른 remember의 상태 값이 변했을 때 특정 상태에 대한 계산하거나 파생에 사용합니다. 보통 이런 코드를 if 문으로 할 수 있지만 리컴포지션이 일어나면 항상 계산되니 불필요한 행동입니다. 값은 이전 값과 동일한 경우 이벤트가 생성되지 않습니다.
  32. val listState = rememberLazyListState() val showButton by remember { derivedStateOf

    { listState.firstVisibleItemIndex > 0 } } 유용하게 사용될 부분은 listState의 값 중 리스트 0번째 아이템을 벗어날 경우의 값이 필요한 경우에 활용될 수 있습니다.
  33. 정리 • remember를 활용한 상태 저장 • key의 상태에 따른

    값의 변화 ◦ LaunchedEffect ◦ DisposableEffect • 컴포저블 라이프 사이클과 관련한 부분을 알아서 처리 ◦ Compose가 편한 이유는 라이프 사이클과 관련한 부분을 알아서 처리해 줍니다. • 리컴포지션 카운트는 결국 상태 관리를 통해 해결 오늘의 정리는 remember를 활용한 상태 저장과 key를 통한 상태 변화가 일어난다는 점입니다. 컴포저블 라이프 사이클과 관련한 부분을 알아서 처리해 주고 있으니 더 편하게 개발합니다.