Compose Compiler Metrics Report는 Compose Compiler의 성능과 효율성을 측정하고 개선하기 위해 생성되는 보고서입니다.

Compose를 사용하여 개발할 때, 의도치 않은 Recomposition이 발생할 수 있기 때문에 Layout Inspector를 사용하여 RecompositionCount와 SkipCount를 확인할 수 있습니다.

그러나 이 방법은 Composable의 위치는 확인할 수 있지만, 정확히 어떤 부분에서 문제가 발생했는지를 알기 어렵습니다.

이때 Compose Compiler Metrics Report를 사용하면 더 자세하게 문제를 파악할 수 있습니다.

사용 방법

app.gradle 파일에 아래와 같은 코드를 추가하여 Compose Compiler Metrics Report를 생성할 수 있습니다.

android{
    kotlinOptions {    
        freeCompilerArgs += [
            "-P",
            "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${rootProject.file(".").absolutePath}/report/compose-reports"
        ]
    }
}

빌드 후에 해당 reportsDestination 위치를 찾아 가면 report 폴더안에 아래와 같은 파일들을 볼 수 있습니다.

그 중 app_debug-classes.txt 파일을 보면 다음과 같이 stable 과 unstable 그리고 runtime 으로 표시 된걸 볼 수 있습니다.

Composable의 파라미터로 들어가게 되는 UiState에는 unstable value 가 들어가는 경우 불필요한 Recomposition 이 발생됩니다.

해당 부분의 원인을 찾아 stable 한 상태로 변경 시켜주면 불필요한 recomposition을 막을 수 있습니다.

제가 어떤 식으로 원인을 찾았고 또 어떤 방법으로 문제를 해결하였는지에 대해서는 다음 포스트에 올리도록 하겠습니다.

Pager 를 사용하는 경우 Indicator 를 함께 보여주는 UI를 개발하는 경우가 종종 있다.

기본적인 Indicator 를 사용하는 경우 Accompanist 에서 제공되는 Indicator를 사용하면 된다.

Accompanist Horizontal Indicator 를 사용한 gif

하지만 보통 회사에서 사용하는 경우 특별한 효과가 들어가는 Indicator를 사용하는 케이스가 있다. 기존에 회사에서 라이브러리를 사용 했는데 Compose 버전은 지원하지 않아서 동일하게 적용 하기 위해 직접 만들어 보았다. 아래와 같이 드래그 좌우로 스와이프 할 때 물방울 같은 효과를 보여준다.

CustomHorizontalPagerIndicator 라는 이름으로 Composable Canvas 를 통해 한땀한땀 그렸다.

CustomHorizontalPagerIndicator 코드 링크

https://github.com/kimyounghoons/CleanArchitectureCompose

accompanist indicator 라이브러리 관련 링크

https://google.github.io/accompanist/pager

함수형 프로그래밍에서는 명령문을 반복할 때 루프 대신에 재귀를 사용 한다.

3.1 함수형 프로그래밍에서 재귀가 가지는 의미

재귀는 어떤 함수의 구현 내부에서 자기 자신을 호출하는 함수를 정의하는 방법을 말한다.

피보나치 수열을 명령형 프로그래밍으로 구현한 예제를 보자.

fun main(args: Array<String>){
    println(fibo(10,IntArray(100)) // 55 출력
}

private fun fibo(n : Int, fibo: IntArray): Int {
    fibo[0] = 0
    fibo[1] = 1

    for(i in 2..n){
        fibo[i] = fibo[i-1] + fibo[i-2]
    }
    return fibo[n]
}

피보나치 수열의 결과를 한번에 얻으려고 하지 않고 단계별 결과 값을 구해서 합하였다.

이전 값들을 기억하기 위해 한 메모리 IntArray(100) 을 확보해 놓았다.

루프가 반복 되면서 이전 값이 필요하면 메모리에 저장된 값을 사용한다.

본예제에서는 피보나치수열을 100개 까지만 계산할 수 있다.

피보나치 수열을 재귀로 구현한 예제를 보자.

fun main(args: Array<String>){
    println("결과 : "+fiboResursion(10))//55 출력
}

private fun fiboResursion(n : Int) : Int = when(n){
        0 -> 0
        1 -> 1
        else -> fiboResursion(n-1) + fiboResursion(n-2)
}

내부에서 자기 자신을 호출하여 재귀로 피보나치 수열 문제를 해결 하였다.

재귀로 구현한 예제는 고정 메모리 할당이나 값의 변경이 없다.

메모리를 직접 할당해서 사용하지 않고 스택을 활용 한다.

재귀 호출을 사용하면 컴파일러는 내부적으로 현재 호출하는 함수에 대한 정보들을 스택에 기록해 두고 다음 함수를 호출한다.

메모리에 할당 하지 않고 컴파일러에 의해 관리 된다고 보면 된다.

함수형 프로그래밍에서 재귀

함수형 프로그래밍에서는 어떻게 값을 계산할 수 있을지 선언하는 대신 무엇을 선언할지를 고민 해야 한다.

for , while 문과 같은 반복 명령어는 구조적으로 어떻게 동작해야 하는지를 명령하는 구문이다.

따라서 함수형 프로그래밍은 루프를 사용해서 해결하던 문제들을 재귀로 풀어야 한다.

재귀는 반복문에 비하여 복잡한 알고리즘을 간결하게 표현할 수 있지만, 다음과 같은 문제점을 가진다.

  1. 동적 계획법 방식에 비해서 성능이 느리다.
  2. 스택 오버플로 오류가 발생할 수 있다.

변성(Variance)은 자바나 코틀린뿐 아니라 다른 언어에서도 존재하는 개념이다.

변성을 제대로 이해하려면 “타입 S가 T의 하위 타입일 때, Box[S]가 Box[T]의 하위 타입인가? 라는 질문에서 시작 해야 한다.

Box[S]와 Box[T] 는 상속 관계가 없다. (무공변)

Box[S]는 Box[T]의 하위 타입 이다. (공변)

Box[T]는 Box[S]의 하위 타입 이다. (반공변)

타입 S와 T의 관계가 동일한 방향의 상하위 관계이면 공변 아니면 반공변 관계가 없으면 무공변으로 이해하면 된다.

공통으로 사용할 클래스들

interface Box<T>
open class Language
open class JVM: Language()
class Kotlin: JVM()

//Kotlin < JVM < Language 상속 관계로 볼 수 있다.

val languageBox = object : Box<Language> {}
val jvmBox = object : Box<JVM>{}
val kotlinBox = object : Box<Kotlin>{}

무공변의 의미와 예시

fun main(args: Array<String>){
    invariant(lanuageBox) //컴파일 오류
    invariant(jvmBox)
    invariant(kotlinBox) //컴파일 오류
}
fun invariant(value: Box<JVM>){}

타입이 Box 으로 선언 되었기 때문에 jvmBox 외에 매개변수를 받을 수 없다. invariant의 매개변수는 무공변이다.

공변의 의미와 예시

fun main(args: Array<String>){
    invariant(lanuageBox) //컴파일 오류
    invariant(jvmBox)
    invariant(kotlinBox)
}
fun invariant(value: Box<out JVM>){}

타입이 Box 으로 선언 되었기 때문에 jvmBox,kotlinBox 매개변수를 받을 수 있다. invariant의 매개변수는 공변이다.

반공변의 의미와 예시

fun main(args: Array<String>){
    contravariant(lanuageBox)
    contravariant(jvmBox)
    contravariant(kotlinBox)//컴파일 오류
}
fun contravariant(value: Box<in JVM>){}

타입이 Box 으로 선언 되었기 때문에 lanuageBox,jvmBox 매개변수를 받을 수 있다. contravariant 매개변수는 반공변이다.

in out 키워드를 사용할 때 주의할 점이 있다.

out 부터 보자.

interface Box2<out T> {
    fun read(): T
    fun write(value: T) // 컴파일 오류
}

T를 읽어서 반환할 때는 어떤 상위 타입에 하위 타입을 할당하는 것이 가능하기 때문에 문제가 없지만 write()함수는 value 값을 받아서 처리 할때 어떤 하위 타입이 들어올지 알 수 없기 때문에 런타임 오류가 발생할 가능성이 있다.

in 예제를 보자.

interface Box2<in T> {
    fun read(): T //컴파일 오류
    fun write(value: T)
}

반공변으로 선언 했을 때는 read함수에서 컴파일 오류가 발생 한다. 그 이유는 호출자가 선언한 T 타입의 변수에 T보다 상위 타입의 값을 할당하면 런타임 오류가 발생하기 때문이다.

in, out 키워드의 의미는 문자 그대로 이해하면 쉽다.
in 으로 선언된 타입은 입력값에만 활용(읽기 전용)할 수 있고 out으로 선언된 타입은 반환값(출력)의 타입으로만 사용할 수 있다.

그렇게 되면 프로그래머의 의도치 않은 실수를 컴파일 타임에 막을 수 있다.

Kotlin 변성(Variance)에 대해 알아 보았다.

  1. apply
  2. also
  3. with
  4. run
  5. let

apply

fun <T>.apply(block: T.() -> Unit) : T
//T의 확장 함수 로서 block 함수의 입력을 람다 리시버로 받았으므로 block  내에서 this 없이 객체에 접근할 수 있다.  
//apply 는 객체 내부 프로퍼티를 변경할 때 사용한다.

Person().apply {
    name = "홍길동"
}

also

fun <T> T.also(block: (T) -> Unit) : T
//블럭의 반환 값이 없고 자기 자신을 반환한다.
//어떤 fun 호출 후 추가적으로 궁금한 부분이나 변경할 부분에 대해 사용한다.
fun getRandomInt(): Int {
    return Random.nextInt(100).also {
        Log.d("getRandomInt() generated value $it")
    }
}

val i = getRandomInt()

with

fun <T, R> with(receiver: T, block: T.() -> R) : R
//일반적인 함수로 선언 되어 있다. 
//receiver 받은 객체에 this 를 사용하지 않고 접근 가능 하고 block 함수에서 반환한 값을 그대로 반환한다.
//프로퍼티에 직접적으로 접근 하면서 return 값을 변경해서 사용해야 할때 사용한다.

val numbers = mutableListOf("one", "two", "three")
val firstAndLast = with(numbers) {
    "The first element is ${first()}," +
    " the last element is ${last()}"
}
println(firstAndLast)

run

fun <T,R> T.run(block: T.()->R) : R
//T의 확장 함수로 선언되었고 block 함수에 this 가 람다 리시버로 전달 된다.
//run은 람다에 객체 초기화와 반환 값 계산이 모두 포함되어있을 때 유용합니다.

val service = Service("url", 8888)

val result = service.run {
    port = 8080
    query(port)
}

//let 으로 사용했을때와의 차이를 보면 확실히 편하게 사용한걸 볼 수 있다.
val letResult = service.let {
    it.port = 8080
    it.query(it.port)
}

let

fun <T,R> T.let(block: (T)->R): R
//T의 확장 함수 이고 block 의 반환 값을 리턴한다.
// processNonNullString str 이 널이 아니어야 하는 상황 그리고 return 값이 필요할때 사용 한다.
val str: String? = "Hello"   
val length = str?.let { 
    processNonNullString(it)      // OK: 'it' is not null inside '?.let { }'
    it.length
}

5가지 Scopefunctions 에 대해 알아 보았다.
앞으로 각각의 상황에 맞게 잘 사용하면 좋을 것 같다.