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

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

Навигация верхнего уровня

Когда я говорю о навигации верхнего уровня, я имею в виду разные точки назначения, куда может попасть пользователь с начального экрана приложения (например,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, например так:

Регистрация VideoFeatureImpl

В этом файле есть полное имя класса реализации для этого случая:

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

Еще один элегантный способ навигации во время установки — применить 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". Это до установки видео-содержимого:

App links

Спасибо за внимание!

Читайте также:


Перевод статьи Jesper Åman: Navigation with Dynamic Feature Modules

Предыдущая статья4 лучших шаблона для написания простого кода
Следующая статьяКак вернуть былой энтузиазм к программированию