Так, давайте еще раз.

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

Открытым остался вопрос: как ее к UI подключать?

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

В этой статье я разложу по полочкам само решение, как я к нему пришел и при чем тут Алан Тьюринг. А бонусом покажу как это все масштабируется и оставлю вас размышлять о том, почему мы не додумались до этого раньше (ну за 85 лет уже можно было бы).

Т-функция

t-function ru.png
t-function ru.png

Так, само решение - это функция, которая пишет в 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-иерархии.

hourglass.png
hourglass.png

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

  • Параллельный: слушаем результат сразу двух каналов параллельно.

  • Исключающий: слушаем результат из первого или из второго, но никогда не из двух.

  • Последовательный: слушаем результат из первого, пока не получим доступ ко второму.

Теперь поговорим о каждом в отдельности и с примерами. Начну с параллельного, как с самого простого.

Параллельная композиция

Во многих, если не во всех библиотеках вроде 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>
}

Зачем так сложно?

И все же: зачем так сложно? Зачем композировать все каналы во что-то одно, чтобы потом это снова распиливать на части?

Ответ простой - масштабирование.

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

hourglass scalability.png
hourglass scalability.png

Заключение

И так мы подробно рассмотрели Т-функцию, из чего она появилась, а так же как и почему каналы, торчащие из Т-функций надо объединять. Этот текст дался мне не легко, потому что он, на мой взгляд, поднимает больше вопросов, чем дает ответов. Но давайте рассматривать его как что-то вроде точки входа для дальнейших размышлений.

Я думаю, что что-бы вы не думали о том подходе, который я описал в предыдущей статье и о подходах к композиции каналов, вы всегда можете взять и начать использовать Т-функцию в вашем коде уже сейчас. Пример самой функции есть на github.

А если вам интересно следить то, что я здесь описываю, то подписывайтесь на мои boosty и patreon. Обещаю, что в новом году буду писать туда больше, может даже чаще, чем раз в неделю.

Увидимся в новом году!

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