在移动应用程序中,存在带有复杂的多步骤填充的表单-例如,问卷或应用程序。此类功能的设计通常会使开发人员感到头疼:在屏幕之间传输大量数据并形成刚性连接-谁,向谁,应以什么顺序传输这些数据以及下一步打开哪个屏幕。
在本文中,我将分享一种方便的方法来组织逐步功能的工作。借助它的帮助,可以最大程度地减少屏幕之间的连接并轻松地更改步骤顺序:添加新屏幕,更改其顺序和向用户显示的逻辑。
*本文中的“功能”一词是指移动应用程序中的一组屏幕,这些屏幕在逻辑上相互连接,并为用户表示一个功能。
通常填写问卷并在移动应用程序中提交应用程序包含几个顺序的屏幕。一个屏幕上的数据可能需要另一个屏幕上的数据,步骤链有时会根据答案而变化。因此,允许用户“草稿中”保存数据是有用的,以便他以后可以返回到该过程。
可以有许多屏幕,但是实际上用户用数据填充了一个大对象。在本文中,我将告诉您如何方便地通过一系列场景来组织工作。
假设某位用户正在申请工作并填写表格。如果在中间中断,则输入的数据将保存在草稿中。当用户返回填写时,草稿中的信息将自动替换为调查表的字段-他不需要从头开始填写所有内容。
当用户填写整个问卷时,他的答复将发送到服务器。
问卷包括:
- 第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>?
)
但!
使用这样的对象,我们注定了我们的代码将被多余的空检查所覆盖。例如,该数据结构不以任何方式保证该字段
educationType
将已经在“教育”屏幕上填写。
如何做得更好
我建议将数据管理移到一个单独的对象中,该对象将在每个步骤的输入处提供必要的非空数据,并将每个步骤的结果保存到草稿中。我们将这个对象称为交互器。它对应于来自Robert Martin的纯体系结构的用例层,并且所有屏幕都负责提供从各种来源收集的数据(网络,数据库,先前步骤的数据,提案草案的数据...)。
在我们的项目中,Surf使用Dagger。由于多种原因,通常将交互器的作用域限定为@PerApplication:这使我们的交互器在应用程序内成为单例。实际上,如果您的所有步骤都是片段,则交互器可以是要素中的单个实例,甚至可以是激活中的单个实例。这完全取决于您的应用程序的整体体系结构。
进一步在示例中,我们将假定整个应用程序只有一个交互器实例。因此,脚本结束时必须清除所有数据。
设置任务时,除了集中式数据存储外,我们还希望对应用程序的组成和步骤顺序进行简单的管理:根据用户已经填写的内容,它们可以更改。因此,我们还需要一个实体-方案。她的职责范围是保持用户必须执行的步骤顺序。
使用脚本和交互器的逐步功能的组织允许:
- 毫不费力地更改脚本中的步骤:例如,如果在执行过程中发现用户无法提交请求或需要更多信息,则可以添加其他步骤来重叠进一步的工作。
- 设置合同:每个步骤的输入和输出必须包含哪些数据。
- 如果用户未完成所有屏幕的显示,则将应用程序保存为草稿。
预填屏幕,其中数据保存在草稿中。
基本实体
该功能的机制将包括:
- 一组用于描述步骤,输入和输出的模型。
- 场景-描述用户需要经历哪些步骤(屏幕)的实体。
- Interaktora(ProgressInteractor)-负责存储有关当前活动步骤的信息,在完成每个步骤后汇总已填充的信息并发布输入数据以开始新步骤的类。
- 草稿(ApplicationDraft)-负责存储填充信息的类。
类图表示将从中继承具体实现的所有基础实体。让我们看看它们之间的关系。
对于Scenario实体,我们将设置一个接口,在其中描述我们对应用程序中的任何场景期望的逻辑(包含必要步骤的列表,并在完成上一步骤后重建(如有必要)。
该应用程序可能具有多个功能,包括许多连续的屏幕,并且每个屏幕都有)。我们将所有不依赖功能或特定数据的通用逻辑移至基类ProgressInteractor中。
基类中不存在ApplicationDraft,因为可能不需要将用户填写的数据保存到草稿中。因此,ProgressInteractor的具体实现将与草案一起使用。屏幕演示者还将与之互动。
基本类的特定实现的类图:
所有这些实体都将彼此交互,并与屏幕呈现器进行如下交互:有
很多类,因此让我们从本文开始就使用功能分别分析每个块。
步骤说明
让我们从第一点开始。我们需要实体来描述步骤:
// , ,
interface Step
对于我们的职位申请示例中的功能,步骤如下:
/**
*
*/
enum class ApplicationSteps : Step {
PERSONAL_INFO, //
EDUCATION, //
EXPERIENCE, //
ABOUT_ME, // " "
MOTIVATION //
}
我们还需要描述每个步骤的输入数据。为此,我们将密封类用于其预期目的-创建有限的类层次结构。
它在代码中的外观
:
//
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()
}
我们按照剧本行事
随着步骤的描述和输入/输出数据的整理。现在,让我们在代码中的功能脚本中固定这些步骤的顺序。方案实体负责管理当前步骤顺序。该脚本将如下所示:
/**
* , ,
*/
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
)
}
}
交互器如何工作
考虑逐步功能体系结构中的下一个构建块-交互器。如上所述,其主要职责是为步骤之间的切换提供服务:将必要的数据作为输入提供给步骤,并将输出数据汇总到请求草稿中。
让我们为交互器创建一个基类,并将所有逐步功能共有的行为放入其中。
/**
*
* 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()方法。这使我们可以方便地从主题访问此信息。
它在代码中的外观
//
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(步骤: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)
您可以通过将方法设为public来简化此代码
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年以来最受欢迎的轶事的集合。
通过仅产生结果(数据或错误)的单独的交互器方法完成聚合时,演示者代码会容易得多。如果可以立即清楚地查找所有内容,则开发人员可以更轻松地进行更改和调整。
但这还不是交互器中的全部。当然,我们需要一种方法来发送最终的应用程序-所有步骤都通过后。让我们描述最终的应用程序以及使用“ Builder”模式创建它的能力
提交最终申请的班级
/**
*
*/
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())
}
如何在屏幕上全部使用
现在值得介绍到演示级别,看看屏幕演示者如何与此交互器交互。
我们的功能是在其中包含一堆碎片的活动。
成功提交应用程序将打开一个单独的活动,在该活动中,用户将被告知提交成功。主要活动将负责根据交互器的命令显示所需的片段,并负责显示工具栏中已执行的步骤数。为此,在根活动演示者中,从交互器订阅主题,并实现用于切换堆栈中的片段的逻辑。
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()
就这样。我们具有由五个屏幕组成的功能。可以跳过“关于教育”屏幕,其中包含填写工作经验的屏幕-替换为用于撰写论文的屏幕。我们可以在任何步骤中断填充,然后再继续,输入的所有内容都将保存在草稿中。
特别感谢Vasya Beglyanin @ icebail-该方法在项目中的首次实现的作者。还有Misha Zinchenko @midery-帮助将体系结构草案带到最终版本中,本文将对此进行介绍。