일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- Retrofit2
- ThreeTen Backport
- Android
- 기기고유값
- RecyclerView
- 화면 회전
- DataBinding
- Collections Function
- NumberPIcker
- multipart
- Lifecycle
- kotlin
- studywithme
- todo
- BottomSheetDialogFragment
- http
- findNavController
- log
- DialogFragment
- WorkManager
- json
- Load failed
- SSAID
- Room
- layout_constrainedHeight
- Popup menu background color
- Navigation
- layout_constrainedWidth
- 생명주기
- gradle plugin
- Today
- Total
chacha's
State 관리 본문
목차
State in Jetpack Compose codelab
컴포즈 공식 가이드 읽고 분석하기 - medium
를 참고하여 작성하였습니다.
👻 틀린 부분이 있다면 댓글로 남겨주시면 감사하겠습니다!
아래에서 사용한 코드는 Github에 있습니다.
🎲 State
애플리케이션에서 상태(State)는 시간이 지남에 따라 변경될 수 있는 모든 값입니다. 상태(State)라는 것은 클래스의 변수부터 Room 데이터베이스에 이르기까지 포함하는 광범위한 정의입니다.
아래는 상태(State)에 대한 몇 가지 예시입니다.
- 네트워크 연결을 설정할 수 없는 경우 표시되는 스낵바
- 블로그 게시물 및 관련된 댓글
- 사용자가 클릭될 때 보이는 버튼 리플 애니메이션 효과
- 이미지 위에 사용자가 그릴 수 있는 스티커
🧩 State와 Composition
컴포저블을 업데이트하는 유일한 방법은 새로운 파라미터 값으로 동일한 컴포저블 함수를 다시 호출하는 것입니다. 즉, TextField
와 같이 기존의 XML으로 구성된 뷰처럼 자동으로 업데이트되지 않습니다. 이때, 이 파라미터 값은 UI 상태를 나타냅니다. 상태(State)가 업데이트될 때마다 재구성(recomposition)이 발생합니다.
🅰 주요 용어
- 컴포지션 (Composition) : Jetpack Compose가 컴포저블을 실행하여 빌드한 UI에 대한 설명
- 초기 구성 (Initial Composition) : 처음으로 컴포저블을 실행하여 만들어진 컴포지션
- 재 구성 (Recomposition) : 데이터가 변경될 때, 컴포저블 트리를 업데이트하기 위해 동일한 컴포저블을 다시 실행하는 프로세스
🌲 재구성(Recomposition)
컴포즈(Compose)는 우리에게 익숙한 안드로이드 뷰 시스템의 UI 위젯 트리와 다르게 동작합니다. 컴포즈는 UI 위젯 트리 대신 컴포저블 트리를 생성합니다. 컴포즈가 처음으로 컴포지션을 실행할 때, 호출된 모든 컴포저블의 트리를 빌드합니다. 그다음, 재구성 시 호출되는 새 컴포저블 트리로 업데이트합니다.
위의 구조에서 LazyColumn
은 화면의 모든 자식을 재구성합니다. 그러면 TodoRow
가 다시 호출되어 새로운 색상을 생성합니다. 즉, TodoRow
는 재구성될 때마다 업데이트되고 아이콘의 색상이 계속 변화하게 됩니다. 아래와 같이 컴포지션 트리에 값을 저장할 수 있도록 변경하면, 재구성이 발생하더라도 아이콘의 색상이 유지되는 것을 확인할 수 있습니다.
# 💻 코드 보기
🔽 Side-Effect가 있는 코드
// TodoScreen.kt
import androidx.compose.material.LocalContentColor
@Composable
fun TodoRow(
todo: TodoItem,
onItemClicked: (TodoItem) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.clickable { onItemClicked(todo) }
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(todo.task)
val iconAlpha = randomTint()
Icon(
imageVector = todo.icon.imageVector,
tint = LocalContentColor.current.copy(alpha = iconAlpha),
contentDescription = stringResource(id = todo.icon.contentDescription)
)
}
}
LocalContentColor.current
란?
아이콘 및 타이포그래피와 같은 콘텐츠에 선호하는 색상을 제공합니다. 배경을 그리는 Surface와 같은 컴포지블에 의해 변경됩니다.
🔽 색상이 계속 변경되는 Side-Effect를 해결한 코드
@Composable
fun TodoRow(
todo: TodoItem,
onItemClicked: (TodoItem) -> Unit,
modifier: Modifier = Modifier,
// 호출부에서 alpthTint를 제어할 수 있음
iconAlpha: Float = remember(todo.id) { randomTint() }
) {
Row(
modifier = modifier
.clickable { onItemClicked(todo) }
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(todo.task)
Icon(
imageVector = todo.icon.imageVector,
tint = LocalContentColor.current.copy(alpha = iconAlpha),
contentDescription = stringResource(id = todo.icon.contentDescription)
)
}
}
💾 Compose에서 값 기억하기
컴포저블 함수는 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 컴포저블로 만들 수 있습니다.
// TodoScreen.kt
import androidx.compose.runtime.mutableStateOf
@Composable
fun TodoInputTextField(modifier: Modifier) {
val (text, setText) = remember { mutableStateOf("") }
TodoInputText(text, setText, modifier)
}
위의 코드에서 remember를 이용하여 메모리에 값을 기억하고, 해당 메모리에는 컴포즈 실행 중 관찰 가능한 상태 홀더 MutableState<T>
를 생성하는 mutableStateOf
를 저장합니다. value가 변경되면 컴포저블 함수가 자동으로 재 구성됩니다.
# 💣 컴파일 에러가 발생한다면??
아래와 같이 import 되어 있는지 확인해 주세요.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
# 💣 컴파일 에러가 발생한다면??
컴포저블에서 MutableState
객체를 선언하는 세 가지 방법이 있습니다.
val state = remember { mutableStateOf(default) }
var value by remember { mutableStateOf(default) }
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)을 이용하여 만들 수 있습니다.
- State를 호출하는 곳에서 제어
# [🖱️Click ] Stateful 컴포저블에서 Stateless 컴포저블 추출하기
TodoItemInput
코드를 Stateless 컴포저블로 변경해봅시다!
1.TodoItemInput
의 UI 부분(Column
및 그 하위 요소 )을 선택합니다.@Composable fun TodoItemInput( text: String, onTextChange: (String) -> Unit, icon: TodoIcon, onIconChange: (TodoIcon) -> Unit, submit: () -> Unit, iconsVisible: Boolean ) { Column { Row( Modifier .padding(horizontal = 16.dp) .padding(top = 16.dp) ) { TodoInputText( text, onTextChange, Modifier .weight(1f) .padding(end = 8.dp), submit ) TodoEditButton( onClick = submit, text = "Add", modifier = Modifier.align(Alignment.CenterVertically), enabled = text.isNotBlank() ) } if (iconsVisible) { AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp)) } else { Spacer(modifier = Modifier.height(16.dp)) } } }
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 하게 만드는 것입니다.
일반적으로 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
를 계속 사용하는 것이 좋습니다.
END