본문 바로가기

안드로이드 Android

안드로이드 개발 (9) Compose 상태 관리

jetpack compose 에 한창 포스팅 중입니다.

https://gift123.tistory.com/33

 

안드로이드 개발 (8) Compose 이해 정리

이번 포스팅부터 Compose에 대해 차근히 파헤쳐 가보겠습니다. Android Compose 공식 문서를 보면서 정리한 내용들 입니다. https://developer.android.com/jetpack/compose/mental-model?hl=en Compose 이해  |..

gift123.tistory.com

-이전편 Compose 이해정리 

 

 

오늘은 Compose 상태관리를 알아보도록 하겠습니다.

Compose에서의 상태 

*앱의 상태는 시간에 따라 변할 수 있는 값을 모두 포함한 것을 이야기한다.

(ex: Room 데이터베이스부터 클래스 변수까지 모든 변할 수 있는값을 뜻함)

 

상태개념은 Compose에서 핵심

 

-모든 안드로이드 앱은 사용자에게 앱의 상태를 표시함 

->예를들어 아래와 같은경우들이 있음

  • 네트워크 연결을 설정할 수 없을 때 표시되는 스낵바
  • 블로그 게시물 및 관련 댓글
  • 사용자가 클릭하면 버튼에서 재생되는 물결 애니메이션
  • 사용자가 이미지 위에 그릴 수 있는 스티커

-Jetpack Compose 는 상태를 저장하거나 사용하는 위치와 방법들을 명시적으로 나타냄

 

컴포지션 및 리컴포지션

컴포지션: Jetpack Compose가 컴포저블을 실행할 때 빌드한 UI에 관한 설명

초기 컴포지션: 처음 컴포저블을 실행하여 컴포지션을 만듬

리컴포지션(재구성): 데이터가 변경될 때 컴포지션을 업데이트하기 위해 컴포저블을 다시 실행하는 것을 말합니다.

 

-컴포지션은 UI를 기술하는 역할을 하며 컴포저블을 실행하면 생성

(컴포지션은 UI를 기술하는 컴포저블의 트리 구조)

 

컴포지션 흐름 

1) 초기 컴포지션 시 Compose는 컴포지션에서 호출하는 컴포저블을 추적

2) 추적 후 다음 앱 상태가 변경되면 Jetpack Compose는 리컴포지션을 예약

3) 리컴포지션에서는 상태 변경에 관한 응답으로 변할 수 있는 컴포저블을 실행

4) Jetpack Compose는 변경사항을 반영하도록 컴포지션을 업데이트

 

-컴포지션은 초기 컴포지션을 통해서만 생성되고 리컴포지션을 통해서만 업데이트

-컴포지션을 수정하는 유일한 방법은 리컴포지션을 통하는 것입니다.

 

상태 소개

예제 1. 텍스트를 적어도 아무것도 실행되지않는 Composable함수

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       Text(
           text = "Hello!",
           modifier = Modifier.padding(bottom = 8.dp),
           style = MaterialTheme.typography.h5
       )
       OutlinedTextField(
           value = "",
           onValueChange = { },
           label = { Text("Name") }
       )
   }
}

텍스트 박스에 글을 적어도 반응이 없습니다.

 

예제2. 이벤트가 작동하는 코드

   @Composable
    fun HelloContent() {
        Column(modifier = Modifier.padding(16.dp)) {
            var name by remember { mutableStateOf("") }
            Text(
                text = "Hello",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.h5
            )
            OutlinedTextField(
                value = name,
                onValueChange = { name = it },
                label = { Text("Name") }
            )
        }
    }

Composable함수를 업데이트를 하려면 

 

 remember { mutableStateOf() }를 사용해야합니다.

 

OutlinedTextField의 상태를 나타내는 값을 전달하고,  (value) 

OutlinedTextField의 값이 변경될 때 상태를 업데이트하는 코드를 추가합니다. (onValueChance)

 

그러면 Composable함수를 업데이트를 하기 때문에

아래와 같이 동작합니다.

 

Comosable 함수는

remember Composable을 사용해서 메모리에 단일 객체를 저장할 수 있습니다. 

remember에 의해 계산된 값은 초기 컴포지션 중에 컴포지션에 저장되고

저장된 값은 리컴포지션(재구성) 중에 반환됩니다.

 

 

-remember는 객체를 컴포지션에 저장하고, 

-remember를 호출한 컴포저블이 컴포지션에서 삭제되면 그 객체를 삭제합니다.

-remember는 변경 가능한 객체뿐만 아니라 변경할 수 없는 객체를 저장하는 데에도 사용할 수 있습니다.

-remember는 리컴포지션(재구성)에서 상태를 보존하는 데 도움이 됩니다.

(위 예제에서 remember를 함께 사용하지 않고 mutableStateOf를 사용하면 HelloContent 함수의 텍스트가 리컴포지션할때마다 빈 문자열로 다시 초기화됩니다.)

 

mutableStateOf

 

mutableStateOf는 Compose에서 observable type인 MutableState을 생성합니다.

interface MutableState<T> : State<T> {
   override var value: T
}

 

위 value가 변경되면 그 값을 읽는 Comosable 함수의 리컴포지션(재구성)이 생깁니다.

 

아래와 같은 3가지 유형으로 mutableStateOf를 사용할 수 있습니다. 

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

remember와 같이 사용하면 

기억된 값을 다른 Composable의 매개변수로 사용하거나 구문의 로직으로

사용하여 표시할 Composable을 변경할 수 있습니다.

 

예를 들어

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       var name by remember { mutableStateOf("") }
       if (name.isNotEmpty()) {
           Text(
               text = "Hello, $name!",
               modifier = Modifier.padding(bottom = 8.dp),
               style = MaterialTheme.typography.h5
           )
       }
       OutlinedTextField(
           value = name,
           onValueChange = { name = it },
           label = { Text("Name") }
       )
   }
}

 

if문으로 name변수가 비어 있지 않는 경우 인사말을 표시하는 분기처리도 가능합니다. 

그러면 아래처럼 name에 text가 비어있으면 위의 인사말이 사라졌다가

text가 글이 있으면 인사말이 표시 됩니다. 

 

 

remember가 리컴포지션(재구성) 과정 전체에서 상태를 유지하는 데 도움은 되지만

구성 변경시에 유지가 되지 않습니다. 

( the state is not retained across configuration changes.)

 

이 경우에는 rememberSaveable을 사용해야 합니다.

 rememberSaveable은 Bundle에 저장할 수 있는 모든 값을 자동으로 저장합니다.

다른 값의 경우에는 맞춤 Saver 객체를 전달할 수 있습니다.

 //위 예제에서 var name by remember {mutableStateOf("")} 이걸 지우고 아래껄 사용하면 
 //구성 변경 환경에서도 상태가 저장됨
 var name by rememberSaveable { mutableStateOf("") }

예시)

구성 변경이나 전화 통화 같은 중단 상황이 발생하면 내부 상태를 유지할지 선택해야합니다.

유지해야 하는 경우라면 rememberSaveable을 사용하여 상태를 저장합니다.

 

Stateful  와 stateless

remember객체를 저장하는 데 사용하는 Composable은 자체적으로 상태를 생성하여 Composable을 Stateful 합니다.

이것은 호출자가 상태를 제어 할 필요가없고 상태를 직접 관리하지 않고도 사용할 수있는 상황에서 유용 할 수 있습니다. 

 

그러나 Composable이 자체 상태를 갖는 경우 컴포저블의 재사용과 테스트가 어렵게 됩니다

 

재사용과 테스트를 쉽게 하려면 상태를 갖지않는 Composable인 Stateless Composable도 만들어야 합니다.

 

재사용 가능한 Composable를 개발할 때

동일한 컴포저블의 스테이트풀(Stateful) 버전과 스테이트리스(Stateless) 버전을 모두 노출하고자 할 수 있습니다.

 

스테이트풀(Stateful) 버전은 상태를 염두에 두지 않는 호출자에 편리하며,

스테이트리스(Stateless) 버전은 상태를 제어하거나 끌어올려야 하는 호출자에 필요합니다.

 

상태 끌어올리기 State Hosting

 상태 끌어올리기는 구성요소를 스테이트리스(Stateless)로 만들기 위해 상태를 트리 위로 옮기는 패턴입니다.

이 패턴이 컴포저블에 적용되는 경우 컴포저블에 매개변수 2개가 추가될 때가 많습니다.

  • value: T: 표시할 현재 값
  • onValueChange: (T) -> Unit: T가 제안된 새 값인 경우 값을 변경하도록 요청하는 이벤트

(2개만 사용한다는 얘기는 아닙니다. 필요하다면 파라미터를 더 만들어서 쓰면 됩니다.)

 

예제.

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

위 예에서 HelloContent에서 name onValueChange를 추출한 다음,

이 항목을 트리 상단에  HelloContent를 호출하는 HelloScreen Composable로 옮깁니다.

 

HelloContent는 변경할 수 없는 String 매개변수의 형태로 상태에 액세스할 수 있습니다.

또한 상태 변경을 요청하고자 할 때 호출할 수 있는 람다 onNameChange에도 액세스할 수 있습니다.

 

람다는 Composable의 이벤트를 설명하는 가장 일반적인 방법입니다.

위 예에서 (String) -> Unit를 사용해서 onNameChange 이벤트를 정의합니다.

 

HelloContent에서 상태를 끌어올리면 더 쉽게 Composable을 추론하고

여러 상황에서 재사용하며 테스트할 수 있습니다.

 

HelloContent는 상태의 저장 방식과 분리됩니다.

 

분리된다는 것은 HelloScreen을 수정하거나

교체할 경우 HelloContent의 구현 방식을 변경할 필요가 없다는 의미입니다.

상태가 내려가고 이벤트가 올라가는 패턴을 단방향 데이터 흐름이라고 합니다.

이 경우 상태는 HelloScreen에서 HelloContent로 내려가고

벤트는 HelloContent에서 HelloScreen으로 올라갑니다.

 

단방향 데이터 흐름을 따르면 UI에 상태를 표시하는

Composable과 상태를 저장하고 변경하는 앱 부분을 서로 분리할 수 있습니다.

 

또 다른 예제.

 

@Composable
fun ExpandingCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }
    ExpandingCard(
        title = title,
        body = body,
        expanded = expanded,
        onExpand = { expanded = true },
        onCollapse = { expanded = false }
    )
}

@Composable
fun ExpandingCard(
    title: String,
    body: String,
    expanded: Boolean,
    onExpand: () -> Unit,
    onCollapse: () -> Unit
) {
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(title)
            if (expanded) {
                Spacer(Modifier.height(8.dp))
                Text(body)
                IconButton(onClick = onCollapse, Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandLess, contentDescription = "Expand less")
                }
            } else {
                IconButton(onClick = onExpand, Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandMore, contentDescription = "Expand more")
                }
            }
        }
    }
}

 

  • 단일 소스 저장소: 상태를 복제하지 않고 위로 옮겼기 때문에expanded의 소스 저장소가 하나만 있습니다. 버그 방지에 도움이 됩니다.
  • 캡슐화: 스테이트풀(Stateful) ExpandingCard만 상태를 수정할 수 있습니다. 
  • 공유 가능: 끌어올린 상태를 여러 Composable과 공유 가능합니다. (예를 들어 Card가 펼쳐질 때 Fab 버튼을 숨기려고 한다면 끌어올리기를 통해 그 작업이 가능합니다.)
  • 가로채기 가능: 스테이트리스(Stateless) ExpandingCard의 호출자는 상태를 변경하기 전에 이벤트를 무시할지 수정할지 결정할 수 있습니다.
  • 분리됨: 스테이트리스(Stateless) ExpandingCard의 상태는 어디에나 저장할 수 있습니다. 예를 들어 이제는 title, body, expanded를 ViewModel로 옮길 수 있습니다.

위예제도 마찬가지로 단방향 데이터 흐름을 따르게 됩니다.

 

이 두번째 예제도 마찬가지로

 

상태는 스테이트풀(Stateful) 컴포저블에서 아래로 전달되고

이벤트는 스테이트리스(Stateless) 컴포저블에서 위로 흐릅니다.

 

 *상태를 끌어올릴 때 상태의 이동 위치를 쉽게 파악할 수 있는 세 가지 규칙이 있습니다.*

  1. 상태는 적어도 그 상태를 사용하는 모든 컴포저블의 가장 낮은 공통 상위 요소로 끌어올려야 합니다(읽기).
  2. 상태는 최소한 변경될 수 있는 가장 높은 수준으로 끌어올려야 합니다(쓰기).
  3. 동일한 이벤트에 대한 응답으로 두 상태가 변경되는 경우 두 상태를 함께 끌어올려야 합니다.

 

Compose에서 다른 유형의 상태 사용

 Compose에서는 상태를 보존하기 위해 MutableState<T>를 사용할 필요가 없습니다.

 

 Compose는 observable type을 지원합니다.

observable type을 읽으려면 상태를 State<T>로 변환해야 합니다.

그래야 상태가 변할 때 자동으로 리컴포지션(재구성)됩니다.

Compose에는 이미 안드로이드에서 자주 사용 하는observable type에서 State<T>를 만들 수 있는 함수가 내장되어 있습니다.

앱에 observable한 커스텀 클래스를 사용하는 경우 observable type을 읽어오기 위한

Compose용 확장 함수를 빌드할 수 있습니다.

 

모든 변경사항을 수신하도록 Compose에 허용하는

모든 객체를 State<T>로 변환하고 Composable을 통해 읽어올 수 있습니다.

 

Compose는 State<T> 객체를 읽어오면서 자동으로 리컴포지션(재구성)됩니다.

Compose에서 LiveData 같은 다른 observable type을 사용할 경우

Composable에서 LiveData<T>.observeAsState() 같은 Composable 함수를 사용하여

그 type을 읽어오려면 유형을 State<T>로 변환해야 합니다.

 

주의할점: Compose에서 ArrayList<T> 또는 mutableListOf() 

같은 변경 가능 객체를 상태로 사용하면 앱에 잘못되거나 오래된 데이터가 표시될 수 있습니다.

 

변경 가능한 객체 중 ArrayList<T> 또는 변경 가능한 데이터 클래스

같은 observe가 불가능한 객체는

그 객체가 변경될 때 컴포지션을 트리거하도록 Compose에서 관찰할 수 없습니다.

 

observe가 불가능하면서 변경 가능한 객체를 사용하는 대신에

State<List<T>> 변경 불가능한 listOf() 같은 관찰 가능 데이터 홀더를 사용하는 것이 좋습니다.

 

ViewModel 및 상태

Compose에서 ViewModel을 사용하면 상태를 observable type( LiveData, Flow,Rx java 등등)에

노출하고 그 상태에 영향을 주는 이벤트를 처리할 수도 있습니다.

 

예제.

 

ViewModel

class HelloViewModel : ViewModel() {
    
    private val _name = MutableLiveData("")
    val name: LiveData<String> = _name
    
    fun onNameChange(newName: String) {
        _name.value = newName
    }
}

 

 

Activity

@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
    val name: String by helloViewModel.name.observeAsState("")
    HelloContent(name = name, onNameChange = { helloViewModel.onNameChange(it) })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

 

observeAsState는 LiveData<T>를 관찰하고, 

LiveData가 변경될 때마다 업데이트되는 State<T> 객체를 반환합니다. 

 

State<T>는 J Compose가 직접 사용할 수 있는 관찰 가능한 유형입니다. 

observeAsState는 컴포지션에 있는 동안에만 LiveData를 관찰합니다.

 

 

val name: String by helloViewModel.name.observeAsState("")

observeAsState에서 반환된 상태 객체를 자동으로 래핑 해제하는 구문 슈가입니다.

또한 할당 연산자(=)를 사용하여 상태 객체를 할당할 수 있습니다.

이 경우 String 대신 State<String>이 됩니다.

 

val nameState: State<String> = helloViewModel.name.observeAsState("")

 

ViewModel 사용시, 

 

상태는 HelloViewModel에서 아래로 흐르고

이벤트는 HelloScreen에서 위로 흐르는 단방향 데이터 흐름 패턴을 따릅니다.

 

  1. 이벤트: 사용자가 문자를 입력시 그 응답으로 onValueChange가 호출
  2. 상태 업데이트: HelloViewModel.onNameChange가 처리를 진행 후 변경 가능한 LiveData, _name의 상태를 설정
  3. 상태 표시: HelloViewmodel.name의 값이 변경 -> observeAsState에서 관찰됩니다. 그러면 HelloScreen이 name의 새 값에 따라 UI를 기술하도록 다시 실행

 

Compose 상태 복원

- 액티비티 또는 프로세스가 다시 생성된 이후 rememberSaveable을 사용하여 UI 상태를 복원

- rememberSaveable은 재구성 과정 전체에서 상태를 유지

- rememberSaveable은 활동 및 프로세스 재생성 전반에 걸쳐 상태를 유지

 

상태를 저장하는 방법

@Composable
fun MyExample() {
    var selectedId by rememberSaveable<String?> { mutableStateOf(null) }
    /*...*/
}

Bundle에 추가되는 모든 데이터 유형은 자동으로 저장됩니다. 

Bundle에 추가할 수 없는 항목을 저장하려는 경우 몇 가지 옵션이 있습니다.

 

가장 간단한 방법은 객체에 @Parcelize 주석을 추가하는 것입니다.

그러면 객체가 parcelable이 되며 번들로 제공될 수 있습니다. 

 

@Parcelize
data class City(val name: String, val country: String)

@Composable
fun MyExample() {
  var selectedCity = rememberSaveable { mutableStateOf(City("Madrid", "Spain")) }
}

MapSaver

 

어떤 이유로 @Parcelize가 적합하지 않을 경우

 mapSaver를 사용하여 시스템이 Bundle에 저장할 수 있는 값 집합으로

객체를 변환하는 고유한 규칙을 정의할 수 있습니다.

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

ListSaver

맵에 대한 키를 정의 할 필요가 없도록 listSaver 인덱스를 키로 사용 하고 사용할 수도 있습니다 .

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

 

컴포즈에 대해 알면 알수록 더 빠져드는것 같습니다.

이상 Compose 상태관리에 대한 정리였습니다.