Так, давайте еще раз.
Я - Владимир, который в прошлый раз рассказал про то, как сделать классную, цельную и масштабируемую логику для кроссплатформенного приложения в вакууме.
Открытым остался вопрос: как ее к UI подключать?
Точнее я уже частично отвечал на этот вопрос в двух прошлых статьях, но в этой я хотел бы целенаправленно поговорить именно об этой части того подхода, который я предлагаю.
В этой статье я разложу по полочкам само решение, как я к нему пришел и при чем тут Алан Тьюринг. А бонусом покажу как это все масштабируется и оставлю вас размышлять о том, почему мы не додумались до этого раньше (ну за 85 лет уже можно было бы).
Т-функция
Так, само решение - это функция, которая пишет в MutableStateFlow
(2). Пишет она пакет, состоящий из входных данных и callback'а, который должен вернуть результат в саму функцию. Все это работает, потому что в качестве реализации такого callback'а я использую Continuation.resume
, а дальше, благодаря поведению suspend-функций, это все работает как обычная синхронная функция. Плюс я отправляю null в канал (5), когда функция закончила свою работу, как признак того, что больше от принимающей стороны ничего не ждут. И конечно же я все это реализовал через suspendCancellableCoroutine()
, чтобы такую функцию можно было легко отменить, если потребуется. А как показала практика, требоваться будет часто.
suspend fun <T, U> MutableStateFlow<Pair<T, U>?>.showAndGetResult(input: T): U {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation { value = null }
val callback = { result: T -> resume(result) }
value = Pair(input, callback)
}
}
Такая реализация позволяет Т-функции выглядеть как обычная функция снаружи, при этом подключаясь к любой внешней системе будь то другой поток, процесс, периферийное устройство, UI, бэкенд или что-либо ещё.
На самом деле тут больше нечего объяснять, так что можно перейти к истории создания.
История
С чего все началось
Идея того, что работа с UI должна бы быть синхронной посетила меня много лет назад. Тогда я задался довольно простым вопросом: “почему консольные приложения, которые я писал, пока учился - а так же скрипты и приложения для встраиваемых систем - так сильно отличаются от того, что я пишу для андроида?”. И это относится почти ко всем UI-приложениям.
Тогда я зацепился за мысль, что все дело в том, что в консольных приложениях ввод/вывод данных делается синхронно: функции вроде print()
, scan()
, работа с файлами и БД. Вроде в WinAPI есть даже блокирующие диалоговые окна.
После этого я стал экспериментировать с блокирующей Т-функцией. То есть когда функция взаимодействия с внешней системой реально блокирует поток на котором вызывается пока ждёт ответа.
fun <Input, Output> MutableStateFlow<Pair<Input, (Output) -> Unit>>.blockingTFunc(input: Input) : Output {
val blockingQueue = ArrayBlockingQueue<Output>(1)
val callback = { output: T -> blockingQueue.offer(output) }
value = Pair(input, callback)
return blockingQueue.take()
}
Результаты были интересными: код становился цельным и очень напоминал консольные приложения из прошлого. Концептуально становилось намного лучше: код мог ждать результатов и продолжать работать только тогда, когда они появлялись, читать такой код было проще и тестировать тоже (до определенного момента).
Это выглядело очень многообещающе, но оставались вопросики. Была проблема с масштабированием и, как следствие, с тестированием. Была проблема с количеством потоков на масштабе и, как следствие, с “выеданием” пула потоков. Было что-то ещё, но я уже не помню.
Зато код было просто и понятно читать. Интеграционные тесты были просто космос: представьте, что можно целое приложение взять и запустить в тесте без привязки к UI, что позволяло быстро проверять какие-то аспекты работы приложения прямо в тесте, а не собирать тяжёлое андроид-приложение и не ждать пока оно установится и запустится, а потом ещё и не писать на него сложные espresso-тесты. Так ещё и такое приложение имело потенциал формата “write once, run everywhere”, потому что UI становился просто плагином к логике, а не ее основой.
На тот момент я временно забыл про эту идею, потому что не знал что делать с блокировками и масштабированием. Без решения этих двух вопросов предлагаемый подход не годился для продакшена от слова “совсем”.
Шаг назад, два вперед
Шли годы, я пытался переосмыслить MVC и MVP. Сделал два своих подхода к ELM-архитеутуре. И даже смог внедрить что-то из этого, о чем сейчас, пожалуй, немного жалею. Подходы работали, помогали кодить быстрее и допускать меньше типовых ошибок, помогали точнее планироваться и лучше отслеживать ход выполнения работ. Но были сложными как для использования, так и для доработки. Они, как мне до сих пор кажется, плохо работали без меня. Но я и сам понимал и боялся, что так будет, и что решение не идеально.
В какой-то момент я решил, что надо бы подтянуть основы теории вычислений. Мне казалось, что ответ к правильной архитектуре приложения лежит где-то в домене конечных автоматов. Я взялся за курс на EDX, который дал мне намного больше, чем я ожидал.
Видите ли, под впечатлением от успехов моей реализации ELM/Flux-like архитектуры я думал, что архитектура такого типа - это абсолютно идеальное решение. Оно компактное, масштабируемое, тестируемое. Все созданные таким образом части логики хорошо шарятся между модулями. Чего ещё можно пожелать?
Много чего: чтобы не было эффектов, а ещё one-shot состояний. Чтобы не было внешнего “выполнителя” эффектов и всего бойлерплейта, который нужен, чтобы запустить логику и остальные компоненты.
В общем я решил пройти курс до конца. Узнал много нового, а многое не понял. Всё ещё возвращаюсь к 3-SAT и диагонализации для доказательства Halting problem, потому что они никак не улягутся в голове. Порадовала эквивалентность регулярок и конечных автоматов. А Pushdown automaton так и остался для меня чем-то странным.
Но потом я добрался до Машины Тьюринга.
Из того, что я вижу, люди чаще всего фокусируются на этой модели вычислений с точки зрения ее реализации. Есть множество статей и видео, где представлено много разных реализаций МТ в коде и в физическом мире. Но меня заинтересовало другое.
С точки зрения пользователя Машина Тьюринга - это устройство, в которое можно отправить “запрос” и, подождав, получить ответ. А знаете что еще работает схожим образом? Процедуры и функции.
В этой статье я оставлю эту аналогию необоснованной, но обязательно вернусь к обоснованию в следующий раз. А пока такая аналогия позволяет нам опираться на различные теоремы и свойства МТ. И сейчас нас интересует две из них: сhoice-machine (c-machine) и Universal Turing Machine (UTM - универсальная Машина Тьюринга).
c-machine - это такая МТ, результат работы которой зависит не только от ее конфигурации, но и от каких-то решений, принимаемых “снаружи”. В общем она может зависнуть в некоторых состояниях, ожидая ввода пользователя.
UTM - это такая МТ, которая может выполнять другие МТ.
Две эти вещи в сумме дают обоснование того, что логику можно описывать как вложенные друг в друга функции, которые иногда могут останавливаться в ожидании внешнего решения.
В общем так я убедился в том, что иду в правильном направлении, и могу сделать что-то лучшее, чем ELM-like архитектура. А так же я нашел способ масштабировать систему описанную как набор вложенных друг в друга функций. (Забавно, вы никогда не думали, что весь код, который мы пишем - это просто набор вложенных друг в друга функций, а вся эта объекто-ориентированная мишура просто сбивает нас с толку)
Масштабирование
Последним вопросом, над которым я до сих пор думаю, осталось масштабирование, а точнее композиция множества Т-функций, которые могут торчать из нашей программы. Конечно мы можем оставить их висеть как есть, но тогда на принимающей стороне не остаётся информации о том, в каком порядке эти каналы будут работать. А это может привести к ситуации, когда UI будет ждать какого-то значения, которое ещё не скоро появится, и пользователь будет заблокирован навсегда. А также без единой точки входа нам придется принимать решение о том, как доставить каналы, торчащие из логики, до того места, где их используют. В случае с какой-нибудь иерархией UI-компонент, где каждый новый слой вложен в предыдущий, самому корневому слою придется принимать сразу все каналы. Сколько их может быть одновременно в большом приложении? Я не знаю, но боюсь, что довольно много.
Поэтому я хочу организовать все каналы по подобию песочных часов: из программы торчат наши каналы, которые постепенно - слой за слоем, на основе иерархии функций логики - объединяются в один канал, который потом можно обратно разобрать на составляющие. Видите, это напоминает форму песочных часов. А центральная часть и будет той единственной зависимостью, которую передают в корневой слой UI-иерархии.
Итак, три подхода к композиции двух каналов, которые, на мой взгляд, должны закрывать все потребности. В целом они соответствуют трем вариантам композиции функций:
Параллельный: слушаем результат сразу двух каналов параллельно.
Исключающий: слушаем результат из первого или из второго, но никогда не из двух.
Последовательный: слушаем результат из первого, пока не получим доступ ко второму.
Теперь поговорим о каждом в отдельности и с примерами. Начну с параллельного, как с самого простого.
Параллельная композиция
Во многих, если не во всех библиотеках вроде Rx или Kotlin Flow есть функции объединения двух каналов в один, вроде zip
или combine
: слушаем два канала, объединяем результаты в единый Pair
при каждом изменении. Так принимающая сторона точно знает, что два канала данных работают параллельно.
fun <T : Any, U : Any> Flow<T?>.and(other: Flow<U?>): Flow<Pair<Flow<T>, Flow<U>>?> {
val firstFlow: Flow<Flow<T>?> = this.emitSelfWhenHaveValue()
val secondFlow: Flow<Flow<U>?> = other.emitSelfWhenHaveValue()
return combine(firstFlow, secondFlow) { first, second ->
if (first != null && second != null) {
Pair(first, second)
} else {
null
}
}
}
Flow<T>.emitSelfWhenHaveValue()
fun <T : Any> Flow<T?>.emitSelfWhenHaveValue(): Flow<Flow<T>?> =
this.distinctUntilChangedBy { it?.let { Unit } }.map { it?.let { this.filterNotNull() } }
Взаимно-исключающая композиция
Следующий вариант композиции - исключающий. Он отличается о параллельного тем, что конвертирует результат работы двух каналов не в пару, а в тип с двумя взаимно-исключающими подтипами. Вы скорее всего знаете его как Either или что-то подобное. Такой подход помогает принимающей стороне понять, что значение придет от одного из двух каналов, но не от двух сразу.
fun <T : Any, U : Any> Flow<T?>.xor(other: Flow<U?>): Flow<OneOf<Flow<T>, Flow<U>>?> {
val firstFlow: Flow<Flow<T>?> = this.emitSelfWhenHaveValue()
val secondFlow: Flow<Flow<U>?> = other.emitSelfWhenHaveValue()
return combine(firstFlow, secondFlow) { first, second ->
when {
first == null && second == null -> null
first != null && second == null -> First(first)
first == null && second != null -> Second(second)
first != null && second != null -> null
else -> null
}
}
}
OneOf<First, Second>
sealed interface OneOf<out First, out Second> {
data class First<T>(val first: T) : OneOf<T, Nothing>
data class Second<T>(val second: T) : OneOf<Nothing, T>
}
Последовательная композиция
Последний вариант композиции и самый непонятный в плане реализации (на мой взгляд) - это последовательная композиция. Как показать, что значения придут сначала из первого канала, а потом уже из второго, но при этом не сделать то же самое, что в случае взаимно исключающей композиции?
Моё предложение: пока никак, только поменять тип и дать гарантию, что мы не пришлём значение из второго канала, пока не придет хотя бы одно значение из первого. А новое значение из первого не придёт, пока второй не “закончит” работать.
fun <T : Any, U : Any> Flow<T?>.then(other: Flow<U?>): Flow<Then<Flow<T>, Flow<U>>?> {
val firstFlow: Flow<Flow<T>?> = this.emitSelfWhenHaveValue()
val secondFlow: Flow<Flow<U>?> = other.emitSelfWhenHaveValue()
var waitingForFirst = true
return combine(firstFlow, secondFlow) { first, second ->
if (waitingForFirst) {
first?.let {
waitingForFirst = false
Then.First(first)
}
} else when {
first == null && second == null -> null
first != null && second == null -> Then.First(first)
first == null && second != null -> Then.Second(second)
first != null && second != null -> null
else -> null
}
}
}
Then<First, Second>
sealed interface Then<out First, out Second> {
data class First<T>(val first: T) : Then<T, Nothing>
data class Second<T>(val second: T) : Then<Nothing, T>
}
Зачем так сложно?
И все же: зачем так сложно? Зачем композировать все каналы во что-то одно, чтобы потом это снова распиливать на части?
Ответ простой - масштабирование.
Такая иерархия - в виде песочных часов - поможет нам не перелопачивать горы кода просто ради того, чтобы добавить новый канал. Вместо этого, при добавлении нового канала со стороны логики, мы должны будем поправить только принимающую сторону.
Заключение
И так мы подробно рассмотрели Т-функцию, из чего она появилась, а так же как и почему каналы, торчащие из Т-функций надо объединять. Этот текст дался мне не легко, потому что он, на мой взгляд, поднимает больше вопросов, чем дает ответов. Но давайте рассматривать его как что-то вроде точки входа для дальнейших размышлений.
Я думаю, что что-бы вы не думали о том подходе, который я описал в предыдущей статье и о подходах к композиции каналов, вы всегда можете взять и начать использовать Т-функцию в вашем коде уже сейчас. Пример самой функции есть на github.
А если вам интересно следить то, что я здесь описываю, то подписывайтесь на мои boosty и patreon. Обещаю, что в новом году буду писать туда больше, может даже чаще, чем раз в неделю.
Увидимся в новом году!