Pull to refresh
Dodo Engineering
О том, как разработчики строят IT в Dodo

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

Level of difficultyMedium
Reading time21 min
Views2.7K

О чём статья?

Мир программирования быстро меняется. Возможно, скоро свою нишу в нём займёт и не особо популярная в кругах 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.

Tags:
Hubs:
+10
Comments11

Articles

Information

Website
dodoengineering.ru
Registered
Founded
Employees
201–500 employees
Location
Россия