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


Задача


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



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


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


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


Знакомо?


Наблюдение


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


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


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


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


Пример


В качестве примера используем одну из типичных задач из учебников по программированию — это задача с 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. вот ссылка на проект, сделанный в статье, а также гитхаб проекта с использованными функциями

Комментарии (1)


  1. ALexKud
    20.11.2024 02:46

    Хороший подход. Именно так я и делаю в серверном TSQL коде своих приложений. Создаю действия(операции) в таблице и реализую их в виде хранимых процедур. В клиентком коде в интерфейсе их вызываю. Логика приложений, даже сложных упрощается, как и тестирование. Плюс легко добавить логирование,, так как можно привязаться к id операции.. Мало того, можно в таблице операций хранить сами запросы с виртуальными параметрами, убрав их из клиентского приложения. Именно так и сделано было в одном их приложений которое может работать с сервером tsql и локальной sqlite.