Week 2-2 Using state in Jetpack Compose (1)

jihyo·2021년 12월 3일

DevFest 2021

목록 보기
7/8

이번에는 Jetpack Compose에서 상태를 사용하고 조작하는 방법을 배울 것이다.

상태(State)란?

기본적으로 상태는 시간이 지남에 따라 변경될 수 있는 모든 값을 말한다. 광범위한 정의인데 Room DB에서 클래스의 변수까지 모든 것을 포함한다.

모든 Android 앱은 사용자에게 상태를 표시해준다. 그 예로,

  1. 네트워크 연결을 설정할 수 없을 때 표시되는 Snackbar
  2. 블로그 게시물 및 관련 댓글
  3. 사용자가 클릭할 때 재생되는 버튼의 애니메이션
  4. 사용자가 이미지 위에 그릴 수 있는 Sticker

위 예시 말고도 너무나 많다. TODO 앱을 알아보며 학습할 것이다.

단방향 데이터 흐름에 대한 이해

Android View 시스템을 사용하는 단방향 데이터 흐름의 개념에 대해 알아볼 것이다.

UI 업데이트 루프

Android 앱에서 상태는 이벤트에 대한 응답으로 업데이트된다. 이벤트OnClickListener를 호출하는 버튼을 탭하는 사용자, afterTextChanged를 호출하는 EditText나 새 값을 보내는 Accelerometer(가속도계)와 같이 앱 외부에서 생성된 입력이다.

모든 Android 앱에는 핵심 UI 업데이트 루프가 있다.

UI Update Loop

  • Event : 사용자나 프로그램의 다른 부분에 의해 이벤트가 생성
  • Update State : 이벤트 핸들러는 UI에서 사용하는 상태를 변경
  • Display State : 새로운 상태를 표시하도록 UI가 업데이트

Compose에서 상태를 관리하는 것은 상태와 이벤트가 상호 작용하는 방식을 이해하는 것이다.

Unstructured state

Compose를 시작하기 전에 Android View 시스템에서 이벤트와 상태를 보면, "Hello, World" 상태로 사용자가 자신의 이름을 입력할 수 있는 hello world Activity를 만들 것이다.
만드는 한 가지 방법은 이벤트 콜백이 TextView에서 상태를 직접 설정하도록 하는 것이며 ViewBinding을 사용한다. ViewBinding 사용법

ViewBinding 예시

class HelloCodelabActivity : AppCompatActivity() {

   private lateinit var binding: ActivityHelloCodelabBinding
   var name = ""

   override fun onCreate(savedInstanceState: Bundle?) {
       /* ... */
       binding.textInput.doAfterTextChanged {text ->
           name = text.toString()
           updateHello()
       }
   }

   private fun updateHello() {
       binding.helloText.text = "Hello, $name"
   }
}

작은 규모에서는 이런 구조가 괜찮지만 UI가 커질수록 관리하기 어려워질 수 있다.

이렇게 빌드된 Activity에 더 많은 이벤트와 상태를 추가하면 문제가 발생할 수 있다.

  1. Testing : UI의 상태가 View와 엮여있기 때문에 테스트하기 어려울 수 있음
  2. Partial State Updates : 화면에 더 많은 이벤트가 있을 때 이벤트에 대한 응답으로 상태의 일부를 업데이트 하는게 누락될 수 있음. 결과적으로 일관되지 않거나 잘못된 UI가 표시될 수 있음
  3. Partial UI Updates : 각 상태가 변경된 후 UI를 수동으로 업데이트하기 때문에 사용자는 무작위로 업데이트 되는 오래된 데이터를 보게될 수 있음
  4. Code Complexity : 로직을 추출하기 어려워지며 가독성이 떨어지는 경향이 있음

단방향 데이터 흐름 사용

앞서 말한 비정형 상태 문제를 해결하기 위해 ViewModelLiveData가 포함된 Android Architecture Components를 도입했다.

ViewModel을 사용하면 UI에서 상태를 추출하고 UI가 해당 상태를 업데이트하기 위해 호출할 수 있는 이벤트를 정의할 수 있다. ViewModel을 사용하여 작성된 동일한 Activity 예시를 살펴보겠다.

ViewModel 사용 예시

class HelloCodelabViewModel: ViewModel() {

   // LiveData는 UI에서 관찰되는 상태를 유지
   // (상태는 ViewModel에서 아래로 흐른다.)
   private val _name = MutableLiveData("")
   val name: LiveData<String> = _name

   // onNameChanged는 UI가 호출할 수 있도록 정의하는 이벤트
   // (이벤트는 UI에서 위로 흐른다.)
   fun onNameChanged(newName: String) {
       _name.value = newName
   }
}

class HelloCodeLabActivityWithViewModel : AppCompatActivity() {
   private val helloViewModel by viewModels<HelloCodelabViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       /* ... */

       binding.textInput.doAfterTextChanged {
           helloViewModel.onNameChanged(it.toString()) 
       }

       helloViewModel.name.observe(this) { name ->
           binding.helloText.text = "Hello, $name"
       }
   }
}

이번 예시에서는 상태를 Activity에서 ViewModel로 이동시켰다. ViewModel에서 상태는 LiveData로 표현된다. LiveDataobservable(관찰 가능한) 상태 보유자(State Holder)이다. 이 말은 상태의 변경사항을 관찰하는 방법을 누구에게나 제공할 수 있음을 의미한다. UI에서 상태가 변경될 때마다 UI를 업데이트 하기위해 observe 메서드를 사용한다.

Observable은 누구나 해당 상태의 변경 사항을 수신할 수 있는 방법을 제공하는 상태 객체이다. LiveData, StateFlow, Flow, Observable 모두 해당된다.

ViewModelonNameChanged라는 하나의 이벤트도 노출한다. 이 이벤트는 EditText의 텍스트가 변경될 때마다 발생하는 것과 같은 사용자 이벤트에 대한 응답으로 UI에서 호출된다.

이전에 말한 UI 업데이트 루프로 돌아가서 ViewModel이 이벤트와 상태에 어떻게 맞춰지는지 알 수 있다.

  • Event : 텍스트 입력이 변경될 때 UI에서 onNameChanged가 호출됨
  • Update State : onNameChanged가 처리를 수행한 다음 _name의 상태를 설정
  • Display State : 상태 변경을 UI에 알리는 name의 observer가 호출됨

이런 방식으로 구성하면 ViewModel을 향해 "위로" 흐르는 이벤트를 생각할 수 있다. 이벤트에 대한 응답으로 ViewModel이 일부 처리를 수행하고 상태를 업데이트할 수 있다. 상태가 업데이트되면 Activity를 향해 "아래로" 흐른다.
Flow

이런 패턴을 단방향 데이터 흐름이라고 한다. 단방향 데이터 흐름은 상태는 아래로, 이벤트는 위로 흐른다. 단방향 데이터 흐름은 여러 장점이 있는데,

  • Testability : 상태를 표시하는 UI에서 상태를 분리하여 ViewModelActivity를 모두 테스트하는 것이 더 쉬움
  • State Encapsulation : 상태는 한 곳(ViewModel)에서만 업데이트할 수 있으므로 UI가 커짐에 따라 Partial State Update 버그가 발생할 가능성이 적음
  • UI Consistency : Observable 상태 보유자를 사용하여 모든 상태 업데이트가 UI에 즉시 반영됨

따라서 이 패턴은 코드가 추가되긴 하지만 복잡한 상태와 이벤트를 처리하는 데 더 쉽고 안정적이다.

  • 단방향 데이터 흐름은 이벤트가 위로, 상태가 아래로 흐른다.
  • ViewModel에서는 상태가 LiveData를 사용하여 아래로 흐르는 동안 UI의 메서드 호출과 함께 이벤트가 전달된다.
  • ViewModel을 설명하는 용어가 아니다. 이벤트가 위로 흐르고 상태가 아래로 흐르는 모든 디자인은 단방향이다.

Compose and ViewModels

이제 Viewmodel을 사용하여 Compose에서 단방향 데이터 흐름을 사용하는 방법을 살펴보겠다. 간단한 TODO 앱을 만들어 보려한다.

TodoScreen 컴포저블 탐색

코드랩을 통해 다운로드한 프로젝트를 살펴보면 수정해서 사용할 여러 컴포저블이 있다. 크게 두 개의 파일로 분할되어 있는데,

  • TodoScreen.kt : 이러한 컴포저블은 상태와 직접 상호 작용하며 상태를 탐색하며 이 파일을 수정`
  • TodoComponents.kt : 이 컴포저블은 TodoScreen을 빌드하는 데 사용할 UI의 재사용 가능한 비트를 정의한다.

TodoScreen.kt 파일을 열어 TodoScreen 컴포저블을 보면 TODO 목록을 표시하지만 자기 자신의 상태는 없다. 상태는 변경할 수 있는 모든 값이지만 TodoScreen에 대한 인수는 수정할 수 없다.

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit
) {
   /* ... */
}
  • items : 화면에 표시하지만 변경할 수 없는 item 리스트
  • onAddItem : 사용자가 item 추가를 요청할 때의 이벤트
  • onRemoveItem : 사용자가 item 제거를 요청할 때의 이벤트

TodoScreen 컴포저블은 stateless이다. 전달된 item 리스트만 표시되며 리스트를 직접 편집할 수 있는 방법은 없다. 대신 변경을 요청할 수 있는 두 개의 이벤트 onRemoveItemonAddItem이 전달된다.

stateless composable은 상태를 직접 변경할 수 없는 컴포저블이다.

stateless는 State Hoisting을 사용해 수정 가능한 리스트를 표시할 수 있다. State Hoisting은 구성 요소를 stateless로 만들기 위해 상태를 위로 이동하는 패턴이다. Stateless 구성요소는 테스트하기 쉽고 버그가 적으며 재사용 기회가 더 많다.

이런 매개변수의 조합은 caller가 컴포저블에서 상태를 hoist할 수 있도록 한다. TodoScreen 컴포저블의 UI 업데이트 루프를 보면서 어떻게 작용하는지 알아보겠다.

  • Event : 사용자가 item 추가/제거를 요청할 때 TodoScreenonAddItem을 호출한다.
  • Update State : TodoScreen caller는 상태를 업데이트하여 이벤트에 응답할 수 있다.
  • Display State : 상태가 업데이트되면 TodoScreen이 새 item과 함께 다시 호출되고 화면에 표시할 수 있다.

caller는 상태를 유지하는 위치와 방법을 알아낼 책임이 있다. 그러나 메모리에 item을 저장하거나 Room DB에서 item을 읽을 수 있다. TodoScreen은 상태가 관리되는 방식과 완전히 분리된다.

State Hoisting은 구성 요소를 stateless 상태로 만들기 위해 상태를 위로 이동하는 패턴이다.
컴포저블에 적용할 때 컴포저블에 2개의 매개변수를 도입하는 것을 의미한다.

  • value: T : 표시할 현재 값
  • onValueChange: (T) -> Unit : 값 변경을 요청하는 이벤트, T는 제안된 새로운 값

TodoActivityScreen 컴포저블 정의

TodoViewModel.kt 파일을 열고 ViewModel을 보면 상태 변수 1개, 2개의 이벤트가 정의되어 있음을 알 수 있다.

class TodoViewModel : ViewModel() {

   // state: todoItems
   private var _todoItems = MutableLiveData(listOf<TodoItem>())
   val todoItems: LiveData<List<TodoItem>> = _todoItems

   // event: addItem
   fun addItem(item: TodoItem) {
        /* ... */
   }

   // event: removeItem
   fun removeItem(item: TodoItem) {
        /* ... */
   }
}

ViewModel을 사용하여 TodoScreen에서 상태를 hoist할 것이다. 아래와 같은 단방향 데이터 흐름 디자인이 나오게 된다.
Data Flow

TodoScreenTodoActivity에 통합하려면 TodoActivity.kt를 열고 새 @Composable 함수 TodoActivityScreen(TodoViewModel: TodoViewModel)을 정의하고 onCreatesetContent에서 호출하면 된다.

TodoActivity.kt

아래 코드는 ViewModelTodoScreen에 연결하지 않았기 때문에 버튼을 클릭해도 아무런 일도 일어나지 않는다.

class TodoActivity : AppCompatActivity() {

   private val todoViewModel by viewModels<TodoViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           StateCodelabTheme {
               Surface {
                   TodoActivityScreen(todoViewModel)
               }
           }
       }
   }
}

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items = listOf<TodoItem>() // in the next steps we'll complete this
   TodoScreen(
       items = items,
       onAddItem = { }, // in the next steps we'll complete this
       onRemoveItem = { } // in the next steps we'll complete this
   )
}

TodoActivityScreen 컴포저블은 ViewModel에 저장된 상태와 프로젝트에 이미 정의된 TodoScreen 컴포저블 사이의 다리 역할을 해준다. ViewModel을 직접 사용하도록 TodoScreen을 변경할 수 있지만 TodoScreen은 재사용성이 떨어진다. List<TodoItem>과 같은 단순 매개변수를 선호하여 TodoScreen은 상태가 hoist되는 특정 위치에 연결되지 않는다.

Flow the events up

이제 필요한 모든 구성요소(ViewModel, 브릿지 컴포저블 TodoActivityScreen)가 있으므로 단방향 데이터 흐름을 사용하여 동적 리스트를 표시하기 위해 모두 연결해보겠다.

TodoActivityScreen에서 ViewModel에서 addItemremoveItem을 전달한다.

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items = listOf<TodoItem>()
   TodoScreen(
       items = items,
       onAddItem = { todoViewModel.addItem(it) },
       onRemoveItem = { todoViewModel.removeItem(it) }
   )
}

TodoScreen에 전달된 이벤트는 Kotlin 람다 구문을 사용한다.

TodoScreenonAddItem이나 onRemoveItem을 호출하면 ViewModel의 올바른 이벤트에 대한 호출을 전달할 수 있다.

메서드 reference 구문을 사용하여 단일 메서드를 호출하는 람다를 생성할 수 있다. 메서드 reference 구문을 사용하여 위의 onAddItemonAddItem = todoViewModel::addItem으로 표현할 수 있다.

Pass the state down

단방향 데이터 흐름의 이벤트를 연결했으니 이제 상태를 전달해야 한다.

observeAsState를 사용하여 todoItems LiveData를 관찰하도록 TodoActivityScreen을 편집하겠다.

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items: List<TodoItem> by todoViewModel.todoItems.observeAsState(listOf())
   TodoScreen(
       items = items,
       onAddItem = { todoViewModel.addItem(it) },
       onRemoveItem = { todoViewModel.removeItem(it) }
   )
}

LiveData를 관찰하고 현재 값을 List<TodoItem>으로 직접 사용하도록 만든다.

코드를 분석해보겠다.

  • val items: List<TodoItem>List<TodoItem>타입의 변수 items를 선언한다.
  • todoViewModel.todoItemsViewModelLiveData<List<TodoItem>>이다.
  • .observeAsStateLiveData<T>를 관찰하고 State<T> 객체로 변환하여 Compose가 값 변경에 반응할 수 있도록 한다.
  • listOf()LiveData가 초기화되기 전에 가능한 null 결과를 피하기 위한 초기값이다. 전달되지 않은 경우 items는 nullable인 List<TodoItem>?이 된다.
  • by는 Kotlin의 속성 대리자 구문이다. by를 사용해서 자동으로 State<List<TodoItem>>observeAsState에서 일반 List<TodoItem>으로 래핑 해제할 수 있다.

observeAsStateLiveData를 관찰하고 LiveData가 변경될 때마다 업데이트되는 State 객체를 반환한다.
컴포저블이 컴포지션에서 제거되면 자동으로 관찰을 중지한다.

Run the app again

이제 앱을 실행하면 동적으로 업데이트된다. 하단의 버튼을 클릭하면 새 item이 추가되고 item을 클릭하면 제거된다.
ToDo ex

ViewModel을 사용해 Compose에서 단방향 데이터 흐름 디자인을 구축하는 방법과 State Hoisting을 사용해 stateless 컴포저블 사용하여 stateful UI를 표시하는 방법도 알아보았다.

0개의 댓글