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

По всем правилам приличия представлюсь — меня зовут Перевалов Данил, а теперь давайте перейдём к теме.

Проблема 

Для начала стоит понять, для чего нужны глобальные события. Почему бы просто не вызвать нужный метод, не заморачиваясь с событиями? Рассмотрим несколько сценариев, в которых будут проявляться негативные последствия от привычного нам подхода с прямым вызовом нужных методов в определённой точке. Дабы лучше прочувствовать причины, рассмотрим три сценария в порядке от самого простого, но менее явного — до более сложного, но максимально проявляющего проблему. И первым примером будет привычная всем инициализация приложения.

Инициализация приложения

У нас есть класс Application, и у него есть метод onCreate. Это и будет точкой инициализации нашего приложения. Множество библиотек и компонентов приложения хотят инициализировать себя либо выполнить какое-либо действие именно в этой точке. Это приводит к тому, что по мере роста проекта количество строчек кода и логики в Application.onCreate начинает превышать все разумные пределы. 

class CianApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        someClass1.someMethod()
        someClass2.someMethod()
        someClass3.someMethod()
        someClass4.someMethod()
        .......................................
    }
}

Проблема тут даже не в количестве кода и логики, а в количестве связей с другими компонентами приложения у нашего бедного класса Application, то есть в количестве его зависимостей. 

Но можно подумать: «Да и ничего страшного, это ведь точка входа в приложение, именно этим она и должна заниматься». И действительно, если в вашем проекте всего один так называемый главный модуль, то ничего страшного не случилось. Некрасивый и большой получается класс Application, и что с того? 

Справедливо, но если у вас несколько главных модулей или есть демоприложения, то, в очередной раз копируя код из одного Application класса в другой, у вас однозначно возникнут мысли, что, вообще-то в этом подходе явно что-то не так. 

Рассмотрим другой сценарий.

Инициализация UI

Очевидно, что не все компоненты и библиотеки стоит инициализировать в Application.onCreate. Ведь он срабатывает не только при открытии приложения через его иконку, но и при приходе пуша, срабатывании WorkManager и т. п. Логично, что компоненты и библиотеки, завязанные на UI, инициализировать в Application как-то не очень практично, учитывая, что UI может даже не запуститься. 

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

class StartActivity : Activity() {

    override fun onCreate() {
        super.onCreate()
        someClass1.someMethod()
        someClass2.someMethod()
        someClass3.someMethod()
        someClass4.someMethod()
        .......................................
    }
}

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

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

Авторизация и выход из аккаунта

Я думаю, у многих в приложении есть какая-либо авторизация. Пользователь авторизуется, а дальше просто пользуется приложением. Постепенно разные компоненты начинают что-либо хранить в базе данных, в Shared Preferences или банальных кешах. 

И раз есть авторизация, то есть и выход из аккаунта. 

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

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



В данном случае такую структуру кода прям очень тяжело назвать логичной. Если ничего не предпринимать, то компонент авторизации превратится в эдакий God Object. Так ещё и с огромным количеством зависимостей. 

Зацепление и связность

Я неоднократно, как выжившая из ума соседка, повторяю: «Много зависимостей! Много зависимостей!». Много зависимостей, и что? Чем это плохо?

Есть два таких термина:

  • Связность — степень взаимодействия и взаимосвязанности элементов внутри одного компонента.

  • Зацепление — степень взаимодействия и взаимосвязанности между разными компонентами приложения.

Так вот хорошим показателем является высокий уровень связности и низкий уровень зацепления. 

Во всех трёх примерах выше у нас как раз таки высокий уровень зацепления, что, как гласит Wikipedia, не очень хорошо. 

Так почему же? 

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

Да и отключить или удалить какой-то из компонентов при высоком зацеплении очень тяжело. Хотя это, откровенно говоря, не очень частый сценарий, нужный в основном в демоприложениях и dynamic-feature

Сейчас для уменьшения зацепления в Android-сообществе самым популярным инструментом является разбиение на модули. Но, очевидно, он не единственный.

Решение 

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

Это немного улучшит ситуацию, у нас хотя бы компонент авторизации не будет наполнен ненужными ему логикой и кодом. Но в остальном-то то же самое. У нас всё ещё есть God Object, делающий и знающий слишком много. И мы всё так же зацеплены авторизацией на кучу компонентов, пусть и не напрямую, а транзитивно через обёртку в виде LogoutManager.

Ладно, давайте улучшим наше решение. Пусть каждый компонент, которому нужно что-либо сделать, на выход из аккаунта предоставляет какой-нибудь ComponentLogoutManager. Мы вызовем каждый из них из общего LogoutManager, и пусть уже внутри своего ComponentLogoutManager компонент сам разбирается, что ему делать. 

Это ещё немного улучшит ситуацию. В каждом из компонентов у нас стал выше уровень инкапсуляции и сокрытия. Наш общий LogoutManager уже не делает кучу действий и перестал быть God Object. 

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

Надо думать дальше… По сути, у нас основная проблема в том, что LogoutManager имеет много зависимостей. Почему бы не попробовать инвертировать все эти зависимости. 

Пусть лучше каждый из компонентов знает о LogoutManager. Тогда компонент, отвечающий за выход из аккаунта, просто сообщает: «Ребята, выход из аккаунта произошёл. Реагируйте на это». 

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

За счёт этого уровень зацепления стал гораздо ниже, у нас больше нет компонента, который бы знал о куче других компонентов. Правда, у нас появился компонент LogoutManager, о котором знает много кто, но он «тупой» и не знает ни о ком. Так что это приемлемо. 

По сути, мы начинаем применять события.

События

Сразу скажу, что это не события из событийно-ориентированного программирования, там речь скорее об EventLoop, очереди сообщений и вот этом всём. Наверное, самой известной реализацией такого подхода для Android-разработчика будет главный цикл Android-приложения

События в Android SDK

Тут речь о модели делегирования событий (Delegation event model), которая по-простому, по-крестьянски является привычными нам Listener’ами. Самый банальный пример — это OnClickListener и связанный с ним метод setOnClickListener у Android View. 

view.setOnClickListener {
   // наша реакция
}

Хотя более подходящим для нас примером будет OnLayoutChangeListener и связанные с ним методы addOnLayoutChangeListener и removeOnLayoutChangeListener всё у той же Android View. Так как тут в отличие от OnClickListener ведётся работа со списком Listener.

val onLayoutChangeListener = View.OnLayoutChangeListener { params ->
   // наша реакция
}
view.addOnLayoutChangeListener(onLayoutChangeListener)
view.removeOnLayoutChangeListener(onLayoutChangeListener)

Логика тут простая: Android View понимает, что у него произошло какое-то событие, например, клик, и сообщает об этом всем, кому это может быть интересно, через Listener’ы. При этом Android View вообще находится в Android SDK и никак не может быть зацеплен с компонентами нашего приложения.

Иногда, чтобы не плодить большое количество классов Listener, при этом события как-то логически объединены, используют один Listener, в который приходит объект события. Такое происходит, например, с OnTouchListener у всё того же Android View.

view.setOnTouchListener { view, event ->
   return@setOnTouchListener when(event.action) {
       MotionEvent.ACTION_DOWN -> true
       MotionEvent.ACTION_CANCEL -> true
       else -> false
   }
}

С событиями такой локальной штуки, как View, всё понятно. Берём какую-то кнопку, нашпиговываем её Listener’ами до отвала — и всё работает. Но нам-то надо, чтобы потенциально любой компонент приложения мог отреагировать на какое-то событие.

Глобальные события

Нам нужно, чтобы на события мог отреагировать кто угодно, то есть события должны быть «широковещательными». Напоминает подход Event Bus.

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

Что подводит нас к следующим ограничениям для глобальных событий: 

  • Компонент глобальных событий сам не должен иметь зависимостей, иначе он транзитивно затянет их в огромное количество компонентов.

  • События должны быть действительно глобальными, то есть такими, на которые может отреагировать очень большое количество компонентов, а не один-два. Иначе компонент глобальных событий очень быстро захламится. Логин или логаут —- подходят, показ баннера или начало звонка — нет.

С нудной теорией закончил, давайте, наконец, попробуем их реализовать.

Пробуем применить события

И многие из вас такие:

И как бы — да. Он буквально переводится как «широковещательный приёмник». Поэтому для начала рассмотрим именно его. Зачем что-то изобретать, если нам может подойти реализация из Android SDK.

Broadcast Receiver

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

Зарегистрировать Broadcast Receiver можно как через Android Manifest:

<receiver 
  android:name=".MyBroadcastReceiver" 
  android:exported="false">
    <intent-filter>
        <action android:name="global.event.logout"/>
    </intent-filter>
</receiver>

так и через код:

var filter = IntentFilter("global.event.logout")
context.registerReceiver(receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)

Нам больше подойдёт вариант с Android Manifest. Почему именно так, рассмотрим чуть дальше. 

Создаём класс события

Для начала нам нужно создать класс события, чтобы иметь возможность отправлять и обрабатывать наши события. Можно, конечно, просто сделать обёртку над String:

class Event(
    val name: String
) {

    companion object {

        val APP_INITIALIZED = "app_initialized"
        val FIRST_SCREEN_STARTED = "first_screen_started"
        val LOGOUT = "logout"
    }
}

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

Поэтому создадим sealed класс для наших событий, в котором будут:

  • инициализация приложения; 

  • старт первого экрана;

  • выход из аккаунта. 

Получится что-то такое:

sealed class GlobalEvent(
    val uid: String = UUID.randomUUID().toString()
) : Parcelable {

    @Parcelize
    class AppInitialized: GlobalEvent()
    @Parcelize
    class FirstScreenStarted: GlobalEvent()
    @Parcelize
    data class Logout(val userId: Long): GlobalEvent()
}

Отправляем событие

Теперь перейдём к отправке событий. Мы будем использовать стандартный механизм работы с Broadcast Receiver, который можно найти в документации Android

Создаём Intent с особым action, который будет состоять из префикса имени класса нашего события. Затем помещаем объект события внутрь Intent, для этого мы и наследовали наш GlobalEvent от Parcelable. 

fun sendEvent(context: Context, event: GlobalEvent) {
    val intent = Intent("${event::class.simpleName}")
    intent.putExtra("event", event)
    intent.`package` = context.packageName
    context.sendBroadcast(intent)
}

Ну и добавляем отправку события в компоненте авторизации:

val event = GlobalEvent.Logout(userId)
sendEvent(context, event)

Теперь надо добавить кого-то, кто будет обрабатывать наши события. Для этого создаём простой BroadcastReceiver, не забывая добавить его в Android Manifest. 

class GlobalEventBroadcastReceiver : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        when (intent.action) {
            GlobalEvent.AppInitialized::class.simpleName -> {
                // Обработка события инициализации приложения
            }
            GlobalEvent.FirstScreenStarted::class.simpleName -> {
                // Обработка события старта первого экрана
            }
            GlobalEvent.Logout::class.simpleName -> {
                // Обработка события выхода из аккаунта
            }
        }
    }
}

И вуаля — всё работает! Достаточно просто получилось. Мы отправляем события, они приходят и обрабатываются. Это именно то, что нам нужно. Код можно улучшить, избавиться от имён классов и прочего лишнего.

Однако как только мы начинаем масштабировать эту систему, создавать Broadcast Receiver'ы для каждого компонента, который должен обрабатывать события и отправлять события из разных мест, мы сталкиваемся с проблемой…

Производительность

Broadcast Receiver'ы выполняются последовательно, что логично, ведь выполняются они на главном потоке. 

Как следствие, если у нас будет много Broadcast Receiver'ов, последовательное выполнение становится проблемой. Долгая инициализация может привести к задержкам в работе приложения, что нежелательно. Да и чистить базы данных и прочее на главном потоке как-то не комильфо. Поэтому надо увести работу внутри Broadcast Receiver в побочный поток. Правда, принятие сообщения и создание Broadcast Receiver системой всё равно останется на главном потоке. Но всё же станет гораздо лучше.

Однако не всё так просто. Broadcast Receiver имеет необычный жизненный цикл. По умолчанию система прибивает его сразу после выполнения метода onReceive, а мы-то хотим уйти в побочный поток. Значит, сразу после создания потока метод завершится, и система попытается прибить Broadcast Receiver.

Чтобы система не прибила наш Broadcast Receiver раньше времени, можно воспользоваться методом goAsync. Вызывая его, мы как бы говорим системе, что планируем обрабатывать события в побочном потоке. Теперь она хотя бы не будет пытаться прибить нас сразу по завершении метода.

class GlobalEventBroadcastReceiver : BroadcastReceiver() {
    
    override fun onReceive(context: Context, intent: Intent) {
        val pendingResult = goAsync() { 
            // уходим в другой поток
            pendingResult.finish()
        }
    }
}

Ах да. Время выполнения onReceive у Broadcast Receiver ограниченно даже с использованием goAsync: не успели — всё, система прибивает. 

Ну и на сладенькое. У Broadcast Receiver нет метода onDestroy или чего-то такого. Поэтому не получится создать локальный Scope для корутин, а потом отменить его в случае завершения Broadcast Receiver. Воспользуемся GlobalScope, хотя в реальном приложении лучше всё-таки создать отдельный Scope на все Broadcast Receiver.

class GlobalEventBroadcastReceiver : BroadcastReceiver() {
    
    override fun onReceive(context: Context, intent: Intent) {
        val pendingResult = goAsync()
        GlobalScope.launch(Dispatchers.Default) {
            try {
                // Обработка глобальных событий
            } finally {
                pendingResult.finish()
            }
        }
    }
}

Неприятно, но жить можно. 

Снова отправляем наше событие большому количеству Broadcast Receiver и… Оно всё ещё медленно работает! 

Очевидно, что часть вины за это лежит на сложном механизме работы. Мы сначала упаковываем данные в Intent, отдаём его системе, она сама создаёт экземпляры BroadcastReceiver и убивает их. Целая куча всего! А мы просто хотели несложные реакции на события! 

К тому же Android делает отдельную копию изначального Intent для каждого из Broadcast Receiver. То есть если Intent содержит extras, то мы заново распаковываем отдельную копию в каждом из Broadcast Receiver. Напомню, что в Intent в extras изначально лежит просто набор байт и лишь после вызова unparcel у Bundle всё превращается в привычные нам объекты. Это не самая быстрая операция. 

Наверное, если долго-долго думать, то можно решить эти проблемы, но как-то даже не хочется. Уже и так выглядит не супер. К тому же есть дополнительные мелочи, которые отталкивают: 

  • Надо делать события сериализуемыми. 

  • Ограниченное время на исполнение.

  • Приходится завязываться на Context.

  • Оставлю тут небольшое «чеховское ружье». На ранней Android 10 есть баг: если во время обновления приложения придёт пуш или сработает Work Manager, то наше приложение неожиданно запустится, но Android Manifest будет от новой версии, а код — от старой. А значит, часть Broadcast Receiver не будет выполняться, что может привести к неожиданным последствиям и непонятным багам.

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

Своё решение

А ведь и правда, зачем нам мучиться с системным Broadcast Receiver, когда написать своё явно проще. Нам всего-то нужно:

  • Класс с событиями.

  • Интерфейс или класс нашего Receiver.

  • Два интерфейса на отправку и на регистрацию Receiver и их реализация естественно.

События

Начнём с класса с событиями, ведь он у нас уже есть. Просто выкидываем из него Parcelable.

sealed class GlobalEvent(
    val uid: String = UUID.randomUUID().toString()
) {

    class AppInitialized: GlobalEvent()
    class FirstScreenStarted: GlobalEvent()
    data class Logout(val userId: Long): GlobalEvent()
}

Интерфейсы

Заводим интерфейс для будущих GlobalEventReceiver. Один метод onEvent, в который и будет приходить событие. Обязательно делаем этот метод suspend, чтобы в дальнейшем в нём без каких-либо проблем выполнять операции любой длительности.

interface GlobalEventReceiver {

    suspend fun onEvent(globalEvent: GlobalEvent)
}

Заводим UseCase для отправки событий. Единственный suspend метод send делает то, о чём гласит его название, — отправляет событие.

interface GlobalEventSendUseCase {

    suspend fun send(globalEvent: GlobalEvent)
}

Отдельный UseCase нам понадобится для регистрации наших GlobalEventReceiver в компоненте отправки событий. Это, скажем так, аналог setOnClickLintener.

interface GlobalEventRegisterUseCase {

    fun registerReceiver(globalEventReceiver: GlobalEventReceiver)
}

Реализации

Сначала посмотрим на реализацию GlobalEventReceiver. Просто принимаем события и реагируем только на те, что нам нужны.

class ChatsGlobalEventReceiver: GlobalEventReceiver {

   override suspend fun onEvent(globalEvent: GlobalEvent) {
       when(globalEvent) {
           is GlobalEvent.Logout -> {
               // удаляем данные
           }
       }
   }
}

Ну и обязательно делаем реализацию наших UseCase. Я для удобства демонстрации сделал один Interactor на оба UseCase. 

class GlobalEventInteractor(
    private val globalEventsScope: CoroutineScope
) : GlobalEventSendUseCase, GlobalEventRegisterUseCase {

    private val receivers = mutableListOf<GlobalEventReceiver>()

    override suspend fun send(globalEvent: GlobalEvent) {
        globalEventsScope.launch(Dispatchers.IO) { 
            receivers.forEach { it.onEvent(globalEvent) } 
        }
    }

    override fun registerReceiver(globalEventReceiver: GlobalEventReceiver) {
        receivers += globalEventReceiver
    }
}

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

Теперь зарегистрируем GlobalEventReceiver наших компонентов. Для удобства сделаем это в App.onCreate. Дальше я подобное действие буду называть связыванием. Не знаю почему, но так привык.

globalEventRegisterUseCase.registerReceiver(FirstGlobalEventReceiver())
globalEventRegisterUseCase.registerReceiver(SecondGlobalEventReceiver())

Ну и дело за малым — отправить событие из компонента, отвечающего за выход из аккаунта. 

globalEventSendUseCase.send(GlobalEvent.Logout(userId))

Общая схема получилась следующей:

И это работает. События посылаются и принимаются, весь код на нашей стороне, так что можно накинуть оптимизаций. Всё просто великолепно, но! Вообще-то, у Broadcast Receiver перед нашей реализацией есть одно очень важное преимущество. Или у нашей реализации есть очень важный недостаток. 

Нам приходится регистрировать все GlobalEventReceiver в коде в App.onCreate. И это не очень хорошо. Представьте, как абсолютно все разработчики на проекте периодически меняют один и тот же список. Это будет самое конфликтное место в проекте. 

Также из-за этого списочка:

  • Мы лишаемся использования dynamic-feature. Там нет возможности обратиться к классу напрямую.

  • Если у вас многомодульное приложение, то вы не сможете сделать связывание и реализацию в фичёвом модуле.

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

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

Связывание

Стандартные способы

Опять Broadcast Receiver 

Раз в подходе с Broadcast Receiver такой проблемы не было, то, может быть, произвести связывание в нём? Звучит максимально логично.

Просто вызовем регистрацию прямо в onReceive.

class FirstBroadcastReceiver : BroadcastReceiver() {
    
    override fun onReceive(context: Context, intent: Intent) {
        GlobalEventRegisterUseCase.registerReceiver(FirstGlobalEventReceiver())
    }
}

Так как Broadcast Receiver регистрируется в Android Manifest, то у нас автоматом улетают все проблемы. Если подключил модуль к сборке, он сам всё зарегистрирует, зависимости свяжет и вообще умничка. В демоприложениях тоже делать ничего не нужно, «всё само». Dynamic-feature тоже работают хорошо. И связывание модулей тоже происходит автоматически, надо лишь подключить нужный модуль к главному модулю — и всё заработает.

Но вот проблемы с производительностью Broadcast Receiver никуда не делись, и мы получаем ужасно медленный старт. 

Может, есть что-то ещё, что тоже регистрируется в Android Manifest?

ContentProvider

На первый взгляд, он подходит идеально:

  • Прописывается в Android Manifest.

  • Инициализируется сам.

  • Стандартный компонент.

Мы просто создаём новый ContentProvider и у него в onCreate вызываем регистрацию.

class FirstProvider : ContentProvider() {

    override fun onCreate(): Boolean {
        GlobalEventRegisterUseCase.registerReceiver(FirstGlobalEventReceiver())
        return true
    }
    ………
}

Не забываем, конечно, прописать наш FirstProvider в Android Manifest.

<provider
    android:name=".FirstProvider"
    android:authorities="${applicationId}.first.provider"
    android:exported="false"
    android:initOrder="101" />

И я не безумен. В целом это распространённая практика по инициализации компонентов и библиотек, так делают и Firebase, и VK SDK, и Leak Canary, и много кто ещё. Оно и немудрено: onCreate у нашего FirstProvider вызовется до onCreate у Application, что весьма удобно. То есть мы сразу можем кидаться событиями в разные стороны с полной уверенностью, что всё уже работает. Но есть некоторые нюансы:

  • Первый нюанс. Экземпляр ContentProvider создаётся системой, а не нами. Следовательно, меньше контроля за жизненным циклом такого объекта. 

  • Второй нюанс. ContentProvider предназначен не для этого. Это огромный и мощный инструмент для работы с данными, а мы его используем, просто чтобы создать класс без полей и засунуть его в другой класс. 

  • Третий нюанс. В целом вытекает из второго. Это мощный инструмент с кучей полей и методов, а, следовательно, создание его экземпляра — дело затратное. В итоге можно получить большой удар по времени старта приложения. Хоть и не такой большой, как в случае с BroadcastReceiver.

ContentProvider тоже отметаем. Что есть ещё?

App Startup

Что бы вам ни казалось, но в Google тоже не дураки сидят, и, видя, как Content Provider используют не по назначению, замедляя время старта, они придумали App Startup

Эта библиотека создана как раз для инициализации всякого разного на старте приложения. Под капотом у неё всё тот же Content Provider, но теперь один на все компоненты, которые хотят инициализироваться.

В этом единственном Content Provider вызываются сущности Initializer, в которых и предполагается производить инициализацию.

Поэтому заводим новый Initializer, в onCreate которого будем регистрировать GlobalEventReceiver.

class FirstInitializer : Initializer<Boolean> {
    override fun create(context: Context): Boolean {
        GlobalEventRegisterUseCase.registerReceiver(FirstGlobalEventReceiver())
        return true
    }
    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

Ну и не забываем добавить Initializer в Android Manifest. В отличие от предыдущих решений мы не заводим новый уникальный компонент, а описываем общий InitializationProvider с параметром tools:node="merge". Таким образом, при создании общего Android Manifest все описания InitializationProvider объединяются в Мегазорда одно целое.

Наш Initializer прописывается внутри InitializationProvider как meta-data.

<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false"
    tools:node="merge">
        <meta-data  
          android:name="ru.cian.auth.FirstInitializer"
          android:value="androidx.startup" />
</provider>

Meta-data? Что-то пахнет подозрительно? На самом деле нет. Это тоже старая практика, если вам нужно объявить собственные классы, не относящиеся к Android SDK в Android Manifest, то meta-data подходит отлично. 

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

Но тут тоже как-то не идеально — мы создаём и регистрируем отдельный класс Initializer, чтобы выполнить буквально одну строчку. Как-то это перебор!

Хочется пойти дальше и попробовать создавать GlobalEventReceiver классы напрямую без всяких Initializer. Поэтому уйдём от стандартных и общепринятых способов к чему-то более специализированному.

Нестандартные способы

Итак, нам надо придумать что-то необычное, чтобы и плюсы решения с AppStartup сохранить, и в то же время создавать классы GlobalEventReceiver, минуя всякие ненужные Initializer.

Кодогенерация

Зачем нам вообще париться — напишем кодогенератор, который проанализирует все подключённые к главным модулям другие модули, найдёт в них GlobalEventReceiver и сам, без нашего вмешательства, создаст их список и добавит в GlobalEventRegisterUseCase. 

Звучит хорошо, но вот с dynamic-feature не очень хорошо получится. Нельзя просто так создать экземпляры их классов, ведь мы не можем обратиться к ним напрямую, так как класс находится вне области видимости классов из общего APK.

Ладно, можно заморочиться сильнее и написать кодогенератор, который сам будет генерировать AppStartup Initializer отдельно для главных модулей и отдельно для dynamic-feature. А потом при старте приложения всё это дружно проинициализируется. 

Это определённо рабочий способ. Но, честно говоря, писать отдельный кодогенератор, который будет активирован в каждом фичёвом модуле, анализировать весь код — и это всё, чтобы выполнить одну строчку. Опять какой-то перебор. Да и в целом, если у вас в проекте мало кодогенерации, то использование пусть даже более лёгкого и простого KSP ударит по времени сборки.

С другой стороны, это явно неплохая задачка, если вы просто хотели потренироваться с кодогенерацией. 

А мы поищем что-то ещё.

Рефлексия

А почему бы не воспользоваться рефлексией. Наши GlobalEventReceiver не имеют полей, так что создаваться будут мгновенно. Ещё и конструктор всегда пустой и настраивать их никак не надо. Идеальная цель для рефлексии. 

Рефлексия позволит нам динамически создавать GlobalEventReceiver просто на основе имени класса. Код будет достаточно простым:

private fun createReceiverFromClassName(
  className: String
): GlobalEventReceiver? {
    try {
        val clazz = context.classLoader.loadClass(className)
        val instance = clazz.newInstance()
        return instance as GlobalEventReceiver
    } catch (error: Throwable) {
        return null
    }
}

Чтобы всё заработало, нам достаточно взять имена классов наших GlobalEventReceiver и засунуть их в этот метод. И всё. 

Только вот… А где нам взять имена классов-то? Какие вообще есть для этого практики?

Список

Сразу вспоминается dynamic-feature, где подобные проблемы — норма жизни. 

Google в своих примерах работы с dynamic-feature просто зашивают имена классов как константы. 

const val PROVIDER_CLASS = "com.google.StorageFeatureImpl\$Provider"
…
val clazz = Class.forName(PROVIDER_CLASS).kotlin
val provider = clazz.objectInstance as StorageFeature.Provider
val storage = provider.get(baseComponent)

Перенося подобное решение на наш случай, где-то в Application классе мы должны будем держать список названий классов GlobalEventReceiver. Затем в onCreate пробежимся по этому списку, создадим экземпляры классов и сразу зарегистрируем их. 

class CianApplication : Application() {

    private val receiverClassList = listOf<String>(
        "ru.cian.auth.FirstReceiver",
        "ru.cian.chats.SecondReceiver"
    )

    override fun onCreate() {
        super.onCreate()
        receiverClassList.forEach {
            val receiver = createReceiverFromClassName(it)
            if (receiver != null) {
                GlobalEventRegisterUseCase.registerReceiver(receiver)
            }
        }
    }
}

В целом это будет работать. Но мы так-то вернулись к тому, с чего начали, — у нас огромный список GlobalEventReceiver в Application. Да, теперь он поддерживает dynamic-feature, но в остальном это так себе решение, являющееся магнитом для конфликтов и «очепяток». 

ServiceLoader

Если покопаться, то можно найти такую замечательную штуку, как ServiceLoader. Он буквально позволяет найти все реализации какого-либо интерфейса. Звучит так, как будто это прям то, что нам нужно. Так ещё и, например, в Ktor используется

private val engines: List<HttpClientEngineContainer> = HttpClientEngineContainer::class.java.let {
    ServiceLoader.load(it, it.classLoader).toList()
}

private val FACTORY = engines.firstOrNull()?.factory ?: error(
    "Failed to find HTTP client engine implementation in the classpath: consider adding client engine dependency. " +
        "See https://ktor.io/docs/http-client-engines.html"
)

По сути, всё, что нужно, — это прописать нужную реализацию GlobalEventReceiver в META-INF.services модуля, в котором лежит реализация. Android Gradle Plugin сам при сборке объединит META-INF со всех модулей. 

Теперь при обращении к ServiceLoader.load он вернёт нам все прописанные в META-INF реализации. Поэтому без задней мысли при старте приложения запрашиваем список наших GlobalEventReceiver через ServiceLoader и затем регистрируем их.

class CianApplication : Application() {

    private val receiverList = GlobalEventReceiver::class.java.let {
        ServiceLoader.load(it, it.classLoader).toList()
    }

    override fun onCreate() {
        super.onCreate()
        receiverList.forEach {
            GlobalEventRegisterUseCase.registerReceiver(it)
        }
    }

И это работает, что как бы логично, ведь Ktor-то не падает. Но потихоньку начинают закрадываться мысли, что что-то тут не так. Решение-то не сильно популярное. Да и в том же App Startup имена классов через meta-data достают, а не через ServiceLoader. Хотя ServiceLoader выглядит явно проще.  

И да. С ним есть проблемы. Из известных мне это то, что на Xiaomi под Android 12 и 13 он не грузит те классы, которые ещё не импортировали напрямую из уже используемых классов. А ведь это именно наш сценарий… Сходу это решить, конечно, никак не получится, так как причина где-то в реализации ClassLoader на Xiaomi. Есть ещё несколько проблем.

Meta-data

Если App Startup позволяет себе такие выходки, как доставать имена классов из meta-data, то почему нам нельзя? Давайте поступим точно так же.

Если кто не знал, то meta-data даёт нам возможность прописать в AndroidManifest пару ключ-значений, а затем считать их в коде. Поэтому можно смело в качестве ключа указать имя нашего <Feature>GlobalEventReceiver класса, а в качестве значения — какое-то кодовое слово. Например, global.event.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application>
        <meta-data
            android:name="ru.cian.consultant.event.ConsultantGlobalEventReceiver"
            android:value="global.event" /> 
    </application>

</manifest>

Дальше достаточно просто считать все значения metadata и выделить нужные нам.

private fun getClassNameListFromMeta(): Set<String> {
   val appInfo = context.packageManager.getApplicationInfo(
       context.packageName,
       PackageManager.GET_META_DATA
   )
   val metadata = appInfo.metaData
   // Чтобы вызвать unparcel
   metadata.getString("")
   val metadataMap = metadata.keySet().map { it to metadata.get(it) }.toMap()
   val classNames = metadataMap.filterValues { it == "global.event" }.keys
   return classNames
}

Ну и в финале зарегистрируем наш список.

class CianApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        getClassNameListFromMeta().forEach {
            val receiver = createReceiverFromClassName(it)
            if (receiver != null) {
                GlobalEventRegisterUseCase.registerReceiver(receiver)
            }
        }
    }
}

Таким нехитрым способом мы получим весь список GlobalEventReceiver, которые в данный момент присутствуют в .apk. 

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

Ах да! Чеховское ружьё. У нас ведь на Android 10 в сборках некоторых вендоров может наблюдаться проблема: если в момент обновления придёт push уведомление, то приложение может запуститься с AndroidManifest от новой версии и кодом от старой. 

Лечится это довольно просто, если использовать собственное решение. Храним список классов GlobalEventReceiver в SharedPreferences. А при открытии приложения просто объединяем их между собой. Таким образом у нас будут созданы и те GlobalEventReceiver, которых уже нет в AndroidManifest.

Ну да, да. Ружьё не то чтобы выстрелило. Скорее, зашипело и упало со стены. Но как же без интриги.

Улучшаем

С подключением разобрались. Теперь настало время допилить ядро нашей концепции. 

Всё медленно

Напомню, что временно всё вызывается в одной корутине. Это работает относительно медленно, так как все <Feature>GlobalEventReceiver выполняются последовательно.

Улучшение достаточно простое — пусть каждый из <Feature>GlobalEventReceiver отрабатывает в отдельной корутине. 

private suspend fun invokeAsync(globalEvent: GlobalEvent) {
    receivers.map { receiver ->
        globalEventsScope.async(globalEventsDispatcher) {
            try {
                receiver.onEvent(globalEvent)
            } catch (throwable: Throwable) {
                println(throwable)
            }
        }
    }.forEach { it.await() }
}

На всякий случай обернём обработку события каждым из <Feature>GlobalEventReceiver в try-catch. Чтобы если произошла ошибка в одном из обработчиков, то событие могло спокойно обработаться другими. 

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

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

override suspend fun send(globalEvent: GlobalEvent) {
    if (globalEvent is SyncEvent) {
        invokeSync(globalEvent)
    } else {
        invokeAsync(globalEvent)
    }
}
private suspend fun invokeSync(globalEvent: GlobalEvent) {
    receivers.forEach {
        try {
            it.onEvent(globalEvent)
        } catch (throwable: Throwable) {
            println(throwable)
        }
    }
}

Если пришло «синхронное» событие, то есть наследуемое от SyncEvent, то просто выполняем последовательно. Если «асинхронное», то уже подключаем корутины.

Всё некрасиво

Теперь разберёмся с тем, что обработка событий выглядит как-то некрасиво. Я напомню:

class ChatsGlobalEventReceiver: GlobalEventReceiver {

   override suspend fun onEvent(globalEvent: GlobalEvent) {
       when(globalEvent) {
           is GlobalEvent.OnAppInit -> {
               // обрабатываем событие
           }
           is GlobalEvent.OnUiInit -> {
               // обрабатываем событие
           }
           is GlobalEvent.Logout -> {
               // обрабатываем событие
           }
       }
   }
}

Ну некрасиво это, по крайней мере, на мой эстетствующий вкус. Чтобы это было нагляднее и удобнее, стоит добавить декларативную обёртку. На мой взгляд, это сделает код более понятным и читаемым.

В Kotlin для этого используются Kotlin DSL (Type-safe builders). Создадим обёртку DeclarativeReceiver и внутри абстрактного класса добавим метод receiver для её создания.

abstract class DeclarativeGlobalEventReceiver : GlobalEventReceiver() {

    abstract fun buildReceiver(): DeclarativeReceiver

    fun receiver(init: DeclarativeReceiver.() -> Unit): DeclarativeReceiver {
        val receiver = DeclarativeReceiver()
        receiver.init()
        return analyticsEvent
    }

    override suspend fun onEvent(globalEvent: GlobalEvent) {
        ...
    }
}

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

internal class FeatureGlobalEventReceiver : DeclarativeGlobalEventReceiver() {

    override fun buildReceiver() = receiver {
        onAppInitialized { application -> ... }
        onFirstScreenStarted { 
           utilMethod { ... }
           ... 
        }
        onLogout { userId -> ... }
    }
}

Всё неудобно

И напоследок небольшое исправление. Список <Feature>GlobalEventReceiver мы создаём внутри класса Application. Это не очень удобно. 

Поэтому создадим отдельный GlobalEventWarmUpUseCase с одним единственным методом warmUp.

interface GlobalEventWarmUpUseCase {

    fun warmUp()
}

Внутри реализации этого метода найдём все классы GlobalEventReceiver и создадим их.

class GlobalEventInteractor(
    private val globalEventsScope: CoroutineScope
) : GlobalEventSendUseCase, GlobalEventWarmUpUseCase {
   
   ...
   override fun warmUp() {
        getClassNameListFromMeta().forEach {
            val receiver = createReceiverFromClassName(it)
            if (receiver != null) { receivers += receiver }
        }
    }
}

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

Вместо завершения

Если говорить откровенно, то я не вижу применимости глобальных событий в небольших приложениях. В средних приложениях — может быть, но и то, можно с чистой совестью прописать все обработчики в Application и не париться. А вот в больших приложениях, особенно если у вас есть dynamic-feature или демоприложения, это прям отличная штука. Особенно если смотреть с точки зрения связывания и реализации в фичёвом модуле. Тогда, чтобы модуль заработал, достаточно подключить его в build.gradle, а дальше он поймает нужные события, и сам инициализируется.

Комментарии (3)


  1. Evgenij_Popovich
    03.04.2024 12:42
    +1

    Спасибо за статью, как всегда очень основательно.

    Вы не рассматривали возможность использовать мультибиндинги из какого-нибудь DI фреймворка или Manual DI? App ведь знает о всех модулях и может строить общий DI граф в котором может быть какая-то коллекция интерфейсов нужного типа.


    1. princeparadoxes Автор
      03.04.2024 12:42
      +2

      Мультибиндинги рассматривали. Более того, до внедрения описанной в статье системы с meta-data, мультибиндинги были основным способом создания коллекций таких интерфейсов. Они и сейчас у нас остаются для, скажем так, не критичных штук - вроде диплинков и шорткатов.

      В целом это весьма хорошее решение. Но конкретно у нас с таким подходом есть ряд проблем:

      • Нет прямой поддержки dynamic-feature, так как их код не виден из главного модуля.

      • Для демоприложений такой граф надо настраивать для каждого из демоприложений. Потом ещё и поддерживать. С системой с meta-data достаточно просто подключить модуль к приложению и он сам заработает.

      • У app есть два варианта "знать" о подключённых к нему модулях: implementation и runtimeOnly. Первый вариант позволяет видеть код из подключённых модулей в app, но в тоже время, если в каком-то из подключённых модулей меняется код, то и app вынужден пересобраться (иногда лишь частично, но не суть). Это било по времени горячей сборки и мы перешли на второй вариант - runtimeOnly. С ним код в app уже не доступен, зато и лишних пересборок нет. Так как код недоступен, то и от мультибиндингов смысла нет.


  1. yegor_orlov
    03.04.2024 12:42

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