О чём статья

В четвёртой статье цикла про функциональное программирование в Android мы соберём знания, полученные в предыдущих частях цикла. А ещё применим их на практике — соберём скелет приложения для медитации.

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

  1. Функциональное программирование в Android. Знакомство с парадигмой.

  2. Функциональное программирование в Android. Структуры данных и State Machine.

  3. Функциональное программирование в Android. Теория категорий и DI.

Перед тем, как перейти к написанию кода, я должен вас предупредить, что он во многом будет демонстрационным. Для ускорения написания приложения я использовал в нём и mock-реализации, и hardcode — в общем, это не руководство по построению эталонной архитектуры.

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

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

Ключевые компоненты архитектуры

В первый раз я пробовал строить ФП-архитектуру на ViewModel. Сейчас я буду использовать The Elm Architecture (TEA) — паттерн управления состоянием, заимствованный из функционального языка Elm.

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

Цикл потока данных ELM-архитектуры
Цикл потока данных ELM-архитектуры

State предоставляет иммутабельный снимок текущего состояния приложения или экрана. Он содержит данные для отрисовки UI и проектируется как легко сериализуемый объект. Его иммутабельность гарантирует предсказуемость. Любое изменение требует создания новой копии состояния, исключая скрытые модификации и побочные эффекты.

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

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

Effect представляет конкретное UI-действие после обновления состояния: ShowToast, NavigateToScreen. В отличие от команд, эффекты выполняются после фиксации нового состояния и не создают события. Они моделируют «реакцию» системы на изменения.

Механизмы обработки бизнес-логики

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

StateResultсистемный контейнер-транспортёр. Он гарантирует атомарность обновления: состояние изменяется синхронно, а потом параллельно запускаются команды и эффекты. Такой подход предотвращает RaceConditions и обеспечивает последовательность логики.

Actor исполняет асинхронные операции. Его метод execute преобразует команды в события, а subscribe обрабатывает внешние потоки данных: WebSocket, таймеры. Актор изолирует «грязную» работу с внешним миром, возвращая результаты через события, которые вновь попадают в редьюсер.

Центр управления

Store — это хранилище, координирующее все компоненты. Оно содержит потоки для наблюдения за изменениями состояния, обработки эффектов и метод send для отправки событий.

interface Event
interface Command
interface Effect
data class StateResult<S : State>(
  val state: S,
  val commands: List<Command> = emptyList(),
  val effects: List<Effect> = emptyList()
)

interface Reducer<S : State, E : Event> {
  fun reduce(state: S, event: E?): StateResult<S>
}

interface State

interface Actor {
  suspend fun execute(command: Command): Event?
  suspend fun subscribe(): Flow<Event>
}

interface Store<S : State, E : Event> {
  val state: StateFlow<S>
  val effects: SharedFlow<Effect>
  fun send(event: E)
}

Цикл работы замыкается: команды при выполнении генерируют новые события, которые вновь отправляются в хранилище. Эффекты потребляются UI-слоем без возможности влиять на состояние.

Побочные эффекты

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

  • запись в базу данных;

  • отправка сетевого запроса;

  • изменение глобальной переменной;

  • обновление UI.

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

Уничтожить побочные эффекты невозможно. Но в функциональном программировании можно:

  • контролировать предсказуемость выполнения этих эффектов, например, при помощи монады;

  • выносить эффекты за пределы чистых функций.

Изолировать побочные эффекты на UI-слое можно внедрив Launch, Disposable или
Side-эффекты из библиотеки Compose.

Другим приёмом изоляции побочных эффектов на UI-слое, является внедрение Launch, Disposable или Side эффектов из библиотеки Compose.

Compose построен на принципах ФП:

UI = func(@Composable (State) \to UI)

Как я уже упоминал ранее, полностью избавиться от эффектов нельзя. В мобильной разработке таких ситуаций масса: запуск анимации, отправка аналитики, подписка на потоки данных. Если выполнять эффекты прямо в теле Composable-функций, может произойти неконтролируемая рекомпозиция, утечка ресурсов или краш при перерисовке. Чтобы решить эту проблему, были разработаны Effects.

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

@Composable 
fun UserProfile(userId: String) {
    LaunchedEffect(key1 = userId) { // Запускается при изменении userId
        val user = api.fetchUser(userId) // Сетевой запрос (эффект)
        updateUi(user)
    }
}

DisposableEffect аналог LaunchedEffect, но с возможностью освобождения ресурсов как onDestroy в Activity. Его стоит использовать для подписки или отписки на потоки, регистрацию слушателей или её отмену.

@Composable
fun LocationTracker() {
    val player = remember { createExoPlayer("example.mp3") }

    
    DisposableEffect(player) {
        val listener = object : Player.Listener {
      override fun onPlaybackStateChanged(playbackState: Int) {
        when (playbackState) {
          Player.STATE_ENDED -> {
            player.seekTo(0) // Возвращаем в начало, но не воспроизводим
          }
        }
      }
    }
    player.addListener(listener)
    onDispose {
      player.removeListener(listener)
      player.release()
    }
  }
}

Для такого типа эффекта есть возможность привязаться к жизненному циклу Activity. Ваш вариант, если используете lifecycleOwner и выполняете различные задачи в зависимости от текущего состояния активности.

SideEffectсинхронные изменения внешнего мира. Этот вид эффектов однократно выполняет код после успешной рекомпозиции. Он не имеет своего состояния и привязки к ключам — выполняется немедленно, синхронно и в UI-потоке.

Этот вид побочных эффектов подходит для:

  • легковесных операций: логирование, отправка аналитики, обновление внешних кэшей;

  • глобальных синхронизаций: обновление системных UI-компонентов и т.д.

  • синхронизации с внешними системами: обновление legacy-компонентов: View, Fragment; взаимодействие с нативными API: WebView, CameraX; интеграция с не-Compose библиотеками.

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

Но не всё так идеально. SideEffetct не должен изменять состояние Compose, иначе это вызовет бесконечную рекомпозицию. Этот вид эффектов не подходит для запуска корутин — для этого есть LaunchedEffect.

А ещё с SideEffect болезненно переносить тяжеловесные операции, поскольку он выполняется в UI-потоке. Так как жить с побочными эффектами в функциональном программировании?

  • признайте их неизбежность. Без эффектов приложение бесполезно;

  • изолируйте и контролируйте их. Используйте специальные инструменты — монады или Compose Effects;

  • следуйте простым правилам: эффекты не должны зависеть от состояния или ключей; ресурсы нужно освобождать; в теле Composable не должно быть не детерминированных эффектов.

Compose-эффекты — это элегантное воплощение идей функционального программирования в Android. Они превращают хаотичные побочные эффекты в предсказуемые и управляемые сущности. Такой подход позволяет строить отзывчивые и стабильные UI-системы.

Реализациях архитектуры на примере конкретной фичи

У нас есть:

  • чистые функции;

  • Either и Compose Effects для борьбы с «грязными» функциями;

  • Closure (Capability Passing) для замены DI;

  • ELM-архитектура, на которой это всё должно по идее завестись.

Досконально расписывать каждый класс и экран приложения я не буду — рассмотрю только самые интересные кейсы. Итоговый результат найдёте в исходном коде — он тут, в ветке feature/make_application.

Начнём с интересной фичи — переключение режима сна и бодрствования. На первый взгляд, задача простая — сделать свитчер «светлая/тёмная тема». Однако на деле всё немного сложнее, ведь свитчер должен переключаться автоматически в зависимости от текущего времени суток.

Можно сохранять текущую тему в локальное хранилище или базу и помещать в closure. Но я хочу показать альтернативный способ на стыке парадигм. Пойдём против правил функционального программирования и используем для этого CompositionLocalProvider

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

Но есть другое важное замечание: согласно документации Google, CompositionLocal имеет смысл использовать, когда гарантируется значение по умолчанию, и если это потенциально может использовать любой потомок, а не несколько из них. Кажется, что по обоим условиям мы проходим, да и инициализация значения будет гарантированно происходить в корневой Composable-функции.

enum class ThemeMode {
  WAKE, SLEEP
}

class ThemePreferences(
  private val prefs: SharedPreferences
) {
  fun getThemeMode(): Either<Throwable, ThemeMode?> = Either.catch {
    when (prefs.getString("theme_mode", null)) {
      "SLEEP" -> ThemeMode.SLEEP
      "WAKE" -> ThemeMode.WAKE
      else -> null
    }
  }

  fun setThemeMode(mode: ThemeMode): Either<Throwable, Unit> = Either.catch {
    prefs.edit { putString("theme_mode", mode.name) }
  }
}

Мы заготовили Enum-класс для видов темы и класс-обёртку над SharedPrefs для сохранения текущей темы. Поскольку мы зависим от побочного эффекта — актуального времени —, нам нужно обернуть всё в монаду, чтобы изолировать этот эффект.

class SleepWakeHandler(
  private val prefs: ThemePreferences,
  private val timeProvider: () -> Calendar = { Calendar.getInstance() }
) {
  fun getCurrentMode(): Either<Throwable, ThemeMode> =
    prefs.getThemeMode()
      .flatMap { savedMode ->
        savedMode?.right() ?: getDefaultThemeByTime()
      }

  fun setThemeMode(mode: ThemeMode): Either<Throwable, Unit> =
    prefs.setThemeMode(mode)

  private fun getDefaultThemeByTime(): Either<Throwable, ThemeMode> = Either.catch {
    val hour = timeProvider().get(Calendar.HOUR_OF_DAY)
    if (hour in 6..21) ThemeMode.WAKE else ThemeMode.SLEEP
  }
}

Готово. Создадим провайдер и получим доступ к данным внутри него:

val LocalSleepWakeHandler = staticCompositionLocalOf<SleepWakeHandler> {
  error("No ThemeManager provided")
}
@Composable
fun SilentMoonApp {
  val sleepWakeHandler = remember {
    SleepWakeHandler(
      ThemePreferences(context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE))
    )
  }

  CompositionLocalProvider(
    LocalSleepWakeHandler provides sleepWakeHandler
  ) {
    /*..*/
  }
}

С Base-слоем закончили. Определимся с логикой переходов в приложении.

Навигация

Ударяться в библиотеки и решения с компонентным уклоном — типа Decompose — не хочется. Используем стандартное решение с Jetpack Compose навигацией. Опишем логику переходов между экранами:

  1. При открытии приложения пользователя встретит Welcome-экран.

  2. Далее он увидит Onboarding-экран для выбора предпочтений в медитации — этот выбор сохраняется.

  3. Попадаем на главную. При повторном открытии Welcome-экран и Onboarding будут пропущены, если они уже были пройдены.

  4. На главной — элемент управления навигацией корневых экранов: Home, Meditate, Sleep.

Описание содержимого:

  1. На главной — приветствие с учётом времени суток, Suggests-панель и рекомендации.

  2. Экран с детальной информацией курса: статистика по избранным и прослушиваниям, подборка из аудиозаписей с двумя типами голосов.

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

  4. Проигрыватель. Перемотка жестом на фиксированную величину, а также непосредственное проигрывание.

  5. Сон. Аналог экрана медитации, но с подборками для сна. При клике на эту вкладку включается режим сна в приложении.

enum class Destination(
  val route: String,
  val label: String,
  val contentDescription: String,
  @DrawableRes val icon: Int
) {
  HOME(
    route = "home",
    label = "Home",
    contentDescription = "Home screen",
    icon = R.drawable.ic_home
  ),
  /*Остальные табы*/
}
@Composable
fun BottomBar(
  navController: NavHostController,
) {
  val navBackStackEntry by navController.currentBackStackEntryAsState()
  val currentDestination = navBackStackEntry?.destination
  val sleepWakeHandler = LocalSleepWakeHandler.current

  val themeMode by sleepWakeHandler.currentModeFlow.collectAsState()
  val themeResources = remember(themeMode) {
    themeMode.fold(
      ifLeft = { getThemeResources(ThemeMode.WAKE) },
      ifRight = { getThemeResources(it) }
    )
  }

  NavigationBar(
    windowInsets = NavigationBarDefaults.windowInsets,
    containerColor = themeResources.animatedBackgroundColor,
  ) {
    Destination.entries.forEach { destination ->
      NavigationBarItem(
        selected = destination.route == currentDestination?.route,
        onClick = {
          if (destination.route != currentDestination?.route) {
            navController.navigate(destination.route) {
              popUpTo(navController.graph.startDestinationId) {
                saveState = true
              }
              launchSingleTop = true
              restoreState = true
            }
            when (destination) {
              Destination.SLEEP -> sleepWakeHandler.setThemeMode(ThemeMode.SLEEP)
              Destination.MEDITATION -> sleepWakeHandler.setThemeMode(ThemeMode.WAKE)
              else -> {}
            }
          }
        },
}

В коде выше мы используем стандартную реализацию BottomBar навигации. Поэтому отдельно остановлюсь только в паре мест. Так как задача стоит реактивно реагировать на изменение темы приложения, необходимо подписаться на поток, отвечающий за актуальный режим приложения. Для этого мы немного доработаем SleepWakeHandler. Добавим дополнительный поток и несколько вспомогательных функций. Теперь при выборе вкладки Sleep и Meditate хэндлер будет переключать режим при клике на соответствующие табы.

class SleepWakeHandler(/*..*/) {

  private val _currentModeFlow = MutableStateFlow<Either<Throwable, ThemeMode>>(Either.Right(ThemeMode.WAKE))
  val currentModeFlow: StateFlow<Either<Throwable, ThemeMode>> = _currentModeFlow

  init {
    refreshTheme()
  }

  fun getCurrentMode(): Either<Throwable, ThemeMode> = _currentModeFlow.value

  private fun refreshTheme() {
    prefs.getThemeMode()
      .flatMap { savedMode ->
        savedMode?.right() ?: getDefaultThemeByTime()
      }
      .fold(
        ifLeft = { error ->
          _currentModeFlow.value = Either.Left(error)
        },
        ifRight = { mode ->
          _currentModeFlow.value = Either.Right(mode)
        }
      )
  }
}

По аналогии со SleepWakeHandler для решения задачи управления создадим ProfileProvider, чтобы управлять бесшовным открытием приложения: для случая, когда пользователь уже прошёл онбординг, и для случая, когда он его ещё не прошёл. Он будет поставщиком необходимой информации для реализации этой логики. Только использовать его будем уже без CompositionLocalProvider

CompositionLocalProvider выглядит универсальным решением для передачи зависимостей в Compose-приложениях. Однако на практике он создаёт больше проблем, чем решает.

Его главная проблема в неявности зависимостей. Когда компонент берёт данные через CompositionLocal.current, его требования к внешнему контексту становятся скрытыми. Просто взглянуть на сигнатуру и понять, что нужно для работы, не получится, а это усложняет тестирование, переиспользование кода и анализ потоков данных.

А ещё CompositionalLocal нарушает принцип контролируемой видимости. В классическом DI зависимости ограничены своей областью — экраном или модулем — а тут значение может «протекать» в любую дочернюю композицию, даже если этого не предполагалось. В итоге получим хрупкую связь между не связанными частями приложения.

Использовать CompositionLocalProvider как замену DI-контейнера вообще опасно. В отличие от систем внедрения зависимостей, он не управляет жизненным циклом объектов, не поддерживает квалификаторы или множественные реализации интерфейсов. По сути, это просто механизм передачи данных по дереву UI.

Для управления зависимостями лучше выбрать Dagger/Hilt, Koin или ручное внедрение через параметры и делегаты. CompositionLocal стоит применять для логически привязанных к UI данных: теме, локализации, контексту, навигации. Всё остальное приведёт к спагетти-коду, где зависимости неявны, а поведение компонентов непредсказуемо.

Вернёмся к навигации:

NavHost(
  navController = navController,
  startDestination = if (profileProvider.isOnboardingCompleted()) Destination.HOME.route else "welcome",
) {
  composable("welcome") {
    sleepWakeHandler.getCurrentMode().fold(
      ifLeft = { ErrorScreen() },
      ifRight = { mode ->
        handleThemeMode(
          mode = mode,
           onWake = {
             WelcomeScreen {
               navigateFromWelcomeToHome(profileProvider, navController)
             }
           },
           onSleep = {
             WelcomeSleepScreen {
               navigateFromWelcomeToHome(profileProvider, navController)
             }
           }
         )
       }
    )
 }

 composable("choose_topic") {
   ChooseTopicScreen {
     navigateFromOnboardingToHome(profileProvider, navController)
   }
}

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

ELM-архитектура в действии

Теперь продемонстрирую реализацию ELM. Сделаю это на примере экрана медиапроигрывателя. Алгоритм реализации фичей такой:

Типовой набор компонентов для фичи
Типовой набор компонентов для фичи
  • вводим стейт экрана, формируем данные для него;

  • создаём список событий, эффектов и команд;

  • выделяем необходимые для работы зависимости;

  • пишем реализации Actor и Reducer.

data class PlayerState(
  val musicItem: MusicItem? = null,
  val isPlaying: Boolean = false,
  val progress: Long = 0,
  val isFavorite: Boolean = false,
  val error: String? = null
) : State

sealed class PlayerCommand : Command {
  data class LoadMusic(val musicId: MusicId) : PlayerCommand()
  data class StartPlayback(val item: MusicItem) : PlayerCommand()
  data object PausePlayback : PlayerCommand()
  data object StopPlayback : PlayerCommand()
  data class SeekToPosition(val offsetMs: Long) : PlayerCommand()
  data class RewindPlayback(val offsetMs: Long) : PlayerCommand()
  data class ToggleFavoriteStatus(val musicId: MusicId) : PlayerCommand()
}

sealed class PlayerEffect : Effect {
  data class ShowError(val message: String) : PlayerEffect()
}

sealed class PlayerEvent : Event {
  data class StartLoad(val musicId: MusicId) : PlayerEvent()
  data class MusicLoaded(val musicItem: MusicItem) : PlayerEvent()
  data class ErrorOccurred(val message: String) : PlayerEvent()
  
  data object PlaybackStateChanged : PlayerEvent()
  data object PlaybackStopped : PlayerEvent()
  
  data class PaybackSought(val offsetMs: Long) : PlayerEvent()
  data class PaybackRewound(val offsetMs: Long) : PlayerEvent()
  data class PositionChanged(val position: Long) : PlayerEvent()

  data class FavoriteClicked(val musicId: MusicId) : PlayerEvent()
  data class FavoriteStatusChanged(val isFavorite: Boolean) : PlayerEvent()
}
interface MusicPlayerCapability {
  val audioPlayer: AudioPlayer
}

interface MusicDataCapability {
  val musicRepository: MusicRepository
}

class PlayerCapabilityImpl(
  override val audioPlayer: AudioPlayer,
  override val musicRepository: MusicRepository
) : MusicDataCapability, MusicPlayerCapability

fun makePlayerCapability(
  context: Context,
  coroutineScope: CoroutineScope,
  audioPlayer: AudioPlayer = ExoAudioPlayer(context, coroutineScope),
  musicRepository: MusicRepository = FakeMusicRepository(context),
) = PlayerCapabilityImpl(
  audioPlayer = audioPlayer,
  musicRepository = musicRepository,
)

Наполним логикой сущности ExoAudioPlayer и FakeMusicRepository:

FakeMusicRepository
class FakeMusicRepository(context: Context) : MusicRepository {
  private val mutex = Mutex()
  private val databaseFile = File(context.filesDir, "music_database.json")
  private val json = Json { prettyPrint = true }


  private val initialDatabase = MusicDatabase(
    items = mapOf(
      MusicId("1") to MusicItem(/*..*/),
      /*..*/
    )
  )

  private fun loadDatabase(): Either<MusicError, MusicDatabase> = either {
    if (!databaseFile.exists()) {
      return@either initialDatabase
    }

    Either.catch { json.decodeFromString<MusicDatabase>(databaseFile.readText()) }
      .mapLeft { MusicError.FileReadError }
      .getOrElse { initialDatabase }
  }

  private fun saveDatabase(db: MusicDatabase): Either<MusicError, Unit> = either {
    Either.catch { databaseFile.writeText(json.encodeToString(db)) }
      .mapLeft { MusicError.FileWriteError }
      .bind()
  }

  private val currentDatabase: MutableStateFlow<Either<MusicError, MusicDatabase>> by lazy {
    MutableStateFlow(loadDatabase())
  }

  override suspend fun getMusicById(id: MusicId): Either<MusicError, MusicItem> = either {
    val db = currentDatabase.value.bind()
    db.items[id] ?: raise(MusicError.NotFound)
  }

  override suspend fun toggleFavorite(id: MusicId): Either<MusicError, Boolean> = either {
    mutex.withLock {
      val currentDb = currentDatabase.value.bind()
      val currentItem = currentDb.items[id] ?: raise(MusicError.NotFound)
      val updatedItem = currentItem.copy(isFavorite = !currentItem.isFavorite)
      val newDb = currentDb.copy(items = currentDb.items + (id to updatedItem))

      saveDatabase(newDb).bind()
      currentDatabase.value = Either.Right(newDb)

      updatedItem.isFavorite
    }
  }
}

Класс FakeMusicRepository хранит музыкальные треки в JSON-файле, а не в настоящей базе данных. Это сделано для упрощения примера: нам не нужно подключать сложные библиотеки — Room или SQLite —, но мы можем сохранить логику работы с хранилищем: чтение, запись, обновление.

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

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

either{} создаёт блок, в котором можно последовательно выполнять операции с ошибкой. Если на каком-то этапе произойдёт сбой, дальнейшие действия скипнутся и сразу вернётся ошибка.

bind() «разворачивает» результат операции. Получили Either.Left выполнение прервётся и ошибка вернётся из either, а получили Either.Rightработа продолжится с полученным значением.

ensure() проверяет условие. Если оно не выполняется, возвращается указанная ошибка. Это аналог if (!condition) return error в функциональном стиле.

raise() принудительно прерывает выполнение с ошибкой аналогично throw, но без исключений.

Если на любом из перечисленных выше этапов возникнет проблема, вся цепочка остановится.

ExoAudioPlayer
class ExoAudioPlayer(context: Context, scope: CoroutineScope) : AudioPlayer {
    
    // Модель ошибок плеера
    sealed interface Error { 
        data class PlaybackFailed(val cause: Throwable) : Error 
    }
    
    // Состояния плеера (инициализация через Either для обработки ошибок)
    private val playerState = MutableStateFlow<Either<Error, ExoPlayer>>(...)
    private val playbackState = MutableStateFlow<Either<Error, PlaybackState>>(...)
    private val positionState = MutableStateFlow<Either<Error, Long>>(...)
    private var updatingPosition = false

    init {
        // Начальная настройка и подписка на изменения
        observePlayerState()
    }

    // Создание и базовая настройка ExoPlayer
    private fun createPlayer(context: Context): ExoPlayer {
        return ExoPlayer.Builder(context)
            .setAudioAttributes(...)
            .build().apply {
                addListener(object : Player.Listener {
                    // Обработчики изменений состояния плеера
                    override fun onPlaybackStateChanged(state: Int) { ... }
                    override fun onIsPlayingChanged(isPlaying: Boolean) { ... }
                })
            }
    }

    override fun play(url: String) {
        _playerState.value.map { player ->
            Either.catch {
                if (needNewMediaItem(player, url)) {
                    player.setMediaItem(...)
                    player.prepare()
                }
                player.play()
            }.onLeft { handlePlaybackError(it) }
        }
    }

    override fun pause() {
        // Приостановка воспроизведения
        _playerState.value.map { it.pause().also { updatingPosition = false } }
    }

    override fun seekBy(ms: Long) {
        // Перемотка на указанное количество миллисекунд
        _playerState.value.map { it.seekTo(...) }
    }

    override fun release() {
        // Освобождение ресурсов плеера
        _playerState.value.map { it.release() }
    }

    // Внутренние вспомогательные методы
    private fun observePlayerState() {
        // Подписка на изменения состояния плеера
        scope.launch { 
            _playerState.collect {...}
        }
    }

    private fun startPositionUpdates() {
        // Периодическое обновление позиции воспроизведения
        scope.launch { 
            while (updatingPosition) {
                updateCurrentPosition()
                delay(200)
            }
        }
    }

    // Состояния воспроизведения
    private sealed class PlaybackState {
        object Idle : PlaybackState()
        object Ready : PlaybackState()
        object Buffering : PlaybackState()
        object Ended : PlaybackState()
    }
}

Класс ExoAudioPlayer инкапсулирует три основных потока данных:

  • текущее состояние плеера — задел на будущее для возможности управлять плеером в фоне или без привязки к конкретному экрану;

  • позиция воспроизведения;

  • возможные ошибки.

При инициализации создаётся экземпляр ExoPlayer с базовой конфигурацией для музыкального контента, включая обработку прерываний воспроизведения. Плеер автоматически отслеживает изменения состояния через Player.Listener и обновляет соответствующие StateFlow.

Логика воспроизведения включает в себя проверки на текущее состояние. При вызове play() происходит либо инициализация нового медиа-источника, либо возобновление воспроизведения с текущей позиции. У каждого трека она обновляется с фиксированным интервалом в 200мс только во время активного воспроизведения, оптимизируя производительность. Операции с плеером — пауза, перемотка и т.д. — защищены обработкой через Either. А значит они будут безопасно выполняться даже при возникновении ошибок.

Реализация представляет собой разделение ответственности: ExoPlayer управляет низкоуровневыми операциями, а класс-обёртка предоставляет чистый и реактивный интерфейс с явной типизацией всех возможных состояний и ошибок.

Я не стал приводить эти классы к привычному closure-виду — не хочется загромождать код. Однако в идеале их стоит унифицировать.

Напишем реализации Actor и Reducer:

class PlayerActor<Ctx>(private val capabilities: Ctx) : Actor
        where Ctx: MusicPlayerCapability,
              Ctx: MusicDataCapability {
  override suspend fun execute(command: Command): Event = when (command) {
    is PlayerCommand.LoadMusic -> loadMusicData(command.musicId)
    is PlayerCommand.StartPlayback -> playMusic(command.item)
    is PlayerCommand.PausePlayback -> pauseMusic()
    is PlayerCommand.SeekToPosition -> seekTo(command.offsetMs)
    is PlayerCommand.RewindPlayback -> rewind(command.offsetMs)
    is PlayerCommand.ToggleFavoriteStatus -> toggleFavorite(command.musicId)
    is PlayerCommand.StopPlayback -> stopMusic()
    else -> throw IllegalArgumentException("Unknown command")
  }

  @OptIn(ExperimentalCoroutinesApi::class)
  override suspend fun subscribe(): Flow<Event> {
    return merge(
      capabilities.audioPlayer.playbackState.flatMapConcat { stateEither ->
        stateEither.fold(
          ifLeft = { ... },
          ifRight = { ... }
        )
      },
      capabilities.audioPlayer.currentPosition.flatMapConcat { positionEither ->
        positionEither.fold(
          ifLeft = { ... },
          ifRight = { ... }
        )
      }
    )
  }

  private suspend fun loadMusicData(id: MusicId): Event {
    return capabilities.musicRepository.getMusicById(id).fold(
      ifRight = { PlayerEvent.MusicLoaded(it) },
      ifLeft = { PlayerEvent.ErrorOccurred("Failed to load music data: $it") }
    )
  }

  private fun playMusic(musicItem: MusicItem): Event {
    capabilities.audioPlayer.play(musicItem.audioUrl)
    return PlayerEvent.NoOp
  }
  //Остальные команды//
}

Тут всё просто, но некоторые команды не требуют явного реагирования UI. Так что можно добавить специальное событие NoOp. Можно было бы и Nullable-типами обойтись, но мне такой путь не нравится, о чём я писал во второй части цикла.

class PlayerReducer : Reducer<PlayerState, PlayerEvent> {

  override fun reduce(state: PlayerState, event: PlayerEvent): StateResult<PlayerState> {
    return when (event) {
      is PlayerEvent.StartLoad -> handleLoadData(state, event)
      is PlayerEvent.MusicLoaded -> handleDataLoaded(state, event)
      is PlayerEvent.ErrorOccurred -> handleError(state, event)
      is PlayerEvent.PlaybackStateChanged -> handlePlayPause(state)
      is PlayerEvent.PlaybackStopped -> handleStop(state)
      is PlayerEvent.PaybackSought -> handleSeek(state, event)
      is PlayerEvent.PaybackRewound -> handleRewind(state, event)
      is PlayerEvent.FavoriteClicked -> handleToggleFavorite(state, event.musicId)
      is PlayerEvent.PositionChanged -> handlePositionChanged(state, event.position)
      is PlayerEvent.PaybackEnded -> handleEndedTrack(state)
      else -> StateResult(state)
    }
  }

private fun handleError(
    state: PlayerState,
    event: PlayerEvent.ErrorOccurred
  ): StateResult<PlayerState> {
    return StateResult(
      state = state.copy(error = event.message),
      effects = listOf(PlayerEffect.ShowError(event.message))
    )
  }

 private fun handlePlayPause(
    state: PlayerState,
  ): StateResult<PlayerState> {
    require(state.musicItem != null)
    return StateResult(
      state = state.copy(isPlaying = !state.isPlaying),
      commands = listOf(
        if (state.isPlaying) PlayerCommand.PausePlayback
        else PlayerCommand.StartPlayback(state.musicItem)
      )
    )
  }

// Остальные события
}

C редьюсером дело обстоит аналогично: перегружаем метод reduce, обрабатываем события — обновляем состояние, отправляем команды или генерируем эффекты. 

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

class ElmStore<S : State, E : Event>(
  initialState: S,
  private val reducer: Reducer<S, E>,
  private val actor: Actor,
  private val coroutineScope: CoroutineScope,
) : Store<S, E> {

  private val _state = MutableStateFlow(initialState)
  private val _effects = MutableSharedFlow<Effect>(extraBufferCapacity = DEFAULT_EFFECTS_BUFFER)
  override val state = _state.asStateFlow()
  override val effects: SharedFlow<Effect> = _effects.asSharedFlow()

  init {
    coroutineScope.launch {
      actor.subscribe().collect { event ->
        @Suppress("UNCHECKED_CAST")
        (event as? E)?.let { processEvent(it) }
      }
    }
  }

  override fun send(event: E) {
    coroutineScope.launch {
      processEvent(event)
    }
  }

  private fun processEvent(event: E) {
    val currentState = _state.value
    val result = reducer.reduce(currentState, event)

    _state.value = result.state

    result.commands.forEach { command ->
      coroutineScope.launch {
        when (val commandResult = actor.execute(command)) {
          else -> @Suppress("UNCHECKED_CAST") {
            processEvent(commandResult as E)
          }
        }
      }
    }

    result.effects.forEach { effect ->
      coroutineScope.launch {
        _effects.emit(effect)
      }
    }
  }

  companion object {
    const val DEFAULT_EFFECTS_BUFFER = 64
  }
}

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

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

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

Для систем, где критична надёжная доставка, стоит рассмотреть политику retry для эффектов — при неудачной обработке эффект помещается в очередь повторной отправки. В сложных сценариях поможет разделение эффектов на категории — критические и некритические — с разными стратегиями обработки. Например, навигационные команды или важные уведомления должны использовать отдельный канал с гарантированной доставкой, а второстепенные UI-эффекты могут работать и через стандартный буферизованный поток.

Точка входа связи бизнес-логики с UI будет выглядеть так:

@Composable
fun <Ctx> rememberPlayerStore(
  musicId: MusicId,
  context: Ctx,
  coroutineScope: CoroutineScope = rememberCoroutineScope()
): Store<PlayerState, PlayerEvent>
        where Ctx : MusicPlayerCapability,
              Ctx : MusicDataCapability {

  return remember(musicId) {
    ElmStore(
      initialState = PlayerState(),
      reducer = PlayerReducer(),
      actor = PlayerActor(context),
      coroutineScope = coroutineScope
    )
  }
}
@Composable
fun PlayerScreen(
  modifier: Modifier = Modifier,
  musicId: MusicId,
  onBack: () -> Unit,
) {
  val coroutineScope = rememberCoroutineScope()
  val context = LocalContext.current

  val store = rememberPlayerStore(
    musicId = musicId,
    context = makePlayerCapability(context, coroutineScope)
  )
  val state by store.state.collectAsState()
  BackHandler {
    store.send(PlayerEvent.PlaybackStopped)
    onBack()
  }

  LaunchedEffect(Unit) {
    store.send(PlayerEvent.StartLoad(musicId))
  }

  LaunchedEffect(store) {
    store.effects.collect { effect ->
      when (effect) {
        is PlayerEffect.ShowError -> { showToast(context, effect.message) }
      }
    }
  }

  PlayerContent(
    modifier = modifier,
    state = state,
    onSeekBackward = { store.send(PlayerEvent.PaybackSought(-15_000)) },
    onSeekForward = { store.send(PlayerEvent.PaybackSought(15_000)) },
    onPlayPause = { store.send(PlayerEvent.PlaybackStateChanged) },
    onRewind = { store.send(PlayerEvent.PaybackRewound((it * 1000).toLong())) },
    onToggleFavorite = { store.send(PlayerEvent.FavoriteClicked(musicId)) },
  )
}

Экран PlayerScreen — прослойка между UI и бизнес-логикой. Он инициализирует хранилище состояния ElmStore через rememberPlayerStore. А это значит, что состояние сохранится при пересоздании Composable-функции, но сбросится при изменении musicId. Объединяя три ключевых компонента, хранилище образует замкнутый цикл обработки событий.

LaunchedEffect в PlayerScreen выполняет две важные задачи. Во-первых, инициирует загрузку данных через событие StartLoad при первом запуске экрана. Так он демонстрирует принцип активной инициализации состояния.

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

Слой PlayerContent получает текущее состояние и колбэки для отправки событий, делая этот экран «глупым» презентационным компонентом. Так разделяются бизнес-логика и отображение. Контент ничего не знает об обработке событий, а лишь уведомляет систему о действиях пользователя. Преобразование состояния и бизнес-правила инкапсулированы в редьюсере и акторе.

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

Я записал GIF-анимацию с демонстрацией работы плеера. Чтобы убедиться, что всё работает со звуком, клонируйте репозиторий и запустите приложение локально.

Анимация работы проигрывателя
Анимация работы проигрывателя

На практике такая архитектура требует дисциплины — UI должен отправлять события, не меняя состояние напрямую. Бизнес-логика концентрируется в редьюсере, а сайд-эффекты выносятся в актор. Такая структура может быть эффективна в сложных приложениях со множеством асинхронных операций, где классические подходы часто приводят к «распылению» логики.

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

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

Что дальше

На этом цикл статей о функциональной архитектуре в Android подошёл к концу. Последняя часть получилась большой, так что тема Unit и UI-тестов сюда не влезла. Если она вам интересна, напишите в комментах — расскажу о ней в отдельной статье.
Да и benchmark-замеры производительности ELM-архитектуры не помешали бы. Надеюсь, материал оказался полезным или, как минимум, позволил расширить кругозор. 

Код из чистых функций более предсказуемый, ведь результат зависит от входных данных, а не от скрытого состояния. Избегая мутаций и работая с данными как с неизменным состоянием, вы сможете лучше отслеживать логику и предотвращать случайные ошибки. При этом не обязательно ограничиваться одной парадигмой: можно сочетать декларативность функционального подхода с удобством ООП, где это уместно. Главное — чтобы архитектура оставалась гибкой, а компоненты слабо связанными. Попробуйте иногда применять эти принципы. Со временем заметите, насколько проще стало вносить изменения и поддерживать код.

Спасибо, что дочитали статью! Ставьте плюсики, если материал показался вам интересным, и делитесь им с друзьями. А чтобы быть в курсе последних новостей Dodo Engineering, подписывайтесь на наш Telegram-канал.

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