DexClassLoader, или зареждане на част от приложението от мрежата
Веднъж написах малка програма, която изтегля от сървъра предварително компилиран код във формат .dex и го стартира. Исках да разбера може ли част от приложението да живее не в самия APK, а като код на сървъра, който се зарежда по време на работа.
Идеята ми дойде след статия в „Хакер“ за модули за Android троянци (част 2). Троянците не ме интересуваха: интересно ми беше как е устроена самата механика и какво ще каже Google Play за това.
Как работи
Кодът на Android приложението след билда се намира във файлове .dex – това е байткод за виртуалната машина ART. Обикновено всички .dex са опаковани в APK и се зареждат от системния зареждач на класове. Но в Android има и публични зареждачи, на които може да се подаде собствен файл:
DexClassLoader– зарежда класове от.dex,.jarили.apkфайл, който се намира на диска;PathClassLoader– същото, системата го използва за самото приложение;InMemoryDexClassLoader(от API 26) – зарежда.dexдиректно отByteBuffer, без файл.
Зареденият клас по нищо не се различава от „собствен“: можете да го създадете, да извикате методите му, да му подадете контекст. Изключение са компонентите на приложението: Activity или Service от такъв код не са декларирани в манифеста, затова системата няма да ги стартира.
Код
Плъгинът най-удобно се свързва с приложението чрез общ интерфейс: интерфейсът е в приложението, а класът в .dex го реализира. Тогава след зареждането класът се привежда към интерфейса и се извиква без рефлексия.
File dexFile = new File(getCodeCacheDir(), "plugin.dex");if (dexFile.exists()) { dexFile.delete();}
try (InputStream input = new URL(baseUrl + "plugin.dex").openStream(); FileOutputStream output = new FileOutputStream(dexFile)) { // Android 14+: файлът трябва да стане read-only, преди в него да е записано нещо dexFile.setReadOnly(); byte[] buffer = new byte[8192]; int count; while ((count = input.read(buffer)) != -1) { output.write(buffer, 0, count); }}
DexClassLoader loader = new DexClassLoader( dexFile.getAbsolutePath(), null, // optimizedDirectory: от API 26 се игнорира null, // път до нативните библиотеки, ако са нужни getClassLoader()); // родител: през него плъгинът вижда класовете на приложението
Class<?> pluginClass = loader.loadClass("com.example.plugin.PluginImpl");Plugin plugin = (Plugin) pluginClass.getDeclaredConstructor().newInstance();plugin.run(getApplicationContext());Ако няма общ интерфейс, остава рефлексията – така беше в първия ми вариант:
for (Method method : pluginClass.getDeclaredMethods()) { method.setAccessible(true); Log.d(TAG, "method: " + method.getName());}for (Field field : pluginClass.getDeclaredFields()) { field.setAccessible(true); Log.d(TAG, "field: " + field.getName());}Как се прави .dex
Плъгинът се компилира с обикновения javac или kotlinc срещу същия интерфейс Plugin, а получените .class се превръщат в .dex с помощта на инструмента d8 от Android SDK (папката build-tools). На d8 са нужни вече съществуваща папка за резултата, android.jar от SDK и в classpath компилираният интерфейс Plugin, който остава в приложението и в плъгина не попада:
mkdir -p outd8 --lib "$ANDROID_HOME/platforms/android-36/android.jar" --classpath build/classes \ --output out/ build/classes/com/example/plugin/PluginImpl.classВ out/ ще се появи classes.dex – именно него трябва да качите на сървъра.
В релизния билд интерфейсът трябва да оцелее и при R8: плъгинът е компилиран срещу първоначалните му имена и те се запазват от правилото -keep interface com.example.app.Plugin { *; }.
Ограничения
Android 14. Ако приложението е насочено към API 34 и по-висок, всеки динамично зареждан файл трябва да е достъпен само за четене, иначе системата ще хвърли изключение при зареждането. Файлът трябва да се маркира като read-only веднага след отварянето му, преди записването на съдържанието, както в кода по-горе. Вече съществуващ файл е по-добре да се изтрие и да се изтегли наново. Подробности – в промените в поведението на Android 14.
Google Play. В първата версия на тази бележка написах, че след модерацията на устройството може да се зарежда каквото и да е. Това не е вярно. Правилото на Google Play Device and Network Abuse изрично забранява на приложенията и вградените в тях SDK да изтеглят изпълним код – .dex, .jar, .so – от какъвто и да е източник освен самия Google Play. Модерацията може да пропусне такова приложение, но това е нарушение на правилата и основание за премахване. Изключение е кодът, който се изпълнява във виртуална машина или интерпретатор и няма пряк достъп до Android API, например JavaScript в WebView. Законният начин да се доставят части от приложението при поискване е Play Feature Delivery.
Сигурност. Зареденият код получава всички права на приложението. Затова – само HTTPS и проверка на подписа на файла преди зареждането, с публичен ключ, зашит в APK; иначе подмяната на файла на сървъра или в мрежата води до изпълнение на чужд код във вашето приложение. Хешът, изтеглен от същия сървър, не доказва нищо: който е подменил файла, ще подмени и хеша. Google като цяло съветва да се избягва динамичното зареждане на код, ако няма сериозна причина за него.
Къде все пак е приложимо: корпоративни приложения, които не се разпространяват през Google Play, системи за плъгини във вътрешни инструменти, експерименти и изследване на това как е устроено зареждането на класове в ART.