Содержание
Приветствую, уважаемые читатели Хабра.
Вероятно, в вашей команде уже всерьёз обсуждается необходимость внедрения дизайн-системы, а может, вы уже её активно внедряете.
Важное место в дизайн-системе занимают токены — самые низкоуровневые элементы, которые являются базой для всех компонентов и экранов.
К токенам можно отнести:
цвета,
типографику,
радиусы/формы,
паддинги/отступы,
иконки.
Список этим не ограничивается. По сути, токен — это любая информация, ассоциированная с именем. Некая константа, например:
color-text-primary: #000000
По мере развития проекта в целом и дизайн-системы в частности токенов становится очень много. Для примера — у нас уже около четырёхсот иконок, больше двухсот цветов и перспектива внедрения нескольких тем в приложении. Ещё мы активно внедрением BDUI, на BFF которого верстаем виджеты, применяя токены дизайн-системы. А что ещё, если не автоматизация, позволит держать в консистентности всю эту систему?
Меня зовут Никита Яцкивский. Занимаю позицию главного Android-разработчика в отделе разработки мобильной платформы компании «Магнит». В статье расскажу про наш тернистый путь к собственному генератору токенов дизайн-системы.
Почему решили идти в историю с автоматизацией
Основные причины:
держать токены консистентными и синхронизированными с дизайном;
снизить человеческий фактор (ошибки);
экономить время разработчика на обновлении токенов.
Все эти причины можно смело умножить на два ввиду BDUI.
В том, что мы действительно идём в правильном направлении, мы убедились, взглянув на масштаб работ.


Research и наша первая попытка
Разработка нового SDK дизайн-системы для Android стартовала в июне 2023 года. Сразу после определения основного направления развития дизайн-системы, подходов к созданию компонентов и разработки сторибука, мы решили изучить существующие в сообществе решения для автоматизации процесса работы с токенами.
В ходе поиска нашли следующие решения, для каждого из которых выделили наиболее важные для нас недостатки:
Style Dictionary от Amazon: не поддерживает интеграцию с Figma API и требует специальный формат описания токенов.
FigmaGen от Headhunter: отсутствует поддержка Android.
FigmaExport от RedMadRobot: генерируемый код для Android не удовлетворяет нашим требованиям и рекомендациям Google по работе с Jetpack Compose.
Общий недостаток всех решений в том, что они являются 3rd party библиотеками, подстроить которые под особенности нашего продукта будет сложно. Наиболее подходящим вариантом оказалась FigmaExport, поэтому мы решили попробовать именно её. В процессе столкнулись с некоторыми ограничениями, которые бы затрудняли использование библиотеки у нас в проекте:
Использование Stencil для шаблонизации, что в первую очередь ориентировано на разработку на Swift.
Сгенерированный код для Compose не соответствовал нашим требованиям. Мы целимся в перспективе в поддержку нескольких тем и скорее всего не ограничимся только тёмной и светлой. Для XML аналогичные ограничения: не генерируются тема и атрибуты темы.
Необходимо следовать требованиям к оформлению токенов в Figma, устанавливаемым библиотекой.
Нет возможности встроить в общую схему генерацию токенов для BFF BDUI.
Для примера, так библиотека генерирует цвета для Compose:
object Colors @Composable @ReadOnlyComposable fun Colors.backgroundPrimary(): Color = colorResource(id = R.color.background_primary)
Подобный подход не позволяет использовать больше двух тем: только светлая и тёмная.
В итоге приняли решение разработать собственный генератор. Это даст необходимую гибкость и возможность создавать токены в том представлении, которое удобно нам.
Реализация
Генератор представляет из себя следующее:
Основа: реализован как Gradle Convention Plugin с несколькими Gradle tasks.
Язык: Kotlin.
Сетевой клиент: Retrofit 2.
Сериализация: Moshi.
Генерация кода: Kotlin Poet (.kt) и org.w3c.dom (.xml).
Генерация изображений:
png: Echosvg. Форк известной в Java-среде библиотеки Batik. В Batik есть очень критичный для нас баг, который контрибьюторы проекта за много лет так и не приняли. Поэтому выбрали форк Echosvg, где он уже исправлен.
webp: Scrimage.
Android Vector Drawable (xml): класс Svg2Vector.java из Android SDK. Находится в модуле com.android.tools:sdk-common.
Для работы с сетью выбор остановили на Retrofit 2 и Moshi, поскольку в нашем основном приложении эти библиотеки уже используются.
В процессе работы очень пристально смотрели на решения от HeadHunter и RedMadRobot, вместе с ChatGPT разбирались в перипетиях Figma API и кодогенерации на KotlinPoet.
На моей памяти самыми сложными аспектами в реализации были:
Реализация data-слоя:
Заведение dto-классов: очень не хватало Open API (Swagger) спецификации Figma API, что сэкономило бы время на описании dto, воспользовавшись библиотечным Open API генератором либо нашим внутренним. Нас очень выручило, что в FigmaGen от hh все необходимые dto уже есть, а ChatGPT помог преобразовать Swift-структуры в Kotlin-классы.
Сложность Figma API: запросы, которые бы отдавали список текстовых стилей или цветов, отсутствуют. Есть метод, который возвращает дерево всех дочерних узлов для переданного списка узлов. Затем пишется алгоритм, который обходит и сопоставляет узлы между собой в поисках самих стилей и их значений. Ответ этого запроса очень громоздкий, так как приходит много лишней информации, и тяжёлый. К примеру Intellij Idea даже не всегда может отформатировать подобный JSON. С приходом Figma Variables ситуация заметно улучшилась.
KotlinPoet: очень многословный инструмент. Отсутствует dsl для более лаконичного описания, очень развесистый код создания FileSpec и других Spec-классов.
Для упрощения далее будут приводится только небольшие фрагменты кода с результатами генерации, поскольку счёт токенов уже приближается к тысячи.
Data-слой
Работа с Figma API сводится к нескольким запросам:
interface FigmaApi { @Mock @GET("v1/files/{file_key}") suspend fun getFile( @Path("file_key") fileKey: String, @Query("ids") ids: String? = null, ): FigmaFile @GET("v1/images/{fileKey}") suspend fun getImages( @Path("fileKey") fileKey: String, @Query("ids") nodeIds: String, @Query("format") format: FigmaImageFormat ): FigmaImagesResponse @GET suspend fun loadSvg( @Url url: String ): Response<ResponseBody> }
Где:
getFile()возвращает Figma-файл, из которого затем извлекаются цвета, типографика;getImages()возвращает ссылки на изображения;loadSvg()загружает непосредственно сами svg по ссылкам, полученным вgetImages().
Все ответы Figma API кэшируются в папку build, что позволяет в любой момент найти проблемный узел и проверить, почему результат генерации отличается от ожидаемого.
В дополнение к описанным запросам пользуемся Variables API, но в «урезанном» виде. Плагином variables2css выгружаем JSON с токенами, сохраняем его в репозиторий дизайн-системы и вызываем генератор.
Что такое Figma Variables API?
Variables API — это нововведение от Figma. Позволяет забирать токены в более простом и читаемом формате. Однако для обращения к API нужен тариф Figma Enterprise, который стоит дополнительных средств. В переписке Figma отказала нам в Enterprise, ссылаясь на то, что наша компания зарегистрирована в РФ. В качестве альтернативы можно использовать сторонний плагин для Figma либо написать свой для экспорта JSON-файла с токенами, поскольку плагины могут обращаться к Variables API без Enterprise-тарифа. Стоит учесть, что не все можно оформить как Variables и, следовательно, забрать через новый API. Например, текстовые стили и картинки нельзя до сих пор.
Генерация цветов
Для цветов генерируется класс-холдер — аналог ColorScheme из Material3 — и отдельно билдер.
Класс-холдер:
import androidx.compose.ui.graphics.Color data class DsColors( val text: Text, val badge: Badge, ) { data class Text( val primary: Color, val accent: Color, val invert: Invert, ) { data class Invert( val secondary: Color, val primary: Color, ) } data class Badge( val border: Border, ) { data class Border( val primary: Color, val secondary: Color, ) } companion object }
Билдер:
object DsColorsLightBuilder { fun DsColors.Companion.buildLight(): DsColors = DsColors( text = DsColors.Text( primary = Color(0xFF232323), accent = Color(0xFFEC0E00), invert = DsColors.Text.Invert( secondary = Color(0x66FFFFFF), primary = Color(0xFFFFFFFF) ), ), badge = DsColors.Badge( border = DsColors.Badge.Border( primary = Color(0xFFFFFFFF), secondary = Color(0xFFF5F5F5) ), ), ) }
Пример получения цвета в коде:
DsTheme.colors.text.primary
Цвет — это пока единственный вид токенов, который генерируется по Variables API. Пример JSON от плагина variables2css:
[ { "mode": { "name": "Base", "id": "2492:1" }, "color": [ { "name": "? badge/background/product/discount/secondary", "color": "#ffc9c3", "var": "red/200", "rootAlias": "red/200" }, { "name": "? badge/border/primary", "color": "#ffffff", "var": "white/1000", "rootAlias": "white/1000" }, // и так далее... ] } ]
Генерация типографики
Типографика оформлена аналогично цветам — класс-холдер и билдер:
data class DsTypography internal constructor( val headline: Headline, val body: Body, ) { data class Headline internal constructor( val large: TextStyle, val medium: TextStyle, val small: TextStyle, ) data class Body internal constructor( val large: Large, val small: Small, ) { data class Large internal constructor( val regularLow: TextStyle, val accent: TextStyle, val accentLow: TextStyle, val regular: TextStyle, ) data class Small internal constructor( val accent: TextStyle, val accentLow: TextStyle, val regular: TextStyle, val regularLow: TextStyle, ) } // и т.д. companion object }
object DsTypographyBuilder { fun DsTypography.Companion.buildTypography(): DsTypography = DsTypography( headline = DsTypography.Headline( large = TextStyle( fontWeight = FontWeight(700), fontSize = 32.0.sp, fontFamily = DsFontFamily.MagnitBox, lineHeight = 40.0.sp, letterSpacing = 0.0.sp, lineHeightStyle = LineHeightStyle( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None ), platformStyle = PlatformTextStyle(includeFontPadding = false) ), medium = TextStyle( fontWeight = FontWeight(700), fontSize = 28.0.sp, fontFamily = DsFontFamily.MagnitBox, lineHeight = 36.0.sp, letterSpacing = 0.0.sp, lineHeightStyle = LineHeightStyle( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None ), platformStyle = PlatformTextStyle(includeFontPadding = false) ), ), // и т.д. ) }
Пример получения текстового стиля из кода:
DsTheme.typography.headline.large
Генерация изображений
Как упоминалось ранее, для генерации изображений используем пару сторонних библиотек и класс Svg2Vector из Android SDK.
Все изображения в дизайн-системе подразделяются на:
Иллюстрации: обычно имеют большие размеры, например, 220x280, содержат множество цветов и оттенков.

Пример иллюстрации из нашей дизайн-системы
Иконки: обычно небольшие (в среднем около 20dp, иногда до 60dp), содержат небольшой набор цветов и оттенков.

Пример иконки из нашей дизайн-системы
Решение, к какой категории отнести ту или иную картинку, принимает дизайнер. На уровне кода разделение на иконки и иллюстрации отсутствует.
Алгоритм экспорта следующий:
Все иконки конвертируются в Android Vector Drawable с помощью Svg2Vector.
Для иллюстраций применяется логика посложнее. Если не получается преобразовать в Android Vector Drawable, то иллюстрация преобразуются в webp следующим образом:
Echosvg нарезает png из svg для каждого dpi.
Scrimage преобразует svg в webp.
Для иллюстраций выбран растровый формат, поскольку Android Vector Drawable имеет ограничения и не все svg-теги могут быть преобразованы в понятный ему формат. Для примера, при конвертации в Vector Drawable в иллюстрациях могла пропасть тень либо градиент. Недостаток растрового формата очевиден: увеличивается размер итогового APK-файла, поэтому в будущем планируем всерьёз заняться вопросом переноса иллюстраций на бэкенд.
data class DsImages internal constructor( val dp32: Dp32, ) { data class Dp32 internal constructor( val imageNotLoad: DsImageRes, val loyalty: Loyalty, ) { data class Loyalty internal constructor( val expressBonus: ExpressBonus, val bonus: Bonus, val magnet: Magnet, ) { data class ExpressBonus internal constructor( val monochrome: DsImageRes, val colored: DsImageRes, ) data class Bonus internal constructor( val colored: DsImageRes, val monochrome: DsImageRes, ) data class Magnet internal constructor( val monochrome: DsImageRes, val colored: DsImageRes, ) } } companion object }
object DsImagesBuilder { fun DsImages.Companion.buildImages(): DsImages = DsImages( dp32 = DsImages.Dp32( imageNotLoad = DsImageRes(R.drawable.ds_img_32_image_not_load), loyalty = DsImages.Dp32.Loyalty( expressBonus = DsImages.Dp32.Loyalty.ExpressBonus( monochrome = DsImageRes(R.drawable.ds_img_32_loyalty_express_bonus_monochrome), colored = DsImageRes(R.drawable.ds_img_32_loyalty_express_bonus_colored) ), bonus = DsImages.Dp32.Loyalty.Bonus( colored = DsImageRes(R.drawable.ds_img_32_loyalty_bonus_colored), monochrome = DsImageRes(R.drawable.ds_img_32_loyalty_bonus_monochrome) ), magnet = DsImages.Dp32.Loyalty.Magnet( monochrome = DsImageRes(R.drawable.ds_img_32_loyalty_magnet_monochrome), colored = DsImageRes(R.drawable.ds_img_32_loyalty_magnet_colored) ) ), ) ) }
DsImageRes:
/** * Абстракция над локальными изображениями. Пока что только над [DrawableRes]. */ @Immutable sealed interface DsImageRes { @Composable fun asPainter(): Painter companion object } fun DsImageRes(@DrawableRes res: Int): DsImageRes = DrawableResId(res)
Пример получения картинки в коде:
DsTheme.images.dp32.loyalty.bonus
Все SVG, полученные от Figma, храним в отдельной папке репозитория дизайн-системы. Можно всегда легко сравнивать исходные файлы с результатами генерации, если были замечены ошибки.

Работа с устаревшими (или deprecated) токенами
Мы реализовали механизм для обработки токенов, которые скоро будут удалены из дизайн-системы, но пока только для изображений. Алгоритм следующий:
Отметка устаревших картинок в Figma: дизайнер перемещает такую в отдельный Frame в Figma. Это позволяет на этапе генерации забрать отдельно актуальные и устаревшие картинки.
Пометка в коде: помечаем такие изображения аннотацией @Deprecated:
@Deprecated(message = "Уточни у дизайнера новый токен") val localNetwork: DsImageRes
В дальнейшем планируем добавить ReplaceWith с указанием нового токена. Для этого потребуется доработать как код, так и процесс отметки устаревших токенов в Figma.
Остальные токены
Радиусы (Shapes), тени пока что добавляются в проект вручную: их очень мало и автоматизация экспорта не рассматривается. В любом случае у нас уже есть стабильные рельсы, которые позволяет внедрять новые типы генерации в будущем.
Генерация XML для Android View System
Для Android View System генератор также умеет создавать тему, атрибуты темы, типографику, цвета. В данный момент эта функциональность генератора отключена, поскольку идёт планомерное переписывание приложения «Магнит: акции и доставка» на Compose. Экраны, написанные на Android View продолжают работать с цветами и типографикой старой дизайн-системы.
Схема генерации. Итоговый проект.
Для запуска генератора необходимо вызвать одну из добавленных Gradle-задач. Например, можно обновить только цвета или изображения. Затем выполняется запрос или серия запросов на Figma API либо обращение к локальному JSON-файлу с Variables. Полученные dto преобразуются в своего рода «доменные» модели Image, TextStyle, Color, по которым затем создаются файлы для Android View (XML), Compose, BDUI.
На данный момент структура генератора выглядит так:

Весь код располагается вместе с остальными Gradle-плагинами проекта дизайн-системы в build-logic/convention:

Для удобства добавлены несколько Gradle-задач для генерации только необходимого типа токенов:

Итоги
В нашем роадмапе дизайн-системы много планов и идей, но касательно генератора основной вектор — это совершенствовать CI/CD, автоматизацию.
Сейчас процесс далёк от идеала: надо открыть Android Studio, вызвать Gradle-задачу, сделать коммит, пройти ревью, проставить release notes. Есть мысли в сторону автоматизации процесса релиза SDK при перегенерации токенов, а также их версионирование между платформами (Android, BDUI и, возможно, iOS).
Для некоторых наших внутренних библиотек применяем Downstream pipelines — функциональность Gitlab Ci/CD, которая позволяет запускать пайплайны в одном репозитории по триггеру в другом. Например, merge ветки в dev. Есть идея размещать файл Variables.json в отдельном репозитории, версионировать его и при изменениях в нём триггерить релиз новой версии дизайн-системы для Android, BFF BDUI и, может быть, iOS.
Одно знаем точно: останавливаться не собираемся, потому небольшими и уверенными итерациями продолжим уверенно двигаться дальше.
Полезные ссылки
Style Dictionary от Amazon https://amzn.github.io/style-dictionary/#/.
Библиотека FigmaGen от Headhunter https://github.com/hhru/FigmaGen ;
Библиотека FigmaExport от RedMadRobot https://github.com/RedMadRobot/figma-export
Библиотека Echosvg https://github.com/css4j/echosvg?tab=readme-ov-file
Библиотека Scrimage https://sksamuel.github.io/scrimage/.
Плагин для Figma variables2css https://www.figma.com/community/plugin/1261234393153346915/variables2css
