Введение стилей просмотра в Bumble

Мы - команда разработчиков Badoo и Bumble, двух крупнейших в мире приложений для знакомств и установления связи с миллионами пользователей по всему миру. Чтобы справиться с проблемами, возникающими с такими разными продуктами, мы полагаемся на возможность повторного использования кода, и, избегая изобретать колесо, мы сохраняем стабильность наших приложений, согласованности наших решений UX и многое другое.

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

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

Любая чистая архитектура требует, чтобы решение было разделено как минимум на два уровня:

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

Этот шаблон очень характерен для любой основной архитектуры приложений (MVP, MVVM, VIPER и т. Д.)

Похоже на хорошее чистое решение, не правда ли? Но действительно ли эти решения могут предоставлять повторно используемые компоненты, которые можно стилизовать для любого конкретного случая? Давайте протестируем их и посмотрим, как мы создаем такие компоненты в Bumble. Готовый?

Что проблематично в классической реализации MVC / MVP / MVVM?

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

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

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

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

Мы можем сделать наоборот и создать презентатора, который можно использовать повторно. Он не будет контролировать цвет представления. Если View собирается устанавливать свой цвет самостоятельно, тогда для каждого случая необходимо реализовать определенный класс View.

У обоих шаблонов есть проблемы с возможностью повторного использования кода. Каждый раз, когда всплывающую подсказку необходимо повторно использовать, разработчик вынужден реализовать совершенно новый класс View / Presenter. Скорее всего, разработчик создаст подкласс View / Presenter и испытает все побочные эффекты подкласса.

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

Реализация компонента стиля

Давайте ненадолго погрузимся в типографику. Представьте, что мы издаем журнал или управляем веб-страницей Выплаты сотрудникам Bumble. Вот блок текста, который можно было бы поместить туда:

Редакторы меняют текст двумя способами:

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

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

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

  • Содержание. Это основные данные, которые View должен выводить независимо от того, в каком приложении или в какой части приложения используется.
  • Стиль. Это данные формата, специфичные для каждого случая многократного использования.

Когда определенное представление необходимо отобразить с шаблоном MVP на экране:

  • Представление создано
  • View применяет определенный стиль в зависимости от случая
  • Представление назначается докладчику.

Таким образом, Presenter управляет содержанием View, а стиль View контролируется фабрикой, которая его создала. Эта архитектура довольно гибкая и облегчает реализацию повторно используемых компонентов и объединяет их вместе:

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

Хорошо, теперь, когда у нас есть архитектурный дизайн, давайте создадим понятный, поддерживаемый и тестируемый стиль для следующего компонента:

Я предлагаю определить стиль как структуру:

struct NewsViewStyle {
    let backgroundColor: UIColor
    let borderColor: UIColor
    let borderWidth: CGFloat
    let cornerRadius: CGFloat
    let titleAlignment: NSTextAlignment
    let titleFont: UIFont
    let messageAlignment: NSTextAlignment
    let messageFont: UIFont
}

А затем, реализуя View:

class NewsView: UIView {
    private let titleLabel = UILabel()
    private let messageLabel = UILabel()
override init(frame: CGRect)  {
        super.init(frame: frame)
        self.setupConstraints()
    }
required init?(coder: NSCoder)  {
        super.init(coder: coder)
    }
var title: String?  {
        get { self.titleLabel.text }
        set { self.titleLabel.text = newValue }
    }
var message: String?  {
        get { self.messageLabel.text }
        set { self.messageLabel.text = newValue }
    }
func apply(style: NewsViewStyle) {
        self.backgroundColor = style.backgroundColor
        self.layer.borderColor = style.borderColor.cgColor
        self.layer.borderWidth = style.borderWidth
        self.layer.cornerRadius = style.cornerRadius
        self.titleLabel.textAlignment = style.messageAlignment
        self.titleLabel.textColor = style.messageColor
        self.titleLabel.font = style.messageFont
        self.messageLabel.textAlignment = style.messageAlignment
        self.messageLabel.textColor = style.messageColor
        self.messageLabel.font = style.messageFont
    }
func setupConstraints() {
        //  ...
    }
}

Как видите, текст заголовка и текст сообщения являются отображаемым содержимым этого представления. В то же время стилем View стал огромный набор свойств. Определенный объект стиля можно легко определить. Например, общий стиль компонента для приложения Badoo может выглядеть так:

extension NewsViewStyle {
    public static var badoo: NewsViewStyle {
        NewsViewStyle(
            backgroundColor: .white,
            borderColor: UIColor(hex: 0x783Bf9),
            borderWidth: 1,
            cornerRadius: 0,
            titleAlignment: .left,
            titleColor: UIColor(hex: 0x783Bf9),
            titleFont: UIFont.systemFont(ofSize: 22),
            messageAlignment: .left,
            messageColor: UIColor(hex: 0x767676),
            messageFont: UIFont.systemFont(ofSize: 14)
        )
    }
}

Итак, теперь мы можем создать представление, применить стиль и назначить представление докладчику.

let view = NewsView()
view.apply(style: .badoo)
let presenter = NewPresenter(view: view)

Подстили

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

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

struct ViewStyle { 
    let backgroundColor: UIColor?
    let borderColor: UIColor?
    let borderWidth: CGFloat
    let cornerRadius: CGFloat
}
extension UIView {
    func apply(style: ViewStyle) {
        self.backgroundColor = style.backgroundColor
        self.layer.borderColor = style.borderColor?.cgColor
        self.layer.borderWidth = style.borderWidth
        self.layer.cornerRadius = style.cornerRadius
    }
}

Теперь давайте создадим LabelStyle, который будет применяться к UILabel. UILabel является подклассом UIView, поэтому он должен содержать данные стиля суперкласса. Этого можно добиться за счет агрегирования.

struct LabelStyle { 
    // Superclass style
    let viewStyle: ViewStyle
    // Own values
    let textAlignment: NSTextAlignment
    let textColor: UIColor?
    let font: UIFont?
}
extension UILabel {
    func apply(style: LabelStyle) {
        // Applying superclass style
        self.apply(style: style.viewStyle)
        // Applying own values
        self.textAlignment = style.textAlignment
        self.textColor = style.textColor
        self.font = style.font
    }
}

Наконец, нам нужно реорганизовать структуру NewsViewStyle. NewsView является подклассом UIView, поэтому он должен объединять структуру ViewStyle. Кроме того, он содержит два UILabels и должен иметь их стили.

struct NewsViewStyle { 
    // Superclass style
    let viewStyle: ViewStyle
    // Aggregated components’ styles
    let titleStyle: LabelStyle
    let messageStyle: LabelStyle
    // Own values
    // -
}
class NewsView {
    // ...
    func apply(style: NewsViewStyle) {
        // Applying superclass style
        self.apply(style: style.viewStyle)
        // Applying aggregated components’ styles
        self.titleLabel.apply(style: style.titleStyle)
        self.messageLabel.apply(style: style.messageStyle)
        // Applying own values
        // -
    }
    // ...
}

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

Значения стилей по умолчанию

Как теперь выглядит наше определение NewsViewStyle.badoo?

extension NewsViewStyle { 
    public static var badoo: NewsViewStyle {
        NewsViewStyle(
            viewStyle: ViewStyle(
                backgroundColor: .white,
                borderColor: UIColor(hex: 0x783BF9),
                borderWidth: 1,
                cornerRadius: 0
            ),
            titleStyle: LabelStyle(
                viewStyle: ViewStyle(
                    backgroundColor: .clear,
                    borderColor: .clear,
                    borderWidth: 0,
                    cornerRadius: 0
                ),
                textAlignment: .left,
                textColor: UIColor(hex: 0x783BF9),
                font: UIFont.systemFont(ofSize: 22)
            ),
            messageStyle: LabelStyle(
                viewStyle: ViewStyle(
                    backgroundColor: .clear,
                    borderColor: .clear,
                    borderWidth: 0,
                    cornerRadius: 0
                ),
                textAlignment: .left,
                textColor: UIColor(hex: 0x767676),
                font: UIFont.systemFont(ofSize: 14)
            )
        )
    }
}

Хм ... он действительно громоздкий, правда? Кроме того, половина кода описывает значения по умолчанию. Нужно ли это делать для каждого стиля? Swift позволяет нам предоставлять значения по умолчанию для параметров, поэтому их удобно передавать конструктору Style:

struct ViewStyle {
    // ...
    init(backgroundColor: UIColor? = nil,
          borderColor: UIColor? = nil,
          borderWidth: CGFloat = 0,
          cornerRadius: CGFloat = 0) {
        self.backgroundColor = backgroundColor
        self.borderColor = borderColor
        self.borderWidth = borderWidth
        self.cornerRadius = cornerRadius
    }
    // ...
}

Однако подклассы страдают хитрым негативным эффектом. Что происходит, когда значение по умолчанию cornerRadius некоторого подкласса не равно нулю? В таком случае сущность подкласса не может отличить значение по умолчанию от явного нулевого значения.
UIKit предоставляет алгоритм сброса на ноль для многих свойств компонентов. Примеры можно найти в UIView.backgroundColor, UILabel.font, UILable.textColor и т. Д. Давайте сделаем то же самое.

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

struct ViewStyle {
    let backgroundColor: UIColor?
    let  borderColor: UIColor?
    let borderWidth: CGFloat?
    let cornerRadius: CGFloat?
init(backgroundColor: UIColor? = nil,
          borderColor: UIColor? = nil,
          borderWidth: CGFloat? = nil,
          cornerRadius: CGFloat? = nil) {
        self.backgroundColor = backgroundColor
        self.borderColor = borderColor
        self.borderWidth = borderWidth
        self.cornerRadius = cornerRadius
    }
}
extension UIView {
    func apply(style: ViewStyle?) {
        self.backgroundColor = style?.backgroundColor
        self.layer.borderColor = style?.borderColor?.cgColor
        self.layer.borderWidth = style?.borderWidth ?? 0
        self.layer.cornerRadius = style?.cornerRadius ?? 0
    }
}

Это намного компактнее! Его легко читать, поддерживать и расширять.

Наконец! Единое стилевое решение, чтобы править всеми… Или нет?

Конечно, это решение - не серебряная пуля. Вот как минимум пара проблем, с которыми мы столкнулись.

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

В предыдущем примере мы определили универсальную вспомогательную структуру ViewStyle, которая имеет свойство backgroundColor, но можно ли ее использовать для любого подкласса UIView? Взгляните на следующий простой вид:

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

Кроме того, тот же вид можно использовать в качестве палитры цветов:

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

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

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

У него есть изображение. Должен ли он быть частью ButtonStyle или нет? Что ж, это зависит от контекста. Эта часть представления может быть указана на уровне представления или на уровне модели.

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

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

Таким образом, в общем случае изображение не может быть частью ButtonStyle, но в конкретном случае оно может быть частью ImagedButtonStyle.

Можем ли мы сделать это еще лучше?

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

  • Основные стили и компоненты стилей (цвета, шрифты, размеры…) могут контролироваться системой дизайна для всех платформ компании. А у нас один.
  • View-компоненты со всеми возможными стилями могут быть легко представлены в приложении Галерея для быстрой разработки.
  • Стили могут быть покрыты визуальными регрессионными тестами.
  • Базовые стили и компоненты стилей (цвета, шрифты, размеры…) могут храниться в качестве ресурсов на устройстве и могут быть заменены в любое время на стороне сервера.

У вас есть еще идеи, как можно использовать стили? Как еще вы могли бы решить проблему повторного использования компонентов пользовательского интерфейса? Подойдет ли вам ViewStyles? Не стесняйтесь делиться своими идеями в комментариях!