Описание проблемы
Задача
Я — андроид разработчик. Обычно ко мне приходят с фразой вроде “вот мы тут придумали фичу, сделаешь?” и с макетом дизайна, вроде такого.
Я смотрю на это всё и вижу: вот экраны, эти данные на них — статические, а вот эти динамические, значит их надо откуда-то взять; вот тут интерактивные компоненты: при взаимодействии с ними надо что-то сделать. Иногда просто открыть другой экран или виджет, иногда выполнить логику. Исходя из этого я проектирую то, как будет выглядеть логика фичи. Описываю ее в компонентах архитектуры, разбиваю на задачи, узнаю где и как взаимодействовать с сервером, и прочее.
Скрытые кейсы
Но потом я обнаруживаю, что далеко не все переходы такие простые, как нарисовано на дизайне, например, как в случае с авторизацией. Не все явно присутствуют, как, например, переходы назад. А в некоторых случаях не хватает всевозможных экранов ожидания, экранов “пустых” состояний и экранов ошибок.
Наблюдение
В общем я долго думал и пришел к выводу, что я все это время смотрел на новые фичи и приложения не с той стороны.
Что я имею ввиду?
С точки зрения пользователя приложение — это экраны, точки взаимодействия и ожидание, пока приложение сделает свою работу. Видите, даже в этом описании есть второй актор — приложение. Почему бы не посмотреть на все процессы с его точки зрения.
Другой подход
Пример
В качестве примера используем одну из типичных задач из учебников по программированию — это задача с 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/
Добавить комментарий