시배's Android

Compose | Side Effects 본문

Android/Compose

Compose | Side Effects

si8ae 2023. 9. 6. 22:03
 

Jetpack Compose Side Effects in Details

Optimizing UI Performance in Jetpack Compose with SideEffect, LaunchedEffect, and DisposableEffect. Learn how to manage UI effects.

medium.com

Whey Side-effects?

Jetpack Compose에서 사이드 이펙트의 목적은 컴포저블 함수 외부에서 앱의 상태를 변경하는 UI와 관련이 없는 작업을 제어되고 예측 가능한 방식으로 실행할 수 있도록 하는 것입니다.
데이터베이스 업데이트 또는 네트워크 호출과 같은 부수 효과는 코드의 성능과 유지 관리성을 개선하기 위해 UI 렌더링 로직과 별도로 유지해야 합니다.
Jetpack Compose는 SideEffect, LaunchedEffect, DisposableEffect와 같은 여러 컴포저블 함수를 제공하여 개발자가 UI 렌더링 로직에서 분리하고 별도의 코루틴 범위에서 실행함으로써 부작용을 효과적으로 관리할 수 있도록 지원합니다.
제트팩 컴포즈에서 사이드 이펙트를 사용하면 다음과 같은 주요 이점이 있습니다:

  • 성능 향상: 컴포저블 함수 외부에서 UI와 관련이 없는 연산을 실행함으로써 UI 렌더링 로직의 반응성과 성능을 유지할 수 있습니다.
  • 더 나은 코드 구성: UI와 관련이 없는 연산을 UI 렌더링 로직에서 분리하여 코드베이스를 더 쉽게 이해하고 유지 관리할 수 있습니다.
  • 디버깅 개선: 사이드 이펙트는 로깅 및 분석 작업에 사용할 수 있으므로 개발자가 앱의 동작을 더 잘 이해하고 문제를 식별하는 데 도움이 될 수 있습니다.

요약하면 Jetpack Compose에서 사이드 이펙트의 목적은 UI와 관련이 없는 작업을 UI 렌더링 로직에서 분리하여 코드베이스의 성능, 유지 보수성 및 디버깅을 개선하는 것입니다.

SideEffect

SideEffect는 부모 컴포저블이 재구성될 때 사이드 이펙트를 실행할 수 있게 해주는 컴포저블 함수입니다. 사이드 이펙트는 로깅, 분석 또는 외부 상태 업데이트와 같이 UI에 직접 영향을 주지 않는 연산입니다. 이 함수는 컴포저블의 상태나 소품에 의존하지 않는 연산을 실행할 때 유용합니다.
컴포저블이 재구성되면 컴포저블 함수 내부의 모든 코드가 부작용을 포함하여 다시 실행됩니다. 하지만 UI는 컴포저블의 상태 또는 프로퍼티에 적용된 변경 사항만 업데이트됩니다.

How to Use SideEffect?

SideEffect를 사용하려면 컴포저블 함수 안에서 호출하고 실행하려는 부작용이 포함된 람다를 전달해야 합니다. 다음은 예시입니다:

@Composable
fun Counter() {
    // Define a state variable for the count
    val count = remember { mutableStateOf(0) }

    // Use SideEffect to log the current value of count
    SideEffect {
        // Called on every recomposition
        log("Count is ${count.value}")
    }

    Column {
        Button(onClick = { count.value++ }) {
            Text("Increase Count")
        }

        // With every state update, text is changed and recomposition is triggered
        Text("Counter ${count.value}")
    }
}

이 예제에서 SideEffect 함수는 카운터 함수가 재구성될 때마다 카운트 상태 변수의 현재 값을 기록합니다. 이는 컴포저블의 동작을 디버깅하고 모니터링하는 데 유용합니다.

주의할 점은 현재 컴포저블 함수가 재구성될 때만 부작용이 발동되며, 중첩된 컴포저블 함수에 대해서는 발동되지 않는다는 점입니다. 즉, 다른 컴포저블 함수를 호출하는 컴포저블 함수가 있는 경우, 내부 컴포저블 함수가 재구성될 때 외부 컴포저블 함수의 부작용이 발동되지 않는다는 뜻입니다. 이를 이해하기 위해 코드를 다음과 같이 변경해 보겠습니다:

@Composable
fun Counter() {
    // Define a state variable for the count
    val count = remember { mutableStateOf(0) }

    // Use SideEffect to log the current value of count
    SideEffect {
        // Called on every recomposition
        log("Count is ${count.value}")
    }

    Column {
        Button(onClick = { count.value++ }) {
            // This recomposition doesn't trigger the outer side effect
            // every time button has been tapped
            Text("Increase Count ${count.value}")
        }
    }
}

위 코드에서 앱을 처음 실행하면 카운터 컴포저블 함수가 컴포저블되고 사이드이펙트가 콘솔에 카운트의 초기값을 기록합니다. 버튼을 클릭하면 텍스트 컴포저블이 새로운 카운트 값으로 재구성되지만, 이 경우 사이드이펙트가 다시 트리거되지는 않습니다.

이제 내부 사이드 이펙트를 추가하여 어떻게 작동하는지 살펴봅시다:

@Composable
fun Counter() {
    // Define a state variable for the count
    val count = remember { mutableStateOf(0) }

    // Use SideEffect to log the current value of count
    SideEffect {
        // Called on every recomposition
        log("Outer Count is ${count.value}")
    }

    Column {
        Button(onClick = { count.value++ }) {
            // Use SideEffect to log the current value of count
            SideEffect {
                // Called on every recomposition
                log("Inner Count is ${count.value}")
            }

            // This recomposition doesn't trigger the outer side effect
            // every time button has been tapped
            Text("Increase Count ${count.value}")
        }
    }
}

위의 코드에서 버튼을 클릭하면 출력은 다음과 같습니다:

Outer Count is 0
Inner Count is 0
Inner Count is 1
Inner Count is 2
Inner Count is 3

LaunchedEffect

LaunchedEffect는 별도의 코루틴 스코프에서 부수 효과를 실행하는 컴포저블 함수입니다. 이 함수는 네트워크 호출이나 애니메이션과 같이 시간이 오래 걸릴 수 있는 작업을 UI 스레드를 차단하지 않고 실행할 때 유용합니다.
다음은 LaunchedEffect를 사용하는 방법의 예시입니다:

@Composable
fun MyComposable() {
    val isLoading = remember { mutableStateOf(false) }
    val data = remember { mutableStateOf(listOf<String>()) }

    // Define a LaunchedEffect to perform a long-running operation asynchronously
    // `LaunchedEffect` will cancel and re-launch if
    // `isLoading.value` changes
    LaunchedEffect(isLoading.value) {
        if (isLoading.value) {
            // Perform a long-running operation, such as fetching data from a network
            val newData = fetchData()
            // Update the state with the new data
            data.value = newData
            isLoading.value = false
        }
    }

    Column {
        Button(onClick = { isLoading.value = true }) {
            Text("Fetch Data")
        }
        if (isLoading.value) {
            // Show a loading indicator
            CircularProgressIndicator()
        } else {
            // Show the data
            LazyColumn {
                items(data.value.size) { index ->
                    Text(text = data.value[index])
                }
            }
        }
    }
}

// Simulate a network call by suspending the coroutine for 2 seconds
private suspend fun fetchData(): List<String> {
    // Simulate a network delay
    delay(2000)
    return listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5",)
}

이 예제에서 LaunchedEffect 함수는 isLoading 상태 변수가 true로 설정되면 네트워크 호출을 실행하여 API에서 데이터를 가져옵니다. 이 함수는 별도의 코루틴 범위에서 실행되므로 작업이 수행되는 동안 UI가 계속 반응할 수 있습니다.

LaunchedEffect 함수는 두 개의 매개변수, 즉 isLoading.value로 설정된 key와 실행할 사이드 이펙트를 정의하는 람다인 block을 받습니다. 이 경우 블록 람다는 코루틴을 2초 동안 일시 중단하여 네트워크 호출을 시뮬레이션하는 fetchData() 함수를 호출합니다. 데이터를 불러오면 데이터 상태 변수를 업데이트하고 isLoading을 false로 설정하여 로딩 표시기를 숨기고 불러온 데이터를 표시합니다.

What's the logic behind the key parameter?

LaunchedEffect의 키 매개변수는 LaunchedEffect 인스턴스를 식별하고 불필요하게 재구성되는 것을 방지하는 데 사용됩니다.

컴포저블이 리컴포저블되면 Jetpack Compose가 다시 그려야 하는지 여부를 결정합니다. 컴포저블의 상태나 소품이 변경되었거나 컴포저블이 무효화를 호출한 경우 Jetpack Compose는 컴포저블을 다시 그립니다. 컴포저블을 다시 그리는 작업은 특히 컴포저블이 재구성될 때마다 다시 실행할 필요가 없는 장기 실행 작업이나 부작용이 포함된 경우 비용이 많이 드는 작업일 수 있습니다.

LaunchedEffect에 키 파라미터를 제공하면 LaunchedEffect 인스턴스를 고유하게 식별하는 값을 지정할 수 있습니다. 키 파라미터의 값이 변경되면 Jetpack Compose는 LaunchedEffect 인스턴스를 새 인스턴스로 간주하고 사이드 이펙트를 다시 실행합니다. 키 매개변수의 값이 동일하게 유지되면 Jetpack Compose는 부수 효과 실행을 건너뛰고 이전 결과를 재사용하여 불필요한 재구성을 방지합니다.

LaunchedEffect 에 여러 키를 사용할 수도 있습니다:

// Use a random UUID as the key for LaunchedEffect
val key = remember { UUID.randomUUID().toString() }

LaunchedEffect(key, isLoading.value) {
  ....
}

DisposableEffect 

DisposableEffect는 부모 컴포저블이 처음 렌더링될 때 사이드 이펙트를 실행하고, 컴포저블이 UI 계층구조에서 제거되면 이펙트를 폐기하는 컴포저블 함수입니다. 이 함수는 이벤트 리스너나 애니메이션과 같이 컴포저블을 더 이상 사용하지 않을 때 정리해야 하는 리소스를 관리할 때 유용합니다.

다음은 일회용 효과를 사용하는 방법의 예시입니다:

@Composable
fun TimerScreen() {
    val elapsedTime = remember { mutableStateOf(0) }

    DisposableEffect(Unit) {
        val scope = CoroutineScope(Dispatchers.Default)
        val job = scope.launch {
            while (true) {
                delay(1000)
                elapsedTime.value += 1
                log("Timer is still working ${elapsedTime.value}")
            }
        }

        onDispose {
            job.cancel()
        }
    }

    Text(
        text = "Elapsed Time: ${elapsedTime.value}",
        modifier = Modifier.padding(16.dp),
        fontSize = 24.sp
    )
}

이 코드에서는 DisposableEffect를 사용하여 매초마다 elapsedTime 상태 값을 증가시키는 코루틴을 실행합니다. 또한 일회용 효과를 사용하여 컴포저블이 더 이상 사용되지 않을 때 코루틴이 취소되고 코루틴에 사용된 리소스가 정리되도록 합니다.

DisposableEffect의 정리 함수에서는 job에 저장된 Job 인스턴스의 취소() 메서드를 사용하여 코루틴을 취소합니다.

onDispose 함수는 컴포저블이 UI 계층 구조에서 제거될 때 호출되며, 컴포저블이 사용한 모든 리소스를 정리하는 방법을 제공합니다. 이 경우, 코루틴을 취소하고 코루틴에 사용된 모든 리소스를 정리하기 위해 onDispose를 사용합니다.

이 일회용 효과가 어떻게 작동하는지 확인하기 위해 다음 코드를 실행하여 결과를 확인해 보겠습니다:

@Composable
fun RunTimerScreen() {
    val isVisible = remember { mutableStateOf(true) }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Bottom
    ) {
        Spacer(modifier = Modifier.height(10.dp))

        if (isVisible.value)
            TimerScreen()

        Button(onClick = { isVisible.value = false }) {
           Text("Hide the timer")
        }
    }
  }

사용자가 타이머 화면의 표시 여부를 토글할 수 있는 새로운 RunTimerScreen 컴포저블을 추가합니다. 사용자가 "타이머 숨기기" 버튼을 클릭하면 UI 계층 구조에서 TimerScreen 컴포저블이 제거되고 코루틴이 취소되고 정리됩니다.

onDispose 함수에서 job.cancel() 호출을 제거하면 TimerScreen 컴포저블이 더 이상 사용되지 않는 경우에도 코루틴이 계속 실행되어 누수 및 기타 성능 문제가 발생할 수 있습니다.

이러한 방식으로 일회용 효과와 코루틴 스코프를 함께 사용하면 타이머스크린 컴포저블이 더 이상 사용되지 않을 때 코루틴 스코프가 실행한 코루틴이 취소되고 리소스가 정리됩니다. 이를 통해 리소스 누수 및 기타 성능 문제를 방지하고 앱의 성능과 안정성을 개선할 수 있습니다.

When to use each

Use Cases of DisposableEffect

  • 이벤트 리스너 추가 및 제거하기
  • 애니메이션 시작 및 중지
  • 카메라, 위치 관리자 등과 같은 센서 리소스 바인딩 및 바인딩 해제
  • 데이터베이스 연결 관리

Use Cases of LaunchedEffect 

  • 네트워크에서 데이터 가져오기
  • 이미지 처리 수행
  • 데이터베이스 업데이트

Use Cases of SideEffect

  • 로깅 및 분석
  • 블루투스 장치에 대한 연결 설정, 파일에서 데이터 로드 또는 라이브러리 초기화와 같은 일회성 초기화 수행.
@Composable
fun MyComposable() {
    val isInitialized = remember { mutableStateOf(false) }

    SideEffect {
        if (!isInitialized.value) {
            // Execute one-time initialization tasks here
            initializeBluetooth()
            loadDataFromFile()
            initializeLibrary()
            
            isInitialized.value = true
        }
    }

    // UI code here
}