Привет, Хабр! На связи Дима Мандельштам, мобильный разработчик в core‑команде Яндекс 360, и Лёша Карпенко, руководитель команды дизайн‑системы. Дизайн — часть повседневной работы наших команд, и он не живёт отдельно. Поэтому сегодня в статье мы вместе поговорим о том, как мы собирали компонент List‑item.

Мобильные разработчики в этой статье найдут метод, с которым можно достичь баланса между гибкостью кода и простотой поддержки. Мы расскажем, как применили data‑driven‑подход к рефакторингу UI: написали анализатор AST для поиска реальных паттернов использования и вывели математическую метрику сложности API. А ещё расскажем о том, как аргументировать и провести масштабную переработку legacy‑кода, не останавливая продуктовую разработку.

Продуктовым дизайнерам будет интересен альтернативный взгляд на проектирование List‑item в дизайн‑системе. Мы разберём, как собирать List‑item — как универсальный компонент с максимальной гибкостью или как набор семантических шаблонов под конкретные сценарии.

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


Отправная точка

В Яндекс 360 мы создаём 13 продуктов для личных дел, учёбы и работы. Чтобы пользовательский опыт оставался единым и консистентным, мы работаем над дизайн‑системой, которая объединяет все сервисы и продукты, — над «Орбитой».

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

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

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

Проблемы дизайна

Рассмотрим List‑item. До рефакторинга это был фундаментальный компонент для проектирования интерфейсов в Яндекс 360. В процессе проектирования дизайнеры используют его в самых разных сценариях, например: 

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

  • Когда надо просто представить информацию на экране в виде структурированных строк.

  • Когда строка списка становится интерактивным элементом,который ведёт к следующему шагу или детализирует действие.

Фактически List‑item стал «рабочей лошадкой»: его используют везде, где требуется последовательная подача данных. Однако семантические роли компонента до сих пор никак не были регламентированы в дизайн‑системе. Один и тот же визуальный паттерн применялся как для выбора, так и для навигации, информирования или управления — что неизбежно приводило к разночтениям в интерфейсе.

List-item и его возможные конфигурации
List‑item и его возможные конфигурации

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

Этот разрыв между намерением дизайнера и поведением компонента стал одной из ключевых причин для пересмотра роли List‑item в «Орбите».

Казалось бы, это самый гибкий компонент нашей дизайн‑системы, и изначально мы именно так о нём и думали. Дизайн‑система отдавала набор слотов, которые иногда были предзаполнены с минимальным форматированием: типографика, базовые отступы и прочее. При этом мы никак не управляли содержимым этих слотов. При таком подходе количество вариаций компонента начинает стремительно расти: если примерно посчитать, сейчас это более 272 000 000 возможных комбинаций. 

Состав компонента: 1 — Start-slot, 2 — Content, 3 — End-slot
Состав компонента: 1 — Start‑slot, 2 — Content, 3 — End‑slot

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

Разница реализаций одной и той же задачи с гибким компонентом
Разница реализаций одной и той же задачи с гибким компонентом

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

Проблемы разработки

При анализе дизайн‑системы с точки зрения разработки мы обнаружили тот же набор проблем, что и в дизайне. За «универсальность» компонента приходится платить более сложным и менее предсказуемым API для разработчиков. В случае с ListItem разработчику нужно было думать о 12 различных параметрах API:

@Composable
fun ListItem(
    label: ListItem.Text.Label?,
    startSlot: ListItem.StartSlot,
    modifier: Modifier = Modifier,
    endSlot: ListItem.EndSlot = ListItem.EndSlot.None,
    enabled: Boolean = true,
    description: ListItem.Text.Description? = null,
    superscript: ListItem.Text.Superscript? = null,
    middleSlot: @Composable (() -> Unit)? = null,
    bottomSlot: @Composable (() -> Unit)? = null,
    divider: ListItem.Divider? = null,
    hierarchyPadding: Dp? = null,
    onClick: (() -> Unit)? = null,
)

Помимо проблем от количества параметров, при использовании добавляло сложность и то, что startSlot и endSlot — не просто иконка или текст, а sealed‑интерфейс с самыми разными реализациями. Например, в случае с ListItem.StartSlot параметр мог принимать семь различных форм: Icon, Avatar, IconTile, Status, Image, Emoji и None. У каждого из этих вариантов, в свою очередь, был собственный набор параметров.

В результате, чтобы собрать один элемент списка, разработчику приходилось:

  • изучить 12 параметров API ListItem;

  • выбрать каждую реализацию для ListItem.StartSlot, ListItem.EndSlot и прочих параметров.

Количество возможных комбинаций становилось неконтролируемым. Это начало порождать неявные контракты и вопросы: «Что произойдёт, если передать onClick в ListItem и вместе с этим использовать EndSlot.Button со своим onClick?» Нагрузка на разработчиков начала становиться несоизмеримой с простотой решаемой задачи.

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

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

  1. Высокий порог вхождения и сложность API. Разработчикам приходилось тратить неоправданно много времени на изучение всех параметров и их комбинаций для решения типовых задач. Это замедляло разработку и повышало вероятность ошибки. 

  2. Неконсистентность и дублирование кода. Отсутствие готовых решений для частных, но распространённых сценариев приводило к тому, что каждая команда решала одни и те же задачи по‑своему. Это нарушало основной принцип дизайн‑системы — быть единым источником правды для UI.

Стало очевидно, что необходим инстр��мент для измерения и контроля сложности компонентов. Мы решили разработать метрику, которая позволила бы нам объективно оценивать сложность API и принимать решения о его рефакторинге или декомпозиции. Нашей целью было не только упростить ListItem, но и создать системный подход к управлению сложностью всей дизайн‑системы.

Найденное решение 

Чтобы перейти от субъективных ощущений («этот компонент слишком сложный») к объективным данным, мы разработали метрику для оценки сложности API компонента. Она позволила нам не только подтвердить проблему с ListItem, но и создать систему для контроля сложности всех компонентов в будущем.

Метрика сложности компонента

Расчёт метрики основан на нескольких ключевых принципах:

  • Количество параметров. Больше параметров — выше сложность. При этом обязательные параметры вносят больший вклад в итоговый балл, чем опциональные.

  • Типы данных. У простых типов (String, Boolean, Int) — минимальный балл. Сложность составных типов, таких как data class, зависит от количества и сложности их составляющих.

  • Иерархия и состояния. Sealed‑интерфейсы и классы получают высокий балл сложности, так как каждый их наследник — это отдельный сценарий использования, который разработчик должен держать в голове. Именно этот пункт внёс основной вклад в сложность ListItem с его startSlot и endSlot.

  • Функциональные типы. Лямбды также увеличивают сложность, так как определяют дополнительный контракт внутри компонента.

На основе этих принципов мы реализовали скрипт, который оценивает сложность публичного API дизайн‑системы. За базовую единицу сложности мы приняли простые типы данных. Для любых составных, иерархических или функциональных типов применяется рекурсивная декомпозиция: мы спускаемся по дереву типов до примитивов, суммируя их веса. Получившаяся метрика позволяет выявить сложные с точки зрения API компоненты.

Технически это реализовано через механизм Kotlin Reflection. Это позволяет скрипту динамически заглядывать внутрь сложных объектов и sealed‑классов, чтобы оценить реальный вес всех возможных состояний, которые разработчику приходится держать в голове.

В упрощённом виде алгоритм выглядит так:

fun calculateApiComplexity(component: KFunction<*>): Double {
    return component.parameters.sumOf { param ->
        val typeScore = calculateTypeScore(param.type)
        if (param.isOptional) typeScore.toDouble() else typeScore * 1.5
    }
}

fun calculateTypeScore(type: KType): Int {
    return when {
        // Примитивы -- 1 балл
        type.isPrimitive -> 1

        // Функциональные типы: 1 балл + сложность аргументов + сложность возвращаемого значения
        type.isFunctionType -> {
             1 + type.arguments.sumOf { calculateTypeScore(it.type) }
        }

        // Sealed Classes: Сумма сложностей всех возможных наследников
        type.isSealed -> type.sealedSubclasses.sumOf { 
             calculateTypeScore(it.starProjectedType) 
        }

        // Data Classes: Сумма сложностей всех полей
        else -> type.memberProperties.sumOf { 
             calculateTypeScore(it.returnType) 
        }
    }
}

Когда мы применили метрику к ListItem, результат оказался предсказуемо высоким: он превышал средние показатели по дизайн‑системе в несколько раз. Это стало финальным аргументом в пользу того, что компонент нужно не исправлять, а кардинально переделывать.

Анализ использований компонента

Итак, мы приняли решение о декомпозиции ListItem. Чтобы нам было на что опираться, мы решили собрать статистику о его использовании в продуктовом коде.

Для этого мы реализовали AST‑анализатор, который просканировал репозитории мобильных продуктов Яндекс 360 и собрал статистику вызовов ListItem. Скрипт группировал вызовы по набору переданных аргументов, выявляя устойчивые паттерны.

Верхнеуровнево алгоритм получился следующим:

fun analyzeComponentUsage(projectRoot: File, componentName: String) {
    val patternFrequency = mutableMapOf<Set<String>, Int>()
    val paramFrequency = mutableMapOf<String, Int>()
    projectRoot.walkTopDown()
        .filter { it.extension == "kt" } // Ищем только Kotlin-файлы
        .forEach { file ->
            // Используем PSI или простой парсинг для поиска AST-узлов
            val syntaxTree = parseKotlinFile(file)
            // Находим все вызовы конкретной функции
            val functionCalls = syntaxTree.findCallsByName(componentName)
            functionCalls.forEach { call ->
                // Извлекаем имена параметров, которые были явно переданы
                // Например: [label, startSlot, endSlot]
                val assignedParams = call.valueArguments.map { it.argumentName }
                
                // Считаем общую частоту параметров
                assignedParams.forEach { param ->
                    paramFrequency[param] = paramFrequency.getOrDefault(param, 0) + 1
                }
                // Определяем уникальную сигнатуру вызова (паттерн)
                val usagePattern = extractPatternSignature(call) 
               patternFrequency[usagePattern] = patternFrequency.getOrDefault(usagePattern, 0) + 1
            }
        }
    generateMarkdownReport(paramFrequency, patternFrequency)
}

Результаты работы скрипта позволили выделить отчётливые паттерны. Несмотря на то, что компонент поддерживал сотни теоретических комбинаций, в 80% случаев разработчики использовали одни и те же три‑четыре устойчивых паттерна.

Вот пример отчёта, который мы получили, и выводы, которые мы сделали о параметрах:

Параметр

Частота использования (%)

Выводы

label

100%

Обязателен везде

startSlot

100%

Используется всегда, но часто это None

endSlot

100%

Ключевой маркер различий (кнопка, тогл, радио)

modifier

40%

Часто требуется кастомная стилизация

onClick

32%

Треть списков — интерактивные

hierarchyPadding

3%

Крайне редкий кейс, возможно, стоит убрать из API

Кроме частоты использования определённых параметров мы также смогли увидеть паттерны использования. Оказалось, что среди всех использований можно выделить устойчивые паттерны. Например, 20% использований приходится на следующий паттерн:

ListItem(
    label = Label, 
    endSlot = Toggle, 
    startSlot = ListItem.StartSlot.None
)

Этот отчёт стал нашей «дорожной картой». Мы увидели, что нам не нужно самим заранее придумывать специфические сценарии использования. Вместо этого достаточно создать пять‑шесть специализированных компонентов, чтобы покрыть 90% потребностей продукта. Для других специфических использований мы оставили доступ к общему API ListItem.

Вооружившись метрикой сложности и статистикой реального использования атома, мы перешли к проектированию.

Решение в Figma: от универсального компонента к семантическим шаблонам

В Figma карта использований стала для нас опорой при проектировании новых паттернов, но не единственной. Вторым, не менее важным ориентиром стали здравый смысл и семантические роли компонентов.

В процессе анализа мы заметили, что во многих сценариях именно Start‑slot фактически определяет роль List‑item. Например, если в Start‑slot появляется аватар, то почти всегда речь идёт о репрезентации пользователя. Такой List‑item закономерно тянет за собой специфический набор параметров: отображение фамилии, имени, отчества, статус пользователя, признак типа пользователя, почту и другие атрибуты, связанные именно с персональными данными.

Новая сетка компонента для List-item-avatar
Новая сетка компонента для List‑item‑avatar

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

Это наблюдение позволило нам достаточно быстро сформировать логические группы компонентов, основанные не на визуальных различиях, а на их роли и смысле:

  • List‑item‑avatar — для представления пользователей;

  • List‑item‑file — для отображения файлов;

  • List‑item‑data — для неинтерактивной информации (например, для параграфов текста или статических строк данных);

  • List‑item‑menu — для меню и навигационных списков;

  • и другие специализированные варианты.

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

Например, для List‑item‑file мы можем максимально подробно описать:

  • как компонент ведёт себя в процессе загрузки;

  • как отображаются разные статусы;

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

  • какие контролы для него уместны, а какие — нет.

Новая сетка компонента для List-item-file
Новая сетка компонента для List‑item‑file

При этом все эти правила изначально заложены в шаблон и не требуют от дизайнера каждый раз принимать решение «с нуля».

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

  • вносить изменения точечно — например, только в List‑item‑avatar;

  • тестировать ограниченный и чётко определённый набор параметров;

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

Фактически мы сместили фокус с «гибкости компонента» на предсказуемость поведения, не теряя при этом возможности расширения там, где это действительно необходимо.

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

Декомпозиция в коде

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

  1. Ядро компонента. Мы вынесли всю логику универсального компонента в отдельный приватный метод ListItemBase. Это внутренний, базовый компонент, который содержит всю общую логику отображения: расположение слотов, отступы, обработку кликов, отображение разделителя. Его API остался таким же большим и сложным.

  2. Специфичные компоненты‑адаптеры. Наружу мы отдали набор лёгких компонентов‑шаблонов. Их задача — быть адаптерами и добавлять оригинальную продуктовую логику. Они принимают от разработчика более простые типы данных и преобразуют их в сложные конфигурации, которые ожидает ядро компонента.

На уровне кода это выглядит следующим образом. Допустим, мы строим компонент ListItemAvatar. От разработчика API нового компонента ожидает title типа String и avatarIcon типа Icon. Мы берём эти данные и упаковываем их в специальные sealed‑классы, которые понимает ядро компонента. Внутри title превращается в ListItem.Text.Label, а avatarIcon оборачивается в ListItem.StartSlot.Avatar.

@Composable
fun ListItemAvatar(
    title: String,
    avatarIcon: Icon,
    modifier: Modifier = Modifier,
    subtitle: String? = null,
    onClick: (() -> Unit)? = null
) {
    ListItemBase(
        modifier = modifier.clickable(enabled = onClick != null) { onClick?.invoke() },
        
        // Адаптируем данные: String -> Complex Label Object
        label = ListItem.Text.Label(title), 
        
        // Адаптируем данные: String -> Complex Sealed Class
        startSlot = ListItem.StartSlot.Avatar(
            icon = avatarIcon,
            size = ListItem.Size.M
        ),
        
        // Фиксируем слоты, которые не нужны в этом сценарии
        endSlot = ListItem.EndSlot.None, 
        
        description = subtitle?.let { ListItem.Text.Description(it) }
    )
}

А теперь — выводы 

Работа над упрощением ListItem стала для нас важным уроком. Мы перестали гнаться за абстрактной гибкостью и начали опираться на реальные данные и эргономику использования.

  1. Метрики говорят сами за себя. Чтобы оценить, насколько новые компоненты стали проще в использовании, мы оценили их по формуле расчёта сложности. Оказалось, что новые атомы в среднем в три раза проще оригинального ListItem. Таким образом, мы в разы снизили нагрузку на разработчиков продуктовых команд при работе над компонентом.

  2. Влияние на процессы. Мы сократили скорость разработки (TTM), так как разработчику больше не нужно держать в голове документацию компонента. За счёт ограничения количества параметров IDE при написании кода предлагает более актуальные подсказки, а вероятность ошибиться при использовании компонента кратно снижена.

    А ещё улучшили консистентность. Жёсткие контракты новых компонентов физически не позволяют собрать «монстра Франкенштейна», которого не было в дизайне. Интерфейсы разных продуктов Яндекс 360 стали выглядеть и вести себя более единообразно.

  3. Новый подход к проектированию. Главный урок, который мы вынесли: декомпозировать компоненты нужно не в момент написания кода, а на этапе проектирования в Figma. Теперь, создавая новый атом, мы заранее моделируем сценарии его использования и оцениваем сложность API. Мы перестали спрашивать себя: «А что, если это когда‑нибудь понадобится?» — и начали спрашивать: «Какие три сценария покрывают 90% задач?»

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