---
title: "DexClassLoader, або завантаження частини застосунку з мережі"
url: "https://romankryvolapov.com/uk/dex-class-loader/"
description: "Як Android-застосунок завантажує .dex із сервера та підвантажує з нього класи через DexClassLoader, що обмежує Android 14 і чому в Google Play так робити не можна."
language: uk
published: 2021-01-21
updated: 2026-11-05
---
Колись я написав невелику програму, яка завантажує із сервера заздалегідь скомпільований код у форматі `.dex` і запускає його. Хотілося з'ясувати, чи може частина застосунку жити не в самому APK, а у вигляді коду на сервері, що підвантажується під час роботи.

Ідея з'явилася після статті в журналі «Хакер» про модулі для Android-троянів ([частина 2](https://xakep.ru/2016/07/05/android-plugins-binder-2/)). Трояни мене не цікавили: цікаво було, як влаштована сама механіка і що на це скаже Google Play.

## Як це працює

Код Android-застосунку після збирання лежить у файлах `.dex` — це байт-код для віртуальної машини ART. Зазвичай усі `.dex` запаковані в APK, і їх завантажує системний завантажувач класів. Але в Android є й публічні завантажувачі, яким можна передати власний файл:

- `DexClassLoader` — завантажує класи з `.dex`, `.jar` або `.apk`, що лежить на диску;
- `PathClassLoader` — те саме, його система використовує для самого застосунку;
- `InMemoryDexClassLoader` (з API 26) — завантажує `.dex` просто з `ByteBuffer`, без файлу.

Завантажений клас нічим не відрізняється від «свого»: його можна створити, викликати його методи, передати йому контекст. Виняток — компоненти застосунку: Activity або Service з такого коду не оголошені в маніфесті, тому система їх не запустить.

## Код

Плагін найзручніше пов'язати із застосунком спільним інтерфейсом: інтерфейс є в застосунку, а клас у `.dex` його реалізує. Тоді після завантаження клас приводиться до інтерфейсу й викликається без рефлексії.

```java
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());
```

Якщо спільного інтерфейсу немає, лишається рефлексія — так було в моєму першому варіанті:

```java
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`, який залишається в застосунку й у плагін не потрапляє:

```bash
mkdir -p out
d8 --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](https://developer.android.com/about/versions/14/behavior-changes-14).

**Google Play.** У першій версії цієї нотатки я написав, що після модерації на пристрій можна вантажити що завгодно. Це не так. Правило Google Play [Device and Network Abuse](https://support.google.com/googleplay/android-developer/answer/16559646) прямо забороняє застосункам і вбудованим у них SDK завантажувати виконуваний код — `.dex`, `.jar`, `.so` — з будь-якого джерела, крім самого Google Play. Модерація може пропустити такий застосунок, але це порушення правил і підстава для видалення. Виняток — код, що виконується у віртуальній машині чи інтерпретаторі без прямого доступу до Android API, наприклад JavaScript у WebView. Законний спосіб доставляти частини застосунку на вимогу — [Play Feature Delivery](https://developer.android.com/guide/playcore/feature-delivery).

**Безпека.** Завантажений код отримує всі права застосунку. Тому лише HTTPS і перевірка підпису файлу до завантаження, з відкритим ключем, вшитим в APK, інакше підміна файлу на сервері чи в мережі перетворюється на виконання чужого коду всередині вашого застосунку. Хеш, завантажений із того самого сервера, нічого не доводить: хто підмінив файл, підмінить і хеш. Google загалом [радить](https://developer.android.com/privacy-and-security/risks/dynamic-code-loading) уникати динамічного завантаження коду, якщо для нього немає вагомої причини.

Де це все ж доречно: корпоративні застосунки, що поширюються не через Google Play, системи плагінів у внутрішніх інструментах, експерименти й дослідження того, як влаштоване завантаження класів в ART.
