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

    Привет! Меня зовут Вита Соколова, я 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 — за помощь в доведении чернового варианта архитектуры до финальной версии, которая и описана в этой статье.
    Surf
    Мобильные приложения и цифровая трансформация

    Комментарии 5

      +1

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


      Обвяз вокруг базовой структуры данных любопытен (интеракторы, сохранение, нотификации), однако корневая проблема, заявленная в начале статьи, выглядит нерешённой. Её суть, как Вы правильно отметили, в неудовлетворительном контракте, предоставляемой объектом «Заявка». Цитирую: «Конечно, все эти данные (разные куски заявки, собираемые на разных шага визарда – прим.) стоит упаковать в один объект заявки. Работая с таким объектом, мы обрекаем наш код покрыться лишним ненужным количеством проверок null. Например, такая структура данных никак не гарантирует, что поле educationType уже будет заполнено на экране «Образование»:


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

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


      /**
       * Черновик заявки
       */
      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()
         }
      }

      Мало того, что мы по-прежнему не можем быть уверены в наличии полей (таков контракт словаря MutableMap), так ввиду потери информации о типе из-за приведения всего к ApplicationStepOutData мы вдобавок без юнит-тестов ещё и не можем быть уверены, что они правильного типа. Это красноречиво демонстрирует следующий код из статьи:


      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") // <==== THIS
                     ),
                     outData = draft.getEducationStepOutData()
                 )
             )
             // ...
         }
      }

      Корень зла на мой взгляд в недостаточном моделировании самой сути предметной области. Под катом предлагаю своё решение корневой проблемы (на более близком мне Swift).


      Steps model (in Swift)
      import Foundation
      
      struct PassedStep<Input, Output> {
          let input: Input
          let output: Output
      }
      
      extension PassedStep where Input == Void {
          init(output: Output) {
              self.input = ()
              self.output = output
          }
      }
      
      struct ActiveStep<Input, Output> {
          typealias Result = PassedStep<Input, Output>
      
          let input: Input
      
          func pass<NextOutput>(with output: Output) -> ActiveStep<Result, NextOutput> {
              ActiveStep<Result, NextOutput>(input: Result(input: input, output: output))
          }
      }
      
      extension ActiveStep where Input == Void {
          init() {
              self.input = ()
          }
      }
      
      struct A {}
      struct B {}
      struct C {}
      
      struct Result {
          let a: A
          let b: B
          let c: C
      }
      
      typealias StepA = ActiveStep<Void, A>
      typealias StepB = ActiveStep<StepA.Result, B>
      typealias StepC = ActiveStep<StepB.Result, C>
      typealias FinalStep = ActiveStep<StepC.Result, Void>
      
      // Flow implrmrnted in some scenario class
      // Asynchronous step completions replaced with synchrounous ones for clarity
      let stepA = StepA()
      let stepB: StepB = stepA.pass(with: A())
      let stepC: StepC = stepB.pass(with: B())
      let finalStep: FinalStep = stepC.pass(with: C())
      let result = Result(
          a: finalStep.input.input.input.output,
          b: finalStep.input.input.output,
          c: finalStep.input.output
      )

      Её особенность как раз в том, что на каждом шаге визарда мы имеем состояние, контракт которого однозначно определяет, какие поля существуют, а какие нет. Работа с состоянием как полностью типобезопасна, так и лишена проверок на существование полей. В зависимости от требования на вход каждой странице, которая выполняет роль билдера объектов доменной модели (они же куски Application, представленные в листинге в виде классов A, B, C), можно подавать либо весь объект состояния, содержащей заполненные на предыдущих шагах куски, либо оставить знания о них только в классе Scenario, либо что-то среднее.

        +1
        Очень приятно, что вас заинтересовала статья и её проблематика. Постараюсь имплементировать ваше решение в репозитории с примером в свободный вечер как еще один вариант развития этого подхода и в случае успеха сослаться на это в статье.
        Когда я писала о том, что мы избавимся от бесполезных nullable проверок и заведем жесткие контракты, какие данные обязательный на вход каждому шагу, я скорее держала в голове ситуацию, что каждому фрагменту в Bundle передали объект Application, который не очень отражает, какие данные для этого шага обязательно уже должны быть заполнены.
        Если создавать цепочки «для открытия шага В должен сначала завершиться предыдущий шаг А и предоставить свои выходные данные Х», то мы теряем возможность легко менять шаги местами, что тоже заявлено как одно из достоинств подхода. Мы должны знать, что нам нужен Х, но не быть жестко связаны с тем, кто его предоставляет. Поэтому вопрос организации хранения выходных данных и предоставления их на вход другим шагам действительно очень интересный и, как вы верно заметили, не особо развит в данной версии. Это аспект можно ещё улучшать и улучшать. Спасибо, что спроектировали возможное решение.

        Касательно выкидывания исключения
        draft.getPersonalInfoOutData()?.info?.educationType
        ?: error("Not enough data for EDUCATION step") // <==== THIS
        

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

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


          Не знаю, почему, но всякий раз, когда я вижу материал об архитектурах в мобильной разработке (особенно, Android), складывается впечатление о "решении тривиальных задач нетривиальным образом", и первая мысль: "зачем так сложно все реализовывать, ведь можно было значительно проще"? Это касаемо моделей данных, интеракторов и прочих радостей современности. Как-то без них раньше обходились (занимаюсь платформой Android с 2013 года), и было не так уж плохо.


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

            0

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

            +1

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


            Сейчас, например, при более сложном сценарии ваш класс ApplicationScenario превратится в месиво условий..


            Почему не рассмотрели вариант со стейт машиной?

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

            Самое читаемое