Как стать автором
Обновить
70.27
Surf
Создаём веб- и мобильные приложения

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

Время на прочтение17 мин
Количество просмотров7.9K
Привет! Меня зовут Вита Соколова, я 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()
            )
        )
    }


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

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

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

/**
 * Завершение работы интерактора
 */
@CallSuper
open 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 заведём под черновик дополнительное поле.

/**
 * Интерактор фичи подачи заявления
 */
@PerApplication
class 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 — за помощь в доведении чернового варианта архитектуры до финальной версии, которая и описана в этой статье.

Больше полезного про Android — в нашем телеграм-канале Surf Android Team. Здесь мы публикуем кейсы, лучшие практики, новости и вакансии Surf, а также проводим прямые эфиры. Присоединяйтесь!
Теги:
Хабы:
+8
Комментарии5

Публикации

Информация

Сайт
surf.ru
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия