천복만복 프로그래밍/천복만복 안드로이드

[Compose] Compose 에서 구성 변경(Configuration Change) 시 UI 상태 유지

U&MeBlue 2025. 2. 23. 21:25

📌 androidx.compose.runtime.saveable 패키지란?

Jetpack Compose에서 구성 변경(Configuration Change) 시 UI 상태를 유지하려면 androidx.compose.runtime.saveable 패키지를 활용해야 합니다. 이 패키지는 화면 회전, 다크모드 변경, 프로세스 종료 후 복원 등에서 UI 상태가 유지될 수 있도록 도와줍니다.


✅ 주요 API

API 설명
rememberSaveable 구성 변경 시에도 상태를 유지하도록 remember를 확장한 함수
Saver 커스텀 객체를 저장할 수 있도록 변환하는 클래스
mapSaver Saver의 간단한 버전으로, Map<String, Any>을 통해 객체를 저장 및 복원
listSaver Saver의 간단한 버전으로, List를 사용하여 객체 저장 및 복원

1️⃣ rememberSaveable 기본 사용법

remember는 구성 변경 시 상태가 유지되지 않지만, rememberSaveable을 사용하면 자동으로 상태가 저장됩니다.

@Composable
fun RememberSaveableExample() {
    var text by rememberSaveable { mutableStateOf("") }

    Column {
        TextField(value = text, onValueChange = { text = it })
        Text(text = "입력한 값: $text")
    }
}

📌 동작

  • 일반적인 remember와 동일하게 동작하지만, 화면 회전 후에도 상태가 유지됨.
  • 내부적으로 Bundle을 사용하여 상태를 저장하므로 기본 데이터 타입(Int, String, Boolean, List 등)만 저장 가능.

2️⃣ rememberSaveable과 MutableStateList 사용

SnapshotStateList 같은 Compose 전용 컬렉션(mutableStateListOf())도 저장 가능합니다.

@Composable
fun RememberSaveableListExample() {
    val items = rememberSaveable { mutableStateListOf("Apple", "Banana", "Cherry") }

    Column {
        items.forEach { Text(it) }
        Button(onClick = { items.add("Orange") }) {
            Text("Add Orange")
        }
    }
}

📌 동작

  • mutableStateListOf()를 rememberSaveable과 함께 사용하면 리스트 변경 사항도 저장됨.

3️⃣ Saver를 사용한 커스텀 객체 저장

rememberSaveable은 기본 데이터 타입만 저장할 수 있기 때문에, 사용자 정의 객체는 Saver를 이용하여 변환해야 합니다.

🚨 오류 발생 예시

data class User(val name: String, val age: Int)

@Composable
fun ErrorExample() {
    var user by rememberSaveable { mutableStateOf(User("Alice", 25)) } // ❌ ERROR
}

📌 오류 이유

  • User는 Bundle로 저장할 수 없는 객체이므로 rememberSaveable에서 사용할 수 없음.

✅ 해결 방법: Saver 사용

@Composable
fun RememberSaveableWithSaver() {
    val userSaver = Saver<User, List<Any>>(
        save = { listOf(it.name, it.age) }, // 저장할 때 리스트로 변환
        restore = { User(it[0] as String, it[1] as Int) } // 복원할 때 객체로 변환
    )

    var user by rememberSaveable(stateSaver = userSaver) { mutableStateOf(User("Alice", 25)) }

    Column {
        Text("이름: ${user.name}, 나이: ${user.age}")
        Button(onClick = { user = user.copy(age = user.age + 1) }) {
            Text("나이 증가")
        }
    }
}

📌 동작

  • User 객체를 List로 변환하여 저장하고, 다시 User로 복원.
  • 화면 회전 후에도 유지됨.

4️⃣ mapSaver를 사용한 객체 저장

Saver를 더 간단하게 만들고 싶다면 mapSaver 를 사용할 수도 있습니다.

val userSaver = mapSaver(
    save = { user -> mapOf("name" to user.name, "age" to user.age) },
    restore = { map -> User(map["name"] as String, map["age"] as Int) }
)

@Composable
fun RememberSaveableWithMapSaver() {
    var user by rememberSaveable(stateSaver = userSaver) { mutableStateOf(User("Bob", 30)) }

    Column {
        Text("이름: ${user.name}, 나이: ${user.age}")
        Button(onClick = { user = user.copy(age = user.age + 1) }) {
            Text("나이 증가")
        }
    }
}  

📌 장점

  • mapSaver를 사용하면 Saver보다 더 간단한 코드로 객체 저장 가능.

🎯 rememberSaveable은 화면 회전이나 프로세스 종료 후에도 UI 상태를 유지하는 핵심 API이므로, 상태 저장이 필요한 Compose UI에서는 적극적으로 활용해야 합니다! 🚀


📌 중첩된 커스텀 객체를 androidx.compose.runtime.saveable API로 저장하는 방법

androidx.compose.runtime.saveable 패키지를 이용하여 커스텀 객체가 다른 커스텀 객체를 멤버로 포함하고 있을 때도 저장할 수 있습니다.
이를 위해 Saver 또는 mapSaver를 활용하여 중첩된 객체를 개별 필드로 변환 및 복원해야 합니다.

1️⃣ Saver를 사용한 중첩 객체 저장

🚨 일반적인 rememberSaveable 사용 시 발생하는 문제

아래와 같은 중첩된 데이터 클래스를 rememberSaveable로 저장하면 에러가 발생합니다.

data class Address(val city: String, val zipCode: String)
data class User(val name: String, val age: Int, val address: Address)

@Composable
fun ErrorExample() {
    var user by rememberSaveable { mutableStateOf(User("Alice", 25, Address("Seoul", "12345"))) } // ❌ ERROR
}

🚨 이유

  • rememberSaveable은 Bundle에 저장 가능한 기본 데이터 타입만 지원 (Int, String, Boolean, List, etc.)
  • User 내부의 Address 객체는 Bundle로 직접 저장할 수 없음.
  • 따라서 객체를 저장 가능한 데이터로 변환해야 함.

✅ 해결 방법: Saver로 중첩 객체 저장

중첩된 커스텀 객체(Address)를 포함하는 User를 저장하려면 각 객체를 List로 변환해야 합니다.


val userSaver = Saver<User, List<Any>>(
    save = { listOf(it.name, it.age, listOf(it.address.city, it.address.zipCode)) },
    restore = { 
        val addressData = it[2] as List<String>
        User(it[0] as String, it[1] as Int, Address(addressData[0], addressData[1])) 
    }
)

@Composable
fun RememberSaveableNestedObject() {
    var user by rememberSaveable(stateSaver = userSaver) { 
        mutableStateOf(User("Alice", 25, Address("Seoul", "12345")))
    }

    Column {
        Text("이름: ${user.name}, 나이: ${user.age}")
        Text("주소: ${user.address.city}, ${user.address.zipCode}")
        Button(onClick = {
            user = user.copy(address = user.address.copy(city = "Busan")) // ✅ 새 객체 할당
        }) {
            Text("도시 변경")
        }
    }
}

📌 Saver로 해결한 원리

  1. addressSaver: Address 객체를 [city, zipCode] 리스트로 변환 후 저장.
  2. userSaver:
  3. User 객체를 [name, age, [city, zipCode]] 리스트로 변환 후 저장.
  4. 복원 시 리스트에서 name, age를 가져오고, addressData를 Address 객체로 변환.
  5. rememberSaveable(stateSaver = userSaver)를 사용하여 상태를 저장.

2️⃣ mapSaver를 사용한 중첩 객체 저장

mapSaver를 활용하면 Saver보다 더 간결한 코드로 중첩된 객체를 저장할 수 있습니다.


val userSaver = mapSaver(
    save = { user ->
        mapOf(
            "name" to user.name,
            "age" to user.age,
            "address" to mapOf("city" to user.address.city, "zipCode" to user.address.zipCode)
        )
    },
    restore = { map ->
        val addressMap = map["address"] as Map<String, String>
        User(map["name"] as String, map["age"] as Int, Address(addressMap["city"]!!, addressMap["zipCode"]!!))
    }
)

@Composable
fun RememberSaveableWithMapSaver() {
    var user by rememberSaveable(stateSaver = userSaver) {
        mutableStateOf(User("Bob", 30, Address("Incheon", "54321")))
    }

    Column {
        Text("이름: ${user.name}, 나이: ${user.age}")
        Text("주소: ${user.address.city}, ${user.address.zipCode}")
        Button(onClick = {
            user = user.copy(address = user.address.copy(city = "Daegu")) // ✅ UI 업데이트 반영
        }) {
            Text("도시 변경")
        }
    }
}

🚀 mapSaver를 활용한 방식

  • addressSaver는 Map<String, String> 형태로 저장.
  • userSaver는 Map<String, Any>을 사용하여 중첩된 Address도 Map 형태로 저장.
  • 복원 시 Map에서 데이터를 추출하여 User와 Address를 다시 생성.

androidx.compose.ui.text 패키지의 Saver.kt 를 참고하여 코드를 좀 더 가독성 있게 만들기

📌 Savers.kt에서 사용되는 save 확장 함수 이해하기

Jetpack Compose의 androidx.compose.ui.text.Savers.kt 파일을 보면, Saver<T, Saveable>을 좀 더 간결하게 다룰 수 있도록 확장 함수가 정의되어 있습니다.

/**
 * Utility function to be able to save nullable values. It also enables not to use with() scope
 * for readability/syntactic purposes.
 */
internal fun <T : Saver<Original, Saveable>, Original, Saveable> save(
    value: Original?,
    saver: T,
    scope: SaverScope
): Any {
    return value?.let { with(saver) { scope.save(value) } } ?: false
}

/**
 * Utility function to restore nullable values. It also enables not to use with() scope
 * for readability/syntactic purposes.
 */
internal inline fun <T : Saver<Original, Saveable>, Original, Saveable, reified Result> restore(
    value: Saveable?,
    saver: T
): Result? {
    // Most of the types we save are nullable. However, value classes are usually not but instead
    // have a special Unspecified value. In that case we delegate handling of the "false"
    // value restoration to the corresponding saver that will restore "false" as an Unspecified
    // of the corresponding type.
    if (value == false && saver !is NonNullValueClassSaver<*, *>) return null
    return value?.let { with(saver) { restore(value) } as Result }
}

/**
 * Use for non-null value classes where the Unspecified value needs to be separately handled,
 * for example as in the [OffsetSaver]
 */
private interface NonNullValueClassSaver<Original, Saveable : Any> : Saver<Original, Saveable>
private fun <Original, Saveable : Any> NonNullValueClassSaver(
    save: SaverScope.(value: Original) -> Saveable?,
    restore: (value: Saveable) -> Original?
): NonNullValueClassSaver<Original, Saveable> {
    return object : NonNullValueClassSaver<Original, Saveable> {
        override fun SaverScope.save(value: Original) = save.invoke(this, value)

        override fun restore(value: Saveable) = restore.invoke(value)
    }
}

// ...
  • save 확장함수 : 널 값을 갖는 상태도 저장할 수 있도록 구현됨. 널 값은 기본적으로 false 로 저장
  • restore 확장함수 : 널 값을 갖는 상태도 복원할 수 있도록 구현됨. 널 값은 기본적으로 false 로 저장
  • NonNullValueClassSaver : 널 값을 멤버 프로퍼티로 갖지 않는 클래스를 복원할때 널 값으로 저장된 (false 로 저장된) 값을 복원하려는 경우, 널 값이 아닌 Unspecified 와 같은 값으로 복원해야 하는데 이러한 처리를 해주는 클래스

이와 비슷한 확장함수를 저희 코드에도 선언하여 상태 값의 저장과 복원을 좀 더 가독성 있는 코드로 개선할 수 있습니다.

🔽 save 확장 함수 적용 후 코드 간결화

val addressSaver = Saver<Address, List<String>>(
    save = { listOf(it.city, it.zipCode) },
    restore = { Address(it[0], it[1]) }
)

val userSaver = Saver<User, List<Any>>(
    // 각 클래스 별로 상태 저장 로직을 분리하여 좀 더 가독성 있게 개선됨
    save = { listOf(it.name, it.age, addressSaver.save(it.address)) },
    restore = { 
        val addressData = it[2] as List<String>
        User(it[0] as String, it[1] as Int, addressSaver.restore(addressData)!!)
    }
)
728x90

'천복만복 프로그래밍 > 천복만복 안드로이드' 카테고리의 다른 글

[Compose] TooltipBox  (0) 2025.03.01
[Compose] SnapshotStateList  (0) 2025.02.23
[Compose] Alignment line  (0) 2025.02.21
인텐트 필터 매칭 동작 정리  (0) 2025.01.02
Edge to Edge  (2) 2024.10.02