chacha's

State 관리 본문

Android/Jetpack Compose

State 관리

Cha_Cha 2022. 4. 8. 21:51

목차

    State in Jetpack Compose codelab
    컴포즈 공식 가이드 읽고 분석하기 - medium
    를 참고하여 작성하였습니다.

    👻 틀린 부분이 있다면 댓글로 남겨주시면 감사하겠습니다!
    아래에서 사용한 코드는 Github에 있습니다.

     

    🎲 State

    애플리케이션에서 상태(State)는 시간이 지남에 따라 변경될 수 있는 모든 값입니다. 상태(State)라는 것은 클래스의 변수부터 Room 데이터베이스에 이르기까지 포함하는 광범위한 정의입니다.

    아래는 상태(State)에 대한 몇 가지 예시입니다.

    • 네트워크 연결을 설정할 수 없는 경우 표시되는 스낵바
    • 블로그 게시물 및 관련된 댓글
    • 사용자가 클릭될 때 보이는 버튼 리플 애니메이션 효과
    • 이미지 위에 사용자가 그릴 수 있는 스티커

    🧩 State와 Composition

    컴포저블을 업데이트하는 유일한 방법은 새로운 파라미터 값으로 동일한 컴포저블 함수를 다시 호출하는 것입니다. 즉, TextField와 같이 기존의 XML으로 구성된 뷰처럼 자동으로 업데이트되지 않습니다. 이때, 이 파라미터 값은 UI 상태를 나타냅니다. 상태(State)가 업데이트될 때마다 재구성(recomposition)이 발생합니다.

    🅰 주요 용어

    1. 컴포지션 (Composition) : Jetpack Compose가 컴포저블을 실행하여 빌드한 UI에 대한 설명
    2. 초기 구성 (Initial Composition) : 처음으로 컴포저블을 실행하여 만들어진 컴포지션
    3. 재 구성 (Recomposition) : 데이터가 변경될 때, 컴포저블 트리를 업데이트하기 위해 동일한 컴포저블을 다시 실행하는 프로세스

     

    🌲 재구성(Recomposition)

    컴포즈(Compose)는 우리에게 익숙한 안드로이드 뷰 시스템의 UI 위젯 트리와 다르게 동작합니다. 컴포즈는 UI 위젯 트리 대신 컴포저블 트리를 생성합니다. 컴포즈가 처음으로 컴포지션을 실행할 때, 호출된 모든 컴포저블의 트리를 빌드합니다. 그다음, 재구성 시 호출되는 새 컴포저블 트리로 업데이트합니다.

    TodoScreen Tree ( 출처 : Codelab - Using State in Jetpack Compose )

    위의 구조에서 LazyColumn은 화면의 모든 자식을 재구성합니다. 그러면 TodoRow가 다시 호출되어 새로운 색상을 생성합니다. 즉, TodoRow는 재구성될 때마다 업데이트되고 아이콘의 색상이 계속 변화하게 됩니다. 아래와 같이 컴포지션 트리에 값을 저장할 수 있도록 변경하면, 재구성이 발생하더라도 아이콘의 색상이 유지되는 것을 확인할 수 있습니다.

    TodoRow Tree using remember

    더보기

    # 💻 코드 보기


    🔽 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() }
    1. key arguments – 이 메모리가 사용하는 'key'는 괄호 안에 전달되는 부분입니다. 여기서 todo.id를 키로 전달합니다.
    2. calculation – 기억할 새 값을 계산하는 람다로 후행 람다로 전달됩니다. 여기에서 randomTint() 를 사용하여 임의의 값을 계산합니다.

    위의 코드를 설명하자면, 처음 컴포저블 트리를 구성할 때 remember은 항상 randomTint를 호출하고 todo.id를 추적하며 재구성 시 기억한 결과를 사용합니다. 이후, 새로운 todo.id가 전달되지 않는 한 TodoRowrandomTint를 호출하지 않고 기억된 값을 반환합니다.

    💡 컴포지션에 기억된 값은 호출 컴포지블이 트리에서 제거되는 즉시 잊혀집니다.
          ( 호출하는 컴포저블이 트리에서 이동하는 경우도 다시 초기화됩니다. )

     

    🎠 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

    // 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 객체를 선언하는 세 가지 방법이 있습니다.

    1. val state = remember { mutableStateOf(default) }
    2. var value by remember { mutableStateOf(default) }
    3. 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. 필요한 경우 매개변수의 이름을 변경합니다. ( 아래에서는 setTextonTextChange, setIcononIconChange로 변경합니다. )

    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 – 호이스트 상태는 여러 컴포저블과 함께 변경할 수 없는 값으로 공유될 수 있습니다. 위의 코드에서는  TodoInputTextFieldTodoEditButton 모두 State를 사용할 수 있습니다.
    • Interceptable – TodoItemInput은 상태를 변경할 때, 이벤트를 무시하거나 수정할 수 있습니다. 
    • Decoupled – TodoInputTextField의 상태는 어디에나 저장할 수 있습니다. 예를 들어  TodoInputTextField 를 수정하지 않고 문자를 입력할 때마다 업데이트되는 Room 데이터 베이스에서 이 상태를 백업하도록 선택할 수 있습니다.
    중요: 상태 호이스팅 진행 시, 상태를 어디로 올려야 하는지에 도움이 되는 3가지 규칙이 있습니다.

    1. State는 State를 사용하는 모든 컴포저블의 최소 공통 상위 항목으로 호이스트 되어야 합니다.
    2. State는 최소한 수정할 수 있는 최고 수준으로 호이스트 되어야 합니다. 
    3. 동일한 이벤트에 대한 응답으로 두 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
       )
    }

    mutableStateListOfMutableState로 실행된 작업은 Compose에서 사용하기 위한 것입니다. 이 ViewModel을 뷰 시스템(Xml 사용하는 방식)에서도 사용하는 경우, LiveData를 계속 사용하는 것이 좋습니다.

     

    END

    Comments