이번에는 1편에 이어서 Compose의 부수효과를 알아보도록 하자!
# DisposableEffect
✅ 키가 변경되거나 컴포저블이 컴포지션을 종료한 후 정리해야 하는 부수 효과를 처리해 주는 API
- Composable의 생명주기에 맞춰 정리되어야 하는 리스너나 작업이 있는 경우에 리스너나 작업을 제거하기 위해 사용된다.
- 제공된 키 값이 변경될 때마다 이전 효과를 정리하고 새로운 효과를 실행한다.
- LaunchedEffect, onDestroy와 유사한 역할을 한다.
DisposableEffect는 다음과 같은 형태로 사용된다.
DisposableEffect(key1, key2) {
// Composable이 제거될 때 Dispose 되어야 하는 효과 초기화
onDispose {
// Composable이 Dispose될 때 호출되어 Dispose 되어야 하는 효과 제거
}
}
이 코드를 보면, DisposableEffect 블록 내에서 효과를 초기화하고, onDispose 블록 내에서 정리 작업을 정의하는 구조로 되어 있다.
DisposableEffect의 key값이 바뀔 때마다 onDispose 블록을 호출한 후 초기화 로직을 다시 호출하는 순서이다.
## 예제 코드
@Composable
fun MyDisposableEffectExample() {
var showTimer by remember { mutableStateOf(true) }
Column(modifier = Modifier.padding(16.dp)) {
Button(onClick = { showTimer = !showTimer }) {
Text("Toggle Timer")
}
if (showTimer) {
TimerComposable()
}
}
}
@Composable
fun TimerComposable() {
val context = LocalContext.current
var timer by remember { mutableStateOf(0) }
val scope = rememberCoroutineScope()
DisposableEffect(Unit) {
val job = scope.launch {
while (true) {
delay(1000L)
timer++
}
}
// onDispose 스코프안에서 타이머 종료
onDispose {
job.cancel()
Toast.makeText(context, "timer job cancel", Toast.LENGTH_SHORT).show()
}
}
Text("Timer: $timer seconds")
}
이번 예제는 버튼을 누르면 타이머가 실행이 되고 한번 더 버튼을 누르면 타이머를 종료하면서 토스트메시지를 띄워주는 예제이다.
TimerComposable Composable에서 보면 DisposableEffect의 onDispose를 활용하여 cancel() 함수를 호출해 준다.
즉 TimerComposable Composable이 소멸될 때 DisposableEffect의 onDispose가 실행이 된다는 것을 알 수 있다.
# SideEffect
✅ 상태나 데이터 변경에 따라 실행되어야 하는 추가적인 작업을 정의하는 데 사용하는 API
- 부모 Composable 함수가 리컴포지션될 때 side effect를 실행할 수 있게 하는 Composable 함수
- 로깅 및 분석을 위한 코드를 사용할 때 유용
## 예제 코드
Composable
fun MySideEffectExample() {
val context = LocalContext.current
var counter by remember { mutableStateOf(0) }
// SideEffect를 사용하여 상태 변경 시 toast 메시지를 출력
SideEffect {
Toast.makeText(context, "counter : $counter", Toast.LENGTH_SHORT).show()
}
Column(modifier = Modifier.padding(16.dp)) {
Button(onClick = { counter++ }) {
Text("Increase Counter")
}
Text("Counter: $counter")
}
}
이번 예제는 버튼을 누르면 counter가 증가하면 토스트를 띄워주는 예제이다.
코드에서는 SideEffect를 사용함으로써 리컴포지션될 때 토스트를 해준다.
이렇게 SideEffect를 사용하면 Compose 내의 상태 변경을 감지하고, 필요에 따라 외부 시스템이나 객체와 상호작용할 수 있다.
# produceState
✅ 일반적인 값을 Compose의 상태로 만들어주는 API
- 상태를 produceState로 생성하면 컴포저블이 이 상태를 구독하게 되며, 상태가 변경될 때마다 컴포저블이 리컴포지션된다.
- non Compose State를 Compose State로 변환한다.
"뭐지 도대체 Compose State를 어떤 식으로 만든다는 거야?"라고 의문이 드는 게 정상이다.
그래서 그 이유를 찾아보자 produceState의 코드는 다음과 같다.
@Composable
fun <T> produceState(
initialValue: T,
producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val result = remember { mutableStateOf(initialValue) }
LaunchedEffect(Unit) {
ProduceStateScopeImpl(result, coroutineContext).producer()
}
return result
}
return값이 State<T>이기때문에 일반적인 값을 Compose의 상태로 만들어 반환이 가능한 것이다.
또한 내부에서 LaunchedEffect를 사용하기 때문에 생명주기도 LaunchedEffect와 동일하게 적용된다.
## 예제 코드
@Composable
fun FilterExample() {
var filter by remember { mutableStateOf("a") }
val data = listOf("Apple", "Banana", "Cherry", "Date", "Fig", "Grape")
// 데이터 필터링
val filteredData by produceState(initialValue = data) {
// 실제로는 통신 로직을 통해 성공 유무에 따라 다르게 해주는게 좋음
delay(3000) // Simulate delay
value = data.filter { it.contains(filter, ignoreCase = true) }
}
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = filter,
onValueChange = { filter = it },
label = { Text("Filter") }
)
Text("Filtered Data:")
filteredData.forEach { item ->
Text(item)
}
}
}
이번 예제는 과일이름이 들어있는 리스트에서 3초 후 a가 포함된 것만 필터링하는 예제이다.
주석에도 있다시피 실제로는 통신 로직이나 Repository를 통해 함수를 만들어 처리해 주는 게 좋다.
# derivedStateOf
✅주어진 상태를 기반으로 새로운 상태를 파생하여 생성하는 API
- 주 상태가 변경될 때만 파생된 상태를 재계산한다. 이를 통해 불필요한 계산을 줄이고 성능을 최적화할 수 있다.
- 복잡한 계산이나 데이터 가공을 상태 변화에 따라 효율적으로 처리할 때 사용된다.
## 예제 코드
@Composable
fun SideEffectEx() {
var username by remember { mutableStateOf("") }
//DO NOT THIS
// val submitEnabled = username.isNotEmpty()
val submitEnabled by remember {
derivedStateOf { username.isNotEmpty() }
}
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = username,
onValueChange = { username = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("텍스트를 입력하세요") }
)
Button(
enabled = submitEnabled,
onClick = { }
) {
Text(text = "Button")
}
}
}
이번 예제는 이름 유무에 따라 버튼을 활성화하는 예제이다.
여기서 주석을 보면 derivedStateOf 모르고 사용 안 했을 때 당연하게 적었을 것이다.
하지만 아래 표를 보면 derivedStateOf을 사용하는 게 불필요한 상태전환을 막을 수 있는지 알 것이다.
<derivedStateOf 미사용>
username | submitEnabled |
false | |
A | true |
AB | true |
ABC | true |
ABCD | true |
ABCDE | true |
이런 식으로 한 글자 입력될 때마다 submitEnabled 상태가 불필요하게 갱신된다.
<derivedStateOf 사용>
username | submitEnabled |
false | |
A | |
AB | |
ABC | |
ABCD | |
ABCDE |
반면에 derivedStateOf를 사용해 주면 derivedStateOf 스코프 안에 있는 조건일 때만 상태 검사를 하기 때문에 상태는 계속해서 변경되지 않으므로 불필요한 상태전환을 막을 수 있다.
# snapshotFlow
✅ Compose의 상태를 Flow로 변환하는 API
- State<T> 객체를 Flow로 변환해야 할 때 사용
- snapshotFlow로 생성된 Flow는 Lifecycle에 영향을 받기 때문에 해당 컴포저블이 종료되면 만들어진 Flow도 자동으로 취소된다 이는 메모리 누수를 방지하는 데 유용하다.
snapshotFlow의 내부코드를 살펴보면 State <T> 객체를 Flow로 반환하는 것을 볼 수 있다.
fun <T> snapshotFlow(
block: () -> T
): Flow<T> = flow {
...
...
}
## 예제 코드
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
이번 예제는 LazyColumn에서 첫 번째 아이템이 스크롤로 인하여 가려지면 특정 작업을 수행하도록
하는 안드로이드 공식 문서에 있는 예제를 가져와봤다.
예제를 보다 시피 snapshotFlow를 사용하면 Flow가 지원하는 다양한 operator(distinctUntilChanged, collect 등)를 이용하여 좀 더 효율적인 코드를 구성할 수 있다. 이러한 이유 때문에 사용한다.
# 느낀점
항상 정확히 모르고 사용하던 것들이기도 하고 처음 보는 것들도 있었다.
그렇기 때문에 이번 포스팅은 도움이 많이 되는 것 같다.
컴포즈로 개발 시 앞으로 제공해 주는 Effect API들을 잘 활용해야겠다는 것도 느낄 수 있었다.