Jetpack Compose 의 핵심 요소중 하나인 Compose Compiler가 요구하는 Annotaion에 대해서 알아봅니다.
This article is based on the book that name is “Jetpack Compose Internals” by Jorge castillo. If you want to know more, we recommend that you purchase the book below.
Jetpack Compose Internals https://jorgecastillo.dev/book/
Compose Compiler, Compose Runtime, Compose UI
Compose는 크게 Compiler, Runtime, UI로 볼 수 있습니다.
Compose는 multi platform을 지원하는 것을 지향하기 때문에 UI는 Android 뿐만 아니라 Web, Desktop 등 다양한 플랫폼에서 지원 하고 있습니다. 따라서 Compose에서는 Platform Dependency 가 존재하는 부분을 Compose UI로, 그리고 Platform Dependency가 존재하지 않는 부분을 Compose Compiler, Compose Runtime으로 구분하였습니다. 결국 Compose Compiler와 Runtime에는 Compose의 핵심을 포함하게 됩니다.
A Kotlin compiler plugin
JVM 혹은 Kotlin을 사용하는 경우 일반적인 경우 annotaion processor로써 kapt를 사용합니다. 하지만 Compose는 kapt보다 더 확장된 기능을 제공하는 Kotlin Compiler Plugin 위에서 동작합니다. 이는 컴파일 타임에 임베딩을 할 수 있으며 코드의 형태에 대한 정보를 얻을 수 있습니다. 또한 전반적인 프로세스의 속도를 향상 시켜줍니다. kapt는 compile 전에 실행 되어야 하는 반면에 Compiler plugin은 컴파일 프로세스에 직접 inline하기 때문에 조금 더 자유로울 수 있습니다.
Kotlin Compiler Plugin의 또 다른 장점은 기존에 존재한 코드를 수정 하는 것 이 가능하다는 것입니다. 기존의 애노테이션들은 새로운 코드를 추가하는 것 까지만 지원합니다. 하지만 Kotlin Compiler Plugin을 사용한다면 컴파일러에 의해서 생성되는 명령어를 특정 플랫폼이 지원할 수 있도록 번역하여 멀티 플랫폼을 제공할 수 있게 됩니다.
Compose annotations
이제는 Compose Compiler가 인식하는 애노테이션 중에서 몇가지 애토테이션에 대해서 알아보겠습니다.
@ Composable
Composable function에 대한 이전 글을 에서 Composable Function에 대해서 다루어 보았습니다. Compose 컴파일러는 Composable 함수의 context 파라미터를 추가하고 함수의 시작과 끝에서 start, end를 호출하는 코드를 추가한다고 하였습니다. 이 애노테이션이 붙어 있다면 Compose 컴파일러는 IR 변환기를 통해서 선언과 표현식을 바꾸게 되고 Composable에 대한 규칙을 적용할 수 있습니다. 또한 Compose 애노테이션을 통해서 구성 가능한 함수와 구성 가능하지 않는 함수를 명확하게 나눌 수 있게 됩니다.
Composable 애노테이션을 적용하면 특정 메모리를 제공받게 됩니다. 이 메모리에는 Composer/slot 테이블을 활용하고 기억하기 위해서 사용됩니다. 그리고 body에서 실행된 이펙트를 준수할 수 있는 lifecycle을 제공합니다. Composable Function에 식별자를 할당하고, UI Tree의 위치를 기억해서 구성 단계에서 node를 emit하거나 CompositionLocals를 처리할 수 있게 됩니다.
@ ComposeCompilerApi
Compose 컴파일러에 의해서 사용되는 것을 알리는 애노테이션 입니다. 이는 개발자에게 이 api를사용하는 경우 주의를 주기 위해서 제공됩니다.
@ InternalComposeApi
Compose 내부에서 사용되는 것을 알리는 애노테이션 입니다. 이 애노테이션이 붙은 api는 public이며 변경되지 않고 안정적으로 제공된다고 하더라도 Compose 컴파일러에 의해서 형태가 변경될 수 있다는 사실을 알려줍니다. 또한 이 애노테이션은 kotlin에서 지원하지 않는 개념인 모듈 전반에 걸쳐 사용을 허용하기 때문에 internal 키워드 보다 스코프가 넓습니다.
@ DisallowComposableCalls
내부 함수에서 Composable Call이 발생하지 않도록 방지하기 위해서 사용됩니다. 함수의 람다 파라미터에 이 애노테이션을 적용하면 Composable call이 발생하더라도, 해당 함수는 새로 호출되지 않습니다.
이러한 애노테이션이 필요한 경우는 remember가 존재합니다. remember의 파라미터에는 초기값을 반환하는 calculation block을 람다 파라미터로 받습니다.
remember에서 calculation은 초기에만 동작하고 이후에는 항상 동일한 값을 반환 해야 합니다. 만약 @ DisallowComposableCalls를 정의하지 않는다면 calculation은 slot table에서 공간을 차지한 이후 사용되지 않기 때문에 첫 번째 재구성 단게에서 람다는 사라지게 되고 매번 새로운 calculation을 만들게 됩니다.
kotlin에서 forEach와 같은 람다 함수는 인라인으로 제공됩니다. 그렇기 때문에 Composable 함수 내에서 inline 함수를 사용하는 경우에는 Composable context 내에서 실행되는 것과 동일한 상황이 됩니다. 이는 경우에 따라서 바람직할 수도 바람직할 수도 있기 때문에 필요에 따라서 이 애노테이션을 활용할 수 있습니다.
Compose 내부에서는 다양한 플랫폼을 제공하기 위해서 Compose UI 와 다른 사례에서 Jetpack Compose를 사용하려는 경우 이 애노테이션을 통해서 고유한 클라이언트 라이브러리를 작성할 이 애노테이션을 통해서 제약을 줄 수 있습니다.
@ ReadOnlyComposable
이 애노테이션이 붙은 Composable 함수는 컴포지션 중에서 쓰지 않고 읽기만 한다는 것을 컴파일러에게 알립니다. 이 애노테이션의 body에서 호출된 Composable 호출 또한 읽기 전용이 됩니다. 이를 통해서 컴파일 런타임에 불필요한 코드 생성을 피할 수 있게 됩니다.
컴포지션 내부에 사용되는 모든 Compsable 함수의 경우 컴파일러가 본문을 래핑하는 “그룹”이라는 것을 만들어서 런타임에 emit하게 됩니다. 이때 emit된 그룹은 컴포지션에 필요한 필수 정보를 가지고 있어서 다른 Composable 의 데이터로 재정의 될 때 기존에 작성된 데이터를 정리하거나, 식별자를 유지한 상태로 데이터를 이동하는 방법을 알 고 있습니다.
그룹에 대해서 이해하기위해서 아래 예시를 보겠습니다.
여기서 if 문에는 두 개의 Text가 있습니다. 두개의 Text는 소스의 위치가 키로 가지기는 각각의 그룹입니다. 만약 의미가 존재하는 key를 직접 지정하는 경우 동일한 부모내에서 재정렬을 수행할 수 있게 됩니다.
컴포지션 과정 중 Composable 함수가 사용되지 않게 되면 대체되는 데이터가 대체되거나 이동 되지 않기 때문에 그룹을 생성하여도 값을 제공하지 않습니다. 이런 경우를 피하기 위해서 @ ReadOnlyComposable 를 제공합니다.
이 애노테이션이 사용되는 대표적인 예시는 머티리얼 색상이나 Typography, darkTheme, localContext와 같이 어플리케이션 리소스를 얻기 위해 위임되는 유틸리티나 CompositionLocal 기본 값들 이 있습니다. 이 애노테이션은 프로그램 수행 중 한 번 만 설정 되고 동일하게 유지되고 트리의 Composable 함수에서 읽을 수 있고 동일하게 유지되는 경우에 사용될 수 있습니다.
@ NonRestartableComposable
이 애노테이션이 붙은 Composable 함수는 restart를 하지 않습니다. 이 애노테이션이 붙으면 함수를 재구성 하거나 재구성을 건너뒬 수 있도록 하는데 사용되는 필수 상용구를 생성하지 않게 됩니다. 하지만 이 애노테이션은 언제까지 지정된 Composable 함수에 적용되며 만약 부모 혹은 감싸고 있는 Composable이 재구성 되는 경우는 restart될 수 밖에 없기 때문에 주의 해야 합니다.
정확성을 생각한다면 이 주석은 사용하면 안되지만 이 동작이 더 성능이 나온다면 애노테이션을 통해 약간의 성능 최적화를 얻을 수 있습니다.
Composable 런타임에는 안정성을 나타내는 애노테이션도 존재합니다. 차례대로 @StableMarker, @Immutable, @Stable 에 대해서 알아보겠습니다.
@ StableMarker
StableMarker는 애노테이션을 위한 애노테이션 입니다. 이 애노테이션은 데이터 안정성과 관련된 아래 요구사항을 나타냅니다.
- 동일한 인스턴스에 대한 equals에 대한 호출 결과는 항상 동일하다.
- 어노테이션이 있는 유형의 공용 속성이 변경되면 컴포지션에 알려준다.
- 애노테이션이 달린 모든 공용 속성도 안정적입니다.
이러한 요구사항은 @ Immutable, @ Stable도 마찬가지로 적용되는데 이는 이러한 애노테이션 들이 @ StableMarker 애노테이션이 정의 되어 있기 때문입니다.
이러한 요구사항은 소스를 처리할 때 컴퍼일러에 제공한 약속이지 컴파일 타임에 유효성 검사를 수행하는 것은 아닙니다. 따라서 요구사항을 충족시키는 것은 개발자에게 달려있습니다.
즉 Compose 컴파일러는 특정 상황에 해당하는 요구 사항을 충족할 때 유추하고 애노테이션을 달지 않아도 안정적인 것으로 처리하기 위해서 최선을 다합니다. 많은 경우는 이것이 정확하지만 아래 두 가지 예외 경우 경우에는 애노테이션을 직접 지정해 줘야 합니다.
- 인터페이스나 추상 클래스에 대한 요구일 때 이 주석은 컴파일러에 대한 약속 뿐만 아니라 구현에 대한 요구사항이 됩니다. (하지만 이에 대한 어떤 검증도 존재하지 않습니다.)
- 내부에 캐시가 존재하여서 타입이 변경 될 수 있지만 해당 타입의 public api는 캐시와 무관하게 안정적으로 제공되는 경우입니다.
클래스 안정성 추론에 대해서는 이후 장에서 다뤄보게 됩니다.
@ Immutable
이 주석이 달린 클래스는 생성된 이후에 공개된 모든 클래스 프로퍼티와 필드가 변하지 않음을 컴파일러에게 엄격한 약속 합니다. val를 이용해서도 immutable을 만들 수 있지만, mutable한 데이터 구조로 선언한다면 완벽한 Immutable으로 만들 수 없습니다. 이 애노테이션은 언어 차원에서 제공하는 것 이상으로 제공합니다. kotlin 언어 차원에서는 변경 불가능한 데이터 구조에 대해서 지원하지 않기 때문에 Compose에서 이 애노테이션은 필수적입니다.
Compose는 이 애노테이션이 붙은 클래스는 초기화 된 이후에 더이상 변경되지 않는 다는 것을 가정하고 스마트 리컴퍼지션에서 최적화를 위해서 활용합니다.
이 애노테이션을 붙일 수 있는 대표적인 경우는 val 태그만 있고 재정의한 getter가 없는 data class가 있습니다.
앞선 설명과 같이 이 애노테이션은 @StableMarker 또한 상속하여 모든 요구사항을 따릅니다. immutable은 절때 변경되지 않기 때문에 자연스럽게 이를 지킬 수 있습니다.
@ Stable
이 애노테이션은 immutable보다는 다소 가벼운 약속입니다. 이 애노테이션은 변경 가능함을 암시하면 StableMarker를 따릅니다. 대신 이 애노테이션은 동일한 입력에 대해서는 동일한 출력이 나오는 순수 함수임을 약속합니다.
Composeable함수의 모든 매개 변수가 Stable을 따르고 소스 코드의 위치가 동일하다면 이전 호출 결과와 동일하다는 것을 알 수 있기 때문에 컴파일러는 컴포지션을 생략할 수 있게 됩니다.
이 애노테이션이 붙은 클래스는 공개된 프로퍼티는 변하지 않지만 불변임을 보장하지 않습니다. 따라서 클래스 내부에 private 으로 변경 가능한 상태를 가지거나 외부 MutableState 객체에 위임할 수 있지만 외부에서 사용되는 방식이 대해서는 변경할 수 없습니다.
이 애노테이션은 완전히 충족되는 경우에만 사용해야 합니다. 만약 그러지 못하게 된다면 런타임 중에 에러가 발생할 수 있습니다.
Immutable과 Stable는 각각 다른 의미를 가지고 있지만 실제 Compose 컴파일러가 스마트 리컴포지션과 최적화등을 하기 위해서 이 애노테이션을 사용하는데, 같은 방법으로 처리하고 있습니다. 현재는 동일하기 다루지만 미래에는 다르게 다룰 수 도 있기 때문에 각각의 애노테이션으로 의미를 열어 두었다고 합니다.