Как стать автором
Обновить
1498.29
OTUS
Цифровые навыки от ведущих экспертов

Новый подход к безопасному управлению состояниями в Kotlin-приложениях

Время на прочтение20 мин
Количество просмотров2.7K
Автор оригинала: Nek.12

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

Итак, эта история началась примерно в то время, когда обрели свою популярность реактивные приложения с использованием MVI / MVVM+, и я начал делать свои первые шаги в их разработке. В этой статье я буду говорить в основном о MVI, потому что управление состояниями в MVI чуть более продвинутое, поэтому и примеры будут более наглядными, но те же проблемы и решения применимы и к MVVM (в котором также есть состояния).

Помните, как в одно время мы все помешались на однонаправленном потоке данных (UDF) и начали пихать его везде, куда только можно? В некоторых приложениях это породило отдельную категорию багов, получившую название "inconsistent state problem" (ICS или проблема несогласованного состояния). Позвольте мне продемонстрировать ее на реальном примере:

Заметили? Приложение одновременно отображает состояния "Загрузка", "Ошибка" и "Успех". Упс! Я уверен, что вы уже сталкивались, либо вам еще предстоит столкнуться с чем-то подобным при работе с UDF.

В этой статье мы решим эту проблему раз и навсегда.

Перед тем как начать: Что такое состояние? Что такое переход состояния?

Для начала давайте разберемся, что такое состояние (state) в контексте этой статьи и что такое "переходы состояний" (state transactions).

Я определяю состояние приложения следующим образом:

Это объект или группа объектов, хранящихся в памяти, которые представляют состояние приложения в определенный момент времени и служат для хранения срезов данных, необходимых для работы приложения.

Если говорить простым языком, то я определяю состояние как:

Класс, представляющий самые последние данные, полученные приложением из его множества источников достоверных данных (sources of truth).

Самый распространенный пример состояния, с которым вы, скорее всего, хорошо знакомы, — это состояние пользовательского интерфейса (UI):

data class UIState(
   val isLoading: Boolean = true,
   val error: Exception? = null,
   val items: List<Item>? = null,
)

Обычно каждая страница (экран) нашего приложения имеет свое собственное состояние UI. Иногда состояние может быть разделено между различными компонентами (например, виджетами) приложения. Но что же такое "переход" состояния?

Переход (транзакция) состояния — это атомарная операция, которая изменяет текущее состояние приложения.

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

  1. Установить состояние для отображения индикатора загрузки.

  2. Запросить элементы из источника данных и дождаться их поступления.

  3. Установить состояние для отображения этих элементов.

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

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

Теперь давайте разберемся, в чем разница между постоянными (persistent) и временными (transient) состояниями.

Я определяю постоянное (или персистентное) состояние как:

Состояние приложения, которое не зависит от жизненного цикла приложения.

Типичными примерами могут послужить базы данных, сетевые серверы, соединения веб-сокетов, SavedStateHandles (в Android) и файлы. Все они имеют одну общую черту — они переживут жизненный цикл нашего работающего приложения.

Соответственно, временное состояние — это:

Состояние приложения, которое существует, пока процесс приложения жив, т.е. состояние, хранящееся в памяти.

Типичными примерами временного состояния могут послужить видимость индикатора загрузки, состояния ввода текста или сессии и соединения клиент<>бэкенд.

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

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

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

Второй способ используется в MVVM — это несколько независимых объектов состояния, которые изменяются отдельно, в рамках одного блока бизнес-логики. Примером может быть:

val isLoading = MutableStateFlow<Boolean>(true)
val error = MutableStateFlow<Exception?>(null)
val items = MutableStateFlow<List<Item>?>(null)

Не поймите меня неправильно — нам всегда приходится декомпозировать состояния, будь то MVI или MVVM. Просто в MVI мы делаем это менее раздроблено, объединяя состояния в рамках компонента бизнес-логики. Объяснение того, почему мы могли бы хотеть делать именно так, вы найдете чуть дальше.

Шаг 1: Делаем состояние реактивным

В рамках UDF нашей целью является сделать состояние приложения зависимым от состояния источника данных.

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

Итак, с точки зрения клиента (разработчика), наш поток сейчас:

  1. Установит состояние для отображения индикатора загрузки.

"Подождите, а где же все остальное?" — спросите вы. По-моему, это самое большое обещание UDF — мы полагаемся на то, что наше состояние всегда будет поступать из надежного источника данных, так что нам не придется управлять им самостоятельно. В нашем случае мы просто должны "начать" с индикатора загрузки, а наш триггер базы данных сделает все остальное, запрашивая новые данные и наблюдая за их изменениями. Когда данные будут загружены, другая часть нашего кода установит наше временное состояние в соответствие с постоянным. То же самое можно проделать с сетевыми запросами, обернув их в реактивные потоки, такие как Cold Flows (холодные потоки) Coroutines:

val items = flow {
   api.getLatestItems()
}

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

В случае состояния, которое не подкреплено источником данных (оно изначально и всецело временное), мы можем просто создать наблюдаемый объект, например горячий поток (Hot Flow), и мутировать его при изменении данных вручную. Наши клиенты получат уведомление об изменении точно так же, как и в случае с постоянным состоянием. Мы как бы сами становимся своего рода триггером базы данных в данном случае.

val username = MutableStateFlow<String>("")

fun onUsernameChanged(value: String) = username.value = value

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

Шаг 2: Унификация состояний

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

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

  1. Мы предоставляем единую, четко определенную точку доступа к состоянию нашего приложения, готовую к потреблению клиентами.

  2. Мы явно определяем все связанные изменения состояния в рамках одной транзакции (и, как следствие, в рамках одного легко понимаемого блока кода).

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

  4. Мы упрощаем использование и изменение связанных состояний, группируя их свойства вместе.

  5. Мы защищаем себя от доступа к данным, к которым у нас не должно быть доступа во время компиляции.

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

Посмотрите на предыдущий пример с MVVM:

val isLoading = MutableStateFlow<Boolean>(true)
val error = MutableStateFlow<Exception?>(null)
val items = MutableStateFlow<List<Item>?>(null)

Теперь увидели проблему? Мы должны объявить кучу переменных, которые являются null или "пустыми", потому что очень часто их просто нет. Мы также должны учитывать тот факт, что список элементов может быть не только пустым, но и вообще отсутствовать.

По моему скромному мнению, это значительно усложняет нашу бизнес-логику:

  1. Мы должны всегда проверять, присутствует ли значение, даже если мы уверены, что оно будет присутствовать в каком-то конкретном месте.

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

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

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

Это основные причины, по которым я предпочитаю унифицировать состояния до определенной (разумной) степени при использовании MVI. Это также, вероятно, причина, по которой Google (и большинство коммерческих современных приложений) отходят от традиционного MVVM и переходят к MVVM+ (то же самое, но с унифицированными состояниями).

Вы можете возразить: "Но я могу создать единый класс, и у него все равно останутся все те же проблемы!", ссылаясь на мой первый пример:

data class UIState(
   val isLoading: Boolean = true,
   val error: Exception? = null,
   val items: List<Item>? = null,
)

Вот здесь я и предлагаю новую парадигму для описания состояния, которая полагается на возможности языка, — мы перейдем к, как я их называю, семействам состояний (State Families).

Шаг 3: Согласуем наши состояния, группируя их в семейства

По сути, термин "семейство состояний" подразумевает следующее:

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

Если говорить о Kotlin, то это означает, что мы определим наше состояние как sealed interface и удалим из результирующего объекта все свойства, которых не должно быть, когда приложение имеет это состояние:

internal sealed interface EditProfileState {


   data object Loading : EditProfileState
  
   data class Error(val e: Exception?) : EditProfileState
  
   data object Success : EditProfileState
  
   data object DisplayingAccountDeleted : EditProfileState
  
   data class EditingProfile(
       val email: String, // данные из хранилища
       val name: Input, // значение, введенное пользователем
   ) : EditProfileState
}

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

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

  2. Когда мы отображаем состояние Error или EditingProfile, у нас больше нет необязательных (nullable) полей, которые не служат никакой цели. Находясь в состоянии Error, мы на 100% уверены, что отображается именно ошибка и ничего больше.

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

  4. У нас может быть столько состояний, сколько мы захотим, возможны "вложенные состояния", и мы можем создавать семейство состояний, используя любую комбинацию источников данных (даже наш старый код с кучей потоков!).

Посмотрите, насколько мы продвинулись! Вот чего мы достигли благодаря смене парадигмы:

  1. Во-первых, мы значительно сократили количество состояний и переходов благодаря использованию UDF и реактивных потоков, что избавило нас от ряда проблем со сменой состояний.

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

  3. Наконец, мы определили наше состояние как закрытое семейство типов, устранив все риски обращения к конкретным значениям, когда этого делать не следует, как в бизнес-, так и в клиентской (т.е. пользовательской) логике. Мы также получили наглядную, простую для понимания структуру кода и единое место, где мы можем хранить и управлять всеми нашими переходами и значениями состояния.

К сожалению, этот подход не лишен недостатков. Теперь перед нами возникли две новые проблемы...

Решение проблемы №1: Потерянная информация

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

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

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

val state = MutableStateFlow<EditProfileState>(Loading)


fun onSaveChangesClicked(state: EditingProfile) {
   state.value = Loading
   validateNameAsync(state)
}


fun showValidationError(e: Exception) { /* отображаем снэкбар и т.д. */ } 


fun validateNameAsync(previous: EditingProfile) = launch {
   try {
       repository.verifyNameIsUnique(previous.name)
       state.value = Success
   } catch (e: Exception) {
       state.value = previous
       showValidationError(e)
   }
}

Видите? Теперь мы захватываем состояние при запуске операции, запускаем ее асинхронно, а затем устанавливаем состояние в Loading. Наша операция в зависимости от результата может либо отобразить Success, либо вывести ошибку проверки и восстановить предыдущее (previous) состояние с помощью переданного параметра.

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

Решение проблемы №2: Управление типом объекта

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

Например, всякий раз, когда мы хотим обновить текущее состояние новыми данными из внешнего источника (помните про UDF?), мы на самом деле не знаем, каково текущее состояние. Синтаксис Kotlin в этом случае не самый удобный, но, проделывая все это, мы явно следуем нашему контракту во время компиляции и, таким образом, получаем оговоренные выше преимущества. Например:

class EditProfileContainer(
   private val repo: UserRepository,
) {
   val state = MutableStateFlow<EditProfileState>(Loading)
  
   override suspend fun onSubscribed() { // (1)
       repo.getUserProfileFlow().collect { user: User ->
           state.update { state: EditProfileState -> // (2)
               val current = state as? EditingProfile // (3)
               EditingProfile( // (4)
                   email = user.email,
                   name = current?.name ?: Input.Valid(user.name ?: ""), // (5)
               )
           }
       }
   }
  
   // в демонстрационных целях мы не используем функцию "update()"
   fun onNameChange(value: String) {
       state.value = (state.value as? EditingProfile)?.let {
           it.copy(name = Input.Valid(value))
       } ?: state.value // (6)
   }
}

1. Это важно: мы должны следить за изменениями постоянного состояния только тогда, когда пользователь видит страницу, а не вообще все время. В противном случае мы тратим ресурсы впустую, а пользователь может даже не увидеть изменений. Это новое требование появилось потому, что наше состояние теперь является горячим потоком данных и требует от нас реализации обработки его жизненного цикла. Если бы мы использовали холодный поток (например, с помощью combine()), нам бы это не понадобилось. Но мы хотим использовать текущее значение состояния, когда захотим, и приостанавливать его для обновления, поэтому я решил использовать здесь горячий поток. Возросшая сложность — это цена, которую мы должны заплатить за отказ от нескольких потоков в пользу объединения их в один. Однако эта проблема не является неразрешимой. При желании можно разработать подход, в котором свойство state является обычным полем и может быть собрано из нескольких потоков, как в MVVM, если вам так больше нравится.

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

3. Мы проверяем тип текущего состояния, чтобы узнать, содержит ли оно уже какие-либо данные. В нашем примере это поле формы "Name". Теперь нам нужно безопасно обработать оба случая — когда значение есть и когда его нет.

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

5. Теперь мы вынуждены для начала проверять, не вносил ли пользователь изменения в свое имя.

  • Если да, то мы просто используем предыдущее значение. Мы успешно сохранили изменения, внесенные пользователем.

  • Если нет, то сначала мы проверяем, задал ли пользователь значение в удаленном объекте пользователя. Хорошим UX было бы показать пользователю его предыдущее имя, если он хочет сделать небольшое изменение.

  • В противном случае мы предлагаем пользователю добавить имя пользователя и заполняем форму ввода пустой строкой.

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

Я осознаю, что код выше выглядит немного сложнее, но я считаю, что мы можем легко решить и эту проблему:

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

  • Мы можем упростить внешний вид и шаблон этих приведений типов, создав хороший DSL, который будет делать всю проверку типов за нас.

  • Для каждого конкретного случая мы можем создать простые расширения, которые будут за нас генерировать “шаблонный код”, если нам не нравится, как выглядит наш собственный код. Например, мы можем создать функцию:

fun Input?.input(default: String? = null) = this ?: Input.Valid(default ?: "")


// использование:


EditingProfile(
  name = current?.name.input(user.name),
)

Вот как выглядит мой код для этой функции после некоторого рефакторинга:

val store = store<EditProfileState>(Loading) {


   whileSubscribed {
       repo.user.collect { user ->
           updateState {
               val current = typed<EditingProfile>()
               EditingProfile(
                   email = user.email,
                   name = current?.name.input(user.name),
               )
           }
       }
   }
  
   fun onNameChanged(value: String) = updateState<EditingProfile, _> { // this: EditingProfile
       copy(name = value.input())
   }
}

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

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

Шаг 4: Параллельное обновление состояния

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

  1. Мы делаем наши приложения полностью реактивными и многопоточными.

  2. Мы выполняем операции параллельно и в фоновом режиме.

  3. Мы обеспечиваем обработку ошибок и освобождение ресурсов при выполнении параллельных операций.

  4. Мы постоянно поддерживаем для пользователя надлежащую обратную связь.

Помните времена, когда мы могли просто прилепить пустой экран во время загрузки данных и вывести ошибку на весь экран, если она не удавалась, и пользователи не сильно возражали? Мне кажется, что те времена давно прошли, и теперь появление пустого экрана даже на секунду может легко стать причиной отзыва на 1⭐️, а полноэкранный индикатор загрузки вызовет праведный гнев UX-дизайнера в вашей команде, жалующегося на показатели удержания.

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

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

fun onNameChanged(value: String) = updateState<EditingProfile, _> { // this: EditingProfile
   val new = copy(name = value.input())
   launchValidateUsername(new)
   new
}


fun launchValidateUsername(state: EditingProfile) = launch {
   val unique = repo.verifyUsernameIsUnique(state.name.value)
   if (!unique) updateState {
       state.copy(name = Input.Error(name.value, "Username is not unique")
   }
}

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

Это приводит к следующему (и это лишь один из примеров, который можно привести):

  1. Пользователь изменяет свое имя, и оно является уникальным.

  2. Мы начинаем проверять имя пользователя, но эта операция выполняется довольно медленно.

  3. Пользователь сразу же нажимает кнопку "Submit".

  4. Мы отправляем значение для сохранения и устанавливаем состояние Loading.

  5. Пока мы пытаемся сохранить изменения, приходит результат проверки!

  6. Мы обновляем состояние, чтобы восстановить предыдущее значение.

  7. Экран пользователя мерцает, а индикатор загрузки исчезает.

  8. Пользователь, не понимая, что произошло и почему нет никакой обратной связи, снова нажимает кнопку "Submit".

  9. Мы начинаем обновлять состояние и снова показываем индикатор загрузки.

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

  11. Приходит следующий результат отправки имени пользователя. Имя больше не является уникальным, и функция выбрасывает ошибку.

  12. Экран мерцает, и пользователь видит сообщение о том, что его изменения не удалось сохранить, хотя на самом деле оно было успешно сохранено, всего через секунду после появления сообщения об успешном изменении.

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

Шаг 5: Сериализуемые переходы состояний

Термин "сериализуемый" происходит из терминологии архитектуры баз данных (DBA) и не имеет ничего общего с REST или JSON-структурами.

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

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

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

Я самостоятельно исследовал этот вопрос и пришел к выводу, что большинство клиентских приложений и архитектурных фреймворков используют следующие подходы:

  1. Делают все операции последовательными
    — Например, в MVI мы определяем наши намерения как очередь команд (например, с помощью канала), и в результате они обрабатываются последовательно. Мы уже говорили, что такой подход не подойдет для нашего высокопараллельного реактивного приложения.

  2. Используют только основной (или единственный) поток для обновления состояния
    — Такие фреймворки, как MVIKotlin, Orbit MVI и большинство других, используют эту стратегию, запрещая обновление состояния в фоновом потоке. Как мы уже говорили, наша цель — сделать полностью асинхронное, высокопроизводительное приложение

  3. Разделяют состояния на несколько потоков (streams), обновляемых атомарно между потоками (threads)
    — Это подход MVVM / MVVM+, но, вернувшись к нему, мы потеряем все остальные преимущества, которые только что получили.

  4. Делают все состояния постоянными
    — Этот пункт не требует пояснений, но мы не можем охватить все возможные варианты его использования. Существует довольно много императивных/stateful платформенных API, с которыми нужно иметь дело, как в нашем примере.

  5. Используют различные флаги в состоянии для индикации хода выполнения операций, постоянно управляя ими.
    — Например, мы можем добавить флаг в наше состояние isValidatingUsername и проверять его, чтобы решить, переводить ли пользователя в следующее состояние или нет, и/или отменить задачу обновления, когда мы отправляем данные
    — Лично мне не нравится это решение не только потому, что мы возвращаемся к тому, с чего начали, пытаясь избавиться от бессмысленных значений, но и потому, что сложность таких решений может возрастать в геометрической прогрессии.

  6. Вручную синхронизируют каждый параллельный источник данных с помощью таких примитивов, как семафоры и мьютексы.
    — Этот вариант на самом деле достаточно хорош и (спойлер) служит основой нашего решения, но это очень громоздко — создавать и управлять блокировками для всего, что у нас есть, а также перекладывать ответственность за атомарность на бизнес-логику, которая не хочет иметь ничего общего с состояниями нашего презентационного слоя.
    — Кроме того, это в конечном итоге замедлит работу нашего приложения, когда мы столкнемся с обычными проблемами синхронизированного кода, такими как взаимные блокировки, лайвлоки и голодание потоков.

Я предлагаю нечто иное — почему бы нам не перенять опыт баз данных и не сделать наши переходы состояний сериализуемыми? Реализация этого решения также проста:

private val _states = MutableStateFlow(initial)
val states: StateFlow<S> = _states.asStateFlow()
private val stateMutex = Mutex()


suspend fun withState(
   block: suspend S.() -> Unit
) = stateMutex.withReentrantLock { block(states.value) }


suspend fun updateState(
   transform: suspend S.() -> S
) = stateMutex.withReentrantLock { _states.update { transform(it) } }

Мы можем извлечь этот код в отдельный интерфейс и навсегда забыть об этой проблеме.

P.S. Для тех, кто разбирается в DBA, это делает наши переходы состояния соответствующими уровню изоляции транзакций SERIALIZABLE в Postgres.

Должен отметить, что использование реентерабельной блокировки здесь очень важно, так как мы хотим поддерживать вложенные переходы состояний для нескольких пограничных случаев. Если вам интересно, почему мы просто не используем _states.update { } без блокировки, то вам нужно прочитать документацию к этому методу, чтобы понять, что функция может вызвать вашу лямбду несколько раз, если полученное и предыдущее значения не совпадут. Мы не хотим этого, например, для критических вызовов API, и в то же время мы не хотим, чтобы состояние менялось во время выполнения длительной задачи. Таким образом, блокировка — это лучшее решение для нашего случая, когда нужно сделать обновления состояния сериализуемыми.

"И это все?" — спросите вы.

К этому решению есть одна небольшая оговорка: вы должны понимать, что мы внедрили в наш код логику синхронизации, которая сделает обновления состояния по своей природе последовательными. Блокировка вносит некоторые накладные расходы на производительность каждый раз, когда мы пытаемся обновить состояние, поэтому вы можете рассмотреть возможность создания "запасного" подхода или опции отключения сериализуемых переходов состояния для данной операции обновления или всего блока бизнес-логики, чтобы предотвратить трату ресурсов на синхронизацию, когда обновления состояния происходят очень часто. По моему личному опыту, эти замедления редко заметны и не приводят к подвисаниям, если выполняются в основном потоке.

Послесловие

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

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

После прочтения этой статьи у вас может остаться пара вопросов:

  1. Где можно увидеть этот подход в действии?

  2. Где можно посмотреть пример реализации?

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

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

Этот фреймворк:

  • Делает переходы состояний полностью сериализуемыми с реентерабельными блокировками и возможностью отключить сериализацию для отдельного перехода или целого блока бизнес-логики.

  • Имеет хороший DSL, подобный приведенному выше, который упрощает проверку типов и безопасное обновление состояния.

  • Управляет жизненным циклом подписки для запуска, остановки и повторного запуска заданий по обновлению состояния.

  • Позволяет сделать бизнес-логику полностью асинхронной и переключаться на любое количество потоков.

  • Реализован с помощью корутин как первоклассный продукт.

  • Обеспечивает согласованность состояний и позволяет легко объединить их в единый источник истины.

  • Обрабатывает все ошибки за вас и позволяет обновлять состояние соответствующим образом без неудобных try/catches для каждой функции.

  • Абстрагирует от всех тонкостей обновления состояний и параллельной обработки, чтобы вам не нужно было тратить на это свои нервы.

  • Управляет фоновыми заданиями за вас и автоматически высвобождает ресурсы в нужный момент.

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

  • Поддерживает сохранение состояния по мере необходимости в качестве дополнительного уровня безопасности.

Я буду рад, если вы попробуете его и дадите мне знать, что вы думаете об этом новом подходе к управлению состояниями!

Обновление от 5 апреля 2024: Я добавил в статью дополнительные пояснения по поводу горячих и холодных потоков и использования мьютекса, основываясь на отзывах читателей.


Прокачать все необходимые навыки разработки Android-приложений до уровня Middle/Senior можно в рамках курса "Android Developer. Professional".

Теги:
Хабы:
Всего голосов 6: ↑6 и ↓0+9
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS