컴포즈 공식 가이드 읽고 분석하기 — (2)

김종식
17 min readFeb 28, 2022

(목차로 돌아가기)

State and Jetpack Compose

상태(State)는 앱이 실행되는 중 변경될 수 있는 모든 값입니다. 상태라는 것은 클래스의 변수부터 룸(Room) 데이터베이스에 이르기까지 정의하기에 매우 광범위합니다.

모든 안드로이드 앱은 사용자에게 상태를 표시합니다. 아래는 몇 가지 상태 표시에 대한 예시입니다 :

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

젯팩 컴포즈는 안드로이드 앱에서 상태를 저장하고 사용하는지 명시적으로 도와줄 수 있습니다. 이 가이드는 상태와 컴포저블 사이의 관계, 젯팩 컴포즈에서 상태를 좀 더 쉽게 동작할 수 있게끔 제공하는 API 에 대해 초점을 맞추고 있습니다.

State and composition

컴포저블은 선언적으로 작성되기 때문에 이를 업데이트 하는 유일한 방법은 새로운 인자값으로 동일한 컴포저블 함수를 다시 호출하는 것입니다. 이러한 인자값은 UI 의 상태를 나타냅니다. 상태가 업데이트 될 때마다 재구성(recomposition)이 발생됩니다. 따라서, TextField 와 같은 명령형 XML 으로 구성된 뷰처럼 자동적으로 업데이트 되지 않습니다. 컴포저블은 새로운 상태에 따라서 업데이트 되기 위해 명시적으로 선언해야 합니다.

이 코드를 실행해보면 아무 일도 일어나지 않습니다. 그 이유는 TextField가 스스로 업데이트하지 않기 때문입니다 — value 파라메터가 변경될 때 갱신됩니다. 컴포즈에서 구성 및 재구성이 동작하는 방식 때문입니다.

주요 용어
컴포지션 (Composition)
: 젯팩 컴포즈가 컴포저블이 실행하여 빌드한 UI 에 대한 설명입니다.

초기 구성 (Initial Composition) : 처음으로 컴포저블을 실행하여 만들어진 컴포지션입니다.

재 구성(Recomposition) : 데이터가 변경될 때 컴포저블을 재실행하여 컴포지션을 업데이트

초기 구성 및 재구성에 대한 내용을 좀 더 확인하려면 Thinking in Compose (번역 문서)를 참조하세요.

State in composables

컴포저블 함수는 remember 컴포저블을 이용하여 메모리에 단일 객체를 저장할 수 있습니다. remember 값은 초기 구성 과정 중 값을 저장하고, 재구성(recomposition) 시 저장된 값이 리턴되어 사용합니다. remember 를 이용하여 가변 및 불변객체로 저장될 수 있습니다.

Note : remember 는 컴포지션 과정에서 객체를 저장하고, 컴포지션 중 remember 를 호출하는 컴포저블이 제거되면 객체를 잊어버립니다.

mutableStateOf 는 컴포즈 실행 중 결합가능한 MutableState<T>의 관찰 가능한 인스턴스를 생성합니다.

value 가 어떤 값으로든 변경된다면, 값을 읽는 모든 컴포저블 함수가 재구성이 예약됩니다. 예를들어 ExpandingCard 의 경우 expanded 가 변경될 때마다 ExpandingCard 가 재구성을 야기합니다.

컴포저블에서 MutableState 객체를 선언하는 세 가지 방법이 있습니다.

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

이 선언들은 모두 동일하며, 상태를 접근하는데 편하게 사용하기 위해 다른 방식으로 제공하고 있습니다. 코드 작성 시 컴포저블 코드에서 읽기 쉬운 코드 작성 방법으로 적절하게 선택 합니다.

by delegate 구문을 사용하려면 아래 항목들을 import 합니다:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

또한, 이미 기억된 값 (remembered value) 를 다른 컴포저블들의 파라미터나 표시를 위해 사용할 수 있습니다. 예를들어, name 이 비어있을 경우 greeting 을 표시하고 싶지 않다면 if 구문을 이용할 수 있습니다:

재구성 중 상태를 유지하는데 remember 는 도움이 되지만, 환경 변경(configration changes) 상태는 유지되지 않습니다. 이 작업을 수행하려면 rememberSaveable 을 이용해야 합니다. rememberSaveableBundle 에 저장 가능한 모든 값을 자동으로 저장합니다. (일반적인 값이 아닌) 다른 값의 경우, 커스텀 세이버 객체를 전달할 수 있습니다.

Other supported types of state

젯팩 컴포즈에서 상태를 유지하기 위해 MutableState<T> 를 꼭 사용할 필요가 없습니다. 젯팩 컴포즈는 다른 관찰 가능한 타입(observable type)을 지원합니다. 젯팩 컴포즈에서 다른 관찰 가능한 타입을 읽기 전에 State<T> 으로 변환하여 자동적으로 젯팩 컴포즈가 상태가 변경될 때 재구성할 수 있습니다.

컴포즈는 안드로이드 앱에서 일반적으로 사용하는 관찰 가능한 타입에 대하여 State<T> 타입으로 전환합니다 :

커스텀 옵져버블 클래스를 사용할 경우, 젯팩 컴포즈를 위한 확장 함수를 만들어서 관찰 가능한 유형으로 재구성 할 수 있습니다. 모든 변경을 구독가능한 객체들은 State<T>로 변환 가능하며 컴포저블에서 읽을 수 있습니다.

💡 중요 : 컴포즈는 State<T> 객체를 참조하여 자동적으로 재구성 합니다.

컴포즈에서 LiveData 와 같은 관찰 가능한 유형을 사용하는 경우 LiveData<T>.observeAsState() 확장함수와 같이 State<T> 로 변환해야 합니다.

⚠️ 주의 : ArrayList<T> 또는 mutableListOf 와 같은 가변 객체를 사용할 경우 유저가 올바르지 않거나 바로 갱신되지 않은(=오래된) 데이터를 볼 수 있습니다.
ArrayList<T> 또는 변경 가능한 데이터 클래스는 관찰 가능한 객체가 아니므로, 컴포즈에서 변경사항에 대해 재구성을 트리거 할 수 없습니다.
관측할 수 없는 변경 가능 객체(non-observable mutable objects)를 사용하는 대신,
State<List<T>>불변의 listOf() 와 같은 데이터 홀더를 사용하는 것이 좋습니다.

Stateful versus stateless

컴포저블에서 remember 를 통해 저장되는 내부의 상태를 만들어, 상태에 따라 좀 더 구성 가능한 상태(stateful)를 만들어 줍니다. HelloContent 는 name의 상태를 내부적으로 보관 및 수정하기 때문에 구성 가능한 상태의 예가 될 수 있습니다. 이것은 호출하는 입장에서 상태를 통제하거나 관리를 할 필요가 없는 상황에서는 유용할 수 있습니다. 하지만, 내부 상태를 가지고있는 컴포저블은 테스트하기 어렵고 재사용성이 적은 경향이 있습니다.

상태가 없는(stateless) 컴포저블은 어떠한 상태도 가지고 있지 않습니다. 상태 비저장을 달성하는 가장 단순한 방법으로 상태 호이스팅(state hoisting)이 있습니다.

재사용 가능한 컴포저블을 개발할 때 종종 상태 지정 컴포저블(stateful)과 상태 비지정 컴포저블(stateless) 모두 노출하는 경우가 많습니다. 상태 지정 버전은 상태를 신경쓰지 않는 호출자에게 편리하며, 상태 비지정 버전은 상태를 제어에 대한 니즈가 있는 호출자에게 제어나 상태 호이스트를 위해 필요합니다.

State hoisting

컴포즈에서 상태 호이스팅은 컴포저블을 상태 비지정으로 만들기 위해 컴포저블 호출자에게 상태를 이동하는 패턴입니다. 젯팩 컴포즈에서 상태 호이스팅을 위한 하기 위한 패턴은 일반적으로 컴포저블의 상태변수를 다음 두 가지 매개 변수로 바꾸는 것입니다.

  • value: T : 현재 화면에 표시되어야 하는 값
  • onValueChange: (T) -> Unit : 값 변경을 요청하는 이벤트, 여기서 T는 제안되는 새로운 값입니다.

그러나 onValueChange 에만 국한되지는 않습니다. ExpandingCard 가 onExpand 와 onCollapse 을 이용하듯 컴포저블에 보다 명확한 이벤트가 적합할 경우 람다를 정의하여 사용합니다.

이러한 방식으로 호이스팅된 상태는 몇 가지 중요한 특징을 가지고 있습니다:

  • Single source of truth : 상태를 복제하는 대신 이동시킴으로써 하나의 상태만이 진실의 근원임을 확실하게 합니다. 이것은 버그를 회피하는데 도움이 됩니다.
  • Encapsulated : 상태 지정 컴포저블만이 자신의 상태를 수정 가능합니다. 완벽히 내부적으로 만듭니다.
  • Shareable : 호이스트된 상태는 여러 컴포저블에서 공유될 수 있습니다. name 이 서로다른 컴포저블에서 원한다고 가정한다면, 호이스팅이 그것을 가능하게 해줍니다.
  • Interceptable : 상태 비지정 컴포저블에 대한 호출자는 상태가 변경될 때 이를 무시하거나 수정하도록 결정할 수 있습니다.
  • Decoupled : 상태 비지정 컴포저블 ExpandingCard 의 상태는 어디에나 저장할 수 있습니다. 예를들어, name 을 viewModel 으로 이동 가능합니다.

이 예제에서는 HelloContent 에서 name 과 onValueChange 를 추출하여 HelloContent를 호출하는 HelloScreen 컴포저블 함수 트리 위로 이동합니다.

HelloContent 에서 상태 호이스팅을 함으로써(=컴포저블에 필요한 상태를 호출자로 끌어올림으로써), 좀 더 쉽게 이 컴포저블을 추론가능하게 하고, 여러 상황에서 재사용이 가능하며, 테스트하기 쉬워졌습니다. HelloContent 는 상태가 저장되는 것과 분리 되었습니다. 디커플링은 HelloScreen 을 수정하거나 교체하더라도, HelloContent 이 구현된 것을 변경할 필요가 없다는 것을 의미합니다.

상태가 다운되고, 이벤트가 상승하는 패턴을 단방향 데이터 흐름(uidirectional data flow)이라고 합니다. 예제의 경우, 상태는 HelloScreen 에서 HelloContent 로 내려가고, 이벤트는 HelloContent 에서 HelloScreen 으로 상승합니다. 단방향 데이터 흐름으로 인해, UI 상태를 표시하는 컴포저블을 앱의 저장 및 상태 변경 부분에서 분리할 수 있습니다.

💡 중요 : 상태 호이스팅 진행 시, 상태를 어디로 올려야 하는지에 도움이 되는 세 가지 규칙이 있습니다:

1. 상태는 적어도 상태를 사용하는 모든 컴포저블 중
가장 낮은 공통 부모(lowest common parent)가 되어야 합니다. (읽기)
2. 상태는 적어도
변경될 수 있는 가장 높은곳으로 올라가야 합니다. (쓰기)
3. 만약
동일한 이벤트에 두 가지 상태가 함께 변경된다면, 그것들은 함께 호이스트 되어야 합니다.

이러한 규칙보다 높게(=높은 레벨에 위치하도록) 상태를 호이스트를 할 수 있지만, 단방향 데이터 흐름에서 상태 underhoisting 하는것을 매우 어렵거나 불가능하게 할 수 있습니다.

Restoring state in Compose

액티비티나 프로세스가 다시 생성되어 UI 상태를 복원하려면rememberSaveable 을 이용합니다. 또한, rememberSaveable 은 액티비티와 프로세스 재생성 전반에 걸쳐 상태를 유지합니다.

Ways to store state

Bundle 에 추가될 수 있는 모든 데이터 타입이 자동으로 저장됩니다. 만약 번들에 추가될 수 없는 타입이라면 몇 가지 옵션이 있습니다.

Parcelize

가장 단순한 방법은 @Parcelize 어노테이션을 추가하는 것입니다. 객체는 구획(parcelable) 가능하게 되어 번들에 포함될 수 있습니다. 이 예제에서는 City 를 구획(parcelable) 가능한 타입으로 만들고 상태를 저장합니다.

MapSaver

@Parcelize 가 몇 가지 이유로 적합하지 않다면, mapSaver 를 사용하여 객체를 시스템이 번들에 저장할 수 있는 값의 집합으로 변환하는 규칙을 정의할 수 있습니다.

ListSaver

맵 구조에 불필요한 키 정의를 피하고 싶다면 listSaver 를 이용하여 인덱스를 키로 사용할 수 있습니다.

Managing state in Compose

단순 상태 호이스팅은 컴포저블 함수 내에서 관리할 수 있습니다. 하지만 상태를 추적해야 하는 양이 증가하거나, 컴포저블 함수에서 로직을 수행해야 한다면 로직과 상태에 대한 책임을 다른 상태 보유자(State holders) 에게 위임하는 것이 좋습니다.

🅰️ 핵심 용어 : 상태 보유자(State holders)는 컴포저블의 상태와 로직을 관리합니다.

다른 곳에서 상태 보유자는 호이스트 상태 객체(hosted state objects) 라고 부르기도 합니다.

이 장에서는 컴포즈에서 상태를 관리하는 여러 방법을 다룹니다. 컴포저블의 복잡성에 따라, 각기 다른 대안을 고려해 볼 수 있습니다:

  • 단순히 UI 요소의 상태를 관리하기 위한 Composables.
  • 복잡한 UI 요소의 상태관리를 위한 State holders. UI 상태 및 UI 로직을 가지고 있습니다.
  • 비즈니스 로직과 화면, UI 상태에 대한 접근을 제공하는 형태의 특별한 상태 보유자(state holder)인 아키텍처 컴포넌트 뷰모델

상태 보유자는 하단 앱바 같은 단일 위젯부터 전체 화면까지 관리하는 UI 요소의 범위에 따라 규모가 다양합니다. 상태 관리자는 혼합되어질 수 있습니다; 어떤 상태 보유자가 상태를 통합이 필요할 때 다른 상태 보유자와 통합이 가능합니다.

다음 다이어그램은 컴포즈 상태 관리에 관련된 요소(entities) 간 관계를 요약한 것입니다. 이 장의 나머지 부분에서는 각 요소에 대하여 자세히 다룹니다.

  • 컴포저블은 0개 이상의 상태 보유자와 의존할 수 있습니다. (일반 객체, 뷰모델 또는 둘 다일 수 있음)
  • 일반적인 상태 보유자는 비즈니스 로직이나 화면 상태에 접근해야 할 경우 ViewModel 에 의존할 수 있습니다.
  • 뷰모델은 비즈니스 또는 데이터 계층에 의존합니다.
캄포즈 상태 관리와 관련된 각 요소에 대한 종속성 요약입니다. (선택사항)

Types of state and logic

안드로이드 앱은, 다양한 유형의 상태를 고려해야 합니다 :

  • UI 요소의 상태는 UI 요소의 호이스트 상태입니다. (=UI 요소를 표시하기 위해 전달되는 파라미터) 예를들어 ScaffoldStateScaffold 컴포저블 상태를 다룹니다.
  • 화면 또는 UI 상태어떻게 화면이 표시되어야 하는지와 관련이 있습니다. 예를들어, CartUiState 클래스는 카트 아이템 목록, 유저에게 표시되어야 할 메시지, 로딩 플래그를 가지고 있습니다. 이 상태는 일반적으로 어플리케이션 데이터를 포함하고 있기 때문에 다른 영역 또는 계층과 연관됩니다.

또한, 로직의 종류도 다양합니다 :

  • UI 행동 로직 또는 UI 로직어떻게 화면에 상태 변화를 표시하는지와 관련있습니다. 예를 들어, 다음 화면을 표시할 지 결정하는 네비게이션 로직이나, 스낵바나 토스트를 사용하여 유저에게 화면에 메시지를 어떻게 표시할지 결정하는 UI 로직입니다. UI 행동 로직은 항상 구성(Composition) 안에 존재해야 합니다.
  • 비즈니스 로직은 상태 변화에 따라 무엇을 할 것인가 입니다. 예를 들어 결제 또는 사용자 설정을 저장하는 것입니다. 이러한 로직은 보통 비즈니스 혹은 데이터 레이어에 위치하며, 절때 UI 영역에 존재하지 않습니다.

Composables as source of truth

만약 상태와 로직이 단순하다면 UI 로직과 UI 요소에 대한 상태를 컴포저블에 포함시키는 것은 좋은 방법입니다. 예를들어, MyApp 컴포저블은 ScaffoldState 와 CoroutineScope 을 처리합니다.

ScaffoldState 는 변경 가능한 속성이 포함되어 있으므로, MyApp 컴포저블에서 이 상태와의 상호작용이 이루어져야 합니다. 그러지 않고 다른 컴포저블에 상태를 전달해버린다면, 이 상태는 변경가능하기 때문에 진실의 원천 원칙(Source of truth principle)에 위배되며 버그 추적이 훨씬 어려워질 것입니다.

State holders as source of truth

컴포저블이 UI의 여러 상태를 포함하는 복잡한로직을 갖고 있다면, 상태 보유자(state holder)로 책임을 위임해야 합니다. 이것은 로직을 고립시켜 좀 더 테스터블하게 하고, 컴포저블의 복잡성을 감소 시킵니다. 이 접근법은 관심의 분리원칙을(Separation of concerns principle) 분명히 합니다: 컴포저블은 UI 요소를 표시하며, 상태 보유자는 UI 로직과 UI 요소의 상태를 포함합니다.

상태 보유자는 구성(Composition) 간에 생성 및 기억되는 일반 클래스입니다. 컴포저블의 라이프사이클을 따르기 때문에, 컴포즈 의존성을 따릅니다.

만약 Composables as source of truth — MyApp 컴포저블의 책임이 증가하는 경우, MyAppState 상태 보유자를 생성하여 복잡성을 관리할 수 있습니다.

MyAppState 가 종속성을 가져갔기 때문에 컴포지션 중 MyAppState 인스턴스를 기억하고 메소드를 제공하는 것은 동안 종은 방법입니다. 이 경우에는 rememberMyAppState 함수를 사용할 수 있습니다.

이제 MyApp 은 UI 요소를 내보내는데 중점을 두고, 모든 UI 논리와 UI 상태를 MyAppState 에 위임합니다.

보는것 처럼, 컴포저블의 책임이 증가할 수록 상태 보유자의 필요성이 증가합니다. 책임은, UI에 필요한 로직이 될 수도 있고 추적이 필요한 상태의 양이 될 수 있습니다.

참고 : 상태 보유자에 액티비티 또는 프로세스가 재생성 된 후에도 보존할 상태가 포함된 경우, rememberSaveable 을 이용하고 커스텀 Saver 를 생성해 사용합니다.

ViewModels as source of truth

일반 상태 홀더 클래스가 UI 로직 및 UI 요소의 상태를 담당한다면, 뷰모델은 다음을 담당하는 특별한 유형의 상태 홀더 입니다 :

  • 어플리케이션이 접근할 수 있는 일반적인 계층의 다른 계층에 배치되는 비즈니스로직 (비즈니스 / 데이터 계층 처럼)
  • 특정 화면을 표시하기 위해 어플리케이션 데이터를 준비하고, 이것이 화면이나 UI 의 상태가 되는 경우

뷰모델은 환경의 변화(configuration changes)에도 살아남기 때문에 구성(Composition)보다 더 오래 살아남습니다. 뷰모델의 라이프사이클은 컴포즈 컨텐츠 작성자 — 다시 말해, 액티비티 또는 프래그먼트 — 를 따르거나, 네비게이션 라이브러리를 사용하는 경우 네비게이션 그래프의 수명주기를 따릅니다. 이러한 긴 수명때문에, 뷰모델은 컴포즈 라이프사이클과 바인딩된 상태에 대한 참조를 갖지 않아야 합니다. 만약 그렇게 된다면, 메모리 릭의 원인이 될 수 있습니다.

우리는 비즈니스 로직과 UI 상태에 대한 진실의 근원(=뷰를 위한 원천 소스)을 제공하기 위해 뷰모델을 화면 레벨의 컴포저블에서 사용할 것을 추천합니다. 왜 뷰모델이 이 부분에서 적절한지는 뷰모델과 상태홀더 섹션을 확인해 보세요.

다음은 화면 레벨의 컴포저블에서 뷰모델이 사용되는 예제입니다:

참조 : 만약 뷰모델에서 프로세스 재생성 후에도 보존이 필요한 상태가 있다면, SavedStateHandle 을 사용하세요.

ViewModel and state holders

안드로이드 개발에서 뷰모델의 장점은 비즈니스 로직에 대한 접근을 제공하는 것과 화면에 표시하기 위해 어플리케이션 데이터를 준비하는 것입니다. 즉, 이점은 다음과 같습니다.

  • 뷰모델에 의해 트리거된 작업은 환경 변경(configuration changes) 후에도 유지됩니다.
  • Jetpack Navigation 과 통합할 경우 :
    - 네비게이션은 화면이 뒤쪽 스택에 있는동안 뷰모델을 캐시합니다. 이것은 목적지로 돌아올 때 이전에 로드된 데이터를 즉시 사용가능할 수 있도록 하기에 중요한 대목입니다.
    - 뷰모델은 백스택에서 튀어나갈때(pop) 삭제되므로 상태가 자동으로 정리됩니다. 이는 화면 변경이나 configuration change 등 여러가지 이유로 컴포저블이 폐기될 때 청취하는 것과는 다릅니다.
  • Hilt 와 같은 젯팩 라이브러리와 통합에 이점이 있습니다

참조 : ViewModel 의 이점이 당신의 사용 사례에 적용되지 않거나 다른 방식으로 작업을 수행하는 경우, ViewModel 의 책임을 상태 소유자로 이전할 수 있습니다.

뷰모델과 단순 상태 소유자가 서로 다른 책임을 가지고 있다 하더라도 혼합이 가능하기 때문에, 화면 레벨 단위에서 비즈니스 로직에 접근하는 것을 제공하면서 UI 로직과 UI 상태를 관리하는 것이 가능해집니다. 뷰모델은 상태 소유자보다 좀 더 수명이 길기 때문에, 상태 소유자는 필요할 경우 뷰모델을 종속성으로 취할 수 있습니다.

다음 코드는 뷰모델과 단순 상태 홀더가 함께 ExampleScreen 에서 동작하는 예제입니다.

--

--

김종식

앱 개발자 / 꿈은 축구선수 / 쌍둥이 아빠