С приходом 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. 

Внутри них происходит конвертация из объектов, которыми мы оперируем, в формат, понимаемый библиотекой аналитики.

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

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

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

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