Кроссплатформенная архитектура ядра приложения. Простая. Линейная. Масштабируемая

от автора

Описание проблемы

Задача

Я — андроид разработчик. Обычно ко мне приходят с фразой вроде “вот мы тут придумали фичу, сделаешь?” и с макетом дизайна, вроде такого.

Я смотрю на это всё и вижу: вот экраны, эти данные на них — статические, а вот эти динамические, значит их надо откуда-то взять; вот тут интерактивные компоненты: при взаимодействии с ними надо что-то сделать. Иногда просто открыть другой экран или виджет, иногда выполнить логику. Исходя из этого я проектирую то, как будет выглядеть логика фичи. Описываю ее в компонентах архитектуры, разбиваю на задачи, узнаю где и как взаимодействовать с сервером, и прочее.

Скрытые кейсы

Но потом я обнаруживаю, что далеко не все переходы такие простые, как нарисовано на дизайне, например, как в случае с авторизацией. Не все явно присутствуют, как, например, переходы назад. А в некоторых случаях не хватает всевозможных экранов ожидания, экранов “пустых” состояний и экранов ошибок.

Знакомо?

Наблюдение

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

Что я имею ввиду?

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

Другой подход

Пример

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

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

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

И вот макет такого приложения.

Первые фичи

Вопрос, с чего начать? В целом, начать можно было бы с чего угодно, но я подумал, что логичнее начать с core-фичи: отображение списка элементов.

Но зачем мне — приложению — его показывать?

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

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

Теперь у нас (все еще как у приложения) два вопроса, и описаны они в виде входных параметров: откуда взять список и откуда взять элемент для удаления?

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

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

Масштабируемость: добавляем создание элемента

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

Функция добавления нового элемента имеет сигнатуру похожую на функцию удаления элемента. Разве что имена разные. Ну и выполняемые действия =)

Далее мы сталкиваемся все с тем же вопросом: “откуда брать элемент для добавления?” А так как мы не можем сами его решить, то снова делегируем это пользователю и красиво запаковываем в “create and add”.

Но есть ощущение, как будто мы все это уже делали. А где же обещанная масштабируемость? Она заключается в том, что теперь мы можем запустить эти две программы параллельно, таким образом объединив их в одну и в то же время не внося изменения ни в одну их них.

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

Теперь у нас есть кросс-платформенное описание логики приложения, не привязанное к конкретному UI. На бумаге…

Реализация

Логика

Теперь надо превратить это описание в код. Как?

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

Проходя сверху-вниз по нашей таблице, последовательно реализуем “example app”, “create or remove”, “select and remove” и остальные функции.

suspend fun <Item> exampleApp(items: List<Item>): Nothing {     updateLoop(items) {         createOrRemove(it)     } }  suspend fun <Item> createOrRemove(items: List<Item>): List<Item> {     return parallel(         { selectAndRemoveItem(items) },         { createAndAdd(items) }     ) }  suspend fun <Item> selectAndRemoveItem(items: List<Item>): List<Item> {     val item = selectItem(items)      return removeItem(items, item) }  suspend fun <Item> removeItem(items: List<Item>, item: Item): List<Item> {     return items - item }  suspend fun <Item> createAndAdd(items: List<Item>): List<Item> {     val item: Item = createItem()      return addItem(items, item) }  suspend fun <Item> addItem(items: List<Item>, item: Item): List<Item> {     return items + item }

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

suspend fun <Item> selectItem(items: List<Item>): Item {     TODO("Interact with user") }  suspend fun <Item> createItem(): Item {     TODO("Interact with user") }

Это внешняя зависимость по отношению к нашей логике. “Внешняя” значит, что мы должны как-то получить ее откуда-то снаружи. И как бы это сделать?

Зависимости

Я предлагаю описать зависимости этих функции в виде интерфейсов. И пусть его реализацией займётся внешняя система, которая хочет запускать наше приложение.

suspend fun <Item> SelectItemDependencies<Item>.selectItem(items: List<Item>): Item {     return select(items) }  interface SelectItemDependencies<Item> {     suspend fun select(items: List<Item>): Item }  suspend fun <Item> CreateItemDependencies<Item>.createItem(): Item {     return create() }  interface CreateItemDependencies<Item> {     suspend fun create(): Item }

Правда теперь “внешней системой” становятся вызывающие функции.

С точки зрения вызывающей функции мы теперь должны реализовать этот интерфейс или запросить его извне. Но в таком случае мы сильно привяжем себя именно к этой функции и к этому интерфейсу, а мне — как вызывающей функции — это не нужно. Мне достаточно сигнатур этих функций, не реализаций. То есть в целом можно провернуть тот же трюк, что и для selectItem и createItem: вынести зависимости в интерфейс. А затем это можно сделать рекурсивно вплоть до exampleApp.

suspend fun <Item> ExampleAppDependencies<Item>.exampleApp(items: List<Item>): Nothing {     updateLoop(items) {         createOrRemove(it)     } }  interface ExampleAppDependencies<Item> {     suspend fun createOrRemove(items: List<Item>): List<Item> }  suspend fun <Item> CreateOrRemoveDependencies<Item>.createOrRemove(items: List<Item>): List<Item> {     return parallel(         { selectAndRemoveItem(items) },         { createAndAdd(items) }     ) }  interface CreateOrRemoveDependencies<Item> {     suspend fun selectAndRemoveItem(items: List<Item>): List<Item>     suspend fun createAndAdd(items: List<Item>): List<Item> }  suspend fun <Item> SelectAndRemoveItemDependencies<Item>.selectAndRemoveItem(items: List<Item>): List<Item> {     val item = selectItem(items)      return removeItem(items, item) }  interface SelectAndRemoveItemDependencies<Item> {     suspend fun selectItem(items: List<Item>): Item     suspend fun removeItem(items: List<Item>, item: Item): List<Item> }  suspend fun <Item> removeItem(items: List<Item>, item: Item): List<Item> {     return items - item }  suspend fun <Item> CreateAndAddDependencies<Item>.createAndAdd(items: List<Item>): List<Item> {     val item = createItem()      return addItem(items, item) }  interface CreateAndAddDependencies<Item> {     suspend fun createItem(): Item     suspend fun addItem(items: List<Item>, item: Item): List<Item> }  suspend fun <Item> addItem(items: List<Item>, item: Item): List<Item> {     return items + item }  suspend fun <Item> SelectItemDependencies<Item>.selectItem(items: List<Item>): Item {     return select(items) }  interface SelectItemDependencies<Item> {     suspend fun select(items: List<Item>): Item }  suspend fun <Item> CreateItemDependencies<Item>.createItem(): Item {     return create() }  interface CreateItemDependencies<Item> {     suspend fun create(): Item }

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

Это не сложно. Все что нам требуется — это сделать реализацию всех интерфейсов и правильно их скомпоновать.

val selectAndRemoveContext = object : SelectAndRemoveItemDependencies<String> {     override suspend fun selectItem(items: List<String>): String {         // ui-interaction here     }      override suspend fun removeItem(items: List<String>, item: String): List<String> =         com.genovich.cpa.removeItem(items, item) }  val createAndAddContext = object : CreateAndAddDependencies<String> {     override suspend fun createItem(): String {         // ui-interaction here     }      override suspend fun addItem(items: List<String>, item: String): List<String> =         com.genovich.cpa.addItem(items, item) }  val createOrRemoveContext = object : CreateOrRemoveDependencies<String> {     override suspend fun selectAndRemoveItem(items: List<String>): List<String> =         selectAndRemoveContext.selectAndRemoveItem(items)      override suspend fun createAndAdd(items: List<String>): List<String> =         createAndAddContext.createAndAdd(items) }  val exampleAppContext = object : ExampleAppDependencies<String> {     override suspend fun createOrRemove(items: List<String>): List<String> =         createOrRemoveContext.createOrRemove(items) }

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

Консольный UI

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

fun main() {     val outputFlow = MutableSharedFlow<String>(1)     val inputFlow = MutableSharedFlow<OneOf<String, Int>>(         extraBufferCapacity = 1,         onBufferOverflow = BufferOverflow.DROP_OLDEST     )      val selectAndRemoveContext = object : SelectAndRemoveItemDependencies<String> {         override suspend fun selectItem(items: List<String>): String {             outputFlow.emit(                 items.withIndex().joinToString("\n") { (index, item) -> "$index. $item" })             return inputFlow.filterIsInstance<OneOf.Second<Int>>()                 .mapNotNull { items.getOrNull(it.second) }                 .first()         }          override suspend fun removeItem(items: List<String>, item: String): List<String> =             com.genovich.cpa.removeItem(items, item)     }      val createAndAddContext = object : CreateAndAddDependencies<String> {         override suspend fun createItem(): String {             return inputFlow.filterIsInstance<OneOf.First<String>>()                 .map { it.first }                 .first()         }          override suspend fun addItem(items: List<String>, item: String): List<String> =             com.genovich.cpa.addItem(items, item)     }      val createOrRemoveContext = object : CreateOrRemoveDependencies<String> {         override suspend fun selectAndRemoveItem(items: List<String>): List<String> =             selectAndRemoveContext.selectAndRemoveItem(items)          override suspend fun createAndAdd(items: List<String>): List<String> =             createAndAddContext.createAndAdd(items)     }      val exampleAppContext = object : ExampleAppDependencies<String> {         override suspend fun createOrRemove(items: List<String>): List<String> =             createOrRemoveContext.createOrRemove(items)     }      runBlocking {         launch(Dispatchers.Default) { exampleAppContext.exampleApp(emptyList()) }         outputFlow.collectLatest { text ->             println("Items:")             println(text)             print("Enter item number to delete or item name to add: ")              val input = readln()              inputFlow.emit(                 input.toIntOrNull()?.let { OneOf.Second(it) } ?: OneOf.First(input)             )         }     } }

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

Мобильный UI

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

Что про нее нужно знать сейчас: она отправляет “запрос” связанный с callback’ом в канал и ждёт, пока принимающая сторона вызовет этот callback. Также как мы обычно взаимодействуем с backend’ом. А ещё Андроид-разработчики могут знать такой подход по Handler.replyTo.

object Logic  @Composable fun App(     selectItemsFlow: MutableStateFlow<UiState<List<String>, String>?> = MutableStateFlow(null),     createItemsFlow: MutableStateFlow<UiState<Unit, String>?> = MutableStateFlow(null), ) {     MaterialTheme {         // ПРЕДУПРЕЖДЕНИЕ: не повторяйте это дома!!!         // Логика не должна быть частью @Compose-функции!         // В идеале она должна запускаться за пределами App() и передавать selectItemsFlow и createItemsFlow как параметры         // Логика должна иметь возможность "жить" дольше, чем UI         // Да и передавать огромную пачку платформенных зависимостей в App() будет запарно, если будете использовать такой подход         LaunchedEffect(Logic) {             val selectAndRemoveContext = object : SelectAndRemoveItemDependencies<String> {                 override suspend fun selectItem(items: List<String>): String {                     return selectItemsFlow.showAndGetResult(items)                 }                  override suspend fun removeItem(items: List<String>, item: String): List<String> =                     com.genovich.cpa.removeItem(items, item)             }             val createAndAddContext = object : CreateAndAddDependencies<String> {                 override suspend fun createItem(): String {                     return createItemsFlow.showAndGetResult(Unit)                 }                  override suspend fun addItem(items: List<String>, item: String): List<String> =                     com.genovich.cpa.addItem(items, item)             }              val createOrRemoveContext = object : CreateOrRemoveDependencies<String> {                 override suspend fun selectAndRemoveItem(items: List<String>): List<String> =                     selectAndRemoveContext.selectAndRemoveItem(items)                  override suspend fun createAndAdd(items: List<String>): List<String> =                     createAndAddContext.createAndAdd(items)             }              val exampleAppContext = object : ExampleAppDependencies<String> {                 override suspend fun createOrRemove(items: List<String>): List<String> =                     createOrRemoveContext.createOrRemove(items)             }              exampleAppContext.exampleApp(emptyList())         }          Column {             val selectItems by selectItemsFlow.collectAsState()             selectItems?.also { (items, select) ->                 LazyColumn(                     modifier = Modifier.weight(1f),                     reverseLayout = true,                 ) {                     items(items.asReversed()) { item ->                         Text(                             modifier = Modifier                                 .fillMaxWidth()                                 .clickable { select(item) }                                 .padding(16.dp),                             text = item,                         )                     }                 }             }              val createItem by createItemsFlow.collectAsState()             createItem?.also { (_, create) ->                 Row(Modifier.fillMaxWidth()) {                     var value by remember(create) { mutableStateOf("") }                     TextField(                         modifier = Modifier.weight(1f),                         value = value,                         onValueChange = { value = it },                     )                     Button(                         onClick = { create(value) },                     ) {                         Text(text = "Create")                     }                 }             }         }     }

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

Вывод

Главное, на что я хотел бы обратить ваше внимание — это то, насколько логика становится целостной, если проектировать ее с точки зрения приложения, а не пользовательского интерфейса. А ещё насколько она гибкая, тестируемая и масштабируемая, если каждая функция отделена от своих зависимостей на уровне действий (функций), а не объектов.

Я думаю мы ещё сможем поговорить про эти аспекты отдельно. А пока что спасибо за внимание. Увидимся!

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


ссылка на оригинал статьи https://habr.com/ru/articles/851808/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *