깨알 개념/Android

[Android] Jetpack Compose - Side-effects

interfacer_han 2024. 9. 6. 19:02

#1 개요

 

Compose의 부수 효과  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose의 부수 효과 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 부수 효과는 구성 가능한 함수의 범

developer.android.com

Jetpack Compose는 굉장히 선언적이기 때문에, 세심하고 복잡한 동작 구현이 쉽지 않다. Side-effects는 그 구현을 쉽게 만들어준다. Side effects를 번역하면, '부작용'이라는 말이 되는데, 이 사실은 마치 Side effects가 에러 및 그 처리와 관련되어있다는 늬앙스를 연상케한다. 하지만 Jetpack Compose의 Side-effects는 다르다. 여기서의 Side effects는 Compose Runtime의 동작이 UI 처리 외적(side)효과(effect)를 낼 수 있게 만드는 함수들의 집합을 말한다.

 

아래는 Side-effects들의 목록이다.

 

#2 LaunchedEffect

#2-1 예시

손님을 응대하는 업무를 맡은 직원에게 제공될 앱 화면이다. 첫번째 버튼을 누르면 좌석 배정 완료 / 미완료가 전환된다. 두번째 버튼을 누르면 손님에게 드린 간식 갯수를 카운트업 할 수 있다. 세번째 버튼을 누르면 현재 손님 응대가 종료되고 다음 손님 응대로 넘어간다. 이 때, "n번 손님 맞이 시작"이라는 토스트 메시지도 같이 출력된다.

 

이 토스트 메시지를 구현하는 방법은 크게 아래의 2가지를 생각할 수 있다.

 

1. 버튼 클릭 리스너를 이용해 출력
2. Jetpack Compose가 보유하고 있는 State인 '손님 번호'가 변할 때 출력

첫번째 방법이 직관적이기는 하지만, 더 세련된 방법은 2번째 방법이다. 왜냐하면 토스트 메시지의 내용을 생각했을 때, 해당 토스트 메시지가 반드시 버튼 클릭 시에만 나올만한 것이 아니기 때문이다. 예를 들어 아무런 입력없이 5분이 지나면 자동으로 다음 손님으로 넘어가는 동작을, 앱 업데이트를 통해 추가하려고 한다 하자. 이 때도 "n번 손님 맞이 시작"이라는 토스트 메시지가 나와야 할 텐데, 버튼 클릭 리스너를 이용하는 방식을 썼다면 토스트 메시지가 뜨지 않을 것이다.

 

반면 두번째 방법은 State가 변하기만 하면 토스트 메시지를 출력할 수 있으므로, 그 구현을 미래에도 유연하게 재사용할 수 있다. 이 구현을 위해 사용하는 것이 LaunchedEffect다. LaunchedEffect는 State가 변할 때만 수행되는 함수다. 이 두번째 방법을 통해 구현한 이 앱의 코드는 아래와 같다.

 

#2-2 코드

더보기
// package com.example.sideeffects

import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import com.example.sideeffects.ui.theme.SideEffectsTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            SideEffectsTheme {
                Column(
                    modifier = Modifier.fillMaxSize(),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.SpaceEvenly
                ) {
                    val latestCustomerId = remember {
                        mutableStateOf(7)
                    }

                    val seatAssignment = remember {
                        mutableStateOf(false)
                    }

                    val snackCount = remember {
                        mutableStateOf(0)
                    }

                    LaunchedEffect(key1 = latestCustomerId.value) {
                        Toast.makeText(
                            this@MainActivity,
                            "${latestCustomerId.value}번 손님 맞이 시작",
                            Toast.LENGTH_SHORT
                        ).show()

                        seatAssignment.value = false
                        snackCount.value = 0
                    }

                    Text(
                        text = "직원용 손님 맞이 Checker\n(현재 ${latestCustomerId.value}번 손님)",
                        textAlign = TextAlign.Center,
                    )

                    Button(onClick = {
                        seatAssignment.value = !seatAssignment.value
                    }) {
                        Text(
                            text = if (seatAssignment.value) {
                                "좌석 배정 완료"
                            } else {
                                "좌석 배정 미완료"
                            }
                        )
                    }

                    Button(onClick = {
                        snackCount.value++
                    }) {
                        Text(text = "간식 ${snackCount.value}개 드림")
                    }

                    Button(onClick = {
                        latestCustomerId.value++
                    }) {
                        Text(text = "${latestCustomerId.value}번 손님 맞이 완료")
                    }
                }
            }
        }
    }
}

LaunchedEffect는 여러 개의 key를 등록할 수 있다. 여기서는 변화를 감지할 State가 하나 뿐이라서 key를 하나만 등록했다. key를 여러 개 등록한 경우, 하나의 key라도 변경되었다면 Recomposition될 때마다 LaunchedEffect가 수행된다. 또, 아무리 여러면 Recomposition되어도 key들에 아무런 변화가 없다면 LaunchedEffect는 수행되지 않는다.

좌석 배정의 완료 / 미완료를 저장하는 State인 seatAssignment, 간식 제공 갯수를 저장하는 State인 snackCount는 key에 등록되지 않았기에 LaunchedEffect와는 완전히 독립적으로 작동한다.

 

#3 rememberCoroutineScope

#3-1 예시

rememberCoroutineScope는 Composable의 생명주기를 따르는 코루틴 영역이다. remember라는 단어가 들어가는 것에서 볼 수 있듯, Recomposition에 영향받지 않는다. rememberCoroutineScope는 UI 생성의 비동기적 처리를 목적으로 설계되었다. UI 작업을 Recomposition 이외의 방법으로 구현하려면 rememberCoroutineScope를 사용하는 것이 좋다. #3-2에 링크한 게시글에서 사용례를 확인할 수 있다.

 

#3-2 코드

 

[Android] Jetpack Compose - Scaffold

#1 개요#1-1 Scaffold의 사전적 의미Scaffold는 비계((건설) 높은 곳에서 공사를 할 수 있도록 임시로 설치한 가설물)라는 단어로 번역된다. #1-2 Scaffold in Jetpack Compose Jetpack Compose  |  Android Developers이

kenel.tistory.com

위 게시글의 #5 snackbarHost에 rememberCoroutineScope가 사용되었다.

 

#4 rememberUpdatedState

#4-1 rememberUpdatedState를 사용하지 않은 예시

버튼을 누르면 5초 뒤에 토스트 메시지로 시각을 표시하는 앱이다.

 

#4-2 rememberUpdatedState를 사용하지 않은 코드

더보기
// package com.example.sideeffects

import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.sideeffects.ui.theme.SideEffectsTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Locale

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val time = remember {
                mutableStateOf("불러오는 중...")
            }

            LaunchedEffect(Unit) {
                while (true) {
                    delay(1000)
                    time.value = getCurrentTime()
                }
            }

            SideEffectsTheme {
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(16.dp),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.SpaceEvenly
                ) {
                    Card(
                        modifier = Modifier
                            .weight(1f)
                            .padding(bottom = 8.dp)
                    ) {
                        RealTimeContainer(time.value)
                    }

                    Card(
                        modifier = Modifier
                            .weight(2f)
                            .padding(top = 8.dp)
                    ) {
                        ButtonContainer(time.value)
                    }
                }
            }
        }
    }
}

@Composable
private fun RealTimeContainer(timeString: String) {
    Box(
        modifier = Modifier.fillMaxSize()
    ) {
        Text(
            modifier = Modifier
                .align(Alignment.Center)
                .padding(16.dp),
            text = "진짜 현재 시각:\n${timeString}",
            textAlign = TextAlign.Center
        )
    }
}

@Composable
private fun ButtonContainer(timeString: String) {
    val coroutineScope = rememberCoroutineScope()
    val context = LocalContext.current

    Box(
        modifier = Modifier.fillMaxSize()
    ) {
        Button(
            modifier = Modifier.align(Alignment.Center),
            onClick = {
                coroutineScope.launch {
                    delay(5000)
                    Toast.makeText(context, timeString, Toast.LENGTH_SHORT).show()
                }
            }
        ) {
            Text(
                text = "현재 시각\n토스트 메시지로 표시",
                textAlign = TextAlign.Center
            )
        }
    }
}

fun getCurrentTime(): String {
    val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
    return sdf.format(System.currentTimeMillis())
}

버튼이 눌리면 즉시 현재 시각을 저장한다. 그리고 delay(5000) 후에 토스트 메시지로 출력한다. 이 코드는 데모 앱의 코드이므로 delay(5000)를 인위적으로 넣었지만, 실제 앱에서는 delay(5000)를 생략하더라도, 버튼과 토스트 메시지 간의 시차가 생겨날 수 있다. 비동기적 병렬 처리는 필연적인 시차가 발생하기 때문이다.

 

#4-3  rememberUpdatedState를 사용한 예시

#4-1과 동일한 앱이지만, rememberUpdatedState를 사용하여 인위적으로 부여했던 5초의 딜레이를 없앴다. rememberUpdatedState는 (Recomposition을 유발하지 않으면서) 항상 최신 값을 유지하는 State다. 일반적인 변수나 State를 rememberUpdatedState()에 인수로 넣어 rememberUpdatedState로 만들 수 있다.

 

#4-4 rememberUpdatedState를 사용한 코드

더보기
...

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
    }
}

@Composable
private fun RealTimeContainer(timeString: String) {
    ...
}

@Composable
private fun ButtonContainer(timeString: String) {
    ...
    
    val currentTime = rememberUpdatedState(timeString)

    Box(
        ...
    ) {
        Button(
            ...
            onClick = {
                coroutineScope.launch {
                    ...
                    Toast.makeText(context, currentTime.value, Toast.LENGTH_SHORT).show()
                }
            }
        ) {
            ...
        }
    }
}

fun getCurrentTime(): String {
    ...
}

단 2줄의 코드를 제외하고 #4-2와 동일한 코드다. timeString을 rememberUpdatedState로 감싸고 이를 토스트 메시지에서 사용한 것이 유일한 차이다. 이 코드에도 여전히 존재하는 delay(5000)는 먼저 실행시켜야 하는 어떤 비동기적 작업의 작업 시간을 대변한다. 이 시간에 의해 올바르게 참조할 수 없는 State의 최신 값을, rememberUpdatedState는 제대로 참조할 수 있게 만든다. 일반적인 State는, Recomposition이 수반되어야 최신 값을 화면에 표시할 수 있다. 반면, rememberUpdatedState는 Recomposition 유발 없이 최신 값을 유지함이 보장된다. 즉, 컴퓨팅 자원적으로도 이점이 있는 함수다.

 

#5 나머지 Side-effects들

 

Compose의 부수 효과  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose의 부수 효과 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 부수 효과는 구성 가능한 함수의 범

developer.android.com

DisposableEffect, SideEffect, produceState, derivedStateOf, snapshotFlow에 대한 내용은 생략했다. 추후 앱 개발에 필요하면 게시글을 수정해 추가 서술하려고 한다. 만약 자세한 내용이 필요하다면, 위 링크에서 확인하자. 각각의 요소에 대한 간략한 설명은 아래와 같다.

 

DisposableEffect

특정 컴포넌트가 화면에 표시될 때와 화면에서 사라질 때 각각 수행해야 할 작업을 정의.

 

SideEffect

컴포넌트가 그려질(= UI Rendering) 때마다 수행해야 할 작업을 정의. 즉 Recomposition이 될 때마다 수행해야 할 작업을 정의.

 

produceState

State을 런타임 상에서 비동기적으로 생성(produce). 예를 들어, 네트워크에서 데이터를 가져오는 동안 해당 데이터를 담는 State를 생성할 수 있음.

 

derivedStateOf

다른 여러 State를 통합한 하나의 State 생성. 다시 말해, 다른 여러 State로부터 파생된(= derived) State를 생성. 존재 목적은 불필요한 Recomposition을 막아 자원을 절약하는 것. 

 

snapshotFlow

State를 Flow로 변환. 즉, Flow.collectAsState()의 반대.

 

#6 요약

Jetpack Compose의 Side-effects는 UI 처리 외적(side)인 효과(effect)를 낼 수 있게 만드는 함수다.