와챠의 우당탕탕 코딩 일기장

[Android] 2-3. State, Effect, Coroutine 활용② / Saver / snapshotFlow / LifecycleEventObserver + DisposableEffect / derivedStateOf 본문

코딩 일기장/Android(Kotlin)

[Android] 2-3. State, Effect, Coroutine 활용② / Saver / snapshotFlow / LifecycleEventObserver + DisposableEffect / derivedStateOf

minWachya 2025. 5. 8. 22:07
반응형

글이 넘 길어져서 나눠서 올림요

 

<목차>

  1. 맞춤 Saver: listSaver : 복잡한 데이터 구조 저장 방법
  2. snapshotFlow: state에 Flow 연산자의 기능을 사용가능하게 만들기
  3. LifecycleEventObserver + DisposableEffect:  ex)mapView도 상세 화면의 Lifecycle과 똑같이 흐르도록 만들기
  4. produceState: T를 State<T>로 만들기
  5. derivedStateOf:  recomposition을 줄이는 방법. state 변할 때만 실행

 


1. 맞춤 Saver: listSaver 

목적지를 검색하면 리스트에 검색어가 포함된 데이터만 보여주는 기능을 만들어보려고 한다.

(+ 초기 문구는 Choose Destination이라는 hint로 시작, + hint 문구가 수정되면 textStyle 변경

+목적지 검색창 클릭 시 비행기 이모지 색 변경

+ 목적지 검색창 클릭 시 앞에 To라는 caption 붙음...)

검색어(의 상태)가 계속 변경되는 것을 관리하기 위해서는 rememberrememberSaveable를 사용한다고 배웠었다.

 

remember*를 사용하기 전에 어떤 상태를 관리할 것인지 코딩해보면 이렇게 될 것이다.

- textField의 value가 내부 수정 가능하면서 외부에서 직접적인 수정 불가 == var text ~ private set

- 외부에서 간접적으로 수정 요청을 통한 수정은 가능 == fun update

- hint 문구가 아니면 텍스트 스타일 변경하기 위해 현재 value가 hint인지 아닌지 확인 필요 == isHint

// base/EditableUserInput.kt file

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)
       private set

    fun updateText(newText: String) {
       text = newText
    }

    val isHint: Boolean
        get() = text == hint
}

 

이 구조를 remember*로 관리할 수 있을까?

remember는 구성 변경될 때 state유지가 안 된다는 단점이 있었고, 이는 rememberSaveable을 통해 해결할 수 있었다.

하지만 rememberSaveable는 Bundle 내에 저장할 수 있는 값만을 관리할 수 있기 때문에 위처럼 복잡한 데이터 구조를 저장하는 데에는 적합하지 않다.

이러한 문제를 해결하기 위해 맞춤 Saver를 구현하는 방법을 배워보려고 한다!

 

Saver

fun <Original : Any?, Saveable : Any> Saver(
    save: SaverScope.(value) -> Saveable?,
    restore: (value) -> Original?
): Saver<Original, Saveable>

 

Saver는 객체를 단순화하고 Savable 상태로 변환할 수 있는 것을 돕는 역할을 한다.

  • save: 원래 값을 Savable로 변환 // 저장할 값
  • restore: 복원된 값을 원본 클래스의 인스턴스로 변환 // 복원된 값

더 복잡하게 Saver를 구현할 수도 있겠지만, 여기서는 ComposeAPI가 제공해주는 listSaver를 사용해보려고 한다.

(mapSaver...도 있는듯)

이를 사용해서 다시 상태를 정의해보면 다음과 같은 코드가 된다.

(+ Saver 정의는 함께 작동하는 클래스와 가깝게 배치하는 것이 좋다고 한다. 머 당연한듯 유지보수하기 편하게...)

// base/EditableUserInput.kt file

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    fun updateText(newText: String) {
        text = newText
    }

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1]
                )
            }
        )
    }
}

 

이렇게 만들어진 Saver를 remember*처럼 사용하기 위한 클래스도 필요하다.

이처럼 rememberSaveable에 saver를 추가해주면 된닷ㅋ

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState = rememberSaveable(hint, saver = EditableUserInputState.Saver) {
    EditableUserInputState(hint, hint)
}

 

이제 이 상태를 검색바 컴포저블에서 사용해주면 된다.

// base/EditableUserInput.kt file

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            // ...
        )
    }
}

2. snapshotFlow

위에서 만든 검색창을 활용해서 아래 기능이 동작하도록 만들어보자.

  1. 초기 hint 문구는 "Choose Destination"
  2. 목적지 검색창 클릭 시 앞에 "To" 라는 caption 붙음...

코드는 다음과 같을 것이다.

// home/SearchUserInput.kt file

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

 

이 기능의 최종 목표는 검색어가 포함된 여행지 리스트를 반환하는 것이다.

여행지 리스트 데이터를 관리하는 것은 viewModel이므로,

ToDestinationUserInput은 onToDestinationChanged라는 매개변수를 통해 관리할 수 있도록 했다.(호이스팅)

 

LaunchedEffect를 사용하여 state가 변경될 때마다  onToDestinationChanged 람다를 호출할 수 있습니다.

 

snapshotFlow

fun <T : Any?> snapshotFlow(block: () -> T): Flow<T>

snapshotFlow는 State<T> 객체를 Flow로 변환한다.

공식 문서의 설명을 보면 다음과 같다 ㄷㄷㄷㄷㄷ(여기선 중요하지 않아서 공식 설명은 패스 가능)

snapshotFlow creates a Flow that runs block when collected and emits the result, recording any snapshot state that was accessed.
While collection continues, if a new Snapshot is applied that changes state accessed by block, the flow will run block again, re-recording the snapshot state that was accessed.
If the result of block is not equal to the previous result, the flow will emit that new result. (This behavior is similar to that of Flow.distinctUntilChanged.)
Collection will continue indefinitely unless it is explicitly cancelled or limited by the use of other Flow operators.

SnapshotFlow는 수집될 때 block을 실행하고 그 결과를 보내는 Flow를 생성하며, 접근한 스냅샷 상태를 기록합니다.
수집이 계속되는 동안 block별로 접근한 상태를 변경하는 새로운 Snapshot이 적용되면, 플로우는 다시 block을 실행하여 접근한 스냅샷 상태를 다시 기록합니다.
블록의 결과가 이전 결과와 같지 않으면 플로우는 그 새로운 결과를 방출합니다. (이 동작은 Flow.distinctToLanged와 유사합니다.)
다른 Flow 연산자의 사용으로 인해 명시적으로 취소되거나 제한되지 않는 한 수집은 무기한 계속됩니다.

출처: 공식문서

 

설명이 복잡한데... 중요한 건 state에 Flow 연산자의 기능을 사용하려고 snapshotFlow를 사용했다는 것이다.

그니까 걍 snapshotFlow를 통해 state가 변할 때마다 이걸 Flow로 바꾸고,

Flow의 기능을 사용해

hint가 아닌 text를을 collect 처리하려는 거다.

코드로 보면 더 이해가 빠를듯!

// home/SearchUserInput.kt file

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

 

 


3. LifecycleEventObserver + DisposableEffect

특정 기능이 Compose 내에서 사용될 때, Compose의 Lifecycle에 따라 동작할 수 있도록 LifecycleEventObserver를 정의하는 방법을 배워보려고 한다.

이렇게 Compose의 Lifecycle을 따르는 이유는 (리소스를 안전하게 폐기하여) 메모리 누수를 방지할 수 있기 때문이다.

 

이 강의에서는 예제로 구글맵을 들었다.

특정 목적지를 클릭하면 해당 목적지의 구글맵을 보여준다.

구글 맵(mapView)이 상세 화면이라는 Compose에서 사용되는데,

구글 맵도 상세 화면과 같은 Lifecycle을 따르도록 만들어보자.

 

이를 만들려면 이런 두 단계가 필요하다.

<목표: mapView도 상세 화면의 Lifecycle과 똑같이 흐르도록 만들기>

1단계 - mapView의 Lifecycle을 조종할 수 있는 조종기 만들기

2단계 - Compose의 Lifecycle이 변할 때, 1단계에서 만든 조종기로 mapView의 Lifecycle도 똑같이 변하게 하기

 

이때 1단계에서 사용되는 게 LifecycleEventObserver,

2단계에서 사용되는 게 DisposableEffect이다!

 

LifecycleEventObserver

public fun interface LifecycleEventObserver extends LifecycleObserver

 

공식 문서에 따르면 의 설명은 아래와 같다.

Class that can receive any lifecycle change and dispatch it to the receiver.

모든 lifecycle 변경 사항을 수신하여 receiver로 전송할 수 있는 클래스입니다.

 

이를 활용해 MapView를 Lifecycle에 맞게 변경하는 조종기를 만들면 다음과 같다.

// details/MapViewUtils.kt file

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

 

이제 이렇게 만든 LifecycleEventObserver를 Compose의 Lifecycle에 맞게 동작하도록 해야한다.

2단계 ㄱㄱ!

DisposableEffect

(일회용 효과 라는 뜻인듯..)

 

Compose의 side Eccect 공식 문서의 정의는 다음과 같다.

DisposableEffect: 정리가 필요한 Effect
key가 변경되거나 컴포저블이 컴포지션을 종료한 후 정리해야 하는 side effect의 경우 DisposableEffect를 사용하세요.
DisposableEffect key가 변경되면 컴포저블이 현재 effect를 삭제(정리)하고 effcet를 다시 호출하여 재설정해야 합니다.

 

이건 DisposableEffect의 공식 문서...

@Composable
@NonRestartableComposable
fun DisposableEffect(key1: Any?, key2: Any?, effect: DisposableEffectScope.() -> DisposableEffectResult): Unit

 

A side effect of composition that must run for any new unique value of key1 or key2 and must be reversed or cleaned up if key1 or key2 changes, or if the DisposableEffect leaves the composition.
DisposableEffect's key is a value that defines the identity of the DisposableEffect.
If a key changes, the DisposableEffect must dispose its current effect and reset by calling effect again. Examples of keys include:
- Observable objects that the effect subscribes to
- Unique request parameters to an operation that must cancel and retry if those parameters change
DisposableEffect may be used to initialize or subscribe to a key and reinitialize when a different key is provided, performing cleanup for the old operation before initializing the new. 

key1 또는 key2의 새로운 고유 값에 대해 실행되어야 하며, key1 또는 key2이 변경되거나 DisposableEffect가 composition을 떠나는 경우 반전되거나 정리되어야 하는 composition의 side effect.
DisposableEffect의 핵심은 DisposableEffect의 정체성을 정의하는 값입니다.
키들이 변경되면 DisposableEffect는 현재의 effectdispose하고 effect를 다시 호출하여 재설정해야 합니다.
키들의 예로는 다음이 있습니다:
- effect가 구독하는 관찰 가능한 객체
- 해당 매개변수가 변경되면 취소하고 다시 시도해야 하는 작업에 대한 고유 요청 매개변수
DisposableEffect는 다른 키가 제공될 때 키를 초기화하거나 구독하고 다시 초기화하는 데 사용될 수 있으며, 새 작업을 초기화하기 전에 이전 작업을 정리하는데 사용될 수 있습니다.

 

아니 공식 문서 설명이 왤케 복잡한겨....????

걍 맨 위 설명처럼

"key가 변경되면 Composable이 현재 effect를 정리하고 effect를 다시 호출하여 재설정해야 함"<를 보는 게 좋은듯...

아니 걍 코드를 봅시다.

 

// details/MapViewUtils.kt file

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

 

코드를 보면 mapView를 생성하고,

mapView의 lifecycle을 조종할 수 있는 조종기(lifecycleObserver)를 생성하고,

DisposavleEffect를 통해 Compsable의 Lifecycle에게 이 조종기를 쥐어주는 코드를 볼 수 있다!!!!

 

휴 뭔 공식 설명 100줄보다 코드 1줄이 더 이해가 잘 되냐 하 내 힘들다


4. produceState

바로 위 예시 움짤을 보면 상세 화면에 들어가기 전에 데이터를 로드할 때 로딩 화면이 잠깐 뜨는 것을 볼 수 있다.

로딩이 끝나면 바로 상세 화면을 보여준다.

 

이를 코드에서 보면 details/DetailsActivity.kt 파일의 DetailsScreen 컴포저블이 cityDetails를 ViewModel에서 가져오고 결과가 성공적인 경우 DetailsContent를 호출하게 된다.

 

cityDetails 데이터가 성공적으로 받아왔을 때 / 받아오는 중일 때(로딩 중) / 오류가 났을 때에 따라 상세 화면이 달라지게 된다.

이처럼 화면 상태(UI state)를 변화하는 데이터를 관리하는 잘 방법을 배워보고자 한다.

 

위의 화면 상태를 잘 관리하기 위해 아래처럼 한 곳에 모아둔다.

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

 

이 클래스만 보면 이름만 State고 실제 State형태가 아닌 것을 확인할 수 있다.

하지만 우리는 이를 State로써 관리하고자 한다...

이럴 때 사용하는 게 produceState다.

 

produceState

@Composable
fun <T : Any?> produceState(initialValue: T, producer: suspend ProduceStateScope<T>.() -> Unit): State<T>

말 그대로 State로 produce(만들어주다) 해주는 역할이다.

Return an observable snapshot State that produces values over time without a defined data source.
producer is launched when produceState enters the composition and is cancelled when produceState leaves the composition.
producer should use ProduceStateScope.value to set new values on the returned State.
The returned State conflates values;
no change will be observable if ProduceStateScope.value is used to set a value that is equal to its old value,
and observers may only see the latest value if several values are set in rapid succession.
produceState may be used to observe either suspending or non-suspending sources of external data.

정의된 데이터 소스 없이 시간이 지남에 따라 값을 생성하는 관찰 가능한 snapshot State를 반환합니다.
producerproduceState가 composition을 시작할 때 시작되고, produceState가 composition을 끝낼 때 취소됩니다.
반환된 State는 값을 융합합니다;
ProduceStateScope.value를 사용하여 이전 값과 동일한 값을 설정하면 변경 사항이 관찰되지 않으며,
관찰자는 여러 값이 연속적으로 빠르게 설정된 경우에만 최신 값을 볼 수 있습니다.
produceState는 외부 데이터의 일시 suspending 또는 non-suspending 소스를 관찰하는 데 사용될 수 있습니다.

출처: 공식문서

 

produceState를 통해 DetainUiState를 state로 만들어서 관찰 가능하게 만들어보자.

초기값은 데이터를 로딩 중인 화면이 되어야하므로 isLoading = true로 설정했다.

주석으로 추가 설명 할게여

 

// details/DetailsActivity.kt file

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
	// 초기 상태: 로딩 중
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
    	// 그동안 데이터 가져오기
        val cityDetailsResult = viewModel.cityDetails
        // 데이터 가져오는 거 성공하면 상태 변경
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
        // 실패했을 때 상태 변경
            DetailsUiState(throwError = true)
        }
    }

	// 상태에 따라 맞는 화면 보이기
    when {
        uiState.cityDetails != null -> // ...
        uiState.isLoading -> //...
        else -> { onErrorLoading() }
    }
}

 


5. derivedStateOf

스크롤 내리면 UP FAB가 나오고, UP 버튼을 누르면 맨 위로 이동하는 기능을 만들어보자.

 

스크롤을 내릴 때를 알아채는 방법은 

LazyColumn LazyListState 기능인 listState.firstVisibleItemIndex가 0보다 큰지 확인하는 것이다.

기본 구현은 아래와 같다.

val listState = rememberLazyListState()
val showButton = listState.firstVisibleItemIndex > 0 // 비추

 

하지만 위 코드는 firstVisibleItemIndex가 변경될 때마다 recomposition된다.

스크롤을 자주 발생하는 일이므로 이렇게 코드를 짜는 것은 비효율적이다.

대신 true와 false 간에 조건이 변경될 때만 함수를 재구성하려고 합니다.

이때 사용할 수 있는 API가 derivedStateOf 이다.

 

derivedStateOf

@StateFactoryMarker
fun <T : Any?> derivedStateOf(calculation: () -> T): State<T>

 

derived는 파생된, 끌어낸,, 유래된...이라는 뜻인데... 파생된 상태? 이게 머고

유래된 상태라는 번역이 그나마 ㄱㅊ은듯?

기존 상태가 있고, 이 상태가 변할 때마다 derivedStateOf 내부를 실행하는 거니까 기존의(유래된) state가 아닐 때만 state를 변경한다~ 이런 의미??ㅋㅋ하하하하하

공식 문서도 너무 길고 이해 안 되는데 그래도 공식..이니까 가져옴.,,;;

Creates a State object whose State.value is the result of calculation.
The result of calculation will be cached in such a way that calling State.value repeatedly will not cause calculation to be executed multiple times,
but reading State.value will cause all State objects that got read during the calculation to be read in the current Snapshot,
meaning that this will correctly subscribe to the derived state objects if the value is being read in an observed context such as a Composable function.

State.valuecalculation의 결과인 State 객체를 만듭니다.
calculation의 결과는 State.value를 반복적으로 호출해도 calculation이 여러 번 실행되지 않도록 캐시될 것입니다.
그러나 State.value를 읽으면 calculation 중에 읽은 모든 State 객체가 현재 Snapshot에서 읽히게 됩니다.
이는 값이 Composable 함수와 같은 관찰된 컨텍스트에서 읽히는 경우 파생된 state 객체에 올바르게 적용된다는 것을 의미합니다.

출처: 공식문서

 

이게 대체 머슨 소리고....ㅠㅠ

derivedStateOf state가 변할 때마다 실행되고, 그 덕에 Composable 함수는 calculation 결과가 마지막 결과와 다를 때만 recomposition 된다...는 것만 알면 될듯...

공식 설명은 왜 맨날 복잡할까,,,?...왤까...???

 

암튼 deicedStateOf를 사용해 코드를 다시 짜면 이렇게 된다.

recomposition 후에도 상태를 유지하기 위해 remember 사용함

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

 

 

 

전체 코드는 아래와 같다.

UP FAB 누를 때 맨 위로 이동하는 listState.scrollToItem(0)는 suspend 함수이므로 코루틴 내에서 실행되도록 했다.

// base/ExploreSection.kt file

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title, 
                /* 생략 */
            )
            Spacer(Modifier.height(8.dp))
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = listState)

                val showButton by remember {
                    derivedStateOf {
                        listState.firstVisibleItemIndex > 0
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        modifier = /* 생략 */
                        onClick = {
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}

뜨아아 끝!!!

너무 길었다;;;

갑자기 넘 중요해보이는 개념들이 와다다다 나오니까 공식문서 해석하랴 API 이해하랴 겁나 오래 걸렸네;;;

그래도 다 끝내니 뿌듯

갈 길이 멀지만,,,그래도 마니 배운 거 같다.

이제 슬슬 개인 플젝해도 될듯,,,??

반응형
Comments