애플리케이션에서 상태(State)는 시간이 지남에 따라 변경될 수 있는 모든 값입니다. 상태(State)라는 것은 클래스의 변수부터 Room 데이터베이스에 이르기까지 포함하는 광범위한 정의입니다.
아래는 상태(State)에 대한 몇 가지 예시입니다.
네트워크 연결을 설정할 수 없는 경우 표시되는 스낵바
블로그 게시물 및 관련된 댓글
사용자가 클릭될 때 보이는 버튼 리플 애니메이션 효과
이미지 위에 사용자가 그릴 수 있는 스티커
🧩 State와 Composition
컴포저블을 업데이트하는 유일한 방법은 새로운 파라미터 값으로 동일한 컴포저블 함수를 다시 호출하는 것입니다. 즉, TextField와 같이 기존의 XML으로 구성된 뷰처럼 자동으로 업데이트되지 않습니다. 이때, 이 파라미터 값은 UI 상태를 나타냅니다. 상태(State)가 업데이트될 때마다 재구성(recomposition)이 발생합니다.
초기 구성 (Initial Composition) : 처음으로 컴포저블을 실행하여 만들어진 컴포지션
재 구성 (Recomposition) : 데이터가 변경될 때, 컴포저블 트리를 업데이트하기 위해 동일한 컴포저블을 다시 실행하는 프로세스
🌲 재구성(Recomposition)
컴포즈(Compose)는 우리에게 익숙한 안드로이드 뷰 시스템의 UI 위젯 트리와 다르게 동작합니다. 컴포즈는 UI 위젯 트리 대신 컴포저블 트리를 생성합니다. 컴포즈가 처음으로 컴포지션을 실행할 때, 호출된 모든 컴포저블의 트리를 빌드합니다. 그다음, 재구성 시 호출되는 새 컴포저블 트리로 업데이트합니다.
TodoScreen Tree ( 출처 : Codelab - Using State in Jetpack Compose )
위의 구조에서 LazyColumn은 화면의 모든 자식을 재구성합니다. 그러면 TodoRow가 다시 호출되어 새로운 색상을 생성합니다. 즉, TodoRow는 재구성될 때마다 업데이트되고 아이콘의 색상이 계속 변화하게 됩니다. 아래와 같이 컴포지션 트리에 값을 저장할 수 있도록 변경하면, 재구성이 발생하더라도 아이콘의 색상이 유지되는 것을 확인할 수 있습니다.
컴포저블 함수는 remember 컴포저블을 이용하여 메모리에 단일 객체를 저장할 수 있습니다. remember는 초기 구성 과정 중 값을 저장하고, 재구성(Recomposition) 시 저장된 값을 다시 받아 사용합니다. 이때, remember은 가변/불변 객체로 저장하는 데 모두 사용할 수 있습니다.
📌 remember는 컴포지션 과정에서 객체를 저장하고, 컴포지션 중 remember를 호출하는 컴포저블이 제거되면 객체를 잊어버립니다.
⌨️ remember
remember(todo.id) { randomTint() }
key arguments – 이 메모리가 사용하는 'key'는 괄호 안에 전달되는 부분입니다. 여기서 todo.id를 키로 전달합니다.
calculation – 기억할 새 값을 계산하는 람다로 후행 람다로 전달됩니다. 여기에서 randomTint() 를 사용하여 임의의 값을 계산합니다.
위의 코드를 설명하자면, 처음 컴포저블 트리를 구성할 때 remember은 항상 randomTint를 호출하고 todo.id를 추적하며 재구성 시 기억한 결과를 사용합니다. 이후, 새로운 todo.id가 전달되지 않는 한 TodoRow는 randomTint를 호출하지 않고 기억된 값을 반환합니다.
💡 컴포지션에 기억된 값은 호출 컴포지블이 트리에서 제거되는 즉시 잊혀집니다. ( 호출하는 컴포저블이 트리에서 이동하는 경우도 다시 초기화됩니다. )
🎠 Compose에서 State란?
이제 Remember를 사용하여 컴포저블에 상태를 추가하는 방법에 살펴봅시다.
Stateful한 UI에는 대표적으로 EditText가 있습니다. 기존 안드로이드 뷰 시스템에서는 EditText 내부에 상태(State)가 있으며 이를 onTextChanged 리스너를 통해 알 수 있습니다. 하지만 단방향 데이터 흐름(Unidirectional Data Flow)을 위해 설계된 컴포즈에는 이와 같은 방법이 적합하지 않습니다.
# [🖱️클릭 ] 📺 Unidirectional Data Flow (단방향 데이터 흐름)이란?
이벤트(Event)가 위로 흐르고 상태(State)가 아래로 흐르는 디자인을 단방향 데이터 흐름(Unidirectional Data Flow)이라고 합니다. 단방향 데이터 흐름
@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에서 HelloScrren으로 올라가고, 상태는 HelloScreen에서 HelloContent로 내려갑니다. 이러한 단방향 데이터 흐름으로 인해, UI 상태를 표시하는 컴포저블을 앱의 저장 및 상태 변경 부분에서 분리할 수 있습니다.
# [🖱️클릭 ] 📺Unidirectional Data Flow (단방향 데이터 흐름)이란?
컴포즈의 TextField는 머티리얼 디자인의 EditText와 동일합니다. 하지만 컴포즈의 TextField는 Stateless 컴포저블입니다. 이를 아래의 코드와 같이 작성함으로써 Stateful TextField 컴포저블로 만들 수 있습니다.
출처 : Using state in Jetpack Compose - 6. State in Compose
val (value, setValue) = remember { mutableStateOf(default) }
위의 선언들은 모두 동일하며, 코드 작성 시 읽기 쉬운 코드 작성 방법으로 적절하게 선택합니다. 컴포지션에서 State<T>를 만들 때, 이를 기억해두지 않으면 모든 컴포지션에서 다시 초기화됩니다.
MutableState<T>는 MutableLiveData<T>와 유사하지만, 컴포즈와 런타임에 통합된다는 점에서 차이가 있습니다. 즉, 관찰 가능하므로 value가 업데이트될 때마다 컴포즈에 알리므로 모든 컴포저블을 재구성할 수 있습니다.
재구성 중 상태(State)를 유지하는데 remember는 도움이 되지만, 환경 변경(configration changes) 상태는 유지되지 않습니다. 이를 유지하려면 rememberSaveable를 사용해야 합니다. rememberSaveable은 번들(Bundle)에 저장할 수 있는 모든 값을 자동으로 저장합니다. 번들에서 기본적으로 제공하는 타입이 아닌 경우, 커스텀 Saver 객체로 전달할 수 있습니다.
Stateful VS Sateless
Stateful Composable은 시간이 지남에 따라 변경될 수 있는 상태를 내부에 저장하는 컴포저블입니다.
Remember를 사용해서 내부 State를 생성
호출하는 쪽에서 State를 관리하지 않음
재사용 가능성 적음
테스트 하기 어려움
Stateless Composable은 상태를 저장할 수 없는 컴포저블입니다. 즉, 어떠한 상태도 가지고 있지 않습니다. 이는 상태 호이스팅(State Hoisting)을 이용하여 만들 수 있습니다.
2. Refactor ➡️ Function (마우스 오른쪽 버튼 클릭 후 Ctl + Alt + M)을 선택합니다. 3. 새로운 함수가 public인지 확인하고 함수 이름을 지정합니다. 4. 필요한 경우 매개변수의 이름을 변경합니다. ( 아래에서는 setText→onTextChange, setIcon→onIconChange로 변경합니다. )
5. Ok 버튼을 누릅니다.
🔽 Stateless 컴포저블을 추출한 코드
@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) { // Stateful
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
val submit = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
}
// Stateless
TodoItemInput(
text = text,
onTextChange = setText,
icon = icon,
onIconChange = setIcon,
submit = submit,
iconsVisible = iconsVisible
)
}
기존의 Stateful 컴포저블 TodoItemInput을 Stateful 컴포저블(TodoItemEntryInput)과 Stateless 컴포저블(TodoItemInput)로 나눕니다. 이와 같이 Stateful 컴포저블에서 Stateless 컴포저블을 추출하면 다양한 위치에서 UI를 더 쉽게 재사용할 수 있습니다.
📌 참고: 위와 같이 추출할 경우 원래 Stateful 컴포저블과 Stateless 컴포저블 모두 TodoItemInput으로 동일한 이름을 가지지만 구분을 쉽게 하기 위해 Stateful 함수명을 TodoItemEntryInput로 변경해주었습니다. 📌 Stateless 컴포저블에는 UI 관련 코드가 있고 Stateful 컴포저블에는 UI 관련 코드가 없습니다.
# [🖱️Click ]Stateful 컴포저블에서 Stateless 컴포저블 추출하기
🎣 State Hoisting
Compose에서 상태 호이스팅(State Hoisting)은 Stateful한 Composable을 Stateless하도록 만들기 위한 패턴입니다. 즉, 하위 컴포저블의 State를 해당 컴포저블을 호출하는 상위 컴포저블 쪽으로 끌어올림으로써 하위 컴포저블을 Stateless 하게 만드는 것입니다.
출처 : Using state in Jexpack Compose - 6. State in Compose
일반적으로 Jetpack Compose에서 상태 호이스팅(State Hoisting)을 적용하는 것은 컴포저블에 2개의 매개변수를 도입하는 것을 의미합니다.
value : T : 현재 화면에 표시되어야 하는 값
onValueChange : (T) -> Unit : 값 변경을 요청하는 이벤트(Event)/함수
아래 코드는 상태 호이스팅(State Hoisting)을 적용하기 전과 후의 코드입니다. text가 value로 onTextChagne가 onValueChange로 적용된 코드입니다.
// prev apply state hositing
@Composable
fun TodoInputTextField(
modifier: Modifier
) {
val (text, setText) = remember { mutableStateOf("") }
TodoInputText(text, setText, modifier)
}
// TodoInputTextField with hoisted state
@Composable
fun TodoInputTextField(
text: String,
onTextChange: (String) -> Unit,
modifier: Modifier
) {
TodoInputText(text, onTextChange, modifier)
}
위의 방법과 같은 호이스팅 된 상태는 몇 가지 중요한 특징을 가지고 있습니다.
Single source of truth – State를 복제하는 대신 State를 이동하여 텍스트에 대한 소스를 하나로 관리합니다. 이는 버그를 피하는데 도움이 됩니다.
Encapsulated – TodoItemInput만 상태를 수정할 수 있는 반면, 다른 구성 요소는 이벤트를TodoItemInput에 보낼 수 있습니다. 이러한 방식으로 호이스팅 하면 여러 컴포저블이 상태를 사용하더라도 하나의 컴포저블만 상태를 저장합니다.
Shareable – 호이스트 상태는 여러 컴포저블과 함께 변경할 수 없는 값으로 공유될 수 있습니다. 위의 코드에서는 TodoInputTextField와TodoEditButton 모두 State를 사용할 수 있습니다.
Interceptable – TodoItemInput은 상태를 변경할 때, 이벤트를 무시하거나 수정할 수 있습니다.
Decoupled – TodoInputTextField의 상태는 어디에나 저장할 수 있습니다. 예를 들어 TodoInputTextField 를 수정하지 않고 문자를 입력할 때마다 업데이트되는 Room 데이터 베이스에서 이 상태를 백업하도록 선택할 수 있습니다.
✨ 중요: 상태 호이스팅 진행 시, 상태를 어디로 올려야 하는지에 도움이 되는 3가지 규칙이 있습니다.
State는 State를 사용하는 모든 컴포저블의 최소 공통 상위 항목으로 호이스트 되어야 합니다.
State는 최소한 수정할 수 있는 최고 수준으로 호이스트 되어야 합니다.
동일한 이벤트에 대한 응답으로 두 State가 변경되면 함께(둘 다) 호이스트 되어야 합니다.
🧮 ViewModel에서 State
mutableStateListOf를 사용하면 관찰 가능한 MutableList의 인스턴스를 만들 수 있습니다. 즉, MutableList와 동일한 방식으로 todoItems를 사용할 수 있어 LiveData<List>사용의 오버헤드가 삭제됩니다.
/** TodoViewModel.kt **/
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
class TodoViewModel : ViewModel() {
// remove the LiveData and replace it with a mutableStateListOf
//private var _todoItems = MutableLiveData(listOf<TodoItem>())
//val todoItems: LiveData<List<TodoItem>> = _todoItems
// state: todoItems
// private set을 지정함으로써 이 상태 객체에 대한 Set을
// ViewModel 내부에서만 볼 수 있도록 제한하고 있습니다.
var todoItems = mutableStateListOf<TodoItem>()
private set // 쓰기 작업을 ViewModel 내부에서만 할 수 있는 비공개 setter로 제한
// event: addItem
fun addItem(item: TodoItem) {
todoItems.add(item)
}
// event: removeItem
fun removeItem(item: TodoItem) {
todoItems.remove(item)
}
}
/** TodoActivity.kt **/
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
TodoScreen(
items = todoViewModel.todoItems,
onAddItem = todoViewModel::addItem,
onRemoveItem = todoViewModel::removeItem
)
}
mutableStateListOf 및 MutableState로 실행된 작업은 Compose에서 사용하기 위한 것입니다. 이 ViewModel을 뷰 시스템(Xml 사용하는 방식)에서도 사용하는 경우, LiveData를 계속 사용하는 것이 좋습니다.