Dynamic Delivery в многомодульных проектах (часть 1)

yg2cbuone0-rgqo0lakwodpho5e.jpeg

Привет! Меня зовут Юрий Влад, я Android-разработчик в компании Badoo и занимаюсь внедрением Dynamic Features в наши проекты.

Dynamic Delivery — технология, позволяющая устанавливать и удалять части приложения прямо во время работы для того, чтобы уменьшить место, занимаемое приложением. Если какие-то функции не используются, то зачем пользователю иметь их на устройстве?

В первой части статьи я подробнее расскажу о Dynamic Delivery и его API: как загружать и удалять модули. Во второй части — разберу на примере, как я использовал Dynamic Delivery в нашем приложении и получил экономию на размере приложения в полмегабайта.


Модули для Dynamic Delivery

К функциям, которые не нужны пользователю постоянно и которые могут быть удалены, можно отнести:


  1. Функции под А/B-тестами и пользовательскими группами. Некоторые функции могут быть доступны только в определённых регионах, и без Dynamic Delivery они лежат мёртвым грузом в устройствах всех остальных пользователей.
  2. Специфичные функции, которые нужны не всем пользователям. Классический пример — модуль с камерой и распознаванием номера банковской карты.
  3. Функции, которые перестают быть доступны после каких-то действий пользователя. Например, это могут быть экраны регистрации, которые можно удалить после регистрации и установить заново, если пользователь решит зарегистрировать другой аккаунт.

Такие функции можно легко вынести в отдельные загружаемые модули, чтобы уменьшить место, занимаемое приложением. Если такой модуль устанавливается не во время установки приложения, то отображаемый вес приложения в Google Play будет уменьшен на вес этого модуля. Меньше размер приложения — больше конверсия установок. Также важно удалять неиспользуемые модули, чтобы ваше приложение занимало меньше места. Когда место на устройстве заканчивается, Google Play предлагает удалить часть приложений, сортируя их в том числе по объёму занимаемого места. И очень не хочется оказаться в самом начале этого списка.

Модули с перечисленными выше функциями могут содержать в себе код и любые типы ресурсов. После установки классы будут загружены в ClassLoader и их можно будет использовать. К ресурсам можно будет обращаться из установленного модуля. Но есть одно но…


Dynamic Feature Module

Работать с загруженным кодом можно только через рефлексию. Таким образом обеспечивается безопасность использования динамически загружаемых модулей. Ведь если бы загружаемый модуль был подключён зависимостью compileOnly, то в ситуации, когда этот модуль не установлен, мы получали бы ClassNotFoundException при попытке использования классов этого модуля. Рефлексия в этом случае позволяет нам:


  1. Более безопасно обрабатывать ситуации, когда требуемые классы не найдены в рантайме.
  2. Избегать ситуаций, когда мы случайно используем классы из Dynamic Feature Module, не проверив их наличие в ClassLoader.

С точки зрения структуры модулей в Gradle это выглядит так:


glvfa21iwb1h0mcfwotzrf90g4c.png

Источник картинки: Patterns for accessing code from Dynamic Feature Modules, рекомендую к прочтению

Модули в первом ряду (:about и другие) — Dynamic Feature Modules. Они зависят от основного модуля приложения и могут легко использовать его код и ресурсы. Во втором ряду — модуль приложения, а в третьем — его зависимости.

Кажется, что такое требование использовать рефлексию сводит на нет все преимущества технологии, но я покажу, как при нашем многомодульном подходе можно свести всю рефлексию к двум-трём вызовам.


SplitInstallManager

Для начала разберёмся, как устанавливать и удалять модули. Для этого используется SplitInstallManager, который является частью библиотеки com.google.android.play:core. Возможно, вы с ней уже знакомы по In-app Updates и MissingSplitsManager.

Схема работы с модулями следующая:


  1. Проверить через SplitInstallManager.installedModules, не установлен ли у нас уже модуль.
  2. Если модуль не установлен, запросить установку с помощью SplitInstallRequest, указав его название.
  3. Следить за прогрессом установки, показать юзеру модный UI загрузки, если он ждёт.
  4. Если модуль успешно установлен, начать использовать его через рефлексию, если произошла ошибка — показать её.

Всё довольно просто и очевидно, за исключением не очень удобного API, который я покажу на примере кода с android.developers.com.


Проверка факта установки модуля

splitInstallManager.installedModules.contains("module name")


Запрос установки

val request =
    SplitInstallRequest
        .newBuilder()
        .addModule("module name")
        .build()

splitInstallManager
    .startInstall(request)
    .addOnSuccessListener { sessionId -> ... }
    .addOnFailureListener { exception ->  ... }

splitInstallManager.startInstall вернёт Task, но не из пакета com.google.android.gms:play-services-tasks, к которому у вас, скорее всего, уже написаны Kotlin Extensions, а свой собственный. API у них совпадают полностью, но вот package name — разные. В addOnSuccessListener вернётся идентификатор сессии установки. Причём он возвращается один и тот же, если запрашивать установку несколько раз подряд, так что не бойтесь этого делать. Единственное ограничение: если вы указываете загрузку нескольких модулей за раз через SplitInstallRequest.addModule(...).addModule(...), то при попытке ещё раз запросить установку только одного из них вы получите ошибку INCOMPATIBLE_WITH_EXISTING_SESSION. Если произошла ошибка до получения сессии или во время установки, вы получите ошибку в addOnFailureListener.


Прогресс установки

var mySessionId = 0
val listener = SplitInstallStateUpdatedListener { state ->
    if (state.sessionId() == mySessionId) {
      ...
    }
}
splitInstallManager.registerListener(listener)

...

splitInstallManager
    .startInstall(request)
    .addOnSuccessListener { sessionId -> mySessionId = sessionId }

...

splitInstallManager.unregisterListener(listener)

Обновления состояния установки будут приходить в SplitInstallStateUpdatedListener. В него приходят обновления вообще всех сессий, а фильтровать по идентификатору сессии мы их должны сами. В SplitInstallSessionState нам доступны:


  1. Текущее состояние установки (загрузка, распаковка, установка и прочие).
  2. Сколько байтов загружено и сколько осталось загрузить (эту информацию можно использовать для отображения прогресса установки).
  3. Код ошибки (этот же код ошибки придёт в addOnFailureListener).


pjacmkdvz6z-dh7jwoptichlxxi.png

Подтверждение от пользователя

Если размер модуля превышает 10 Мб, нужно запросить у пользователя подтверждение его загрузки. В SplitInstallSessionState вернётся особый статус REQUIRES_USER_CONFIRMATION. В этом состоянии установка будет находиться, пока вы не вызовете splitInstallManager.startConfirmationDialogForResult(Activity, SplitInstallSessionState, Int). Этот вызов запустит через startActivityForResult Google Play c диалогом подтверждения установки. Если пользователь нажмёт на кнопку «Загрузить», то установка продолжится и вам ничего не потребуется обрабатывать. Если он нажмёт на кнопку «Отмена», то установка завершится со статусом CANCELED.


7j-ra47k7yyqcrovfkaqcl4hj_a.png

Установка

Для поддержки в вашем приложении загрузки классов и ресурсов нужно использовать SplitCompat.

class YourApplication : Application() {
    override fun attachBaseContext(base: Context) {
        super.attachBaseContext(base)
        SplitCompat.install(this)
    }
}
class YourActivity : Activity() {
    override fun attachBaseContext(base: Context) {
        super.attachBaseContext(base)
        SplitCompat.installActivity(this)
    }
}

Версия для приложения вытащит из загруженных APK-файлов classes.dex и загрузит их в ClassLoader, а также вызовет context.getAssets().addAssetPath(String), куда передаст путь до APK-файла загруженного модуля. Версия для Activity только добавит путь в AssetManager. Может показаться, что вызывать installActivity не обязательно, если использовать Context приложения, но не стоит так делать. Это может обернуться проблемами с конфигурацией Activity, которая не будет учитываться в этом случае.


Отложенная установка

Можно попросить Google Play Services установить модуль когда-нибудь потом. Официальная документация описывает это «когда-нибудь потом» как «best-effort when the app is in the background». На практике же модуль загрузится, когда ваше приложение не будет запущено и когда Google Play будет устанавливать обновления вашего или других приложений.

Запросить установку в фоновом режиме очень просто:

splitInstallManager.deferredInstall(listOf("module name"))

При этом вы никак не сможете её отслеживать, так как она банально не будет выполнена, пока ваше приложение запущено.

Несмотря на это ограничение, такой подход вполне может быть полезен. Например, для функций под A/B-тестом. Если вы разместили точку входа в новый экран приложения на видном месте, то пользователь как минимум из любопытства нажмёт на неё. Тогда почему бы не запрашивать установку всех таких модулей в фоновом режиме, чтобы не заставлять пользователя ждать потом?


Соберём всё вместе

Для начала напишем функцию, которая просто проверит, установлен ли модуль moduleName и можно ли использовать требуемый класс className через рефлексию.

fun isInstalled(moduleName: String, className: String): Boolean {
    if (splitInstallManager.installedModules.contains(moduleName)) {
        return true
    }
    return try {
        Class.forName(className, false, javaClass.classLoader)
        true
    } catch (ignored: ClassNotFoundException) {
        false
    }
}

В случае с нерелизными сборками вы будете получать пустой список установленных модулей. Поэтому добавим проверку на наличие класса, который мы собираемся использовать через рефлексию. Используем именно эту перегрузку Class.forName, чтобы не инициализировать статические поля проверяемого класса, выполняя тем самым работу, которую можно отложить на потом.

В процессе установки нам нужно обрабатывать разные состояния установки модуля. Для этого используем простой sealed class.

sealed class DynamicDeliveryProgress {

    object Pending : DynamicDeliveryProgress()

    object Installing: DynamicDeliveryProgress()

    data class RequiresConfirmation(
        val state: SplitInstallSessionState
    ) : DynamicDeliveryProgress()

    data class Downloading(
        val totalBytes: Long,
        val currentBytes: Long
    ) : DynamicDeliveryProgress()
}

К сожалению, от использования SplitInstallSessionState в RequiresConfirmation избавиться не получится, так как он необходим для вызова splitInstallManager.startConfirmationDialogForResult. Хотя, казалось бы, можно обойтись просто идентификатором сессии. Надеюсь, в будущем это изменят.

Нам также понадобится функция, которая загрузит модуль и проследит за прогрессом. Мы в Badoo используем реактивный подход, поэтому будем возвращать Observable. Когда модуль будет установлен, завершим поток.

fun load(moduleName: String, className: String) : Observable =
    Observable
        .create { emitter ->
            // Если модуль уже установлен, то завершим сразу же
            if (isInstalled(moduleName, className)) {
                emitter.onComplete()
                return@create
            }
            // Идентификатор сессии установки
            val sessionId = AtomicInteger()
            // Создадим листенер прогресса установки и зарегистрируем его в splitInstallManager
            val listener = createListener(
                moduleName = moduleName,
                sessionId = sessionId,
                emitter = emitter
            )
            splitInstallManager.registerListener(listener)
            emitter.setCancellable { splitInstallManager.unregisterListener(listener) }
            // Создадим новый запрос на установку модуля
            val request = SplitInstallRequest
                .newBuilder()
                .addModule(moduleName)
                .build()
            // startInstall возвращает Task с идентификатором установки и ошибками установки
            splitInstallManager.startInstall(request)
                .addOnSuccessListener {
                    emitter.onNext(DynamicDeliveryProgress.Pending)
                    sessionId.set(it)
                }
                .addOnFailureListener {
                    emitter.onError(DynamicDeliveryInstallationException(moduleName, (it as? SplitInstallException)?.errorCode))
                }
        }
        .distinctUntilChanged()

private fun createListener(
    moduleName: String,
    sessionId: AtomicInteger,
    emitter: ObservableEmitter
) =
    SplitInstallStateUpdatedListener {
        if (it.sessionId() == sessionId.get()) {
            when (it.status()) {
                SplitInstallSessionStatus.PENDING ->
                    emitter.onNext(DynamicDeliveryProgress.Pending)
                SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION ->
                    // Требуется подтверждение от пользователя 
                    emitter.onNext(DynamicDeliveryProgress.RequiresConfirmation(it))
                SplitInstallSessionStatus.DOWNLOADING ->
                    emitter.onNext(
                        DynamicDeliveryProgress.Downloading(
                            totalBytes = it.totalBytesToDownload(),
                            currentBytes = it.bytesDownloaded()
                        )
                    )
                SplitInstallSessionStatus.DOWNLOADED,
                SplitInstallSessionStatus.INSTALLING ->
                    emitter.onNext(DynamicDeliveryProgress.Installing)
                SplitInstallSessionStatus.INSTALLED ->
                    emitter.onComplete()
                SplitInstallSessionStatus.CANCELED ->
                    emitter.onError(CancellationException())
                SplitInstallSessionStatus.CANCELING,
                SplitInstallSessionStatus.UNKNOWN,
                SplitInstallSessionStatus.FAILED -> Unit
            }
        }
    }

Со стороны UI не забудьте обработать состояние DynamicDeliveryProgress.RequiresConfirmation. После завершения установки необходимо снова вызвать SplitCompat.installActivity(this), чтобы загрузить ресурсы из загруженных APK-файлов.

В этой реализации мы никак не обрабатываем состояние INCOMPATIBLE_WITH_EXISTING_SESSION, так как все модули у нас устанавливаются по одному. Однако обработать эту ошибку можно довольно просто. При её возникновении в retryWhen можно создать Observable, который:


  1. Опять подпишется на обновления состояния через splitInstallManager.registerListener.
  2. Найдёт в splitInstallManager.getSessionStates сессию, в которой устанавливаются запрошенные модули.
  3. Дождётся завершения установки и отправит запрос на повторный вызов load(...).


Заключение

Технология Dynamic Delivery от Google позволяет загружать и удалять модули прямо во время работы приложения. Это отличный способ сэкономить место на устройстве: как правило, в приложении есть модули, которые используются редко; их можно подгружать в процессе работы приложения по необходимости.

Несмотря на не очень удобный API, всю работу по загрузке модулей вполне возможно скрыть за единым интерфейсом. Запрос на загрузку модулей Dynamic Delivery осуществить легко, но нужно быть внимательным при обработке её десяти различных состояний.

Есть ещё два момента, на которые стоит обратить внимание:


  1. Не забудьте после установки модуля оповестить текущую Activity, чтобы она получила доступ к загруженным ресурсам.
  2. Не забудьте запросить подтверждение пользователя через запуск специальной диалоговой Activity, если это необходимо.

Во второй части статьи я расскажу, как использовал Dynamic Delivery в одном из проектов Badoo.

© Habrahabr.ru