본문 바로가기
안드로이드 공부 노트/Compose(컴포즈)

[Android-Compose] 안드로이드 컴포즈 부수 효과 알아보기! (Android Compose DisposableEffect, SideEffect, produceState, derivedStateOf, snapshotFlow) - SideEffect - 2편

by 지게요 2024. 7. 30.
728x90
반응형

이번에는 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들을 잘 활용해야겠다는 것도 느낄 수 있었다.

 

 

반응형