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

Ключевые моменты для обсуждения 

  1. Избегайте инициализации состояния в блоке init {}.

  2. Избегайте раскрытия изменяемых состояний.

  3. Используйте update{} при использовании MutableStateFlows

  4. Делайте «ленивое» внедрение зависимостей в конструктор.

  5. Используйте более реактивное и менее императивное кодирование.

  6. Избегайте инициализации ViewModel извне.

  7. Избегайте передачи параметров извне.

  8. Избегайте жёсткого кодирования диспетчеров корутинов.

  9. Проводите модульное тестирование ViewModel.

  10. Избегайте использования приостановленных функций.

  11. Используйте коллбэк onCleared() во ViewModels.

  12. Обрабатывайте завершение процесса и изменение конфигурации.

  13. Внедряйте UseCases, которые вызывают Repositories, которые, в свою очередь, вызывают DataSources.

  14. Включайте во ViewModels только объектов домена.

  15. Используйте операторы shareIn() и stateIn() во избежание многократных обращений к восходящему потоку.

В первой части мы подробно разобрали первый пункт из этого списка, а в этой статье разберём пункты 2-5. 


№2. Избегайте раскрытия изменяемого состояния

Прямое раскрытие MutableStateFlow из Android ViewModel может вызвать проблемы, связанные с архитектурой приложения, целостностью данных и общей поддерживаемостью кода. Вот основные из них:

Нарушение инкапсуляции:

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

Риски для целостности данных

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

Увеличение сложности

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

Проблемы с параллелизмом

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

Сложности при тестировании

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

Чёткость архитектуры

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

Отсутствие контроля над наблюдателями

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

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

class RatesViewModel constructor(
    private val ratesRepository: RatesRepository,
) : ViewModel() {

    val state = MutableStateFlow(RatesUiState(isLoading = true))

}

Ну хорошо, тогда покажите, как не раскрывать изменяемое состояние ?

Чтобы избежать этих проблем, обычно рекомендуется раскрывать состояние из ViewModel только для чтения, используя StateFlow или LiveData. Такой подход сохраняет инкапсуляцию и позволяет ViewModel более эффективно управлять своим состоянием. Изменения состояния могут осуществляться через чётко определённые методы в ViewModel, которые могут проверять и обрабатывать изменения по мере необходимости. Это помогает обеспечить целостность данных, упростить тестирование и поддерживать чёткую архитектуру.

class RatesViewModel constructor(
    private val ratesRepository: RatesRepository,
) : ViewModel() {

    private val _state = MutableStateFlow(RatesUiState(isLoading = true))

    val state: StateFlow<RatesUiState>
        get() = _state.asStateFlow()

}

В приведённом выше примере у нас есть внутреннее приватное состояние ViewModel, которое можно обновлять внутри, а затем мы раскрываем неизменяемое состояние с помощью функции-расширения asStateFlow().

№3. Используйте update{} при работе с MutableStateFlows

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

Вариант 1: Прямое присваивание

mutableStateFlow.value = mutableStateFlow.value.copy()

Этот метод подразумевает непосредственное присвоение нового значения MutableStateFlow путём создания копии текущего состояния с нужными изменениями. Этот подход прост и хорошо работает для обновлений состояния, не требующих сложной логики. Однако он не является атомарным — это означает, что при одновременных обновлениях состояния несколькими потоками могут возникнуть проблемы гонки (race conditions).

Вариант 2: Отправка нового состояния

mutableStateFlow.emit(newState())

Использование .emit() позволяет отправить новое состояние в MutableStateFlow. Хотя .emit() является потокобезопасным и подходит для параллельных обновлений, это останавливаемая (suspend) функция, которую следует вызывать внутри корутины. Этот метод более гибкий, но также вносит дополнительную сложность при использовании в синхронном коде или вне корутин.

Вариант 3: Использование .update{}

mutableStateFlow.update { it.copy(// здесь изменение состояния) }

Почему .update{} часто является предпочтительным подходом:

  • Атомарность: .update{} гарантирует, что операция обновления будет атомарной, что особенно важно в параллельной среде. Эта атомарность обеспечивает применение каждого обновления на основе наиболее актуального состояния, избегая конфликтов между параллельными обновлениями.

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

  • Простота и безопасность: этот метод предоставляет простой и безопасный способ обновления состояния без необходимости явно управлять корутинами, как это требуется при использовании .emit() для неасинхронных обновлений.

В итоге, хотя прямое присваивание и .emit() имеют свои случаи применения, .update{} разработан для того, чтобы обеспечить потокобезопасное и атомарное обновление значений MutableStateFlow. Это делает его отличным выбором для большинства сценариев, когда требуется гарантировать консистентные и безопасные обновления реактивного состояния в многопоточной среде.

Пример использования

Представьте, что у вас есть MutableStateFlow, содержащий состояние типа User, которое представляет собой data class

data class User(val name: String, val age: Int)
val userStateFlow = MutableStateFlow(User(name = "John", age = 30))

Если вы хотите обновить возраст пользователя, сделайте следующее:

userStateFlow.update { currentUser ->
    currentUser.copy(age = currentUser.age + 1)
}

Этот код атомарно обновляет текущее состояние userStateFlow, увеличивая age на 1. CurrentUser внутри лямбды представляет текущее состояние.

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

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"

№4. Старайтесь не импортировать Android-зависимости в ViewModels

Рекомендация избегать импорта Android-зависимостей в ViewModels, за особыми исключениями для таких классов, как LiveData и его преобразователи, основана на принципах чистой архитектуры и тестируемости. Ниже объясняем, что это значит и почему это важно:

1. Разделение ответственности

  • Android-зависимости, такие как R (ресурсы) и другие классы фреймворка Android, напрямую связаны с операционной системой Android и её контекстом. Эти классы отвечают за управление элементами пользовательского интерфейса, доступ к ресурсам Android (строки, графические ресурсы и так далее) и взаимодействие с системой Android (intents, context  и так далее).

  • Избегая Android-зависимостей от в ViewModel, вы обеспечиваете чёткое разделение ответственности. ViewModel не нужно знать о контексте Android или элементах UI, чтобы выполнять свою основную задачу — управление данными для пользовательского интерфейса.

Но что, если мне нужно отправить какую-то строку из моих ViewModel в качестве части состояния?

Для таких случаев использование изолированных (sealed) интерфейсов в Kotlin было бы идеальным решением. Подробнее об этом вы можете узнать в этой статье.

2. Тестируемость

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

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

3. Портируемость

Код, который не зависит напрямую от Android-фреймворка, более портативен и легче в поддержке. Его можно повторно использовать в разных частях приложения или даже в других проектах с минимальными изменениями. В будущем ViewModel можно легко заменить на Circuit Presenters от Slack, чтобы мигрировать ваш код на Kotlin Multi-Platform.

А как же LiveData и Преобразователи? ?

  • LiveData и его преобразователи (такие как Transformations.map и Transformations.switchMap) предназначены для использования в ViewModels. Они учитывают жизненный цикл, позволяя ViewModel безопасно обновлять UI, реагируя на события жизненного цикла Activities и Fragments.

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

№5. Не использовать ленивую инициализацию зависимостей в конструкторах

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

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

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

Когда использовать ленивую инициализацию

  • Большие или редко используемые зависимости: Если у ViewModel есть крупные зависимости или те, которые редко используются, ленивая инициализация полезна, так как она позволяет избежать затрат на инициализацию этих ресурсов до тех пор, пока они действительно не понадобятся.

  • Условные зависимости: Для зависимостей, которые требуются только при определённых условиях (например, в зависимости от действий пользователя или конкретных состояний приложения), ленивая инициализация предотвращает ненужную настройку.

Пример:

@HiltViewModel
class BookViewModel @Inject constructor(
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
    private val bookmarkUseCase: dagger.Lazy<BookmarkUsecase>,

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

Заключение

В этой статье мы сделали акцент на важные принципы разработки и оптимизации ViewModel в Android, направленные на улучшение архитектуры приложения и повышение его производительности. Одним из главных советов является избегание раскрытия изменяемых состояний, таких как MutableStateFlow, что помогает сохранить инкапсуляцию и предотвращает потенциальные проблемы с целостностью данных. Использование метода .update{} для управления состояниями в MutableStateFlow становится предпочтительным подходом благодаря его атомарности и встроенной потокобезопасности.

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


18 сентября пройдет открытый урок для Android-разработчиков на тему «ProGuard / R8: сжатие и оптимизация кода для Android-приложений». На этом уроке:

  • рассмотрим, как ProGuard/R8 миницифируют, оптимизируют и убирают неиспользуемый код;

  • сравним эффективность ProGuard и R8;

  • разберемся, как внедрять ProGuard/R8 в существующий проект.

В результате урока участники научатся писать максимально строгие keep-директивы и настраивать оптимизации. Записаться можно по ссылке.

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