При разработке ИТ‑продуктов команды нередко сталкиваются с ситуацией, когда разные части проекта имеют различный внешний вид и поведение, что затрудняет поддержку, ведет к увеличению сроков выпуска обновлений и ухудшению качества продукта.

Решением подобных проблем является дизайн‑система, которая обеспечивает единую библиотеку компонентов и строгие правила их использования, позволяя сократить время разработки, минимизировать расхождения между частями продукта и значительно облегчить дальнейшую работу над проектом.

Меня зовут Илья Гущин. Я старший Android‑разработчик в СберЗдоровье — MedTech компании № 1 в России. В этой статье я расскажу, как мы в СберЗдоровье строили мобильную дизайн‑систему и оптимизировали интерфейсы.

Что такое дизайн-система и зачем она нужна

Дизайн‑система — набор стандартов, правил и компонентов для создания согласованных интерфейсов. Фактически дизайн‑система выступает единым источником, из которого каждый специалист может узнать, как именно реализовывать тот или иной компонент приложения.

Дизайн‑система решает несколько проблем, с которыми потенциально могут столкнуться многие компании.

  • Несогласованность интерфейса. Отсутствие единого стиля оформления приводит к ситуации, когда разработчики разных платформ руководствуются своими гайдами: например, команда Android предпочитает рекомендации Material Design, тогда как дизайнеры iOS ориентируются на Human Interface Guidelines. Это создает путаницу и снижает качество конечного продукта. Дизайн‑система устанавливает единые стандарты оформления и компоненты, применимые ко всем платформам, исключая различия в стилях между Android и iOS и обеспечивая единообразие восприятия.

  • Медленная разработка. Часто возникает необходимость внесения изменений в интерфейс приложения (например, изменение цвета текста в разных кнопках). Такие мелкие изменения требуют переработки большого объема кода, что замедляет процесс разработки и увеличивает сроки выпуска обновлений. Наличие готовых шаблонов и компонентов позволяет легко вносить изменения в интерфейс, сокращая объем необходимой доработки кода и ускоряя выпуск обновлений.

  • Высокие затраты на поддержку. Постоянные модификации, создание новых элементов и ресурсов для новых фичей (или для новой функциональности) приводят к росту затрат на сопровождение проекта. Каждый новый компонент требует дополнительного тестирования и поддержки, что несёт соответствующую нагрузку на бюджет. Использование стандартных (дс‑ных) элементов уменьшает потребность в создании уникальных решений, упрощает тестирование и существенно снижает расходы на поддержку проекта.

  • Командные конфликты. Недостаточная согласованность процессов может провоцировать внутренние разногласия среди членов команды. Четко прописанные правила и единый источник устраняют неопределенность, снижая вероятность конфликтов между членами команды и позволяя быстрее достигать согласия по ключевым вопросам.

История развития дизайн-системы в приложении СберЗдоровье

Дизайн‑система СберЗдоровья развивалась последовательно — по мере роста сложности наших ИТ‑продуктов.

Так, изначально в нашем приложении были базовые функции, поэтому и компоненты интерфейса были достаточно простым. Поэтому с 2016 по 2020 год мы фактически использовали набор компонентов под фичи

Этого было достаточно для наших задач, но создавало определенные сложности — например, реализованные фичи было непросто переиспользовать в других модулях приложения. 

Постепенно приложение росло, а пул доступных пользователям возможностей расширялся.

В результате подход, подразумевающий работу с набором отдельных компонентов под фичи, стал неоптимальным для нас. Понимая это, мы инициировали разработку первой версии своей дизайн‑системы, которую написали на Uikit/XML. Наполняли и использовали мы ее с 2020 по 2022 год.

В период с 2023 по 2024 год мы отчасти остановили работу с дизайн‑системой, а сфокусировались на использовании того, что уже было.

Предполагалось, что уже существующий набор стандартов и компонентов позволит нам минимизировать издержки на период реорганизации. Практически так и получилось, но не без исключений. Всё это время наши сервисы развивались, получали новые функции и обновления. В том числе, создавались новые компоненты, для которых в дизайн‑системе ещё не могло быть готовых решений. Таким образом формировались возможные риски, о которых шла речь выше.

Понимая важность дизайн‑системы мы начали использовать дизайн‑систему версии 2.0, в которой устранили некоторые шероховатости первой реализации и перешли на новый стек — Jetpack Compose и SwiftUI.

Состав дизайн-системы: токены

Любая дизайн‑система формируется из токенов — уникальных идентификаторов ресурсов, которые помогают четко и правильно понять, с чем мы работаем. К токенам можно отнести определенные наборы цветов, отступов и других настроек компонентов.

В коде токены выглядят следующим образом.

Android:

DSHolder.dimens.opacity.opacity8
DSHolder.colors.neutral.softTextPrimary
DSHolder.typography.body.M

iOS:

.dsOpacity.opacity8
.dsTheme.neutral.softTextPrimary
.dsBasicCornerRadius.XL

При этом мы в компании используем так называемый атомарный подход, то есть условно делим все компоненты на несколько уровней.

  • Атомы — неделимые части компонентов (например, лоадеры, разделители, чекбоксы).

Для наглядности рассмотрим, как атомы выглядят в коде.

В Android:

DSCheckBox(
    modifier = Modifier.fillMaxWidth(),
    colorScheme = DSCheckBoxControlScheme.Neutral,
    isChecked = false,
    isMixed = false
…)

iOS:

DSCheckbox(
    isChecked: .constant(false),
    isMixed: .constant(false)
)
.dsCheckboxAppearance(
    colorScheme: .neutral
)

Здесь нет большого количества настроек, а сами компоненты достаточно простые — сразу понятно, для чего они используются. 

  • Молекулы — простые составные компоненты (например, кнопки, поля, контролы).

Посмотрим, как молекулы выглядят в коде.

В Android:

DSButton(
    label = "Сохранить",
    onClick = { },
    variant = DSButtonVariant.Primary,
    scheme = DSButtonColorScheme.Accent,
    size = DSButtonSize.SizeL,
    startIcon: DSIcons.icSwapOutline24
)

В iOS:

DSButton(
    label: "Сохранить",
    startIcon: DSIcons.icSwapOutline24.swiftUIImage,
    action: {}
)
.dsButtonAppearance(
    variant: .primary,
    colorScheme: .accent,
    size: .L
)

Здесь появляются более сложные настройки, поскольку сами компоненты содержат UI сложнее.

  • Организмы — сложносоставные компоненты (например, списки, карточки, ячейки).

На уровне кода организмы сложнее атомов и молекул.

В Android:

DSBottomSheet(
    modifier = Modifier.fillMaxWidth(),
    isFullHeight = false,
    headerPosition = DSBottomSheetHeaderPosition.STACK,
    onDismiss = { /* Закрыть шторки */  },
    doOnExpanded = { /* При полном раскрытии */ },
    doOnPartiallyExpanded = { /* При частичном раскрытии */ },
    doOnHidden = { /* При скрытии */ }
…)

В iOS:

DSBottomSheet(
    isPresented: isPresented,
    isFullHeight: isFullHeight,
    headerPosition: .none,
    settings: .standard,
    header: { EmptyView() },
    content: content,
    screen: self,
    onDismiss: onDismiss
…)

Здесь видно, что организмы содержат больше настроек и более подробно описывают логику происходящего с компонентами.

Чуть подробнее о цветах

Теперь, когда мы разобрались с токенами и нашим видением компонентов, остановимся подробнее на том, без чего не может обойтись ни одна дизайн‑система — на цветах.

Верхнеуровнево, используемая в СберЗдоровье палитра цветов выглядит примерно так:

Примечание: Это лишь часть используемых цветов.

При этом все цвета делятся на группы, которые содержат набор оттенков под разные сценарии.

Чтобы понять, как с этим работать, рассмотрим код под Android. Сразу оговорюсь, что на iOS здесь отдельно задерживаться не будем, поскольку всё довольно похоже.

Итак, одной из базовых сущностей для работы с цветами на уровне кода является DSColorHolder — класс, предназначенный для управления цветами элементов интерфейса, включая изменение цветов фона, текста, кнопок и других визуальных компонентов. Его часто используют разработчики для настройки внешнего вида приложений.

data class DSColorHolder(
    val accent: DSColorScheme,
    val accentAlt: DSColorScheme,
    val critical: DSColorScheme,
    val fucsia: DSColorScheme,
    val gradient: DSGradientScheme,
    val info: DSColorScheme,
    val malachite: DSColorScheme,
    val neutral: DSColorScheme,
    val orchid: DSColorScheme,
    val pink: DSColorScheme,
    val skyBlue: DSColorScheme,
    val spring: DSColorScheme,
    val success: DSColorScheme,
    val sunny: DSColorScheme,
    val warning: DSColorScheme
)

При этом DSColorHolder имеет свойство типа DSColorScheme — структуру, содержащую полную коллекцию цветов, объединённых в единую дизайн‑систему. DSColorScheme определяет общие правила использования цветов и консолидирует разные типы оттенков (нейтральные, акценты, состояние и другие). Например, DSColorScheme может включать цвета для отображения теней, текста, фона и других компонентов.

interface DSColorScheme {
    val elevation01: Color
    val elevation02: Color
    val elevation03: Color
    val hardBgPrimary: Color
    val hardBgSecondary: Color
    val hardStrokePrimary: Color
    val hardStrokeSecondary: Color
    val hardSurfaceCard: Color
    val hardSurfacePrimary: Color
    val hardSurfaceSecondary: Color
    val hardSurfaceTertiary: Color
    val hardTextParagraph: Color
    val hardTextPrimary: Color
    val hardTextSecondary: Color
    val hardTextTertiary: Color
}

Таким образом, DSColorHolder — что‑то вроде контейнера для хранения конкретных наборов цветов. Однако, чтобы эффективно организовать цвета внутри этого контейнера, нужна дополнительная структура. Эта промежуточная прослойка представлена перечисляемым типом (Enum), называемым ColorSet. Именно ColorSet выступает своеобразным каталогом, объединяя все доступные наборы цветов. При помощи отдельных элементов перечисления задаются конкретные цветовые комбинации для каждой категории. 

Чтобы понять, как это выглядит в коде, можно рассмотреть фрагмент реализации набора акцентных цветов:

internal object DSColorSets {
    enum class DarkDSColorSets(
        override val hardBgPrimary: Color,
        override val hardBgSecondary: Color,
        override val hardSurfaceCard: Color,
        override val hardSurfacePrimary: Color,
        override val hardSurfaceSecondary: Color,
        override val hardSurfaceTertiary: Color
    ) : DSColorScheme {
        Accent(
            hardBgPrimary = DSCorePalette.PinkO,
            hardBgSecondary = DSCorePalette.Pink320,
            hardSurfaceCard = DSCorePalette.Pink480,
            hardSurfacePrimary = DSCorePalette.Pink80,
            hardSurfaceSecondary = DSCorePalette.Pink840,
            hardSurfaceTertiary = DSCorePalette.Pink800
        )
    }
}

Учитывая наличие в нашем мобильном приложении отдельных наборов цветов для темной и светлой тем, контрастной темной и контрастной светлой тем, чтобы охватить весь спектр необходимых цветов, мы применяем дополнительный объект — DSColors. Он предназначен для объединения всех четырех вариаций в единую структуру, обеспечивающую их хранение и удобную манипуляцию при смене пользовательских настроек.

internal object DSColors {
    val LightThemeColors = DSColorHolder(
        accent = LightDSColorSets.Accent,
        critical = LightDSColorSets.Critical,
        fuchsia = LightDSColorSets.Fuchsia,
        info = LightDSColorSets.Info,
        malachite = LightDSColorSets.Malachite,
        neutral = LightDSColorSets.Neutral,
        orchid = LightDSColorSets.Orchid,
        pink = LightDSColorSets.Pink,
        skyBlue = LightDSColorSets.SkyBlue,
        spring = LightDSColorSets.Spring,
        success = LightDSColorSets.Success,
        sunny = LightDSColorSets.Sunny,
        warning = LightDSColorSets.Warning
    )
}

Таким образом, работа с цветами упрощенно сводится к следующему:

  • DSColors содержит совокупность всех возможных цветов, применяемых в приложении;

  • эти наборы используются объектом DSColorHolder, который объединяет множество мелких групп цветов;

  • для заполнения этих групп применяется интерфейс DSColorSchemes, который наполняется значениями через специальные перечисления (enum), где указаны точные значения для каждой группы цветов.

Для наглядности рассмотрим, как работать со всем этим массивом на примере задачи смены темы в мобильном приложении.

Пример реализации в коде Android-приложения

Для начала взглянем на реализацию в коде Android‑приложения:

@Composable
fun DSTheme(
    settingsProvider: DSSettingProvider = DefaultSettings,
    content: @Composable () -> Unit
) {
    val currentSettings by settingsProvider.currentSettings.collectAsState()
    val colors = if (currentSettings.isDarkMode) {
        if (currentSettings.isContrastMode) DarkContrastThemeColors else DarkThemeColors
    } else {
        if (currentSettings.isContrastMode) LightContrastThemeColors else LightThemeColors
    }

    CompositionLocalProvider(
        LocalDSColors provides colors,
        LocalDSSettingProvider provides settingsProvider,
        content = content
    )
}

private val LocalDSColors = staticCompositionLocalOf { LightThemeColors }
private val LocalDSSettingProvider = staticCompositionLocalOf { DefaultSettings }

Здесь примечательно несколько моментов.

Есть функция DSTheme, выступающая точкой входа в мобильный интерфейс в рамках архитектуры Jetpack Compose. Внутри неё задаётся SettingProvider — объект, содержащий настройки приложения, включая параметры контрастности и выбранной темы. Нас интересует именно работа с темой.

В зависимости от текущих настроек выбирается соответствующая группа цветов. Затем эта конфигурация передается в компонент CompositionLocalProvider, который выступает посредником для передачи значений цветов по дереву композиции в Jetpack Compose.

Переданные таким образом цвета становятся доступными во всей иерархии Composable‑функций, позволяя удобно обращаться к ним при создании UI‑элементов. Помимо цветов, через тот же механизм предоставляются и другие необходимые настройки.

При обращении за определенным цветом из любого места интерфейса система возвращает подходящий оттенок, соответствующий заданным настройкам контрастности и общей цветовой гаммы.

Пример реализации в коде iOS-приложения

Теперь посмотрим на то, как это устроено в iOS.

Вначале выполняется предварительная инициализация цветов: заранее определяются оттенки для светлого режима (light), тёмного режима (dark), а также специальных версий с повышенной контрастностью — для светлого (light‑contrast) и тёмного (dark‑contrast). Эта подготовка необходима, чтобы в нужный момент можно было выбрать наиболее подходящий цвет согласно установленным системным предпочтениям.

public extension Color {

    init(
        light: Color,
        dark: Color,
        lightContrast: Color,
        darkContrast: Color
    ) {
        self = Color(
            UIColor { (trait) -> UIColor in
                switch (trait.isDark, trait.isContrast) {
                case (false, false):
                    return UIColor(light)
                case (true, false):
                    return UIColor(dark)
                case (false, true):
                    return UIColor(lightContrast)
                case (true, true):
                    return UIColor(darkContrast)
                }
            }
        )
    }
}

Выбор конкретного оттенка осуществляется с использованием особого механизма — UITraitCollection. Через это расширение реализуются дополнительные свойства для представления текущего состояния системы. 

К стандартному набору добавляется пара ключевых переменных, отвечающих за выбор тёмной темы и повышение контрастности. Когда возникает необходимость получить требуемый цвет в конкретном представлении (view), именно на основании состояния UITraitCollection определяется, какой цвет соответствует текущему контексту.

fileprivate extension UITraitCollection {
    var isDark: Bool {
        userInterfaceStyle == .dark
    }

    var isContrast: Bool {
        accessibilityContrast == .high
    }
}

От цветов к компонентам

Теперь перейдем к компонентам. 

Сразу рассмотрим реализацию в коде Android‑приложения:

@Composable
fun DSButton(
    label: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    isEnabled: Boolean = true,
    isLoading: Boolean = false,
    scheme: DSButtonColorScheme = DSButtonColorScheme.Accent,
    variant: DSButtonVariant = DSButtonVariant.Primary,
    size: DSButtonSize = DSButtonSize.Size1,
    @DrawableRes startIcon: Int? = null,
    @DrawableRes endIcon: Int? = null
) {...}

Здесь примечательно, что мы указываем настройки, например, scheme, variant, size, но не передаем захардкоженных значений. Это обусловлено тем, что мы используем компонентные токены — дополнительную семантическую прослойку, которая содержит информацию о возможных состояниях компонентов. 

Например, при нажатии кнопки и указании фона, мы сразу используем подходящие токены, понимая, от каких факторов зависит изменение внешнего вида компонента.

Рассмотрим пример на коде.

Допустим, у нас есть абстрактная кнопка, и значение, используемое в её оформлении, будет зав��сеть от выбранного варианта. Этот вариант предварительно установлен при создании соответствующей функции. В зависимости от выбранного варианта мы не назначаем фиксированный цвет, а вызываем специальную функцию, которая обладает определёнными семантическими свойствами.

private fun getAccentButtonBackgroundColor(variant: DSButtonVariant) =
    when (variant) {
        DSButtonVariant.Primary -> dSButtonColorAccentBgPrimaryEnabled()
        DSButtonVariant.Secondary -> dSButtonColorAccentBgSecondaryEnabled()
        DSButtonVariant.Inverse -> dSButtonColorAccentBgInverseEnabled()
    }

internal object DSBasicButtonTokens {
    fun dSButtonColorAccentBgPrimaryEnabled() = DSHolder.colors.accent.hardSurfacePrimary
    fun dSButtonColorAccentBgSecondaryEnabled() = DSHolder.colors.accent.softSurfaceSecondary
    fun dSButtonColorAccentBgInverseEnabled() = DSHolder.colors.accent.softSurfaceCard
    …        
}

Семантика этой функции чётко отражает назначение каждого элемента, что облегчает чтение и понимание кода — мы заранее видим, как и в каких ситуациях будет использован тот или иной токен. Благодаря этому у разработчиков практически не возникают сомнения относительно полноты учёта всех состояний при разработке компонента — если использованы все необходимые элементы, вероятность правильной реализации заметно возрастает.

Поддержка и сопровождение

Теперь остановимся на том, как мы всё это поддерживаем. В этом нам помогает Demo App, документация и правила заведения новых компонентов. 

Demo App

Для проведения дизайн‑ревью мы используем Demo App — программное решение, предназначенное для демонстрации функциональности, возможностей или особенностей мобильных приложений. 

У нас есть Demo App как для Android, так и для iOS.

С его помощью мы можем проверять, как будут вести себя любые компоненты в разных состояниях.

Также у нас есть экраны, где мы можем открыть отдельные компоненты и заранее задать им разнообразные настройки. Благодаря этому можно отслеживать, какие параметры принимает компонент, какие анимационные эффекты проявляются при изменениях этих настроек.

Важным преимуществом является и то, что весь исходный код находится в одном месте. Это позволяет детально изучить способы использования компонентов, методы их управления, а также просмотреть работу списков и прочих элементов дизайн‑системы через один интерфейс.

Документация

Для упрощения работы с дизайн‑системой и онбординга, мы:

  • формируем и ведем подробную документацию на каждый компонент дизайн‑системы;

  • ведем раздел терминологии дизайн‑системы, который позволяет разработчикам и дизайнерам четко понимать друг друга;

  • собираем лучшие практики по использованию дизайн‑системы для шаринга экспертизы.

Чек-лист для создания новых компонентов 

Кроме того, мы разработали чек‑лист корректного создания новых компонентов. То есть разработчику предлагается воспользоваться шаблоном и последовательно пройти по пунктам списка.

Чек‑лист предусматривает проверку следующих аспектов:

  • определение правильного местоположения нового компонента;

  • написание документации для компонента и его вспомогательных функций;

  • обеспечение соответствия внесённых настроек существующим стандартам, с проверкой их применения в демо‑приложении.

Помимо этого, мы добавляем ссылку на страницу соответствующего компонента в Figma и задачу в трекере, позволяющую глубже погрузиться в детали вопроса.

О результатах и планах

Внедрение дизайн‑системы и ее последовательное совершенствование помогло нашей компании исключить появление хаоса и недопонимания в процессах команд, выработать единые стандарты, сократить time‑to‑market любых релизов и упростить онбординг специалистов. 

Вместе с тем, мы продолжаем работу над дизайн‑системой. Так, на ближайшую перспективу мы ставим перед собой несколько целей. Среди таковых:

  • полноценное использование всего доступного инструментария дизайн‑системы;

  • автоматизация генерации токенов с помощью плагинов;

  • использование Snapshot-тестов и повышение метричности для получения большего объема актуальной информации о работе всех компонентов.

О том, как будет продвигаться наша работа над дизайн‑системой и какие результаты мы сможем достичь обязательно расскажем в одной из следующих статей.