Уменьшаем размер приложения на Android с помощью Dynamic delivery

Обложка: Dynamic delivery
Азамат Черчесов
Азамат Черчесов
Senior Mobile Developer

В мобильной разработке не первый год актуальна концепция мультифункционального приложения (Super app). Она имеет много преимуществ, но зачастую пользователя интересует лишь часть функционала. А остальные фичи остаются невостребованными и занимают место на устройстве. Создание единого большого приложения ведёт ещё и к увеличению объёма, что негативно отражается на количестве скачиваний.

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

В этой статье я хочу раскрыть вопрос миграции фич в динамические модули на примере нашего флагманского приложения на Android. Расскажу о влиянии Dynamic delivery на архитектуру, о возможных сценариях миграции существующих фич, о сложностях, с которыми я столкнулся и о результатах, которые мы получили.

  1. Коротко о Dynamic feature delivery
  2. Влияние Dynamic delivery на архитектуру нашего приложения
  3. Миграция существующих фич
  4. Результаты
  5. Заключение
  6. Дополнительные материалы

Коротко о Dynamic feature delivery

Dynamic delivery — технология от Google, которая позволяет выделить из монолитного приложения фичи, и устанавливать и удалять их прямо во время выполнения программы.

Если вы только начали знакомство с Dynamic delivery, то рекомендую начать с ссылок, которые я собрал в конце статьи.

После ознакомления с этим материалом вы сможете лучше понять принцип работы Dynamic delivery, ознакомиться с предоставляемым API, найти пример с использованием архитектуры RIBs. Однако, не во всех проектах используется эта архитектура. Поэтому я дополню подборку источников и расскажу, как у нас в мобильном штабе «Лаборатории Касперского» происходила миграция существующего кода в динамический модуль.

Влияние Dynamic delivery на архитектуру нашего приложения

Как была построена многомодульность

Начну с короткого описания нашей архитектуры (более подробное можно найти по ссылкам в конце статьи). В нашем проекте Kaspersky Internet Security для Android есть module-injector, который содержит базовые интерфейсы:

interface ComponentHolder { 
    fun init(dependencies: D) 
    fun get(): C
    fun reset() 
} 

interface BaseDependencies

interface BaseAPI

BaseDependencies используется для перечисления объектов, которые требуются фиче на вход (зависимости фичи). BaseApi — для перечисления объектов, которые фича предоставляет наружу (внешний интерфейс фичи). ComponentHolder нужен для связки BaseDependencies. BaseApi — позволяет получить реализацию BaseApi конкретной фичи, передав все необходимые зависимости.

Для лучшего понимания рассмотрим эти три интерфейса на примере фичи Security news. Эта фича позволяет получать актуальные новости безопасности. В качестве входных зависимостей  у неё будет логин в формате строки. А наружу она предоставит интерактор с методом проверки новостей:

interface SecurityNewsFeatureDependencies: BaseDependencies {
	val account: String
}

interface SecurityNewsFeatureApi: BaseAPI {
	val securityNewsInteractor: SecurityNewsInteractor
}

Связующим звеном выступает SecurityNewsFeatureComponentHolder:

object SecurityNewsFeatureComponentHolder: ComponentHolder<SecurityNewsFeatureApi, SecurityNewsFeatureDependencies> {

	@Volatile
	private var securityNewsFeatureApi: SecurityNewsFeatureApi? = null

	@Synchronized
	override fun init(dependencies: SecurityNewsFeatureDependencies) {
		if (securityNewsFeatureApi == null) {
			securityNewsFeatureApi = createApi(dependencies)
		}
	}

	override fun get(): SecurityNewsFeatureApi {
		checkNotNull(securityNewsFeatureApi)
		return securityNewsFeatureApi
	}

	override fun reset() {
		securityNewsFeatureApi = null
	}

	private fun createApi(dependencies: SecurityNewsFeatureDependencies): SecurityNewsFeatureApi {
		…
	}
}

Таким образом, чтобы получить инстанс интерактора в основном модуле, необходимо в метод init объекта SecurityNewsFeatureComponentHolder передать зависимости фичи.

В проекте присутствует и альтернативный вариант базового ComponentHolder — LazyComponentHolder, который создаёт инстанс API фичи только при первом обращении. Реализацию его в статье приводить не буду, чтоб не усложнять материал.

Если в проекте используется Dagger, то в теле метода createApi будет обращение к dagger-компоненту фичи. Однако, такая тройка интерфейсов не обязывает использовать тот или иной DI-инструмент.

@Component( 
	modules = [SecurityNewsFeatureModule::class], 
	dependencies = [SecurityNewsFeatureDependencies::class] 
) 
interface SecurityNewsComponent : SecurityNewsFeatureApi { 

	val secNewsPresenter: SecNewsPresenter	

	@Component.Factory 
	interface Factory { 
		fun create(securityNewsFeatureDependencies: SecurityNewsFeatureDependencies): SecurityNewsComponent 
	}
}

В приведённом выше коде SecurityNewsComponent расширяет интерфейс SecurityNewsFeatureApi презентором SecNewsPresenter. Он объявлен в интерфейсе SecurityNewsComponent, а не в SecurityNewsFeatureApi, потому что используется в коде внутри модуля фичи, а не внутри модуля App. Чтобы внутри фичи можно было получить SecNewsPresenter, изменим код SecurityNewsFeatureComponentHolder:

object SecurityNewsFeatureComponentHolder: ComponentHolder<SecurityNewsFeatureApi, SecurityNewsFeatureDependencies> {

	@Volatile
	private var securityNewsComponent: SecurityNewsComponent? = null

	internal fun getSecurityNewsComponent(): SecurityNewsComponent {
		checkNotNull(securityNewsComponent)
		return securityNewsComponent
	}

	@Synchronized
	override fun init(dependencies: SecurityNewsFeatureDependencies) {
		if (securityNewsComponent == null) {
			securityNewsComponent = createComponent(dependencies)
		}
	}

	override fun get(): SecurityNewsFeatureApi {
		checkNotNull(securityNewsComponent)
		return securityNewsComponent
	}

	override fun reset() {
		securityNewsComponent = null
	}

	private fun createComponent(dependencies: SecurityNewsFeatureDependencies): SecurityNewsComponent {
		return DaggerSecurityNewsComponent
			.factory().create(dependencies)
	}
}

Как можно заметить, теперь мы держим ссылку типа SecurityNewsComponent, а не SecurityNewsFeatureApi. Появился метод getSecurityNewsComponent, который поможет получить SecNewsPresenter внутри кода фичи. В итоге внутри модуля фичи происходит обращение к методу getSecurityNewsComponent, а вне его — к методу get.

Влияние Dynamic delivery на архитектуру приложения

В случае обычных приложений главный модуль App знает обо всех фича-модулях. В случае Dynamic delivery динамическая фича зависит от модуля App, которыйничего не знает о модуле динамической фичи и работает с ней через ReflectionAPI. Подробнее можно почитать в документации.

Иллюстрация: Dynamic delivery

Cхема зависимости модулей друг от друга

Описанные выше ограничения Dynamic delivery подталкивают нас изменить код внутри модуля фичи. Начнём с разделения модуля на API и реализацию.

Иллюстрация: уменьшаем размер Android-приложения

Схема зависимости модулей с учётом Dynamic feature delivery

Такое разделение следует сделать для создания общего для App и Dynamic feature impl-модуля, куда можно положить классы и интерфейсы, на которые ссылаются в коде оба модуля.

В Dynamic feature API выносим SecurityNewsFeatureDependencies, SecurityNewsFeatureApi и все интерфейсы и классы, которые используются в них (в нашем случае — SecurityNewsInteractor). Однако, мы не можем вынести SecurityNewsFeatureComponentHolder — он связан с сущностями фичи, которые должны остаться в том же модуле. Если обращений к SecurityNewsFeatureComponentHolder будет несколько, то увеличится количество использования ReflectionAPI в коде (например, через ReflectionAPI нужно будет вызвать метод init, чтобы проинициализировать фичу, а затем — get, чтобы получить внешний интерфейс фичи).

Для упрощения кода можно уменьшить количество доступных только через Reflection API методов до одного (оставить только метод инициализации init). Для этого в module-injector создаём абстрактный класс ApiHolder:

abstract class ApiHolder {
	@Volatile 
	private var api: Api? = null 

	fun setApi(api: Api) {
		this.api = api
	}

	fun getApi(): Api {
		checkNotNull(api)
		return api
	}

	fun reset() { 
		api = null 
	}
}

В модуле FeatureSecurityNewsApi создаём объект SecurityNewsApiHolder:

object SecurityNewsApiHolder: ApiHolder()

В модуле FeatureSecurityNewsImpl меняем код в SecurityNewsFeatureComponentHolder:

object SecurityNewsFeatureComponentHolder: ComponentHolder<SecurityNewsFeatureApi, SecurityNewsFeatureDependencies> {

	internal fun getSecurityNewsComponent(): SecurityNewsComponent {
		return SecurityNewsApiHolder.getApi() as SecurityNewsComponent
	}

	@Synchronized
	override fun init(dependencies: SecurityNewsFeatureDependencies) {
		if (SecurityNewsApiHolder.getApi() == null) {
			val securityNewsComponent = createComponent(dependencies)
			SecurityNewsApiHolder.setApi(securityNewsComponent)
		}
	}

	private fun createComponent(dependencies: SecurityNewsFeatureDependencies): SecurityNewsComponent {
		return DaggerSecurityNewsComponent
			.factory().create(dependencies)
	}
}

Таким образом, сперва через ReflectionAPI нужно вызвать метод инициализации у SecurityNewsFeatureComponentHolder, в результате чего будет создан SecurityNewsComponent, а ссылка на компонент сохранится в поле объекта FeatureSecurityNewsApi. После инициализации внутри модуля фичи будет происходить обращение к методу getSecurityNewsComponent объекта SecurityNewsFeatureComponentHolder, а вне модуля — к методу getApi объекта SecurityNewsApiHolder.23ц

Миграция существующих фич

Мы успешно адаптировали архитектуру многомодульного приложения под Dynamic delivery. Следующим шагом применим один из нескольких сценариев включения фичи в состав приложения. Исходя из официального описания, существует 3 сценария:

  • при установке (install-time delivery);
  • при установке с определёнными фильтрами (conditional delivery);
  • по запросу (on demand delivery).

Второй сценарий позволяет исключить из приложения набор фич по заданным признакам (например, страна скачивания или характеристики девайса). Третий — исключить модуль из приложения для всех пользователей с возможностью в дальнейшем его скачать.

Рассмотрим сценарий, когда фича Security news была частью приложения, а теперь мы хотим её сделать динамически подгружаемой по требованию. Если обычный gradle-модуль сделать сразу динамически подгружаемым, то фича в следующем релизе исчезнет у пользователя, который получит обновление. Это может иметь негативные последствия как поведенческого характера, так и юридического. Если это не критично, можно явно пользователя попросить скачать фичу снова. Если по каким-то причинам важно сохранить фичу на девайсах тех, к кому она уже попала (например, если за неё уже заплатили), то нужно проработать процесс миграции. Рассмотрим возможные сценарии миграции от простого к сложному.

1 вариант

Выше мы рассмотрели 3 сценария включения фичи в состав приложения. Чтобы сохранить фичу у пользователя, можно сперва gradle-модуль превратить в динамическую фичу, доступную в момент установки (сценарий install time), а после этого сделать её подгружаемой по требованию (сценарий on-demand delivery). В этом варианте получаем 3 релиза приложения:

  • релиз A содержит фичу как обычный gradle модуль;
  • релиз B содержит фичу как Dynamic module с dist-параметром install-time;
  • релиз C содержит фичу как Dynamic module с dist-параметром on-demand.

В этом случае, при миграции с релиза B на релиз C фича не пропадёт с устройства пользователя. Однако, такой вариант миграции всё равно сохраняет риск потери фичи: если пользователь получит релиз A, не получит релиз B, а затем получит релиз C.

2 вариант

Очевидно, что проблему с неполучением промежуточного релиза можно решить увеличением количества этих промежуточных релизов. То есть между релизами A и C из первого варианта выпустить промежуточные: B1, B2, B3… Если пользователь не получил релиз B1, он сможет получить B2 или B3.

Этот вариант тоже не даст гарантированный результат сохранения фичи у всех пользователей. Однако, поможет уменьшить процент затронутых пользователь.

3 вариант

В этом варианте миграции можно запустить скачивание фичи в фоне при её отсутствии после получения обновления. Загрузка модуля не по запросу пользователя может завершиться обрабатываемой ошибкой:

exception com.google.android.play.core.splitinstall.SplitInstallException: Split Install Error(-7): Download not permitted under current device circumstances (e.g. in background). (https://developer.android.com/reference/com/google/android/play/core/splitinstall/model/SplitInstallErrorCode.html#ACCESS_DENIED).

Я не нашёл в официальной документации упоминание чётких критериев, когда фоновая загрузка может закончиться неудачей и попросить от пользователя явного подтверждения.

По личному опыту, я смог больше 10 раз поставить фичу в фоне, прежде чем получить ошибку (10 раз провести последовательность действий: ставить apk, скачивать в фоне фичу, удалять apk). Только после получения ошибки пришлось зайти в приложение и явно подтверждать скачивание. Вдобавок расходовать трафик на скачивание фичи без его ведома может быть плохим решением.

Этот вариант тоже не даёт гарантию. Потребуется явное согласие пользователя: нужно будет попросить его подтвердить скачивание. Это 4-й вариант.

4 вариант

В случае неудачной попытки скачать отсутствующую фичу в фоне показать пользователю уведомление с пояснением, что фича отсутствует на девайсе. Пользователь должен будет зайти в приложение и подтвердить скачивание. Однако, он может либо не увидеть нужное уведомление, либо отключить уведомления от приложения.

5 вариант

Google предоставляет возможность отложенной установки (deferred install). Такой вариант не даёт контроля над установкой. В официальной документации можно найти такую фразу: best-effort when the app is in the background. Практика показывает, что скачивание происходит при получении и установке следующего обновления. Таким образом, пользователь может пробыть без фичи какое-то время до получения обновления.

6 вариант

Если важно сохранить какой-то функционал у всех пользователей, можно ядро фичи оставить в приложении, а опциональную часть и тяжёлые ресурсы вынести в динамическую фичу, подгружаемую по требованию. В случае фичи Security news в состав ядра можно включить интерактор, который останется в приложении и продолжит мониторить и получать свежие новости. А всю логику, связанную с UI, — загружать отдельно по требованию.

Для каждой фичи должен проводиться отдельный анализ риска исчезновения на девайсах пользователей. И решение о том или ином сценарии миграции должно применяться исходя из результатов анализа.

Результаты

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

Стоить отметить, что Dynamic delivery возможен только с использованием AppBundle, который сам по себе даёт хорошую оптимизацию. В нашем случае переход на AppBundle помог уменьшить размер приложения на разных устройствах в среднем на 16%.

Дальнейшее выделение динамических фич из приложения поможет больше сократить размер. Приложение можно распаковать и оценить ожидаемый результат. Можно оптимизировать размеры:

  • dex-файлов вынесением части кода в динамические модули (в том числе вынесением больших используемых библиотек, которые нужны только этой фиче);
  • папок res и assets вынесением картинок и других файлов в динамические модули;
  • папки lib (например, тяжёлые нативные .so-файлы);
  • бинарных ресурсов вынесением большого количества строк и идентификаторов в динамические модули.

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

Следует помнить, что вынесение определённых фич может негативно отразиться на конверсиях в покупки, подписки и так далее.

Заключение

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

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

Надеюсь, статья была полезна для вас. Делитесь в комментариях своими результатами и мнениями об этой возможности от Google.

Дополнительные материалы