С приходом 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.
Внутри них происходит конвертация из объектов, которыми мы оперируем, в формат, понимаемый библиотекой аналитики.
На этом всё. Сомневаюсь, что такой подход подойдёт или понравится всем. Но его можно настраивать под себя. У нас, например, он зашёл на ура.
Возможно, как задача отправка аналитики — действительно тривиальная, но реализуют все очень по-разному. Всё сильно зависит от требований аналитиков. Поэтому мне кажется, что различные варианты реализации аналитики это достаточно интересная тема, потому что ни у кого нет «красивого» решения для всех. Это всегда какие-то костыли с кучей допущений, заточенные под конкретную задачу.
Надеюсь, кому-то из вас такой подход тоже пришелся по душе и в мире станет больше аналитики с использованием декларативного подхода.