Jetpack Compose, 어디까지 알고 있을까? + 미공개 슬라이드 추가 및 약간의 후기

2022 찰스의 안드로이드 컨퍼런스 발표 자료

Ji Sungbin
성빈랜드
38 min readJun 19, 2022

--

2022 찰스의 안드로이드 컨퍼런스에서 “Jetpack Compose, 어디까지 알고 있을까?” 라는 주제로 발표한 내용을 공유합니다.

안녕하세요, 저는 “Jetpack Compose, 어디까지 알고 있을까?” 라는 주제로 발표할 지성빈 입니다.

저는 대전대학교 컴퓨터공학과 2학년에 재학중이고, 성빈랜드 라는 안드로이드 기술 블로그를 운영하고 있습니다.

이 발표에서는 제가 컴포즈를 작년 말부터 써오면서 최근까지 몰랐던 흥미로운 기능들에 대해 소개해보려고 합니다. 목차는 UI, Runtime, Snapshot System, LiveLiterals 로 준비해 보았습니다.

컴포즈 버전은 1.2.0-beta02 버전을 기준으로 준비했으며, 코드 세부 사항은 빠른 이해를 위해 생략하였습니다.

첫 번째 파트인 UI 부터 시작하겠습니다.

가끔 사용자의 편의를 위해 텍스트에 선택 기능을 제공해야 할 때도 있지만, 기본 Text 는 선택이 가능하지 않습니다.

선택 가능한 Text 를 위해선 Text 를 SelectionContainer 로 감싸 주어야 합니다. 이 중 일부는 다시 선택 불가능하게 할 수도 있습니다.

DisableSelection 으로 감싸주면 해당 범위 안에선 선택이 불가능 해집니다.

다음으로 ClickableText 입니다. ClickableText 를 이용하면 클릭된 텍스트의 오프셋을 얻을 수 있습니다. Modifier.clickable 을 통해서도 클릭을 할 수 있지만, 이는 클릭된 오프셋은 가져오지 못합니다. ClickableText 의 대표 인자로는 AnnotatedString 을 받는 text 와 클릭된 오프셋이 인자로 들어오는 onClick 람다가 있습니다. ClickableText 를 이렇게만 사용한다면 큰 의미가 없습니다.

AnnotatedString 를 이용하여 어노테이션과 함께 활용할 수 있습니다. 이 예제에서는 성빈랜드 단어에 어노테이션을 넣어주었습니다. 이렇게 어노테이션된 문자열은 getStringAnnotations 함수로 주어진 offset 범위 안에 있는 특정 태그를 가진 어노테이션을 가져올 수 있고, 가져온 어노테이션을 토스트로 출력해 보았습니다.

다음으로 LazyList key 입니다. 기본적으로 LazyList 는 아이템을 배치 순서로 재사용 합니다. 따라서 아이템 순서에 변동이 없다면 문제가 없지만,

아이템 순서에 변동이 생긴다면 해당 아이템들은 다 다시 그리게 됩니다. 만약 위치가 변동되는 아이템들이 많다면 이는 성능 손실을 유발합나다.

이런 상황을 예방하기 위해 아이템을 재사용하는 키를 명시적으로 설정해줄 수 있습니다. items 함수의 key 인자를 사용하면 해당 인자의 반환 값을 기준으로 아이템을 재사용합니다. 인자 값으론 현재 아이템의 값이 들어옵니다.

하지만 지금은 다 같은 형태임에도 불구하고 매번 아이템을 그리기 위해 컴포저블을 각각 그리게 됩니다. items 함수의 contentType 인자로 같은 형태의 컴포저블끼리는 컴포저블을 재사용하게 할 수 있습니다. 인자 값으론 현재 아이템의 값이 들어오며 같은 반환 값 끼리는 컴포저블을 재사용 합니다. 이 예제에서는 다 같은 형태의 컴포저블을 사용하기 때문에 상수값으로 반환을 해주었습니다.

지금까지 LazyList 예제를 보면 모두 아이템 간격을 위해 vertical padding 을 주고 있습니다. 따라서 아이템 간격이 의도한 30 이 아닌 60 만큼 주어지게 됩니다.

올바른 간격을 위해 LazyList 의 arrangement 로 spacedBy 를 줌으로써 아이템 사이 간격을 명시적으로 지정할 수 있습니다.

spacedBy 는 LazyListScope 가 아니기에 일반 Layout 에서도 사용할 수 있으며, 원래 arrangement 의 목적인 졍렬은 두 번째 인자인 alignment 를 사용하여 설정할 수 있습니다.

spacedBy 는 아이템 사이 간격이기에 최상단과 최하단에는 패딩을 넣지 않습니다. 이는 LazyList 의 contentPadding 인자로 PaddingValues 를 줌으로써 해결할 수 있습니다. contentPadding 은 clipToPadding 을 false 로 설정한 것과 같은 효과로 패딩이 적용됩니다. 이는 Modifier.padding 으론 불가능한 방법입니다.

다음으로 intrinsic measurement 입니다. intrinsic measurement 를 사용하면 레이아웃의 사이즈를 본질적인 사이즈로 지정할 수 있습니다. Column 의 가로 사이즈로 IntrinsicSize.Max 를 주었습니다. 따라서 Column 의 아이템 중에서 가장 긴 가로 사이즈가 Column 의 가로 사이즈로 설정되고, 이 예제에서는 android developer 가 가장 길기에 Column 의 가로 사이즈가 android developer 의 가로 사이즈로 설정됐습니다.

IntrinsicSize.Min 은 가장 작은 아이템을 기준으로 사이즈가 설정됩니다. 이 예제에서는 성빈랜드가 제일 작기에 Column 의 가로 사이즈가 성빈랜드의 가로 사이즈로 설정됐습니다.

다음으로 정렬 입니다. 아이템을 정렬하는 방법으론 arrangement 와 alignment 가 있습니다. 이 두 가지의 핵심 차이점은 무엇일까요?

바로 레이아웃의 중심축 방향 입니다. arrangement 는 레이아웃의 중심축 방형을 가르키고, alignment 는 레이아웃 중심축의 반대 방향을 가르킵니다. 따라서 Column 의 arrangement 는 세로, alignment 는 가로를 가르키고, Row 의 arrangment 는 가로, alignment 는 세로를 가르키게 됩니다.

arrangement 에는 Start, End, Center, SpaceBetween, SpaceAround, SpaceEvenly 이렇게 6가지의 옵션이 존재합니다. 이 중 마지막 3가지의 옵션이 유독 헷갈립니다. SpaceBetween 은 첫 번째와 마지막 항목을 각각 맨 끝에 배치하고, 나머지 항목들을 균등하게 가운데에 배치합니다. SpaceAround 는 양 쪽 끝에 동일한 간격으로 패딩을 넣어주고, 가운데에는 양 쪽 끝에 들어간 패딩보다 더 큰 패딩을 사용하여 나머지 아이템들을 균등하게 배치합니다. 마지막으로 SpaceEvenly 는 모든 아이템들에 동일한 간격의 패딩을 넣어서 균등하게 배치합니다.

arrangement 에서 기본적으로 지원하는 옵션 의외에 필요한 옵션을 직접 만들수도 있습니다. arrangement 를 상속받은 오브젝트를 만들어 주고, arrange 함수를 오버라이드 해주면 됩니다. 이 예제에서는 vertical arrangement 를 상속하여 아이템들을 세로로 차례대로 배치하되 마지막 아이템만 하단에 배치되도록 만들어 보겠습니다. arrange 함수의 인자로는 배치 가능한 전체 높이를 담은 totalSize, 배치될 아이템들의 높이 배열인 sizes, 아이템들이 배치된 위치가 담길 배열인 outPositions 가 있습니다.

간단하게 y 변수를 만들어 주고, 아이템들을 y 값에 배치해 주었습니다. 아이템을 배치할 때 마다 y 값을 아이템 높이만큼 늘려주고, 만약 다 배치된 후에 배치 가능한 공간이 남았다면 전체 높이에서 마지막 아이템의 높이 만큼 뺀 위치에 마지막 아이템을 재배치 해주는걸로 완성됩니다.

이렇게 만들어진 커스텀 arrangement 를 동일하게 적용해주면 됩니다.

다음으로 modifier 입니다. modifier 는 컴포즈에서 필수적으로 쓰이는 요소며, 그만큼 커스텀이 필요한 상황들이 종종 발생합니다. 이 예제에서는 clickable 에서 리플을 없애는 noRippleClickable 과 invisible 처리해주는 invisible modifier 를 만들어 보겠습니다.

modifier 에는 2가지의 종류가 있습니다. 첫 번째로 standard modifier 이며, composable scope 가 필요 없는 modifier 입니다. modifier 을 오버라이드하여 구현할 수 있으며, then 중위 함수를 이용하여 다른 modifier 와 연결할 수 있습니다. 사전에 정의돼 있는 DrawModifier 를 상속하여 화면에 그리는 역할을 하는 draw 함수를 아무것도 그리지 않게 설정한 InvisibleModifier 오브젝트를 만들고, isInvisible 인자가 true 일 때만 then 으로 연결해주는 modifier 확장 함수를 만들어 간단하게 invisible modifier 를 구현해 보았습니다.

다음으로 composed modifier 입니다. composed modifier 는 composable scope 가 필요한 modifier 이며, modifier 의 composed 확장 함수를 사용하여 구현할 수 있습니다. clickable 에서 리플을 없애기 위해선 indication 을 null 로 설정해야 하고, indication 을 설정하려면 interactionSource 도 같이 설정해야 합니다. interactionSource 로 MutableInteractionSource 를 remember 로 감싸서 사용하기 위해 composed modifier 를 사용하여 noRippleClickable 을 구현해 보았습니다.

이렇게 간단하게 noRippleClickable 과 invisible modifier 을 만들어 보았습니다.

마지막으로 BackHandler 입니다. 컴포저블이 액티비티에 있다면 onBackPressed 를 사용해 뒤로가기를 제어할 수 있지만, 만약 외부 파일에 컴포저블 이 있다면 뒤로가기를 제어하기가 쉽지 않습니다. 이럴땐 BackHandler 컴포저블을 사용하여 뒤로가기를 제어할 수 있습니다. enabled 인자가 true 일 때만 작동하며, 만약 하나의 컴포저블에 여러개의 BackHandler 가 존재한다면 가장 깊숙히 있는 BackHandler 가 작동됩니다.

지금까지 UI 에서 소개해 드렸습니다. 이제 runtime 으로 넘어가겠습니다.

remember 는 리컴포지션시에도 인스턴스를 계속 갖고 있기 위해 자주 사용됩니다. 이와 비슷하게 rememberSaveable 도 있으며, 이는 onCreate 의 savedInstacneState bundle 에 인스턴스가 저장되기에 configuration change 가 일어나도 인스턴스가 유지됩니다.

rememberSaveable 는 번들에 저장되기에 번들 관련 인자들이 존재합니다. saver 와 key 가 이에 해당다며, key 부터 보도록 하겠습니다.

key 인자는 번들에 키로 저장될 값을 뜻하며, 인자로 주어지지 않은 경우에는 외부에 저장된 상태를 컴포지션에 매핑하는데 사용되는 해시 값인 currentCompositeKeyHash 를 키로 사용합니다.

다음으로 saver 는 값을 번들에 저장 가능한 값으로 변환하는 save 함수와, 번들에 저장된 값을 원래의 값으로 변환하는 restore 함수로 이루어진 인터페이스 입니다. rememberSaveable 은 기본적으로 AutoSaver 라는 구현체를 사용하며, 이는 받은 그대로 번들에 저장하고 복원하는 간단한 구현 입니다.

rememberSaveable 은 내부에서 AcceptableClasses 라는 배열을 이용하여 번들에 저장할 수 있는 값인지 검사하는 절차를 거칩니다.

따라서 번들에 저장할 수 없는 타입인 data class 로 IntHolder 를 만들어 rememberSaveable 로 저장한다면 이와 같이 예외가 발생합니다. 예외 메시지를 보면 커스텀 Saver 를 구현하라는 내용이 있습니다. 이를 해결하기 위해 Saver 를 커스텀 해보겠습니다.

IntHolder 에서 value 만 가져와서 save 로 넘겨주고, value 를 IntHolder 로 래핑하여 restore 로 반환하는 saver 를 구현해 보았습니다. 이렇게 구현한 IntHolderSaver 를 rememberSaveable 의 saver 인자로 넘겨주면 정상적으로 작동하게 됩니다.

Saver 를 직접 만드는게 번거롭다면 저장할 객체를 parcelable 로 만드는 방법도 있습니다.

이제 CompositionLocal 에 대해 알아보겠습니다. CompositionLocal 은 이미 Local 접두사를 가진 상태로 많이 존재하며, 이 중 LocalContext 가 가장 많이 쓰일 것입니다.

모든 컴포저블은 컴포저블 트리가 형성되고, 컴포저블 트리간 데이터를 전달하기 위한 도구를 CompositionLocal 이라고 부릅니다.

CompositionLocal 은 compositionLocalOf 와 staticCompositionLocalOf 이렇게 2개의 함수로 만들 수 있습니다. compositionLocalOf 은 값을 추적하기에 만약 제공된 값이 변경된다면 해당 값을 사용중인 컴포저블만 리컴포지션을 진행합니다. 하지만 staticCompositionLocalOf 는 값을 추적하지 않기에 제공된 값이 변경된다면 제공된 컴포저블 트리 자체를 리컴포지션을 진행합니다. 값이 자주 변경되지 않거나 아예 변경되지 않는다면 staticCompositionLocalOf 를 사용해 값을 추적하는 오버헤드를 없앰으로써 최적화할 수 있고, 그렇지 않다면 compositionLocalOf 를 사용해 값이 사용되는 컴포저블만 리컴포지션을 진행해 최적화를 할 수 있습니다.

CompositionLocal 에 provides 중위 함수를 사용하여 값을 제공할 수 있고, 제공된 값은 current 를 사용해 가져올 수 있습니다.

만약 값이 중첩으로 제공된 경우 제공된 범위만큼은 중첩된 값으로 제공됩니다.

CompositionLocal 에 값을 제공하는 방법에는 provides 외에 providesDefault 도 존재합니다. providesDefault 는 3번째 인자인 canOverride 를 보면 알 수 있듯이 값이 중첩되지 않습니다.

따라서 providesDefault 를 사용해 값을 중첩으로 제공하면 중첩된 값이 적용되지 않고 처음에 제공된 값으로 계속 제공됩니다.

마지막으로 state 의 사용 사례 3가지를 들고 각각 올바른 state 사용법에 대해 소개하도록 하겠습니다. 첫 번째 사례 입니다. 인자로 받은 value 를 mutableStateOf 로 래핑한 valueState 를 보여주는 ShowValue 함수가 있습니다. 초기 value 로 “가” 를 보여주고 1초 후에 “나” 로 바꿨지만 바뀐 value 가 표시되지 않습니다. 이유는 “가” 상태인 value 를 remember 로 저장하고 있기 때문입니다.

해결법으로 remember 의 key 로 value 를 지정하거나, 기존 state 에 값을 업데이트 해주는 방법이 있습니다. 전자의 방법은 value 가 바뀔때 마다 매번 새로운 인스턴스를 만들기에 후자의 방법을 지향해야 합니다.

따라서 후자의 방법을 쉽게 쓸 수 있도록 rememberUpdatedState 라는 함수가 존재합니다. 인자로 받은 state 가 추후 변경되는 경우에 사용할 수 있습니다.

두 번째 사례 입니다. 보통 로딩 상태를 나타내기 위해 로딩중 상태의 state 를 만들고, 이후 LaunchedEffect 으로 state 를 갱신하는 형태를 많이 사용합니다. 이러한 형태는 state 생성과 state 업데이트를 위한 LaunchedEffect 이 항상 뒤따른다는 약간의 보일러플레이트가 존재합니다.

이를 해결하기 위해 state 생성과 LaunchedEffect 을 통한 state 업데이트를 같이 할 수 있게 도와주는 produceState 라는 함수가 존재합니다. state 의 기본값과 LaunchedEffect 에서 할 액션을 인자로 받고 있습니다.

아까의 코드를 produceState 를 통해 간단하게 바꿀 수 있습니다. 이렇게 produceState 는 초기 state 를 생성하고 이어서 LaunchedEffect 에서 state 를 업데이트 해줄 때 사용할 수 있습니다.

마지막 사례 입니다. isEven 인자로 숫자가 짝수인지 판단하는 NumberFilter 함수가 있습니다. NumberFilter 함수는 isEven 여부에 따라 숫자를 짝수로 필터링하여 mutableStateOf 로 저장하고, 보여주고 있습니다. main 함수에서는 NumberFilter 을 사용해 처음엔 isEven 을 false 로 숫자를 조회하고, 1초 후에 true 로 숫자를 조회하려고 했지만 숫자가 업데이트되지 않습니다. 이유는 NumberFilter 함수에서 mutableStateOf 로 state 를 생산하기에 내부에 사용하는 state 인 isEvenState 의 변화를 감지하지 못하기 때문입니다.

이 상황처럼 외부 state 에서 파생된 state 를 생성하려면 derivedStateOf 를 사용해야 합니다. mutableStateOf 에서 derivedStateOf 로 바꾸면 내부 state 의 변화를 감지할 수 있기에 원하던 대로 작동하게 됩니다.

이렇게 해서 state 의 사용 사례 3가지를 소개하고 각각 올바른 state 사용법에 대해 알아보았습니다. 하지만 그동안 간과하며 넘어왔던 것이 있습니다.

바로 mutableStateOf 와 compositionLocalOf 의 policy 인자 입니다. policy 인자는 SnapshotMutationPolicy 를 받고 있으며,

이를 알아보기 위해 세 번째 파트인 Snapshot System 을 시작하도록 하겠습니다.

컴포지션은 특정 순서에 구애받지 않고 무작위 병렬로 실행됩니다. 따라서 하나의 state 에 여러 컴포지션이 동시에 접근할 수 있으며, 교착 상태에 빠질 수 있습니다. 컴포즈는 이러한 상황을 예방하기 위해 동시성 제어로 MutliVersion Concurrency Control 을 채택하였습니다. MultiVersion Concurrency Control 은 MVCC 라고 불리며, ACID 중 I 를 구현하기 위한 방법중 하나 입니다. ACI 를 지키며 구현된 상태 시스템이 컴포즈 스냅샷 시스템이며, ACI 에 대해 알아보기 위해 ACID 에 대해 알아보겠습니다.

ACID 란 데이터베이스 트랜잭션간 데이터 유효성을 보장하기 위한 속성 입니다. 이는 원자성, 일관성, 고립성, 지속성으로 구성돼 있으며 ACID 는 안드로이드 개념과는 관계가 멀기에 각 특성별로 간단하게만 알아보겠습니다.

원자성이란 트랜잭션의 결과가 성공/실패 둘 중 하나로 결정돼야 하는 속성입니다. 트랜잭션의 중간 결과를 볼 수 없어야 하고, 결과가 모든 곳에 동일하게 적용돼야 합니다. 일관성이란 트랜잭션을 수행하기 전과 후에 데이터베이스가 일관해야 한다는 속성 입니다. 고립성이란 각 트랜잭션은 다른 트랜잭션으로부터 자유로워야 한다는 속성 입니다. 마지막으로 지속성은 트랜잭션의 결과가 영구히 저장돼야 한다는 속성 입니다.

안드로이드는 데이터를 메모리에 저장하기에 D 를 제외한 ACI 를 컴포즈 스냅샷 시스템에서 지키고 있습니다.

MVCC 는 컴포즈 스냅샷 시스템의 핵심 컨셉이며, 처음에도 말했듯이 ACID 의 I 를 구현하는 방법인 동시성 제어중 하나 입니다. MVCC 란 read 와 write 작업에 lock 대신 각 트랜잭션이 진행될 때 데이터베이스 스냅샷을 캡처하여 해당 스냅샷 안에서 고립된 상태로 트랜잭션을 진행하는 방식 입니다.

age 가 10인 상태로 있고, A 트랜잭션에서 age 를 20으로 설정하였지만 아직 커밋하기 전이기 때문에 B 트랜잭션에선 age 가 여전히 10으로 보이게 됩니다. 커밋하기 전의 변경 사항은 모두 undo 영역에 저장됩니다. 이렇게 각 트랜잭션간 고립된 성질을 갖는 것이 MVCC 입니다.

컴포즈 스냅샷 시스템에서는 age 와 같이 스냅샷에 찍힐 대상을 StateObject 라고 하고, age = 20 와 같은 연산을 StateRecord 라고 합니다. 하나의 StateObject 에 여러개의 StateRecord 가 존재할 수 있으며, StateRecord 는 LinkedList 로 저정됩니다.

StateObject 에는 StateRecord 의 LinkedList 시작점인 firstStateRecord, StateRecord 의 LinkedList 에 새 레코드를 추가하는 prependStateRecord, 스냅샷이 충돌했을 때 병합하기 위한 방법인 mergeRecords 가 존재합니다. mergeRecords 에 대해서는 추후 알아보도록 하겠습니다.

StateRecord 에는 StateRecord 가 생성된 스냅샷의 아이디인 snapshotId, 다음 StateRecord 를 가르키는 next, 다른 StateRecord 에서 값을 복사하는 assign, 새로운 StateRecord 를 생성하는 create 가 존재합니다. MVCC 는 불변성을 활용하므로 레코드가 기록될 때마다 원본을 수정하는 대신 데이터의 새 복사본이 생성됩니다. 이를 위해 create 와 assign 함수가 존재합니다.

이제 스냅샷 시스템 사용에 대해 다뤄보겠습니다. 스냅샷은 Snapshot.takeMutableSnapshot 으로 수정 가능한 스냅샷을 찍을 수 있고, 찍은 스냅샷은 enter 를 통해 접근할 수 있습니다. 이 예제에서는 값이 1인 age 에 대해 snap1 과 snap2 라는 이름으로 스냅샷 2개를 찍어보았습니다. 먼저 스냅샷 밖에서 age 를 출력하고, snap1 에 들어가서 age 를 12로 설정하고 출력하였고, snap2 에 들어가서 age 를 20으로 설정하고 출력한 다음 마지막으로 다시 스냅샷 밖에서 age 를 출력하였습니다.

아무 스냅샷도 적용하지 않았기에 각각 고립된 상태로 존재하고, 따라서 마지막 age 출력은 초기값 1이 그대로 나옵니다.

snap1 에 apply 를 호출해 적용해주고, 더 이상 사용되지 않는 스냅샷이니 추후 누수 방지를 위해 dispose 로 폐기 처리를 해줬습니다. snap1 이 적용되면서 StateObject 인 age 의 값이 snap1 의 StateRecord 인 12로 바뀌었고, 따라서 스냅샷 밖에서 age 를 출력하면 12가 출력됩니다.

이 상태에서 snap2 를 적용한다면 실패하고 기존 값인 12가 그대로 출력됩니다. snap2 가 캡쳐됐을 당시의 age 값은 1이였지만, 위에서 snap1 을 적용하면서 age 의 값이 12로 바뀌었기에 새로운 값인 20이 더 이상 유효하다고 판단하지 못해 apply 에 실패하기 때문입니다. 이러한 현상을 스냅샷 충돌 이라고 부릅니다.

이런 스냅샷 충돌 상황에서 스냅샷을 병합하기 위해 실행되는 함수가 아까 보았던 mergeRecords 이고, 각 상황마다 병합하는 방법은 다르기에 기본값은 null 로 설정돼 있습니다. 따라서 아까같은 상황에서 병합되면서 null 이 나왔고, null 은 유효하지 않은 값으로 판단되기에 기존 값인 12가 그대로 출력된 것입니다.

스냅샷 병합 방법은 스냅샷 시스템 챕터를 시작하기 전에 보았던 policy 인자를 통해 설정할 수 있습니다.

policy 인자는 SnapshotMutationPolicy 인터페이스를 받고 있고, 이는 equivalent 와 merge 라는 2개의 함수를 갖고 있습니다.

equivalent 는 2개의 레코드 값를 서로 비교해서 같지 않을때만 새로운 레코드를 생성하는데 사용되고, 이전 레코드 값인 a 와 새로 만들려고 하는 레코드의 값인 b 인자가 존재합니다. merge 함수는 스냅샷 충돌 상황에 스냅샷을 병합하기 위해 사용되고, 스냅샷을 캡쳐했을 당시의 레코드 값인 previous, 현재의 레코드 값인 current, 적용하려고 한 레코드 값인 applied 인자가 존재합니다.

ShapshotMutationPolicy 의 기본 구현으론 ReferentialEqualityPolicy, StructuralEqualityPolicy, NeverEqualPolicy 이렇게 3개가 존재하며, 각각 레퍼런스 비교, 값 비교, false 로 equivalent 를 구현하고 있습니다. 셋 다 merge 는 기본 구현인 null 을 그대로 사용합니다.

아까와 같은 예제에서 age 를 만들 때 스냅샷 병합 방법으로 previous, current, applied 를 다 이어서 보여주게 정의하였고, snap1 과 snap2 를 적용하고 다시 결과를 보면 정의한 방법대로 스냅샷 병합이 일어나 11,220이 나온 걸 확인할 수 있습니다.

이렇게 스냅샷 시스템에 대해 살펴보았습니다. 하지만 2가지의 의문점이 생길 수 있습니다. 명시적으로 스냅샷 생성 없이 바로 StateObject 에 접근하면 StateRecord 는 어디에 쌓일까요? 또한 컴포즈는 StateObject 에 대한 변경점을 어떻게 감지하고 리컴포지션을 진행하는 걸까요?

이와 같이 StateObject 를 만들고 바로 접근하는게 우리가 많이 하는 방법일 것입니다.

이렇게 바로 접근하여 생성되는 StateRecord 는 Global Snapshot 에 기록됩니다. 글로벌 스냅샷은 명시적으로 생성하지 않아도 컴포즈에서 최상위로 존재하는 스냅샷이며 별도 스냅샷이 없다면 모든 StateRecord 가 기록되는 곳입니다.

글로벌 스냅샷은 StateRecord 를 적용할 더 상위 스냅샷이 없기 때문에 apply 대신에 advanced, 즉 고급화 라는게 존재합니다. 고급화는 기존 글로벌 스냅샷을 지우고 새로운 StateRecord 를 모두 원자적으로 적용한 글로벌 스냅샷을 빠르게 여는것을 뜻합니다.

글로벌 스냅샷은 GlobalSnapshotManager 에 의해 변경점이 감지됩니다. Snapshot 의 registerGlobalWriteObserver 를 통해 글로벌 스냅샷의 쓰기 이벤트를 받고, 이벤트를 받으면 Snapshot 의 snedApplyNoficiations() 을 실행하여 글로벌 스냅샷에 고급화를 요청합니다.

일반 스냅샷의 경우는 takeMutableSnapshot 으로 스냅샷을 찍을 때 readObserver 와 writeObserver 를 인자를 통해 읽기와 쓰기 이벤트를 받아올 수 있습니다.

이를 이용하여 컴포즈는 초기 컴포지션시에 컴포저블을 composing 함수를 통해 자체 스냅샷으로 감싸게 됩니다. 이 과정 덕분에 컴포저블 안에서 StateObject 의 변경 사항이 일어나면 이를 감지하고 리컴포지션이 진행됩니다.

최종적으로 컴포저블을 아는 스냅샷의 형태는 이런식으로 나오게 됩니다.

마지막으로 LiveLiterals 에 대해 알아보도록 하겠습니다.

모든 literals 들은 hot-reload 를 위해 컴파일 과정에서 LiveLiteral 접두사를 가진 파일 안에 state 로 래핑되어 추출됩니다. 이후 이 파일들을 추적하며 literals 에 대한 hot-reload 가 구현됩니다. 이는 모든 literals 들을 추적하기에 엄청난 성능 손상을 유발합니다. 따라서 런타임 최적화를 위해선 LiveLiterals 를 비활성화 해야 합니다.

우선 NoLiveLiterals 어노테이션 사용 입니다. 함수에 사용하게 되면 해당 함수 안에 있는 literals 들은 LiveLiterals 가 비활성화 됩니다.

파일 자체에다가 적용할 수도 있습니다. 파일에 적용하게 되면 해당 파일 안에 있는 모든 literals 은 LiveLiterals 가 비활성화 됩니다. 혹은 릴리즈 빌드를 하면 전체 파일에 대해 LiveLiterals 가 비활성화 됩니다.

제기 준비한 내용은 여기까지입니다. 원래는 컴포저블이 그려지는 과정과 더 다양한 런타임 최적화에 대해 소개하려고 헀지만 시간관계상 하지 못하였습니다. 이 부분을 포함하여

컴포즈 내부에서 일어나는 일들에 대해 성빈랜드에서 총 8편에 거쳐 다루고 있습니다. 이 발표에서 다룬 내용 이외에 추가적으로 궁금하신 분들은 성빈랜드에서 확인하실 수 있습니다.

마치기 전에 간단하게 Fun Fact 를 하나 준비하였습니다. 컴포즈는 원래 구글 한 직원의 개인 프로젝트에서 시작됐으며 초기 디자인은 React Native 와 매우 유사한 형태를 띄고 있었습니다. 또한 원래 컴포즈가 KTX 라고 불릴 예정이였습니다.

이상으로 발표를 마치겠습니다. 여러분들은 얼마나 알고 계셨나요? 감사합니다.

여기까지가 발표를 진행한 내용입니다! 이어서 준비는 했지만 시간상 소개하지 못한 미공개 슬라이드를 추가로 첨부하겠습니다.

UI Internals

지금까지 다양한 컴포저블들을 보았습니다. 하지만 컴포저블을 실제로 쓴다면 화면에 어떻게 그려지게 되는걸까요? 이를 알아보기 위해 UI Internals 파트를 시작하겠습니다.

모든 컴포저블은 setContent 를 통해 시작됩니다.

컴포즈는 멀티 모듈로 구성된 프로젝트이며, ui 와 runtime 이 확실하게 분리돼 있습니다. 따라서 우리가 컴포즈를 프론트 단위에서 사용하기 위해선 ui 와 runtime 을 연결해주는 장치가 필요합니다.

컴포즈에서 ui 와 runtime 을 연결해주는 장치가 setContent 이며,

안드로이드는 ComponentActivity.setContent 로 구현돼 있습니다.

ComponentActivity.setContent 의 코드를 보면 CompositionContext 와 Owner 를 설정하는걸 볼 수 있고, 내부에서 ComposeView 의 setContent 를 다시 호출하고 있습니다.

Owner 란 안드로이드 뷰와 컴포즈 ui 의 통합 지점 입니다. 컴포즈에서 안드로이드 뷰를 다뤄야 하는 작업이 있다면 모두 이 Owner 를 통해 진행되며, 루트 컴포저블마다 존재합니다.

나머지 CompositionContext 에 대해 알아보기 전에 ComposeView.setContent 먼저 알아보도록 하겠습니다. ComposeView 의 setContent 는 CompositionLocal 제공, 컴포즈와 생명주기 연결, Composition 생성. 이렇게 크게 3가지를 하고 있습니다.

첫 번째인 CompositionLocal provide 부터 보도록 하겠습니다.

setContent 는 내부적으로 여러 setContent 를 다시 거치면서 결국 ProvideAndroidCompositionLocals 를 호출하게 되고,

여기에서 owner 를 통해 context 를 얻어와서 LocalContext 로 제공 해주고 있는걸 확인할 수 있습니다. 이 부분을 통해 LocalContet 가 activity-context 라는걸 확인할 수 있고, 컴포즈에서 액티비티를 가져오기 위해선 LocalContext 를 단순히 activity 로 캐스팅해도 된다는걸 알 수 있습니다.

이 밖에도 다양한 CompositionLocal 들이 이 함수를 통해 제공됩니다.

이렇게 제공된 CompositionLocal 을 하위 컴포저블로 전파하는 것이 CompositionContext 입니다. CompositionContext 는 컴포저블 트리에서 상위 컴포저블과 하위 컴포저블을 연결하는 역할을 하며, 루트 컴포저블마다 parent composition context 가 지정됩니다.

이제 컴포즈와 생명주기 연결에 대해 보겠습니다.

아까 보았던 내부의 setContent 함수 밑에는 사실 dispose 와 onStateChanged 라는 함수도 존재합니다. onStateChanged 에서 생명주기 이벤트를 받고 있고, 만약 ON_DESTROY 가 호출된다면 dispose 함수를 실행합니다.

dispose 는 컴포저블과 현재 컴포저블 트리가 사용중인 SlotTable 을 모두 지우는 작업을 하고 있습니다. 이 작업 덕분에 컴포저블 관련 메모리 누수가 일어나지 않게 됩니다. SlotTable 은 컴포즈에서 데이터 저장을 위해 사용하는 클래스 입니다. 컴포지션에 필요한 모든 정보들이 SlotTable 에 기록됩니다.

마지막으로 Composition 생성에 대해 보겠습니다.

setContent 는 최종적으로 doSetContent 를 호출하게 되고, 여기에서 Composition 객체가 만들어집니다. Compositon 는 컴포즈의 모든 데이터를 관리는 최상위 클래스이며, 여기에서 SlotTable 과 CompositionContext, 리컴포지션 등등 컴포저블을 그리는데 필요한 모든 데이터들이 관리됩니다.

이렇게 해서 최상위 컴포저블인 setContent 는 owner 이자, parent CompositionContext 가 되고, Composition 이기도 합니다.

지금까지 본 내용들을 토대로 ComponenctActivity.setContent 를 다시 보겠습니다. 우선 ComposeView 에 CompositionContext 를 설정하고, ComposeView 에 표시할 content 를 설정한 다음 ComposeView 를 Owner 로 지정합니다. 마지막으로 ComponenctActivity 에 ComposeView 를 content 로 설정함으로써 우리에게 컴포저블이 보여질 준비를 마치게 됩니다.

이렇게 setContent 를 통해 컴포저블 트리가 그려질 준비를 마치게 되면 하위 컴포저블 트리에서 각각 컴포저블 마다 방출이라는 과정이 시작됩니다.

모든 UI 컴포저블은 최종적으로 Layout 컴포저블을 호출하고 있고, Layout 에서는 ReusableComposeNode 부분을 통해 SlotTable 에 컴포저블을 기록해주고 있습니다. 이 과정을 방출이라고 하고 이 과정 덕분에 컴포저블 함수 리턴이 유닛인데 불구하고 UI 가 그려지게 됩니다.

모든 컴포저블의 방출이 완료되면 구체화 과정이 시작됩니다. 구체화란 컴포저블 트리를 읽고 해석하여 UI 로 산출하는 과정을 뜻하며, 이 과정은 Applier 인터페이스에 위임됩니다. Applier 에는 트리를 아래로 순회하는 down, 트리를 위로 순회하는 up, 노드를 하향식으로 삽입하는 insertTopDown, 노드를 상향식으로 삽입하는 insertBottomUp 함수가 존재합니다.

컴포즈는 멀티 플랫폼이고 각각 플랫폼 마다 UI 를 그리는 방법은 다르기에, Applier 은 컴포즈 런타임에 위치하며 컴포즈 UI 에서 구현된 Applier 가 사용됩니다.

안드로이드의 경우엔 UiApplier 라는 구현체를 사용하며 setContent 과정에서 Composition 객체를 만들 떄 같이 주입됩니다.

UiApplier 의 구현을 보면 insertTopDown 은 무시되고 insertBottomUp 이 사용됩니다. 이유는 하향식과 상향식에 따른 트리 구축 성능이 아주 다르기 때문입니다.

하향식과 상향식의 트리 구축 성능을 비교해 보겠습니다.

하향식의 경우는 B 를 R 에 삽입하는 것으로 시작됩니다. 다음에 A 와 C 를 각각 B 에 삽입합니다. 이는 각 아이템이 삽입될 때마다 상위 부모들에게 모두 알려지며, 중복 알림이 일어날 수 있습니다. 예를 들어 A 와 C 가 B 에 삽입된다면 B 와 R 에게 삽입됨이 알려집니다.

상향식의 경우는 A 를 B 에 삽입하는 것으로 시작됩니다. 다음에 C 를 B 에 삽입하고 B 를 R 에 삽입합니다. 각 아이템이 삽입될 때마다 상위 부모에게만 알리므로 딱 한 번만 알려지며, 중복 알림이 없습니다. 예를 들어 A 와 C 가 B 에 삽입된다면 B 기준으론 상위 부모가 없으므로 B 에게만 삽입됨이 알려집니다.

이렇듯 하향식과 상향식의 트리 구축 성능은 아주 다릅니다. 이 결정은 새 노드가 삽입될 때마다 알림을 받아야 하는 노드의 수에 따라 Applier 에 의해 결정됩니다. UiApplier 의 경우 중복 알림을 피하기 위해 상향식 구축을 사용합니다. 만약 노드가 삽입될 때마다 모든 자식에게 알려야 한다면 하향식 구축이 올바를 것입니다.

구축된 트리는 Applier 의 up 과 down 함수를 통해 순회됩니다. 지금까지 사용한 예제는 이렇게 트리가 구축되고, 이 트리는 어떻게 순회되는지 알아보겠습니다.

먼저 Column 에 진입하기 위해 down 함수가 호출됩니다. 이어서 Row 에 진입하기 위해 down 함수가 호출됩니다. Row 안에 들어와서 선택적 노드 Text 를 조건에 따라 삽입하거나 삭제합니다. Text 작업 후에 다시 상위 Column 으로 돌아가기 위해 up 함수가 호출됩니다. 마지막으로 column 에서 두 번째 선택적 노드 Text 를 조건에 따라 삽입하거나 삭제하는걸로 순회가 끝납니다.

트리 순회까지 마치면 마지막으로 ComposeView 에서 dispatchDraw 가 호출됩니다. 이를 통해 ViewGroup 의 dispatchDraw 가 호출되며 캔버스로 화면에 그려지게 됩니다.

donut-hole skipping

마지막으로 **도넛홀 건너뛰기** 입니다. 간단하게 텍스트가 컴포지션될 때 마다 로그를 찍는 LoggingText 컴포저블이 있고, 메인에서 LoggingText 를 이용해 number state 를 보여주고 있습니다. 메인 함수에도 컴포지션될 때 마다 로그를 찍고 있고, LoggingText 의 modifier 로 clickable 을 넣어 number 를 증가시키고 있습니다. LoggingText 를 클릭할 때 마다 number 가 증가되면서 리컴포지션이 진행됩니다. 이때 컴포지션된 로그은 어떻게 나올까요? 3초동안 생각해 봅시다.

네, 3초가 지났습니다. 결과는 이렇게 나옵니다. 초기 컴포지션과 number 변경으로 인한 리컴포지션 모두 main 과 Text 둘 다 리컴포지션이 진행됩니다. 컴포즈는 리컴포지션을 최적화 하기 위해 state 가 사용된 최소한의 스코프만을 리컴포지션 합니다. 이 예제에서는 number 가 사용된 최소 스코프가 main 함수 자체가 됩니다. 따라서 main 과 안에 있는 LoggingText 모두 리컴포지션이 진행됩니다.

number 를 사용중인 Text 만 리컴포지션을 하기 위해선 state 사용을 개별 스코프로 만들어주면 됩니다. text 를 람다로 받게 되면 해당 text lambda 를 사용하는 함수 자체로 스코프가 만들어집니다. 이를 이용하여 text 를 람다로 받는 DonutTextWithLogging 컴포저블을 만들고, 기존 예제에서 LoggingText 를 DonutTextWIthLogging 으로 변경하였습니다.

이 결과로 number 가 변경되면 Text 만 리컴포지션이 진행됩니다.

이렇게 컴포저블 안에서 서브 컴포저블만 리컴포지션을 진행하는 리컴포지션 최적화를 컴포즈 개발팀에서는 donut-hole skipping 라고 불렀습니다.

여기까지가 제가 준비한 내용 입니다. 이번 컨퍼런스 참여는 정말 소중한 경험이였습니다.

참고로 찰스님의 초청으로! 진행된 발표였습니다. 초청이 없었어도 연사로 신청할 생각이였는데 먼저 제안 해주셔셔 너무 감사했습니다.

이번 발표가 제 첫 오프라인 행사 참여이자, 첫 오프라인 발표였습니다. 약 한 달간 연습했는데, 연습했던 것보다 더 잘 나와서 정말 다행이라고 생각하고 무엇보다 떨지 않아서 더더욱 다행으로 생각하고 있습니다.

발표가 끝나고 몇몇분이 오셔서 성빈랜드 정말 잘 보고 있다고 말씀해주셨는데 너무 뿌듯한 순간이였습니다 ㅠㅠ. 이번에도 이 글 봐주시는 모든 분들께 진심으로 감사드립니다.

사실 원래 엄청 꾸미고 가려고 했습니다. 안경 벗고 렌즈 끼려고 했는데… 왜 왼쪽 눈만 껴지고 오른쪽 눈은 안껴지는거니 ㅜㅜ 30분동안 씨름하다가 그냥 포기하고 안경을 썻고, 기초 화장도 쪼끔 했었는데.. 너무 오랜만에 해서 양조절에 실패해서 그냥 다 지우고 가게 됐습니다.

PPT 자료는 스피커댁에도 올라와 있습니다. 감사합니다.

안드로이드 개발자 분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.

추가로 성빈랜드의 첫 메인 프로젝트를 같이 만들어갈 안드로이드 개발자분을 모집하고 있습니다! 자세한 정보는 이곳을 확인해 주세요. 감사합니다.

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.