Side-effect 란?
• ISO/IEC 14882
• Accessing an object designated by a volatile lvalue,
modifying an object, calling a library I/O function, or
calling a function that does any of those operations are
all side effects, which are changes in the state of the
execution environment.
• 쉽게 말해서
• 실행 중에 어떤 객체에 접근해서
변화가 일어나는 행위
(라이브러리 등 I/O, 객체 변경 등)
• X = 3 + 4
Side-effect
• Composable Scope 외부에서 일어나
는 앱의 상태 변화
• 이상적인 Composable 은
side-effect 에 대해서 free 해야 함
• 하지만,
필요한 경우, 적절한 사용이 필요
• Ex) 일회성 이벤트, 특정 조건 시 다른
화면으로 Navigate 등
• 보통 Controlled Env. 에서 실행
in Compose
Effect API
컴포즈에서 Side-effect 가 필요할 때
• 기본적인 Compose 의 컨셉은 side-effect 에 대해 Free 함을 전제
• Effect API 를 사용 : 예측 가능한 범위에서 side-effect 를 실행
• Effect 는 Composable 함수이며 UI 를 emit 하지 않음
https://developer.android.com/jetpack/compose/mental-model
🚀LaunchedEffect
• suspend 함수를 Composable 내
에서 안전하게 호출하기 위해 사용
• 컴포지션 진입 시 전달된 블록을 코루
틴으로 실행
• Ex) Scaffold 안에서 Snackbar
suspend 함수를 Composable Scope 에서 실행 시키기
@Composable
fun MyScreen(
state: UiState<List<Movie>>,
scaffoldState: ScaffoldState = rememberScaffoldState()
) {
if (state.hasError) {
LaunchedEffect(scaffoldState.snackbarHostState) {
scaffoldState.snackbarHostState.showSnackbar(
message = "Error message",
actionLabel = "Retry message"
)
}
}
Scaffold(scaffoldState = scaffoldState) {
/* ... */
}
}
rememberCoroutineScope
• vs `LaunchedEffect`
• composable 영역이 아닌 곳에서 코
루틴을 수행
• Composition 을 벗어나면 자동으로
취소되는 스코프
• Composable 함수이며, 호출된
Composition 지점에 bind 되는
CoroutineScope 를 반환
composable 외부에서의 코루틴
@Composable
fun MoviesScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {
// Composition-aware scope
val scope = rememberCoroutineScope()
Scaffold(scaffoldState = scaffoldState) {
Column {
/* ... */
Button(
onClick = {
// Create a new coroutine in the event handler to show a snackbar
scope.launch {
scaffoldState.snackbarHostState.showSnackbar("Something happened!")
}
}
) {
Text("Press me")
}
}
}
}
⭐ rememberUpdatedState
• 상태 값이 바뀌는 경우 재실행되면 안
되는 하나의 effect 안에서 값을 참조
하는 방법
• LaunchedEffect 의 key 로 상수값
을 설정하여 호출된 곳의 수명 주기를
따르도록 할 수 있음
• rememberUpdatedState 를 사용
하여 상위 컴포저블의 최신 상태 값을
유지할 수 있음
최신의 상태를 업데이트 받는 State
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// This will always refer to the latest onTimeout function that
// LandingScreen was recomposed with
val currentOnTimeout by rememberUpdatedState(onTimeout)
// Create an effect that matches the lifecycle of LandingScreen.
// If LandingScreen recomposes, the delay shouldn't start again.
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}
/* Landing screen content */
}
DisposableEffect
• key 의 변경 or Composable 이
Composition 을 벗어나는 경우 clean-
up 이 필요한 side effect.
• key 등이 변경되는 경우 dispose 로 정리
해주며, 다시 실행
• Ex) lifecycleOwner 가 변경되는 경우 새
로운 lifecycleOwner 로 실행, event
observer 등록/해제 등.
• 반드시, 내부 마지막 절로 `onDispose`
를 작성 해야함.
리소스 Clean-up 이 필요한 Effect
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Send the 'started' analytics event
onStop: () -> Unit // Send the 'stopped' analytics event
) {
// Safely update the current lambdas when a new one is provided
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// If `lifecycleOwner` changes, dispose and reset the effect
DisposableEffect(lifecycleOwner) {
// Create an observer that triggers our remembered callbacks
// for sending analytics events
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
// Add the observer to the lifecycle
lifecycleOwner.lifecycle.addObserver(observer)
// When the effect leaves the Composition, remove the observer
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
/* Home screen content */
}
SideEffect
• Compose 상태를 non-Compose
상태에 게시
• Composition이 완료되고 호출됨
• Ex) FirebaseAnalytics 를 현재 사용
자의 userType 으로 업데이트 하여 메
타 데이터로 첨부
• 참고) return 값을 갖는
Composable 은 일반 kotlin 함수와
동일한 naming 규칙을 사용
Publish Compose State 👉 non-Compose State
@Composable
fun rememberAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
/* ... */
}
// On every successful composition, update FirebaseAnalytics with
// the userType from the current User, ensuring that future analytics
// events have this metadata attached
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
produceState
• Composition 으로 값을 변환된 State 로
push 하는 Coroutine 을 Launch
• non-Compose State 를 Compose State
로 변환 하고자 할 때 (Flow, LiveFata 또는
RxJava 등의 구독 기반 상태들)
• 생산자(producer) 는 Composition 에 진입
하면 시작되고, Composition 에서 벗어나는
경우 취소된다.
• 반환된 State는 합쳐지므로(Conflates), 동일
값이 설정되는 경우에는 re-composition 이
트리거되지 않음
Convert non-Compose State 👉 Compose State // produceState 를 사용하여 네트워크에서 이미지를 로드하는 방법
@Composable
fun loadNetworkImage(
url: String,
imageRepository: ImageRepository
): State<Result<Image>> {
// Creates a State<T> with Result.Loading as initial value
// If either `url` or `imageRepository` changes, the running producer
// will cancel and will be re-launched with the new inputs.
return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
// In a coroutine, can make suspend calls
val image = imageRepository.load(url)
// Update State with either an Error or Success result.
// This will trigger a recomposition where this State is read
value = if (image == null) {
Result.Error
} else {
Result.Success(image)
}
}
}
produceState
• Flow 를 State 로 변경 시
produceState 를 사용하고 있음
• LiveData 의 경우 observer 를
붙이고 떼는 작업을 위해
DisposableEffect 를 사용하고 있음
Convert non-Compose State 👉 Compose State
@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 }
}
}
@Composable
fun <R, T : R> LiveData<T>.observeAsState(initial: R): State<R> {
val lifecycleOwner = LocalLifecycleOwner.current
val state = remember { mutableStateOf(initial) }
DisposableEffect(this, lifecycleOwner) {
val observer = Observer<T> { state.value = it }
observe(lifecycleOwner, observer)
onDispose { removeObserver(observer) }
}
return state
}
⭐DerivedStateOf
• 특정 상태가 다른 State Object 로 부
터 계산 되었거나, 파생된 경우 사용
• 계산에 사용된 상태 중 하나가 변경 될
때마다 연산이 발생 함을 보장
• 핵심: UI 를 업데이트 하려는 것보다
State 또는 key 가 더 많이 변경 될 때
• 마지막에 더 자세히..!
하나 또는 여러 상태 객체를 다른 상태로 변환
// Calculate high priority tasks only when the todoTasks or highPriorityKeywords
// change, not on every recomposition
val highPriorityTasks by remember(highPriorityKeywords) {
derivedStateOf { todoTasks.filter { it.containsWord(highPriorityKeywords) } }
}
@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {
val todoTasks = remember { mutableStateListOf<String>() }
val highPriorityTasks by remember(highPriorityKeywords, derivedStateOf) {
todoTasks.filter { it.containsWord(highPriorityKeywords) }
}
Box(Modifier.fillMaxSize()) {
LazyColumn {
items(highPriorityTasks) { /* ... */ }
items(todoTasks) { /* ... */ }
}
/* Rest of the UI where users can add elements to the list */
}
}
snapshotFlow
• State<T> 객체를 cold Flow 로 변경
• collected 되면 블록을 실행, 읽어진
State 객체 상태를 emit
• value 가 이전과 동일하면 emit 하지
않음
• Ex) 목록의 첫 번째 항목을 지나서 스크
롤 되는 경우 (변경 시 마다 한번만 처리)
• 또한 Flow 연산자의 이점을 활용할 수
있게됨.
Compose State 👉 Flow val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
LaunchedEffect(listState) {
snapshotFlow { listState.
fi
rstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.
fi
lter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
Restarting Effect
• Effect API 함수 몇몇은
가변 인자로 key 를 받을 수 있음
• key 가 변경되면 실행중인
effect 를 cancle 및 새로운 effect 를 실행
• 일반적으로 effect 블록에 사용되는
가변 및 불변 변수는 effect api에
키로 추가해야 함
• 또는 변수 변경으로 인해 effect 가
재시작되지 않아야 하는 경우에는
rememberUpdatedState 로 래핑하여
사용 해야 함.
Effect 를 다시 시작 시키기
EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Send the 'started' analytics event
onStop: () -> Unit // Send the 'stopped' analytics event
) {
// These values never change in Composition
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
/* ... */
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
🔑Constants as keys
• Effect 의 key 로 상수를 사용하면
호출 지점의 수명 주기를 따르도록
할 수 있다.
• ⚠ 하지만 LaunchedEffect(true)
같은 코드는 while(true) 만큼이나
사용 시 주의해야 함.
(꼭 필요한 적합한 경우만 사용 하자)
Effect 의 key 로 상수 사용
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}
DerivedStateOf 를 사용하기 적합한 사례
• 입력과 출력 결과 사이에 변동이 큰 경우
• 몇 가지 예시 사례
• 스크롤 값이 임계값을 넘었는지 관찰 (scrollPosition > 0)
• 리스트의 아이템이 임계값 보다 큰지 (item > 0)
• format 의 유효성 검사 (username.isValid())
🙋FAQ
• Composable 내부에서는 보통 re-
composition 시 유지해야 하므로
remember 로 래핑
• 또는 ViewModel 등 활용
DerivedStateOf 를 remember 해야 하나요?
@Composable
fun MyComp() {
// We need to use remember here to survive recomposition
val state = remember { derivedStateOf { … } }
}
class MyViewModel: ViewModel() {
// We don't need remember here (nor could we use it) because the ViewModel is
// outside of Composition.
val state = derivedStateOf { … }
}
🙋FAQ
• remember(key) 와
derivedStateOf 의 차이는
re-composition 의 횟수와 관련있음
• Case 1
• 불필요한 re-composition을 버퍼링
• Case 2
• 입력=출력=re-composition
remember(key) 와 derivedStateOf 의 차이점?
val result = remember(state1, state2) { calculation(state1, state2) }
val result = remember { derivedStateOf { calculation(state1, state2) } }
val isEnabled = lazyListState.firstVisibileItemIndex > 0
val isEnabled = remember {
derivedStateOf { lazyListState.firstVisibleItemIndex > 0 }
}
Case 1
Case 2
val output = remember(input) { expensiveCalculation(input) }
🙋FAQ
• DerivedStateOf 는 Compose
State 객체를 읽을 때만 업데이트 됨
• DerivedStateOf 가 생성 될 때 내부
에서 읽은 다른 모든 변수는 초기 값을
캡처하게 됨
• 연산 시 이런 변수를 사용해야 한다면
remember의 key 로 제공 해야 함
remember(key)와 DerivedStateOf 를 동시에 사용해야할 때?
@Composable
fun ScrollToTopButton(lazyListState: LazyListState, threshold: Int) {
// There is a bug here
val isEnabled by remember {
derivedStateOf { lazyListState.
fi
rstVisibleItemIndex > threshold }
}
Button(onClick = { }, enabled = isEnabled) {
Text("Scroll to top")
}
}
https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b
https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b
🙋FAQ
• remember(key) 를 사용하여 해결
remember(key)와 DerivedStateOf 를 동시에 사용해야할 때?
@Composable
fun ScrollToTopButton(lazyListState: LazyListState, threshold: Int) {
val isEnabled by remember(threshold) {
derivedStateOf { lazyListState.
fi
rstVisibleItemIndex > threshold }
}
Button(onClick = { }, enabled = isEnabled) {
Text("Scroll to top")
}
}
https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b
🙋FAQ
• 결과를 연산하기 위해 사용되는 다중
상태가 있는 경우
• 오버헤드 발생 가능
• 결론 :
UI update < state, key update
시에 사용 하는 것이 좋음
여러 상태를 합치기 위해 DerivedStateOf 는 적합한가?
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
// This derivedStateOf is redundant
val fullName = remember { derivedStateOf { "$firstName $lastName" } }
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
val fullName = "$firstName $lastName"
References
• https://developer.android.com/jetpack/compose/side-effects
• Android UI Development with Jetpack Compose, Thomas Kunneth
• https://developer.android.com/jetpack/compose/mental-model
• https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-
derivedstateof-63ce7954c11b
• https://hyeonk-lab.tistory.com/43
• https://romannurik.github.io/SlidesCodeHighlighter/
• https://tourspace.tistory.com/412
•