Кодогенерация токенов дизайн-системы для Android по Figma API

от автора

Содержание

Приветствую, уважаемые читатели Хабра.

Вероятно, в вашей команде уже всерьёз обсуждается необходимость внедрения дизайн-системы, а может, вы уже её активно внедряете. 

Важное место в дизайн-системе занимают токены — самые низкоуровневые элементы, которые являются базой для всех компонентов и экранов.

К токенам можно отнести:

  • цвета,

  • типографику,

  • радиусы/формы,

  • паддинги/отступы,

  • иконки.

Список этим не ограничивается. По сути, токен — это любая информация, ассоциированная с именем. Некая константа, например:

color-text-primary: #000000

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

Основные причины:

  • держать токены консистентными и синхронизированными с дизайном;

  • снизить человеческий фактор (ошибки);

  • экономить время разработчика на обновлении токенов.

Все эти причины можно смело умножить на два ввиду BDUI.

В том, что мы действительно идём в правильном направлении, мы убедились, взглянув на масштаб работ ?.

Иконки в нашей Figma

Иконки в нашей Figma
Токены цвета и радиусов в нашей Figma

Токены цвета и радиусов в нашей Figma

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.

Все изображения в дизайн-системе подразделяются на:

  • Иллюстрации: обычно имеют большие размеры, например, 220×280, содержат множество цветов и оттенков.

    Пример иллюстрации из нашей дизайн-системы

    Пример иллюстрации из нашей дизайн-системы
  • Иконки: обычно небольшие (в среднем около 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, храним в отдельной папке репозитория дизайн-системы. Можно всегда легко сравнивать исходные файлы с результатами генерации, если были замечены ошибки.

Директория figma-resources с оригинальными svg из Figma

Директория figma-resources с оригинальными 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:

Структура проекта build-logic

Структура проекта build-logic
Структура пакета с генератором

Структура пакета с генератором

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

Список возможных Gradle tasks

Список возможных Gradle tasks

Итоги

В нашем роадмапе дизайн-системы много планов и идей, но касательно генератора основной вектор — это совершенствовать CI/CD, автоматизацию. 

Сейчас процесс далёк от идеала: надо открыть Android Studio, вызвать Gradle-задачу, сделать коммит, пройти ревью, проставить release notes. Есть мысли в сторону автоматизации процесса релиза SDK при перегенерации токенов, а также их версионирование между платформами (Android, BDUI и, возможно, iOS). 

Для некоторых наших внутренних библиотек применяем Downstream pipelines — функциональность Gitlab Ci/CD, которая позволяет запускать пайплайны в одном репозитории по триггеру в другом. Например, merge ветки в dev. Есть идея размещать файл Variables.json в отдельном репозитории, версионировать его и при изменениях в нём триггерить релиз новой версии дизайн-системы для Android, BFF BDUI и, может быть, iOS. 

Одно знаем точно: останавливаться не собираемся, потому небольшими и уверенными итерациями продолжим уверенно двигаться дальше.

Полезные ссылки


ссылка на оригинал статьи https://habr.com/ru/articles/803565/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *