Compose 무작정 맛보기 [2. State 와 Composable 의 LifeCycle 이야기]

ricky_0_k·2022년 3월 25일
1

Compose 맛보기

목록 보기
3/7
post-thumbnail

변명

얼마만에 업데이트인지 모르겠다.

코로나는 1월말에 걸렸다가 다 나았고, 2~3월에는 공식문서나 묵혔던 책도 보기로 마음 먹었었다.
묵혔던 책의 경우는 안드로이드 프로그래밍 NEXT STEP 였는데 지금은 다 읽은 상태이다.
도움이 많이 되었었고, 아직 다 이해가 되지 않은 내용들도 있어 한 번 더 봐야될 것 같다.

그러면서 예상치 못했던 일들도 경험하면서 명치를 씨게 맞고 번아웃도 조금 오긴 왔지만
그만큼 내적, 외적으로 깨달았던 것들도 있었던 것 같다. 근황은 여기까지.. 앞으로 할 건 더욱 많은 것 같다.

작년을 회고하면서 내가 세웠던 목표는 최소 1달에 한 번은 포스팅을 올리는 것이었다.
저번 글을 쓰고 feel 받아 계속 써야했지만 타이밍을 놓쳤던 것 같다.

새로운 주제를 가진 포스팅도 올리고 싶지만,
일단은 밀린 것들 (CI/CD, Jetpack Compose) 을 정리할 필요를 느꼈기에
밀린 것들을 상반기에 모두 정리해보려 한다.

(이제 진짜) 서론

(기억이 가물가물한) 이전 포스팅에서 4.3, 4.5 동그라미 버튼의 선택값을 아래와 같이 기억하도록 했다.

val enablePosition = vm?.enablePosition?.observeAsState()

지난 포스팅에서 vm?.enablePosition 은 LiveData 이며
이 값을 state 형태로 바꾸어 Composable 를 새로 그려주는 작업 (이하 ReComposition) 을 할 수 있게 했다고 말했었다.
그리고 ReComposition 은 Compose LifeCycle 와도 관련이 있다고 했었는데 오늘은 이 내용을 정리해보려 한다.

State

안드로이드 공식 문서 - State and Jetpack Compose 에는 이렇게 설명하고 있다.

State in an app is any value that can change over time.
This is a very broad definition and encompasses everything from a Room database to a variable on a class.
...

app의 State 는 시간이 지남에 따라 변경될 수 있는 모든 값입니다.
이것은 매우 광범위한 정의이며, Room 데이터베이스에서 클래스의 변수에 이르기까지 모든 것을 포함합니다.
...

... 에는 블로그 게시물 및 관련 댓글, 네트워크 연결 실패시 보여주는 snackbar, ripple 애니메이션 효과 등 다양한 예시를 이야기했다.
state 를 활용해 앱 구현을 하고 돌아보는 입장에서 다시 보니, 이 내용이 눈에 들어왔다.

실제 앱 내에서 사용한 State 예로 다시 본 내용

실제 내가 앱 내에서 사용했던 state 들은 아래와 같다.

  1. 기본 점수 설정 화면에서 선택한 기본 점수 (4.3, 4.5)
  2. 학점 입력 화면에서, 현재 학기, 해당 학기의 과목들, 열 index, 보기모드 활성화 여부
  3. 학점 입력 화면에서, 내가 선택한 과목 type 과 학점 및 입력한 값
  4. ripple 애니메이션 설정에서 사용했던 state
  5. 기타 (focus 관련 등)

대체로 내가 선택하거나 직접 입력한 값들 (1,3), 보여주는 컨텐츠 또는 내부에서 관리하는 값(2), 애니메이션 및 기타 값(4,5) 이었고, 연결해보면 ... 에서 언급된 예시와 매치가 되는 내용들이었다.

위에서 언급한 값들은 어떤 공통점이 있을까? 위 블록 내용대로 변경될 수 있는 값이다.

  • 내가 선택 혹은 직접 입력하면서 값이 변경될 수 있다.
  • 내부의 값을 변경하여 현재 설정된 모드를 바꿀 수 있고, 보여지는 컨텐츠들이 변경될 수 있다.
  • 애니메이션 활성화로 View 모습이 (잠시) 변경될 수 있다.

모두 시간이 지남에 따라 변경될 수 있는 내용들이다.

Composable 에서는 이런 변경에 대한 내용을 state 로 관리를 하고,
state 가 바뀌면 그 값에 따라 새로 그려준다. (Recomposition)

공식 문서의 예

아래는 공식문서의 예제이다.

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       Text(
           text = "Hello!",
           modifier = Modifier.padding(bottom = 8.dp),
           style = MaterialTheme.typography.h5
       )
       OutlinedTextField(
           value = "",
           onValueChange = { },
           label = { Text("Name") }
       )
   }
}

이 코드를 실행하면 어떻게 되는지 확인해보면, 아무것도 발생하지 않는 걸 볼 수 있다.
(아마 텍스트 입력 커서는 활성화 되겠지만 입력해도 아무런 동작이 없을 것이다.)

왜 그럴까? Composable UI 에서 업데이트 하는 유일한 방법은 새 상태를 알리는 것 밖에 없기 때문이다.
기존 XML 방식과 다르게 값에 대한 상태를 명시하지 않거나, 상태를 갱신하지 않으면
Composable 기반 UI 는 동작하지 않거나, 값을 갱신하지 않는다.

그럼 위 코드를 동작하게 만들려면 어떻게 해야할까?
정답부터 이야기하면 아래와 같이 작성해야 할 것이다.

@Composable
fun HelloContent() {

	// 
    val text = remember { mutableStateOf("") }
    
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello!",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
        
			// 
            value = text.value,
            
            onValueChange = {
            
            	// 
                text.value = it
                
            },
            label = { Text("Name") }
        )
    }
}

주석(//) 아래에 있는 로직(?)을 추가하면 xml 에서 EditText 를 추가한 듯한 동작이 나올것이다.
저 추가된 로직(?) 에 대해 깊게 확인해보자

1. val text = remember { mutableStateOf("") }

text 라는 State 타입 property 를 선언한다는 건 이해했는데,
remembermutableStateOf() 는 뭘까?

remember

위의 OutlinedTextField 를 기반으로 이야기해보겠다.
OutlinedTextField 는 글을 입력할 수 있는 Composable UI 이다.

비슷한 기능인 EditText 에서는 입력한 글에 대한 상태를 관리해줄 필요가 없지만,
OutlinedTextField 에서는 상태를 관리해주어야 한다.
과연 OutlinedTextField 에서는 글에 대한 상태를 어떻게 불러올 수 있을까?

이는 Composable UI 내에서 가지고 있는 메모리 덕분에 가능하다.
실제 공식문서에도 아래의 내용이 있다.

Composable functions can store a single object in memory by using the remember composable.

single object 라는 말이 모호하지만 확실한 건 Composable 함수는 메모리 내에 single object 를 저장할 수 있다는 것이고, 이는 remember composable 을 통해 가능하다는 것이다.

결론을 정리하면 우린 이렇게 이야기할 수 있다.

remember 을 통해 우리는 Composable UI 에 정보를 상태값을 저장하고 불러올 수 있다.

참고) 별개의 이야기로 remember 도 composable 이다. 실제 구현부를 보면 아래와 같다.

@Composable
inline fun <T> remember(calculation: @DisallowComposableCalls () -> T): T =
	currentComposer.cache(false, calculation)

이말인 즉슨 remember 를 Composable 함수 외에서는 못쓴다는 것이다.

mutableStateOf()

remember 을 통해 Composable UI 에 값을 저장한다는 걸 알았으니, 이건 쉽게 이해될 것이다.
변경가능한(mutable) 상태 타입 인스턴스를 만든다는 말이고 처음에 들어가는 인자는 초기값이다.

그런데 궁금하다 왜 직접 클래스 호출 방식(ex. MutableState()) 은 사용하지 않을까?
이는 실제 구현부를 보면 답이 나온다.

@Stable
interface MutableState<T> : State<T> {
    override var value: T
    operator fun component1(): T
    operator fun component2(): (T) -> Unit
}

공식문서에서는 value 만 있으며, 이외 operator 함수는 아래 의미를 가진다고 한다.

  • component1() : mutableState 내부의 값을 나타냄 (as like getter)
  • component2() : 값이 들어왔을 때 값을 세팅하는 람다 식 (as like setter)

이미 interface 로 구현되어 있으므로 당연히 만드는 게 불가능하고,
MutableState 를 상속받는 다른 자식 클래스들도 있는 걸 보면 (ex. SnapshotMutableState 등),
직접 인스턴스화하여 구현하는 방식을 지양하기 위해 이렇게 호출하는 것 같다.

2. value = text.value

말 그대로 state.value 를 OutlinedTextField 에 넣어 상태 값과 UI 를 연결시키는 것이다.
state 값이 변경된 경우의 결과는 3번에서 다룬다.

3. text.value = it

상태의 값에 새로운 값을 주입하는 것이다.
state 타입의 값이 변경되면, 해당 state 에 연결된 Composable UI 의 recomposition 이 시작된다.

StateFul vs Stateless

메모리를 사용해 객체를 저장하기 때문에, Composable UI 의 내부 상태 값은 State 에 의해 관리된다.
이에 대한 단점도 존재한다. 해당 Composable UI 를 호출하는 곳에서 State 변경을 할 수 없기에
재사용성이 떨어지고, 테스트를 하기에도 어려운 측면이 있다.

이런 단점을 극복하기 위해 State Hoisting pattern 을 사용하여 상태를 갖지 않는 Composable 로 변경이 가능하다고 한다.

State hoisting

말 그대로 상태를 관리하는 곳을 위(ex. caller) 로 끌어올린 느낌인데,
이는 앞서 말한 viewModel 이 그 예가 될 수 있다.

실제 장점은 아래와 같다고 한다.

  1. Single source of truth
    상태를 복제하지 않고 하나의 포인트에서만 상태를 관리한다.
  2. Encapsulated
    stateful composable 만 상태를 수정할 수 있으므로, 캡슐화가 되어 있다.
  3. Sharable
    여러 composable 에서 참고할 수 있다.
  4. Interceptable
    상태가 바뀌는걸 caller 부분에서 제어할 수 있다.
  5. Decoupled
    state는 어디에도 저장될 수 있으며, Viewmodel 같은 곳으로 옮겨져서 처리할 수 있다.
    state가 hoisting 되면 composable과 state는 의존관계가 없어진다.

아래는 실제 구글 공식문서의 예이다.

@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") }
        )
    }
}

HelloScreen (일종의 호출부) 에서 HelloContent 를 다루고 있다.

HelloContent 의 동작(event) 가 HelloScreen 으로 전파되고
HelloScreen 은 state 의 변경에 맞춰 호출 순서대로 상태변경 내역을 반영해나간다.
(name 을 관리하는 HelloContent 에서 HelloScreen 으로 내려간다.)

아래 그림이 위 설명을 표현한 도식이다.
단방향 데이터 흐름도 지키면서, 분리가 되므로
보다 코드 관리는 용이해질 것이다.

주의사항도 일부 있다. 어찌보면 당연한 이야기여서 언급만 하고 넘어간다.

  • 최대한 낮은 공통 부모로 호이스팅되어야 한다.
  • 최대한 높은 자식에 상태가 변경될 수 있어야 한다.
  • 동일한 이벤트에 대한 응답으로 두 상태가 변경되면 함께 호이스팅 되어야 한다.

rememberSaveable

compose 를 사용하면서 이 이야기가 나올 수 있다.

상태 변경 되거나, 앱이 중간에 죽어서 다시 키는 경운 어떻게 해야하나요

이럴 때는 rememberSaveable 를 사용해야 한다.
번들에 추가할 수 없는 데이터를 저장하는 경우 Parcelize, MapSaver 등을 사용해야한다.

내가 만든 앱은 portrait 고정이어서 이 작업은 하지 않았지만
ViewModel 을 활용하면 쉽게 해결되므로 이런 것도 있구나 하고 넘어갔다.

  1. LiveData, Flow, RxJava2 와 연동된다.
    위 코드에서 언급했었지만 observeAsState() 를 통해,
    Composable 에 Observable 형 변수를 연결시킬 수 있다.

Compose LifeCycle

아까 우리는 State hoisting 을 활용해 데이터를 바꿔주고 recomposition 을 해준다고 했다.
한번 이를 어떻게 하는지 깊게 한번 확인해보려 한다.

기본 시나리오

먼저 최상위에 데이터를 제공해주었을 때의 시나리오이다. (처음 실행할때도 마찬가지이다.)
최상위에 호출된 Composable 함수를 실행하여 최상위 UI 를 그리고,
그 내부에 선언되어 있는 다른 Composable 함수를 호출하면서 UI 를 그린다.
경우에 따라 계속 데이터를 전달하면서 UI 가 그려질 수 있다.

위는 Composable 내에서 이벤트가 생겼을 때의 시나리오이다.
변경된 내용에 따라 state 가 연결되어있는 경우, 데이터 제공해주었을 때의 시나리오가 동작하기도 한다.

위 내용을 도합하여 정리하면 Compose 의 lifecycle 은 아래와 같이 정리할 수 있다.

  1. Composition 시작
  2. 최소 0번 이상 재구성
  3. Composition 종료

Composable 은 여러 번 호출되면 Composition 에 여러 인스턴스가 배치되며
각 호출에는 Composition 에서 고유한 수명주기가 있다.

@Composable
fun MyComposable() {
    Column {
        Text("Hello")
        Text("World")
    }
}

실제 위 코드는 아래와 같은 수명주기를 가진다. 색이 다른 건 다른 인스턴스라는 표시이다.

call site

  1. Composition 내에서 Composable 인스턴스를 구분하는 식별자이다.
    실제 Compose 컴파일러는 각 call site 를 고유한 것으로 간주하고,
    여러번 호출하면 여러 인스턴스가 생성된다.
  2. Composable 이 호출되는 소스 코드 위치를 이야기한다.
    이는 Composition 하는 위치에도 영향을 미쳐, UI 트리에도 영향을 끼친다.

어찌보면 위 내용이 충돌되어 보이기도 하는데
실제 recomposition 할 때에는, 어떤 Composable 이 호출되었는지 여부를 식별하고
입력이 변경되지 않은 경우 그 Composable 은 Recomposition 을 하지 않는다.

아래 코드를 보자

@Composable
fun LoginScreen(showError: Boolean) {
    if (showError) {
        LoginError()
    }
    LoginInput() // This call site affects where LoginInput is placed in Composition
}

showError 가 state 변수내 값인 경우, false 라면
LoginScreen -> LoginInput 이 순차적으로 composition 될 것이다.

그럼 여기서 true 로 바뀌면 어떻게 될까?

recomposition 단계에서 LoginScreen, LoginInput 은 새로 그려지지 않고
LoginError 는 기존에 없었으므로 새로 그려지게 된다.

아래와 같이 같은 call site 에서 여러번 호출할 때에는, call site 와 함께 실행 순서가 사용된다.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            // MovieOverview composables are placed in Composition given its
            // index position in the for loop
            MovieOverview(movie)
        }
    }
}

새로운 movie 가 리스트에 추가된다면, 아래와 같이
새롭게 추가된 항목에 대해서만 composition 이 발생하고 나머지는 그대로 재사용할 것이다.

목록의 상단이나 중간에 추가하거나, 항목을 제거 또는 재정렬하여 영화목록이 변경되면
모든 MovieView 에서 recomposition 이 발생한다.

@Composable
fun MovieOverview(movie: Movie) {
    Column {
        // Side effect explained later in the docs. If MovieOverview
        // recomposes, while fetching the image is in progress,
        // it is cancelled and restarted.
        val image = loadNetworkImage(movie.url)
        MovieHeader(image)

        /* ... */
    }
}

만약 이런 코드에서 새로운 값이 추가되거나 맨 위에 값이 추가되면 아래와 같이 전부 새로 그려진다.

그러면 이런 경우를 없애려면 어떻게 해야할까? key 를 활용해 unique 값을 직접 명세할 수 있다.
key의 값은 global 에 유니크한 값일 필요는 없다. call site에서 Composable 호출시에만 유니크하면 된다.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            key(movie.id) { // Unique ID for this movie
                MovieOverview(movie)
            }
        }
    }
}

위 예제처럼 매 movie 는 movies 사이에서 유니크한 key가 필요하다.
key 를 앱의 다른 위치에 있는 다른 Composable 과 공유해도 괜찮다.
이렇게 되면 리스트에서 요소가 변경 시 Compose는 알아채고 재사용이 가능해진다.

참고) 몇몇 Composable은 key composable을 내부에 지원하고 있다.
ex. LazyColumn : items DSL 내부에 특정 custom key를 받을 수 있다.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    LazyColumn {
        items(movies, key = { movie -> movie.id }) { movie ->
            MovieOverview(movie)
        }
    }
}

위 코드가 그 예제이다.

Recomposition skip

Composable 이 Composition 에 이미 있고 모든 입력이 안정적이면서 변하지 않았다면
RecomPosition 을 건너뛸 수 있다.

안정적인 타입은 아래 내용을 준수해야 한다.

  1. 두 인스턴스의 equals 결과가 동일한 두 인스턴스는 항상 동일하다
  2. 타입의 public 속성이 변경되었을 시 Composition 에 알린다.
  3. 모든 public 속성 타입 또한 안정적이다.

Compose 컴파일러가 안정적인 것으로 취급하는 (위 내용을 준수하는) 몇가지 중요한 공통 타입이 있다.

  1. 모든 주요 value 타입 : Boolean, Int, Long, Float, Char 등...
  2. 문자열(Strings)
  3. 모든 함수 타입(람다)

변경할 수 없는 타입은 절대 변경할 수 없으므로
Composition 에 변경사항을 알리지 않아도 되므로 준수하기가 더 쉽다.

MutableState
만약 값이 MutableState 에 유지되고 있다면,
Compose가 State의 .value 속성의 변경사항에 대해 알림을 받을 것이기 때문에
State Object는 전반적으로 안정적이라고 간주된다.

Composable에 파라메터로 전달되는 모든 타입이 안정적일 때,
파라메터 값은 UI 트리의 Composable 위치에 기반하여 동일한 값인지 비교된다.
모든 값이 이전 호출에서의 값과 변경된 것이 없다면 Recomposition 은 skip 된다.
값 비교는 equals() 를 사용한다.

interface 는 비안정적이라고 간주한다.
변경할 수 있는 public 속성을 가지고 있고 구현 변경이 안되므로 안정적이지 않다.

@Stable

Compose가 해당 타입을 안정적으로 처리하도록 하려면 @Stable 어노테이션으로 표시한다.

// Marking the type as stable to favor skipping and smart recompositions.
@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?

    val hasError: Boolean
        get() = exception != null
}
  1. UiState는 interface이기 때문에 Compose는 원칙적으로는 안정적이지 않은 타입
  2. 하지만 @Stable 어노테이션을 추가함으로써 Compose 에 이 타입이 안정적이라는 것을 알려줌
  3. Compose 는 smart recomposition 을 선호하게 됨

-> interface 가 파라메터 타입으로 사용될 시, Compose는 이 interface 의 모든 구현을 안정적으로 간주하고 처리

결론

State 를 정리하면 Composable UI 가 가지고 있는 상태값이고,
Lifecycle 은 Composition, Recomposition, exit 로 정리될 수 있다.

다시 돌아보니 일부 내용을 놓치고 (ex. keys) 개발했던 내용도 있었던 것 같다.
이는 리펙터링 대상으로 자연스럽게 넣어 놓아야겠다.

State 와 Compose Lifecycle 을 다뤄보았으니 이제 다음 Step 인
메인 화면 (학점을 아름답게 볼 수 있는 화면) 을 다뤄보려 한다.

여기에서는 xml 내에 Compose 를 넣어서 처리하는 방식을 사용해봤었는데
호환성에 대해서도 이야기가 나올 것 같다.

참고
1. State and Jetpack Compose
2. Thinking in Compose
3. Lifecycle of composables
4. [Android] Compose Lifecycle(안드로이드 개발자 사이트 번역)
5. 4. 상태관리 - hoisting, mutableState, remember, rememberSaveable, Parcelize, MapSaver, ListSaver

profile
valuable 을 추구하려 노력하는 개발자

1개의 댓글

comment-user-thumbnail
2022년 7월 5일

정말 좋은글 감사합니다...

답글 달기