О чём статья?

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

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

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

Когда я знакомился с функциональщиной, я поделил работающих с ней людей на 3 группы:

  1. Адепты считают функциональное программирование чем-то чудотворным и магическим.

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

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

Я не отношу себя ни к одной из этих групп. Меня интересует, как функциональное программирование работает на практике. Разбираться с ним мы начнём на нескольких простых примерах, проводя аналогии с объектно-ориентированным подходом (ООП). Уверен: большинство из вас с ним знакомо.

Функциональное программирование (ФП) основывается на нескольких ключевых принципах, которые помогают разработчикам создавать более чистый и предсказуемый код. Рассмотрим поподробнее основные из них.

Принцип чистых функций

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

class Counter {
    private var count = 0

    fun increment() {
        count++
    }

    fun getCount(): Int {
        return count
    }
}

fun main() {
    val counter = Counter()
    counter.increment()
    println(counter.getCount()) // Вывод: 1
    counter.increment()
    println(counter.getCount()) // Вывод: 2
}

Сейчас вы видите ООП реализацию, где метод getCount возвращает значение, которое зависит от состояния объекта Counter. Если вы вызовете increment, результат изменится, а поведение метода станет непредсказуемым. Исправим это поведение:

fun nextCount(count: Int): Int {
    return count + 1
}

fun main() {
    val initialCount = 0
    val firstIncrease = nextCount(initialCount)
    println(firstIncrease) // Вывод: 1
    val secondIncrease = nextCount(firstIncrease)
    println(secondIncrease) // Вывод: 2
}

Оставить старый нейминг функции increment в новом примере нельзя. Так мы введём всех привыкших к ООП людей в заблуждение: они будут ждать поведение мутирования стейта из предыдущего примера. Мы заменили имя функции на nextCount. Она принимает текущее значение счётчика и возвращает новое значение; не изменяет состояние; возвращает одно и то же значение для одного и того же входного параметра. Она предсказуемая и простая в тестировании.

Принцип неизменяемости данных

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

class MutablePerson(var name: String, var age: Int)

fun main() {
    val person = MutablePerson("Alice", 30)
    println(person) // MutablePerson(name=Alice, age=30)

    changeAge(person, 31)
    println(person) // MutablePerson(name=Alice, age=31)

    // Допустим, мы передаём person в другую функцию
    anotherFunction(person)
    println(person) // MutablePerson(name=Bob, age=31)

    // Изменение состояния в другой функции
}

fun changeAge(person: MutablePerson, newAge: Int) {
    person.age = newAge
}

fun anotherFunction(person: MutablePerson) {
    person.name = "Bob" // Изменяем имя на "Bob"
}

Создадим экземпляр person в функции main и изменим его возраст с помощью функции changeAge — всё супер. Проблемы начинаются, когда мы пробуем передать объект в другую функцию.

Изменим имя объекта person в функции anotherFunction. Это может привести к изменению состояния самого объекта.

Например, после вызова anotherFunction имя person изменится на Ivan. Если другие части программы полагаются на то, что имя останется Alina, возникнет путаница.

Давайте устранять проблемы. Применим для этого принцип неизменяемости:

data class Person(val name: String, val age: Int)

fun main() {
    val person1 = Person("Alice", 30)
    println(person1) // Person(name=Alice, age=30)

    // Попытка изменить объект приведёт к созданию нового
    val person2 = person1.copy(age = 31)
    println(person2) // Person(name=Alice, age=31)

    // person1 остаётся неизменным
    println(person1) // Person(name=Alice, age=30)
}

С помощью метода copy создадим новый объект с изменёнными значениями person2, не изменяя оригинальный объект.person1.name остаётся тот же, а age меняется. То есть принцип неизменяемости данных позволяет работать с объектами и не беспокоиться о том, что их состояние может измениться при выполнении программы.

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

Функции высшего порядка

Функции могут принимать другие функции в качестве аргументов и возвращать их в качестве результата:

fun applyFunction(func: (Int) -> Int, value: Int): Int {
    return func(value)
}

val doubleValue = applyFunction({ it * 2 }, 5)  // Вернет 10
val tripleValue = applyFunction({ it * 3 }, 5)  // Вернет 15

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

interface Function {
    fun apply(value: Int): Int
}

class DoubleFunction : Function {
    override fun apply(value: Int): Int {
        return value * 2
    }
}

Чтобы добавить новую операцию, создадим новый класс и интерфейс. Так мы тоже сможем переиспользовать код, но он будет не такой лаконичный и гибкий.

Лямбда-выражения

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

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5, -1, -2)
    
    // Использование лямбда-выражения для фильтрации положительных чисел
    val positiveNumbers = numbers.filter { it > 0 }
    
    println(positiveNumbers) // Вывод: [1, 2, 3, 4, 5]
}

Лямбда-выражения поддерживаются во многих языках программирования, в том числе и в Java. Этот язык стал поддерживать их всего 10 лет назад.

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

class NumberFilter {
    fun filterPositiveNumbers(numbers: List<Int>): List<Int> {
        val positiveNumbers = mutableListOf<Int>()
        for (number in numbers) {
            if (isPositive(number)) {
                positiveNumbers.add(number)
            }
        }
        return positiveNumbers
    }

    private fun isPositive(number: Int): Boolean {
        return number > 0
    }
}

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5, -1, -2)
    val numberFilter = NumberFilter()
    val positiveNumbers = numberFilter.filterPositiveNumbers(numbers)
    
    println(positiveNumbers) // Вывод: [1, 2, 3, 4, 5]
}

Код получился длиннее, правда?

Композиция и конвейерность функций

Простые функции можно объединять в более сложные и проводить комплексные операции:

fun addOne(x: Int): Int = x + 1
fun square(x: Int): Int = x * x

val composedFunction: (Int) -> Int = { square(addOne(it)) }

fun main() {
println(composedFunction(2)) // Вывод 9
}

Возможно, на первый взгляд, этот код может показаться чем-то инородным. Попробуем переписать на ООП, используя паттерн «Цепочка ответственности»:

class AddOne {
    fun handle(x: Int): Int = x + 1
}

class Square {
    fun handle(x: Int): Int = x * x
}

val addOne = AddOne()
val square = Square()
val result = square.handle(addOne.handle(2)) // Вернет 9

Такой код гораздо сложнее читать. Да и в тестировании первый пример проще:

class Test : FunSpec({
    test("Composed test") {
        composedFunction(2) shouldBe 9
        composedFunction(3) shouldBe 16
    }
})

Чтобы написать тест «Цепочки ответственности», придётся приложить больше усилий:

class Test : FunSpec({
    test("Chain of responsibility test") {
        // Тестируем, что результат сложения 1 к 2 равен 3
        val addOne = AddOne()
        val square = Square()
        val addOneResult = addOne.handle(2)
        addOneResult shouldBe 3

        // Тестируем, что квадрат 3 равен 9
        val squareResult = square.handle(addOneResult)
        squareResult shouldBe 9

        // Тестируем полную цепочку
        val result = square.handle(addOne.handle(2))
        result shouldBe 9
    }
})

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

Использование рекурсий вместо циклов

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

Альтернатива рекурсии — циклы. Попробуем решить задачу в ООП стиле — найти сумму всех элементов массива.

Если заглянуть в реализацию метода sum() интерфейса Iterable в Kotlin, то вы увидите нечто похожее на такой код:

fun iterativeSum(array: IntArray): Int {
    var sum = 0
    for (number in array) {
        sum += number
    }
    return sum
}

fun main() {
    val numbers = intArrayOf(1, 2, 3, 4, 5)
    val result = iterativeSum(numbers) // Вернет: 15
}

В ФП такую же задачу принято решать через рекурсию:

fun recursiveSum(array: IntArray, index: Int = 0): Int {
    // Базовый случай: если индекс равен длине массива, возвращаем 0
    if (index == array.size) {
        return 0
    }
    // Рекурсивный вызов: текущий элемент + сумма оставшихся элементов
    return array[index] + recursiveSum(array, index + 1)
}

fun main() {
    val numbers = intArrayOf(1, 2, 3, 4, 5)
    val result = recursiveSum(numbers) // Вернёт: 15
}

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

  • 0, если список пуст (базовый случай);

  • первый элемент + сумма остальных элементов, если список не пуст (рекурсивный случай).

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

У вас может возникнуть резонный вопрос: не просто же так разработчики Kotlin в реализации метода sum положили реализацию с циклами? Дело в том, что существует ограничение стека вызова. Рекурсивное решение в таком случае может быть менее эффективным для больших массивов и небезопасным в плане использования памяти.

В рамках этой статьи я не буду глубоко погружаться в рекурсию. Если вы хотите расширить свои знания по этой теме, рекомендую ознакомиться с понятиями: базовый и рекурсивный случай, хвостовая рекурсия, tailrec в Kotlin и рекурсивных алгоритмах (например: Ханойские башни или Рекурсия в графах).

В общем, с рекурсиями нужно быть очень осторожным, особенно в Java и Kotlin. Но в языках функционального программирования их всё равно выбирают чаще, чем циклы, и вот почему:

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

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

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

  4. Чистота и выразительность кода. Рекурсия хорошо вписывается в рассмотренные выше концепции неизменяемости данных и функции высшего порядка — она позволяет избегать изменения состояния и побочных эффектов. Это делает код более предсказуемым и лёгким для тестирования.

Императивный и декларативный стили программирования

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

data class Employee(val name: String, val age: Int, val salary: Double)

fun main() {
    val employees = listOf(
        Employee("Alice", 30, 50000.0),
        Employee("Bob", 35, 60000.0),
        Employee("Charlie", 28, 55000.0),
        Employee("David", 40, 70000.0),
        Employee("Eve", 25, 45000.0)
    )

    var totalSalary = 0.0
    for (employee in employees) {
        if (employee.age > 30 && employee.salary > 50000) {
            totalSalary += employee.salary
        }
    }
    println("Общая зарплата сотрудников старше 30 лет с зарплатой больше 50000: $totalSalary")
}

В этом примере мы создаём список сотрудников и с помощью цикла for проверяем возраст и зарплату каждого, добавляя подходящие значения к totalSalary.

data class Employee(val name: String, val age: Int, val salary: Double)

fun main() {
    val employees = listOf(
        Employee("Alice", 30, 50000.0),
        Employee("Bob", 35, 60000.0),
        Employee("Charlie", 28, 55000.0),
        Employee("David", 40, 70000.0),
        Employee("Eve", 25, 45000.0)
    )

    val totalSalary = employees
        .filter { it.age > 30 && it.salary > 50000 }
        .sumOf { it.salary }

    println("Общая зарплата сотрудников старше 30 лет с зарплатой больше 50000: $totalSalary")
}

Для сравнения: в декларативном подходе мы бы просто описали, что хотим получить, не указывая, как хотим это сделать. Теперь мы используем метод filter для фильтрации сотрудников по возрасту и зарплате, а затем применяем метод sumOf, чтобы получить сумму зарплат отфильтрованных сотрудников. Код более лаконичен и выразителен, так как мы акцентируем внимание на том, что хотим сделать, а не на том, как это сделать.

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

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

Поддержка функционального стиля в языках программирования

По уровню поддержки функционального стиля языки программирования делятся на несколько групп:

  • без поддержки или с ограниченной поддержкой функционального программирования;

  • чисто функциональные языки;

  • кроссфункциональные языки.

Примеры языков без поддержки ФП или с её ограниченной поддержкой:

  • Java. Хотя в последних версиях Java добавлены некоторые функциональные возможности (например, лямбда-выражения и Stream API), язык изначально был ориентирован на объектно-ориентированное программирование, и многие функциональные концепции не поддерживаются на уровне языка;

  • язык C — императивный. Он не поддерживает функциональные концепции, такие как неизменяемость данных или функции высшего порядка. Поэтому в функциональном стиле его использовать трудно.

Чисто функциональные языки:

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

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

Кроссфункциональные языки:

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

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

Подробнее о том, почему Kotlin — мультиплатформенный язык, можно узнать в книге Kotlin in Action.

Объектно-ориентированное и функциональное программирование в Android

У объектно-ориентированного подхода в Android-разработке есть несколько сильных сторон:

  • структурированность кода. Объектно-ориентированное программирование позволяет собрать код в виде объектов, сделав его более структурированным и понятным. Каждый объект содержит данные и методы, что упрощает понимание и поддержку кода. В проектах с большим количеством взаимодействий компонентов друг с другом — это особенно важно;

  • поддержка Android SDK, основанном на объектно-ориентированном программировании, как и большинство библиотек, используемых в разработке приложений;

  • удобство работы с пользовательским интерфейсом. Все его элементы в Android — от кнопок до текстовых полей — представлены как объекты. Так разработчики могут легко управлять состоянием и поведением интерфейса, используя принципы объектно-ориентированного программирования;

  • повторное использование кода в ООП стало возможно благодаря наследованию и полиморфизму. Оно снижает дублирование и упрощает внесение изменений, ведь в базовом классе они автоматически применяются ко всем его производным;

  • историческая составляющая. Android-разработчики давно использовали Java как основной язык для разработки приложений, а он поддерживает функциональный стиль, пусть и ограниченно.

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

  • параллелизм. Функциональное программирование хорошо подходит для параллельной обработки — чистые функции и иммутабельные данные упрощают распределение задач между потоками;

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

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

Предыстория с примером

Я познакомился с функциональным программированием три с небольшим года назад. Тогда Jetpack Compose находился только в Canary среде и вряд ли применялся в производственной.

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

Я тогда присоединился к разработке новостного приложения со встроенным блочным редактором. Стек был стандартный: MVVM, Coroutines+Flow, Koin, Single Activity и Fragment, XML-вёрстка. Мир Android-разработки не предлагал тогда чего-то кардинально отличающегося, если не брать в учёт MVP и Rx.

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

В таких условиях мы... Решили переписать всё с нуля! А ещё запустили редизайн приложения, отказались от привычного бэкенда, стали использовать NoSQL базу данных на Firebase, а потом и всю серверную логику на Firebase-сервисы перевезли.

Бэкенд мы писали сами. Точкой невозврата тогда послужила договорённость писать все UI-компоненты для Android на Jetpack Compose, а для iOS — на SwiftUI.

SwiftUI тогда развивался быстрее, а Compose — только увидел свет за пределами «канарейки», но всё ещё был нестабильным.

Вишенка на торте — применение подхода Dependency Injection Free. Как я потом узнал от моего iOS-напарника, это один из приёмов функционального программирования.

Swift — более продвинутый в функциональном плане язык, чем Kotlin. Так что у моего коллеги, iOS-разработчика, знакомого с TypeScript, отлично получалось писать архитектуру приложения исключительно в функциональном стиле.

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

Было тяжело, больно и местами страшно, но очень интересно. За 4 месяца работы у нас появилось полностью отредизайненное приложение с прежним функционалом, но без единой строчки кода из предыдущей версии. А ещё самописный бэкенд, ролевая модель и база данных. И всё это с опережением по новым фичам на несколько спринтов. Ощущал я себя Христофором Колумбом в мире Android-разработки. Но долго это не продлилось.

Тогдашний наш проект был стартапом. Он, к сожалению, закрылся спустя год, не найдя достаточно инвестиций и правильного вектора развития. Техническая состоятельность архитектуры продукта, в который мы вложили душу, так и не подтвердилась. Сказка закончилась... Но не будем о грустном! История нашего стартапа — не главная тема статьи.

Покажу немного, как это выглядело функционально. Примеров на iOS у меня под рукой нет, а Android-часть будет переписана — не привык я NDA нарушать.

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

Ниже будет немного кода, связанного с Firebase SDK. Поэтому, если вы не сталкивались с этим фреймворком, но хотите разобраться подробнее, посмотрите документацию FirebaseCloudFirestore, FirebaseStorage, FirebaseAuth.

Представим типовую задачу получения и создания какой-либо сущности — еды в приложении доставки, например:

class FoodMenuClient(
    val fetchFoods: (
        categoryId: String,
        callback: (Loadable<List<Food>, Error>) -> Unit
    ) -> Unit,
    val createFood: (
        food: FoodDTO,
        categoryId: String,
        callback: (Loadable<Food, Error>) -> Unit
    ) -> Unit,
) {
  companion object {
	@JvmStatic
	fun live() = FoodMenuClient(
	  fetchFoods = {},
	  createFood = {},
)
  }
}

Клиент содержит два основных метода. Они позволяют взаимодействовать с данными о еде. Разберём его по частям:

  • функция для получения списка еды по заданной категории fetchFoods принимает два аргумента:

    • categoryld — строку, представляющую идентификатор категории, для которой нужно получить блюда;

    • callback — функцию обратного вызова, которая принимает результат в виде объекта Loadable. Он может содержать либо список блюд List<Food>, либо ошибку (Error). Ниже я приведу подробный пример того, что из себя представляет класс Loadable.

  • createFood — функция для создания нового блюда в определённой категории. За небольшим исключением она работает аналогично.

    • food — объект типа FoodDTO, содержащий информацию о новом блюде, которое нужно создать. Это DTO-модель, специфичная для хранения в Firebase.

Внутри объекта определён объект-компаньон, содержащий статистический метод live(). Он создаёт экземпляр FoodMenuClient с реализациями для функций fetchFoods и createFood.

sealed class Loadable<out Output, out Failure> {

    object Idle : Loadable<Nothing, Nothing>()

    object Loading : Loadable<Nothing, Nothing>()

    data class Success<Output>(val data: Output?) : Loadable<Output, Nothing>()

    data class Error<Failure>(val error: Failure) : Loadable<Nothing, Failure>()

    val outputData: Output?
      get() = (this as? Success)?.data

    val failure: Failure?
      get() = (this as? Error)?.error

    val isLoading: Boolean
      get() = this is Loading

    val isLoaded: Boolean
      get() = this is Success || this is Error

}

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

  1. Idle — начальное состояние, когда загрузка не происходит.

  2. Loading — состояние загрузки данных. Оно указывает на то, что процесс получения данных активен.

  3. Success — состояние, содержащее данные, полученные в результате успешной загрузки. Оно принимает параметр data, даже в значении null.

  4. Error — состояние, содержащее информацию об ошибке, произошедшей во время загрузки. Принимает параметр error — объект ошибки.

Кроме того, мы работаем с четырьмя свойствами сущности Loadable:

  1. outputData возвращает данные, если текущее состояние — Success. Если состояние другое, оно возвращает null.

  2. failure возвращает ошибку, если текущее состояние — Error. Если состояние другое, возвращает null.

  3. isLoading возвращает true, если текущее состояние — Loading, а в противном случае — false.

  4. isLoaded возвращает true, если текущее состояние — Success или Error, и false, если состояние — Idle или Loading.

Как это работает? Класс Loadable позволяет легко управлять состоянием загрузки данных в приложении. Например, его можно использовать при получении данных из сети для отслеживания состояния.

  • когда загрузка начинается, состояние устанавливается в Loading;

  • если данные успешно загружены, состояние поменяется на Success, а данные будут доступны через outputData;

  • в случае возникновения ошибки состояние поменяется на Error, а информация о ней будет доступна через failure.

В контексте Client мы будем только складывать значения в процессе его работы. За получение и обработку этого результата будет отвечать ViewModel. Перед тем, как продолжить описывать работу функций fetchFoods и createFood, необходимо создать набор уникальных ошибок. Например:

sealed class Error : UnifiedError {
    object UnauthorizedError : Error()
    class FetchError(val reason: String) : Error()
    class CreateFoodError(val reason: String) : Error()

    override val localizedTitle: String = "Ошибка"
    override val failureReason: String
      get() = when (this) {
        is UnauthorizedError -> "Вы не авторизированы"
        is FetchError -> reason
        is CreateFoodError -> reason
      }
}

Реализация функции fetchFoods следующим образом:

fetchFoods = { categoryId, callback ->
    callback(Loadable.Loading)
    val userId = Firebase.auth.currentUser?.uid
    if (userId == null) {
      callback(Loadable.Error(Error.UnauthorizedError))
      return@FoodMenuClient
    }
    Firebase.firestore.collection(Community.COLLECTION)
        .document(categoryId)
        .collection(Question.COLLECTION)
        .orderBy(CREATED_AT, Query.Direction.DESCENDING)
        .get()
        .addOnSuccessListener {
            callback(Loadable.Success(it.toObjects()))
        }
        .addOnFailureListener {
            callback(
                Loadable.Error(
                    Error.FetchError(
                        reason = it.localizedMessage ?: "Fetch questions error"
                    )
                )
            )
        }
  },

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

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

Метод createFood ничем не отличается, кроме запроса к Firebase и другого типа ошибки — CreateFoodError. Так что я не буду приводить примеры его реализации, чтобы не нагромождать код. О том, как этот процесс обрабатывается на уровне домена и презентационного слоя, я расскажу ниже.

Перейдём к самой интересной части — свяжем доменный слой с клиентом:

class FoodMenuViewModel(
  private val client: FoodMenuClient = FoodMenuClient.live(),
  private val dispatchers: DispatcherProvider = DefaultDispatcherProvider()
) : ViewModel() {
  val foods get() = _foods.asStateFlow()
  private val _foods = MutableStateFlow<List<Food>?>(null)

  val createFoodState get() = _createFoodState.asSharedFlow()
  private val _createFoodState = MutableSharedFlow<Loadable<Any, Any>>()

  val fetchFoodState get() = _fetchFoodState.asSharedFlow()
  private val _fetchFoodState = MutableSharedFlow<Loadable<Any, Any>>()


  fun fetchFoods(categoryId: String?) {
    categoryId?.let { id ->
	client.fetchFoods(id) { loadable ->
	  viewModelScope.launch(dispatchers.io()) {
	    _fetchFoodState.emit(loadable)
	    loadable.outputData?.let { _foods.emit(it) }
	    loadable.failure?.let {
            //Обработка ошибки логгирование/алёрт/тост
          }
  }
}
    }
  }
  // Аналогичная реализация обращения к createFood методу //
}

Как я говорил выше, клиент — легковесная сущность. Нам нужно только вызвать метод live() в конструкторе ViewModel, а после — обратиться к методам fetch и create, для получения результата их работы в callback замыкании. DI нам и не нужен, поскольку для создания клиента не нужны зависимости — все необходимые данные передаются в методах-запросах и при обращении к ним.

Взаимодействие с UI-слоем осуществляется по принципу подписки на State и SharedFlow. Список продуктов и состояние загрузки данных извлекаются при помощи свойства outputData. Свойство failure необходимо использовать для обработки ошибок и дальнейшей передачи их логгеру или показа диалога.

Для полноты картины покажу, как это выглядит на презентационном слое:

@Composable
fun FoodMenuScreen() {
    val state = rememberFoodMenuScreenState(
        viewModel = viewModel<FoodMenuViewModel>()
    )
    
    val foods by state.foodsState.collectAsState()
    LaunchedEffect(Unit) {
        if (foods == null) state::onFetchFoods
    }

    LazyColumn(
        state = state.foodMenuListState
    ) {
        state.foods.value?.let {
            items(it) { item ->
                Card {
                    Image(
                        painter = painterResource(item.imageRes),
                        contentDescription = null
                    )
                }
            }
        }
        //Контент с заглушкой если список пустой
    }

}

@Composable
fun rememberFoodMenuScreenState(
    scope: CoroutineScope = rememberCoroutineScope(),
    foodMenuViewModel: FoodMenuViewModel,
    foodMenuListState: LazyListState = rememberLazyListState(),
) = remember(viewModel) {
    FoodMenuScreenState(
        foodMenuViewModel = viewModel,
        scope = scope,
        foodMenuListState = foodMenuListState,
    )
}
class FoodMenuScreenState(
    val foodMenuListState: LazyListState,
    private val foodMenuViewModel: FoodMenuViewModel,
    private val scope: CoroutineScope,
) {
    companion object {
      private const val SOME_CATEGORY_ID = UUID.randomUUID().toString()
    }
    val foods = foodMenuViewModel.foods
    var fetchFoodsState: Loadable<Any, Any> by mutableStateOf(Loadable.Idle)
        private set
    init {
        scope.launch {
            foodMenuViewModel.fetchFoodState.collect {
                fetchFoodsState = it
                if (it is Loadable.Success) {
			foodMenuListState.animateScrollToItem(0)
   }
            }
        }
    }

   fun onFetchFoods() {
	foodMenuViewModel.fetchFoods(SOME_CATEGORY_ID)
   }

}

Нам нужно только создать State прослойку между ViewModel и UI-слоем. Она будет поставщиком данных на экране и точкой входа для триггера запроса к клиенту.

Теперь напишем Unit-тест для ViewModel и убедимся, что он действительно легковесный. Для начала доработаем Client:

class FoodMenuClient(...) {
  companion object {
    @JvmStatic
    fun live() = {...}
    @JvmStatic
    fun debugSuccess() = FoodMenuClient(
      fetchFoods = { _, callback ->
	    callback(Loadable.Success(emptyList()))
	  }
	  createFood = { _, _, callback ->
        callback(Loadable.Success(Food()))
      }
    )
    @JvmStatic
    fun debugFailure() = FoodMenuClient(
	  fetchFoods = { _, callback ->
	    callback(Loadable.Error(Error.FetchError("Fetch error")))
	  }
	  createFood = { _, _, callback ->
        callback(Loadable.Error(Error.CreateFoodError("Create food error")))
      }
    )
  }
}

Для Unit-тестирования Client нам нужно добавить два метода: debugSuccess и debugFailure. По аналогии с методом live они создадут mock-объект FoodMenuClient. Последний на вызов метода всегда отвечает либо Succes, либо Error. В этом примере я использую библиотеки для тестирования JUnit и Turbine, для работы с Coroutines. С JUnit многие знакомы, а с Turbine — нет, так что прикладываю ссылку на репозиторий.

Unit-тест может выглядеть так:

@SmallTest
@OptIn(ExperimentalCoroutinesApi::class)
class FoodMenuViewModelUnitTest {

    @get:Rule
    val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()

    private lateinit var successViewModel: FoodMenuViewModel
    private lateinit var failureViewModel: FoodMenuViewModel

    @Before
    fun setup() {
        successViewModel = FoodMenuViewModel(
            client = FoodMenuClient.debugSuccess(),
            dispatchers = coroutineTestRule.testDispatcherProvider
        )
        failureViewModel = FoodMenuViewModel(
            client = FoodMenuClient.debugFailure(),
            dispatchers = coroutineTestRule.testDispatcherProvider
        )
    }

    @Test
    fun `Success fetch foods`() = runTest {
        val mockCategoryId = UUID.randomUUID().toString()
        successViewModel.fetchFoodsState.test {
            successViewModel.fetchFoods(mockCategoryId)
            Assert.assertNotNull(awaitItem().outputData)
            Assert.assertEquals(successViewModel.foods.value, emptyList<Food>())
            cancelAndConsumeRemainingEvents()
        }
    }
    @Test
    fun `Failure fetch questions`() = runTest {
        val mockCategoryId = UUID.randomUUID().toString()
        failureViewModel.fetchFoodsState.test {
            failureViewModel.fetchFoods(mockCategoryId)
            Assert.assertNotNull(awaitItem().failure)
            Assert.assertNull(failureViewModel.foods.value)
            cancelAndConsumeRemainingEvents()
        }
    }
}
  • создаём фейковый экземпляр ViewModel;

  • подставляем в него нужную имплементацию метода клиента debugSuccess или debugFailure;

  • выполняем запрос получения или создания необходимой сущности;

  • сопоставляем ожидаемый результат с фактическим из State или Shared Flow.

Примеры архитектуры выше — примитивная реализация лишь некоторых элементов парадигмы функционального программирования.

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

Стек для построения архитектуры приложения

За последние три года отношение к используемым мной инструментам изменилось. Никто не крутит у виска, когда слышит, что я использую Jetpack Compose. Библиотеке Arrow уделяют отдельное внимание на презентациях  Kotlin Conf. А MVI-архитектуру многие разработчики активно затаскивают в свои приложения. Так что для своего Pet-проекта я выбрал следующий стек:

  • Kotln;

  • Jetpack Compose;

  • одна из UDF-архитектур (Redux, TEA, MVI);

  • Arrow;

  • Coroutines + Flow.

Дизайн-макет будущего приложения

Я определился с концепцией и функционалом моего приложения для исследования — оно будет посвящено медитации. Дизайн-макет я взял из открытой библиотеки шаблонов Figma. UI пет-проекта будет выглядеть так:

Что дальше

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

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

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


  1. aamonster
    17.06.2025 16:51

    Про рекурсию я уточнил бы: где возможно – используют не её, а фвп типа fold, zip и так далее.


    1. realist-pessimist Автор
      17.06.2025 16:51

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


  1. Lewigh
    17.06.2025 16:51

    Для сравнения: в декларативном подходе мы бы просто описали, что хотим получить, не указывая, как хотим это сделать. Теперь мы используем метод filter для фильтрации сотрудников по возрасту и зарплате, а затем применяем метод sumOf, чтобы получить сумму зарплат отфильтрованных сотрудников.

    Это распространенное заблуждение которое кочует из статьи в статью про ФП.

        val totalSalary = employees
            .filter { it.age > 30 && it.salary > 50000 }
            .sumOf { it.salary }

    Это не декларативный стиль, это такой же императивный стиль. Вы буквально описали что делать: отфильтровать и затем просуммировать. Просто это спрятано внутри.

    Декларативный пример это SQL ил html. Где вообще нет прямой связи между описываемым результатом и тем как это будет достигнуто.


    1. realist-pessimist Автор
      17.06.2025 16:51

      Спасибо за ваше замечание! Да действительно, в какой-то мере я с вами согласен. Если открыть большинство реализаций high-level функций в Kotlin там под капотом императивный стиль внутри во всей красе. Однако такой вариант через цепочки вызовов функций чуточку ближе к декларативному стилю, чем лобовое решение через циклы. В следующих частях цикла статей будет затронуто больше тем ФП. Поглубже раскрыта суть чистых функций, теория категорий и библиотека Arrow. Возможно в них вы сможете найти для себя больше интересного. Скоро этот материал будет опубликован. Буду рад вашим комментариям!


  1. Not_coolEd
    17.06.2025 16:51

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

    1) Dependency Injection Free
    Философия этого подхода говорит об осознанном отказе от DI библиотек в пользу ручного управления зависимостями. То есть она не отменяет DI и не отказывается от него.
    Суть DI заключается в разрыве прямой связи между реализациями сущностей, что достигается прокидыванием зависимостей в объект извне самого объекта.
    Твой пример:

    class FoodMenuViewModel(
      private val client: FoodMenuClient = FoodMenuClient.live(),
      private val dispatchers: DispatcherProvider = DefaultDispatcherProvider()
    ) : ViewModel() {

    val client: FoodMenuClient = FoodMenuClient.live()
    client не прокидывается извне, а создается именно внутри самого объекта.
    Да, его можно заменить для тестирования. И для проектов с конечным жизненным циклом (зарелизил и забыл), вполне себе отличный вариант.

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

    2) Функции в конструктор класса

    class FoodMenuClient(
        val fetchFoods: (
            categoryId: String,
            callback: (Loadable<List<Food>, Error>) -> Unit
        ) -> Unit,
        val createFood: (
            food: FoodDTO,
            categoryId: String,
            callback: (Loadable<Food, Error>) -> Unit
        ) -> Unit,
    ) {
      companion object {
    	@JvmStatic
    	fun live() = FoodMenuClient(
    	  fetchFoods = {},
    	  createFood = {},
        )
      }
    }

    В данном случае class FoodMenuClient заменяет собой интерфейс, просто перечисляя список доступных функций внутри конструктора, а его реализацией является этот код FoodMenuClient(fetchFoods = {}, createFood = {})

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

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

    Для наглядности вот сразу тот же пример через интерфейс:
    (Лучше не использовать анонимный объект, а дать реализации свое имя, чтобы его было видно в логах)

    interface FoodMenuClient {
      
        fun fetchFoods(
            categoryId: String,
            callback: (Loadable<List<Food>, Error>) -> Unit
        )
    
        fun createFood(
            food: FoodDTO,
            categoryId: String,
            callback: (Loadable<Food, Error>) -> Unit
        )
    ) {
        companion object {
        	@JvmStatic
        	fun live() = object : FoodMenuClient {
                override fun fetchFoods(
                    categoryId: String,
                    callback: (Loadable<List<Food>, Error>) -> Unit
                ) {}
    
                override fun createFood(
                    food: FoodDTO,
                    categoryId: String,
                    callback: (Loadable<Food, Error>) -> Unit
                ) {}
            }
        }
    }

    3) Callback
    Вероятно ты использовал его чисто для статьи, но тем, кто только учится, лучше сразу подсветить, что callback: (Loadable<Food, Error>) -> Unit стоит заменить на возвращаемый Flow<Loadable<Food, Error>>

    4) MVI, UDF и потоки данных

      val foods get() = _foods.asStateFlow()
      private val _foods = MutableStateFlow<List<Food>?>(null)

    Хочу накинуть еще в этом месте, что использование MutableStateFlow, создает разрыв потока данных. И является местом хранения состояния, которое управляется в этом же классе, а не чисто на основе данных.
    Что может приводить к появлению гонок и в следствии неконсистентному стейту.

    Лучше использовать .stateIn(...) и выстраивать не прерываемые потоки.
    Как пример, если бы client.fetchFoods возвращал Flow:

    class FoodMenuViewModel(
      val foodMenuListState: LazyListState,
      private val client: FoodMenuClient = FoodMenuClient.live(),
      private val dispatchers: DispatcherProvider = DefaultDispatcherProvider()
    ) : ViewModel() {
    
      private val selectedCategoryId = MutableSharedFlow<String>(replay = 1)
    
      // на Ui when по Loadable, чтобы отобразить соответствующее значение
      val fetchFoodState = selectedCategoryId
        .flatMapLatest { id -> client.fetchFoods(id) }
        .onEach {
          if (it is Loadable.Success) foodMenuListState.animateScrollToItem(0)
          it.failure?.let { /* Обработка ошибки логгирование */ }
        }
        .stateIn(viewModelScope + dispatchers.io(), SharingStarted.Eagerly, Loadable.Idle)
    
      fun onSelectedCategory(categoryId: String) {
        selectedCategoryId.tryEmit(categoryId)
      }
    }

    На UI по хорошему отдавать вообще только один готовый стейт, который собирается подобным образом, с использованием combine, transform, map, flatMap, fold и т.д.

    Надеюсь коммент будет полезен)


    1. Spaceguest
      17.06.2025 16:51

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

      Разрыв связи, о котором вы пытаетесь сказать это Dependency Inversion. Суть его в том, чтобы изменить направление зависимости: Получатель зависимости зависит от ее абстракции, а не реализации.

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

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

      Большие кодовые базы UI (~1,5 млн строк) прекрасно живут с этим подходом. Попробуйте! Уверен, вам понравится простота, от которой поначалу будет казаться, что где-то должен быть подвох.


      1. Not_coolEd
        17.06.2025 16:51

        Да Dependency Inversion разворачивает зависимость.

        Как пример View и Presenter. Когда Presenter зависит от View, это зависимость от центра, что является плохим решением. В таком случае применяют инверсию, создавая интерфейс для View. И этот интерфейс, по своей сути, остается частью Presenter (связан с ним), что дает нам правильно направление от View к центру.
        View -> (interface View <- Presenter)

        Я же имел ввиду именно Dependency Injection.

        Когда класс А создает класс B внутри себя, он напрямую зависит от класса B.
        Если класс А будет получать класс B в конструктор, то он все еще зависит от класса В, что уже является иньекцией, но не дает разрыва реализаций. Создание интерфейса для класса B как раз и создает разрыв, чтобы зависимость была на абстракцию. Направление зависимостей в этом смысле вообще не важно.

        client: FoodMenuClient = FoodMenuClient.live()

        И вот ключевой момент в этом подходе будет в том, что класс А, знает о месте где можно создать класс B.
        Да, само создание вынесено, в функцию live, но этот live должен знать про реализацию, что является неявной транзитивной зависимостью класса A на реализацию класса B.
        Чтобы полностью избавиться от такой транзитивной зависимости, создание зависимости должно быть вне класса A.

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

        Возможно, то о чем я говорю, кажется не важным в рамках одного модуля. Это так. Проблема возникнет в будущем, когда потребуется вынести FoodMenuClient в отдельный модуль и дополнительно разделить его на api/impl. В этом случае придется переписывать код, чтобы избавиться от неявной транзитивной зависимости.


        1. Spaceguest
          17.06.2025 16:51

          Вы рассуждаете по ООП-шному и как будто хотите склеить два разноплановых подхода ООП-шными принципами.

          Я примерно понимаю на чем стоит ваша аргументация, поправьте если я неправ, что применение FoodMenuClient.live() дефолтным значением аргумента плохо тем, что о существовании live имплементации становится известно модулю (не классу) и он начинает от нее зависеть. В этом нет никакой проблемы, так как в сниппетах кода из статьи конечно же не полный production-grade пример как скомпоновать зависимости. Полная картина может быть несколько сложнее, но принцип тот же. В ней не используются классовые View, Presenter, Interactor или что там еще можно придумать в качестве ООП-like архитектуры.

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

          При таком проектировании, всё исходит из идеи, что View - функция от State (что и представляет собой Compose). А State - кусочек иммутабельных данных без логики.
          Логика с зависимостями от компонентов с версткой отделяется созданием двухэтажных компонент: FoodMenuLogicComposable + FoodMenuLayoutComposable. К этому прилагается тонкая View модель реагирующая на события и обрабатывающая их в Клиентах. Вот и вся архитектура. Под клиентами может быть масса других частей: кеши, база, неворкинг и тд и тп скрытых под своими клиентами. Компонуются плюс-минус тем же способом по принципу фрактала - множество треугольничков имеют похожую форму, но в составной объект может принимать любые формы. То есть при необходимости перекомпоновки под новые требования атомарные компоненты перекомпонуются на раз-два.


          1. Not_coolEd
            17.06.2025 16:51

            применение FoodMenuClient.live() дефолтным значением аргумента плохо тем, что о существовании live имплементации становится известно модулю (не классу) и он начинает от нее зависеть.

            Да, я об этом.

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

            Хочется почитать след статьи, надеюсь там будет пример чистой ФП фичи, чтобы была видна полная разница в проектировании по сравнению с ООП подходом.


    1. realist-pessimist Автор
      17.06.2025 16:51

      Привет. Спасибо за комментарии и интерес к моей статье!

      По поводу DI в 3 и 4 частях цикла я более глубоко буду погружаться в тему DI в ФП. Там будет показан подход с closure и возможно некоторые вопросы отпадут, либо можно будет продолжить дискуссию там.

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


      1. Not_coolEd
        17.06.2025 16:51

        Ждем след статей)

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