Содержание

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

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

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

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

  • цвета,

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

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

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

  • иконки.

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

color-text-primary: #000000

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

Меня зовут Никита Яцкивский. Занимаю позицию главного Android-разработчика в отделе разработки мобильной платформы компании «Магнит». В статье расскажу про наш тернистый путь к собственному генератору токенов дизайн-системы.

Почему решили идти в историю с автоматизацией

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

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

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

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

Все эти причины можно смело умножить на два ввиду 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.

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

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

Директория 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. 

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

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

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