Кроссплатформенная архитектура ядра приложения. Простая. Линейная. Масштабируемая
Задача
Я — андроид разработчик. Обычно ко мне приходят с фразой вроде «вот мы тут придумали фичу, сделаешь?» и с макетом дизайна, вроде такого.
Я смотрю на это всё и вижу: вот экраны, эти данные на них — статические, а вот эти динамические, значит их надо откуда-то взять; вот тут интерактивные компоненты: при взаимодействии с ними надо что-то сделать. Иногда просто открыть другой экран или виджет, иногда выполнить логику. Исходя из этого я проектирую то, как будет выглядеть логика фичи. Описываю ее в компонентах архитектуры, разбиваю на задачи, узнаю где и как взаимодействовать с сервером, и прочее.
Скрытые кейсы
Но потом я обнаруживаю, что далеко не все переходы такие простые, как нарисовано на дизайне, например, как в случае с авторизацией. Не все явно присутствуют, как, например, переходы назад. А в некоторых случаях не хватает всевозможных экранов ожидания, экранов «пустых» состояний и экранов ошибок.
Знакомо?
Наблюдение
В общем я долго думал и пришел к выводу, что я все это время смотрел на новые фичи и приложения не с той стороны.
Что я имею ввиду?
С точки зрения пользователя приложение — это экраны, точки взаимодействия и ожидание, пока приложение сделает свою работу. Видите, даже в этом описании есть второй актор — приложение. Почему бы не посмотреть на все процессы с его точки зрения.
Пример
В качестве примера используем одну из типичных задач из учебников по программированию — это задача с CRUD, то есть с созданием, чтением, изменением и удалением. И на самом деле даже обрежем ее до создания, удаления и отображения.
Вообще изначально я думал сделать шахматы, но в какой-то момент понял, что это слишком сложный пример, и мы засядем с ним очень надолго.
Наша фича будет выглядеть так. Простой список с элементами, при нажатии на элемент он удаляется, а под списком есть поле для ввода и кнопка создания нового элемента.
И вот макет такого приложения.
Первые фичи
Вопрос, с чего начать? В целом, начать можно было бы с чего угодно, но я подумал, что логичнее начать с core-фичи: отображение списка элементов.
Но зачем мне — приложению — его показывать?
И это очень правильный вопрос. Потому что приложению не надо взаимодействовать с пользователем, пока оно может принимать решения самостоятельно. Например, решение о том, как найти элемент в списке и удалить его. Но приложение не может принять решение о том, какой элемент надо удалить, по крайней мере в этом случае. Так что мы делегируем это решение пользователю.
Вот смотрите: есть функция удаления. Она принимает на вход список и элемент, убирает элемент из списка и возвращает измененный список. Обычно это функция стандартной библиотеки, так что мы не будем описывать ее реализацию. Просто примем ее как данное.
Теперь у нас (все еще как у приложения) два вопроса, и описаны они в виде входных параметров: откуда взять список и откуда взять элемент для удаления?
И если список может быть чем-то, чем мы владеем, то принятие решения об удаляемом элементе мы делегируем пользователю. И теперь это все можно запаковать в новую функцию выбора и удаления.
И тут я хочу обратить ваше внимание на то, как плавно мы масштабировали удаление элемента до выбора и удаления. И если вы думаете, что это просто такой пример, то давайте масштабировать дальше: добавим создание нового элемента.
Масштабируемость: добавляем создание элемента
И так, как приложение мы можем добавить элемент в список, но не знаем как именно этот элемент выглядит, что он содержит? Давайте пойдем от понятного.
Функция добавления нового элемента имеет сигнатуру похожую на функцию удаления элемента. Разве что имена разные. Ну и выполняемые действия =)
Далее мы сталкиваемся все с тем же вопросом: «откуда брать элемент для добавления?» А так как мы не можем сами его решить, то снова делегируем это пользователю и красиво запаковываем в «create and add».
Но есть ощущение, как будто мы все это уже делали. А где же обещанная масштабируемость? Она заключается в том, что теперь мы можем запустить эти две программы параллельно, таким образом объединив их в одну и в то же время не внося изменения ни в одну их них.
В итоге, запаковав все это в цикл, передающий результат работы прошлого шага в следующий, мы получим конечную реализацию для нашей задачи.
Теперь у нас есть кросс-платформенное описание логики приложения, не привязанное к конкретному UI. На бумаге…
Логика
Теперь надо превратить это описание в код. Как?
Каждый блок превращается в функцию. В моем случае в suspend-функцию в kotlin, но это не так важно. И я обязательно покажу почему в другой раз.
Проходя сверху-вниз по нашей таблице, последовательно реализуем «example app», «create or remove», «select and remove» и остальные функции.
suspend fun - exampleApp(items: List
- ): Nothing {
updateLoop(items) {
createOrRemove(it)
}
}
suspend fun
- createOrRemove(items: List
- ): List
- {
return parallel(
{ selectAndRemoveItem(items) },
{ createAndAdd(items) }
)
}
suspend fun
- selectAndRemoveItem(items: List
- ): List
- {
val item = selectItem(items)
return removeItem(items, item)
}
suspend fun
- removeItem(items: List
- , item: Item): List
- {
return items - item
}
suspend fun
- createAndAdd(items: List
- ): List
- {
val item: Item = createItem()
return addItem(items, item)
}
suspend fun
- addItem(items: List
- , item: Item): List
- {
return items + item
}
Но тут мы упираемся вот в эту часть, где нам надо взаимодействовать с пользователем.
suspend fun - selectItem(items: List
- ): Item {
TODO("Interact with user")
}
suspend fun
- createItem(): Item {
TODO("Interact with user")
}
Это внешняя зависимость по отношению к нашей логике. «Внешняя» значит, что мы должны как-то получить ее откуда-то снаружи. И как бы это сделать?
Зависимости
Я предлагаю описать зависимости этих функции в виде интерфейсов. И пусть его реализацией займётся внешняя система, которая хочет запускать наше приложение.
suspend fun - SelectItemDependencies
- .selectItem(items: List
- ): Item {
return select(items)
}
interface SelectItemDependencies
- {
suspend fun select(items: List
- ): Item
}
suspend fun
- CreateItemDependencies
- .createItem(): Item {
return create()
}
interface CreateItemDependencies
- {
suspend fun create(): Item
}
Правда теперь «внешней системой» становятся вызывающие функции.
С точки зрения вызывающей функции мы теперь должны реализовать этот интерфейс или запросить его извне. Но в таком случае мы сильно привяжем себя именно к этой функции и к этому интерфейсу, а мне — как вызывающей функции — это не нужно. Мне достаточно сигнатур этих функций, не реализаций. То есть в целом можно провернуть тот же трюк, что и для selectItem и createItem: вынести зависимости в интерфейс. А затем это можно сделать рекурсивно вплоть до exampleApp.
suspend fun - ExampleAppDependencies
- .exampleApp(items: List
- ): Nothing {
updateLoop(items) {
createOrRemove(it)
}
}
interface ExampleAppDependencies
- {
suspend fun createOrRemove(items: List
- ): List
-
}
suspend fun
- CreateOrRemoveDependencies
- .createOrRemove(items: List
- ): List
- {
return parallel(
{ selectAndRemoveItem(items) },
{ createAndAdd(items) }
)
}
interface CreateOrRemoveDependencies
- {
suspend fun selectAndRemoveItem(items: List
- ): List
-
suspend fun createAndAdd(items: List
- ): List
-
}
suspend fun
- SelectAndRemoveItemDependencies
- .selectAndRemoveItem(items: List
- ): List
- {
val item = selectItem(items)
return removeItem(items, item)
}
interface SelectAndRemoveItemDependencies
- {
suspend fun selectItem(items: List
- ): Item
suspend fun removeItem(items: List
- , item: Item): List
-
}
suspend fun
- removeItem(items: List
- , item: Item): List
- {
return items - item
}
suspend fun
- CreateAndAddDependencies
- .createAndAdd(items: List
- ): List
- {
val item = createItem()
return addItem(items, item)
}
interface CreateAndAddDependencies
- {
suspend fun createItem(): Item
suspend fun addItem(items: List
- , item: Item): List
-
}
suspend fun
- addItem(items: List
- , item: Item): List
- {
return items + item
}
suspend fun
- SelectItemDependencies
- .selectItem(items: List
- ): Item {
return select(items)
}
interface SelectItemDependencies
- {
suspend fun select(items: List
- ): Item
}
suspend fun
- CreateItemDependencies
- .createItem(): Item {
return create()
}
interface CreateItemDependencies
- {
suspend fun create(): Item
}
Теперь, когда наши функции настолько отделены друг от друга, нам надо собрать их обратно в полноценную программу.
Это не сложно. Все что нам требуется — это сделать реализацию всех интерфейсов и правильно их скомпоновать.
val selectAndRemoveContext = object : SelectAndRemoveItemDependencies {
override suspend fun selectItem(items: List): String {
// ui-interaction here
}
override suspend fun removeItem(items: List, item: String): List =
com.genovich.cpa.removeItem(items, item)
}
val createAndAddContext = object : CreateAndAddDependencies {
override suspend fun createItem(): String {
// ui-interaction here
}
override suspend fun addItem(items: List, item: String): List =
com.genovich.cpa.addItem(items, item)
}
val createOrRemoveContext = object : CreateOrRemoveDependencies {
override suspend fun selectAndRemoveItem(items: List): List =
selectAndRemoveContext.selectAndRemoveItem(items)
override suspend fun createAndAdd(items: List): List =
createAndAddContext.createAndAdd(items)
}
val exampleAppContext = object : ExampleAppDependencies {
override suspend fun createOrRemove(items: List): List =
createOrRemoveContext.createOrRemove(items)
}
Здесь я просто последовательно реализую все интерфейсы и подставляю вызовы функций там, где это нужно. Мы, конечно, могли бы обсудить громоздкость решения, и почему я не выносил зависимости для добавления и удаления элемента, но лучше сделать это в комментариях или на патреоне, бусти и телеграмме.
Консольный UI
В итоге мы должны запустить exampleApp используя собранный граф зависимостей как контекст для его выполнения. Также я добавил очередь сообщений и ответов, чтобы пользователь мог взаимодействовать с приложением.
fun main() {
val outputFlow = MutableSharedFlow(1)
val inputFlow = MutableSharedFlow>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val selectAndRemoveContext = object : SelectAndRemoveItemDependencies {
override suspend fun selectItem(items: List): String {
outputFlow.emit(
items.withIndex().joinToString("\n") { (index, item) -> "$index. $item" })
return inputFlow.filterIsInstance>()
.mapNotNull { items.getOrNull(it.second) }
.first()
}
override suspend fun removeItem(items: List, item: String): List =
com.genovich.cpa.removeItem(items, item)
}
val createAndAddContext = object : CreateAndAddDependencies {
override suspend fun createItem(): String {
return inputFlow.filterIsInstance>()
.map { it.first }
.first()
}
override suspend fun addItem(items: List, item: String): List =
com.genovich.cpa.addItem(items, item)
}
val createOrRemoveContext = object : CreateOrRemoveDependencies {
override suspend fun selectAndRemoveItem(items: List): List =
selectAndRemoveContext.selectAndRemoveItem(items)
override suspend fun createAndAdd(items: List): List =
createAndAddContext.createAndAdd(items)
}
val exampleAppContext = object : ExampleAppDependencies {
override suspend fun createOrRemove(items: List): List =
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, String>?> = MutableStateFlow(null),
createItemsFlow: MutableStateFlow?> = MutableStateFlow(null),
) {
MaterialTheme {
// ПРЕДУПРЕЖДЕНИЕ: не повторяйте это дома!!!
// Логика не должна быть частью @Compose-функции!
// В идеале она должна запускаться за пределами App() и передавать selectItemsFlow и createItemsFlow как параметры
// Логика должна иметь возможность "жить" дольше, чем UI
// Да и передавать огромную пачку платформенных зависимостей в App() будет запарно, если будете использовать такой подход
LaunchedEffect(Logic) {
val selectAndRemoveContext = object : SelectAndRemoveItemDependencies {
override suspend fun selectItem(items: List): String {
return selectItemsFlow.showAndGetResult(items)
}
override suspend fun removeItem(items: List, item: String): List =
com.genovich.cpa.removeItem(items, item)
}
val createAndAddContext = object : CreateAndAddDependencies {
override suspend fun createItem(): String {
return createItemsFlow.showAndGetResult(Unit)
}
override suspend fun addItem(items: List, item: String): List =
com.genovich.cpa.addItem(items, item)
}
val createOrRemoveContext = object : CreateOrRemoveDependencies {
override suspend fun selectAndRemoveItem(items: List): List =
selectAndRemoveContext.selectAndRemoveItem(items)
override suspend fun createAndAdd(items: List): List =
createAndAddContext.createAndAdd(items)
}
val exampleAppContext = object : ExampleAppDependencies {
override suspend fun createOrRemove(items: List): List =
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. вот ссылка на проект, сделанный в статье, а также гитхаб проекта с использованными функциями