Содержание
Приветствую, уважаемые читатели Хабра.
Вероятно, в вашей команде уже всерьёз обсуждается необходимость внедрения дизайн-системы, а может, вы уже её активно внедряете.
Важное место в дизайн-системе занимают токены — самые низкоуровневые элементы, которые являются базой для всех компонентов и экранов.
К токенам можно отнести:
-
цвета,
-
типографику,
-
радиусы/формы,
-
паддинги/отступы,
-
иконки.
Список этим не ограничивается. По сути, токен — это любая информация, ассоциированная с именем. Некая константа, например:
color-text-primary: #000000
По мере развития проекта в целом и дизайн-системы в частности токенов становится очень много. Для примера — у нас уже около четырёхсот иконок, больше двухсот цветов и перспектива внедрения нескольких тем в приложении. Ещё мы активно внедрением
Основные причины:
-
держать токены консистентными и синхронизированными с дизайном;
-
снизить человеческий фактор (ошибки);
-
экономить время разработчика на обновлении токенов.
Все эти причины можно смело умножить на два ввиду 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.
Все изображения в дизайн-системе подразделяются на:
-
Иллюстрации: обычно имеют большие размеры, например, 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, храним в отдельной папке репозитория дизайн-системы. Можно всегда легко сравнивать исходные файлы с результатами генерации, если были замечены ошибки.
Работа с устаревшими (или 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
ссылка на оригинал статьи https://habr.com/ru/articles/803565/
Добавить комментарий