Схема работы Server driven view

Всем привет, я Дима Авдеев из Туту, наша команда разрабатывает приложения с 20М инсталлов. Расскажу, как можно обновлять приложение без выкатки релиза. Например, когда мы хотим быстро донести до пользователей коронавирусные ограничения.

Ниже реализация на SwiftUI и Kotlin (но вы можете использовать UIkit и серверный язык, принятый в вашей команде), а в GitHub-репозитории в конце статьи вы найдёте код сервера и приложений для детального изучения.

Пост по мотивам моего доклада.

Оглавление

Как это работает

С сервера будет приходить JSON в формате дерева. Там мы опишем нашу вёрстку. Потом на SwiftUI нативно отрендерим JSON. Если пользователь взаимодействует с кнопкой, отправится новый HTTP-вызов: в тело этого вызова мы добавим айдишник кнопки, в результате чего вернётся новый JSON с сервера, а мы его отобразим. Сервер также может послать какие-то дополнительные данные, а мы их обработаем. И даже сможем обеспечить логику наподобие диплинков. 

Это похоже на MVI ?

По сути, это однонаправленная архитектура MVI (Model View Intent). Model — это данные JSON, которые к нам приходят от сервера. View — это наш SwiftUI. Intent — тело нового серверного вызова. 

Вот хорошее видео, если вы хотите больше знать о MVI

Давайте сделаем  экран с важной информацией. Вот вёрстка этого экрана. Наша структура Server driven view — это SwiftUI-структура. Туда, в конструктор, мы передаём  ID пользователя и серверный путь, по которому будем запрашивать JSON для отображения.

ServerDrivenView(
       userId: "my_user_id",
       networkReducerUrl: "http://localhost:8081/important_reducer",
       autoUpdate: true
)

Давайте посмотрим, как сервер отвечает на этот вызов. Отдаётся дерево. Например в корне может лежать вертикальный контейнер, в нём первый ребёнок — это картинка, второй — текст.

rootContainer {
    image("http://localhost:8081/static/covid_test.png", 250, 250)
    text("Это ServerDrivenView")
}

Чтобы не формировать JSON руками, мы сделали специальную DSL-обёртку — причём её можно изменять, перезапускать сервер и сразу видеть изменения на клиенте.

Но зачем Server driven view, когда есть…

  • Вы cможете обеспечить привычный пользователю нативный интерфейс iOS, в отличие от WebView и Push. При этом подходы не противоречат Server driven view, их можно использовать вместе.

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

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

  • А/B-тестами можно будет управлять из серверного кода, добавляя варианты эксперимента на лету без feature toggle на клиенте.

  • Всё это также применимо для Android-приложения — и один микросервис может обслуживать обе платформы.

Но есть нюансы

Правила Apple и Google разрешают использовать такие обновляемые view, но ограниченно: не меняйте основную функциональность приложения.

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

Наконец, версионирование. Нужно сделать несколько серверных URL, и клиент в зависимости от версии будет обращаться к ним. А сервер — разруливать всё так, чтобы старым клиентам не дать лишнюю информацию.

Заглянем глубже

Структура SwiftUI имеет три состояния: состояние загрузки, нормальное состояние и состояние ошибки.

public var body: some View {
   switch myViewModel.myState.screen {
   case let loadingScreen as ServerDrivenViewState.ServerDrivenViewScreenLoading:
       Text("Loading...")
   case let normalScreen as ServerDrivenViewState.ServerDrivenViewScreenNormal:
       RenderNode(normalScreen.node, myViewModel.myState.clientStorage) { (intent: ClientIntent) in
           mviStore.sendIntent(intent: intent)
       }
   case let errorScreen as ServerDrivenViewState.ServerDrivenViewScreenNetworkError:
       VStack {
           Text("Сетевая ошибка")
           Text(errorScreen.exception)
       }
   default:
       Text("wrong ServerDrivenViewState myViewModel.myState.screen: \(myViewModel.myState.screen)")
   }
}

Нормальное состояние вызывает структуру Render Node:она нужна, чтобы рендерить любые элементы нашего JSON. В списке могут быть все возможные JSON-объекты — вертикальный или горизонтальный контейнер, текст, прямоугольник, кнопка и так далее.

Кнопка принимает в себя Intent, он же айдишник кнопки, которая будет нажата.

public struct LeafButton: View {
   let nodeButton: ViewTreeNode.Leaf.LeafButton
   let sendIntent: (ClientIntent) -> Void
   public init(_ nodeButton: ViewTreeNode.LeafButton, _ sendIntent: @escaping (ClientIntent) -> ()) {
       self.nodeButton = nodeButton
       self.sendIntent = sendIntent
   }
   public var body: some View {
       Button(action: {
           sendIntent(SwiftHelperKt.buttonIntent(buttonId: nodeButton.id))
       }) {
           Text(nodeButton.text)
                   .font(.system(size: CGFloat(nodeButton.fontSize)))
       }
   }
}

Контейнер перебирает всех своих детей и для каждого ребёнка заново вызывает функцию Render Node. То есть, по сути, получается рекурсия.

public struct ContainerHorizontal: View {
   let containerHorizontal: ViewTreeNode.Container.ContainerHorizontal
   public init(_ containerHorizontal: ViewTreeNode.ContainerHorizontal) {
       self.containerHorizontal = containerHorizontal
   }
   public var body: some View {
       HStack(alignment: containerHorizontal.verticalAlignment.toSwiftAlignment()) {
           ForEach(containerHorizontal.children) { child in
               RenderNode(child, ...)
           }
       }
   }
}

Контейнер также может содержать ещё один контейнер внутри себя. Таким образом мы можем отрендерить всё дерево: у нас есть глобальный Render Node, и каждый узел мы отдельно описываем в SwiftUI.

Насколько удобно изменять и обновлять вьюшку с сервера на практике

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

Заведём модельку города — название, картинка, статистика заболевших за несколько дней (далее будет идти код сервера на Kotlin).

data class City(
   val name:String,
   val img:String,
   val stat:List<Day> = List(7) {
       Day(
           it.toDayOfWeek(),
           (100..1000).random()
       )
   },
   val desc:String = createRandomDescription()
)

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

data class Day(
   val dayOfWeek:String,
   val infected:Int
)

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

fun NodeDsl.cityCard(city: City) {
   horizontalContainer(lightBlue) {
       verticalContainer {
           text(city.name)
           image(city.img, 100, 100)
       }
       verticalContainer {
           text("Статистика коронавируса", 15)
           horizontalContainer(contentAlignment = VAlign.Bottom) {
               city.stat.forEach {
                   verticalContainer {
                       text(it.infected.toString(), 14)
                       val normalized = it.infected / 1000.0
                       val red = (255 * normalized).toInt()
                       val green = 255 - red
                       val height = (60 * normalized).toInt()
                       rectangle(20, height, Color(red, green, 0))
                       text(it.dayOfWeek, 16)
                   }
               }
           }
           text(city.desc, 16)
       }
   }
}

Далее перебираем в цикле все города cities и отображаем cityCard(city).

val cities = listOf(
   City("Москва", moscowUrl),
   City("Санкт-Петербург", spbUrl),
   City("Владивосток", vladivostokUrl),
)
fun serverResponsePlayground(clientStorage: ClientStorage): ViewTreeNode {
   return rootContainer() {
       cities.forEach { city ->
           cityCard(city)
       }
   }
}

Всё, мы сделали экранчик исключительно в серверном коде, можем достаточно быстро вносить изменения и смотреть, как они отображаются на клиенте.

P.S. Подробности можно изучить в этом GitHub-репозитории: там вы найдёте Android- и iOS-приложения, сервер и инструкцию по сборке и запуску. 

Спасибо за внимание!