
С приходом Compose в голове всё чаще всплывают мысли, что же ещё можно написать в декларативном стиле. И как мне кажется, на эту роль хорошо подходит аналитика, точнее, описание её событий. В среде разработчиков тема аналитики нечасто всплывает в обсуждениях. Отправка аналитики не является целью фичи, а лишь обязательной дополнительной частью. Сама отправка — задача довольно тривиальная и поэтому воспринимается обычно как «обязаловка», а не как интересный процесс, в котором можно блеснуть своими знаниями архитектуры. Тем не менее, если сложить время всех задач по отправке аналитики, то получится довольно приличное количество часов. Поэтому хочется, чтобы работа с этой частью была максимально комфортной. В этом и поможет декларативный подход.
Вообще, декларативный подход к событиям аналитики у нас появился задолго до Compose, и рассказать о нём мы хотели достаточно давно. Но раз уж декларативный подход набирает популярность, то, кажется, время пришло.
Вводные
Представим, что в приложении подключены две аналитики. Такое часто бывает, так как сила конкретного аналитического сервиса в его админке. У всех разные графики, сводки, по-разному резолвятся данные, разные возможности работы с ними. Поэтому аналитики просят интегрировать несколько сервисов, чтобы иметь больше гибкости при работе с данными.
В нашем случае одна — что-то из популярного, вроде Google Analytics или Yandex Metrika. Вторая — самописная аналитика внутри компании, которая предоставляет данные для отдела машинного обучения, например, чтобы более качественно и быстро выдать пользователю рекомендации. События одни и те же, но в разные аналитики уходит разный набор параметров.
Когда-то давно у нас всё было построено на константах и выглядело примерно так:
class OldAnalytics( private val currentTimeProvider: CurrentTimeProvider, private val analytics1: Analytics1, private val analytics2: Analytics2 ) { fun sendEvent(userId: Int, itemsIds: List<Int>) { val analytics1Params = mutableMapOf<String, String>() analytics1Params[FEATURE] = SOME_FEATURE analytics1Params[SCREEN] = SOME_SCREEN analytics1Params[BLOCK] = SOME_BLOCK analytics1Params[ACTION] = CLICK analytics1Params[REASON] = SOME_REASON analytics1Params[USER_ID] = userId.toString() analytics1Params[ITEM_IDS] = itemsIds.joinToString() val time = currentTimeProvider.getCurrentTime().toString() analytics1Params[CURRENT_TIME] = time analytics1.send("SomeEventName", params = analytics1Params) val analytics2Params = mutableMapOf<String, String>() analytics1Params[SCREEN] = SOME_SCREEN analytics2Params[SOURCE] = "SomeSource" analytics2Params[ACTION] = CLICK analytics2.send("SomeEventName", params = analytics2Params) } companion object { const val FEATURE = "feature" const val SCREEN = "screen" const val BLOCK = "block" const val ACTION = "action" const val REASON = "reason" const val SOURCE = "source" const val SOME_FEATURE = "SomeFeature" const val SOME_SCREEN = "SomeScreen" const val SOME_BLOCK = "SomeBlock" const val SOME_REASON = "SomeReason" const val CLICK = "Click" const val USER_ID = "userId" const val ITEM_IDS = "itemIds" const val CURRENT_TIME = "currentTime" } }
Я специально привёл такой большой кусок кода, чтобы вы могли ощутить всю монструозность такого подхода. А ведь это только одно событие. Помимо количества кода, в таком подходе есть ещё целый ворох проблем:
-
Целое полотно из констант, в которых уже через 5-10 событий можно ориентироваться только через поиск. По всему приложению событий аналитики сотни (возможно и за тысячу перевалило, но это не точно). Всё это счастье в одном очень-очень большом файле.
-
Для многих событий нужно «обогащение» данными, когда в событие нужно добавить информацию, которой сейчас нет на экране. Из-за этого приходилось в Presenter/ViewModel добавлять зависимости, которые нужны исключительно для отправки событий в аналитику.
-
В этих константах легко запутаться. Часто получалось, что какая-то константа использовалась, например, в 15 событиях. В новых требованиях аналитики её содержимое нужно поменять, но только в 14 событиях. Хорошо, если разработчик это заметил. Но часто бывало, что разработчик не замечал этого, и в одном из событий аналитики отправлялось неверное значение или ключ.
-
Константы типа String, в целом не очень удобное решение. Иногда константы, предназначенные для конкретного ключа, например, «Screen» использовались с другими ключами, например, «Block». При этом с точки зрения кода и Lint всё было хорошо. Опять же, при изменении значения константы иногда ломалось значение у параметра с совершенно другим ключом.
-
Сборка события происходит в том же потоке, в каком был вызван метод отправки. Это почти всегда был main. В итоге отправка аналитики била по скорости работы приложения.
-
У нас многомодульное приложение и все модули должны были подключать общий модуль, содержащий файл со всей аналитикой. К тому же модуль с аналитикой косвенно знает о всех фичёвых модулях, так как в константах находится информация о них. Это не очень хорошо с точки зрения полноценного деления на модули.
Можно было потихоньку избавиться от каждой из проблем, но мы решили поступить более радикально.
Требования
Мы решили попробовать сделать события аналитики с помощью декларативного подхода. Чтобы больше не возится с константами. Ну, почти…
Мы выработали следующие требования к нашей будущей аналитике:
-
Простота. Хочется, чтобы писать аналитику было довольно просто, без лишних заморочек.
-
Читаемость. Хочется, чтобы когда ты смотришь аналитику, которую делал другой разработчик, например, на Pull Request’е, то всё было понятно.
-
Производительность. Хочется, чтобы отправка аналитики никак не влияла на общую производительность приложения, так как события аналитики отправляются в приложении постоянно, и часто приходится для создания события делать какие-либо операции.
-
Обогащение. Не всегда на экране, с которого должны уходить события аналитики, есть все данные, необходимые для конкретного события. Не хочется портить логику, связанную с экраном, то есть код Presenter/ViewModel, какой-либо логикой, связанной с аналитикой.
-
Многомодульность — хорошая работа с многомодульностью. Чтобы в общих модулях не было значений, относящихся к конкретным модулям.
Создание общей модели
Но… Стоит заметить, что переработка аналитики не очень-то и возможна без самих аналитиков. Поэтому перво-наперво стоит договориться об основных параметрах, которые отправляются в большинстве событий.
В итоге родились два data-класса, описывающие события аналитики. По одному на каждую аналитику.
data class Analytics1Event( val eventName: String, val feature: String?, val screen: String?, val block: String?, val action: String?, val reason: String?, val additionalParams: Map<String, Any?>? ) data class Analytics2Event( val eventName: String, val screen: String?, val source: String?, val action: String?, )
Собственно, в обоих моделях обязательным является только eventName. Остальные могут отсутствовать в некоторых событиях. Также в модели для первой аналитики присутствует property под названием additionalParams. Туда предполагается складывать параметры, которые ситуативны для события.
Вместо констант в ключах у нас теперь property в модели. Теперь настало время что-то придумать и с константами в значениях.
Типы параметров
Напомню, что одна из проблем состояла в том, что они не были типизированы и попадали не в тот ключ. Такую проблему обычно решают с помощью enum, ведь значение одного enum уже не подсунуть вместо значения совершенно другого enum. Мы же решили эту проблему с помощью интерфейса… И ещё класса… И ещё множества enum.
Начнём с интерфейса. Вместо просто String у нас появляются следующие типы-интерфейсы: AnalyticsAction, AnalyticsScreen, AnalyticsFeature, AnalyticsBlock. Каждый из которых относится к своему параметру аналитики. Рассмотрим только AnalyticsAction. Ибо он первый под руку попался. Остальные устроены похожим образом.
interface AnalyticsAction : Parcelable { val key: String }
По сути, он просто содержит в себе одно property типа String. Задача этого интерфейса — просто не давать типам параметров аналитики путаться между собой. Вместо AnalyticsAction уже не подсунуть, например, AnalyticsScreen. Проще говоря, не дать разработчику засунуть параметр не того типа.
Сами же значения хранятся в отдельных фичёвых enum.
@Parcelize enum class CommonAnalyticsAction( override val key: String ): AnalyticsAction { CLICK("Click"), LONG_CLICK("LongClick"), OPEN("Open"), CLOSE("Close"), } @Parcelize internal enum class Feature1AnalyticsAction( override val key: String ): AnalyticsAction { RECEIVE("Receive"), }
«Почему бы не сделать просто enum? Зачем их несколько, да ещё и interface?». Всё дело в многомодульности. В нашем проекте базовые классы аналитики находятся в базовом модуле base-analytics, который подключается к фичёвым модулям.

Так как это две абсолютно разные, не связанные друг с другом фичи, то они не должны знать лишнего друг о друге. Общий enum косвенно разглашал бы информацию о них (вспоминаем проблему конвергенции).
В подходе с «interface с несколькими enum» в модуле base-analytics содержатся только общий interface AnalyticsAction и его наследник — общий enum CommonAnalyticsAction, в котором содержатся реально общие значения, не относящиеся прямо ни к одной из фичей. Если же фича должна иметь собственные значения, то в фичёвом модуле содержится её собственный enum, в котором хранятся относящиеся только к ней значения. Кстати, не лишним будет пометить этот enum в Kotlin как internal.

Но что, если значение будет уникальным не только для фичи, но и для события. Если оно используется только в одном единственном событии и больше нигде, то для этого предусмотрен класс CustomAnalyticsAction:
@Parcelize data class CustomAnalyticsAction( override val key: String ) : AnalyticsAction
В нужном месте будет достаточно прописать:
CustomAnalyticsAction("customAction")
И у вас появилось уникальное значение. Это также актуально, если значение создаётся на основе runtime данных приложения.
CustomAnalyticsAction("customAction$actionType")
Общая модель и типы у нас есть. Теперь надо как-то заполнить эту модель с помощью декларативного подхода.
Решение выглядит не очень эффективным с точки зрения потребления памяти. Либо используются значения enum, либо создаются объекты. В целом так и есть, с использованием строк было бы эффективнее. Но в этом конкретном месте было решено немного сжать рамки для разработчиков не позволяя добавлять значения одного типа в другой тип. Тем самым сделать код немного безопаснее за счёт того, что перепутать типы уже никак нельзя. Да и важность оптимизаций именно в этом месте не очень велика. Относительно современных объёмов оперативной памяти такое потребление ничего особо не поменяет.
Декларативность
В общем смысле декларативный подход в Kotlin основан на Type-safe builders, позволяющих нам делать собственные DSL. Правда, сами разработчики Kotlin называют это полудекларативным подходом («semi-declarative way»). Но нам не очень важно, кто, что и как называет. Главное, что общий подход выглядит следующим образом.
class SemiDeclarativeWay { fun code() = html { } }
Под капотом html представляет из себя обычную функцию, которая в качестве аргумента принимает лямбду c receiver. В роли receiver выступает какой-либо объект.
fun html(init: HTML.() -> Unit): HTML { val html = HTML() html.init() return html }
Использование лямбды с receiver позволит нам внутри этой лямбды обратиться к receiver (HTML) как к this. За счёт этого у нас выстраивается вложенность объектов, и нам нет необходимости обращаться к экземпляру HTML через переменную.
Дело за малым. Осталось переложить эти знания на нашу аналитику. Создаём интерфейс EventBuilder, наследники которого будут описывать конкретные события аналитики.
interface EventBuilder { fun buildEvent(): AnalyticsEvent fun event(init: AnalyticsEvent.() -> Unit): AnalyticsEvent { val analyticsEvent = AnalyticsEvent() analyticsEvent.init() return analyticsEvent } }
В нём всего два метода:
-
buildEvent создаёт наше событие аналитики (AnalyticsEvent);
-
event позволит заполнить содержимое AnalyticsEvent в декларативном стиле.
На данной стадии событие выглядит как-то так:
internal class DeclarativeEventBuilder: EventBuilder { override fun buildEvent() = event { } }
То же самое теперь надо провернуть с самим AnalyticsEvent. Чтобы была возможность заполнить и его поля в декларативном стиле.
class AnalyticsEvent { private var analytics1Event: Analytics1EventDeclarative? = null private var analytics2Event: Analytics2EventDeclarative? = null fun analytics1( init: Analytics1EventDeclarative.() -> Unit ): Analytics1EventDeclarative { val eventDeclarative = Analytics1EventDeclarative() eventDeclarative.init() analytics1Event = eventDeclarative return eventDeclarative } fun analytics2( init: Analytics2EventDeclarative.() -> Unit ): Analytics2EventDeclarative { val eventDeclarative = Analytics2EventDeclarative() eventDeclarative.init() analytics2Event = eventDeclarative return eventDeclarative } }
В нём у нас будет два метода: для первой аналитики — analytics1 и для второй — analytics2. Работает каждый из них со своим объектом, так как в разные аналитики у нас уходит свой набор данных.
Представлять аналитики будут два класса: Analytics1EventDeclarative и Analytics2EventDeclarative. В них, по сути, провернем тоже самое — сделаем заполнение полей через декларативный подход.
Весь их код я приводить не буду. При желании можете посмотреть его под спойлером.
Analytics1EventDeclarative
class Analytics1EventDeclarative { var eventName: String? = null var action: AnalyticsAction? = null var reason: String? = null private var source: EventSourceDeclarative? = null private var additional: EventAdditionalDeclarative? = null fun source( block: EventSourceDeclarative.() -> Unit ): EventSourceDeclarative { val eventDeclarative = EventSourceDeclarative() block(eventDeclarative) source = eventDeclarative return eventDeclarative } fun additional( block: EventAdditionalDeclarative.() -> Unit ): EventAdditionalDeclarative { val additionalDeclarative = EventAdditionalDeclarative() additionalDeclarative.block() additional = additionalDeclarative return additionalDeclarative } fun customAction(customAction: String): AnalyticsAction { return CustomAnalyticsAction(customAction) } fun build(): Analytics1Event { return Analytics1Event( eventName = this.eventName!!, feature = this.source?.feature?.key, screen = this.source?.screen?.key, block = this.source?.block?.key, action = this.action?.key, reason = this.reason, additionalParams = this.additional?.params ) } class EventSourceDeclarative { var feature: AnalyticsFeature? = null var screen: AnalyticsScreen? = null var block: AnalyticsBlock? = null fun customFeature(customFeature: String): AnalyticsFeature { return CustomAnalyticsFeature(customFeature) } fun customScreen(customScreen: String): AnalyticsScreen { return CustomAnalyticsScreen(customScreen) } fun customBlock(customBlock: String): AnalyticsBlock { return CustomAnalyticsBlock(customBlock) } } class EventAdditionalDeclarative { var params: Map<String, Any?>? = null fun <T> fromPair(vararg items: Pair<String, T?>): Map<String, T?> { return mapOf(*items) } } }
Analytics2EventDeclarative
class Analytics2EventDeclarative { var eventName: String? = null var screen: String? = null var source: String? = null var action: AnalyticsAction? = null fun customAction(customAction: String): AnalyticsAction { return CustomAnalyticsAction(customAction) } fun build(): Analytics2Event { return Analytics2Event( eventName = this.eventName!!, screen = this.screen, source = this.source, action = this.action?.key, ) } }
Взглянем же, наконец, на итоговый пример заполнения события аналитики в декларативном стиле. Для примера создадим класс SomeEventBuilder, который описывает отправку какого-то события на каком-то экране.
internal class SomeEventBuilder( private val userId: Int, private val itemsIds: List<Int>, private val timestampProvider: () -> Long ) : EventBuilder { override fun buildEvent() = event { analytics1 { eventName = "SomeEventName" reason = "SomeReason" action = CommonAnalyticsAction.CLICK source { feature = Feature1AnalyticsFeature.FEATURE_1 screen = Feature1AnalyticsScreen.FEATURE_1_SCREEN block = customBlock("SomeBlock") } } analytics2 { eventName = "SomeName" source = "SomeBlockOfUser$userId" action = customAction("Click") screen = Feature1AnalyticsScreen.FEATURE_1_SCREEN } } }
Как по мне, неплохо. Мне всё понятно, но это ощущение субъективно. Вы можете поиграться с вложенностью, пока не получите результат, который нравится лично вам.
В этом моменте вспомним про additionalParams, в который кладутся специфичные для конкретного события параметры. Чтобы это выглядело максимально красиво, стоит принимать их в виде Pair, а чтобы можно было передать их несколько, воспользуемся vararg.
additional { params = fromPair( "userId" to userId, "itemIds" to itemsIds.joinToString(), "currentTime" to timestampProvider() ) }
Сам класс SomeEventBuilder лежит в фичёвом модуле. Таким образом, вся аналитика распределяется по модулям.
Ещё один момент: вы можете обратить внимание, что параметр eventName не является обязательным в декларативном методе, но обязателен в модели для аналитики. Всё из-за того, что в Kotlin DSL пока нет возможности помечать поля как required. Я тут вижу три варианта:
-
Подождать, пока создатели Kotlin допилят Kotlin Contracts. Они обещали.
-
Воспользоваться сторонним решением.
-
Обязательные параметры передавать как параметры метода, как это происходит в Compose. Хотя мне это не очень нравится с эстетической точки зрения. Вот смотрите:
internal class DeclarativeEventBuilder : EventBuilder { override fun buildEvent() = event { analytics1( eventName = "SomeEventName" ) { action = CommonAnalyticsAction.CLICK } } }
Можно заметить, что из объявленных в начале требований декларативный подход к событиям закрывает три из них: простота, понятность, многомодульность. Остались ещё два: обогащение и производительность. Начнём с первого.
Обогащение
Допустим, для нашего события нужно дополнительно послать какую-то информацию, например, о текущем времени. Я знаю, что System.currentTimeMillis() статический, и ситуация не очень реалистичная, но это просто для примера. На его месте может быть что угодно — от информации о пользователе до списка предыдущих экранов. При этом на самом экране и в Presenter/ViewModel, из которого и будет отправляться событие, текущее время не нужно.
Если пытаться отправлять события напрямую из Presenter, то придётся добавлять в его конструктор сущность, которая предоставит информацию для аналитики. Затем перед отправкой события доставать из него текущее время. Пусть этой сущностью будет CurrentTimeProvider:
class SomePresenter @Inject constructor( private val featureAnalytics: FeatureAnalytics, private val timeProvider: CurrentTimeProvider ) { fun onSomeClick() { val currentTime = timeProvider.getTime() featureAnalytics.onSomeClick(currentTime) } }
Весь этот код относится исключительно к аналитике, и не очень хочется, чтобы он захламлял Presenter. Ведь с одного экрана могут уходить десятки событий или для обогащения понадобится больше объектов. Также это будет сильно мешать, если такое событие отправляется с нескольких Presenter, и придётся писать подобный код в каждом из них. В итоге у нас усложняется класс логики экрана лишь из-за отправки аналитики.
Чтобы этого избежать, мы создали класс-прослойку <Feature>Analytics. Который и служит цели обогащения данных для аналитики и инкапсуляции аналитики в целом.
В примере с текущим временем мы в конструктор новоявленного класса FeatureAnalytics добавляем CurrentTimeProvider и будем отдавать текущее время в обратном вызове SomeEventBuilder.
class FeatureAnalytics( private val analyticsSender: AnalyticsSender, private val timeProvider: CurrentTimeProvider ) { fun sendEvent(userId: Int, itemsIds: List<Int>) { val builder = SomeEventBuilder( userId = userId, itemsIds = itemsIds, timestampProvider = timeProvider::getCurrentTime ) analyticsSender.send(builder) } }
А зачем здесь пробрасывается метод timeProvider::getCurrentTime? Почему бы не вызвать его перед созданием SomeEventBuilder и не передать значение сразу в конструктор?
Во-первых, нужно, чтобы вызов происходил во время создания AnalyticsEvent, а не в тот момент, когда создаётся его Builder.
Во-вторых, это ведь может быть не простое получение константы. Возможна более сложная операция, что может ударить по производительности. Ведь вызов создания Builder происходит в потоке в котором вызвали sendEvent, а это скорей всего main.
Также лучше не позволять SomeEventBuilder обращаться к зависимостям напрямую. Например, когда SomeEventBuilder обращается CurrentTimeProvider за текущим временем.
internal class SomeEventBuilder( private val userId: Int, private val itemsIds: List<Int>, private val timeProvider: CurrentTimeProvider ) : EventBuilder { ... }
Пусть он делает это через лямбду в конструкторе.
internal class SomeEventBuilder( private val userId: Int, private val itemsIds: List<Int>, private val timestampProvider: () -> Long ) : EventBuilder { ... }
Это нужно, чтобы класс был более чистым от внешних зависимостей. Так его будет проще переиспользовать в случае чего.

Но вернёмся к прослойкам <Feature>Analytics. С помощью такой прослойки Presenter не засоряется логикой, связанной с аналитикой. В целом использование прослойки — дело дискуссионное, ведь не всегда нужно обогащать события. Тут всё зависит от вашей аналитики. Если события часто обогащаются дополнительными данными, то стоит задуматься. Если же нет, то и от такой прослойки смысла тоже нет.
<Feature>Analytics классы также хранятся в feature модулях. В итоге вся аналитика, связанная с фичёй, остаётся в фичёвом модуле.
Займёмся последним требованием: производительность.
Производительность
Подробно расписывать эту часть я не буду, так как у всех свой набор аналитик и фреймоворк для работы с многопоточкой. Поэтому опишу лишь в общих чертах.
По сути вся работа над производительностью сводится к тому, чтобы вызвать декларативный метод в отдельном потоке. Так как объект аналитики, описанный в декларативном методе, создаётся только при его вызове, то само создание <Some>Builder класса является быстрой задачей, а более долгий метод создания объекта аналитики вызывается в выделенном для аналитики потоке. С помощью таких простых манипуляций мы освободим наш главный поток от аналитики.

По этой же причине стоит превращать данные из моделей для Presentation или бизнес-логики в данные для аналитики прямо внутри метода создания объекта аналитики. Это сэкономит драгоценное время вашего главного потока.
Переход на другой поток, вызов декларативного метода и отправка данных в аналитику происходят внутри AnalyticsSender.
AnalyticsSender переходит на отдельный поток, вызывает buildEvent, тем самым собирая событие и передаёт его в классы, каждый из которых отвечает за свою аналитику Analytics1Sender и Analytics2Sender.
Внутри них происходит конвертация из объектов, которыми мы оперируем, в формат, понимаемый библиотекой аналитики.

На этом всё. Сомневаюсь, что такой подход подойдёт или понравится всем. Но его можно настраивать под себя. У нас, например, он зашёл на ура.
Возможно, как задача отправка аналитики — действительно тривиальная, но реализуют все очень по-разному. Всё сильно зависит от требований аналитиков. Поэтому мне кажется, что различные варианты реализации аналитики это достаточно интересная тема, потому что ни у кого нет «красивого» решения для всех. Это всегда какие-то костыли с кучей допущений, заточенные под конкретную задачу.
Надеюсь, кому-то из вас такой подход тоже пришелся по душе и в мире станет больше аналитики с использованием декларативного подхода.
ссылка на оригинал статьи https://habr.com/ru/company/cian/blog/672862/
Добавить комментарий