Русский
Русский
English
Статистика
Реклама

Rxjava

Как спроектировать пошаговое заполнение данных в мобильном приложении

01.10.2020 14:20:35 | Автор: admin
Привет! Меня зовут Вита Соколова, я Android Team Lead в Surf.

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

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



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

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

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

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

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

Анкета состоит из:
  • Шаг 1 ФИО, типа образования, наличия опыта работы,
  • Шаг 2 место учёбы,
  • Шаг 3 место работы или эссе о себе,
  • Шаг 4 причины, почему заинтересовала вакансия.




Анкета будет меняться в зависимости от того, есть ли у пользователя образование и опыт работы. Если образования нет, исключим шаг с заполнением места учёбы. Если нет опыта работы, попросим пользователя написать немного о себе.



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

В результате хотим получить такую функциональность:



Примером целиком в моём репозитории на GitHub

Очевидное решение


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

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



Конечно, все эти данные стоит упаковать в один объект заявки. Посмотрим, как он будет выглядеть:

class Application(    val name: String?,    val surname: String?,    val educationType : EducationType?,    val workingExperience: Boolean?    val education: Education?,    val experience: Experience?,    val motivation: List<Motivation>?)


НО!
Работая с таким объектом, мы обрекаем код наш код покрыться лишним ненужным количеством проверок null. Например, такая структура данных никак не гарантирует, что поле educationType уже будет заполнено на экране Образование.

Как сделать лучше


Рекомендую вынести управление данными в отдельный объект, который обеспечит на вход каждому шагу необходимые non-nullable данные и сохранит результат каждого шага в черновик. Этот объект мы назовём интерактор. Он соответствует слою Use Case из чистой архитектуры Роберта Мартина и для всех экранов отвечает за предоставление данных, собранных из различных источников (сеть, БД, данные с предыдущих шагов, данные из черновика заявки...).

На своих проектах мы в Surf используем Dagger. По ряду причин интеракторы принято делать скоупом @PerApplication: это делает наш интерактор синглтоном в рамках приложения. На самом деле интерактор может быть синглтоном в рамках фичи или даже активити если все ваши шаги представляют собой фрагменты. Всё зависит от общей архитектуры вашего приложения.

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



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

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

Предзаполнять экраны с помощью данных, сохранённых в черновик.

Основные сущности


Механизм работы фичи будет состоять из:
  • Набора моделей для описания шага, входных и выходных данных.
  • Сценария (Scenario) сущности, описывающей, какие шаги (экраны) нужно пройти пользователю.
  • Интерактора (ProgressInteractor) класса, отвечающего за хранение информации о текущем активном шаге, агрегирование заполненной информации после завершения каждого шага и выдачу входных данных для старта нового шага.
  • Черновика (ApplicationDraft) класса, отвечающего за хранение заполненной информации.

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



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

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

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

Диаграмма классов для конкретных реализации базовых классов:



Все эти сущности буду взаимодействовать между собой и с презентерами экранов следующим образом:



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

Описание шагов


Начнём с первого пункта. Нам понадобятся сущности для описания шагов:

//Маркерный интерфейс, чтобы обозначить классы, являющиеся шагами в сценарииinterface Step


Для фичи из нашего примера с откликом на вакансию шаги будут следующими:

/** * Шаги в фиче заполнения заявки */enum class ApplicationSteps : Step {    PERSONAL_INFO,  // персональные данные    EDUCATION,      // образование    EXPERIENCE,     // опыт работы    ABOUT_ME,       // эссе "о себе"    MOTIVATION      // что интересно в данной вакансии}


Также нам понадобится описать входные данные для каждого шага. Для этого мы будем по прямому назначению использовать sealed class-ы чтобы создать ограниченную иерархию классов.



Как это будет выглядеть в коде
//Входные данные для шагаinterface StepInData


Для нашего примера это:

//Класс, описывающий входные данные для работы шаговsealed class ApplicationStepInData : StepInData//Входные данные для шага об образованииclass EducationStepInData(val educationType: EducationType) : ApplicationStepInData()//Входные данные для шага о причинах выбора этой вакансииclass MotivationStepInData(val values: List<Motivation>) : ApplicationStepInData()



Аналогично описываем выходные данные:



Как это будет выглядеть в коде
//Маркерный интерфейс, помечающий результат шагаinterface StepOutData//Класс, описывающий результат прохождения шагаsealed class ApplicationStepOutData : StepOutData//Результат прохождения  шага "Персональная информация"class PersonalInfoStepOutData(    val info: PersonalInfo) : ApplicationStepOutData()//Результат прохождения шага "Образование"class EducationStepOutData(    val education: Education) : ApplicationStepOutData()//Результат прохождения  шага "Места работы"class ExperienceStepOutData(    val experience: WorkingExperience) : ApplicationStepOutData()//Результат прохождения шага "Обо мне"class AboutMeStepOutData(    val info: AboutMe) : ApplicationStepOutData()//Результат прохождения шага "Выбор причин"class MotivationStepOutData(    val motivation: List<Motivation>) : ApplicationStepOutData()



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

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

Как это будет выглядеть в коде
/** * Входные данные для шага + данные из черновика, если они есть */interface StepData<I : StepInData, O : StepOutData>sealed class ApplicationStepData : StepData<ApplicationStepInData,  ApplicationStepOutData> {    class PersonalInfoStepData(        val outData: PersonalInfoStepOutData?    ) : ApplicationStepData()    class EducationStepData(        val inData: EducationStepInData,        val outData: EducationStepOutData?    ) : ApplicationStepData()    class ExperienceStepData(        val outData: ExperienceStepOutData?    ) : ApplicationStepData()    class AboutMeStepData(        val outData: AboutMeStepOutData?    ) : ApplicationStepData()    class MotivationStepData(        val inData: MotivationStepInData,        val outData: MotivationStepOutData?    ) : ApplicationStepData()}



Действуем по сценарию


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

/** * Интерфейс, которому должны удовлетворять все классы, описывающие порядок шагов в фиче */interface Scenario<S : Step, O : StepOutData> {        // список шагов    val steps: List<S>    /**     * Внесение изменений в сценарий      * в зависимости от выходной информации при завершении шага     */    fun reactOnStepCompletion(stepOut: O)}


В имплементации для нашего примера сценарий будет таким:

class ApplicationScenario : Scenario<ApplicationStep, ApplicationStepOutData> {    override val steps: MutableList<ApplicationStep> = mutableListOf(        PERSONAL_INFO,        EDUCATION,        EXPERIENCE,        MOTIVATION    )    override fun reactOnStepCompletion(stepOut: ApplicationStepOutData) {        when (stepOut) {            is PersonalInfoStepOutData -> {                changeScenarioAfterPersonalStep(stepOut.info)            }        }    }    private fun changeScenarioAfterPersonalStep(personalInfo: PersonalInfo) {        applyExperienceToScenario(personalInfo.hasWorkingExperience)        applyEducationToScenario(personalInfo.education)    }    /**     * Если нет образования - шаг с заполнением места учёбы будет исключен     */    private fun applyEducationToScenario(education: EducationType) {...}    /**     * Если у пользователя нет опыта работы,     * шаг заполнения мест работы будет заменён на шаг рассказа о себе     */    private fun applyExperienceToScenario(hasWorkingExperience: Boolean) {...}}


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

Как, например, выглядит в коде реакция на наличие или отсутствие опыта работы
/** * Если у пользователя нет опыта работы, * шаг заполнения мест работы будет заменён на шаг рассказа о себе */private fun applyExperienceToScenario(hasWorkingExperience: Boolean) {    if (hasWorkingExperience) {        steps.replaceWith(            condition = { it == ABOUT_ME },            newElem = EXPERIENCE        )    } else {        steps.replaceWith(            condition = { it == EXPERIENCE },            newElem = ABOUT_ME        )    }}



Как устроен Interactor


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

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

/** * Базовый класс для интеракторов пошаговых фич * S - входной шаг * I - входные данные для шагов * O - выходные данные для шагов */abstract class ProgressInteractor<S : Step, I : StepInData, O : StepOutData> 


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

// сущность, отвечающая за состав и порядок шаговprotected abstract val scenario: Scenario<S, O>


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

Создадим модель, описывающую нужную экранам информацию о текущем шаге и его положении в сценарии:

/** * Модель для описания шага и его позиции в сценарии */class StepWithPosition<S : Step>(    val step: S,    val position: Int,    val allStepsCount: Int)


Заведём в интеракторе BehaviorSubject, чтобы свободно эмитить в него информацию о новом активном шаге.

private val stepChangeSubject = BehaviorSubject.create<StepWithPosition<S>>()


Чтобы экраны могли подписаться на этот поток событий заведём публичную переменную stepChangeObservable, являющуюся обёрткой над нашим stepChangeSubject.

val stepChangeObservable: Observable<StepWithPosition<S>> = stepChangeSubject.hide()


В ходе работы интерактора часто нужно знать позицию текущего активного шага. Рекомендую завести в интеракторе отдельное свойство currentStepIndex и переопределить методы get() и set(). Так мы получаем удобный доступ к этой информации из subject.

Как это выглядит в коде
// текущий активный шагprivate var currentStepIndex: Int    get() = stepChangeSubject.value?.position ?: 0    set(value) {        stepChangeSubject.onNext(            StepWithPosition(                step = scenario.steps[value],                position = value,                allStepsCount = scenario.steps.count()            )        )    }



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

Добавим методы для инициализации и завершения работы интерактора, сделаем их открытыми для расширения в наследниках:

Методы для инициализации и завершения работы
/** * Инициализация работы интерактора */@CallSuperopen fun initProgressFeature() {    currentStepIndex = 0}/** * Завершение работы интерактора */@CallSuperopen fun closeProgressFeature() {    currentStepIndex = 0}



Добавим функции, которые должен выполнять любой интерактор пошаговой фичи:
  • getDataForStep(step: S) предоставлять данные на вход шагу S;
  • completeStep(stepOut: O) сохранять выходные данные O и переводить сценарий на следующий шаг;
  • toPreviousStep() - переводить сценарий на предыдущий шаг.


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

/** * Метод получения входной информации для шага */protected abstract fun resolveStepInData(step: S): Single<out StepData<I, O>>


Для презентеров конкретных экранов добавим публичный метод, который будет вызывать resolveStepInData() :

/** * Предоставление входных параметров для шага */fun getDataForStep(step: S): Single<out StepData<I, O>> = resolveStepInData(step)


Можно упростить этот код, сделав публичным метод resolveStepInData(). Метод getDataForStep() добавлен для аналогии с методами обработки завершения шага, которые мы рассмотрим ниже.

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

/** * Метод обработки выходной информации для шага */protected abstract fun saveStepOutData(stepData: O): Completable


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

/** * Завершение текущего шага и переход к следующему */fun completeStep(stepOut: O): Completable {    return saveStepOutData(stepOut).doOnComplete {        scenario.reactOnStepCompletion(stepOut)        if (currentStepIndex != scenario.steps.lastIndex) {            currentStepIndex += 1        }    }}


И в завершении реализуем метод для возврата к предыдущему шагу.

/** * Переход на предыдущий шаг */fun toPreviousStep() {    if (currentStepIndex != 0) {        currentStepIndex -= 1    }}


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

/** * Интерактор фичи подачи заявления */@PerApplicationclass ApplicationProgressInteractor @Inject constructor(    private val dataRepository: ApplicationDataRepository) : ProgressInteractor<ApplicationSteps, ApplicationStepInData, ApplicationStepOutData>() {    // сценарий оформления    override val scenario = ApplicationScenario()    // черновик заявки    private val draft: ApplicationDraft = ApplicationDraft()    // установка черновика    fun applyDraft(draft: ApplicationDraft) {        this.draft.apply {            clear()            outDataMap.putAll(draft.outDataMap)        }    }    ...}


Как выглядит класс черновика
Класс для черновика будет выглядеть следующим образом:

/** * Черновик заявки */class ApplicationDraft(    val outDataMap: MutableMap<ApplicationSteps, ApplicationStepOutData> = mutableMapOf()) : Serializable {    fun getPersonalInfoOutData() = outDataMap[PERSONAL_INFO] as? PersonalInfoStepOutData    fun getEducationStepOutData() = outDataMap[EDUCATION] as? EducationStepOutData    fun getExperienceStepOutData() = outDataMap[EXPERIENCE] as? ExperienceStepOutData    fun getAboutMeStepOutData() = outDataMap[ABOUT_ME] as? AboutMeStepOutData    fun getMotivationStepOutData() = outDataMap[MOTIVATION] as? MotivationStepOutData    fun clear() {        outDataMap.clear()    }}



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

/** * Сохранение выходных данных шага в черновик */override fun saveStepOutData(stepData: ApplicationStepOutData): Completable {    return Completable.fromAction {        when (stepData) {            is PersonalInfoStepOutData -> {                draft.outDataMap[PERSONAL_INFO] = stepData            }            is EducationStepOutData -> {                draft.outDataMap[EDUCATION] = stepData            }            is ExperienceStepOutData -> {                draft.outDataMap[EXPERIENCE] = stepData            }            is AboutMeStepOutData -> {                draft.outDataMap[ABOUT_ME] = stepData            }            is MotivationStepOutData -> {                draft.outDataMap[MOTIVATION] = stepData            }        }    }}


Теперь посмотрим на метод получения входных данных для шага:

/** * Получение входной информации для шага */override fun resolveStepInData(step: ApplicationStep): Single<ApplicationStepData> {    return when (step) {        PERSONAL_INFO -> ...        EXPERIENCE -> ...        EDUCATION -> Single.just(            EducationStepData(                inData = EducationStepInData(                    draft.getPersonalInfoOutData()?.info?.educationType                    ?: error("Not enough data for EDUCATION step")                ),                outData = draft.getEducationStepOutData()            )        )        ABOUT_ME -> Single.just(            AboutMeStepData(                outData = draft.getAboutMeStepOutData()            )        )        MOTIVATION -> dataRepository.loadMotivationVariants().map { reasonsList ->            MotivationStepData(                inData = MotivationStepInData(reasonsList),                outData = draft.getMotivationStepOutData()            )        }    }}


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

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

 ABOUT_ME -> Single.just(            AboutMeStepData(                stepOutData = draft.getAboutMeStepOutData()            )        )


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

EDUCATION -> Single.just(    EducationStepData(        inData = EducationStepInData(            draft.getPersonalInfoOutData()?.info?.educationType            ?: error("Not enough data for EDUCATION step")        ),        outData = draft.getEducationStepOutData()    ))


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

MOTIVATION -> {    dataRepository.loadMotivationVariants().map { reasonsList ->        MotivationStepData(            inData = MotivationStepInData(reasonsList),            outData = draft.getMotivationStepOutData()        )    }}


Такие ситуации ещё один аргумент в пользу работы через интеракторы. Иногда, чтобы обеспечить шаг данными, нужно объединить несколько источников данных: например, загрузку из сети и результаты предыдущих шагов.

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

Код презентера значительно облегчается, когда агрегацией занимается отдельный метод интерактора, который выдаёт только результат: данные или ошибка. Разработчикам проще вносить изменения и корректировки, если сразу ясно, где всё искать.

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

Класс для представления финальной заявки
/** * Модель заявления */class Application(    val personal: PersonalInfo,    val education: Education?,    val experience: Experience,    val motivation: List<Motivation>) {    class Builder {        private var personal: Optional<PersonalInfo> = Optional.empty()        private var education: Optional<Education?> = Optional.empty()        private var experience: Optional<Experience> = Optional.empty()        private var motivation: Optional<List<Motivation>> = Optional.empty()        fun personalInfo(value: PersonalInfo) = apply { personal = Optional.of(value) }        fun education(value: Education) = apply { education = Optional.of(value) }        fun experience(value: Experience) = apply { experience = Optional.of(value) }        fun motivation(value: List<Motivation>) = apply { motivation = Optional.of(value) }        fun build(): Application {            return try {                Application(                    personal.get(),                    education.getOrNull(),                    experience.get(),                    motivation.get()                )            } catch (e: NoSuchElementException) {                throw ApplicationIsNotFilledException(                    """Some fields aren't filled in application                        personal = {${personal.getOrNull()}}                        experience = {${experience.getOrNull()}}                        motivation = {${motivation.getOrNull()}}                    """.trimMargin()                )            }        }    }}



Сам метод отправки заявки:

/** * Отправка заявки */fun sendApplication(): Completable {    val builder = Application.Builder().apply {        draft.outDataMap.values.forEach { data ->            when (data) {                is PersonalInfoStepOutData -> personalInfo(data.info)                is EducationStepOutData -> education(data.education)                is ExperienceStepOutData -> experience(data.experience)                is AboutMeStepOutData -> experience(data.info)                is MotivationStepOutData -> motivation(data.motivation)            }        }    }    return dataRepository.loadApplication(builder.build())}


Как всем этим пользоваться на экранах


Теперь стоит спуститься на уровень представления и посмотреть, как взаимодействуют презентеры экранов с этим интерактором.

Наша фича представляет собой активити со стеком фрагментов внутри.

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

progressInteractor.stepChangeObservable.subscribe { stepData ->    if (stepData.position > currentPosition) {        // добавляем шаг в стек через FragmentManager    } else {        // убираем из стека    }    // отображение нужного кол-ва закрашенных шагов в тулбаре}


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

Для примера возьмём экран заполнения информации об образовании.



progressInteractor.getDataForStep(EducationStep)    .filter<ApplicationStepData.EducationStepData>()    .subscribeOn(Schedulers.io())    .subscribe {         val educationType = it.stepInData.educationType // todo: вносим изменения в модель в зависимости от типа образования it.stepOutData?.education?.let {       // todo: применяем к экрану данные из черновика  }    }


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

progressInteractor.completeStep(EducationStepOutData(education)).subscribe {                   // обработка успешного сохранения данных (если нужно)               }


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

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

progressInteractor.sendApplication()            .subscribeOn(Schedulers.io())            .observeOn(AndroidSchedulers.mainThread())            .subscribe(                {                    // реакция на успешную отправку                    activityNavigator.start(ThankYouRoute())                },                {                    // обработка ошибок                }            )


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

progressInteractor.closeProgressFeature()


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

Особая благодарность Васе Беглянину @icebail автору первой реализации этого подхода в проекте. А также Мише Зинченко @midery за помощь в доведении чернового варианта архитектуры до финальной версии, которая и описана в этой статье.
Подробнее..

Как безболезненно мигрировать с RxJava на Kotlin CoroutinesFlow

11.01.2021 10:24:00 | Автор: admin
Для выполнения асинхронных операций в Android-приложениях, где нужна загрузка и обработка любых данных, долгое время использовали RxJava и о том, как перейти на RxJava 3, мы уже писали в нашем блоге. Сейчас на смену фреймворку постепенно приходят инструменты Kotlin Coroutines+Flow. Актуальность этой связки подтверждается тем, что Google сделал Kotlin приоритетным языком для Android-разработки.

Корутины позволяют тратить меньше системных ресурсов, чем RxJava. Кроме того, поскольку они являются частью Kotlin, Android предоставляет удобные инструменты для работы с ними например, viewModelScope и lifecycleScope. В этой статье мы рассмотрим use cases, распространенные в Rx Java, и то, какие возможности вы получите при переходе на Flow.



Переключение потоков и создание


Для начала сравним, как происходит переключение потоков в RxJava и Flow.

RxJava


Observable.create<Int> { emitter ->emitter.onNext(1)emitter.onNext(2)emitter.onNext(3)emitter.onComplete()}.observeOn(Schedulers.io()).map {printThread(map1 value = $it)it + it}.doOnNext { printThread(after map1 -> $it) }.observeOn(Schedulers.computation()).map {printThread(map2 value = $it)it * it}.doOnNext { printThread(after map2 -> $it) }.observeOn(Schedulers.single()).subscribe ({printThread(On Next $it)},{printThread(On Error)},{printThread(On Complete)})


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

Flow


Повторим этот же пример для Flow:

launch {flow {emit(1)emit(2)emit(3)}.map {printThread(map1 value = $it)it + it}.onEach { printThread(after map1 -> $it) }.flowOn(Dispatchers.IO).map {printThread(map2 value = $it)it * it}.onEach { printThread(after map2 -> $it) }.flowOn(Dispatchers.Default).onCompletion { printThread(onCompletion) }.collect { printThread(received value $it) }}


В результате можно отметить следующее:

1) observeOn переключает поток, в котором будут выполняться последующие операторы, а flowOn определяет диспетчер выполнения для предыдущих операторов.

2) Метод collect() будет выполняться в том же диспетчере, что и launch, а emit данных будет происходить в Dispatchers.IO. Метод subscribe() будет выполняться в Schedulers.single(), потому что идет после него.

3) Flow также имеет стандартные методы создания flow:

  • flowOf(): в примере можно было бы использовать Observable.fromArray(1, 2, 3) и flowOf(1, 2, 3)
  • extenstion function asFlow(), который превращает Iterable, Sequence, массивы во flow
  • билдер flow { }

4) В данном примере Flow, как и RxJava, представляет собой cold stream данных: до вызова методов collect() и subscribe() никакой обработки происходить не будет.

5) В RxJava нужно явно вызывать emitter.onComplete(). В Flow метод onCompletion() будет автоматически вызываться после окончания блока flow { }.

6) При попытке сделать эмит данных из другого диспетчера, с помощью withContext, например, приведет к ошибке.

Exception in thread main java.lang.IllegalStateException: Flow invariant is violated:
Flow was collected in [BlockingCoroutine{Active}@5df83c81, BlockingEventLoop@3383bcd],
but emission happened in [DispatchedCoroutine{Active}@7fbc37eb, Dispatchers.IO].
Please refer to 'flow' documentation or use 'flowOn' instead

Подписка и отписка на источник данных


В RxJava метод Observable.subscribe() возвращает объект Disposable. Он служит для отписки от источника данных, когда новые порции данных от текущего источника уже не нужны. Важно иметь доступ к этому объекту, чтобы вовремя отписываться и избегать утечек.

Для Flow ситуация схожа: так как метод collect() suspend метод, он может быть запущен только внутри корутины. Следовательно, отписка от flow происходит в момент отмены Job корутины:

val job = scope.launch {flow.collect { }}job.cancel() // тут произойдет отписка от flow

В случае же использования viewModelScope об этом заботиться не нужно: все корутины, запущенные в рамках этого scope, будут отменены, когда ViewModel будет очищена, т.е. вызовется метод ViewModel.onCleared(). Для lifecycleScope ситуация аналогична: запущенные в его рамках корутины будут отменены, когда соответствующий Lifecycle будет уничтожен.

Обработка ошибок


В RxJava есть метод onError(), который будет вызван в случае возникновения какой-либо ошибки и на вход получит данные о ней. В Flow тоже есть такой метод, он называется catch(). Рассмотрим следующий пример.

RxJava


Observable.fromArray(1, 2, 3).map {val divider = Random.Default.nextInt(0, 1)it / divider}.subscribe({ value ->println(value)},{ e ->println(e)})


При возникновении ArithmeticException будет срабатывать onError(), и информация об ошибке будет напечатана в консоль.

Flow


flowOf(1, 2, 3).map {val divider = Random.Default.nextInt(0, 1)it / divider}.catch { e -> println(e) }.collect { println(it) }


Этот же пример, переписанный на flow, можно представить с помощью catch { }, который под капотом имеет вид привычной конструкции try/catch.

Операторы RxJava onErrorResumeNext и onErrorReturn можно представить в виде:

catch { emit(defaultValue) } // onErrorReturn

catch { emitAll(fallbackFlow) } // onErrorResumeNext

В Flow, как и в RxJava, есть операторы retry и retryWhen, позволяющие повторить операции в случае возникновения ошибки.

Операторы


Рассмотрим наиболее распространенные операторы RxJava и найдем их аналоги из Flow.



Подробнее с операторами Flow можно познакомиться здесь.

Некоторые операторы Flow (например, merge) помечены как экспериментальные или отсутствующие. Их api может измениться (как, например, для flatMapMerge), или их могут задепрекейтить, то есть они станут недоступны. Это важно помнить при работе с Flow. При этом отсутствие некоторых операторов компенсируется тем, что flow всегда можно собрать в список и работать уже с ним. В стандартной библиотеке Kotlin есть множество функций для работы со списками.

Также у Flow отсутствуют отдельные операторы троттлинга и другие операторы, которые работают с временными промежутками. Это можно объяснить молодостью библиотеки, а также тем, что, согласно словам разработчика Kotlin Романа Елизарова, команда Jetbrains не планирует раздувать библиотеку множеством операторов, оставляя разработчикам возможность компоновать нужные операторы самостоятельно, предоставляя им удобные блоки для сборки.

Backpressure


Backpressure это ситуация, когда производитель данных выдает элементы подписчику быстрее, чем тот их может обработать. Готовые данные, в ожидании того, как подписчик сможет их обработать, складываются в буфер Observable. Проблема такого подхода в том, что буфер может переполниться, вызвав OutOfMemoryError.

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

После появления в RxJava 2 Flowable произошло разделение на источники данных с поддержкой backpressure (Flowable) и Observable, которые теперь не поддерживают backpressure. При работе с RxJava требуется правильно выбрать тип источника данных для корректной работы с ним.

У Flow backpressure заложена в Kotlin suspending functions. Если сборщик flow не может принимать новые данные в настоящий момент, он приостанавливает источник. Возобновление происходит позднее, когда сборщик flow снова сможет получать данные. Таким образом, в Kotlin нет необходимости выбирать тип источника данных, в отличие от RxJava.

Hot streams


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

Горячие источники данных полезны, например, при подписке на события от View: при этом нужно получать только новые события, нет смысла обрабатывать заново все пользовательские действия. Также мы не можем запретить пользователю нажимать на экран до тех пор, пока мы не будем готовы обрабатывать его действия. Для обработки событий от View в реактивном виде существует библиотека RxBinding, которая имеет поддержку RxJava3.

В Kotlin Flow есть свои возможности для работы с горячим flow, который производит данные вне зависимости от наличия подписчиков и выдает новые данные одновременно всем имеющимся подписчикам. Для этого можно использовать Channel, SharedFlow, чтобы отправлять новые порции данных одновременно всем подписанным сборщикам.

Кстати, для Flow тоже есть отличная библиотека для обработки событий от View Corbind. В ней есть поддержка большинства Android-виджетов.

RxJava Subjects


Subject в RxJava это специальный элемент, который одновременно является источником данных и подписчиком. Он может подписаться на один или несколько источников данных, получать от них порции данных и отдавать их своим подписчикам.

Аналог Subject в Flow это Channel, в частности, BroadcastChannel. Существуют различные варианты их реализации: с буферизацией данных (ArrayBroadcastChannel), с хранением только последнего элемента (ConflatedBroadcastChannel). Но важно помнить, что, так как библиотека Kotlin Flow молода и постоянно развивается, ее части могут меняться. Так получилось и в случае с BroadcastChannel: в своей статье Роман Елизаров сообщил, что, начиная с версии 1.4 будет предложено лучшее решение shared flows, а BroadcastChannel ждет deprecation в ближайшем будущем.

Заключение


В данной статье мы сравнили RxJava и Kotlin Flow, рассмотрели их схожие моменты и аналоги частей RxJava в Flow. При этом Flow хорошо подойдет в качестве инструмента для обработки событий в реактивном стиле в проектах на Kotlin, использующих паттерн MVVM: благодаря viewModelScope и lifecycleScope запускать корутины можно быстро и удобно, не боясь утечек. В связи с тем, что популярность Kotlin и его инструментов растет, а также этот язык является приоритетным для разработки Android-приложений, в ближайшие годы связка Coroutines+Flow может заменить RxJava скорее всего, новые проекты будут написаны именно с помощью нее. На первый взгляд, миграция с RxJava на Flow не представляется болезненной, потому что в обоих случаях есть похожие операторы и разделение общей концепции Reactive streams. Кроме того, Kotlin имеет достаточно большое комьюнити, которое постоянно развивается и помогает разработчикам в изучении новых возможностей.

А вы готовы мигрировать на корутины? Приглашаем поделиться мнениями!
Подробнее..

Перевод Как Uber переписал приложение iOS на Swift

13.12.2020 18:11:50 | Автор: admin
Итак, друзья, садитесь в кружок и послушайте историю самой большой инженерной катастрофы, в которой я участвовал. Это история о политике, архитектуре и логической ошибке невозвратных затрат (вы уж извините, просто сейчас пью Aberlour Cask Strength Single Malt Scotch).


Шёл 2016 год. Трампа ещё не избрали президентом, поэтому движение #DeleteUber пока не началось. Трэвис Каланик оставался гендиром, мы переживали фазу гиперактивного роста с открытием филиалов в других странах, общественные настроения в целом позитивные, все довольны, Uber на высоте.

Но гиперрост не обошёлся без проблем, и само приложение начало давать сбои. До этого количество разработчиков удваивалось почти каждый год, а когда вы растёте так быстро, то получаете невероятный разброс навыков. В сочетании с хакерским менталитетом, который мы называли Let builder's build, это означало сложную и хрупкую архитектуру приложения. В то время приложение Uber отличалось крайне тяжёлой логикой, так что оно часто падало. Мы постоянно выпускали хотфиксы, патчи, внеплановые релизы и т. д. Также архитектура плохо масштабировалась.

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

Отдел iOS воспользовался этой возможностью, чтобы внедрить Swift (тогда в версии 2.x). Раньше Uber уже пробовал Swift, но как и многие другие на том раннем этапе развития технологии, испытал множество проблем и отложил внедрение.

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

Была также идея использовать одни и те же основные архитектурные шаблоны как на Android, так и на iOS. Разработчики под Android в то время были большими поклонниками RxJava. Соответствующая библиотека RxSwift использовала преимущества парадигмы функционального программирования в Swift. Казалось, всё просто.

Таким образом, небольшая команда разработчиков (Design, Product, and Architecture) на несколько месяцев ушла с головой в новые функциональные/реактивные паттерны, новый язык и новое приложение. Всё шло хорошо. Архитектура в значительной степени опиралась на передовые языковые возможности Swift.

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

Через пару месяцев и несколько ярких демок движение набирало обороты. Проект выглядел успешным. С небольшим количеством инженеров удалось разработать отличную функциональность за короткое время. Большая часть продукта готова. Руководство довольно.

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

Но как только Swift освоили больше десяти инженеров, слаженный механизм стал разваливаться. Компилятор Swift и сегодня значительно медленнее Objective-C, но тогда был практически непригоден для использования. Время сборки стало зашкаливать. Отладка полностью прекратилась.

Где-то есть видеозапись с одной из демонстраций, там инженер Uber набирает однострочный оператор в Xcode, а затем ждёт 45 секунд, пока буквы медленно, одна за другой, появятся в редакторе.

Потом мы упёрлись в стену с динамическим линкером. В то время библиотеки Swift можно было связывать только динамически. К сожалению, компоновщик выполнялся за полиномиальное время, поэтому рекомендуемое Apple максимальное количество библиотек в одном бинарном файле составляло 6. У нас было 92, и число продолжало расти

В результате после нажатия на значок приложения требовалось 8-12 секунд, прежде чем даже вызвать main. Наше новое блестящее приложение оказалось медленнее, чем старое неуклюжее. Затем возникла проблема размера бинарника.

К сожалению, когда проблемы начали проявляться всерьёз, мы уже прошли точку невозврата. Это и есть логическая ошибка невозвратных затрат (sunk cost fallacy). В тот момент вся компания вкладывала в новое приложение всю свою энергию.

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

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

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

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

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

Поскольку Swift при компиляции искажает пространство имён объектов, значит, может им оперировать. Это позволило нам эффективно статически связать наши библиотеки и сократить время запуска main с 10 секунд практически до нуля.

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

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

Но приложение продолжало расти. Вскоре мы упёрлись в лимит загрузки (100МБ) бинарников в iOS8 и более ранних версий. Это означает значительное количество потерянных установок ($10+млн потерянных доходов из-за того, что многие пользователи iOS ещё не обновились).

В этот момент оставалось несколько недель до публичного запуска. Нам оставалось или вернуться на Objective-C, или отказаться от поддержки iOS 8. Поскольку в iOS 9 появилась возможность разделения архитектуры, эта версия была реально вдвое меньше по размеру (плюс-минус). Когда осталась всего неделя, мы решили выбросить десятки миллионов долларов и отказаться от поддержки iOS 8.

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

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

Куча людей получила повышение. Мы все вздохнули с облегчением. После 90 непрерывных недель работы ребята наконец-то получили передышку.

Но затем общественное мнение начало меняться. Новое приложение фокусировалось на расчёте точной цены поездки по конкретному маршруту (в прежние времена вы просто видели тариф и текущий множитель). Для расчёта цены нужно было ввести текущее местоположение.

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

В результате этих волнений люди начали отключать разрешение на местоположение в iOS. Но новое приложение не предусмотрело такой вариант использования.

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

Затем к власти пришёл Трамп (это случилось примерно через три месяца после выпуска нового приложения), что вызвало цепную реакцию, которая привела к движению #DeleteUber.

Всё это время кодовая база Swift стремительно росла. Продолжающиеся проблемы и медленная IDE породили среди наших iOS-разработчиков две враждующие фракции. Назову их фанатиками Swift и занудами Objective-C.

Сумма внешнего и внутреннего давления довела напряжённость до максимума. Фанатики отрицали проблемы Swift. Зануды жаловались на всё, что только можно себе представить, не предлагая особых решений.

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

Решив проблему на этих архитектурах, мы с коллегой @aqua_geek немного покопались и обнаружили, что размер скомпилированного кода растёт со скоростью 1,3МБ в неделю. Я поднял тревогу. Если ничего не сделать, с такой скоростью мы через три недели упрёмся в лимит скачивания по сотовой сети.

Но внутренняя напряжённость достигла такой стадии, что фанатики всё отрицали. Один из технических лидеров из лагеря Swift написал двухстраничную статью о том, что лимит загрузки сотовой связи не имеет значения (Facebook, в конце концов, давно его превысил). Да мы и сами устали тушить пожары.

Поэтому один из наших дата-сайентистов разработал тест, искусственно сдвинув один из архитектурных слоёв за пределы лимита и измерив влияние на бизнес-показатели. На следующей неделе мы вытянули этот слой обратно и выдвинули ещё один за пределы лимита (для контроля над архитектурами).

Эффект был катастрофическим. Негативное влияние на бизнес оказалось на несколько порядков больше, чем все затраты на годовое внедрение Swift. Оказывается, множество людей находится вне зоны действия WiFi, когда первый раз скачивают приложение Uber (кто бы мог подумать?)

Поэтому мы сформировали ещё одну ударную группу. Начали декомпилировать объектные файлы и изучать строку за строкой, чтобы определить, почему размер кода Swift так вырос. Удалили неиспользуемые функции. Тайлеру пришлось переписать приложение watchOS обратно в objc.

(Приложение watch составляло всего 4400 строк, но из-за другой процессорной архитектуры и отсутствия совместимости ABI пришлось бы включить в комплект приложения полный рантайм Swift).

Мы были на пределе. Так устали. Но собрались. Вот тогда и проявили себя по-настоящему гениальные инженеры. Один из разработчиков в Амстердаме придумал, как переставить оптимизационные проходы компилятора. Для тех, кто не специалист по компиляторам, я объясню.

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

Если встроенные функции передают значение, компилятор может это распознать и заменить весь блок. Например:

int x = 3func(x) {X + 4}

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

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

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

Но такой подход приводил в ужас специалистов по компиляторам Swift, они боялись, что непроверенные проходы компилятора выявят непроверенные баги (хотя каждый проход должен быть внутренне безопасным, но трудно рассуждать о возможных комбинациях проходов). Однако мы не испытали никаких серьёзных проблем.

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

В итоге мы добыли достаточно времени, чтобы дождаться хода Apple, которая подняла лимит загрузки по сотовой связи до 150МБ. Они также добавили ряд функций компилятора, чтобы помочь с оптимизацией размера (-Osize). По их собственному признанию, Swift никогда не даст такой же малый размер после компиляции, как Objective-C.

Но по состоянию на этот год мы оптимизировали Swift до 1,5х размера машинного кода Objective-C, и в конце концов Apple снова подняла опциональный лимит до 200МБ. Этого достаточно, чтобы нам продержаться ещё несколько лет.

Но мы едва не потерпели неудачу. Если бы Apple не увеличила лимит, пришлось бы переписывать приложение Uber обратно на ObjC. В конце концов, мы смогли решить и другие проблемы. Блестящий @alanzeino с его командой добились включения поддержки Swift в инструмент сборки Buck, что значительно уменьшило время сборки.

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

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

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

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

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

Как подружить RxJava с VIPER в Android, подходы применения и о структуре планировщиков

23.07.2020 18:11:16 | Автор: admin
image

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

Архитектура, которая подойдет всем


RxJava это реализация концепции ReactiveX, а создала эту реализацию компания Netflix. В их блоге есть цикл статей о том, зачем они это сделали и какие проблемы они решили. Ссылки (1, 2) вы найдете в конце статьи. Netflix использовали RxJava на стороне сервера (backend), чтобы распараллелить обработку одного большого запроса. Хотя они предложили способ применения RxJava на backend, такая архитектура подойдет для написания разных типов приложений (мобильные, desktop, backend и многих других). Разработчики Netflix использовали RxJava в сервисном слое таким образом, чтобы каждый метод сервисного слоя возвращал Observable.

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

/** * Метод, который сразу возвращает значение, если оно * доступно, или использует  другой поток исполнения, * чтобы получить значение и передать его через callback `onNext()` */public Observable<T> getProduct(String name) {    if (productInCache(name)) {        // Если данные доступны, возвращаем их сразу        return Observable.create(observer -> {           observer.onNext(getProductFromCache(name));           observer.onComplete();        });    } else {        // Иначе задействуем другой поток исполнения        return Observable.<T>create(observer -> {            try {                // Выполняем работу в отдельном потоке                T product = getProductFromRemoteService(name);                // вовращаем значение                observer.onNext(product);                observer.onComplete();            } catch (Exception e) {                observer.onError(e);            }        })        // Говорим Observable использовать планировщик IO        // для создания/получения данных        .subscribeOn(Schedulers.io());    }}

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

Подход применим не только в сервисном слое на backend, но и в архитектурах MVC, MVP, MVVM и др. Например, для MVP мы можем сделать класс Interactor, который будет ответственным за получение и сохранение данных в различные источники, и сделать так, чтобы все его методы возвращали Observable. Они будут являться контрактом взаимодействия с Model. Это также даст возможность использовать в Presenter всю мощь операторов, имеющихся в RxJava.

image

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

Дальше посмотрим на пример применения такого подхода для архитектуры VIPER, которая является усовершенствованным MVP. Также стоит помнить, что нельзя делать Observable singleton объектами, потому что подписки к таким Observable будут порождать утечки памяти.

Опыт применения в Android и VIPER


В большинстве текущих и новых Android проектов мы используем архитектуру VIPER. Я познакомился с ней, когда присоединился к одному из проектов, в котором она уже использовалась. Помню, как удивился, когда у меня спросили, не смотрел ли я в сторону iOS. iOS в Android проекте?, подумал я. А между тем, VIPER пришел к нам из мира iOS и по сути является более структурированной и модульной версией MVP. О VIPER очень хорошо написано в этой статье (3).

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

Дело в том, что мы использовали Interactor так же, как и коллеги в своей статье. Interactor реализует небольшой use case, например, скачать продукты из сети или взять продукт из БД по id, и выполняет действия в рабочем потоке. Внутри себя Interactor совершает операции, используя Observable. Чтобы запустить Interactor и получить результат, пользователь реализует интерфейс ObserverEntity вместе с его методами onNext, onError и onComplete и передает его вместе с параметрами в метод execute(params, ObserverEntity).

Вы, наверное, уже заметили проблему структура интерфейса. На практике нам редко нужны все три метода, часто используются один или два из них. Из-за этого в коде могут встречаться пустые методы. Конечно, мы можем пометить все методы интерфейса default, но такие методы скорее нужны для добавления новой функциональности в интерфейсы. К тому же, странно иметь интерфейс, все методы которого опциональны. Мы также можем, например, создать абстрактный класс, который наследует интерфейс, и переопределять нужные нам методы. Или, наконец, создать перегруженные версии метода execute(params, ObserverEntity), которые принимают от одного до трех функциональных интерфейсов. Эта проблема плохо сказывается на читаемости кода, но, к счастью, довольно просто решается. Однако, она не единственная.

saveProductInteractor.execute(product, new ObserverEntity<Void>() {    @Override    public void onNext(Void aVoid) {        // Сейчас этот метод нам не нужен,        // но мы обязана его реализовать    }    @Override    public void onError(Throwable throwable) {        // Сейчас этот метод используется        // Какой-то код    }    @Override    public void onComplete() {        // И этот метод тоже используется        // Какой-то код    }});

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

private void checkProduct(int id, Locale locale) {    getProductByIdInteractor.execute(new TypesUtil.Pair<>(id, locale), new ObserverEntity<Product>() {        @Override        public void onNext(Product product) {            getProductInfo(product);        }        @Override        public void onError(Throwable throwable) {            // Какой-то код        }        @Override        public void onComplete() {        }    });}private void getProductInfo(Product product) {    getReviewsByProductIdInteractor.execute(product.getId(), new ObserverEntity<List<Review>>() {        @Override        public void onNext(List<Review> reviews) {            product.setReviews(reviews);            saveProduct(productInfo);        }        @Override        public void onError(Throwable throwable) {            // Какой-то код        }        @Override        public void onComplete() {            // Какой-то код        }    });    getImageForProductInteractor.execute(product.getId(), new ObserverEntity<Image>() {        @Override        public void onNext(Image image) {            product.setImage(image);            saveProduct(product);        }        @Override        public void onError(Throwable throwable) {            // Какой-то код        }        @Override        public void onComplete() {        }    });}private void saveProduct(Product product) {    saveProductInteractor.execute(product, new ObserverEntity<Void>() {        @Override        public void onNext(Void aVoid) {        }        @Override        public void onError(Throwable throwable) {            // Какой-то код        }        @Override        public void onComplete() {            goToSomeScreen();        }    });}

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

Решение на удивление простое. Вы чувствуете, что этот подход пытается повторить поведение Observable, но делает это неправильно и сам создает непонятные ограничения? Как я уже рассказывал раньше, этот код достался нам из уже существующего проекта. При исправлении этого legacy-кода будем использовать подход, который завещали нам ребята из Netflix. Вместо того, чтобы каждый раз реализовывать ObserverEntity, заставим Interactor просто возвращать Observable.

private Observable<Product> getProductById(int id, Locale locale) {    return getProductByIdInteractor.execute(new TypesUtil.Pair<>(id, locale));}private Observable<Product> getProductInfo(Product product) {    return getReviewsByProductIdInteractor.execute(product.getId())    .map(reviews -> {        product.set(reviews);        return product;    })    .flatMap(product -> {        getImageForProductInteractor.execute(product.getId())        .map(image -> {            product.set(image);            return product;        })    });}private Observable<Product> saveProduct(Product product) {    return saveProductInteractor.execute(product);}private doAll(int id, Locale locale) {    // Берем продукт из хранилища    getProductById (id, locale)    // Добавляем информацию    .flatMap(product -> getProductInfo(product))    // Сохраняем все в другое хранилище    .flatMap(product -> saveProduct(product))    // После сохранения продукты в потоке больше не нужны    .ignoreElements()    // Устанавливаем планировщики    .subscribeOn(Schedulers.io())    .observeOn(AndroidSchedulers.mainThread())    // Переходим на другой экран    .subscribe(() -> goToSomeScreen(), throwable -> handleError());}

Вуаля! Так мы не только избавились от того громоздкого и неповоротливого ужаса, но и привнесли мощь RxJava в Presenter.

Концепции в основе


Я довольно часто встречал, как с помощью функционального реактивного программирования (далее ФРП) пытались объяснить концепцию RxJava. На самом деле, оно никак не связано с этой библиотекой. ФРП больше о непрерывных динамически изменяемых значениях (поведениях), непрерывном времени и денотационной семантике. В конце статьи вы сможете найти пару интересных ссылок (4, 5, 6, 7).

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

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

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

image

Три кита RxJava


Основные три компонента, на которых строится RxJava Observable, операторы и планировщики.
Observable в RxJava отвечает за реализацию реактивной парадигмы. Observable часто называют потоками, так как они реализуют как концепцию потоков данных, так и распространение изменений. Observable это тип, который достигает реализации реактивной парадигмы за счет объединения в себе двух шаблонов из книги Gang of Four: Observer и Iterator. Observable добавляет в Observer две отсутствующие семантики, которые есть в Iterable:

  • Возможность для производителя сигнализировать потребителю о том, что больше нет доступных данных (цикл foreach в Iterable завершается и просто возвращается; Observable в этом случае вызывает метод onCompleate).
  • Возможность для производителя сообщать потребителю, что произошла ошибка и Observable больше не может испускать элементы (Iterable бросает исключение, если во время итерации возникает ошибка; Observable вызывает метод onError у своего наблюдателя и завершается).

Если Iterable использует pull подход, то есть потребитель запрашивает значение у производителя, и поток исполнения блокируется до тех пор, пока это значение не прибудет, то Observable является его push эквивалентом. Это значит, что производитель отправляет значения потребителю, только когда они становятся доступны.

Observable это только начало RxJava. Он позволяет асинхронно получать значения, но настоящая мощь приходит с реактивными расширениями (отсюда ReactiveX) операторами, которые позволяют преобразовывать, комбинировать и создавать последовательности элементов, испускаемых Observable. Именно тут и выходит на передний план функциональная парадигма со своими чистыми функциями. Операторы используют эту концепцию в полной мере. Они позволяют безопасно работать с последовательностями элементов, которые испускает Observable, не боясь побочных эффектов, если, конечно, не создать их самостоятельно. Операторы позволяют применять многопоточность, не заботясь о таких проблемах как потокобезопасность, низкоуровневое управление потоками, синхронизация, ошибки некосистентности памяти, наложение потоков и т.д. Имея большой арсенал функций, можно легко оперировать различными данными. Это дает нам очень мощный инструмент. Главное помнить, что операторы модифицируют элементы, испускаемые Observable, а не сами Observable. Observable никогда не изменяются с момента их создания. Размышляя о потоках и операторах, лучше всего думать диаграммами. Если вы не знаете, как решить задачу, то подумайте, посмотрите на весь список доступных операторов и подумайте еще.

Хотя сама по себе концепция реактивного программирования является асинхронной (не путайте с многопоточностью), по умолчанию все элементы в Observable доставляются подписчику синхронно, в том же потоке, в котором был вызван метод subscribe(). Чтобы привнести ту самую асинхронность, нужно либо самостоятельно вызывать методы onNext(T), onError(Throwable), onComplete() в другом потоке исполнения, либо использовать планировщики. Обычно все разбирают их поведение, так что давайте посмотрим на их устройство.

Планировщики абстрагируют пользователя от источника параллелизма за собственным API. Они гарантируют, что будут предоставлять определенные свойства, независимо от лежащего в основе механизма параллельности (реализации), например, Threads, event loop или Executor. Планировщики используют daemon потоки. Это означает, что программа завершится вместе с завершением основного потока исполнения, даже если происходят какие-то вычисления внутри оператора Observable.

В RxJava есть несколько стандартных планировщиков, которые подходят для определенных целей. Все они расширяют абстрактный класс Scheduler и реализуют собственную логику управлением workers (рабочими). Например, планировщик ComputationScheduler во время своего создания формирует пул рабочих, количество которых равно количеству процессорных потоков. После этого ComputationScheduler использует рабочих для выполнения Runnable задач. Вы можете передать Runnable планировщику с помощью методов scheduleDirect() и schedulePeriodicallyDirect(). Для обоих методов планировщик берет очередного рабочего из пула и передает ему Runnable.

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

Например, в планировщике ComputationScheduler рабочий реализован с помощью ScheduledExecutorService размером в один поток.

image

Таким образом, мы имеем абстрактных рабочих, которые могут реализовывать любую схему параллельности. Такой подход дает много плюсов: модульность, гибкость, один API, различные реализации. Похожий подход мы видели в ExecutorService. К тому же, мы можем использовать планировщики отдельно от Observable.

Заключение


RxJava очень мощная библиотека, которую можно использовать самыми разными способами во множестве архитектур. Способы ее применения не ограничиваются уже существующими, поэтому всегда пробуйте адаптировать ее под себя. Однако помните о SOLID, DRY и других принципах разработки, а также не забывайте делиться с коллегами своим опытом применения. Надеюсь, что вы смогли почерпнуть из статьи что-то новое и интересное, до встречи!

  1. Причины, почему Netflix начали использовать ReactiveX
  2. Презентация RxJava интернет-сообществу
  3. Объяснение архитектуры VIPER и пример применения
  4. Объяснение ФРП от его создателя
  5. Разница между ФРП и реактивным программированием
  6. Рассуждение о ФРП
  7. Блог Conal Elliot о ФРП
Подробнее..

RxRelay это магия? Subject vs RxRelay

03.08.2020 10:14:39 | Автор: admin


В Android-комьюнити я встречал три типа разработчиков, которые сталкивались с RxRelay:
  1. Те, кто не понимают зачем RxRelay используется в их проекте и зачем он нужен, чем отличается от Subject
  2. Те, кто думают, что RxRelay проглатывает ошибки или после того как произошла ошибка RxRelay продолжит работать, а Subject нет (та самая магия)
  3. Те, кто действительно знает, что такое RxRelay.

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

Если вы используете RxJava, то вероятно вы пользуетесь Subject или RxRelay, чтобы прокидывать события из одной сущности в другую или делать из императивного кода реактивный.

Давайте проверим пункт 2 и посмотрим, в чем разница между RxRelay и Subject. Итак, у нас есть две подписки на один relay, при клике на кнопку мы пушим единицу в этот relay.

class MainActivity : AppCompatActivity() {   private val relay = PublishRelay.create<Int>()   private var isError: Boolean = false   override fun onCreate(savedInstanceState: Bundle?) {       super.onCreate(savedInstanceState)       setContentView(R.layout.activity_main)       val disposable1 = relay           .map {               if (isError) {                   isError = false                   throw Exception()               } else {                   isError = true               }           }.subscribe(               {                   Log.d("test", "Цепочка с ошибкой: onNext")               },               {                   Log.d("test", "Цепочка с ошибкой: onError")               }           )       val disposable2 = relay           .subscribe(               {                   Log.d("test", "Цепочка без ошибки: onNext")               },               {                   Log.d("test", "Цепочка без ошибки: onError")               }           )       btn.setOnClickListener {           relay.accept(1)       }   }}


Три раза подряд кликаем на кнопку и видим вот такой лог.
D/test: Цепочка с ошибкой: onNext
D/test: Цепочка без ошибки: onNext

D/test: Цепочка с ошибкой: onError
D/test: Цепочка без ошибки: onNext

D/test: Цепочка без ошибки: onNext

Если заменить переменную RxRelay на PublishSubject, лог не изменится. И вот почему:

При первом клике мы пушим в наш relay данные. Оба подписчика срабатывают.
При втором клике в цепочке у первого подписчика (disposable1) возникает ошибка.
При третьем клике первый disposable1 уже не срабатывает, так как он получил терминальное состояние onError. Дальше будет работать только второй disposable2.
Так будет и с Subject, и с RxRelay. Напомню, что в rx ошибки идут вниз по цепочке к подписчику (downstream) и выше места, где они возникли, не попадают. В итоге мы проверили, что цепочка на основе RxRelay не может работать после того, как возникла ошибка.

Так если разницы в поведении Subject и RxRelay нет, то в чем их отличие?

Вот что пишет сам разработчик в README на гитхабе:
Basically: A Subject except without the ability to call onComplete or onError.
То есть это просто Subject без методов onComplete и onError, даже исходный код классов почти одинаковый. Если мы вызовем на Subject эти методы, то он перестанет работать, так как получит терминальное состояние. Поэтому автор библиотеки решил, что стоит убрать эти методы, потому что те разработчики, которые не знают об этом свойстве Subject могут случайно вызвать их.

Вывод: единственное отличие RxRelay от Subject это отсутствие двух методов onComplete и onError, чтобы разработчик не мог вызвать терминальный стейт.
Подробнее..

Инициализация Rx цепочки

31.05.2021 14:10:41 | Автор: admin

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

В этой статье я не буду описывать что такое Observable, Observer и все остальные сущности, которые используются в RxJava2. Если вы решили прочитать данную статью, то я предполагаю, что вы уже знакомы с этой информацией. А если вы всё же не знакомы с этими понятия, то я рекомендую перед началом чтения ознакомиться с ними.

Вот с чего можно начать:

Грокаем* RxJava

Исследуем RxJava 2 для Android

Давайте посмотрим, как работает простейшая цепочка:

Observable.just (1, 2, 3, 4, 5).map {}.filter {}.subscribe();

По верхам

Сначала опишу вкратце каждый шаг, через который пройдем в этой цепочке (шаги начинаются сверху вниз):

  • Создается объект в операторе just ObservableFromArray.

  • Создается объект в операторе map ObservableMap, который принимает в конструктор ссылку на ранее созданный объект в операторе just.

  • Создается объект в операторе filter ObservableFilter, который принимает в конструктор ссылку на ранее созданный объект в map, в котором уже хранится ссылка на just.

  • После создания всех Observableов у последнего Observable в цепочки вызывается метод subscribe() (в нашем случае это ObservableFilter созданный в операторе filter) в котором создается новый Observer, который и будет обрабатывать все полученные события.

  • В методе ObservableFilter.subscribe() вызывается следующий метод ObservableFilter.subscribeActual(), в котором создается внутренний Observer, в случае с оператором filter, это FilterObserver. В этот внутренний Observer передается ссылка на первый созданный Observer в ObservableFilter.subscribe().

  • Вызывается ObservableMap.subscribe() в котором так же вызывается ObservableMap.subscribeActual()и создается внутренний Observer, в случае с оператором map, это MapObserver, в который передается ссылка на FilterObserver.

  • Вызывается ObservableFromArray.subscribe() и после ObservableFromArray.subscribeActual(), и уже там вызывается метод onSubscribe()у переданного в ObservableFromArray.subscribeActual() Observerа.

  • onSubscribe() вызывается у каждого нижележащего Observerа в цепочке.

  • ObservableFromArray начинает излучать все события в метод onNext() нижележащего Observerа.

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

Создание источников данных

Теперь давайте рассмотрим описанные выше шаги подробнее, сначала попадаем в метод just() где происходит проверка каждого значения на null, далее идет вызов метода fromArray(), который возвращает Observable.

public static <T> Observable<T> just(T item1, T item2, T item3, T item4, T item5) {   ObjectHelper.requireNonNull(item1, "item1 is null");   ObjectHelper.requireNonNull(item2, "item2 is null");   ObjectHelper.requireNonNull(item3, "item3 is null");   ObjectHelper.requireNonNull(item4, "item4 is null");   ObjectHelper.requireNonNull(item5, "item5 is null");   return fromArray(item1, item2, item3, item4, item5);}

В fromArray() проверяется, что метод принимает в себя не пустой массив и имеет больше одного элемента.

public static <T> Observable<T> fromArray(T... items) {   ObjectHelper.requireNonNull(items, "items is null");   if (items.length == 0) {       return empty();   }   if (items.length == 1) {       return just(items[0]);   }   return RxJavaPlugins.onAssembly(new ObservableFromArray<T>(items));}

После прохода через все эти шаги создается новый экземпляр класса ObservableFromArray, который на вход принимает массив с данными.

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

public static <T> Observable<T> onAssembly(@NonNull Observable<T> source) {   Function<? super Observable, ? extends Observable> f = onObservableAssembly;   if (f != null) {       return apply(f, source);   }   return source;}

onAssembly() проверяет хотим ли перехватить текущий Observable и как-то модифицировать его, например таким образом:

RxJavaPlugins.setOnObservableAssembly(o -> {if (o instanceof ObservableFromArray) {    return new ObservableFromArray<>(new Integer[] { 4, 5, 6 });}return o;}); Observable.just(1, 2, 3).filter(v -> v > 3).test().assertResult(4, 5, 6);
Только что созданный ObservableFromArrayТолько что созданный ObservableFromArray

Вызывается следующий оператор в цепочке map(). Оператор проходит почти через те же шаги, что были описаны выше. Сначала делается проверка на null, потом создается новый экземпляр класса ObservableMap.

public final <R> Observable<R> map(Function<? super T, ? extends R> mapper) {   ObjectHelper.requireNonNull(mapper, "mapper is null");   return RxJavaPlugins.onAssembly(new ObservableMap<T, R>(this, mapper));}

Вот тут появляется небольшое отличие, в конструктор ObservableMap передается не только mapper, который будет преобразовывать одно значение в другое, но также принимает в конструктор this (source). В данном случае this это ссылка на экземпляр класса ObservableFromArray созданный на предыдущем шаге. ObservableMap расширяет абстрактный класс AbstractObservableWithUpstream, в котором и храниться source.

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

Далее происходит вызов метода onAssembly() и возвращение созданного Observable.

Обновленная схема с созданным ObservableMapОбновленная схема с созданным ObservableMap

Переходим к следующему оператору в цепочки filter(). В нем не происходит ничего нового, за исключением того, что создается объект ObservableFilter и в его конструктор в this передается ссылка на экземпляр ObservableMap (у которого уже есть ссылка на ObservableFromArray, как показано на схеме выше) созданный на предыдущем шаге.

public final Observable<T> filter(Predicate<? super T> predicate) {   ObjectHelper.requireNonNull(predicate, "predicate is null");   return RxJavaPlugins.onAssembly(new ObservableFilter<T>(this, predicate));}
Обновленная схема с созданным ObservableFilterОбновленная схема с созданным ObservableFilter

Начало подписки

Последний оператор в цепочке subscribe(), который вызывает перегруженную версию метода. В нашем случае обрабатывается только onNext(). Метод subscribe() вызывается у ObservableFilter, который был последним созданным Observable в цепочке.

public final Disposable subscribe(Consumer<? super T> onNext) {   return subscribe(onNext, Functions.ON_ERROR_MISSING, Functions.EMPTY_ACTION, Functions.emptyConsumer());}

В перегруженном методе сначала проверяются все переданные параметры на null, далее создается объект класса LambdaObserver и происходит подписка.

public final Disposable subscribe(Consumer<? super T> onNext, Consumer<? super Throwable> onError,       Action onComplete, Consumer<? super Disposable> onSubscribe) {   ObjectHelper.requireNonNull(onNext, "onNext is null");   ObjectHelper.requireNonNull(onError, "onError is null");   ObjectHelper.requireNonNull(onComplete, "onComplete is null");   ObjectHelper.requireNonNull(onSubscribe, "onSubscribe is null");   LambdaObserver<T> ls = new LambdaObserver<T>(onNext, onError, onComplete, onSubscribe);   subscribe(ls);   return ls;}

А вот и сам метод в котором и происходит подписка.

public final void subscribe(Observer<? super T> observer) {   ObjectHelper.requireNonNull(observer, "observer is null");   try {       observer = RxJavaPlugins.onSubscribe(this, observer);       ObjectHelper.requireNonNull(observer, "The RxJavaPlugins.onSubscribe hook returned a null Observer. Please change the handler provided to RxJavaPlugins.setOnObservableSubscribe for invalid null returns. Further reading: https://github.com/ReactiveX/RxJava/wiki/Plugins");       subscribeActual(observer);   } catch (NullPointerException e) {      ...... }}

В методе subscribeActual() производится подписка на источник данных и в него же передается созданный ранее LambdaObserver. subscribeActual() вызывается в классе ObservableFilter. И вот что там происходит.

public void subscribeActual(Observer<? super T> observer) {   source.subscribe(new FilterObserver<T>(observer, predicate));}

Создается новый объект класса FilterObserver, который принимает в конструктор LambdaObserver созданный ранее и предикат для фильтрации, которые хранится в ObservableFilter в виде поля класса.

Класс FilterObserver расширяет класс BasicFuseableObserver, в котором уже реализован метод onSubscribe(). BasicFuseableObserver это абстрактный класс, который реализуют промежуточные Observerы. Если посмотреть исходники, то его реализуют только 6 классов, два из которых это FilterObserver и MapObserver. В методе BasicFuseableObserver.onSubscribe() также вызывается метод onSubscribe() у нижележащего Observerа, который передавался в конструктор этого класса. А выглядит это вот так:

public final void onSubscribe(Disposable d) {   if (DisposableHelper.validate(this.upstream, d)) {       this.upstream = d;       if (d instanceof QueueDisposable) {           this.qd = (QueueDisposable<T>)d;       }       if (beforeDownstream()) {           downstream.onSubscribe(this);           afterDownstream();       }   }}

После того как подписались на ObservableFilter и создали объект FilterObserver, он передается в source.subscribe(). Хочу напомнить, что source это объект класса ObservableMap, переданный ранее в цепочке. У объекта ObservableMap вызывается метод subscribe().

public final void subscribe(Observer<? super T> observer) {   ObjectHelper.requireNonNull(observer, "observer is null");   try {       observer = RxJavaPlugins.onSubscribe(this, observer);       ObjectHelper.requireNonNull(observer, "The RxJavaPlugins.onSubscribe hook returned a null Observer. Please change the handler provided to RxJavaPlugins.setOnObservableSubscribe for invalid null returns. Further reading: https://github.com/ReactiveX/RxJava/wiki/Plugins");       subscribeActual(observer);   } catch (NullPointerException e) {      ...... }}

Далее происходит те же шаги, в методе subscribe() вызывается subscribeActual(), оба этих метода вызываются у ObservableMap. В subscribeActual() создается новый MapObserver с переданным в качестве параметра экземпляром FilterObserver и функцией mapperа.

public void subscribeActual(Observer<? super U> t) {   source.subscribe(new MapObserver<T, U>(t, function));}
public void subscribeActual(Observer<? super T> observer) {   FromArrayDisposable<T> d = new FromArrayDisposable<T>(observer, array);   observer.onSubscribe(d);   if (d.fusionMode) {       return;   }   d.run();}

Все рассмотренные Observerы расширяли абстрактный класс BasicFuseableObserver, в котором уже реализован метод onSubscribe() и так же есть ссылка на нижележащий Observer, у которого так же вызывается метод onSubscribe().

В конце метода subscribeActual() вызывается метод run(), в котором и начинается излучение всех данных в нижележащие Observerы.

void run() {   T[] a = array;   int n = a.length;   for (int i = 0; i < n && !isDisposed(); i++) {       T value = a[i];       if (value == null) {           downstream.onError(new NullPointerException("The element at index " + i + " is null"));           return;       }       downstream.onNext(value);   }   if (!isDisposed()) {       downstream.onComplete();   }}

Соответственно вызываются onNext() для передачи значений в нижележащие Observerы и потом onComplete() при завершении излучения данных, либо может произойти ошибка и вызовется onError(), который завершит всю цепочку.

Визуальное представление процесса создания и подписокВизуальное представление процесса создания и подписок

Вывод

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

Метод onSubscribe() вызывается до начала отправки данных и это надо иметь ввиду если вы пользуетесь такими оператора как doOnSubscribe().

На каждый оператор создается как минимум 3 объекта:

  • Анонимный класс передаваемый в оператор

  • Observable создаваемый внутри оператора

  • Observer обрабатывающий получаемые данные

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

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

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru