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

Мобильных разработчиков учат верить, что скорость, с которой приложения выпускаются и обновляются, напрямую связана с тем, как быстро они попадают к пользователям. Время модерации приложения в AppStore может оказать удручающе долгим. Исходя из этого, создание кита разработки программного обеспечения (Software Development Kit, SDK) будет еще медленнее, потому что вам придется вписывать свои потребности в чужие циклы разработки и выпуска продукта. Это может быть хорошим предлогом, чтобы вообще не тестировать гипотезы.

В этой статье мы рассмотрим тему и проведем вас по этапам Backend-Driven Development, описав, как она используется для решения конкретных проблем и какие преимущества приносит. Материалы для этой статьи взяты из первоисточника на примере MovilePay. Автор оригинального текста — Родриго Максимо.

Что такое бэкэнд-ориентированная разработка?

Бэкэнд-ориентированная разработка (или Backend-Driven Development, или Backend-Driven UI, или Server-Driven UI) это концепция разработки интерфейсных приложений на основе ответов сервера. Это означает, что экраны и потоки действий меняются в зависимости от ответов сервера.

Основная часть работы по созданию мобильных приложений обычно связана с построением пользовательского интерфейса: размещением элементов, с которыми взаимодействует пользователь на экранах таким образом, чтобы он мог быстро выполнить то или иное действие. Некоторые из этих компонентов заполнены полезной нагрузкой API: обычно, это JSON с примитивными типами (интежеры, булены, стринги) или бизнес-процессы приложения.

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

  • Нужен один и тот же код для многих платформ (Android, iOS, Интернет и т. д.). С этим подходом сложнее поддерживать межплатформенную совместимость, что повышает вероятность ошибок и несовместимости;
  • Каждое обновление или модификация мобильного приложения подразумевает необходимость изменения кода, что приводит к долгому выпуску приложений в App Store;
  • В диджитал проводить A/B-тестирование сложнее. Труднее проверять концепции и собирать данные от пользователей для понимания важной информации о продукте;
  • Поскольку большая часть бизнес-логики находится на стороне приложения, код сложнее обслуживать и поддерживать.

Backend-ориентированная разработка появилась, чтобы решить эти проблемы.

Рассмотрим следующий сценарий (который основан на примере приложения для iOS, но его легко перевести на любой другой интерфейсный проект):

Сценарий для backend-ориентированной разработки

{
    "pageTitle": "Demonstrative Title",
    "boxes": [
        {
            “type": “bigBlue",
            “title”: “Nice box”,
            “subtitle”: “Subtitle of box"
        },
        {
            “type": “smallRed",
            “title”: “Great box”
        },
        {
            “type": “rectangleGreen",
            “title”: “Incredible box”,
            “subtitle”: “Subtitle of box”,
            “number”: 10
        }
    ]
}

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

На примере для вывода блоков на экран («bigBlue», «smallRed» и «rectangleGreen») используется дополнительная информация из каждого поля. Интересно, что переменная «boxes» позволяет бэкенду обрабатывать столько блоков, сколько отдает сервер.

Вы, наверное, уже догадались, как можно провести A/B-тестирование в этой ситуации? Сервер может выбрать определенных пользователей для просмотра только блоков «bigBlue», в то время как другие смогут видеть все три типа блоков. Также, можно провести A/B-тесты, которые изменят порядок вывода блоков на экран.

Еще одним вариантом использования Server-driven Development является внесение изменений в интерфейс приложения. Например, если нам необходимо изменить заголовки в приложении, достаточно просто изменить ответ сервера. Порядок отображения субтитров и блоков также легко изменить. При этом вам не понадобится публиковать новый релиз приложения в АppStore.

Внесение изменений в интерфейс в backend-ориентированной разработке

{
    "pageTitle": "Demonstrative Title",
    "boxes": [
        {
            “type": “rectangleGreen",
            “title”: “Another title”,
            “subtitle”: “Another subtitle”,
            “number”: 100
        },
        {
            “type": “smallRed",
            “title”: “Different title”
        }
        {
            “type": “bigBlue",
            “title”: “Backend Driven”,
            “subtitle”: “Development”
        }
    ]
}

Семь раз отмерьте — один отрежьте

Есть несколько моментов, которые важно помнить, когда дело доходит до использования подхода Backend Driven Development. Во-первых, какая степень гибкости вам требуется?

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

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

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

Создание суперприложения, технический кейс Movile

К настоящему времени вы, вероятно, знакомы с концепцией суперприложения и слышали о таком приложении, как WeChat. WeChat — это мобильная платформа для обмена сообщениями и социальная сеть, разработанная в Китае и ставшая чрезвычайно популярной во всем мире.

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

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

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

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

Виджеты в приложение MovelePay

Виджеты для приложения MovilePay

Виджеты для приложения MovilePay

Виджеты в приложении MovilePay

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

Разбор и создание домашнего экрана

Итак, чтобы создать легко адаптируемый домашний экран, все, что нужно было сделать — получить ответ от бэкэнда со списком разделов, каждый их которых содержит список виджетов. Ниже приведен пример ответа в формате JSON, который приложение MovilePay должно проанализировать и создать домашний экран, как на примере ниже.

[
    {
        "title": "Section 1",
        "widgets": [
            {
                "identifier": "COLLECTION_WIDGET",
                "content": [
                    {
                        "title": "Title A",
                        "image": "A",
                        "color": "yellow"
                    },
                    {
                        "title": "Title B",
                        "image": "B",
                        "color": "blue"
                    },
                    {
                        "title": "Title C",
                        "image": "C",
                        "color": "red"
                    },
                    {
                        "title": "Title D",
                        "image": "D",
                        "color": "purple"
                    },
                    {
                        "title": "Title E",
                        "image": "E",
                        "color": "green"
                    }
                ]
            },
            {
                "identifier": "IMAGES_WIDGET",
                "content": [
                    {
                        "image": "Image",
                        "color": "green"
                    },
                    {
                        "image": "Image",
                        "color": "blue"
                    },
                    {
                        "image": "Image",
                        "color": "orange"
                    }
                ]
            },
            {
                "identifier": "COLLECTION_WIDGET",
                "content": [
                    {
                        "title": "Title E",
                        "image": "E",
                        "color": "green"
                    },
                    {
                        "title": "Title F",
                        "image": "F",
                        "color": "purple"
                    },
                    {
                        "title": "Title G",
                        "image": "G",
                        "color": "red"
                    },
                    {
                        "title": "Title H",
                        "image": "H",
                        "color": "blue"
                    },
                    {
                        "title": "Title H",
                        "image": "H",
                        "color": "yellow"
                    }
                ]
            }
        ]
    },
    {
        "title": "Section 2",
        "widgets": [
            {
                "identifier": "CELLS_WIDGET",
                "content": [
                    {
                        "title": "Cell 1",
                        "color": "red"
                    },
                    {
                        "title": "Cell 2",
                        "color": "purple"
                    },
                    {
                        "title": "Cell 3",
                        "color": "yellow"
                    },
                    {
                        "title": "Cell 4",
                        "color": "blue"
                    },
                    {
                        "title": "Cell 5",
                        "color": "dark green"
                    }
                ]
            }
        ]
    }
]

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

Домашние экраны в backend-ориентированной разработке

Гибкая навигация

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

MovilePay решили разработать платежный SDK, основываясь на модели Google Pay, Apple Pay или VisaCheckout, который можно было бы интегрировать с любым другим приложением.

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

Поскольку MovilePay имели дело с платежами, преобразование потоков было критически важным. MovilePay не могли позволить себе потерять своих пользователей на любом этапе воронки конверсии. Поэтому необходимо было оптимизировать весь бизнес-процесс — от регистрации пользователя до добавления карты и оплаты. Вот где Backend-Driven Development снова пригодился, превратив сервер в «навигатор» приложения.

Оптимизация бизнес-процессов с помощью BDD

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

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

Этот скромный сдвиг в стратегии позволил оптимизировать поток. MovilePay пробовали много разных вариантов отображения формы регистрации. Можно было протестировать, предпочтительнее ли показывать экран оформления заказа до или после добавления карты. Например, это одна из причин, по которой MovilePay смогли повысить коэффициент конверсии на 30% по сравнению с другими вариантами оплаты.

Это еще один пример того, как MovilePay использовали Backend-Driven Development для решения своих проблем. Думаем вам стало интересно, как же применить данный подход на практике. Позвольте продемонстрировать, насколько это просто.

Существует множество альтернативных методов достижения одной и той же цели. Мы покажем вам, как MovilePay это сделали для iOS. При желании эту концепцию можно применить на любой другой front-end платформе.

Когда MovilePay реализовывали его, они учли такие требования, как простота добавления дополнительных виджетов, удобочитаемость кода и единая ответственность. Чтобы сделать все проще и нативнее, MovilePay решили использовать Codable API для сериализации.

Виджеты (iOS)

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

protocol Widget: Decodable {}

enum WidgetIdentifier: String, Decodable {
    case banner = "BANNER"
    case collection = "COLLECTION"
    case list = "LIST"

    var metatype: Widget.Type {
        switch self {
        case .banner:
            return BannerWidget.self
        case .collection:
            return CollectionWidget.self
        case .list:
            return ListWidget.self
        }
    }
}

Используя преимущества API Codable через протокол Decodable, который реализуется протоколом Widget. Ниже приведен пример определения структуры виджетов.

struct BannerWidget: Widget {
    private let imageURLString: String
}

struct CollectionWidget: Widget {

    struct Item: Decodable {
        let imageURLString: String
        let title: String
        let subtitle: String
    }

    let sectionTitle: String
    let list: [Item]
}

struct ListWidget: Widget {

    struct Item: Decodable {
        let imageURLString: String
        let text: String
    }

    let sectionTitle: String
    let list: [Item]
}

Наконец, стирание типов, которое интерпретировало любой виджет с помощью необходимого метода инициализации, когда нужно изменить пользовательскую инициализацию, заданную Decodable.

final class AnyWidget: Decodable {

    private enum CodingKeys: CodingKey {
        case identifier
    }

    let widget: Widget?

    required init(from decoder: Decoder) throws {
        do {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            let type = try container.decode(WidgetIdentifier.self, forKey: .identifier)
            self.widget = try type.metatype.init(from: decoder)
        } catch {
            self.widget = nil
        }
    }
}

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

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

struct HomeResponse: Decodable {

    private enum CodingKeys: CodingKey {
        case widgets
    }

    let widgets: [Widget]

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.widgets = try container.decode([AnyWidget].self, forKey: .widgets).compactMap { $0.widget }
    }

    init(widgets: [Widget]) {
        self.widgets = widgets
    }
}

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

Ниже показан возможный JSON ответа API, который будет разобран этой моделью.

{
    "widgets": [
        {
            "identifier": "BANNER",
            "imageURLString": "url_image_to_be_downloaded"
        },
        {
            "identifier": "COLLECTION",
            "sectionTitle": "Section Title",
            "list": [
                {
                    "imageURLString": "url_image_to_be_downloaded",
                    "title": "Title item 1",
                    "subtitle": "Subtitle item 1"
                },
                {
                    "imageURLString": "url_image_to_be_downloaded",
                    "title": "Title item 2",
                    "subtitle": "Subtitle item 2"
                },
                {
                    "imageURLString": "url_image_to_be_downloaded",
                    "title": "Title item 3",
                    "subtitle": "Subtitle item 3"
                }
            ]
        },
        {
            "identifier": "LIST",
            "sectionTitle": "Section Title",
            "list": [
                {
                    "imageURLString": "url_image_to_be_downloaded",
                    "text": "Text item 1"
                },
                {
                    "imageURLString": "url_image_to_be_downloaded",
                    "text": "Text item 2"
                },
                {
                    "imageURLString": "url_image_to_be_downloaded",
                    "text": "Text item 3"
                }
            ]
        },
        {
            "identifier": "BANNER",
            "imageURLString": "url_image_to_be_downloaded"
        }
    ]
}

Результат такого подхода очень близок к результату суперприложения (Super App), который позволил MovilePay представить различные услуги разным пользователям, протестировать множество вариантов  отображения, отработать гипотезы и определить для какой услуги какой виджет будет использоваться. Изменение сортировки на экране и группировки услуг было близко к тому, что MovilePay делали раньше.

Навигация (iOS)

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

Action — это структурированный объект, возвращаемый в JSON-ответе некоторых API MovilePay, с идентификатором и всеми параметрами, которые должны быть отображены в представляемой им сцене. В результате протокол Action отвечает за помощь в деконструкции структурированного объекта.

protocol Action: Decodable {
    func scene() -> UIViewController
}

enum ActionIdentifier: String, Decodable {
    case home = "HOME"
    case screenOne = "SCREEN_ONE"
    case screenTwo = "SCREEN_TWO"

    var metatype: Action.Type {
        switch self {
        case .home:
            return HomeAction.self
        case .screenOne:
            return ScreenOneAction.self
        case .screenTwo:
            return ScreenTwoAction.self
        }
    }
}

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

struct HomeAction: Action {
    func scene() -> UIViewController {
        return HomeCoordinator.scene()
    }
}

struct ScreenOneAction: Action {
    let title: String

    func scene() -> UIViewController {
        return ScreenOneCoordinator.scene(title: self.title)
    }
}

struct ScreenTwoAction: Action {
    let title: String
    let subtitle: String

    func scene() -> UIViewController {
        return ScreenTwoCoordinator.scene(title: self.title, subtitle: self.subtitle)
    }
}

В примере выше видно, что, при создании, Actions должны содержать все необходимые свойства для инициализации своей сцены и для вызова метода Coordinators для создания экземпляра UIViewController. Coordinators — это структуры, с помощью которых осуществляется предоставление UIViewController. Вот о чем следует упомянуть: MovilePay используют паттерн проектирования в структурах Coordinators, представленный статическим методом scene(), которому поручено генерировать экземпляр UIViewController для каждой сцены.

final class HomeCoordinator: Coordinator {
    static func scene() -> UIViewController {
        // Create the ViewController and all the architecture components of this scene...
        return createdViewController
    }
}

final class ScreenOneCoordinator: Coordinator {
    static func scene() -> UIViewController {
        // Create the ViewController and all the architecture components of this scene...
        return createdViewController
    }
}

final class ScreenTwoCoordinator: Coordinator {
    static func scene() -> UIViewController {
        // Create the ViewController and all the architecture components of this scene...
        return createdViewController
    }
}

Следует, также, отметить, что, несмотря на выбранный архитектурный паттерн проектирования (MVC, MVP, MVVM-C, VIPER-C, VIP или любая другая архитектура, использующая координатор для создания сцен и перехода от одной сцены к другой), реализация с помощью Action и Backend-Driven Development вполне уместна.

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

final class AnyAction: Decodable {

    private enum CodingKeys: CodingKey {
        case identifier
    }

    let action: Action?

    required init(from decoder: Decoder) throws {
        do {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            let type = try container.decode(ActionIdentifier.self, forKey: .identifier)
            self.action = try type.metatype.init(from: decoder)
        } catch {
            self.action = nil
        }
    }
}

Здесь уместно замечание, что MovilePay могли бы использовать шаблон проектирования Generics, чтобы не дублировать код для стирания типов. Однако, они решили не делать этого. Ниже показан пример структуры, которая содержит Action.

struct ResponseModelForActions: Decodable {
    private enum CodingKeys: CodingKey {
        case action, text
    }

    let action: Action?
    let text: String

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.text = try container.decode(String.self, forKey: .text)
        let anyAction = try? container.decode(AnyAction.self, forKey: .action)
        self.action = anyAction?.action
    }
}

Это может быть JSON, предоставленный API, например, для создания UIButton на экране. Объект действия, обработанный после того, как пользователь коснется этой кнопки, может выполнить предоставленное действие, заставив приложение отобразить главный экран.

{
    "text": "Demonstrative text",
    "action": {
        "identifier": "HOME_SCREEN"
    }
}

Этого было легко добиться за счет расширения протокола Coordinator, что позволило бы всем координаторам оказаться в состоянии получить новую сцену через объект Actions.

extension Coordinator {
    func scene(using action: Action) -> UIViewController {
        return action.scene()
    }
}

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

Подсказки по серверной реализации

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

Итак, помимо всех сложных основных служб MovilePay добавили еще один уровень к серверу, который абстрагируя все другие службы и применяя всю логику приложения, преобразует данные в ответ, ожидаемый фронтендом. Этот слой называется BFF, или Backend For Frontend, — это уровень, который обеспечивает связь между двумя отдельными и не связанными друг с другом концами системы. Именно здесь настраиваются и применяются к базовым данным строки, изображения, потоки и вариации стиля, прежде чем они будут отправлены приложениям.

Итоги

Использование подхода Backend-Driven имеет несколько преимуществ, которые мы попытались прояснить на протяжении статьи. Однако, это всего лишь еще один шаблон решения. Это не волшебная пилюля для разработки приложений. Кроме того, необходимо учитывать контекст, в котором будет использоваться приложение. Необходимо ли вам создать приложения для нескольких платформ? Какие типы тестирования вы хотели бы провести? Нужен ли вам полный контроль над всеми экранами? Насколько большими будут ваши полезные нагрузки? Есть ли у вас достаточно большое количество ресурсов для реализации этого проекта, в том числе команда разработчиков, способная справиться с этими задачами?

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

На самом деле, большинство проектов просто не нуждаются в подобной гибкости. При выборе подхода Baсkend-Driven важно учитывать, располагаете ли вы мощностями в виде финансов и команды разработчиков, чтобы развивать и поддерживать подобный проект, и сможете ли вы правильно рассчитать полезную нагрузку вашего приложения.