본문으로 바로가기
반응형

photo by unsplash

R8 Compiler & release mode

Compose의 성능향상을 위한 시작은 환경설정부터 시작합니다. R8 컴파일러를 사용해야 하며, debug mode가 아닌 release모드에서 사용해야만 실제 compose의 성능을 확인할수 있습니다. R8 컴파일러 자체가 proguard를 통한 코드 최적화, 리소스 최적화, 난독화를 하는 작업을 수반하므로 debug 모드가 아니여야만 성능이 제대로 나온다는 말은 사실 동일한 문맥상에 있는 말입니다.

Android Gradle plugin 3.4.0 이상이면 더이상 proguard를 사용하여 최적화 작업을 하지 않습니다. 이 작업을 R8이 대신 작업합니다. 물론 proguard의 난독화를 위한 예외 규칙들은 그대로 R8에 호환되어 컴파일 됩니다. 해당 버전 이상에서는 기본 compiler가 R8이므로 추가적인 R8사용 설정은 필요없습니다만 최적화를 위해 아래와 같은 설정은 필요합니다.
build.gradle 파일을 release의 경우 아래와 같이 설정합니다.[1]

android {
    buildTypes {
        getByName("release") { 
            isMinifyEnabled = true //코드 최적화
            isShrinkResources = true //리소스 최적화
            
            //proguard 적용규칙 적용
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    ...
}

현재는 Gradle plugin 버전을 7.x.x대를 사용하므로 사실 따로 R8을 적용할 필요는 없습니다. 이미 R8을 사용하고 있겠죠?

다만 성능을 측정할때는 반드시 isMinifyEnabled = true로 하여 Release 상태에서 측정해야 합니다.

Baseline profile의 사용

Compose는 Android 플랫폼의 일부가 아닌 library 형태로 배포 됩니다. 따라서 Android 버전과 상관없이 자주 배포되고 업데이트될수 있다는 장점을 가질수 있는 반면, 앱이 실행될때 로드되고 실제 기능이 필요할때 JIT(just-in-time) 방식으로 해석 됩니다. 따라서 Compose를 처음 사용할때 (화면을 처음 로딩할때) UI의 약간의 버벅임이 생기며, 그 이후로는 문제없이 동작하는 현상이 발생하게 됩니다.

이를 방지하기 위해서 사용자가 apk install 시점에 특정(클래스 or 메서드) 부분을 미리 컴파일해 놓을수 있도록 사용자가 지정할 수 있습니다. 예를 들어 앱실행시 매번 진입하게 되는 첫화면이라면 baseline profile에 명시에 놓아 미리 컴파일된 코드를 갖게 하여 앱 구동시 빠르게 화면을 로드할 수 있습니다. 

여기서는 baseline profile의 자세한 소개를 하지는 않습니다. 이는 구글 android 문서를 확인하여 작업을 진행하시기 바랍니다.[2]

Layout Inspector

Dolphine 버전부터 layout inspector를 통해 recomposition의 횟수를 측정할수 있습니다.

테스트로 사용할 화면은 아래와 같습니다. 두개의 박스와 각 박스의 색상을 변경하는 버튼을 두개 만듭니다.

간소화한 코드는 아래와 같습니다.

@Composable
fun PerformanceTestScreen() {

    var bgColor1 by remember { mutableStateOf(Color.Black)}
    var bgColor2 by remember { mutableStateOf(Color.Red)}

    Box() {
        Column... {
            Row... {
                Box(
                    modifier = Modifier
                  ...
                        .background(bgColor1)
                )
                Box(
                    modifier = Modifier
                     ...
                        .background(bgColor2)
                )
            }

            Row... {
                Button(...) {
                    Text("Box1 Color")
                }

                Button(...) {
                    Text("Box2 Color")
                }
            }
        }
    }
}

Android studio 오른쪽 하단에 보면 Layout Inspector를 클릭하면 하기와 같은 화면을 볼수 있습니다.

이는 연결된 device와 연동되며, 특정 동작이 일어나면 recompose되는 횟수를 확인할수 있습니다. 만약 특정 작업 (e.g. 버튼을 클릭하는 작업)을 진행시 불필요한 component까지 recompose가 발생하는지를 체크해 볼수 있습니다.

캡쳐된 이미지의 상태에서는 첫번때 버튼을 클릭하여 해당 Button에서 recomposition이 1회 발생했습니다. 이때 클릭시 bgColor1 state 변수에 저장된 색상을 변경하므로 이와 가까운 최상단 composable function이  PerformanceTestScreen() 함수부터 1회 recomposition이 발생합니다.

다만  두번째 버튼은 영향을 받지 않으므로 skip이 1회 되었고 표기 됩니다.

Benchmark

Compose의 개선여부를 위해서는 반드시 동작 성능을 측정해야 합니다. "느낌상 느린것같다.", "좀더 빨라진것 같다."등등의 개인적인 체감보다는 정량적인 자료로 표현되어 비교해야 성능을 위해 수정된 코드가 적절했는지를 판단할 수있습니다.

Manually 하게 Logcat이나, App Inspector를 분석할수도 있지만 여기서 UiAutomator를 이용한 방법을 소개합니다. Compose를 UiAutomator를 이용하여 자동으로 테스트 하기 위해서는 Macrobenchmark를 사용해야 합니다.[5]

Macrobenchmark를 사용하기 위해서는 테스트하려는 프로젝트에 모듈을 추가해야 합니다. Android studio의 project tab의 tree에서 메인 모듈을 우클릭하여 새 모듈을 추가합니다.

이때 하기와 같은 상자가 나오면 왼쪽 template중에 "Benchmark"를 선택하고 오른쪽과 같은 정보를 넣습니다.

  • Target application: benchmark 대상이 되는 모듈
  • Modulfe name: 새로 추가하는 benchmark 모듈의 이름
  • Package name: 새로 추가하는 benchmark package 지정

app 단위 build.gradle에 아래와 같은 dependency가 잘 추가되었는지 확인합니다.

android {
...
    buildTypes {
        release {
                 ...
        }
        benchmark {
            initWith buildTypes.release //릴리즈 모드의 설정을 따름
            signingConfig signingConfigs.debug //debug key로 signing
            matchingFallbacks = ['release']
            debuggable false //성능에 영향이 있는 debuggable은 false 처리
        }
    }
...


dependencies {
...
    debugImplementation "androidx.compose.ui:ui-tooling:1.3.0"
    debugImplementation "androidx.compose.ui:ui-test-manifest:1.3.0"
...
}

 

추가로 AndroidManifest.xml에는 아래와 같은 "<profileable ... "문구가 추가 됩니다.

모듈이 추가되면 화면 진입 시간 측정을 위한 기본 class가 추가됩니다.

@RunWith(AndroidJUnit4::class)
class ExampleStartupBenchmark {
    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun startup() = benchmarkRule.measureRepeated(
        packageName = "com.mytest.composetest",
        metrics = listOf(StartupTimingMetric()),
        iterations = 5,
        startupMode = StartupMode.COLD
    ) {
        pressHome()
        startActivityAndWait()
    }
}
  • packageName: 기본적으로 benchmark 모듈은 원래 app의 process와는 다른 process에서 시작됩니다. 따라서 benchmark 대상이 되는 앱의 package명을 명시합니다.
  • metrics: 화면 진입시간을 측정하기위한 matric 이외에도 다양한 matic이 존재합니다.[4][6]
    • AudioUnderrunMetric(): 오디오 재생중에 발생하는 정보들을 capture
    • FrameTimingMetric(): 화면 사용중 버벅거림등을 측정
    • PowerMatric(): 전원의 변경, 배터리의 충전등의 정보를 일정시간동안 capture
    • TraceSectionMetric(): 이름을 명명한 trace section의 시간을 측정 
  • iteration: 테스트 반복횟수
  • startupMode: COLD / WARM..
반응형

맨 위에서도 언급했지만 테스트는 release 버전에서 실제 단말로 진행되어야 합니다.(emulator로 측정을 위해서는 추가적인 설정이 필요합니다.) 따라서 실제 단말을 연결한 후에 테스트를 진행합니다.

-> Run 'startup()'을 실행

아래와 같은 진행화면이 표시되면 실제로 단말의 화면이 깜박이는걸 볼수 있습니다. 

최종 결과값은 위와 같이 최소 467ms, 최대값은 825ms, 중간값은 524ms임을 알려줍니다.

이때 이 결과값은 outputs 폴더에 json으로도 생성되므로 추후 서버에 올려 지속적으로 관리할수도 있습니다.

마지막으로 리스트를 flicking() 했을때 ui frame상태를 측정하기 위해 아래와 같은 코드를 추가합니다.

@RunWith(AndroidJUnit4ClassRunner::class)
class ScrollFriendListBenchmark {
    @get:Rule
    val rule = MacrobenchmarkRule()

    @Test
    fun scrollFeed() = rule.measureRepeated(
        packageName = "com.mytest.composetest",
        metrics = listOf(FrameTimingMetric()),
        iterations = 5,
        startupMode = StartupMode.COLD,
        setupBlock = {
            pressHome()
            startActivityAndWait()
        }) {
        val contactList = device.findObject(By.res("contact_list"))
        val searchCondition = Until.hasObject(By.res("contact_item"))

        // 연락처 리스트를 읽고 화면에 표시될때까지 기다립니다. 
        friendList.wait(searchCondition, 5_000L)

        // getsture로 화면이동(naviation)을 막기위한 여백을 추가합니다.
        friendList.setGestureMargin(device.displayWidth / 5)

        // 아래 방향으로 스크롤 합니다.
        friendList.fling(Direction.DOWN)

        // 스크롤이 끝날때까지 기다립니다.
        device.waitForIdle()
    }

기본 설정은 진입테스트와 같지만 metric을 FrameTiimingMetric() 으로 변경하고 setupBlock{}을 추가했습니다. setupBlock()에 화면 진입 코드를 넣었으므로 이는 화면 진입 이후부터 측정됩니다.

화면에 리스트가 로드 되기를 기다리기 위하여 "contact_list" tag가 있는 LazyColumn과 실제 목록으로 표현되는 composable function (tag가 "contact_item"로 설정된)을 찾아 화면에 표시될때 까지 대기 합니다.

해당 tag는 아래와 같이 Modifier를 통해서 설정할 수 있습니다.

LazyColumn(
    modifier = Modifier.fillMaxSize().testTag("contact_list"),
    ...
) {
    itemsIndexed(...) { index, item ->       
       ContactCard(modifier = Modifier.testTag("contact_item"), ...)
    }
}

또한 설정한 tag를 정상적으로 읽기 위해서는 상위 composable function에서 "testTagAsResourceId = true" 설정을 넣어야 합니다.[7]

Scaffold(
        modifier = Modifier.semantics {
               // uiAutomator 사용을 위한 추가.
               testTagsAsResourceId = true
        }) {
        ...
}

위 지표는 실제 수행한 결과로 frameDurationCpuMs는 수행하는데 걸린 시간을 ms 단위의 시간을 나타내며. android 12 (API 31) 이상에서는 frameOverrunMs(frame을 그리는데 초과된 시간)또한 표현됩니다.

평균값만으로 표현될 경우 이상점을 표현할수 없기에 위와 같이 통계지표로 표현합니다. 위 지표를 분석하면 아래와 같은 해석을 할 수 있습니다.

  P50 P90 P95 P99
frameDurationCpuMs 10.5ms

- 50%의 확률로 10.5ms 이전에 그려짐

- 나머지 50%의 경우 그리는데 10.5ms을 넘김
25.2ms

- 90%의 확률로25.2ms 이전에 그려짐.

- 10%의 경우가 frame을 처리하는데 25.2ms 이상이 걸림.
26.6ms

- 95%의 확률로(경우로) 26.6ms 이전에 그려짐.

- 5%의 경우에 frame을 처리하는데 26.6ms 이상이 걸림.
41.7 ms

- 99%의 확률로(경우로) 41.7ms 이전에 그려짐.

- 1%의 경우에 frame을 처리하는데 41.7ms 이상이 걸림.
frameOverrunMs 1.3ms

- 50%의 확률로 frame을 그리는데 1.3ms 를 초과하지 않음

- 나머지 50%의 경우 그리는데 1.3ms을 넘김
17.9ms

- 90%의 확률로 frame을 그리는데 17.9ms 를 초과하지 않음

- 나머지 10%의 경우 그리는데 17.9ms을 넘김
18.4ms

- 95%의 확률로 frame을 그리는데 18.4ms 를 초과하지 않음

- 나머지 5%의 경우 그리는데 18.4ms을 넘김
45.5ms

- 99%의 확률로 frame을 그리는데 45.5ms 를 초과하지 않음

- 나머지 1%의 경우 그리는데 45.5ms을 넘김

 frameOverrunMs은 - 값이 나오는 경우가 있습니다. 이는 frame을 그리고도 시간이 남음을 의미합니다.

출처:&nbsp;https://www.youtube.com/watch?v=ahXLwg2JYpc&t=31s

Conclusion

성능을 개선하는것 만큼이나 측정하는것도 중요합니다. 이번 포스팅에서는 실제 성능을 개선하기 위한 방법 보다는 이를 확인하고, 측정하는 방법에 대한 내용을 주로 다뤘습니다. 다음 포스팅에서는 case별 성능을 향상시킬수 있는 방법에 대해서 다룹니다.

References

[1] https://developer.android.com/studio/build/shrink-code?hl=ko

[2] https://developer.android.com/studio/profile/baselineprofiles?hl=ko

[3] https://developer.android.com/codelabs/android-macrobenchmark-inspect#0

[4] https://www.youtube.com/watch?v=-Mgy_nTMCtc

[5] https://developer.android.com/codelabs/android-macrobenchmark-inspect#0

[6] https://developer.android.com/reference/kotlin/androidx/benchmark/macro/Metric

[7] https://developer.android.com/jetpack/compose/testing#uiautomator-interop

반응형