В последнее время моя команда начала проходить испытание по модуляризации нашего приложения. Одна из первых вещей, за которые мы взялись основательно, была навигация, а точнее, как мы представляем навигацию верхнего уровня или модуль навигации в динамических модулях при настройке динамического модуля. Перед тем, как принимать любые быстрые решения, я хотел лучше разобраться в этой проблеме. Например, для начала попробовать сделать всё в меньшем масштабе, а поэтому я решил использовать свой любимый проект как полигон.
В своей статье я опишу результаты этого небольшого опыта и некоторые шаблоны навигации, которые, по-моему, хорошо сработали для переходов между направлениями, собранными внутри динамических модулей. Не буду раскрывать до мелочей. Если вы захотите узнать больше, то почитайте вот здесь.
Когда я говорю о навигации верхнего уровня, я имею в виду разные точки назначения, куда может попасть пользователь с начального экрана приложения (например,BottomNavigationView
).
Обычно это экран с иерархией, где родительский элемент отображает общие компоненты, такие как BottomNavigationView
, DrawerLayout
, Toolbar
и т.д., а дочерний элемент показывает актуальный контент точки назначения. В потоке этого типа должна быть определенная плавность в условиях UX — общий компонент должен оставаться на месте. А пока содержимое заменяется, предпочтительно добавить какую-то симпатичную анимацию. Это модно сделать единоразовой установкой. Но что, если фрагмент назначений верхнего уровня в этом потоке находится в модулях динамического модуле? Мы совсем скоро ответим на этот вопрос.
Когда начинаешь думать о модульности, отношения зависимости между модулями, которые приходят на ум интуитивно, выглядят так:
Модуль приложения → Модули содержимого→ Общие библиотекиТо есть главный модуль приложения знает обо всех динамических модулях. Они полностью изолированы и работают без зависимостей от других динамических модулей или модуля главного приложения. С таким расположением модуль приложения может сшить воедино навигацию верхнего уровня при помощи доступа к API динамических модулей.
Тем не менее динамические модули переворачивают отношения зависимостей с ног на голову — главный модуль приложения может не зависеть от модулей динамического содержимого, а вот модули динамического содержимого должны зависеть от модуля приложения, например, вот так:
Модули содержимого →Модуль приложения→ Общие компонентыМодули динамического содержимого также могут быть установлены по запросу. Это значит, что они могут не входить в APK, который пользователь скачивает изначально, но могут быть установлены во время выполнения. А следовательно, такая установка выносит некоторые новые проблемы:
Один из вариантов — это, конечно, пользоваться рефлексией. Вообще, когда вам что-то понадобилось от модуля динамического контента, можно решить задачу с помощью рефлексии. Хотя это выглядит не слишком привлекательно.
Другой подход, который мне нравится больше — определение динамического содержимого через открытые интерфейсы в общем библиотечном модуле и загрузка их актуальных реализаций (располагаются в модулях динамического содержимого) во время выполнения с ServiceLoader
.
Если вы пользуетесь ServiceLoader
, то обычно этому сопутствует удар по производительности Android. Причина в том, что нужно делать рефлексивный поиск во время выполнения. Однако эту проблему можно решить, если пользоваться R8 с включенной функцией сокращения кода. Как поясняется в документации к ServiceLoaderRewriter
(часть исходного кода R8):
ServiceLoaderRewriter
попробует переписать вызовы в форме: ServiceLoader.load(X.class, X.class.getClassLoader()).iterator()
… на Arrays.asList(new X[] { new Y(), …, new Z() }).iterator()
для классов Y..Z
определённых в META-INF/services/X
.
Очень изящно. Единственный недостаток этого — нужно вручную добавлять определения службы в META-INF/services/
. Что это значит? То, что если у нас есть интерфейс определения службы VideoFeature
и его реализация называетсяVideoFeatureImpl
, то нам следует добавить регистрационный файл, чтобы запустить поиск для ServiceLoader
, например так:
В этом файле есть полное имя класса реализации для этого случая:
com.jeppeman.jetpackplayground.video.platform.VideoFeatureImplОшибка такой практики кроется в чувствительности к рефакторингу. Мы можем избавиться от этого неудобства, если применим кое-что от Google: AutoService
— это процессор аннотаций, который сканирует проект на предмет классов, связанных с @AutoService
. Для любого обнаруженного класса автоматически генерируется файл определения служб. Так значительно лучше, теперь нам больше не нужно создавать их самим. Мы сможем провести рефакторинг и свободно переместить классы наших служб, не беспокоясь о прерывании поисков для ServiceLoader
.
А здесь я покажу, как выглядит определение динамического содержимого и общего модуля библиотеки в проекте:
// Все определения фич расширяют этот интерфейс, T - это зависимости, которые требует динамическая фича.
interface Feature<T> {
fun getMainScreen(): Fragment
fun getLaunchIntent(context: Context): Intent
fun inject(dependencies: T)
}
interface VideoFeature : Feature<VideoFeature.Dependencies> {
interface Dependencies {
val okHttpClient: OkHttpClient
val context: Context
val handler: Handler
val backgroundDispatcher: CoroutineDispatcher
}
}
Метод getMainScreen()
нужен для получения входной точки содержимого UI. Это можно применять в экране с иерархией— скоро мы к нему вернемся. Метод inject()
нужен для ввода любых зависимостей, необходимых содержимому, чтобы работать корректно. Его можно подключить при помощи Dagger
. Если вам любопытно узнать побольше об установке Dagger
, то вы можете взглянуть на сам проект.
Интерфейс Feature
реализуется в модуле динамического содержимого вот таким образом:
internal var videoComponent: VideoComponent? = null
private set
@AutoService(VideoFeature::class)
class VideoFeatureImpl : VideoFeature {
override fun getLaunchIntent(context: Context): Intent = Intent(context, VideoActivity::class.java)
override fun getMainScreen(): Fragment = createVideoFragment()
override fun inject(dependencies: VideoFeature.Dependencies) {
if (videoComponent != null) {
return
}
videoComponent = DaggerVideoComponent.factory()
.create(dependencies, this)
}
Способ, по которому мы получаем актуальный экземпляр функции, реализуется через выше упомянутый ServiceLoader
. Он становится проще, потому что класс находится в модуле общей библиотеки FeatureManager
. Изначально он отвечает за установку модулей динамических функций (скоро об этом будет подробнее), как и за предоставление экземпляров содержимого. А вот как выглядит код для получения экземпляра содержимого:
inline fun <reified T : Feature<D>, D> FeatureManager.getFeature(
dependencies: D
): T? {
return if (isFeatureInstalled<T>()) {
val serviceIterator = ServiceLoader.load(
T::class.java,
T::class.java.classLoader
).iterator()
if (serviceIterator.hasNext()) {
val feature = serviceIterator.next()
feature.apply { inject(dependencies) }
} else {
null
}
} else {
null
}
}
И еще одно замечание о ServiceLoader
до того, как мы пойдем дальше. Чтобы оптимизация R8 правильно выполнила свою часть работы,нужно выполнить следующие три условия:
load()
с двумя аргументами..class
в Java или ::class.java
в Kotlin.ServiceLoader
, кроме iterator()
.Вот почему функция getFeature
должна быть inline
и у нее должен быть параметр типа reified
для класса функции — компилятор Kotlin добавит в линию тело метода в месте вызова и заменит T::class.java
постоянной класса, используемой в качестве аргумента типа, например, VideoFeature::class.java
.
Теперь мы знаем, как получить доступ к входной точке UI модуля динамического содержимого , но только после установки. Мы не получим доступ к коду без установки. Это очевидно.
Лучший способ протестировать поведение модулей динамического содержимого — это выгрузить бандл во внутреннюю тестовую дорожку на Google Play. Также можно использовать bundletool
, чтобы локально проверить определенные ситуации. Пользуйтесь Play Store, если хотите получить максимальную точность.
Play Core Library предоставляет API для того, чтобы загрузить и установить динамическое содержимое APK во время выполнения без необходимости перезапускать приложение, так что мы можем продолжать делать тоже самое и условно перемещаться, вне зависимости от того, установлено содержимое или нет. А если не установлено, индикатор отображается, пока мы загружаем и производим установку. Привожу пример кода из проекта, который делает описанное:
class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemSelectedListener {
...
override fun onNavigationItemSelected(item: MenuItem): Boolean {
if (item.itemId == bottomNavigation?.selectedItemId) {
return false
}
val isInstalled = mainViewModel.isFeatureInstalled(item.itemId)
return if (!isInstalled) {
launchInstallDialog(item.itemId)
false
} else {
goToFeatureEntryPoint(item.itemId)
true
}
}
override fun onCreate(savedInstanceState: Bundle?) {
...
bottomNavigation?.setOnNavigationItemSelectedListener(this)
...
}
На гифке видна навигация к модулю динамического содержимого, который еще не был установлен в потоке иерархии:
Навигация верхнего уровня с модулями динамического содержимого по запросу в качестве пунктов назначенияС учётом этого мы понимаем, что можно реализовать навигацию верхнего уровня и гарантировать пользователю отсутствие проблем во время работы с приложением (соответственно, предположительно диалог установки может быть менее навязчивым). Для этого нужны шаблоны доступа к звуку для пунктов назначения, которые находятся в модулях динамического содержимого.
Определенно, это может быть случай, когда содержимое не укладывается нужным способом в иерархический поток — и тогда может понадобится более тщательный контроль активности, чтобы все функционировало корректно, или собственный внутренний поток с иерархией. Заметьте, что интерфейс Feature
также предоставляет метод getLaunchIntent()
, который нужен для того, чтобы обеспечить возможность запустить содержимое в режиме “полностью в песочнице”, если нужна такая форма активности. Это можно использовать следующим образом:
val feature = featureManager.getFeature<VideoFeature,
VideoFeature.Dependencies>(dependencies)
val featureIntent = feature.getLaunchIntent(context)
context.startActivity(featureIntent)
Еще один элегантный способ навигации во время установки — применить App Links (так называемые глубокие линки). Мы можем линковать разные части нашего приложение в режиме REST, например https://jeppeman.com/video
открывает видео-контент. Также у этой штуки есть приятное преимущество: она позволяет слинковать точки назначения напрямую с веб-сайта в тех случаях, когда пользователь работает с устройством на Android.
Тем не менее, как вы могли верно догадаться, есть проблема с App Links. Она возникает, когда есть конъюнкция с модулями динамического содержимого. Если мы декларируем intent-фильтр для того, чтобы управлять линками приложения в AndroidManifest.xml
модуля динамического содержимого, а после этого пробуем открыть линк приложения, который совпадает с нашим intent-фильтром до того, как модуль динамического содержимого будет установлен, происходит следующее:
2019-11-08 11:42:58.576 10768-10768/? E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.jeppeman.jetpackplayground, PID: 10768
java.lang.RuntimeException: Unable to instantiate activity ComponentInfo{com.jeppeman.jetpackplayground/com.jeppeman.jetpackplayground.video.presentation.VideoActivity}: java.lang.ClassNotFoundException: Didn't find class "com.jeppeman.jetpackplayground.video.presentation.VideoActivity" on path: DexPathList[[zip file "/data/app/com.jeppeman.jetpackplayground-Kc1tJT42xngJzSabLmLazA==/base.apk", zip file "/data/app/com.jeppeman.jetpackplayground-Kc1tJT42xngJzSabLmLazA==/split_config.en.apk", zip file "/data/app/com.jeppeman.jetpackplayground-Kc1tJT42xngJzSabLmLazA==/split_config.ko.apk", zip file "/data/app/com.jeppeman.jetpackplayground-Kc1tJT42xngJzSabLmLazA==/split_config.sv.apk", zip file "/data/app/com.jeppeman.jetpackplayground-Kc1tJT42xngJzSabLmLazA==/split_config.xxhdpi.apk", zip file "/data/app/com.jeppeman.jetpackplayground-Kc1tJT42xngJzSabLmLazA==/split_home.apk", zip file "/data/app/com.jeppeman.jetpackplayground-Kc1tJT42xngJzSabLmLazA==/split_home.config.xxhdpi.apk"],nativeLibraryDirectories=[/data/app/com.jeppeman.jetpackplayground-Kc1tJT42xngJzSabLmLazA==/lib/arm64, /system/lib64]]
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2998)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3235)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:78)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:108)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:68)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1926)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:214)
at android.app.ActivityThread.main(ActivityThread.java:6986)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:494)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1445)
adb shell am start -W -a android.intent.action.VIEW -d "http://jeppeman.com/video" before dynamic feature is installed
Причина всего этого — декларация активности из модуля динамического содержимого слилась с основным манифестом, а актуальное динамическое содержимое APK еще не установлено.
Чтобы разрешить проблему, мы можем получить централизованное управление линками приложения в главном модуле, например, вот так:
class AppLinkActivity : AppCompatActivity() {
...
private fun handleAppLink(uri: Uri) {
val feature = uri.pathSegments.firstOrNull()
if (feature != null) {
if (appLinkViewModel.isFeatureInstalled(feature)) {
launchFeature(feature)
} else {
createInstallDialogFragment(feature).show(supportFragmentManager, "install")
}
} else {
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent?.data?.let(::handleAppLink) ?: finish()
}
...
}
AppLinkActivity
Немного упрощаем: мы попали в сегмент первого пути с названием содержимого, а затем определяем условие навигации в зависимости от того, установлен ли модуль динамического содержимого или нет. Если нет, то запускаем диалог инсталлятора. А вот и результат выполнения: adb shell am start -W -a android.intent.action.VIEW -d "http://jeppeman.com/video"
. Это до установки видео-содержимого:
Спасибо за внимание!
Перевод статьи Jesper Åman: Navigation with Dynamic Feature Modules
Комментарии