
Всем привет! Меня зовут Максимкин Андрей, я iOS-разработчик в hh.ru. В далеком 2023 году мы уже рассказывали про нашу мобильную аналитику в этой статье. С тех пор много воды утекло, а вместе с ней поменялся и наш флоу работы с аналитикой. И начну я как раз с того, что нас к этому подтолкнуло.
Для погружения в контекст можно перечитать старую статью, но я постараюсь изложить материал так, чтобы было понятно даже тем, кто её не читал. Рассмотрим в первую очередь технические моменты, хотя и про продуктовые не забудем. В конце будет практическая часть, в которой мы попробуем генерировать код на Swift.
Статья будет полезна разработчикам, аналитиками и вообще всем, кто так или иначе вовлечён в создание мобильных и веб-приложений.
Что было не так со старым флоу
Если максимально кратко описать старый флоу, то выглядел он так: было три репозитория — Аналитики, iOS, Android. Аналитики добавляли события через YAML-файлы, а iOS-разработчики генерировали события уже у себя в репозитории на основе этих YAML-файлов на языке Swift и вставляли их в нужные места в приложении. На Android кодогенерации не было, поэтому там события заводились вручную.
К каким проблемам это приводило?
Дифф между платформами. Android-разработчики заводили события руками, поэтому строгого соответствия документации не было. В результате возникало два источника правды: для iOS — репозиторий аналитики, а для Android — репозиторий Android.
Мусорные события. Накапливались события, которых не было в документации аналитики, но которые при этом использовались. Приходилось тратить время, чтобы понять, почему события вызываются именно так, а не иначе.
Сложности с аналитикой. Из-за расхождений аналитику приходилось отдельно считать каждую платформу.
Двойная работа. Одни и те же события сначала заводил аналитик, а потом и Android-разработчик.
К счастью, теперь этих проблем нет: мы их решили с помощью нового подхода.
Пример эксперимента
В hh мы любим эксперименты. Любой новый функционал, перед тем как попасть ко всем пользователям, проходит этап A/B-тестирования. По его результатам мы принимаем решение, раскатывать эксперимент на всех или нет.
Давайте рассмотрим это на реальном примере. Когда пользователь откликается на вакансию, он видит экран отклика, а на нём — поле для сопроводительного письма. Мы решили проверить гипотезу: если пользователю дать возможность генерировать письмо с помощью AI, то на такие отклики работодатели будут чаще приглашать соискателей, чем на отклики, написанные вручную.
В контрольной группе не меняем ничего, а в экспериментальной под полем появляется кнопка «Сгенерировать». При клике на неё письмо генерируется автоматически — пользователю остаётся только проверить и отправить его.
Через три недели подводим итоги: сравниваем, в какой группе пользователям чаще приходил ответ от работодателя. Эти расчёты делает аналитик вручную на основе мобильной аналитики и данных с бэкенда.
Эксперимент оказался удачным: на отклики со сгенерированным письмом отвечали положительно чаще. Наша гипотеза подтвердилась, поэтому мы раскатили возможность генерации сопроводительного письма на всех соискателей.

Но при оценке эксперимента мы смотрели не только количество ответов от работодателей, но и метрики приложения.
Как мы оцениваем эксперимент
Метрики — это показатели, по которым мы следим за состоянием приложения. В нашей внутренней системе для A/B-тестирования выглядят они примерно так:

Каждый столбик — это отдельная метрика. Метрикой может быть, например, количество откликов пользователей на вакансию. Зелёные означают, что показатели растут, серые — остаются в пределах нормы, красные — что показатели стали падать.
Итоговая оценка эксперимента складывается из двух частей: метрик приложения и ручного расчёта аналитика по событиям — например, количества ответов работодателей. Метрики помогают понять, не сломали ли мы что-то в продукте в целом, а ручной расчёт — сработала ли сама гипотеза.
На сам расчёт метрик мобильные разработчики почти не влияют: они считаются автоматически и, как правило, используют данные бэкенда. А вот для ручного расчета — например, количества нажатий на кнопку или открытия нужного экрана — как раз используется мобильная аналитика. Для этого на мобильном устройстве мы отправляем соответствующие события.
И вот здесь уже многое зависит от того, как устроена мобильная аналитика: чем лучше организована отправка событий во внутренние сервисы, тем проще и быстрее делать расчёты. Давайте посмотрим, как мы выстроили этот процесс.
Единый флоу для iOS и Android
В hh мы постарались сделать отправку событий аналитики на iOS и Android максимально идентичной. Для этого выстроили флоу вокруг трёх принципов:
аналитик работает только с одной задачей, даже если продуктовый функционал реализуется на двух платформах. То есть для любой фичи задача аналитика одна и та же для iOS и Android
разработчики подключают новые методы из этой задачи
единый источник правды — репозиторий аналитики.
Шаг 1. Аналитик добавляет события
В нашем флоу работы с мобильной аналитикой участвуют три репозитория — аналитики, iOS и Android. Но начинается всё в репозитории аналитики.

Ветка master в репозитории аналитики — единый источник правды: там лежит вся актуальная аналитика. Для продуктовой задачи аналитик создаёт ветку с названием аналитической задачи AN-XXXXX, наследуя её от master. Далее добавляет необходимые события через YAML-файлы. Каждое событие это YAML-файл. Затем аналитик делает пуш в remote ветку и создаёт pull-request AN-XXXXX→master.
Шаг 2. В дело вступает CI
Перед тем, как начать описывать работу CI, важно рассказать про две ветки из репозитория аналитики — develop_ios и develop_android:
develop-ios — содержит события, которые уже реализованы в ветке develop репозитория iOS
develop-android — то же самое для Android
После создания pull request CI делает следующее:
Валидирует добавленные аналитиком YAML-файлы и проверяет, соответствуют ли они спецификации.
Включает автомерж pull request в master. Чтобы смержить pull request потребуется 2 аппрува.
Создаёт две платформенные ветки — AN-XXXXX-ios и AN-XXXXX-android. Они создаются от веток develop-ios и develop-android соответственно, а не от ветки задачи. В них CI накатывает изменения аналитика из ветки AN-XXXXX. Именно из этих веток разработчики будут брать аналитику для своих фичей.
А зачем нам вообще платформенные ветки, если вроде бы можно использовать первоначальную ветку аналитики? Дело в том, что iOS и Android выкатываются в разное время. Фича может быть готова на iOS сегодня, а на Android — через две недели. При этом в develop каждой платформы должна лежать ровно та аналитика, которая на этой платформе уже реализована.
Чтобы pull request аналитика смержился автоматически, нужно получить два аппрува. Как правило от iOS- и Android-разработчика. Иногда фича реализуется только на одной платформе, но второй аппрув всё равно нужен. Платформенные ветки создаются в любом случае, поэтому лишняя проверка тут не помешает.
После автомержа ветка AN-XXXXX удаляется, а платформенные ветки AN-XXXXX-ios и AN-XXXXX-android остаются.
Но не всегда всё идеально в этом мире. Бывают случаи, когда даже после аппрувов и автомержа возникает потребность в доработках. Что же делать? Да почти всё то же самое.
Ветка AN-XXXXX после автомержа уже удалена, поэтому её нужно создать заново — с тем же названием и снова от свежего master. Дальше — правки, pull request в master, два аппрува и автомерж. Новые правки автоматом подтянутся к платформенным веткам. Спасибо CI.
Итоговая схема будет выглядеть так:

Как CI пересобирает платформенные ветки? Он не переносит коммиты по одному, а берёт итоговый дифф ветки задачи относительно master и накатывает его на платформенную ветку одним коммитом поверх актуального develop-{platform}.
При повторных доработках предыдущий коммит по этому же PR снимается ресетом, платформенная ветка обновляется от develop-{platform}, и дифф накатывается заново. Поэтому неважно, сколько коммитов вы сделали в ветке задачи: в платформенной всегда будет ровно один аккуратный коммит с актуальным состоянием.
Важно: вручную вносить изменения в платформенные ветки AN-XXXXX-ios и AN-XXXXX-android нельзя. Любое изменение спецификации вносится только в ветку задачи AN-XXXXX, созданную от master. Платформенные ветки CI пересоберёт автоматически.

Итак, на этом этапе у нас есть смерженный pull-request в master и две ветки аналитики — по одной для каждой платформы. Работа аналитика пока что закончилась. Теперь давайте посмотрим, что из себя представляют YAML-файлы, которые он добавил.
Как устроена YAML-спецификация событий
Каждый файл описывает одно событие аналитики:
name: Название события category: Категория события description: Подробное описание team: Команда-владелец internal: event: тип_события # параметры события... external: event: тип_события # параметры события...
События делятся на внутренние и внешние. Внутренние (internal) отправляются в наши сервисы, где с ними уже работают аналитики, а внешние (external) — во внешние сервисы, например AppMetrica. Событие может содержать и internal, и external. В таком случае сгенерируются два события — для внешней и внутренней аналитики.
Обратите внимание на поле team — команда-владелец. Если по событию возникают вопросы, по этому полю можно быстро найти ответственную команду.
Теперь рассмотрим другие важные поля.
event (string) — тип события
Основных типа четыре:
screen_shown — показ экрана
element_shown — показ элемента на экране
button_click — клик по кнопке
form_submit — отправка формы или применение фильтра
Почему именно эти четыре? Их достаточно, чтобы описать основные действия пользователя. Мы придерживаемся следующего правила: если событие описывает действие пользователя, то оно должно укладываться в один из четырёх типов. Если не укладывается, это почти всегда повод переформулировать событие, а не заводить пятый тип.
Для каждого типа события есть своя таблица, поэтому чем меньше этих таблиц, тем проще делать расчёты. Технически спецификация другие значения не запрещает — это договорённость команды, а не ограничение генератора. Исторически у нас есть некоторое количество событий и с другими типами, но новые мы стараемся не плодить.
platform (string) — для какой платформы генерировать событие
internal:event: element_shownplatform: iOS
Очень редко, но бывают случаи, когда аналитика на платформах всё же может отличаться. Тогда в блоке указываем конкретную платформу — iOS или Android. Если platform не указана, событие сгенерируется для обеих.
Отдельный случай — события в компонентах нашей дизайн-системы (ДС). Например, в компоненте Alert есть кнопки, при нажатии которых внутри компонента отправляется событие. На обеих платформах это работает одинаково, чтобы поведение аналитики на iOS и Android не различалось.
Часть параметров такого события компонент проставляет сам — например, текст на кнопке. Переопределить их нельзя: именно за счёт этого события ДС на iOS и Android гарантированно остаются одинаковыми.
А теперь посмотрим, как всё это выглядит на практике. Возьмём пример YAML-файла и код на Swift, который из него генерируется.
name: Нажатие на карточку на главном экране description: Пользователь нажал на конкретную карточку на главном экране category: Главный экран team: M1 internal: event: button_click buttonName: const: action_card description: Наименование кнопки hhtmSource: const: main description: Главный экран position: type: integer description: Позиция карточки при клике
public struct ActionCardTapEvent: InternalAnalyticsEvent { public var edition: [AnalyticsEventEdition] { .any } public enum CodingKeys: String, CodingKey { case buttonName = "buttonName" case position = "position" case hhtmSource case hhtmFrom } /// Название события public let eventName = "button_click" /// С какого экрана событие будет отправлено public let hhtmSource: HHTMSource? /// Предыдущий экран public let hhtmFrom: HHTMSource? /// Наименование кнопки public let buttonName = "action_card" /// Позиция карточки при клике public let position: Int public init( hhtmSource: HHTMSource?, hhtmFrom: HHTMSource?, position: Int ) { self.hhtmSource = hhtmSource self.hhtmFrom = hhtmFrom self.position = position } } extension ActionCardElementShownEvent { public static func track( position: Int ) { let event = Self(position: position) Analytics.trackEvent(event) } }
Как мы видим, все параметры максимально защищены от изменений. buttonName — константа, поэтому её при вызове не подменить. Извне передаются только те параметры, у которых в YAML указан type, — здесь это position.
Вообще, параметры могут быть шести типов: String, Int, Double, Bool, массивы и enum. Кроме того, генератор сразу отдаёт статический метод track(...), поэтому отправка события выглядит так:
ActionCardTapEvent.track(position: index)
И всё: вызов занимает одну строку, ошибиться сложно, как и сделать отправку по-разному на двух мобильных платформах.
Отдельно хочется отметить два параметра — hhtmSource и hhtmFrom. Это «где произошло событие» и «откуда пользователь туда пришёл». Это ключ текущего и предыдущего экрана. По ним аналитики восстанавливают путь пользователя по приложению. В сам вызов события их передавать не нужно — они объявлены в протоколе InternalAnalyticsEvent и проставляются слоем аналитики приложения.
От YAML к коду
С файлами, добавленными аналитиком, вроде разобрались. Теперь в дело вступают iOS и Android-разработчики. Начинать работу они могут в разное время, то есть текущий флоу применим не только для параллельной разработки.
Сейчас нужно сгенерировать файлы уже под каждую платформу и добавить их в текущую ветку разработки. Генерацию разработчик запускает у себя — локально одной командой. Для iOS у нас есть утилита analyticsgen, которая из YAML-файлов генерирует код на Swift. На Android кодогенерация работает с помощью Gradle.
В обоих случаях нужно указать только номер задачи аналитика — суффикс платформы генератор добавит сам. То есть по AN-XXXXX он сходит в ветку AN-XXXXX-ios или AN-XXXXX-android. Ветка задачи AN-XXXXX к этому моменту уже удалена, и это нормально.
Для iOS команда выглядит так:
analyticsgen generate --config .analyticsGen.yml --branch AN-XXXXX
Для Android:
./gradlew :analytics-gen:run --args="--branch AN-XXXXX"
Дальше генератор делает три вещи:
1. Открывает pull request AN-XXXXX-ios → develop-ios в репозитории аналитики
2. Генерирует Swift- и Kotlin-файлы и коммитит их в текущую ветку разработки — это уже репозиторий приложения.
3. Вешает на этот коммит git-тег вида analytics/AN-XXXXX-1, где число на конце — порядковый номер генерации. Если вы перегенерируете события после доработок, появится analytics/AN-XXXXX-2 и так далее. Тег пригодится нам чуть позже.
После этого разработчику остаётся вызвать сгенерированные методы в нужных местах. Тут важно не запутаться, в каком репозитории что происходит: pull request AN-XXXXX-ios → develop-ios живёт в репозитории аналитики, а сгенерированный код и тег — в репозитории приложения.
После успешного тестирования продуктовая фича вливается в ветку develop iOS- или Android-приложения. Вместе с изменениями туда попадает и git-тег analytics/AN-XXXXX, добавленный на этапе генерации.
При вливании фичи в develop приложения CI:
анализирует изменения pull request
ищет теги вида analytics/AN-XXXXX
при обнаружении такого тега автоматически:
вливает AN-XXXXX-ios в develop-ios,
или AN-XXXXX-android в develop-android
Аналитик возвращается: считаем эксперимент
Через некоторое время в систему аналитики начинают поступать новые события, и аналитик может оценить, насколько чаще пользователям из экспериментальной группы отвечают приглашением. Например, считаем пользователей экспериментальной группы, которые откликались с сопроводительным письмом — это знаменатель, а числитель — те из них, кто получил приглашение. Делим числитель на знаменатель и получаем конверсию экспериментальной группы, затем тем же способом считаем конверсию контрольной. Сравниваем конверсии, подводим итоги.
После подведения итогов эксперимент больше не нужен, поэтому его можно удалить из кода. Проверка на попадание пользователя в эксперимент у нас выглядит примерно так:
if AiCoverLetterExperiment.isEnabled() { // функционал для экспериментальной группы } else { // функционал для контрольной группы }
А сам эксперимент — так:
public struct AiCoverLetterExperiment: Experiment { public static let experimentDescription = "AI сопроводительное письмо" public static let experimentName = "ai_cover_letter" public static let portfolioURL = URL(string: "<ссылка на задачу>") }
Структура максимально простая: она содержит только название, описание и ссылку на задачу.
В независимости от того, был эксперимент успешным или нет, разработчики заводят задачу в бэклог и, как только до неё доходят руки, удаляют эксперимент из кода. В нашем случае мы удаляем AiCoverLetterExperiment, а вместо условия AiCoverLetterExperiment.isEnabled оставляем функционал для экспериментальной или контрольной группы — в зависимости от результатов эксперимента.
Подключаем AI
Чуть выше я рассказывал про расчёты аналитика. Сейчас часть из них уже может делать AI. Мы обновили документацию, поэтому теперь можно, например, дать агенту такой запрос:
«Мне нужна конверсия из регистрации в создание первого резюме по платформам за неделю»
Агент сделает необходимые выборки и объяснит, что и для чего было сделано. Особенно это полезно разработчикам, которые хуже аналитиков знают, в каких таблицах лежат нужные данные. Если раньше на такие расчёты могло уйти несколько часов, то теперь можно уложиться в 10–20 минут.
Но расчётами возможности AI не ограничиваются. Например, нам уже не обязательно вручную размечать события в коде: агент может посмотреть их описание и сделать это сам.
Можно пойти ещё дальше: взять, например, pull request для iOS, показать его AI и получить аналогичную реализацию для Android. То есть потенциально значительную часть работы можно автоматизировать.
А теперь, пока нас ещё совсем не заменили, перейдём к практике.
Пример
Как я и обещал в начале статьи, сейчас попробуем сгенерировать код на Swift на основе YAML-файлов — примерно так же, как это происходит у нас на CI.
Утилита для генерации кода есть на GitHub. До полноценного Open Source она пока что не дотягивает, поскольку довольно сильно завязана на наш внутренний флоу. Но для практического примера её возможностей достаточно. А если будет интересно, пишите в комменты — подумаем над тем, чтобы сделать AnalyticsGen полноценной Open Source утилитой.
Итак, что нужно сделать:
Склонировать репозиторий
В гите переключиться на ветку article_branch
В терминале перейти в директорию с проектом в папку Example и выполнить:
swift run analyticsgen generate --config .analyticsGenLocal.yml
Теперь посмотрим, что сгенерировалось. Готовые файлы будут лежать в директории:
../Example/Generated/AnalyticsGen.
А сами YAML-файлы, на основе которых генерировали, лежат тут:
../Example/schemas/applicant
При желании директорию с генерацией можно поменять, изменив её в файле:
../Example/.analyticsGenLocal.yaml.
Под капотом AnalyticsGen лежит Stencil. О том, как мы его используем, у нас тоже есть отдельная статья.
Что получили в итоге
В начале статьи мы перечислили проблемы старого флоу: события на iOS и Android могли расходиться, появлялись события вне документации, аналитикам приходилось отдельно считать показатели для каждой платформы, а часть работы дублировалась.
Новый флоу помог эти проблемы решить:
единым источником правды стал репозиторий аналитики, а события для iOS и Android генерируются из одной спецификации
разработчикам стало сложнее добавить событие, которое не соответствует этой спецификации
аналитикам больше не нужно отдельно учитывать особенности реализации событий на каждой платформе
за счёт кодогенерации и автоматизации стало меньше ручной работы, а вместе с ней — и возможностей для ошибок
единая документация и предсказуемая структура данных позволили подключить AI к части аналитических задач
Сам флоу появился недавно, поэтому мы продолжаем собирать обратную связь от команды и дорабатывать его. В планах — дальше сокращать количество ручных операций и развивать сценарии с AI.
Как устроена мобильная аналитика у вас? Расскажите в комментариях — будет интересно сравнить подходы.
А ещё подписывайтесь на телеграм-канал «Охэхэнные новости» — там мы интересно и без занудства рассказываем о работе в hh.ru. Будем рады видеть!

