다음 예시는 최적화를 위해 R8을 사용하지만 유지 규칙을 작성하기 위한 고급 안내가 필요한 일반적인 시나리오를 기반으로 합니다.
반사
일반적으로 최적의 성능을 위해 리플렉션을 사용하지 않는 것이 좋습니다. 하지만 특정 시나리오에서는 피할 수 없을 수도 있습니다. 다음 예에서는 리플렉션을 사용하는 일반적인 시나리오에서 유지 규칙에 관한 안내를 제공합니다.
이름으로 로드된 클래스를 사용한 리플렉션
라이브러리는 클래스 이름을 String
로 사용하여 클래스를 동적으로 로드하는 경우가 많습니다.
하지만 R8은 이러한 방식으로 로드된 클래스를 감지할 수 없으며 사용되지 않는 것으로 간주되는 클래스를 삭제할 수 있습니다.
예를 들어 라이브러리와 라이브러리를 사용하는 앱이 있는 다음 시나리오를 생각해 보세요. 코드는 앱에서 구현한 StartupTask
인터페이스를 인스턴스화하는 라이브러리 로더를 보여줍니다.
라이브러리 코드는 다음과 같습니다.
// The interface for a task that runs once.
interface StartupTask {
fun run()
}
// The library object that loads and executes the task.
object TaskRunner {
fun execute(className: String) {
// R8 won't retain classes specified by this string value at runtime
val taskClass = Class.forName(className)
val task = taskClass.getDeclaredConstructor().newInstance() as StartupTask
task.run()
}
}
라이브러리를 사용하는 앱에는 다음 코드가 있습니다.
// The app's task to pre-cache data.
// R8 will remove this class because it's only referenced by a string.
class PreCacheTask : StartupTask {
override fun run() {
// This log will never appear if the class is removed by R8.
Log.d("AppTask", "Warming up the cache...")
}
}
fun onCreate() {
// The library is told to run the app's task by its name.
TaskRunner.execute("com.example.app.PreCacheTask")
}
이 시나리오에서 라이브러리에는 다음 유지 규칙이 있는 소비자 유지 규칙 파일이 포함되어야 합니다.
-keep class * implements com.example.library.StartupTask {
<init>();
}
이 규칙이 없으면 앱이 클래스를 직접 사용하지 않기 때문에 R8이 앱에서 PreCacheTask
를 삭제하여 통합이 중단됩니다. 이 규칙은 라이브러리의 StartupTask
인터페이스를 구현하는 클래스를 찾아 보존하며, 인수 없는 생성자와 함께 보존하여 라이브러리가 PreCacheTask
을 성공적으로 인스턴스화하고 실행할 수 있도록 합니다.
::class.java
를 사용한 리플렉션
라이브러리는 앱이 Class
객체를 직접 전달하도록 하여 클래스를 로드할 수 있습니다. 이는 이름으로 클래스를 로드하는 것보다 더 강력한 방법입니다. 이렇게 하면 R8이 감지할 수 있는 클래스에 대한 강력한 참조가 생성됩니다. 이렇게 하면 R8이 클래스를 삭제하지는 않지만, 클래스가 리플렉션 방식으로 인스턴스화되고 생성자와 같이 리플렉션 방식으로 액세스되는 멤버를 보호한다고 선언하려면 keep 규칙을 사용해야 합니다.
예를 들어 라이브러리와 라이브러리를 사용하는 앱이 있는 다음 시나리오를 생각해 보세요. 라이브러리 로더는 클래스 참조를 직접 전달하여 StartupTask
인터페이스를 인스턴스화합니다.
라이브러리 코드는 다음과 같습니다.
// The interface for a task that runs once.
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()
}
}
라이브러리를 사용하는 앱에는 다음 코드가 있습니다.
// The app's task is to pre-cache data.
class PreCacheTask : StartupTask {
override fun run() {
Log.d("AppTask", "Warming up the cache...")
}
}
fun onCreate() {
// The library is given a direct reference to the app's task class.
TaskRunner.execute(PreCacheTask::class.java)
}
이 시나리오에서 라이브러리에는 다음 유지 규칙이 있는 소비자 유지 규칙 파일이 포함되어야 합니다.
# Allow any implementation of StartupTask to be removed if unused.
-keep,allowobfuscation,allowshrinking class * implements com.example.library.StartupTask
# Keep the default constructor, which is called via reflection.
-keepclassmembers class * implements com.example.library.StartupTask {
<init>();
}
이러한 규칙은 이러한 유형의 리플렉션과 완벽하게 작동하도록 설계되어 코드가 올바르게 작동하는지 확인하면서 최대한 최적화할 수 있습니다. 이 규칙을 통해 앱에서 StartupTask
클래스를 사용하지 않는 경우 R8이 클래스 이름을 난독화하고 StartupTask
클래스의 구현을 축소하거나 삭제할 수 있습니다. 하지만 예에 사용된 PrecacheTask
와 같은 구현의 경우 라이브러리에서 호출해야 하는 기본 생성자 (<init>()
)를 유지합니다.
-keep,allowobfuscation,allowshrinking class * implements com.example.library.StartupTask
: 이 규칙은StartupTask
인터페이스를 구현하는 모든 클래스를 타겟팅합니다.-keep class * implements com.example.library.StartupTask
: 인터페이스를 구현하는 클래스 (*
)를 보존합니다.,allowobfuscation
: 클래스를 유지하더라도 이름을 바꾸거나 난독화할 수 있다고 R8에 지시합니다. 라이브러리가 클래스의 이름을 사용하지 않고Class
객체를 직접 가져오므로 안전합니다.,allowshrinking
: 이 수정자는 사용되지 않는 경우 클래스를 삭제할 수 있다고 R8에 알려줍니다. 이렇게 하면 R8이TaskRunner.execute()
에 전달되지 않는StartupTask
구현을 안전하게 삭제할 수 있습니다. 간단히 말해 이 규칙은 다음과 같습니다. 앱이StartupTask
를 구현하는 클래스를 사용하는 경우 R8은 클래스를 유지합니다. R8은 크기를 줄이기 위해 클래스 이름을 바꿀 수 있으며 앱에서 사용하지 않는 경우 삭제할 수 있습니다.
-keepclassmembers class * implements com.example.library.StartupTask { <init>(); }
: 이 규칙은 첫 번째 규칙에서 식별된 클래스의 특정 구성원(이 경우 생성자)을 타겟팅합니다.-keepclassmembers class * implements com.example.library.StartupTask
:StartupTask
인터페이스를 구현하는 클래스의 특정 멤버(메서드, 필드)를 유지합니다. 단, 구현된 클래스 자체가 유지되는 경우에만 해당합니다.{ <init>(); }
: 회원 선택기입니다.<init>
은 Java 바이트코드의 생성자를 위한 특수 내부 이름입니다. 이 부분은 특히 기본 인수 없는 생성자를 타겟팅합니다.- 이 규칙은 코드가 인수가 없는
getDeclaredConstructor().newInstance()
를 호출하여 기본 생성자를 리플렉션 방식으로 호출하기 때문에 중요합니다. 이 규칙이 없으면 R8은 코드가new PreCacheTask()
를 직접 호출하지 않는 것을 확인하고 생성자가 사용되지 않는다고 가정하여 삭제합니다. 이로 인해 런타임에 앱이InstantiationException
로 비정상 종료됩니다.
메서드 주석을 기반으로 한 리플렉션
라이브러리는 개발자가 메서드나 필드에 태그를 지정하는 데 사용하는 주석을 정의하는 경우가 많습니다.
그러면 라이브러리가 리플렉션을 사용하여 런타임에 이러한 주석이 달린 멤버를 찾습니다. 예를 들어 @OnLifecycleEvent
주석은 런타임에 필요한 메서드를 찾는 데 사용됩니다.
예를 들어 라이브러리와 라이브러리를 사용하는 앱이 있는 다음 시나리오를 생각해 보세요. 이 예에서는 @OnEvent
로 주석이 추가된 메서드를 찾아 호출하는 이벤트 버스를 보여줍니다.
라이브러리 코드는 다음과 같습니다.
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class OnEvent
class EventBus {
fun dispatch(listener: Any) {
// Find all methods annotated with @OnEvent and invoke them
listener::class.java.declaredMethods.forEach { method ->
if (method.isAnnotationPresent(OnEvent::class.java)) {
try {
method.invoke(listener)
} catch (e: Exception) { /* ... */ }
}
}
}
}
라이브러리를 사용하는 앱에는 다음 코드가 있습니다.
class MyEventListener {
@OnEvent
fun onSomethingHappened() {
// This method will be removed by R8 without a keep rule
Log.d(TAG, "Event received!")
}
}
fun onCreate() {
// Instantiate the listener and the event bus
val listener = MyEventListener()
val eventBus = EventBus()
// Dispatch the listener to the event bus
eventBus.dispatch(listener)
}
라이브러리에는 주석을 사용하는 메서드를 자동으로 보존하는 소비자 유지 규칙 파일이 포함되어야 합니다.
-keepattributes RuntimeVisibleAnnotations
-keep @interface com.example.library.OnEvent;
-keepclassmembers class * {
@com.example.library.OnEvent <methods>;
}
-keepattributes RuntimeVisibleAnnotations
: 이 규칙은 런타임에 읽을 수 있는 주석을 유지합니다.-keep @interface com.example.library.OnEvent
: 이 규칙은OnEvent
주석 클래스 자체를 유지합니다.-keepclassmembers class * {@com.example.library.OnEvent <methods>;}
: 이 규칙은 클래스가 사용되고 클래스에 해당 멤버가 포함된 경우에만 클래스와 특정 멤버를 유지합니다.-keepclassmembers
: 이 규칙은 클래스가 사용 중이고 클래스에 해당 멤버가 포함된 경우에만 클래스와 특정 멤버를 보존합니다.class *
: 규칙이 모든 클래스에 적용됩니다.@com.example.library.OnEvent <methods>;
:@com.example.library.OnEvent
로 주석이 추가된 메서드 (<methods>
)가 하나 이상 있는 클래스를 유지하고 주석이 추가된 메서드 자체도 유지합니다.
클래스 주석을 기반으로 한 리플렉션
라이브러리는 리플렉션을 사용하여 특정 주석이 있는 클래스를 검색할 수 있습니다. 이 경우 작업 러너 클래스는 리플렉션을 사용하여 ReflectiveExecutor
로 주석이 추가된 모든 클래스를 찾아 execute
메서드를 실행합니다.
예를 들어 라이브러리와 라이브러리를 사용하는 앱이 있는 다음 시나리오를 고려해 보세요.
라이브러리에는 다음 코드가 있습니다.
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class ReflectiveExecutor
class TaskRunner {
fun process(task: Any) {
val taskClass = task::class.java
if (taskClass.isAnnotationPresent(ReflectiveExecutor::class.java)) {
val methodToCall = taskClass.getMethod("execute")
methodToCall.invoke(task)
}
}
}
라이브러리를 사용하는 앱에는 다음 코드가 있습니다.
// In consumer app
@ReflectiveExecutor
class ImportantBackgroundTask {
fun execute() {
// This class will be removed by R8 without a keep rule
Log.e("ImportantBackgroundTask", "Executing the important background task...")
}
}
// Usage of ImportantBackgroundTask
fun onCreate(){
val task = ImportantBackgroundTask()
val runner = TaskRunner()
runner.process(task)
}
라이브러리가 리플렉션을 사용하여 특정 클래스를 가져오므로 라이브러리에는 다음 유지 규칙이 있는 소비자 유지 규칙 파일이 포함되어야 합니다.
# Retain annotation metadata for runtime reflection.
-keepattributes RuntimeVisibleAnnotations
# Keep the annotation interface itself.
-keep @interface com.example.library.ReflectiveExecutor
# Keep the execute method in the classes which are being used
-keepclassmembers @com.example.library.ReflectiveExecutor class * {
public void execute();
}
이 구성은 R8에 보존할 항목을 정확하게 알려주므로 매우 효율적입니다.
선택적 종속 항목을 지원하는 리플렉션
리플렉션의 일반적인 사용 사례는 핵심 라이브러리와 선택적 부가기능 라이브러리 간에 소프트 종속 항목을 만드는 것입니다. 핵심 라이브러리는 앱에 부가기능이 포함되어 있는지 확인할 수 있으며, 포함되어 있는 경우 추가 기능을 사용 설정할 수 있습니다. 이렇게 하면 핵심 라이브러리가 애드온 모듈에 직접 종속되지 않아도 애드온 모듈을 제공할 수 있습니다.
핵심 라이브러리는 리플렉션 (Class.forName
)을 사용하여 이름으로 특정 클래스를 찾습니다. 클래스가 발견되면 기능이 사용 설정됩니다. 그렇지 않으면 정상적으로 실패합니다.
예를 들어 핵심 AnalyticsManager
가 선택적 VideoEventTracker
클래스를 확인하여 동영상 분석을 사용 설정하는 다음 코드를 고려해 보세요.
핵심 라이브러리에는 다음 코드가 있습니다.
object AnalyticsManager {
private const val VIDEO_TRACKER_CLASS = "com.example.analytics.video.VideoEventTracker"
fun initialize() {
try {
// Attempt to load the optional module's class using reflection
Class.forName(VIDEO_TRACKER_CLASS).getDeclaredConstructor().newInstance()
Log.d(TAG, "Video tracking enabled.")
} catch (e: ClassNotFoundException) {
Log.d(TAG,"Video tracking module not found. Skipping.")
} catch (e: Exception) {
Log.e(TAG, e.printStackTrace())
}
}
}
선택사항인 동영상 라이브러리에는 다음 코드가 있습니다.
package com.example.analytics.video
class VideoEventTracker {
// This constructor must be kept for the reflection call to succeed.
init { /* ... */ }
}
선택적 라이브러리의 개발자는 필요한 소비자 유지 규칙을 제공해야 합니다. 이 유지 규칙은 선택적 라이브러리를 사용하는 앱이 핵심 라이브러리에서 찾아야 하는 코드를 유지하도록 합니다.
# In the video library's consumer keep rules file
-keep class com.example.analytics.video.VideoEventTracker {
<init>();
}
이 규칙이 없으면 R8은 해당 모듈의 어떤 항목도 VideoEventTracker
를 직접 사용하지 않으므로 선택적 라이브러리에서 VideoEventTracker
를 삭제할 가능성이 큽니다. keep 규칙은 클래스와 생성자를 유지하므로 핵심 라이브러리가 이를 성공적으로 인스턴스화할 수 있습니다.
비공개 멤버에 액세스하기 위한 리플렉션
리플렉션을 사용하여 라이브러리의 공개 API에 포함되지 않은 비공개 또는 보호된 코드에 액세스하면 심각한 문제가 발생할 수 있습니다. 이러한 코드는 예고 없이 변경될 수 있으며, 이로 인해 애플리케이션에서 예기치 않은 동작이나 비정상 종료가 발생할 수 있습니다.
비공개 API에 리플렉션을 사용하는 경우 다음과 같은 문제가 발생할 수 있습니다.
- 차단된 업데이트: 비공개 또는 보호된 코드의 변경사항으로 인해 더 높은 라이브러리 버전으로 업데이트하지 못할 수 있습니다.
- 혜택 누락: 새로운 기능, 중요한 비정상 종료 수정사항 또는 필수 보안 업데이트를 놓칠 수 있습니다.
R8 최적화 및 리플렉션
라이브러리의 비공개 또는 보호된 코드를 반영해야 하는 경우 R8의 최적화에 주의하세요. 이러한 멤버에 대한 직접 참조가 없으면 R8에서 사용되지 않는 것으로 가정하고 이후에 삭제하거나 이름을 바꿀 수 있습니다.
이로 인해 런타임 비정상 종료가 발생할 수 있으며, NoSuchMethodException
또는 NoSuchFieldException
와 같은 오해의 소지가 있는 오류 메시지가 표시되는 경우가 많습니다.
예를 들어 라이브러리 클래스에서 비공개 필드에 액세스하는 방법을 보여주는 다음 시나리오를 고려해 보세요.
내가 소유하지 않은 라이브러리에 다음 코드가 있습니다.
class LibraryClass {
private val secretMessage = "R8 will remove me"
}
앱에 다음 코드가 있습니다.
fun accessSecretMessage(instance: LibraryClass) {
// Use Java reflection from Kotlin to access the private field
val secretField = instance::class.java.getDeclaredField("secretMessage")
secretField.isAccessible = true
// This will crash at runtime with R8 enabled
val message = secretField.get(instance) as String
}
R8이 비공개 필드를 삭제하지 못하도록 앱에 -keep
규칙을 추가합니다.
-keepclassmembers class com.example.LibraryClass {
private java.lang.String secretMessage;
}
-keepclassmembers
: 클래스 자체가 유지되는 경우에만 클래스의 특정 멤버를 유지합니다.class com.example.LibraryClass
: 필드를 포함하는 정확한 클래스를 타겟팅합니다.private java.lang.String secretMessage;
: 이름과 유형으로 특정 비공개 필드를 식별합니다.
Java 네이티브 인터페이스 (JNI)
R8의 최적화는 네이티브 (C/C++ 코드)에서 Java 또는 Kotlin으로의 업콜을 사용할 때 문제가 발생할 수 있습니다. Java 또는 Kotlin에서 네이티브 코드로의 다운콜에도 문제가 있을 수 있지만 기본 파일 proguard-android-optimize.txt
에는 다운콜이 작동하도록 다음 규칙이 포함되어 있습니다. 이 규칙은 네이티브 메서드가 잘리는 것을 방지합니다.
-keepclasseswithmembernames,includedescriptorclasses class * {
native <methods>;
}
Java 네이티브 인터페이스 (JNI)를 통한 네이티브 코드와의 상호작용
앱이 JNI를 사용하여 네이티브 (C/C++) 코드에서 Java 또는 Kotlin으로 업콜하는 경우 R8은 네이티브 코드에서 호출되는 메서드를 확인할 수 없습니다. 앱에 이러한 메서드에 대한 직접 참조가 없으면 R8은 이러한 메서드가 사용되지 않는다고 잘못 가정하고 이를 삭제하여 앱이 비정상 종료됩니다.
다음 예에서는 네이티브 라이브러리에서 호출하도록 설계된 메서드가 있는 Kotlin 클래스를 보여줍니다. 네이티브 라이브러리는 애플리케이션 유형을 인스턴스화하고 네이티브 코드에서 Kotlin 코드로 데이터를 전달합니다.
package com.example.models
// This class is used in the JNI bridge method signature
data class NativeData(val id: Int, val payload: String)
package com.example.app
// In package com.example.app
class JniBridge {
/**
* This method is called from the native side.
* R8 will remove it if it's not kept.
*/
fun onNativeEvent(data: NativeData) {
Log.d(TAG, "Received event from native code: $data")
}
// Use 'external' to declare a native method
external fun startNativeProcess()
companion object {
init {
// Load the native library
System.loadLibrary("my-native-lib")
}
}
}
이 경우 애플리케이션 유형이 최적화되지 않도록 R8에 알려야 합니다. 또한 네이티브 코드에서 호출된 메서드가 서명에서 자체 클래스를 매개변수 또는 반환 유형으로 사용하는 경우 이러한 클래스의 이름이 바뀌지 않았는지도 확인해야 합니다.
앱에 다음 유지 규칙을 추가합니다.
-keepclassmembers,includedescriptorclasses class com.example.JniBridge {
public void onNativeEvent(com.example.model.NativeData);
}
-keep class NativeData{
<init>(java.lang.Integer, java.lang.String);
}
이러한 keep 규칙은 R8이 onNativeEvent
메서드와 매개변수 유형을 삭제하거나 이름을 바꾸지 못하도록 합니다.
-keepclassmembers,includedescriptorclasses class com.example.JniBridge{ public void onNativeEvent(com.example.model.NativeData);}
: 클래스가 Kotlin 또는 Java 코드에서 먼저 인스턴스화된 경우에만 클래스의 특정 멤버를 유지합니다. 앱이 클래스를 사용하고 클래스의 특정 멤버를 유지해야 한다고 R8에 알립니다.-keepclassmembers
: 클래스가 Kotlin 또는 Java 코드에서 먼저 인스턴스화된 경우에만 클래스의 특정 멤버를 유지합니다. 앱이 클래스를 사용하고 클래스의 특정 멤버를 유지해야 한다고 R8에 알립니다.class com.example.JniBridge
: 필드를 포함하는 정확한 클래스를 타겟팅합니다.includedescriptorclasses
: 이 수정자는 메서드의 서명이나 설명자에 있는 클래스도 유지합니다. 이 경우 R8이 매개변수로 사용되는com.example.models.NativeData
클래스의 이름을 바꾸거나 삭제하지 못하도록 합니다.NativeData
의 이름이a.a
로 변경되면 메서드 서명이 네이티브 코드에서 예상하는 것과 더 이상 일치하지 않아 비정상 종료가 발생합니다.public void onNativeEvent(com.example.models.NativeData);
: 보존할 메서드의 정확한 Java 서명을 지정합니다.
-keep class NativeData{<init>(java.lang.Integer, java.lang.String);}
:includedescriptorclasses
는NativeData
클래스 자체가 유지되도록 하지만 네이티브 JNI 코드에서 직접 액세스하는NativeData
내의 모든 멤버 (필드 또는 메서드)에는 자체 유지 규칙이 필요합니다.-keep class NativeData
:NativeData
라는 클래스를 타겟팅하며 블록은NativeData
클래스 내에서 유지할 멤버를 지정합니다.<init>(java.lang.Integer, java.lang.String)
: 생성자의 서명입니다. 두 매개변수를 사용하는 생성자를 고유하게 식별합니다. 첫 번째는Integer
이고 두 번째는String
입니다.
간접 플랫폼 호출
Parcelable
구현을 사용하여 데이터 전송
Android 프레임워크는 리플렉션을 사용하여 Parcelable
객체의 인스턴스를 만듭니다. 최신 Kotlin 개발에서는 프레임워크에 필요한 CREATOR
필드와 메서드를 비롯한 필요한 Parcelable
구현을 자동으로 생성하는 kotlin-parcelize
플러그인을 사용해야 합니다.
예를 들어 kotlin-parcelize
플러그인을 사용하여 Parcelable
클래스를 만드는 다음 예를 살펴보세요.
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
// Add the @Parcelize annotation to your data class
@Parcelize
data class UserData(
val name: String,
val age: Int
) : Parcelable
이 시나리오에서는 권장되는 보관 규칙이 없습니다. kotlin-parcelize
Gradle 플러그인은 @Parcelize
로 주석을 단 클래스에 필요한 유지 규칙을 자동으로 생성합니다. 생성된 CREATOR
와 생성자가 Android 프레임워크의 리플렉션 호출을 위해 유지되도록 복잡성을 처리합니다.
@Parcelize
를 사용하지 않고 Kotlin에서 Parcelable
클래스를 수동으로 작성하는 경우 CREATOR
필드와 Parcel
를 허용하는 생성자를 유지해야 합니다. 이렇게 하지 않으면 시스템이 객체를 역직렬화하려고 할 때 앱이 비정상 종료됩니다. @Parcelize
를 사용하는 것이 표준이며 더 안전한 방법입니다.
kotlin-parcelize
플러그인을 사용할 때는 다음 사항에 유의하세요.
- 플러그인은 컴파일 중에
CREATOR
필드를 자동으로 만듭니다. proguard-android-optimize.txt
파일에는 적절한 기능을 위해 이러한 필드를 유지하는 데 필요한keep
규칙이 포함되어 있습니다.- 앱 개발자는 특히 맞춤 구현이나 서드 파티 종속 항목의 경우 필수
keep
규칙이 모두 있는지 확인해야 합니다.