R8 옵티마이저의 잠재력 최대한 활용

R8은 호환성 모드와 전체 모드라는 두 가지 모드를 제공합니다. 전체 모드에서는 앱 성능을 개선하는 강력한 최적화를 제공합니다.

이 가이드는 R8의 가장 강력한 최적화를 사용하려는 Android 개발자를 대상으로 합니다. 호환성 모드와 전체 모드의 주요 차이점을 살펴보고 프로젝트를 안전하게 이전하고 일반적인 런타임 비정상 종료를 방지하는 데 필요한 명시적 구성을 제공합니다.

전체 모드 사용 설정

전체 모드를 사용 설정하려면 gradle.properties 파일에서 다음 줄을 삭제합니다.

android.enableR8.fullMode=false // Remove this line to enable full mode

속성과 연결된 클래스 유지

속성은 실행 코드에 포함되지 않고 컴파일된 클래스 파일 내에 저장된 메타데이터입니다. 하지만 특정 유형의 리플렉션에는 필요할 수 있습니다. 일반적인 예로는 Signature (타입 삭제 후 일반 타입 정보를 유지함), InnerClassesEnclosingMethod(클래스 구조 반영용), 런타임에 표시되는 주석이 있습니다.

다음 코드는 바이트 코드의 필드에 대한 Signature 속성이 어떻게 표시되는지 보여줍니다. 필드의 경우:

List<User> users;

컴파일된 클래스 파일에는 다음 바이트 코드가 포함됩니다.

.field public static final users:Ljava/util/List;
    .annotation system Ldalvik/annotation/Signature;
        value = {
            "Ljava/util/List<",
            "Lcom/example/package/User;",
            ">;"
        }
    .end annotation
.end field

리플렉션을 많이 사용하는 라이브러리 (예: Gson)는 이러한 속성을 사용하여 코드의 구조를 동적으로 검사하고 이해하는 경우가 많습니다. R8의 전체 모드에서는 기본적으로 연결된 클래스, 필드 또는 메서드가 명시적으로 유지되는 경우에만 속성이 유지됩니다.

다음 예는 속성이 필요한 이유와 호환 모드에서 전체 모드로 이전할 때 추가해야 하는 유지 규칙을 보여줍니다.

Gson 라이브러리를 사용하여 사용자 목록을 역직렬화하는 다음 예시를 고려해 보세요.


import com.google.gson.Gson
import com.google.gson.reflect.TypeToken

data class User(
    @SerializedName("username")
    var username: String? = null,
    @SerializedName("age")
    var age: Int = 0
)

fun GsonRemoteJsonListExample() {
    val gson = Gson()

    // 1. The JSON string for a list of users returned from remote
    val jsonOutput = """[{"username":"alice","age":30}, {"username":"bob","age":25}]"""

    // 2. Deserialize the JSON string into a List<User>
    // We must use TypeToken for generic types like List
    val listType = object : TypeToken<List<User>>() {}.type
    val deserializedList: List<User> = gson.fromJson(jsonOutput, listType)

    // Print the list
    println("First user from list: ${deserializedList}")
}

컴파일 중에 Java의 유형 삭제는 일반 유형 인수를 삭제합니다. 즉, 런타임에 List<String>List<User>가 모두 원시 List로 표시됩니다. 따라서 리플렉션을 사용하는 Gson과 같은 라이브러리는 JSON 목록을 역직렬화할 때 List에 포함된 것으로 선언된 특정 객체 유형을 확인할 수 없으므로 런타임 문제가 발생할 수 있습니다.

Gson은 유형 정보를 유지하기 위해 TypeToken를 사용합니다. TypeToken을 래핑하면 필요한 역직렬화 정보가 유지됩니다.

Kotlin 표현식 object:TypeToken<List<User>>() {}.typeTypeToken를 확장하고 일반 유형 정보를 캡처하는 익명 내부 클래스를 만듭니다. 이 예에서 익명 클래스 이름은 $GsonRemoteJsonListExample$listType$1입니다.

Java 프로그래밍 언어는 컴파일된 클래스 파일 내에 Signature 속성이라고 하는 메타데이터로 슈퍼클래스의 일반 서명을 저장합니다. 그런 다음 TypeToken는 이 Signature 메타데이터를 사용하여 런타임에 유형을 복구합니다. 이렇게 하면 Gson이 리플렉션을 사용하여 Signature를 읽고 역직렬화에 필요한 전체 List<User> 유형을 성공적으로 검색할 수 있습니다.

호환성 모드에서 R8이 사용 설정되면 특정 유지 규칙이 명시적으로 정의되지 않은 경우에도 $GsonRemoteJsonListExample$listType$1과 같은 익명 내부 클래스를 비롯한 클래스의 Signature 속성이 유지됩니다. 따라서 R8 호환 모드에서는 이 예시가 예상대로 작동하기 위해 추가 명시적 유지 규칙이 필요하지 않습니다.

// keep rule for compatibility mode
-keepattributes Signature

R8이 전체 모드로 사용 설정되면 익명 내부 클래스 $GsonRemoteJsonListExample$listType$1Signature 속성이 삭제됩니다. Signature에 이 유형 정보가 없으면 Gson이 올바른 애플리케이션 유형을 찾을 수 없으므로 IllegalStateException이 발생합니다. 이를 방지하는 데 필요한 유지 규칙은 다음과 같습니다.

// keep rule required for full mode
-keepattributes Signature
-keep,allowobfuscation,allowshrinking,allowoptimization class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking,allowoptimization class * extends com.google.gson.reflect.TypeToken
  • -keepattributes Signature: 이 규칙은 Gson이 읽어야 하는 속성을 유지하도록 R8에 지시합니다. 전체 모드에서 R8은 keep 규칙으로 명시적으로 일치하는 클래스, 필드 또는 메서드의 Signature 속성만 유지합니다.

  • -keep,allowobfuscation,allowshrinking,allowoptimization class com.google.gson.reflect.TypeToken: 이 규칙은 TypeToken이 역직렬화되는 객체의 유형을 래핑하기 때문에 필요합니다. 타입 삭제 후 일반 유형 정보를 유지하기 위해 익명 내부 클래스가 생성됩니다. com.google.gson.reflect.TypeToken를 명시적으로 유지하지 않으면 전체 모드의 R8은 역직렬화에 필요한 Signature 속성에 이 클래스 유형을 포함하지 않습니다.

  • -keep,allowobfuscation,allowshrinking,allowoptimization class * extends com.google.gson.reflect.TypeToken: 이 규칙은 이 예의 $GsonRemoteJsonListExample$listType$1와 같이 TypeToken를 확장하는 익명 클래스의 유형 정보를 유지합니다. 이 규칙이 없으면 전체 모드의 R8이 필요한 유형 정보를 삭제하여 역직렬화가 실패합니다.

Gson 버전 2.11.0부터 라이브러리는 전체 모드에서 역직렬화에 필요한 필수 유지 규칙을 번들로 제공합니다. R8을 사용 설정하여 앱을 빌드하면 R8이 라이브러리에서 이러한 규칙을 자동으로 찾아 적용합니다. 이렇게 하면 프로젝트에 이러한 특정 규칙을 수동으로 추가하거나 유지관리하지 않아도 앱에 필요한 보호가 제공됩니다.

앞서 공유한 규칙은 일반 유형 (예: List<User>). R8은 클래스의 필드 이름도 바꿉니다. 데이터 모델에 @SerializedName 주석을 사용하지 않으면 필드 이름이 더 이상 JSON 키와 일치하지 않으므로 Gson이 JSON을 역직렬화하지 못합니다.

하지만 Gson 버전이 2.11보다 오래되었거나 모델에서 @SerializedName 주석을 사용하지 않는 경우 해당 모델에 명시적 유지 규칙을 추가해야 합니다.

기본 생성자 유지

R8 전체 모드에서는 클래스 자체가 유지되더라도 인수 없음/기본 생성자가 암시적으로 유지되지 않습니다. class.getDeclaredConstructor().newInstance() 또는 class.newInstance()를 사용하여 클래스의 인스턴스를 만드는 경우 전체 모드에서 인수가 없는 생성자를 명시적으로 유지해야 합니다. 반면 호환성 모드는 항상 인수가 없는 생성자를 유지합니다.

리플렉션을 사용하여 PrecacheTask 인스턴스를 만들어 run 메서드를 동적으로 호출하는 예를 살펴보겠습니다. 이 시나리오에서는 호환성 모드에 추가 규칙이 필요하지 않지만 전체 모드에서는 PrecacheTask의 기본 생성자가 삭제됩니다. 따라서 특정 보관 규칙이 필요합니다.

// In library
interface StartupTask {
    fun run()
}
// The library object that loads and executes the task.
object TaskRunner {
    fun execute(taskClass: Class<out StartupTask>) {
        // The class isn't removed, but its constructor might be.
        val task = taskClass.getDeclaredConstructor().newInstance()
        task.run()
    }
}

// In app
class PreCacheTask : StartupTask {
    override fun run() {
        Log.d("Pre cache task", "Warming up the cache...")
    }
}

fun runTaskRunner() {
    // The library is given a direct reference to the app's task class.
    TaskRunner.execute(PreCacheTask::class.java)
}
# Full mode keep rule
# default constructor needs to be specified

-keep class com.example.fullmoder8.PreCacheTask {
    <init>();
}

액세스 수정은 기본적으로 사용 설정되어 있습니다.

호환성 모드에서 R8은 클래스 내 메서드와 필드의 공개 상태를 변경하지 않습니다. 하지만 전체 모드에서 R8은 메서드와 필드의 공개 상태를 변경하여 최적화를 강화합니다(예: 비공개에서 공개로). 이렇게 하면 더 많은 인라이닝이 가능합니다.

이 최적화로 인해 코드가 특정 공개 상태를 갖는 멤버에 특히 의존하는 리플렉션을 사용하는 경우 문제가 발생할 수 있습니다. R8은 이 간접 사용을 인식하지 못하므로 앱이 다운될 수 있습니다. 이를 방지하려면 구성원을 유지하는 특정 -keep 규칙을 추가해야 하며, 이렇게 하면 원래 공개 상태도 유지됩니다.

리플렉션을 사용하여 비공개 멤버에 액세스하는 것이 권장되지 않는 이유와 이러한 필드/메서드를 유지하는 규칙에 관한 자세한 내용은 이 를 참고하세요.