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


Задача

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

unwtxxewd2vkctwsuyqo5c5aano.png

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


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

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

Знакомо?


Наблюдение

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

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

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


Пример

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

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

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

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

jhelsefkvqrfiltowwbtohnic5c.png


Первые фичи

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

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

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

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

grfxgtblzdzq4azoqd95u-nss50.png

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

amayck84lvazjhsfpfm2uwjnnnw.png

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

0nby-sz5rdofw4ln4gtgcwcbr_i.png

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


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

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

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

ecddmrmpujthodovuym1dy2zzzw.png

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

i8sp92_4wnbsaox7mei6hxpf890.png

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

uaobdpmrrd9cksxxa5gbnmnhlwu.png

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

qftbyhkquppklydcuh3fp0yz9ai.png

Теперь у нас есть кросс-платформенное описание логики приложения, не привязанное к конкретному 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)
            )
        }
    }
}

lk_irjwk_laux2t1hl46j-0eooy.gif

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


Мобильный 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")
                    }
                }
            }
        }
    }

yppgwdypavbeoflwtfjznh-pzeq.gif

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

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

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

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

© Habrahabr.ru