...

четверг, 5 марта 2020 г.

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

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

Я уже рассказывал, что такое Dynamic Delivery и какой у него API. В этой статье я подробнее опишу, как я использовал Dynamic Delivery в нашем приложении и почему интеграция оказалась такой лёгкой. В результате мне удалось уменьшить вес приложения на полмегабайта для 99% наших пользователей, превратив доступную для жителей определённого региона функцию в загружаемый модуль.


Bumble Brew

Мы развиваем два ключевых продукта: приложения Badoo и Bumble. Я занимаюсь разработкой Bumble.

В последнее время мы экспериментируем в области взаимодействия с пользователями в офлайне и добавили новый экран с QR-кодом, который может быть считан где-нибудь (в ресторане, кафе, магазине). Выглядит он так:


Логики на нём практически никакой нет, зато есть очень красивый фон. Красивый аж на 547 KБ в максимальном размере. Из-за своей специфики этот экран будет использоваться очень маленьким количеством пользователей, проживающих в городе, где проводится офлайн-встреча. А полмегабайта на свои устройства получат все. В общем, идеальный кандидат для Dynamic Delivery.


Подготавливаем модуль Brew

Для создания экранов мы в Badoo используем архитектуру RIBs. Наши статьи о ней выйдут позже, а пока для простоты понимания мы будем рассматривать отдельный RIB как Fragment, ведь идея у них общая: оба они — самодостаточные элементы экрана, с UI или без, которые могут быть встроены в другие элементы или использованы самостоятельно.

Для любого модуля экрана, будь то Fragment или RIB, верно следующее утверждение: у него есть публичный API, который описывает взаимодействие с этим экраном извне, и внутренний API, который нужен для функционирования этого экрана. Обычно мы сегрегируем публичный и приватный API с помощью модификаторов доступа. Однако можно пойти дальше и разделить их на два разных модуля: :components:BrewScreen:Interface и :components:BrewScreen:Implementation.

В первом модуле мы описываем, как работать с экраном и какие у него зависимости.

interface Brew : Rib {

    interface Dependency {
        val brewOutput: Consumer<Output>
        val uiScreen: UIScreen
        val hotpanelTracker: HotpanelTracker
        val customisation: Customisation
    }

    interface Customisation {
        @DrawableRes val background: Int
        @DrawableRes val logo: Int
    }

    sealed class Output {
        object Closed: Output()
    }
}

Весь публичный API умещается в четыре интерфейса, с которыми и будет работать модуль приложения. Dependency объявляет зависимости, которые нужны для работы данного экрана. brewOutput выступает в качестве функции обратного вызова для получения результатов с данного экрана, uiScreen — информация о том, что и как нужно отобразить на экране (экран не делает сетевой запрос сам, а получает результат выполнения сетевого запроса извне), hotpanelTracker — для аналитики. Customisation позволяет изменить внешний вид экрана, заменив, например, логотип и фон на брендированные. Такой подход позволяет намного легче адаптировать уже существующие экраны для других приложений. Output задаёт результаты выполнения экрана. В нашем случае экран можно только закрыть. Но в будущем может появиться новый тип события, например «открыть какой-то другой экран в приложении».

Во втором же модуле мы реализуем всю логику и UI экрана.


Все классы этого модуля — internal, кроме BrewBuilder. BrewBuilder — фабрика, которая создаст экземпляр RIB, который мы сможем использовать в дальнейшем.

class BrewBuilder(
    dependency: Brew.Dependency
) : Builder<Brew.Dependency>() {
    override fun build(savedInstanceState: Bundle?): Node<BrewView> =
        DaggerBrewComponent
            .factory()
            .create(
                dependency = dependency,
                savedInstanceState = savedInstanceState
            )
            .node()
}

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


DynamicDeliveryContainer

Поскольку все классы, унаследованные от RibBuilder, реализуют единый интерфейс Builder<Dependency>, можно создать обобщённый RIB-контейнер для всех динамически загружаемых экранов.

interface DynamicDeliveryContainer : Rib {
    interface Dependency {
        val dynamicDeliveryFeatureDataSource: DynamicDeliveryFeatureDataSource
        val childRibConfiguration: ChildRibConfiguration
    }

    class ChildRibConfiguration(
        // Имя модуля, который нужно загрузить
        val moduleName: String,
        // Имя класса фабрики, например com.badoo.mobile.brew.builder.BrewBuilder
        val ribBuilderClass: String, 
        val dependency: Lazy<Any>
    ) {
        fun build(saveInstanceState: Bundle?): Node<*> {
            val clazz = Class.forName(className)
            val builder = clazz.constructors[0].newInstance(dependency)
            return clazz.getMethod("build", Bundle::class.java).invoke(builder, saveInstanceState) as Node<*>
        }
    }
}

Реализация DynamicDeliveryContainer делает следующее:


  1. Спрашивает у DynamicDeliveryFeatureDataSource, реализация которого приведена в первой части, установлен ли модуль childRibConfiguration.moduleName.
  2. Если модуль установлен, то вызывает childRibConfiguration.build(bundle) и размещает созданный RIB внутри себя.
  3. Если модуль не установлен, то запрашивает установку модуля childRibConfiguration.moduleName и показывает какой-нибудь красивый UI.
  4. Когда модуль установлен, заменяет красивый UI на загруженный RIB.

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


Конфигурация Dynamic Feature Module

Dynamic Feature Module является обычным Gradle-модулем. Рассмотрим конфигурацию на нашем примере:

import com.badoo.ProjectHelper

apply plugin: 'com.android.dynamic-feature'
ProjectHelper.configureAndroidLibraryProject(project, true)
ProjectHelper.addKotlin(project)

android {
    defaultConfig {
        versionCode = findProperty('versionCode').toInteger()
        versionName = findProperty('versionName')
    }
}

dependencies {
    implementation project(':bumble:app')
    implementation project(':components:BrewScreen:Interface')
    implementation project(':components:BrewScreen:Implementation')
}

tasks.configureEach {
    if (it.name.matches(~/.*AndroidTest.*/)) {
        it.enabled = false
    }
}

Android Gradle plugin содержит новый плагин для таких модулей — com.android.dynamic-feature. Что важно, его использование практически не накладывает ограничений: вы можете спокойно переиспользовать скрипты настройки ваших модулей (ProjectHelper.configureAndroidLibraryProject).

Документация утверждает, что versionCode и versionName нужно не указывать, так как эти значения автоматически подтянутся из модуля приложения. Да, это так, вот только они могут быть закешированы. Я столкнулся с тем, что проект перестал собираться после изменения этих значений в модуле приложения. В итоге я решил вручную ставить версию, переиспользовав gradle.properties-файл приложения.

Зависимости, которые вы укажете, будут корректно обработаны плагином. В модуль будут включены только те, которых нет в основном модуле. В нашем случае это :components:BrewScreen:Implementation.

На этапе проверок CI я обнаружил, что androidTest не то что запуститься — даже собраться не может. По какой-то причине при попытке собрать модуль для тестов он вёл себя не как Dynamic Feature Module, а как обычный. Из-за этого возникало много ошибок на этапе слияния манифестов и ресурсов. Все тесты лежат либо в модуле приложения, либо в модуле экрана, а это значит, что даже пытаться запустить их не было смысла. Поэтому я отключил все задачи, связанные с androidTest.

tasks.configureEach {
    if (it.name.matches(~/.*AndroidTest.*/)) {
        it.enabled = false
    }
}

Манифест для такого модуля выглядит следующим образом:

<manifest xmlns:dist="http://schemas.android.com/apk/distribution"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.bumble.app.brew">

    <dist:module
        dist:instant="false"
        dist:title="@string/dynamic.delivery.feature.brew"
        tools:ignore="ManifestResource">
        <dist:delivery>
            <dist:on-demand />
        </dist:delivery>
        <dist:fusing dist:include="true" />
    </dist:module>

</manifest>

В нём мы настраиваем поведение модуля. В данном случае модуль не является Instant App, будет установлен по требованию (on-demand) и имеет полностью локализованное имя @string/dynamic.delivery.feature.brew. Последнее важно, так как это имя будет отображено в диалоге Google Play.


В данном случае локализованное имя модуля — Brew и оно используется в именительном падеже во всех языках, размер модуля был увеличен для теста

Также вы можете настроить установку этого модуля в тот момент, когда устанавливается само приложение. Это может быть полезно, когда вы делаете из экранов регистрации Dynamic Feature Module, чтобы после прохождения пользователем регистрации их удалить. dist:fusing отвечает за включение этого модуля в APK-файл для устройств на Android 4.4 и ниже, но такие мы уже не поддерживаем.

Android Gradle plugin сам заполнит поле split в манифесте именем Gradle-модуля из project.name. Именно это имя нужно использовать для загрузки модуля. Вы не можете изменить это имя, не изменив имя модуля.

Подробнее про все возможности вы можете прочитать в документации.

Итоговая структура Dynamic Feature Module получилась такой:


BrewCustomisationProvider отвечает за идентификатор картинки bg_brew.webp. Этот BrewCustomisationProvider мы создадим через рефлексию и используем при создании Dependency. Поскольку мы будем создавать Dependency после установки модуля, никаких проблем у нас не возникнет.

class BrewCustomisationProvider : () -> Brew.Customisation {
    override fun invoke(): Brew.Customisation = object : Brew.Customisation {
        override val background: Int = R.drawable.bg_brew
        override val logo: Int = com.bumble.app.R.drawable.ic_bumble_logo
    }

Предупреждение: при работе с Dynamic Feature Module следите за ресурсами. Те, которые находятся внутри модуля, вы должны получать через R-класс этого модуля. Те, которые лежат в модуле приложения, — через R-класса приложения (как я это делаю в случае с ic_bumble_logo). В противном случае вы будете получать ResourceNotFoundException. Оба варианта использования доступны из кода, так как aapt сгенерирует R-класс вместе с идентификаторами всех ресурсов из модуля приложения.


Конфигурация приложения

В первую очередь сконфигурируем модуль приложения.

android {
    dynamicFeatures = [':bumble:components:Brew']
}

dependencies {
    implementation project(':components:DynamicDeliveryContainer')
    implementation project(':components:BrewScreen:Interface')
}

В BaseAppModuleExtension появилось новое свойство dynamicFeatures, в котором необходимо перечислить все пути до подключаемых Dynamic Feature Modules.

К модулю приложения также подключим в качестве зависимостей DynamicDeliveryContainer, который осуществит загрузку модуля и отобразит его на экране, и BrewScreen:Interface, в котором объявлены только интерфейсы для работы с экраном.

Создадим класс, в котором будем хранить все константы для работы с Dynamic Feature Module. К ним относятся имя класса фабрики создания RIB, имя класса кастомизации интерфейса и имя модуля (как в Gradle).

object DynamicDeliveryRegistry {
    object Brew : Module {
        const val moduleName = "Brew"
        const val builderName = "com.badoo.mobile.brew.builder.BrewBuilder"
        const val customisationName = "com.bumble.app.brew.BrewCustomisationProvider"
    }
}

Поскольку эти классы используются через рефлексию, добавим их в ProGuard.

-keep class com.badoo.mobile.brew.builder.BrewBuilder {
    *;
}
-keep class com.bumble.app.brew.BrewCustomisationProvider {
    *;
}

Осталось создать экземпляр DynamicDeliveryContainer, передать в него нужные параметры и подключить к Activity.

// Создаём экземпляр BrewCustomisationProvider и вызываем его метод Invoke
fun createCustomisation() = 
    Class.forName(DynamicDeliveryRegistry.Brew.customisationName).also {
        val instance = it.getConstructor().newInstance()
        it.getMethod("invoke").invoke(instance)
    }

// Создаём DynamicDeliveryContainer и передаём в него конфигурацию дочернего динамически загружаемого RIB
val node = DynamicDeliveryContainerBuilder(
    object : DynamicDeliveryContainer.Dependency {
        override val dynamicDeliveryFeatureDataSource = DynamicDeliveryFeatureDataSource(context)
        override val childRibConfiguration =
            DynamicDeliveryContainer.ChildRibConfiguration(
                moduleName = DynamicDeliveryRegistry.Brew.moduleName,
                builderName = DynamicDeliveryRegistry.Brew.builderName,
                dependency = lazy {
                    object : Brew.Dependency {
                        override val uiScreen = uiScreen
                        override val hotpanelTracker = hotpanelTracker
                        override val brewOutput: Consumer<Output> = output
                        override val customisation = createCustomisation()
                    }
                }
            )
    }).build(it)

// Подключаем созданный DynamicDeliveryContainer к Activity и отображаем его на экране
activity.attachNode(node)

Приведённый выше код можно сделать менее громоздким, вынеся создание зависимостей в Dagger. Но описание Dagger-компонентов и модулей сделало бы наш код более запутанным, так что для простоты я создаю все зависимости на месте.

Bumble Brew доступен не всем пользователям, а только для тем, кто проживает в городе проведения мероприятия. Такие пользователи включаются в специальную группу. Список групп, в которых состоит пользователь, приложение получает при первом подключении к серверу, а значит, именно в этот момент мы можем сказать, будет показываться кнопка открытия экрана Brew или нет, и в этот же момент мы можем запросить отложенную установку модуля с помощью SplitInstallManager.deferredInstall.


Как я тесты сломал

В Badoo мы используем полноценные Е2Е-тесты. Тестовый инструментарий (в моём случае это Appium) устанавливает приложение и симулирует нажатия пользователя на экран. При этом само приложение использует актуальную версию бэкенда. Для установки приложения мы используем следующую функцию:

def bundletool_cmd(apk_file)
    [
      "ADBHOST=#{EnvVar.adb_server_host}",
      "ANDROID_ADB_SERVER_PORT=#{EnvVar.adb_server_port}",
      "java -jar #{BMA_ROOT}/shared/android_device_utils/tools/bundletool-all.jar",
      'install-apks',
      "--device-id #{EnvVar.adb_device_arg}",
      "--apks #{apk_file}",
    ].join(' ')
end

Для тестирования мы используем App Bundle, из которого создаём APKS и устанавливаем на устройство. Установка APKS производится с помощью команды install-apks. И если вы тоже используете bundletool в своих Е2Е-тестах, то не забудьте добавить параметр --modules _ALL_, чтобы установить все Dynamic Features сразу. У нас такого параметра не было, поэтому все тесты для Brew начали падать.

Как я упоминал выше, если приложение не установлено из Google Play, то оно не может устанавливать модули. Либо они все установлены сразу, либо нет. Поэтому подход с --modules _ALL_ не охватывает сценарий установки Brew модуля. В данный момент мы используем Internal App Sharing для тестирования Dynamic Features.

Однако недавно Google выпустила новую версию com.google.android.play:core, в которой появился FakeSplitInstallManagerFactory. По функционалу он не полностью аналогичен SplitInstallManager, но может повысить качество автоматизированного тестирования. Для того чтобы задать его поведение, доступны два параметра:


  1. setShouldNetworkError, после указания которого любая установка будет завершаться с ошибкой.
  2. modulesDirectory для указания папки на устройстве, откуда нужно устанавливать запрашиваемые модули. Сами же модули копируются в эту папку в виде APK-файлов.

Получить необходимые APK-файлы для установки можно следующей командой:

bundletool extract-apks
    --apks=/MyApp/my_existing_APK_set.apks
    --output-dir=/MyApp/my_pixel2_APK_set.apks
    --device-spec=/MyApp/bundletool/pixel2.json

Полученные APK-файлы нужно передать на устройство через adb и указать папку в FakeSplitInstallManagerFactory.

Хоть этот подход и приближен к ручному тестированию с помощью Internal App Sharing, для его использования понадобится отдельная сборка приложения, использующая FakeSplitInstallManagerFactory. Также придётся внести изменения в тестовый инструментарий. Так что, наверное, игра стоит свеч только тогда, когда динамических модулей становится больше.

Корректность рефлексии же можно проверять обычными юнит- и интеграционными тестами. В моем случае есть Е2Е- и интеграционный тесты, которые просто переходят с главного экрана на экран Brew.


Итоги

Представленная Google технология Dynamic Delivery позволяет загружать и удалять модули прямо во время работы приложения.

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

Когда все экраны приложения имеют схожую архитектуру и чётко обозначенные зависимости, для загрузки и отображения Dynamic Feature Module можно создать универсальный контейнер. С его помощью вы очень быстро сконвертируете в Dynamic Features те экраны приложения, которые редко используются юзерами, и таким образом ещё больше уменьшите вес приложения.

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий