일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- TEST
- textfield
- MiTweet
- Hilt
- livedata
- pandas
- 코드포스
- AWS
- 백준
- Kotlin
- relay
- Codeforces
- Coroutine
- Gradle
- 프로그래머스
- activity
- androidStudio
- Compose
- 암호학
- android
- Python
- ProGuard
- boj
- Rxjava
- MyVoca
- architecture
- 쿠링
- 코루틴
- GitHub
- Coroutines
- Today
- Total
이동식 저장소
[Android] R8 컴파일러로 앱 경량화하기 본문
왜 경량화해야 하는가?
APK 파일에는 실행 가능한 바이트코드가 DEX(Dalvik Executable) 형태로 저장되어 있다. DEX 파일에는 앱에서 실행되는 Android framework 메서드, 개발자가 직접 작성한 메서드 등의 목록이 저장되어 있다.
그런데 하나의 DEX 파일에는 메서드가 최대 65536개까지만 포함될 수 있다. 왜 65536개인지는 나중에 다시 공부할 것이고, 어쨌든 이 한도를 넘으면 DEX 파일을 여러 개 작성해야 한다. 그런데 원칙적으로 DEX 파일은 하나만 존재해야 하며, DEX 파일이 여러 개 존재하면 앱의 성능이 크게 나빠질 수 있다. 왜인지는 나중에.
이런 이유로 APK에서 불필요한 메서드를 최대한 제거해야 한다. R8 컴파일러를 사용하면 앱을 경량화할 수 있다.
R8
R8 컴파일러는 Java 바이트코드를 최적화하여 DEX 포맷으로 저장한다. 사용되지 않는 메서드, 리소스 등을 제거하여 DEX를 경량화하고, 난독화 등의 기능도 제공한다.
사실 AGP 3.4.0 이상에서는 ProGuard 대신 R8이 기본으로 사용된다.
R8 vs. ProGuard
사실 ProGuard는 앱을 Java로 개발하던 시절에 나온 물건이라, Kotlin을 주로 사용하는 지금 상황에는 2% 부족하다. R8은 최근에 개발된 컴파일러답게 Kotlin 컴파일러가 내놓은 바이트코드를 더 잘 최적화한다.
R8은 Java 바이트코드를 바로 DEX로 바꾸기 때문에 더 빠르기도 하다. 경량화 성능도 더 좋고.
요약하면 R8이 절대적으로 더 좋다. 하위 호환성도 갖추고 있어 ``proguard-rules`` 파일을 R8이 그대로 이용할 수 있다.
경량화 과정
축소 (Shrink)
축소 단계는 크게 코드 축소와 리소스 축소로 나뉜다.
코드 축소는 런타임에 필요하지 않은 코드를 제거하는 과정이다. 외부 라이브러리 중 사용되지 않는 부분을 제거할 수 있다. 라이브러리의 개수는 많지만 사용하는 부분이 매우 적을 때 큰 효과를 볼 수 있다.
코드 축소는 마치 트리의 탐색과 유사하게 진행된다. 우선 앱의 동작이 시작되는 지점(entry point)을 찾는다. Activity나 Service 등이 시작점이 될 수 있다. R8은 시작점으로부터 출발하여 앱에서 사용되는 코드를 탐색하고, 사용된 코드를 엮어 트리를 만든다.
모든 시작점에 대해 트리를 완성했다면, 어떠한 트리에도 포함되지 않는 코드를 불필요한 코드로 판정하여 삭제한다.
특정 코드가 삭제되지 않게 지정할 수도 있다. ``proguard-rules`` 파일을 수정하면 된다. 특히 JNI나 런타임에 동적으로 실행되는 코드는 실행 여부와 상관없이 삭제될 수 있기 때문에 keep 옵션으로 보존해야 한다.
# at proguard-rules.pro file
-keep public class MyClass
또는 AndroidX Annotations 라이브러리의 ``@Keep`` 어노테이션을 적용해도 된다. 이 어노테이션은 R8을 활성화해야만 적용할 수 있다.
코드 수축이 완료되면 리소스 수축 과정이 진행된다. 일단 불필요한 코드를 다 지워야 리소스가 불필요한지 아닌지 알 수 있기 때문이다.
리소스 수축 과정을 수행하려면 ``bulid.gradle``에서 ``shrinkResources``를 ``true``로 설정해야 한다. 기본값은 ``false``이다.
android {
...
buildTypes {
release {
shrinkResources true
minifyEnabled true
proguardFiles
getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
}
리소스를 수축하기 전에 먼저 코드 수축 과정의 결과물을 검증하자. 꼭 필요한 코드와 리소스 모두 제거되는 불상사를 막을 수 있다.
사용되지 않는 특정 리소스를 보존할 수도 있다. 자세한 내용은 공식 문서를 참고하자.
난독화 (obfuscation)
난독화는 코드를 읽기 힘들게 바꾸는 것이다. 예를 들어 ``androidx.compose.ui.Row``를 ``a.b.c.d``로 바꾸면 읽기 아주 어려워지겠지? 이렇게 클래스나 변수의 이름을 바꾸고, 의미 없는 로직을 섞어 코드를 읽기 어렵게 만들 수 있다.
특히 JVM 계열 언어에서는 난독화가 매우 중요하다. 컴파일 결과로 나오는 바이트코드를 디컴파일하면 원본 코드를 대부분 복원할 수 있기 때문이다. 난독화를 적용하면 정적 분석을 어느 정도 방어할 수 있다.
긴 클래스 이름을 짧게 바꾸기 때문에 DEX 파일의 용량이 줄어드는 효과도 있다.
하지만 바이트코드가 난독화되기 때문에 디버깅이 어려워진다. ``proguard-rules`` 파일에 다음 옵션을 추가해 보자.
-keepattributes LineNumberTable,SourceFile
-renamesourcefileattribute SourceFile
위의 옵션을 추가하면 소스 파일의 이름과 line number 정보가 별도의 mapping 파일로 보존되고, stack trace의 가독성을 방어할 수 있다.
최적화 (optimization)
최적화를 적용하면 앱의 사이즈를 더 줄일 수 있다. (당연한 말인가?)
- 절대로 실행되지 않는 else 문을 제거한다.
- 어떤 메서드가 한 곳에서만 호출된다면, 함수의 본문을 코드에 직접 붙여넣고 해당 함수를 제거한다.
- 기타 등등 최적화 기법
R8을 활성화한 이상 최적화 과정은 무조건 수행된다. 옵션으로 끄려고 해도 무시된다.
``gradle.properties`` 파일에 다음 옵션을 추가하면 더욱 공격적인 최적화 기법이 수행된다.
android.enableR8.fullMode=true
경고: 어떤 일이 일어날 지 모름
제거된 코드 확인하기
``proguard-rules``에 다음 옵션을 추가하면 R8이 삭제한 코드를 확인할 수 있다.
-printusage <output-dir>/usage.txt
대충 이렇게 생겼다.
androidx.drawerlayout.R$attr
androidx.vectordrawable.R
androidx.appcompat.app.AppCompatDelegateImpl
public void setSupportActionBar(androidx.appcompat.widget.Toolbar)
public boolean hasWindowFeature(int)
public void setHandleNativeActionModesEnabled(boolean)
android.view.ViewGroup getSubDecor()
public void setLocalNightMode(int)
final androidx.appcompat.app.AppCompatDelegateImpl$AutoNightModeManager getAutoNightModeManager()
public final androidx.appcompat.app.ActionBarDrawerToggle$Delegate getDrawerToggleDelegate()
private static final boolean DEBUG
private static final java.lang.String KEY_LOCAL_NIGHT_MODE
static final java.lang.String EXCEPTION_HANDLER_MESSAGE_SUFFIX
...
``-printusage`` 대신 ``-printseeds``를 적용하면 R8이 삭제하지 않은 코드를 확인할 수 있다.
com.example.myapplication.MainActivity
androidx.appcompat.R$layout: int abc_action_menu_item_layout
androidx.appcompat.R$attr: int activityChooserViewStyle
androidx.appcompat.R$styleable: int MenuItem_android_id
androidx.appcompat.R$styleable: int[] CoordinatorLayout_Layout
androidx.lifecycle.FullLifecycleObserverAdapter
...
적용
MyVoca에 경량화를 적용해 보자. 일단 코드 경량화와 난독화만 수행하고, 결과를 비교할 것이다.
먼저 경량화를 적용하지 않은 APK와 AAB를 빌드하자. Build variant를 release로 설정하여 APK와 AAB를 빌드한다.
이제 경량화를 활성화하자. Build variant 중 ``release``에만 ``minifyEnabled``를 ``true``로 설정한다.
Gradle 파일을 sync한 후, APK와 AAB를 빌드한다.
이제 APK를 분석해 보자. 먼저 축소되지 않은 APK를 분석했는데.....
Dex 파일이 무려 4개나 있다. 이러니 용량이 클 수밖에.
이제 축소된 APK를 보자.
Dex가 무려 1개로 줄어들었다! 심지어 참조하는 메서드 수도 여유로운 모습이다. 와... 이 정도일 줄은 몰랐는데.
하단 메서드 목록을 보면 난독화까지 적용된 모습이다.
오.. 대단해 (실제로 한 말)
AAB도 크게 다르지 않다. 왼쪽이 경량화되지 않은 AAB이고, 오른쪽이 경량화된 AAB이다.
효과 확실하구만?
결론
적어도 ``release`` 빌드에는 꼭 경량화를 적용하자. 디버깅은 몰라도 실제로 사용될 앱은 최대한 가벼워야 하니까.
나는 ``release``와 ``debug`` 모두에 경량화를 적용했다. 특별히 ``debug``에는 위에서 말했던 난독화 복원 rule을 적용했다.
참고
'Primary > Android' 카테고리의 다른 글
[Android] Build variant 기초 (0) | 2022.07.17 |
---|---|
Android Runtime with ART, AOT, JIT, DEX (0) | 2022.07.12 |
[Android] proguard-rules.pro는 무엇인가 (0) | 2022.07.07 |
Hilt Test Principles (0) | 2022.06.30 |
Android Architecture Layers - 4. UI events (0) | 2022.06.27 |