Привет! Меня зовут Игорь Дубровин, я Android-разработчик в SuperJob. Давно хотел поднять тему неконсистентности дизайна в приложении, поговорить о проблеме отсутствия единого стиля. Представьте: вы открываете приложение с вакансиями и на разных экранах видите предложения о работе в разном дизайне – в поисковой выдаче одно, а в ленте избранного немного другое. По факту блоки могут иметь совсем незначительные отличия, но пользователь все равно начинает путаться. Почему? Все просто. Он привык к единому внешнему виду элементов экрана.

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

Почему теряется общий стиль

Первая причина – это дублирование кода. Разберем проблему на простом примере: в приложении на нескольких экранах необходимо сделать квадратную кнопку. Этими экранами занимаются разные разработчики и изначально делают кнопки одинаково. Казалось бы, в чем проблема?

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

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

Есть много причин, из-за которых может возникать дублирование кода. Я постарался выделить основные, которые касаются внешнего вида элементов экрана при разработке Android-приложений:

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

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

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

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

Как мы решали проблему неконсистентности

В нашем случае всплыли две основных проблемы: дублирование кода и отсутствие перечня реализованных элементов, доступных для использования разработчиками. Как их решать? Ответ мы нашли в дизайн-системе.

Дизайн-система (ДС) – это стандартизация дизайна, некий набор компонентов и их состояний, которые могут быть использованы в приложении. ДС помогает разрабатывать приложение так, чтобы всегда использовалась только одна кнопка. Кроме того, в случае изменения внешнего вида элемента, он будет меняться везде без необходимости исследовать весь код.

Давайте рассмотрим, как мы выделяем дизайн-компоненты на примере одного из экранов, «Мои связи». Все элементы этого экрана являются частями дизайн-системы. Каждый выделенный квадрат заведен как отдельный компонент в ДС. Кроме того, некоторые компоненты могут являться частью других, как, например, блок рекомендаций является частью сниппета контакта. На данный момент все наши экраны строятся на основе дизайн-системы, и разработчик всегда использует только те компоненты, которые есть в доступе.

Но бывает так, что компонент с требуемым функционалом или внешним видом еще не был реализован. Для таких случаев мы выработали алгоритм:

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

  2. Происходит обсуждение нового компонента с разработчиками. Четко описывается для чего он нужен, где будет использоваться, в каких состояниях может находиться;

  3. Создается отдельная задача и начинается разработка компонента;

  4. Обязательное дизайн-ревью. Дизайнер проверяет правильность реализации компонента с точки зрения внешнего вида и поведения.

Давайте немного подробнее рассмотрим наш единый подход к разработке новых дизайн-компонентов. Каждый раз создается отдельная сущность ViewState, которая является контрактом внешнего вида компонента. ViewState выглядит максимально просто: это data class с набором полей, которые компонент может визуализировать. Кроме этого есть мета-информация компонента, такая как id, payload, декоратор элемента списка.

Под спойлером код в текстовом формате
data class EmptyStateMediumVs(
   override val id: String = UUID.randomUUID().toString(),
   val image: DrawableVs? = null,
   val primaryMessage: StringVs = "".toStringVs(),
   val secondaryMessage: StringVs? = null,
   val primaryAction: StringVs? = null,
   val flatAction: StringVs? = null,
   val imageSize: ImageSize = ImageSize.SMALL,
   val background: ColorVs = R.color.white.toColorRes()
) : ItemVs() {
   enum class ImageSize(val size: DimensionVs) {
        SMALL(R.dimen.empty_state_medium_image_size_small.toDimensionRes()),
        MEDIUM(R.dimen.empty_state_medium_image_size_medium.toDimensionRes())
    }
}

Далее создается еще один контракт, описывающий действия, которые можно совершить над компонентом – listener. В нашем случае можно только нажать на компонент, поэтому в листенере только один коллбэк. В более сложных случаях количество коллбэков листенера может быть увеличено.

Код в текстовом формате
class EmptyStateMediumListener(
   val primaryActionClick: ItemListener<EmptyStateMediumVs>? = null,
   val flatActionClick: ItemListener<EmptyStateMediumVs>? = null
)

Дальше все достаточно тривиально:

  1. Верстаем компонент. Все отступы компонента, внешние и внутренние, задаются в верстке;

  2. Описываем логику работы компонента;

class EmptyStateMediumView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {

    var viewState: EmptyStateMediumVs = EmptyStateMediumVs()
        set(value) {
            field = value
            applyViewState()
        }

    var listener: EmptyStateMediumListener? = null

    private val binding: ViewEmptyStateMediumBinding

    init {
        orientation = VERTICAL
        View.inflate(context, R.layout.view_empty_state_medium, this)
        binding = ViewEmptyStateMediumBinding.bind(this)
        with(binding) {
            emmPrimaryAction.setOnClickListener {
                listener?.primaryActionClick?.invoke(viewState)
            }
            emmFlatAction.setOnClickListener {
                listener?.flatActionClick?.invoke(viewState)
            }
        }
    }

    private fun applyViewState() {
        // логика установки view state
    }
}

3.Если предполагается, что компонент будет использоваться в списке, то создаем для него элемент списка и адаптер-делегат, в котором описана тривиальная логика передачи текущего состояния в дизайн-компонент.

Дополнительный уровень абстракции

Небольшое отступление, еще один уровень абстракции. Рассмотрим его на примере работы со строками. В Android существуют различные типы строк: это может быть непосредственно String, а может быть строка, хранящаяся в строковых ресурсах strings.xml или plurals.xml. У EmptyStateMediumVs есть primaryMessage (сообщение об ошибке), и оно может прийти из бекенда. В таком случае надо будет туда положить эту строку. Но может быть и такая ситуация, что понадобится достать ее из ресурсов. 

У нас было несколько вариантов решения этой проблемы:

  • Сделать несколько полей для primaryMessage. Выглядело бы это следующим образом:

data class EmptyStateMediumVs(
    ...
    val primaryMessage: StringVs?, 

    val primaryMessageId: Int?, 
    ...
)

В зависимости от ситуации мы бы передавали в EmptyStateMediumVs строку или id строкового ресурса, а вьюшка бы сама решала как их обработать. Но primaryMessage является обязательным аргументом EmptyStateMediumVs, и его нельзя игнорировать, наш компонент не умеет работать без него. А в данном случае мы позволяем вообще ничего не передавать.

  • Реализовать ResourceProvider, который по id ресурса отдавал бы нам сообщение, и использовать его в тот момент, когда необходимо достать строку из ресурсов. В данном случае мы сохраним у view state только одно поле primaryMessage, и это поможет нам оставить его обязательным. Но есть несколько ограничений: во-первых, пришлось бы прокидывать эту зависимость в места создания view state, т.е. во все view model; во-вторых, раскрывались бы детали реализации для view model: как именно мы получаем ту или иную строку из ресурсов. Из strings.xml и plurals.xml они достаются по разному, соответственно в ResourceProvider у нас бы были разные методы.

Мы хотели научить наши компоненты самостоятельно визуализировать строки вне зависимости от того, какое они имеют представление. Поэтому мы пошли по другому пути решения проблемы и ввели отдельную абстракцию StringVs, которая описывает все необходимые нам варианты. Выглядит она следующим образом:

sealed class StringVs {
   data class StringResource(
       @StringRes val id: Int,
       val formatArgs: List<Any>
   ) : StringVs()
   data class StringPlural(
       @PluralsRes val id: Int,
       val quantity: Int,
       val formatArgs: List<Any>
   ) : StringVs()
   data class StringValue(val string: String) : StringVs()
}

Дополнительно мы написали несколько экстеншенов, которые помогают нам все создавать:

fun String.toStringVs(): StringVs {
   return StringVs.StringValue(this)
}
fun Int.toStringVs(vararg formatArgs: Any): StringVs {
   return StringVs.StringResource(this, formatArgs.toList())
}
fun Int.toPluralStringVs(quantity: Int, vararg formatArgs: Any): StringVs {
   return StringVs.StringPlural(this, quantity, formatArgs.toList())
}

Таким образом нам больше не нужна дополнительная зависимость в наших view model, и им больше не надо знать, как переводить разные типы строк в String. Осталось только научить наши компоненты с ними работать. Для этого мы написали небольшой экстеншен-парсер:

fun Context.parseStringVs(stringVs: StringVs): String {
   return return when (stringVs) {
       is StringVs.StringResource -> getString(
           stringVs.id,
           *stringVs.formatArgs.toTypedArray()
       )
       is StringVs.StringPlural -> resources.getQuantityString(
           stringVs.id,
           stringVs.quantity,
           *stringVs.formatArgs.toTypedArray()
       )
       is StringVs.StringValue -> stringVs.string
   }
}

И наконец нужно внутри нашего дизайн-компонента, в методе применения ViewState, установить соответствующее значение:

private fun applyViewState() {
   viewState.run {
       emmPrimaryMessage.text = parseStringVs(primaryMessage)
       ...
   }
}

Такие абстракции мы сделали для всех типов данных, которые могут храниться как в ресурсах, так и в любом другом источнике данных.

  • Цвета:

sealed class ColorVs {
   data class ColorResource(@ColorRes val id: Int) : ColorVs()
   data class ColorValue(@ColorInt val color: Int) : ColorVs()
   data class ColorString(val color: String) : ColorVs()
}
  • Размеры:

sealed class DimensionVs {
   data class DimensionResource(@DimenRes val id: Int) : DimensionVs()
   data class DimensionValuePx(@Dimension val dimension: Int) : DimensionVs()
   data class DimensionValueDp(@Dimension val dimension: Int) : DimensionVs()
}
  • Картинки:

sealed class DrawableVs {
   data class ImageResource(@DrawableRes val id: Int) : DrawableVs()
   data class ImageDrawable(val drawable: Drawable) : DrawableVs()
   data class ImageUrl(
       val url: String,
       val placeHolder: DrawableVs? = null,
       val error: DrawableVs? = null
   ) : DrawableVs()
   data class ImageFile(
       val file: File,
       val placeHolder: DrawableVs? = null,
       val error: DrawableVs? = null
   ) : DrawableVs()
}

Все остальное реализовано также, как и у StringVs. Есть экстеншены, которые помогают нам создавать абстракции. Передаем их в ViewState дизайн-компонентов, а они уже сами знают, как будут использовать тот или иной тип абстракции.

Гибкость дизайн-компонент

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

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

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

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

Первый наш дизайн-компонент GeneralItem достаточно гибко настраивается. У него есть много состояний, комбинаций элементов, в которых он может находиться. Однозначно описать связь его элементов друг с другом сложно. Помимо большого количества состояний, у него также могут настраиваться отступы между элементами. 

Серый квадрат в левом верхнем углу – это контейнер для других элементов экрана. На данный момент у нас поддерживается check-box, radio-button, progress-bar и picture. С учетом всех особенностей GeneralItem, код компонента получился сложным и большим. Его достаточно дорого поддерживать. Кроме того такие компоненты могут отрицательно влиять на скорость работы приложения.

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

Так как GeneralItem получился достаточно гибким, то использоваться он может в любом месте приложения. Изначально этот дизайн-компонент делался для экрана выбора статуса видимости резюме. Но в дальнейшем мы стали использовать GeneralItem и на других экранах. Например во всплывающем подтверждении удаления подписки. Выглядят они совершенно по разному, и для подтверждения можно было бы создать новый компонент с текстом и иконкой, а не использовать большой и тяжелый. И, возможно, он уже создан.

Именно из-за этого сейчас мы делаем небольшие компоненты с ограниченной областью применения и ограниченным набором состояний. Все это для того, чтобы на всех экранах они выглядели одинаково и выполняли только свое предназначение. Кроме прочего это позволяет исключить фактор человеческой ошибки (например передачу неправильных отступов во ViewState компонента).

Итоги

Что же нам дала дизайн-система и уход от гибких дизайн-компонентов? Главное - мы избавились от дублирования кода. Достаточно просто и быстро реализовали дизайн-приложение с полным набором реализованных дизайн-компонентов. Это в конечном итоге помогает нам привести наше приложение к единому стилю и консистентному дизайну.

Также мы получили несколько дополнительных плюсов. Во-первых, единый интерфейс для работы с дизайн-компонентами, что снижает порог входа в использование нового незнакомого элемента. Во-вторых, создание компонентов без привязки к месту их использования. Мы можем создавать новые компоненты и тестировать их в дизайне приложения не дожидаясь реализации фичи, для которой требуется этот компонент. И, наконец, шаблон для AndroidStudio автоматически генерирует нам скелет наших дизайн-компонентов. Это помогает не заниматься скучной рутинной работой, а сразу переходить к реализации логики работы компонентов.

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