본문으로 바로가기
반응형

Photo by unsplash

State 란?

Room database부터 클래스의 변수까지(최하위에서부터 최상위레벨 까지) 앱은 시간에 따라 계속적으로 상태가 변화합니다. Android의 앱들은 이렇게 변화하는 상태를 사용자에게 화면에 표시하여 인지하도록 해 줍니다. 예를 들면,

  • 네트워크가 끊어졌을 때 Snackbar로 알림 표시
  • 블로그의 포스팅과 댓글 표시
  • 버튼을 눌렀을 때 ripple 효과
  • 사용자가 사진 위에 그려서 붙이는 sticker

이 글에서는 앱에서 Compose로 상태를 표시할 때 해당 상태가 어디에 위치해야 하며, 어떻게 저장되고 사용되는지에 대한 내용을 다룹니다. 또한 Jetpack Compose에서 이러한 상태들을 연결하고 관리하기 위해 제공하는 API들에 대해서 얘기합니다.

이 글은 Android developer 공식 사이트에서 제공하는 문서를 기반으로 의역, 번역 하였습니다.[1]

State와 composition

앞선 글들에서 계속 얘기해 왔듯이 Compose는 선언형(declarative) UI framework입니다. 따라서 화면을 변경하기 위해서는 해당 부분을 그리는 composable function에 변경된 데이터를 전달하여 다시 호출하는 방법밖에는 없습니다. 따라서 composable function의 param은 UI의 state를 표현하는 수단이며, 이 상태가 변경됨을 명시적으로 알 수 있어야 새로운 param의 정보로 변경된 화면을 그릴 수 있습니다.

@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") }
        )
    }
}

화면에 Text와 입력 가능한 TextField를 배치했습니다. 실제 실행 후에 TextField에 입력값을 넣더라도 화면에는 아무 변화가 일어나지 않습니다. 즉 value 값이 변경되었으나, 화면이 갱신되지 않기 때문에 입력한 글자조차 화면에 보이지 않습니다. 그렇다면 이런 동작들을 정상적으로 실행시키기 위해서 composition과 recomposition이 어떻게 동작하는지에 대해서 알아보겠습니다.

그전에 먼저 사용할 용어들에 대한 정의가 필요합니다.

Composition: Jetpack Compose가 composable function을 실행시켜서 만들어진 UI를 말합니다.
Initial composition: composable function이 수행되어 처음으로 만들어진 Composition을 말합니다.
Recomposition: 데이터가 변경되어 composable을 재실행하는 걸 말합니다.

 

State in composables

Composable function은 recompose 되기 때문에 일반적인 변수를 함수 내에 선언해서는 안됩니다. 함수가 재시작될 때마다 초기화되기 때문에 제 역할을 못하는 거죠. 따라서 remember라는 composable을 이용하여 변수를 선언해야 하며, 이 변수는 initial composition에서 메모리에 저장되어, recompose때에 값을 반환받아 사용할 수 있습니다. 즉 recompose로 인한 함수의 재호출과 상관없이 변숫값이 유지될 수 있습니다.

또한 mutable / immutable변수 둘 다 remember로 저장할 수 있습니다. 물론 이 변수는 해당 composable function이 composition에서 제거될 때 같이 제거됩니다. (예를 들면 화면에서 더 이상 보이지 않게 될 때가 되겠죠?)

Compose는State <T> 타입의 변수를 runtime에 observing 할 수 있습니다. 이 타입은 mutableStateOf를 이용하여 MutableState <T>로 만들어 사용합니다.

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

이처럼 State의 value가 변경되면 이 값을 읽어가는 compose 들은 recompose의 대상이 됩니다. 예를 들어 ExpandingCard에서 expanded가 변경되면 ExpandingCard가 recomposed 됩니다.

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

MutableState 객체를 composable function 내부에 선언하는 세 가지 방법입니다. 물론 어떤 것이든 형식만 다를 뿐 동일하기 때문에 취향에 맞춰서??? 가독성이 높다고 생각되는 형태로 사용하시면 됩니다.

remembered 된 값(value)은 composable의 param으로 사용해도 되고, 특정 composable을 보여줄지 말지에 따른 logic 구문에 사용해도 상관없습니다.

@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") }
       )
   }
}

위 구문처럼 nameremeber 변수로 사용하면, 이를 이용하여 이름이 있을 때만 특정 Text를 보여주는 구문을 작성할 수 있습니다.

다만 remeber 변수는 recompose시에는 저장되어 값을 유지할 수 있으나, configuration change가 발생하면 (화면이 회전하거나 해서 다시 그려지는 경우) 값이 유지되지 않습니다. 따라서 이를 유지하기 위해서는 remeberSaveable을 사용해야 합니다. 이때는 값을 bundle로 사용하기 때문에 bundle에서 지원하는 primitive type + string + array... 등을 이용할 수 있습니다. 하지만 그 외의 custom object의 경우에는 saver object를 사용해야 합니다.

saver obejct에 대한 내용은 이 포스팅 끝부분에서 다룹니다.

Other supported types of state

사실 Composable이 MutableState만을 observing하지 않습니다. State<T>를 구현하는 경우 이를 observing 할 수 있는데, 아래의 대표적인 타입들은 State로 변경하는 extension function을 제공합니다.

  • LiveData
  • Flow
  • RxJava2

예를 들어 LiveData를 State 형태로 변환하기 위해서는 LiveData<T>.observeAsState()를 사용할 수 있습니다.

mutableListOf()나 ArrayList<T>처럼 변경되는 객체는 compose에서 state로 사용하지 않습니다. 변경이 되더라도 observing 할 수 없고, 그렇기 때문에 데이터가 바뀌더라도 recomposing을 시작하지 못하기 때문입니다.

따라서 이런 값들의 변경을 recomposing의 trigger로 사용하려면 State<List<T>> 처럼 State로 감싸서 사용하거나 불변 객체인 listOf()를 사용해야 합니다.

Stateful vs Stateless

위 예제에서 name을 remeber 변수로 갖는 HelloContent()는 stateful 한 composable function입니다. 이는 함수 내부에서 상태를 가지기 때문에 외부에서 이 상태의 변경에 따른 신경을 쓸 필요가 없습니다. caller는 그냥 필요에 따라서 호출해서 쓰는 거죠.

다만 이런 경우 테스팅 측면에서도 불리하고, caller에서 State를 변경할 수 없기에 재사용성이 줄어드는 문제가 있습니다.

따라서 이런 단점을 극복하기 위해서 state hoisting pattern를 이용하여 상태를 갖지 않는(stateless 한) composable으로 변경할 수 있습니다. 물론 stateless 한 composable의 경우 stateful의 단점을 극복하는 대신 stateful의 장점은 없어지겠죠? 어느 한쪽이 맞다기보다는 상황에 따라서 두 가지 중 좀 더 맞는 쪽으로 또는 둘 다 만들어서 사용하면 됩니다.

State hoisting

Stateless 한 composable을 만들기 위해서 상태를 caller로 끌어올리는걸 state hoisting이라고 합니다. Jetpack Compose에서는 state hoisting을 위해서 일반적으로 다음과 같이 두 개의 param을 만드는 pattern을 제시했습니다.

  • Param1: value T: compose에서 표시해야 할 값
  • Param2: onValueChage: (T) -> Unit: T의 값이 compose의 interaction에 의해 변경되는 경우 값을 전달하도록 하는 lambda

물론 이 두 개만을 사용하는 게 아니라 composable의 여러 interfaction이 있는 경우 추가적인 param을 넣을 수도 있습니다.

State hoising을 이용하여 state가 caller로 끌어올려지면 아래와 같은 특성이 생깁니다.

  • Single source of truth: state를 여러 곳에서 복제해서 사용하지 않고 state를 나태 내는 source가 하나 이므로 버그를 방지할 수 있습니다.
  • Encapsulated: stateful composable만 상태를 변경할 수 있습니다. 상태가 caller로 끌어올려졌더라도 해당 상태는 위치한 composable의 내부 속성이 되므로 캡슐화됩니다.
  • Sharable: 위 예제에서 사용한 name이 hositing 되면 이 name을 여러 composable function에서 사용할 수 있습니다. 물론 name이 변경되면 관련된 composable들이 모두 recompose 되도록 쉽게 관리할 수 있습니다.
  • Interceptable:  상태가 caller로 옮겨졌으니 해당 상태를 if문 등을 통해서 제어할 수 있습니다. 즉, 상태가 바뀌는걸 caller가 제어할 수 있습니다.
  • Decoupled: state가 hoisting 되면 composable과 state는 의존관계가 없어집니다. 즉 state는 어디에도 저장될 수 있으며, Viewmodel 같은 곳으로 옮겨져서 처리할 수도 있습니다.

그럼 위 HelloContent()의 name을 hoisting 하여 HelloScreen()이라는 상위 Composable로 옮겨보겠습니다.

@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") }
        )
    }
}

state가 caller로 이동되면서 HellcoContent()는 다른 곳에서 재사용하기 유리한 형태가 되었으며, param으로 값을 변경할 수 있기 때문에 test 측면에서도 유리해졌습니다. 또한 state가 HelloContent()와 분리되었기 때문에 HelloScreen()에서 어떤 변경을 하든 HelloContent()에는 영향을 미치지 않습니다. 즉 두 함수가 decoupling 되었습니다.

참조: https://developer.android.com/images/jetpack/compose/udf-hello-screen.png

상태와 event의 이동은 그림처럼 항상 단방향으로만 흘러야 합니다. state는 항상 위에서 아래로, event는 아래서 위로 전달되도록 구성해야만 UI에 상태를 표현하는 부분과, 상태를 저장/변경하는 부분을 서로 분리할 수 있습니다.

State hositing을 할 때 state를 어디까지 끌어올려야 할지에 대한 위치를 파악하는 규칙은 아래 세 가지입니다.

  1. 여러 Composable이 하나의 state를 공용으로 읽어 간다면 그 Composable들 중 가장 낮은 공통 상위 요소에 위치시킵니다. 즉 예제에서 사용한 name을 A, B, C composable에서 사용한다면, A, B, C의 공통 부모 composable 중에 depth가 가장 낮은 (A, B, C와 가장 근접한) 공통 부모 composable에 state를 위치시켜야 합니다. 최소 공배수의 개념이라고 보면 될 것 같네요.
    • State should be hoisted to at least the lowest common parent of all composables that use the state (read).
  2. State는 변경될 수 있는 가장 높은 수준으로 끌어올려야 합니다. State에 쓰는 작업은 가장 상위 레벨에 위치해야 합니다. 쉽게 말해 MVVM 패턴이라면 ViewModel에 state가 위치해야 합니다. 최대 공약수의 개념이라고 하면 될 것 같습니다. (더 헷갈리려나요?)
    • State should be hoisted to at least the highest level it may be changed (write).
  3. 어떤 이벤트에 대한 응답으로 변경되는 state가 한 개가 아니라면, 같이 변경되는 state들은 같이 hoisting 되어야 합니다.
    • If two states change in response to the same events they should be hoisted together.

hoisting된 state 위치가 적절하지 않으면 event와 state의 단반향 흐름이 깨질 수 있습니다.

ViewModel and state

ViewModel은 configuration에 대해서도 변경되지 않기 때문에 state를 담는 좋은 위치입니다. 게다가 ViewModel 내부에 state가 존재하기에 캡슐화되고, 여러 composable이 사용하는 state를 단일하게 유지할 수 있는 장점도 갖습니다.

ViewModel에 state를 담으려면 LiveDataStateFlow 같은 observable 객체로 감싸 져야 합니다. 그래야 이를 참조하는 composable이 변경 시 같이 recomposing 될 수 있습니다.

이제 마지막으로 예제의 name을 ViewModel까지 끌어올려 보겠습니다.

@HiltViewModel
class MainViewModel @Inject constructor(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    // LiveData holds state which is observed by the UI
    // (state flows down from ViewModel)
    private val _name = MutableLiveData("")
    val name: LiveData<String> = _name

    // onNameChange is an event we're defining that the UI can invoke
    // (events flow up from UI)
    fun onNameChange(newName: String) {
        _name.value = newName
    }
}

@Composable
fun HelloScreen(mainViewModel: MainViewModel = viewModel()) {
    // by default, viewModel() follows the Lifecycle as the Activity or Fragment
    // that calls HelloScreen(). This lifecycle can be modified by callers of HelloScreen.

    // name is the current value of [helloViewModel.name]
    // with an initial value of ""
    val name: String by mainViewModel.name.observeAsState("")
    HelloContent(name = name, onNameChange = { mainViewModel.onNameChange(it) })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
   ...
}

 

ObserveAsState는 LiveData<T>를 compose가 바로 observing 가능한 State<T>로 변경해 줍니다. 따라서 LiveData의 값이 바뀌면 Compose가 바로 알 수 있는 거죠. 다만 observeAsState를 사용하기 위해서 아래와 같이 dependency를 gradle에 추가해 줘야 합니다. [2]

implementation "androidx.compose.runtime:runtime-livedata:$compose_version"

또한 아래 코드에서 param의 default value로 사용한 viewModel()은 이미 activity에 추가되어 있던 viewModel을 바로 가져오기 위함이며, 이를 사용하기 위해서도 dependency 추가가 필요합니다. [2]

@Composable
fun HelloScreen(mainViewModel: MainViewModel = viewModel()) {
  ...
}
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0-beta01"

또한 State의 값을 바로 value로 전달받기 위해서 "by"를 사용했습니다. syntactic sugaring 되어 자동으로 state에서 value를 뽑아주는 역할을 하며, State<T> 객체로 바로 받으려면 "="을 사용하면 됩니다.

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

// or

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

Observing State

State<T> 형태의 선언은 "Compose에게 해당 변수의 변경을 주시하라" 라고 알림을 주는것과 같습니다. 따라서 State를 갖는 형태의 변수가 변경되면 해당 변수가 사용된 composable을 찾아서 recomposing을 진행 합니다. 좀더 상세한 동작을 알기 위해 아래와 같이 ViewModel을 만들어 보겠습니다. [4]

class TodoViewModel : ViewModel() {

   // private state
   private var currentEditPosition by mutableStateOf(-1)

    // state: todoItems
    var todoItems = mutableStateListOf<TodoItem>()
        private set

   // state
   val currentEditItem: TodoItem?
       get() = todoItems.getOrNull(currentEditPosition)

   // ..
}

currentEditItem은 State 변수인 todoItems와 currentEditPosition을 이용하여 값을 반환 합니다. 따라서 currentEditItem을 읽을때마다 todoItems와 crrentEditPosition을 observing하고 값이 변경되었다면 새로운 값을 get()하여 반환하게 됩니다.

여기서 currentEditItem은 State<T> 값을 일반적인 kotlin code로 변형한 형태의 변수 입니다.

Compose는 composable에서 읽는 모든 State<T> 변수를 observe합니다. 또한 currentEditItem 처럼 일반적인 kotlin 함수로 변경되어 composable에서 읽어가는 형태라도 observe의 대상이 됩니다. 예를 들어 A 라는 composable function이 currentEditItem의 값을 읽어간다면 currentEditPosition 과 todiItems 둘중 하나라도 변경되는 경우라도 A composable은 recomposing의 대상이 됩니다. 
class TodoViewModel : ViewModel() {

   // private state
   // private var currentEditPosition by mutableStateOf(-1)   
   private var currentEditPosition: Int = -1

   ..
}

만약 위 처럼 currentEditPosition을 일반 Int 변수로 사용한다면 Compose는 해당 값이 변경되더라도 추적할 수 없습니다.

반대의 경우로 State<T>는 Compose에서 사용하기 위해 만들어진 타입입니다. 따라서 Compose 용도가 아닌 Application의 상태를 저장하기 위한 용도로는 사용하지 말아야 합니다.

Restoring state in Compose

위에서 언급했던 rememberSaveable을 사용하면 state를 configuration이 바뀌거나, process가 죽더라도 State를 유지할 수 있습니다. 자동으로 bundle에 해당 상태를 저장해 주기 때문인데, 만약 저장하지 못하는 형태의 object라면 아래와 같은 세 가지 방법으로 저장 가능한 형태로 만들어 줄 수 있습니다.

Parcelize

제일 간단한 방법은 state object를parcelize로 바꿔서 저장하는 방법인데, 이 역시 annotation만 추가함으로써 간단하게 바꿀 수 있습니다.

먼저 build gradle에 plugin을 추가합니다. [3]

plugins {
    id 'kotlin-parcelize'
}

그리고 state object에 @Parcelize를 붙여주면 parcelabe 한 객체가 됩니다. [1]

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

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

MapSaver

Map 형태인 key / value로 object의 속성 값을 직접 지정하여, 저장할 방법을 명시해 줄 수도 있습니다. [1]

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

좀 더 간단하게 list 형태로도 StateSaver를 만들 수 있습니다. [1]

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"))
    }
}

 

 

References

[1] https://developer.android.com/jetpack/compose/state

[2] https://developer.android.google.cn/jetpack/compose/libraries

[3] https://developer.android.com/kotlin/parcelize

[4] https://developer.android.com/codelabs/jetpack-compose-state?hl=ko&continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fcompose%3Fhl%3Dko%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-state#8

반응형