So entwerfen Sie inkrementelle Datenfüllung in einer mobilen App

Hallo! Mein Name ist Vita Sokolova, ich bin Android Team Lead bei Surf .



In mobilen Anwendungen gibt es Formulare mit komplexer mehrstufiger Ausfüllung - zum Beispiel Fragebögen oder Anwendungen. Das Entwerfen solcher Funktionen bereitet Entwicklern normalerweise Kopfschmerzen: Es wird eine große Datenmenge zwischen Bildschirmen übertragen, und es werden starre Verbindungen hergestellt - wer, an wen, in welcher Reihenfolge sollen diese Daten übertragen werden und welcher Bildschirm als nächstes nach sich selbst geöffnet werden soll.



In diesem Artikel werde ich eine bequeme Möglichkeit vorstellen, die Arbeit einer schrittweisen Funktion zu organisieren. Mit seiner Hilfe ist es möglich, Verbindungen zwischen Bildschirmen zu minimieren und die Reihenfolge der Schritte einfach zu ändern: Fügen Sie neue Bildschirme hinzu, ändern Sie deren Reihenfolge und die Logik der Anzeige für den Benutzer.







* Mit dem Wort "Feature" in diesem Artikel meine ich eine Reihe von Bildschirmen in einer mobilen Anwendung, die logisch verbunden sind und eine Funktion für den Benutzer darstellen.



Das Ausfüllen von Fragebögen und das Einreichen von Anträgen in mobilen Anwendungen besteht normalerweise aus mehreren aufeinander folgenden Bildschirmen. Daten von einem Bildschirm werden möglicherweise auf einem anderen benötigt, und die Schritte ändern sich manchmal abhängig von den Antworten. Daher ist es nützlich, dem Benutzer zu erlauben, die Daten "im Entwurf" zu speichern, damit er später zum Prozess zurückkehren kann.



Es kann viele Bildschirme geben, aber tatsächlich füllt der Benutzer ein großes Objekt mit Daten. In diesem Artikel werde ich Ihnen erklären, wie Sie die Arbeit mit einer Reihe von Bildschirmen, die ein Szenario darstellen, bequem organisieren können.



Angenommen, ein Benutzer bewirbt sich um eine Stelle und füllt ein Formular aus. Wird es in der Mitte unterbrochen, werden die eingegebenen Daten im Entwurf gespeichert. Wenn der Benutzer zum Ausfüllen zurückkehrt, werden die Informationen aus dem Entwurf automatisch in die Felder des Fragebogens eingesetzt - er muss nicht alles von Grund auf neu ausfüllen.



Wenn der Benutzer den gesamten Fragebogen ausfüllt, wird seine Antwort an den Server gesendet.



Der Fragebogen besteht aus:



  • Schritt 1 - Vollständiger Name, Art der Ausbildung, Berufserfahrung,

  • Schritt 2 - der Studienort,

  • Schritt 3 - Arbeitsort oder Aufsatz über sich selbst,

  • Schritt 4 - die Gründe, warum die Stelle interessiert ist.









Der Fragebogen ändert sich je nachdem, ob der Benutzer über eine Ausbildung und Berufserfahrung verfügt. Wenn es keine Ausbildung gibt, schließen wir den Schritt mit der Besetzung des Studienortes aus. Wenn keine Berufserfahrung vorliegt, bitten Sie den Benutzer, ein wenig über sich selbst zu schreiben.







In der Entwurfsphase müssen wir mehrere Fragen beantworten:



  • So machen Sie das Feature-Skript flexibel und können auf einfache Weise Schritte hinzufügen und entfernen.

  • So stellen Sie sicher, dass beim Öffnen eines Schritts die erforderlichen Daten bereits ausgefüllt werden (z. B. wartet der Bildschirm "Bildung" am Eingang auf eine bereits bekannte Art von Bildung, um die Zusammensetzung der Felder wiederherzustellen).

  • So aggregieren Sie Daten zu einem gemeinsamen Modell für die Übertragung auf den Server nach dem letzten Schritt.

  • So speichern Sie eine Anwendung in einem "Entwurf", damit der Benutzer das Ausfüllen unterbrechen und später darauf zurückgreifen kann.



Aus diesem Grund möchten wir die folgenden Funktionen erhalten: Das







gesamte Beispiel befindet sich in meinem Repository auf GitHub 



Eine offensichtliche Lösung



Wenn Sie eine Funktion "im Energiesparmodus" entwickeln, ist es am naheliegendsten, ein Anwendungsobjekt zu erstellen, es von Bildschirm zu Bildschirm zu übertragen und es bei jedem Schritt neu zu füllen.



Hellgraue Farbe markiert die Daten, die in einem bestimmten Schritt nicht benötigt werden. Gleichzeitig werden sie an jeden Bildschirm übertragen, um schließlich die endgültige Anwendung einzugeben.







Natürlich sollten alle diese Daten in ein Anwendungsobjekt gepackt werden. Mal sehen, wie es aussehen wird:



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


ABER!

Wenn wir mit einem solchen Objekt arbeiten, verurteilen wir unseren Code dazu, mit einer zusätzlichen unnötigen Anzahl von Nullprüfungen abgedeckt zu werden. Diese Datenstruktur garantiert beispielsweise in keiner Weise, dass das Feld educationTypebereits auf dem Bildschirm "Bildung" ausgefüllt wird.



Wie man es besser macht



Ich empfehle, die Datenverwaltung in ein separates Objekt zu verschieben, das die erforderlichen nicht nullbaren Daten als Eingabe für jeden Schritt bereitstellt und das Ergebnis jedes Schritts in einem Entwurf speichert. Wir werden dieses Objekt einen Interaktor nennen. Es entspricht der Use-Case-Ebene aus der reinen Architektur von Robert Martin und ist für alle Bildschirme verantwortlich für die Bereitstellung von Daten aus verschiedenen Quellen (Netzwerk, Datenbank, Daten aus vorherigen Schritten, Daten aus einem Vorschlagsentwurf ...).



Bei unseren Projekten verwenden wir bei Surf Dagger. Aus einer Reihe von Gründen werden Interaktoren üblicherweise mit dem Gültigkeitsbereich @PerApplication erstellt: Dies macht unseren Interaktor zu einem Singleton innerhalb der Anwendung. Tatsächlich kann der Interaktor ein Singleton innerhalb eines Features oder sogar eine Aktivierung sein - wenn alle Ihre Schritte Fragmente sind. Alles hängt von der Gesamtarchitektur Ihrer Anwendung ab.



Weiter in den Beispielen nehmen wir an, dass wir eine einzelne Instanz des Interaktors für die gesamte Anwendung haben. Daher müssen alle Daten gelöscht werden, wenn das Skript endet.







Beim Festlegen der Aufgabe wollten wir neben der zentralen Datenspeicherung eine einfache Verwaltung der Zusammensetzung und Reihenfolge der Schritte in der Anwendung organisieren: Je nachdem, was der Benutzer bereits ausgefüllt hat, können sie sich ändern. Deshalb brauchen wir noch eine Entität - das Szenario. Ihr Verantwortungsbereich besteht darin, die Reihenfolge der Schritte einzuhalten, die der Benutzer ausführen muss.



Die Organisation einer schrittweisen Funktion mithilfe von Skripten und einem Interaktor ermöglicht:



  • Es ist schmerzlos, die Schritte im Skript zu ändern: Überlappende weitere Arbeiten, wenn sich während der Ausführung herausstellt, dass der Benutzer keine Anforderungen senden oder Schritte hinzufügen kann, wenn weitere Informationen benötigt werden.

  • Festlegen von Verträgen: Welche Daten müssen bei der Eingabe und Ausgabe jedes Schritts vorhanden sein?

  • Organisieren Sie das Speichern der Anwendung in einem Entwurf, wenn der Benutzer nicht alle Bildschirme ausgefüllt hat.



Vorfüllbildschirme mit im Entwurf gespeicherten Daten.



Grundlegende Entitäten



Der Mechanismus der Funktion besteht aus:



  • Eine Reihe von Modellen zur Beschreibung eines Schritts, von Ein- und Ausgängen.

  • Szenario - Eine Entität, die beschreibt, welche Schritte (Bildschirme) der Benutzer ausführen muss.

  • Interaktora (ProgressInteractor) - eine Klasse, die dafür verantwortlich ist, Informationen über den aktuell aktiven Schritt zu speichern, die gefüllten Informationen nach Abschluss jedes Schritts zu aggregieren und Eingabedaten auszugeben, um einen neuen Schritt zu starten.

  • Entwurf (ApplicationDraft) - eine Klasse, die für das Speichern gefüllter Informationen verantwortlich ist. 



Das Klassendiagramm stellt alle Basisentitäten dar, von denen konkrete Implementierungen erben. Mal sehen, wie sie zusammenhängen.







Für die Entität "Szenario" legen wir eine Schnittstelle fest, in der wir beschreiben, welche Logik wir für jedes Szenario in der Anwendung erwarten (eine Liste der erforderlichen Schritte enthalten und gegebenenfalls nach Abschluss des vorherigen Schritts neu



erstellen . Die Anwendung kann mehrere Funktionen haben, die aus vielen aufeinander folgenden Bildschirmen bestehen, und jede wird dies tun Wir werden die gesamte allgemeine Logik, die nicht von der Funktion oder bestimmten Daten abhängt, in die Basisklasse ProgressInteractor verschieben.



ApplicationDraft ist in den Basisklassen nicht vorhanden, da das Speichern der Daten, die der Benutzer in einen Entwurf eingegeben hat, möglicherweise nicht erforderlich ist. Daher wird eine konkrete Implementierung von ProgressInteractor mit dem Entwurf funktionieren. Screen Presenter werden auch damit interagieren.



Klassendiagramm für bestimmte Implementierungen von Basisklassen:







Alle diese Entitäten interagieren miteinander und mit Bildschirmpräsentatoren wie folgt: Es gibt







einige Klassen. Schauen wir uns also die Funktion vom Anfang des Artikels an und analysieren Sie jeden Block separat.



Beschreibung der Schritte



Beginnen wir mit dem ersten Punkt. Wir brauchen Entitäten, um die Schritte zu beschreiben:



// ,   ,    

interface Step




Für die Funktion aus unserem Bewerbungsbeispiel lauten die Schritte wie folgt:



/**
 *     
 */
enum class ApplicationSteps : Step {
    PERSONAL_INFO,  //  
    EDUCATION,      // 
    EXPERIENCE,     //  
    ABOUT_ME,       //  " "
    MOTIVATION      //     
}



Wir müssen auch die Eingabedaten für jeden Schritt beschreiben. Zu diesem Zweck verwenden wir versiegelte Klassen für den beabsichtigten Zweck - um eine begrenzte Klassenhierarchie zu erstellen.







Wie es im Code aussehen wird
//   
interface StepInData


:



//,      
sealed class ApplicationStepInData : StepInData

//     
class EducationStepInData(val educationType: EducationType) : ApplicationStepInData()

//        
class MotivationStepInData(val values: List<Motivation>) : ApplicationStepInData()




Wir beschreiben die Ausgabe auf ähnliche Weise:







Wie es im Code aussehen wird
// ,   
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()




Wenn wir uns nicht das Ziel gesetzt hätten, nicht ausgefüllte Anträge in Entwürfen zu belassen, könnten wir uns darauf beschränken. Da jedoch jeder Bildschirm nicht nur leer geöffnet, sondern auch aus dem Entwurf ausgefüllt werden kann, werden sowohl Eingabedaten als auch Daten aus dem Entwurf vom Interaktor eingegeben - sofern der Benutzer bereits etwas eingegeben hat.



Daher benötigen wir einen weiteren Satz von Modellen, um diese Daten zusammenzuführen. Für einige Schritte sind keine Informationen erforderlich, und es wird nur ein Feld für Daten aus dem Entwurf bereitgestellt



Wie es im Code aussehen wird
/**
 *     +   ,   
 */
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()
}




Wir handeln nach dem Drehbuch



Mit der Beschreibung der Schritte und aussortierten Eingabe- / Ausgabedaten. Lassen Sie uns nun die Reihenfolge dieser Schritte im Feature-Skript im Code festlegen. Die Entität "Szenario" ist für die Verwaltung der aktuellen Reihenfolge der Schritte verantwortlich. Das Skript sieht folgendermaßen aus:



/**
 * ,     ,     
 */
interface Scenario<S : Step, O : StepOutData> {
    
    //  
    val steps: List<S>

    /**
     *     
     *        
     */
    fun reactOnStepCompletion(stepOut: O)
}


In der Implementierung für unser Beispiel sieht das Skript folgendermaßen aus:



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) {...}
}


Es sollte beachtet werden, dass jede Änderung im Skript in beide Richtungen erfolgen muss. Angenommen, Sie entfernen einen Schritt. Stellen Sie sicher, dass der Schritt dem Skript hinzugefügt wird, wenn der Benutzer zurückgeht und eine andere Option auswählt.



Wie sieht der Code beispielsweise aus als Reaktion auf das Vorhandensein oder Fehlen von Berufserfahrung?
/**
 *      ,
 *           
 */
private fun applyExperienceToScenario(hasWorkingExperience: Boolean) {
    if (hasWorkingExperience) {
        steps.replaceWith(
            condition = { it == ABOUT_ME },
            newElem = EXPERIENCE
        )
    } else {
        steps.replaceWith(
            condition = { it == EXPERIENCE },
            newElem = ABOUT_ME
        )
    }
}




Wie Interactor funktioniert



Betrachten Sie den nächsten Baustein in der Architektur eines schrittweisen Features - eines Interaktors. Wie oben erwähnt, besteht seine Hauptverantwortung darin, das Umschalten zwischen den Schritten zu bedienen: die erforderlichen Daten für die Eingabe in die Schritte zu geben und die Ausgabedaten zu einem Anforderungsentwurf zusammenzufassen.



Erstellen wir eine Basisklasse für unseren Interaktor und fügen das Verhalten ein, das allen schrittweisen Funktionen gemeinsam ist.



/**
 *      
 * S -  
 * I -    
 * O -    
 */
abstract class ProgressInteractor<S : Step, I : StepInData, O : StepOutData> 


Der Interaktor muss mit dem aktuellen Szenario arbeiten: Benachrichtigen Sie ihn über den Abschluss des nächsten Schritts, damit das Szenario seine Schritte neu erstellen kann. Daher deklarieren wir ein abstraktes Feld für unser Skript. Jetzt muss jeder spezifische Interaktor seine eigene Implementierung bereitstellen.



// ,      
protected abstract val scenario: Scenario<S, O>


Der Interaktor ist auch dafür verantwortlich, den Status zu speichern, in dem der Schritt gerade aktiv ist, und zum nächsten oder vorherigen Schritt zu wechseln. Es muss den Stammbildschirm unverzüglich über die Schrittänderung informieren, damit es zum gewünschten Fragment wechseln kann. All dies kann leicht unter Verwendung von Ereignissendungen organisiert werden, d. H. Einem reaktiven Ansatz. Außerdem führen die Methoden unseres Interaktors häufig asynchrone Vorgänge aus (Laden von Daten aus dem Netzwerk oder der Datenbank), sodass wir RxJava verwenden, um mit dem Interaktor mit den Präsentatoren zu kommunizieren. Wenn Sie mit diesem Tool noch nicht vertraut sind, lesen Sie diese Reihe einführender Artikel



Erstellen wir ein Modell, das die von den Bildschirmen benötigten Informationen zum aktuellen Schritt und seiner Position im Skript beschreibt:



/**
 *         
 */
class StepWithPosition<S : Step>(
    val step: S,
    val position: Int,
    val allStepsCount: Int
)


Starten wir ein BehaviorSubject im Interaktor, um Informationen über den neuen aktiven Schritt frei darin auszugeben.



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


Damit Bildschirme diesen Ereignisstrom abonnieren können, erstellen wir eine öffentliche Variable stepChangeObservable, die einen Wrapper über unser stepChangeSubject darstellt.



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


Während der Arbeit des Interaktors ist es häufig erforderlich, die Position des aktuell aktiven Schritts zu kennen. Ich empfehle, eine separate Eigenschaft im Interaktor - currentStepIndex - zu erstellen und die Methoden get () und set () zu überschreiben. Dies gibt uns bequemen Zugriff auf diese Informationen vom Betreff.



Wie es im Code aussieht
//   
private var currentStepIndex: Int
    get() = stepChangeSubject.value?.position ?: 0
    set(value) {
        stepChangeSubject.onNext(
            StepWithPosition(
                step = scenario.steps[value],
                position = value,
                allStepsCount = scenario.steps.count()
            )
        )
    }




Lassen Sie uns einen allgemeinen Teil schreiben, der unabhängig von der spezifischen Implementierung des Interaktors für das Feature gleich funktioniert.



Fügen wir Methoden zum Initialisieren und Herunterfahren des Interaktors hinzu, um sie für die Erweiterung in Nachkommen zu öffnen:



Methoden zum Initialisieren und Herunterfahren
/**
 *   
 */
@CallSuper
open fun initProgressFeature() {
    currentStepIndex = 0
}

/**
 *   
 */
@CallSuper
open fun closeProgressFeature() {
    currentStepIndex = 0
}




Fügen wir die Funktionen hinzu, die jeder Schritt-für-Schritt-Feature-Interaktor ausführen sollte:



  • getDataForStep (Schritt: S) - Daten als Eingabe für Schritt S bereitstellen;

  • completeStep (stepOut: O) - Speichern Sie die O-Ausgabe und verschieben Sie das Skript zum nächsten Schritt.

  • toPreviousStep () - Verschieben Sie das Skript zum vorherigen Schritt.



Beginnen wir mit der ersten Funktion - der Verarbeitung von Eingabedaten. Jeder Interaktor bestimmt selbst, wie und wo die Eingabedaten abgerufen werden sollen. Fügen wir eine abstrakte Methode hinzu, die dafür verantwortlich ist:



/**
 *      
 */
protected abstract fun resolveStepInData(step: S): Single<out StepData<I, O>>




Fügen Sie für Präsentatoren bestimmter Bildschirme eine öffentliche Methode hinzu, die aufgerufen wird resolveStepInData() :



/**
 *     
 */
fun getDataForStep(step: S): Single<out StepData<I, O>> = resolveStepInData(step)


Sie können diesen Code vereinfachen, indem Sie die Methode öffentlich machen resolveStepInData(). Die Methode wird getDataForStep()zur Analogie zu den Schrittvervollständigungsmethoden hinzugefügt, die wir unten diskutieren werden.



Um einen Schritt abzuschließen, erstellen wir auf ähnliche Weise eine abstrakte Methode, bei der jeder spezifische Interaktor das Ergebnis des Schritts speichert.



/**
 *      
 */
protected abstract fun saveStepOutData(stepData: O): Completable


Und eine öffentliche Methode. Darin nennen wir das Speichern der Ausgabeinformationen. Wenn es fertig ist, weisen Sie das Skript an, sich an die Informationen aus dem Endschritt anzupassen. Wir werden die Abonnenten auch darüber informieren, dass wir einen Schritt vorwärts gehen.



/**
 *       
 */
fun completeStep(stepOut: O): Completable {
    return saveStepOutData(stepOut).doOnComplete {
        scenario.reactOnStepCompletion(stepOut)
        if (currentStepIndex != scenario.steps.lastIndex) {
            currentStepIndex += 1
        }
    }
}


Schließlich implementieren wir eine Methode, um zum vorherigen Schritt zurückzukehren.



/**
 *    
 */
fun toPreviousStep() {
    if (currentStepIndex != 0) {
        currentStepIndex -= 1
    }
}


Schauen wir uns die Implementierung des Interaktors für unser Bewerbungsbeispiel an. Wie wir uns erinnern, ist es für unsere Funktion wichtig, Daten in einer Entwurfsanforderung zu speichern. Daher erstellen wir in der ApplicationProgressInteractor-Klasse ein zusätzliches Feld unter dem Entwurf.



/**
 *    
 */
@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)
        }
    }
    ...
}


Wie eine Entwurfsklasse aussieht
:



/**
 *  
 */
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()
    }
}




Beginnen wir mit der Implementierung der in der übergeordneten Klasse deklarierten abstrakten Methoden. Beginnen wir mit der Schrittvervollständigungsfunktion - sie ist ziemlich einfach. Wir speichern die Ausgabe eines bestimmten Typs in einem Entwurf unter dem gewünschten Schlüssel:



/**
 *      
 */
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
            }
        }
    }
}


Schauen wir uns nun die Methode zum Abrufen von Eingabedaten für einen Schritt an:



/**
 *     
 */
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()
            )
        }
    }
}


Beim Öffnen eines Schritts gibt es zwei Möglichkeiten:



  • Der Benutzer öffnet den Bildschirm zum ersten Mal.

  • Der Benutzer hat den Bildschirm bereits ausgefüllt und wir haben Daten im Entwurf gespeichert.



Für Schritte, für deren Eingabe nichts erforderlich ist, geben wir die Informationen aus dem Entwurf weiter (falls vorhanden). 



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


Wenn wir Daten aus vorherigen Schritten als Eingabe benötigen, ziehen wir sie aus dem Entwurf heraus (wir haben sichergestellt, dass sie am Ende jedes Schritts dort gespeichert werden). Ebenso werden wir Daten an outData übertragen, mit denen wir den Bildschirm vorab ausfüllen können.



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


Es gibt auch eine interessantere Situation: Der letzte Schritt, in dem Sie angeben müssen, warum der Benutzer an dieser bestimmten Stelle interessiert ist, erfordert eine Liste möglicher Gründe für das Herunterladen aus dem Netzwerk. Dies ist einer der bequemsten Momente in dieser Architektur. Wir können eine Anfrage senden und, wenn wir eine Antwort erhalten, diese mit den Daten aus dem Entwurf kombinieren und als Eingabe an den Bildschirm senden. Der Bildschirm muss nicht einmal wissen, woher die Daten stammen und wie viele Quellen er sammelt.



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




Solche Situationen sind ein weiteres Argument für die Arbeit mit Interaktoren. Um einen Schritt mit Daten bereitzustellen, müssen Sie manchmal mehrere Datenquellen kombinieren, z. B. einen Download aus dem Web und die Ergebnisse der vorherigen Schritte.



In unserer Methode können wir Daten aus vielen Quellen kombinieren und dem Bildschirm alles bieten, was wir brauchen. Es kann schwierig sein, ein Gefühl dafür zu bekommen, warum dies in diesem Beispiel großartig ist. In realen Formen - zum Beispiel bei der Beantragung eines Darlehens - muss der Bildschirm möglicherweise viele Nachschlagewerke, Informationen über den Benutzer aus der internen Datenbank, Daten, die er 5 Schritte zurück ausgefüllt hat, und eine Sammlung der beliebtesten Anekdoten aus dem Jahr 1970 einreichen.



Der Präsentationscode ist viel einfacher, wenn die Aggregation durch eine separate Interaktormethode erfolgt, die nur das Ergebnis erzeugt: Daten oder einen Fehler. Für Entwickler ist es einfacher, Änderungen und Anpassungen vorzunehmen, wenn sofort klar ist, wo nach allem gesucht werden muss.



Aber das ist noch nicht alles im Interaktor. Natürlich benötigen wir eine Methode, um die endgültige Bewerbung zu senden - wenn alle Schritte bestanden wurden. Beschreiben wir die endgültige Anwendung und die Möglichkeit, sie mithilfe des "Builder" -Musters zu erstellen



Klasse für die Einreichung des endgültigen Antrags
/**
 *  
 */
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()
                )
            }
        }
    }
}




Die Methode zum Senden der Anwendung selbst:



/**
 *  
 */
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())
}


Wie man alles auf Bildschirmen benutzt



Jetzt lohnt es sich, auf die Präsentationsebene zu gehen und zu sehen, wie die Bildschirmpräsentatoren mit diesem Interaktor interagieren.



Unser Feature ist eine Aktivität mit einem Stapel von Fragmenten im Inneren.







Die erfolgreiche Einreichung des Antrags öffnet eine separate Aktivität, in der der Benutzer über den Erfolg der Einreichung informiert wird. Die Hauptaktivität ist dafür verantwortlich, das gewünschte Fragment abhängig vom Befehl des Interaktors anzuzeigen und auch anzuzeigen, wie viele Schritte bereits in der Symbolleiste ausgeführt wurden. Abonnieren Sie dazu im Root-Aktivitäts-Presenter das Thema vom Interaktor und implementieren Sie die Logik zum Wechseln von Fragmenten im Stapel.



progressInteractor.stepChangeObservable.subscribe { stepData ->
    if (stepData.position > currentPosition) {
        //      FragmentManager
    } else {
        //   
    }
    //   -    
}


Jetzt werden wir im Präsentator jedes Fragments am Anfang des Bildschirms den Interaktor bitten, uns Eingabedaten zu geben. Es ist besser, empfangende Daten in einen separaten Stream zu übertragen, da sie, wie bereits erwähnt, mit dem Herunterladen aus dem Netzwerk verbunden sein können.



Nehmen wir zum Beispiel den Bildschirm zum Ausfüllen von Bildungsinformationen.



progressInteractor.getDataForStep(EducationStep)
    .filter<ApplicationStepData.EducationStepData>()
    .subscribeOn(Schedulers.io())
    .subscribe { 
        val educationType = it.stepInData.educationType
 // todo:         

 it.stepOutData?.education?.let {
       // todo:      
  }
    }


Angenommen, wir führen den Schritt "Über Bildung" aus und der Benutzer möchte noch weiter gehen. Alles was wir tun müssen, ist ein Objekt mit der Ausgabe zu bilden und es an den Interaktor zu übergeben.



progressInteractor.completeStep(EducationStepOutData(education)).subscribe {
                   //     ( )
               }


Der Interaktor speichert die Daten selbst, initiiert bei Bedarf Änderungen im Skript und signalisiert der Root-Aktivität, zum nächsten Schritt zu wechseln. Fragmente wissen also nichts über ihre Position im Skript: Sie können leicht neu angeordnet werden, wenn sich beispielsweise das Design eines Features geändert hat.



Im letzten Fragment werden wir als Reaktion auf das erfolgreiche Speichern von Daten das Senden der endgültigen Anforderung hinzufügen, da wir uns daran erinnern, dass wir sendApplication()im Interaktor eine Methode dafür erstellt haben .



progressInteractor.sendApplication()
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(
                {
                    //    
                    activityNavigator.start(ThankYouRoute())
                },
                {
                    //  
                }
            )


Auf dem letzten Bildschirm mit der Information, dass die Anwendung erfolgreich gesendet wurde, wird der Interaktor gelöscht, damit der Prozess von Grund auf neu gestartet werden kann.



progressInteractor.closeProgressFeature()


Das ist alles. Wir haben eine Funktion, die aus fünf Bildschirmen besteht. Der Bildschirm "Über Bildung" kann übersprungen werden, der Bildschirm mit ausgefüllter Berufserfahrung - ersetzt durch einen Bildschirm zum Schreiben eines Aufsatzes. Wir können das Befüllen jederzeit unterbrechen und später fortfahren, und alles, was wir eingegeben haben, wird im Entwurf gespeichert.



Besonderer Dank geht an Vasya Beglyanin @icebail - die Autorin der ersten Implementierung dieses Ansatzes im Projekt. Und auch Misha Zinchenko @midery - für Hilfe bei der Umsetzung des Architekturentwurfs in die endgültige Version, die in diesem Artikel beschrieben wird.



All Articles