Привет, Хабр! Я Александр, занимаюсь Flutter разработкой продукта Свой бизнес и Дизайн-системы в команде РСХБ.Цифра. РСХБ это не только банк топ 5* по размеру активов, но и огромная экосистема Своё со множеством мобильных приложений разной тематики: банкинг, туризм, жильё, маркетплейс фермерских продуктов, поиск работы в агропромышленном комплексе и другие .
Мобильные продукты экосистемы развиваются стремительно, в режиме стартапов, не ограничивая команды в самостоятельном создании элементов интерфейса. Часто компоненты в параллельных командах похожи, что на этапе бурного развития допустимо. Но факт остаётся фактом: это приводит к фрагментации и снижению эффективности поддержки.
Когда продукты завершают взрывной рост и появляется необходимость в единстве дизайна и согласованности компонентов, в действие вступает единая дизайн-система. В этой статье рассмотрим инструмент Widgetbook для проектов на Flutter, а именно, как он позволяет ускорить проектирование, контролировать качество компонентов и замыкать единый процесс взаимодействия дизайнеров и разработчиков.

Почему традиционные подходы больше не подходят?
Часто проекты используют автономные подходы к созданию компонентов интерфейса. И даже в разных модулях одного приложения могут появляться разные версии одного элемента, которые хоть немного, но отличаются друг от друга. Это вводит пользователей в заблуждение и снижает качество продукта. Часто команды копируют компоненты вручную, нарушая принцип DRY («Don't Repeat Yourself»), что замедляет разработку, увеличивает риски репликации ошибок и необходимости исправления их в нескольких местах.
Проблемы становятся очевидны, когда нужно проверить точность визуального отображения компонентов. Для этого дизайнеры устанавливают приложение на устройство или загружают сборки через сервисы вроде TestFlight. Но этот метод трудоемок и не гарантирует точную оценку результатов на большом количестве устройств. Для улучшения обратной связи от дизайнеров к разработчикам мы решили использовать Widgetbook.
Что такое Widgetbook?
Widgetbook — это интерактивный каталог виджетов/компонентов на Flutter. Инструмент для организации, просмотра и совместного тестирования компонентов интерфейса для мобильной среды. В этом каталоге можно удобно проверить, получились ли компоненты в реальности такими как их задумали и нарисовали дизайнеры в Figma. Подобно Storybook в веб-разработке, он обеспечивает легкий доступ ко всем компонентам вашего проекта через браузер — Flutter for Web.

Процесс совместной разработки с Widgetbook
Процесс начинается с дизайнера, создающего макеты в Figma. Затем эти макеты передаются команде разработчиков, которые воплощают их в виде конкретных компонентов. Компоненты импортируются в Widgetbook, где создается документация и настраиваются параметры (Knobs) для динамического изменения внешнего вида элемента.
Разработчики создают страницу для компонента в проекте Widgetbook и после выпуска версии свободны. А дизайнер вместо длительных циклов установки приложений и тестирования на физических устройствах открывает страницу с опубликованным Widgetbook.
Используя нобсы, дизайнер изменяет параметры компонентов для оценки их соответствия задуманному поведению. Если компонент проходит проверку, он передается продуктовым командам для дальнейшего использования. Такой подход предлагает мгновенный доступ к живым/рабочим экземплярам компонентов, ускоряет процесс проверки, утверждения и уменьшает риск ошибок отображения на разных устройствах/темах/масштабировании, обеспечивает быструю обратную связь уже от дизайнеров к разработчикам.

Widgetbook со своими блекджеком и доработками
При работе с инструментом наша команда нашла несколько неприятных для себя особенностей. Рассказываю, как мы с ними справились.
1. Отсутствие статистики.
Дерево компонентов красивее, чем просто список, но непонятен статус компонентов: какие требуют доработки, какие ещё не начаты даже, какие требуют ревью у дизайнера. Для этого обычно делается отдельный файл/таблица и шарится между всеми участниками (а это десяток и больше людей), и получается ещё одна ссылка, сущность, которая не синхронизируется автоматом.
Наше решение — написать расширение класса WidgetbookComponent и WidgetbookCategory, описывающий лист и ветку, с небольшим усовершенствованием его визуала.
// Расширение для удобства (минимизация boilerplate) создания компонента, // автоматически дописывает к названию иконку статуса готовности компонента extension WidgetbookComponentEnt on WidgetbookComponent { static create({ required String name, required ReviewStatus reviewStatus, Widget? catalog, Widget? useCases, String? designLink, }) => WidgetbookComponent( name: name, reviewStatus: reviewStatus, catalog: catalog, useCases: useCases, designLink: designLink, ); } static String getStatusEmoji(ReviewStatus reviewStatus) => switch (reviewStatus) { ReviewStatus.backlog => '📝', ReviewStatus.develop => '💻', ReviewStatus.ready => '✅', ReviewStatus.toBeFixed => '🔧', ReviewStatus.readyAndWillBeMade => '✅+🔧', }; // Расширение для удобства (минимизация boilerplate) создания категории, // автоматически дописывает к названию иконку статуса готовности вложенных компонентов и если все готовы, то ставит иконку готовности extension WidgetbookCategoryExt on WidgetbookCategory { static create({ required String name, required ReviewStatus reviewStatus, Widget? catalog, Widget? useCases, String? designLink, }) => WidgetbookCategory( name: name, reviewStatus: reviewStatus, catalog: catalog, useCases: useCases, designLink: designLink, ); static String getStatistic( List<WidgetbookNode>? components, int figmaComponentCount, ) { var readyLength = components?.where((component) => component.name.contains('✅')).length; final isAllReady = readyLength == figmaComponentCount ? '✅' : ''; final count = readyLength ?? -1; final percent = (readyLength / figmaComponentCount * 100).toStringAsFixed(1) + '%'; return '$isAllReady $count/$percent'; } }
Мы добавили в название иконку, которая описывает статус компонента: готов / на ревью / готов но дизайнеры будут менять / не прошёл ревью и отправлен на доработку.
Но компонентов 85 штук и хочется знать не только статус, но и статистику по статусам, поэтому то же расширение подсчитывает количество галочек у своих потомков и выводит количественную и процентную стадию готовности.
А заглянув в консоль браузера, можно получить список компонентов по статусам для отчёта руководству


2. Неудобный и не всегда рабочий инспектор
Ну вот, сейчас он слетел и выглядит нечитабельно. Но если при проверке в APK (сборка приложения для Android) или публикации в TestFlight (сервис для установки и тестирования для iOs) было бы проблематично проверять PixelPerfect (когда итоговый результат «пиксель в пиксель», совпадает с дизайнерским макетом), то тут это возможно (после просьбы дизайнеров, когда они снова будут им пользоваться, мы обязательно починим)

3. Работа с нобсами
Knobs — это ручки настройки. Компонент — это изменяющаяся сущность с набором параметров. Чтоб показать, как он будет себя вести в приложении при тех или иных вариантах параметров, и нужны нобсы. По сути это элементы, которые передают своё содержание в поле класса виджета и при их изменениях компонент перерисовывается.
Мы долго не могли понять принцип, по которому они появляются на панели в вебе, но потом заметили закономерность: они появляются в порядке появления их в build функции, и стали этим пользоваться так:
Widget build(BuildContext context) { final title = context.knobs.title; final showActions = context.knobs.showActions; final showContent = context.knobs.showContent; final child = context.knobs.content; final showBasicActions = context.knobs.showBasicActions; final showBasicActionsShadow = context.knobs.showBasicActionsShadow; final groupButtonsType = context.knobs.groupButtonsType; return ElementScreen( reviewStatus: getStatus(ComponentsName.basicDialog), modalOverlayColor: context.theme.bottomSheetTheme.modalBarrierColor, child: Expanded( child: Align( alignment: Alignment.bottomCenter, child: BottomSheetWidget( bottomSheetHeader: BottomSheetHeader( title: title, ), action: showActions ? SegmentedControl( size: SegmentedControlSize.medium, selectedIndex: 0, onSelect: (int index) {}, items: [ SegmentedControlItemEntity( text: 'Contriol', ), SegmentedControlItemEntity( text: 'Contriol', ), ]) : null, basicAction: showBasicActions ? BasicAction( groupButtons: switch (groupButtonsType) { GroupButtonsPositionEnum.horizontal => getHorizontalGroupButtons(context), GroupButtonsPositionEnum.vertical => getVerticalGroupButtons(context), GroupButtonsPositionEnum.mix => getMixGroupButtons(context), GroupButtonsPositionEnum.one => getOneGroupButtons(context), }, bgType: showBasicActionsShadow ? BasicActionBgType.shadow : BasicActionBgType.basic, ) : null, child: showContent ? (child.key != sizedBoxShrinkKey ? child : null) : null, ), ), ), ); } GroupButtons _getHorizontalGroupButtons(BuildContext context) { return GroupButtons.horizontal( accent: context.knobs.accent, primaryButton: PrimaryButton( text: 'Primary', onTap: () {}, ), secondaryButton: SecondaryButton( text: 'Secondary', onTap: () {}, ), ); } ...




Так же к нобсам можно писать пояснения, и мы этим пользовались в особо замороченных компонентах;
// Расширение для удобства (минимизация boilerplate) создания нобсов extension PhoneInputKnobs on KnobsBuilder { bool get readOnly => this.boolean( label: 'Read only', initialValue: false, description: 'Запрещает ввод текста', ); bool get hasIcon => this.boolean( label: 'Has icon', initialValue: false, description: 'Показывает иконку, которая отображается, когда нет состояний error и warning', ); bool get showWarning => this.boolean( label: 'Show warning', initialValue: false, description: 'Показывает warning текст, который заменяет additional text и warning text при наличии ошибки', ); bool get isLabelInside => this.boolean( label: 'Is label inside', initialValue: true, description: 'Переключение между вариантами лейбла Inside и Outside, работает только при наличии Label text', ); bool get showError => this.boolean( label: 'Show error', initialValue: false, description: 'Показывает error текст, который заменяет additional text и warning text', ); String get labelText => this.stringOrNull( label: 'Label text', initialValue: 'Label text', ); String get additionalText => this.string( label: 'Additional text', initialValue: 'Additional text', description: 'Текст под полем ввода, отображается при выключенных warning и error', ); }
Не сложно заметить, что у многих компонентов есть параметры: заголовок, пояснение, размер (не в смысле пикселей, а смысле значимости small, medium, large ...), цвет, картинка/иконка. Их мы оформили в общие нобсы и переиспользовали (некоторые чисто технические и не являются полями компонента, такие как showTitle, showSubtitle):
// Общие нобсы, использующиеся во многих компонентах extension CommonKnobs on KnobsBuilder { Color get commonColor => this.list( label: 'Color', description: 'Нужен чтоб показать - что в элементе компонента есть возможность смены цвета. Это просто цвет, любой рандомный цвет, который можно присвоить любому элементу в любом компоненте. Это общий кнобс, не привязанный к компоненту, нет возможности отображать цвета определённого списка для определённого типа компонентов', options: [ const Color(0xFF0072E7), ... const Color(0xFF95A0A9), ], initialOption: Color(0xFF171A1C), labelBuilder: (value) { final color = value.toString().substring(8, 16).toLowerCase(); return switch (color) { 'ff0072e7' => 'bg-info-primary', ... 'ff171a1c' => 'text-primary', String() => 'Что-то пошло не так', }; }, ); bool get showTitle => this.boolean( label: 'Show Title', description: 'Переведите в False чтоб не отображался Title, переведите в True, чтоб увидеть Title. Кнобс для ввода Title виден всегда', initialValue: true, ); String get title => string( label: 'Title', initialValue: 'Title, достаточно большой текст, который показывает как организован перенос и обрезание слов в компоненте. Правильное ограничение количества строк в визуальных компонентах играет важную роль для обеспечения удобства восприятия информации пользователем. Визуальные компоненты, такие как списки, таблицы, карточки и другие элементы интерфейсов, часто содержат много текста, который может быть сложно воспринимать при отсутствии ограничений.', ); bool get showSubtitle => ... String get subtitle => ... bool get showDescription => ... String get description => ... bool get showIcon => ... String? get icon => this.list( label: 'Icon', description: 'Нужен чтоб показать - что в элементе компонента есть возможность смены иконки. Это просто список рандомных иконок, нет возможности в списке расположить все 100500 иконок, поэтому это маленькая случайная выборка иконок.', options: [ null, Assets.images.coreIcons.advanced.calendar.base, ... Assets.images.coreIcons.actions.delete.base, ], initialOption: Assets.images.svg.star.star3, labelBuilder: (String? value) => switch (value) { String() => value.substring(24, value.length - 9), //показываем последние символы из пути файла, там где само имя это значимая часть, начало у всех похоже null => 'No icon', }, ); bool get showImage => ... String get image => this.list( label: 'Images', description: 'Нужен чтоб показать - что в элементе компонента есть возможность смены картинки. Это просто список рандомных картинок, нет возможности в списке расположить картинки из Figma и подбирать под каждый компонент определённый список, поэтому это маленькая случайная выборка картинок, чтоб вы увидели и попробовали возможность смены картинок.', options: [ 'assets/images/png/banner/bg_image.png', 'assets/images/png/banner/image.png', 'assets/images/png/banner/bg.png', ], ); ... }

4. Аддоны находятся над нобсами, что неудобно
Аддоны (Надстройки) находятся над нобсами, что неудобно. Зачастую дизайнеры заходят именно за нобсами. Мы залезли в исходники и поменяли их местами, чтобы аддоны и нобсы не мешали друг другу, но при этом были видны, нам этого решения было достаточно.
Но пока дошли руки написать статью, создатели Widgetbook догадались сами, что UX заставлял пользователя постоянно скролить. В новых версиях создатели вынесли в отдельный таб — очень удачное решение, спасибо.
Что же скрывается в аддонах? А то что вы туда поместите:
class WidgetbookApp extends StatelessWidget { WidgetbookApp({super.key}); final ThemeColors lightTC = const ThemeColors.light(); final ThemeColors darkTC = const ThemeColors.dark(); Widget build (BuildContext context) Widgetbook.material( directories: directories, addons: [ Material ThemeAddon ( themes: getWidgetbookThemesFrom(context), ), //Такие размеры масштабирования сейчас стоят в “Своём бизнесе” и “Своих финансах” TextScaleAddon (scales: [1.0, 0.8, 1.2, 1.3]), DeviceFrameAddon (devices: Devices.all), InspectorAddon(), GridAddon (10), AlignmentAddon(), LocalizationAddon( locales: [Locale('ru')], localizationsDelegates: [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, CalendarLocalizationDelegate.delegate, ], // LocalizationAddon ), ); // Widgetbook.material } List<Widgetbook Theme> getWidgetbook Themes From( BuildContext context, ){ return [ WidgetbookTheme ( name: 'Light', data: getAppTheme (context, false).copyWith( colorScheme: ColorScheme ), ), WidgetbookTheme( name: 'Dark', data: getAppTheme (context, true).copyWith( colorScheme: ColorScheme ), ), }; }
Смена темы, скейлинг (если он поддерживается вашими компонентами), инспектор компонента, выбор устройства — можно посмотреть как будет выглядеть на разных разрешениях и размерах экранов (это не просто узнаваемые фреймы устройств)


Что мы получили в итоге?
Инструмент — на 5 из 5, особенно если допилить.
Улучшили качество проверки компонентов:
Оптимизировали процессы тестирования: за счёт нобсов (настраиваемых полей компонента) можно проверить все вариации работы компонента.
Так как компоненты опубликованы в вебе, то в Figma на странице компонента можно оставить ссылку на компонент в Widgetbook и наоборот.
Сократили сроки и цикл разработки:
Публикация Web проекта менее затратна, чем установка apk каждым желающим. Это снижает порог использования и позволяет большему числу людей присоединиться к проверке компонентов, например не только дизайнерам, но и тестировщикам и продуктовым разработчикам, без пересылок apk по многочисленным чатам.
Получили более быстрый отзыв. Удобно по видеоконференции созвониться, пошарить экран и показать, где сломано (или для задачи в жире делать скриншоты).
Повысили удовлетворенность пользователей:
Widgetbook является по сути не только инструментом проверки, но и документацией, а следовательно меньше отвлекающих Core команду вопросов от продуктовых команд.
Благодаря CI/CD всегда опубликована последняя версия компонентов, а не эта путаница, где последний apk. Одна точка истины
Упростили работу со статистикой для отчёта, особенно, когда от нашей работы зависят все остальные команды и проект на контроле у руководства. Да и самим хорошо бы видеть, насколько ярок свет в конце туннеля.
Им просто и удобно пользоваться.
Если по каким-то причинам ещё не используете — срочно начинайте.
Мы в РСХБ.Цифра воспользовались, что позволило быстро, практически параллельно с дизайнерами создавать около сотни компонентов новой дизайн-системы для мобильных приложений всей экосистемы. А потом Core команде предложили испытать своё детище на мобильном приложении для юрлиц «Свой бизнес», известный на iOS как «Курочка Ряба». Не без правок компонентов, но за квартал команда из 10 разработчиков сделала полный редизайн у больше чем 30 продуктовых модулей.
P.S.: Создание документации — тоже работа, но вносит разнообразие в процесс разработки. А создание страницы компонента или прототипа экрана продукта на моках (имитации реальных данных от сервера) — это приятная прогулка по парку в сравнении с тёмным лесом разработки.
P.P.S.: Да Flutter for Web всё ещё не шустрый, но нам было достаточно.
