R8 działa w 2 trybach: zgodności i pełnym. Tryb pełny zapewnia zaawansowane optymalizacje, które poprawiają wydajność aplikacji.
Ten przewodnik jest przeznaczony dla deweloperów aplikacji na Androida, którzy chcą korzystać z najskuteczniejszych optymalizacji R8. Wyjaśniamy w nim najważniejsze różnice między trybem zgodności a trybem pełnym oraz podajemy konkretne konfiguracje potrzebne do bezpiecznej migracji projektu i uniknięcia typowych awarii w czasie działania.
Włączanie trybu pełnego
Aby włączyć tryb pełny, usuń ten wiersz z pliku gradle.properties:
android.enableR8.fullMode=false // Remove this line to enable full mode
Zachowywanie klas powiązanych z atrybutami
Atrybuty to metadane przechowywane w skompilowanych plikach klas, które nie są częścią kodu wykonywalnego. Mogą być jednak potrzebne w przypadku niektórych typów odbicia. Typowe przykłady to Signature (który zachowuje informacje o typie ogólnym po usunięciu typu), InnerClasses i EnclosingMethod (do odbicia struktury klasy) oraz adnotacje widoczne w czasie działania.
Poniższy kod pokazuje, jak wygląda atrybut Signature w przypadku pola w kodzie bajtowym. W przypadku pola:
List<User> users;
Skompilowany plik klasy będzie zawierał ten kod bajtowy:
.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
Biblioteki, które intensywnie korzystają z odbicia (np. Gson), często polegają na tych atrybutach, aby dynamicznie sprawdzać i rozumieć strukturę kodu. Domyślnie w trybie pełnym R8 atrybuty są zachowywane tylko wtedy, gdy powiązana klasa, pole lub metoda jest jawnie zachowywana.
Poniższy przykład pokazuje, dlaczego atrybuty są niezbędne i jakie reguły zachowywania trzeba dodać podczas przechodzenia z trybu zgodności do trybu pełnego. Oprócz zachowania klas, pól lub metod, które są poddawane odbiciu, musisz też jawnie zachować atrybuty, na których polegają.
Rozważmy ten przykład, w którym deserializujemy listę użytkowników za pomocą biblioteki 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}")
}
Podczas kompilacji usuwanie typów w Javie usuwa argumenty typu ogólnego. Oznacza to, że w czasie działania zarówno List<String>, jak i List<User> są traktowane jako surowa
List. Dlatego biblioteki takie jak Gson, które polegają na odbiciu, nie mogą
określić konkretnych typów obiektów, które mają być zawarte w List podczas
deserializacji listy JSON, co może prowadzić do problemów w czasie działania.
Aby zachować informacje o typie, Gson używa TypeToken. Zawijanie TypeToken zachowuje niezbędne informacje o deserializacji.
Wyrażenie Kotlin object:TypeToken<List<User>>() {}.type tworzy anonimową klasę wewnętrzną, która rozszerza TypeToken i przechwytuje informacje o typie ogólnym. W tym przykładzie anonimowa klasa ma nazwę $GsonRemoteJsonListExample$listType$1.
Język programowania Java zapisuje sygnaturę ogólną klasy nadrzędnej jako metadane, czyli atrybut Signature, w skompilowanym pliku klasy.
TypeToken używa następnie tych metadanych Signature, aby odzyskać typ w czasie działania.
Dzięki temu Gson może używać odbicia do odczytania Signature i pomyślnie
wykryć pełny typ List<User>, który jest potrzebny do deserializacji.
Gdy R8 jest włączony w trybie zgodności, zachowuje atrybut Signature dla klas, w tym anonimowych klas wewnętrznych, takich jak $GsonRemoteJsonListExample$listType$1, nawet jeśli nie są zdefiniowane konkretne reguły zachowywania. Dlatego tryb zgodności R8 nie wymaga żadnych dodatkowych reguł zachowywania, aby ten przykład działał zgodnie z oczekiwaniami.
// keep rule for compatibility mode
-keepattributes Signature
Gdy R8 jest włączony w trybie pełnym, atrybut Signature anonimowej klasy wewnętrznej $GsonRemoteJsonListExample$listType$1 jest usuwany. Bez tych informacji o typie w Signature Gson nie może znaleźć prawidłowego typu aplikacji, co powoduje IllegalStateException.
Jeśli używasz biblioteki Gson w wersji starszej niż 2.11.0, reguły zachowywania niezbędne do zapobiegania temu problemowi to:
// 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: ta reguła instruuje R8, aby zachował atrybut, który musi odczytać Gson. W trybie pełnym R8 zachowuje atrybutSignaturetylko w przypadku klas, pól lub metod, które są jawnie dopasowane przez regułękeep.-keep,allowobfuscation,allowshrinking,allowoptimization class com.google.gson.reflect.TypeToken: Ta reguła jest niezbędna, ponieważTypeTokenopakowuje typ deserializowanego obiektu. Po usunięciu typu tworzona jest anonimowa klasa wewnętrzna, aby zachować informacje o typie ogólnym. Jeśli nie zachowasz jawniecom.google.gson.reflect.TypeToken, R8 w trybie pełnym nie uwzględni tego typu klasy w atrybucieSignaturepotrzebnym do deserializacji.-keep,allowobfuscation,allowshrinking,allowoptimization class * extends com.google.gson.reflect.TypeToken: Ta reguła zachowuje informacje o typie klas anonimowych, które rozszerzająTypeToken, takich jak$GsonRemoteJsonListExample$listType$1w tym przykładzie. Bez tej reguły R8 w trybie pełnym usuwa niezbędne informacje o typie, co powoduje niepowodzenie deserializacji.
Ważne jest, aby zrozumieć, że udostępnione wcześniej reguły rozwiązują tylko
problem z wykrywaniem typu ogólnego (np. List<User>). R8 zmienia też
nazwy pól klas. Jeśli w modelach danych nie używasz adnotacji @SerializedName, Gson nie będzie mógł deserializować JSON, ponieważ nazwy pól nie będą już pasować do kluczy JSON.
@SerializedName w polach, nie musisz określać dodatkowych reguł zachowywania dla modeli.
Jeśli jednak używasz biblioteki Gson w wersji starszej niż 2.11 lub jeśli Twoje modele nie używają adnotacji @SerializedName, musisz dodać jawne reguły zachowywania dla tych modeli.
Zachowywanie konstruktora domyślnego
W trybie pełnym R8 konstruktor bezargumentowy/domyślny nie jest zachowywany niejawnie, nawet jeśli sama klasa jest zachowywana. Jeśli tworzysz instancję klasy za pomocą class.getDeclaredConstructor().newInstance() lub class.newInstance(), musisz jawnie zachować konstruktor bezargumentowy w trybie pełnym. Natomiast tryb zgodności zawsze zachowuje konstruktor bezargumentowy.
Rozważmy przykład, w którym instancja PrecacheTask jest tworzona za pomocą odbicia, aby dynamicznie wywołać jej metodę run. Ten scenariusz nie wymaga dodatkowych reguł w trybie zgodności, ale w trybie pełnym konstruktor domyślny PrecacheTask zostałby usunięty. Dlatego wymagana jest konkretna reguła zachowywania.
// 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>();
}
Modyfikacja dostępu jest domyślnie włączona
W trybie zgodności R8 nie zmienia widoczności metod i pól w klasie. W trybie pełnym R8 zwiększa jednak optymalizację, zmieniając widoczność metod i pól, np. z prywatnej na publiczną. Umożliwia to większe wstawianie kodu.
Ta optymalizacja może powodować problemy, jeśli kod używa odbicia, które polega na tym, że elementy mają określoną widoczność. R8 nie rozpozna tego pośredniego użycia, co może prowadzić do awarii aplikacji. Aby temu zapobiec, musisz dodać konkretne reguły -keep, aby zachować elementy, co spowoduje też zachowanie ich pierwotnej widoczności.
Więcej informacji znajdziesz w tym przykładzie, który wyjaśnia, dlaczego nie zaleca się uzyskiwania dostępu do elementów prywatnych za pomocą odbicia, oraz w regułach zachowywania tych pól i metod.
Metadane specyficzne dla języka Kotlin
Podczas kompilowania kodu Kotlin kompilator Kotlin przechowuje metadane specyficzne dla języka (takie jak dopuszczalność wartości null, funkcje rozszerzające i sygnatury współprogramów) w adnotacji @kotlin.Metadata w każdym pliku klasy.
Jeśli Twoja aplikacja lub jej zależności używają odbicia Kotlin (kotlin.reflect), biblioteka odbicia analizuje te metadane w czasie działania, aby sprawdzić strukturę klasy.
W trybie pełnym R8 domyślnie usuwa adnotacje, jeśli nie są one jawnie zachowywane. Jeśli R8 minimalizuje lub zmniejsza klasy bez zachowania i aktualizowania metadanych, odbicie Kotlin nie powiedzie się w czasie działania, co spowoduje nieprzewidywalne działanie lub awarie (np. KotlinReflectionInternalError).
Aby zapobiec nieprzewidywalnemu działaniu i zapewnić prawidłowe działanie odbicia Kotlin po minimalizacji, musisz zachować adnotacje widoczne w czasie działania i jawnie zachować klasę kotlin.Metadata:
# Preserve runtime-visible annotations required for inspecting metadata
-keepattributes RuntimeVisibleAnnotations
# Keep Kotlin metadata to ensure kotlin.reflect functions correctly
-keep class kotlin.Metadata { *; }