Как стать автором
Обновить

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

Время на прочтение 17 мин
Количество просмотров 7.9K
Всего голосов 8: ↑8 и ↓0 +8
Комментарии 5

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

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


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

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

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

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

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

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


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


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

Зарегистрируйтесь на Хабре , чтобы оставить комментарий