선언형 UI인 Compose는 상태값의 변경 및 해당 상태값이 주입 된 copmosable함수만 다시 그릴 수 있는데, 이를 Recomposition이라고 한다. 하지만 보통 UI의 구조는 1개의 composable만 있는 게 아닌 여러개의 UI가 트리형태로 존재하는데, 이때, Compose Runtime은 어떤 UI를 건너뛸 수 있을지를 판단할 수 있어야 한다.
만약 개발자가 안정적이지 못한 composable 함수를 만들게 된다면? Compose Runtime은 건너뛰어야 할 UI를 건너뛰지 못하고 불필요한 UI 재호출을 지속할 수 있다. 따라서 개발자는 안정적인 Composable 함수를 만들 수 있어야 하며, 이는 Stability즉, 안정성을 올바르게 설계할 수 있는 능력과 직결된다.
다만, SmartRecomposition을 위해 알아야할 출발 지식이 있다.
- compose compiler를 통해 composable함수의 파라미터 타입이 stable & unstable이 된다는 것
- 모든 파라미터 타입이 stable할 경우, composable함수는 skippable해진다는 것.(Compose matrix에 기재됨)
- 단 하나의 파라미터라도 unstable할 경우, composable함수는 skippable하지 않으며 Recomposition대상에 무조건적으로 포함된다는 것.
개발자가 Composable 함수를 작성하고 빌드하면 Compose Compiler는 Compose Runtime이 알아들을 수 있는 코드로 변경하게 되고, Compose Runtime은 composable함수를 건너뛸 수 있는지? 아닌지? 를 판단하게 된다. 이때, 이러한 부분을 결정짓는 중요한 요소 중 하나가 composable함수의 파라미터 타입이다. (참고) 이에 따라 compose compiler는 코드를 아래와 같이 변환한다.
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun SnackCollection(
stable snackCollection: SnackCollection
stable onSnackClick: Function1<Long, Unit>
stable modifier: Modifier? = @static Companion
stable index: Int = @static 0
stable highlight: Boolean = @static true
)
위 코드를 보면 알다시피, 각 파라미터들 앞에 stable
이 붙어있다. 그리고 더 나아가, 모든 파라미터들이 stable
하다면, 해당 composable 함수들에는 skippable
표시가 붙게된다. skippable
표시가 붙은 composable 함수는, Compose Runtime이 시의 적절하게 Recomposition대상에서 제외할 수가 있다. 즉, 주입된 composable 상태값이 변경되지 않았다면, 해당 composable함수를 Recomposition하지 않는다는 뜻이다.
그럼, composable함수를 skippable
하게 만들기 위한 첫 걸음인, 파라미터를 stable
하게 만들기 위한 composable 함수의 파라미터 타입은 무엇일까?
- 원시 타입(eg., String, Int, Float...)
- 원시 타입을 파라미터로 정의하고 반환하는 함수 타입(eg., (String) -> Boolean)
이러한 타입 지정을 통해 composable함수의 각 파라미터 타입은 stable
해진다.
restartable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
stable index: Int
unstable snacks: List<Snack>
stable onSnackClick: Function1<Long, Unit>
stable modifier: Modifier? = @static Companion
)
stable
한 타입이 있는 것처럼, 그 반대인 Unstable
타입도 있다. 이는 composable함수가 주입 된 composable 상태값의 변경을 추적할 수 없다는 뜻이고, 이러한 unstable
이 1개라도 포함될 경우, 해당 composable함수는 SmartRecomposition대상에서 제외하게 된다. 즉, 해당 composable함수가 포함된 상위 composable함수의 호출은 무조건적으로 해당 composable 함수를 호출한다는 뜻이며 이를 통해 UI의 불필요한 버벅거림을 경험할 확률이 높아지게 된다.
위 코드를 보면 알다시피, List<Snack>
타입이 unstable
하다고 나와있다. 이는 Compose Compiler가 Abstract Class
or Interface
타입을 unstable
로 지정하기 때문이다. 왜냐하면 인터페이스, 추상 클래스 타입은 내부 구현체가 변경될 가능성이 있을 뿐만 아니라, val
로 선언되어 '불변' 처럼 보이는 List
라 하더라고 MutableList
처럼 내부 값이 변경 가능성을 배제할 수 없기 때문이다. 즉, unstable
한 타입이 단 1개라도 포함될 경우, Compose Compiler는 해당 composable 함수 타입에 unstable
을 표시하고 이는 skippable
표시 또한 제거되는 것을 확인할 수 있다.
그렇다면 composable함수를 정의할 때, 참조 타입이나 인터페이스 타입은 항상 unstable
하게만 둬야만 하는걸까? 그렇지 않다. compose에서는 참조 타입 또한 stable한 타입으로 만들어주기 위한 여럿 방법을 제공하고 있다.
@Stable
, @Immutable
어너테이션 사용ImmutableCollection
사용위 2가지를 사용해준다면 각 파라미터 타입들이 stable하게 바뀐다는걸 확인할 수 있다.
@Stable
/@Immutable
을 적용한 타입은 Compose Matrix상에서 stable
하게 표시된다는 공통점이 있다. 즉, 기존에 unstable
하던 Abstract Class
/Class
/Interface
타입을 stable
하게 만들어줄 수 있다는 것이다. 하지만 사용 및 용도에 있어 차이가 존재한다.
[@Immutable
]
Compose Compiler에게 클래스의 public한 내부 property들이 일절 변경되지 않음을 약속시키는 어너테이션이다. 해당 어너테이션이 선언되었을 때, Recomposition은 어떻게 동작할까? @Immutable
이 붙은 타입이 composable 함수에 상태 변수로 주입된다 할 때, 새로운 객체가 들어와야만 Recomposition이 발생한다.
헷갈릴 수 있는 점이,
data class
는equals()
,hashCode()
를 결과값을 내부 프로퍼티 값을 기반으로 한다는 점에서, 객체 동등성 비교를hashCode()
로 하는것 아닌가? 할 수 있지만 그렇지 않다. 순수 객체 참조 비교(===)를 통해 이뤄진다.
따라서 해당 어너테이션 사용 시, 지켜야할 3가지가 있다.
var
를 사용하지 말 것. 모두 val
로 정의할 것.MutableCollection
등의 방법으로 내부 콜렉션 상태를 변경할 일이 없도록 할 것만약, 위 3가지를 지키지 않겠다는 말은 객체 내부 상태값을 변경하겠다는 의미이고, 이는 @Immutable
사용의 의미에 반하게 된다. 만약 위 3가지에 대한 확신이 서지 않는 상태로 @Immutable
을 사용한다면 건너뛰지 말아야 할 Recomposition을 건너뛰게되어 UI 갱신이 이뤄지지 않을 수 있다.
[@Stable
]
Compose Compiler에게 '참조 타입 객체의 내부 public한 property' 또는 '하위 타입'이 안전하게 변경될 수 있음을 알려주는 어너테이션이다. 즉, var
로 선언 된 public한 property의 변경 또는 val
로 선언된 인터페이스 하위 구현객체의 변경을 안정적으로 추적하여 SmartRecomposition을 진행해 준다는 것을 의미한다. 이에 따라 @Stable
을 사용하는 경우는 크게 2가지로 나뉠 수 있다.
- 클래스에 적용 :
var
로 선언 된 public한 내부 프로퍼티들의 안전한 변경을 compose compiler에게 약속하고자 하는 경우- 인터페이스에 적용 : 하위 구현체 타입의 안전한 변경을 compose compiler에게 약속하고자 하는 경우
우선, 일반적인 UiModel을 사용하고자 하는 경우, @Immutable
을 사용하면 좋다.
@Immutable
public data class UserUiModel(
public val id: String,
public val nickname: String,
public val profileImages: List<String>,
)
위와 같이 @Immutable
을 적용해줌으로써 위, UserUiModel
객체 내부의 프로퍼티들은 일절 변경되지 않음을 compose compiler에게 알린다. 따라서 내부 상태가 변경된다 할지라도(ex. profileImages
가 MutableList
객체 형태를 통한 변경) Recomposition 대상에 포함시켜주지 않는다. Recomposition을 시켜주고 싶거든 UserUiModel
객체를 다시 만들어주면 된다. (eg., data class의 copy
메서드 사용)
@Stable
public data class UserUiModel(
public var id: String,
public var nickname: String,
)
다만, UiModel을 만들 때, 내부 public한 property들을 var
로 선언할 수 있다는 점이다. 그럴 경우, @Stable
어너테이션을 사용하여, 내부 public한 property들의 변경마다 Recomposition 수행할 것임을 compose compiler에게 알려 성능을 향상시킬 수 있다.
또한 @Stable
어너테이션은 하위 타입이 변경될 수 있는 interface
/Abstract Class
에도 사용될 수 있다. 여기서 타입이 변경될 수 있다는 말은 해당 타입이 '제네릭'으로 선언되었음을 의미한다. 이에 가자 유용하게 사용될 수 있는 경우가 바로 UiState
이다.
@Stable
interface UiState<T : Result<T>> {
val value: T?
val exception: Throwable?
val hasSuccess: Boolean
get() = exception == null
}
@Stable
, @Immutable
어너테이션 사용을 위해 안드로이드 공식홈페이지를 둘어보다보면 아래와 같은 문구를 발견할 수 있다.
@Stable
, @Immutable
어너테이션은 여러 public한 property들 전체가 포함되어 있는 클래스를 통으로 stable
하게 만들어버린다. 따라서 해당 어너테이션을 사용하는 것보다 하나의 프로퍼티 상태를 stable
하게 만듦으로써 data class 자체를 stable
하게 만드는 것을 우선적으로 고려하는 것이 바람직하다. 그에 대한 좋은 솔루션으로 ImmutableCollection
을 사용하는 것이다.
public data class UserUiModel(
public val id: String,
public val nickname: String,
... 그 외, 20개의 public한 프로퍼티들 ...
public val userProfiles: List<Profile>
)
기존 List
는 순수한 인터페이스이다. 또한 val
로 선언된 List
는 더더욱 불변처럼 보이지만, 내부 구현체로 MutableList
를 둘 수 있으며 이를 통해 내부 요소들에 변할 수 있다는 것을 알아야 한다. (이로 인해 Compose Runtime은 해당 타입을 unstable
하게 간주함)
반면 ImmutableCollection
의 경우, 내부 구현체들이 변경될 때, 깊은 복사를 적용해 새로운 객체를 만드는 방식을 적용한다. 즉, 단 1개의 요소를 추가/삭제 한다 하더라도 새로운 객체를 생성하기에 참조 동등성 비교(===)에서 다른 결과를 만들어 낸다는 것이다.
참고 : List/MutableList vs PersistentList/ImmutableList의 차이점
해당 라이브러리를 사용하기 위해선 JetBrains의 공식 깃허브 또는 Guava의 공식 깃허브를 사용할 수 있다.
참고 : Composable함수 상태값 전달에서, Field vs Lambda 주입의 차이
[참고]