Содержание
Приветствую, уважаемые читатели Хабра.
Вероятно, в вашей команде уже всерьёз обсуждается необходимость внедрения дизайн-системы, а может, вы уже её активно внедряете.
Важное место в дизайн-системе занимают токены — самые низкоуровневые элементы, которые являются базой для всех компонентов и экранов.
К токенам можно отнести:
цвета,
типографику,
радиусы/формы,
паддинги/отступы,
иконки.
Список этим не ограничивается. По сути, токен — это любая информация, ассоциированная с именем. Некая константа, например:
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