Backend-gesteuerte Benutzeroberfläche mit Widgets

Berücksichtigen Sie die Funktionen dieses Ansatzes und unsere Implementierung mithilfe von Widgets, deren Konzept, Vorteile und Unterschiede zu anderen Ansichten in Android.



Backend-gesteuerte Benutzeroberfläche - Ein Ansatz, mit dem Sie Benutzeroberflächenkomponenten basierend auf der Serverantwort erstellen können. Die API-Beschreibung sollte die Komponententypen und ihre Eigenschaften enthalten, und die Anwendung sollte abhängig von den Typen und Eigenschaften die erforderlichen Komponenten anzeigen. Im Allgemeinen kann die Logik der Komponenten festgelegt werden, und für eine mobile Anwendung sind sie eine Black Box, da jede Komponente eine vom Rest der Anwendung unabhängige Logik haben kann und vom Server abhängig von der erforderlichen Geschäftslogik beliebig konfiguriert werden kann. Aus diesem Grund wird dieser Ansatz häufig in Bankanwendungen verwendet: Zum Beispiel, wenn Sie ein Übersetzungsformular mit einer großen Anzahl dynamisch definierter Felder anzeigen müssen. Die Anwendung kennt die Zusammensetzung des Formulars und die Reihenfolge der darin enthaltenen Felder nicht im Voraus.Dieser Ansatz ist die einzige Möglichkeit, Code ohne Krücken zu schreiben. Darüber hinaus bietet es zusätzliche Flexibilität: Auf der Serverseite können Sie das Formular jederzeit ändern, und die mobile Anwendung ist dafür bereit.

Anwendungsfall




Die folgenden Arten von Komponenten sind oben dargestellt:

  1. Liste der zur Übertragung verfügbaren Konten;
  2. Der Name der Art der Übersetzung;
  3. Feld zur Eingabe einer Telefonnummer (es hat eine Maske zur Eingabe und ein Symbol zur Auswahl von Kontakten vom Gerät);
  4. Feld zur Eingabe des Überweisungsbetrags.

Auch auf dem Formular ist eine beliebige Anzahl anderer Komponenten möglich, die in die Geschäftslogik eingebettet und in der Entwurfsphase festgelegt sind. Informationen zu jeder Komponente, die in der Antwort vom Server eingehen, müssen den Anforderungen entsprechen, und von jeder Komponente muss erwartet werden, dass sie von der mobilen Anwendung korrekt verarbeitet wird.



Unterschiedliche Eingabefelder haben unterschiedliche Masken und Validierungsregeln. Die Schaltfläche kann beim Booten eine schimmernde Animation aufweisen. Ein Widget zum Auswählen eines Abbuchungskontos kann beim Scrollen Animationen enthalten und so weiter.

Alle UI-Komponenten sind unabhängig voneinander, und die Logik kann in separate Ansichten mit unterschiedlichen Verantwortungsbereichen aufgenommen werden - nennen wir sie Widgets. Jedes Widget erhält seine Konfiguration in der Serverantwort und kapselt die Logik der Anzeige und Datenverarbeitung.

Bei der Implementierung des Bildschirms ist RecyclerView am besten geeignet, dessen Elemente Widgets enthalten. Der ViewHolder jedes eindeutigen Listenelements initialisiert das Widget und gibt ihm die Daten, die angezeigt werden sollen.

Widget-Konzept


Lassen Sie uns Widgets genauer betrachten. Ein Widget ist im Kern eine benutzerdefinierte Ansicht "mit maximaler Geschwindigkeit". Eine gewöhnliche benutzerdefinierte Ansicht kann auch Daten und die Logik ihrer Anzeige enthalten, aber das Widget impliziert etwas mehr - es hat einen Präsentator, ein Bildschirmmodell und einen eigenen DI-Bereich.

Bevor wir uns mit den Details der Implementierung von Widgets befassen, diskutieren wir deren Vorteile:

  • , , «» , — UI- , .
  • , , .
  • , — : , , .


Um skalierbare Widgets zu implementieren, verwenden wir die Basisklassen für die am häufigsten verwendeten ViewGroups, die über eigene DI-Bereiche verfügen. Alle Basisklassen werden wiederum von der gemeinsamen Schnittstelle geerbt, die alles enthält, was zum Initialisieren von Widgets erforderlich ist.

Der einfachste Fall für die Verwendung von Widgets sind statische Ansichten, die direkt im Layout angegeben werden. Nach der Implementierung der Widget-Klassen können Sie sie sicher zum XML-Layout hinzufügen, ohne die ID im Layout anzugeben (basierend auf der ID wird der DI-Bereich eines Widgets gebildet).

In diesem Artikel werden dynamische Widgets ausführlicher betrachtet, da der oben beschriebene Fall des Übersetzungsformulars mit einem beliebigen Satz von Feldern mit ihrer Hilfe bequem gelöst werden kann.

Jedes statische und dynamische Widget in unserer Implementierung unterscheidet sich in Bezug auf MVP kaum von der normalen Sichtweise. Normalerweise werden 4 Klassen benötigt, um ein Widget zu implementieren:

  1. Ansichtsklasse, in der das Layoutlayout und die Inhaltsanzeige erfolgen;

    class TextInputFieldWidget @JvmOverloads constructor(
            context: Context,
            attrs: AttributeSet? = null
    ) : CoreFrameLayoutView(context, attrs) {
    @Inject
    lateinit var presenter: TextInputFieldPresenter
    init {
         inflate(context, R.layout.view_field_text_input, this)
      }
    }
    

  2. Eine Klasse für den Präsentator, in der die Grundlogik des Widgets beschrieben wird, zum Beispiel:

    1. Laden und Übertragen von Daten für ein Rendering;
    2. Abonnieren verschiedener Ereignisse und Ausgeben von Ereignissen bei Änderungen der Widget-Eingabe;

    @PerScreen
    class TextInputFieldPresenter @Inject constructor(
            basePresenterDependency: BasePresenterDependency,
            rxBus: RxBus
    ) : BaseInputFieldPresenter<TextInputFieldWidget>(
           basePresenterDependency, rxBus
    ) {
    private val sm = TextInputFieldScreenModel()
    ...
    }
    

    In unserer Implementierung ist die RxBus-Klasse ein PublishSubject-basierter Bus zum Senden und Abonnieren von Ereignissen.
  3. Eine Klasse für das Bildschirmmodell, mit deren Hilfe der Präsentator Daten empfängt und zum Rendern in einer Ansicht überträgt (in Bezug auf das Präsentationsmodellmuster);

    class TextInputFieldScreenModel : ScreenModel() {
    	val value = String = “”
    	val hint = String = “”
    	val errorText = String = “”
    }
    

  4. Eine Konfiguratorklasse zum Implementieren von DI, mit deren Hilfe Abhängigkeiten für das Widget bereitgestellt werden, die den gewünschten Bereich haben, und der Präsentator in seine Ansicht eingefügt wird.

    class TextInputFieldWidgetConfigurator : WidgetScreenConfigurator() {
    	// logic for components injection
    }
    


Der einzige Unterschied zwischen Widgets und unserer Implementierung vollwertiger Bildschirme (Aktivität, Fragment) besteht darin, dass das Widget nicht über viele Lebenszyklusmethoden verfügt (onStart, onResume, onPause). Es gibt nur die onCreate-Methode, die anzeigt, dass das Widget derzeit seinen Bereich erstellt hat, und der Scoop wird in der onDetachedFromWindow-Methode zerstört. Aus Gründen der Benutzerfreundlichkeit und Konsistenz erhält der Präsentator des Widgets jedoch dieselben Lebenszyklusmethoden wie die übrigen Bildschirme. Diese Ereignisse werden ihm automatisch vom Elternteil übermittelt. Es ist zu beachten, dass die Basisklasse des Widget-Präsentators dieselbe Basisklasse von Präsentatoren anderer Bildschirme ist.

Dynamische Widgets verwenden


Fahren wir mit der Implementierung des am Anfang des Artikels beschriebenen Falls fort.

  1. In der Bildschirmdarstellung werden die Daten für das Übersetzungsformular geladen, die Daten werden zum Rendern an die Ansicht übertragen. In diesem Stadium spielt es für uns keine Rolle, ob die Ansicht des Aktivitätsbildschirms ein Fragment oder ein Widget ist. Wir sind nur daran interessiert, RecyclerView zu haben und damit ein dynamisches Formular zu rendern.

    // TransferFormPresenter
    private val sm = TransferFormScreenModel()
    private fun loadData() {
    	loadDataDisposable.dispose()
      	loadDataDisposable = subscribe(
                  observerDataForTransfer().io(), 
                  { data -> 
                          sm.data = data
                          view.render(sm)
                  },
                  { error -> /* error handling */ }
      	)
     }
    

  2. Formulardaten werden an den Listenadapter übertragen und mithilfe von Widgets gerendert, die sich im ViewHolder für jedes eindeutige Formularelement befinden. Der gewünschte ViewHolder zum Rendern der Komponente wird basierend auf vordefinierten Typen von Formularkomponenten bestimmt.

    // TransferFormView
    fun render(sm: TransferFormScreenModel) {
        //      
        //   EasyAdapter [3]
        val list = ItemList.create()
        //       Controller,
        //       
    
        sm.data
            .filter { transferField -> transferField.visible }
            .forEach { transferField ->
                when (transferField.type) {
                    TransferFieldType.PHONE_INPUT -> {
                        list.add(
                            PhoneInputFieldData(transferField),
                            phoneInputController
                        )
                    }
                    TransferFieldType.MONEY -> {
                        list.add(
                            MoneyInputFieldData(transferField),
                            moneyInputController
                        )
                    }
                    TransferFieldType.BUTTON -> {
                        list.add(
                            ButtonInputFieldData(transferField),
                            buttonController
                        )
                    }
                    else -> {
                        list.add(
                            TextInputFieldData(transferField),
                            textInputController
                        )
                    }
                }
            }
            //     RecyclerView
            adapter.setItems(list)
    }  
    

  3. Das Widget wird in der Bindemethode von ViewHolder initialisiert. Neben der Übertragung von Daten für das Rendern ist es auch wichtig, eine eindeutige ID für das Widget festzulegen, auf deren Grundlage der DI-Bereich gebildet wird. In unserem Fall hatte jedes Element des Formulars eine eindeutige ID, die für die Ernennung der Eingabe verantwortlich war und zusätzlich zum Elementtyp als Antwort kam (Typen können im Formular wiederholt werden).

    // ViewHolder
    override fun bind(data: TransferFieldUi) {
    	// get initialize params from given data
    	itemView.findViewById(R.id.field_tif).initialize(...)
    }
    

  4. Die Initialisierungsmethode initialisiert die Widget-Ansichtsdaten, die dann mithilfe der onCreate-Lebenszyklusmethode an den Präsentator übertragen werden, wobei die Feldwerte auf das Widget-Modell und dessen Rendering festgelegt werden.

    // TextInputFieldWidget
    fun initialize(
           id: String = this.id,
           value: String = this.value,
           hint: String = this.hint,
           errorText: String = this.errorText
    ) {
           this.id = id
           this.value = value
           this.hint = hint
           this.errorText = errorText
    }
        
    override fun onCreate() {
           presenter.onCreate(value, hint, errorText)
           // other logic...
    }
    // TextInputFieldPresenter
    fun onCreate(value: String, hint: String, errorText: String) {
           sm.value = value
           sm.hint = hint
           sm.errorText = errorText
           view.render(sm)
    }
    


Unterwasserfelsen


Wie aus der Beschreibung hervorgeht, ist die Implementierung sehr einfach und intuitiv. Es gibt jedoch Nuancen, die berücksichtigt werden müssen.

Betrachten Sie den Lebenszyklus von Widgets


Da die Basisklassen von Widgets Erben der häufig verwendeten ViewGroups sind, kennen wir auch den Lebenszyklus von Widgets. In der Regel werden Widgets im ViewHolder durch Aufrufen einer speziellen Methode initialisiert, bei der die Daten übertragen werden, wie im vorherigen Absatz gezeigt. Eine andere Initialisierung findet in onCreate statt (z. B. Festlegen von Klick-Listenern). Diese Methode wird nach onAttachedToWindow mit einem speziellen Delegaten aufgerufen, der die Schlüsselentitäten der Widget-Logik steuert.

// CoreFrameLayoutView (      ViewGroup)
public abstract class CoreFrameLayoutView
          extends FrameLayout implements CoreWidgetViewInterface {
@Override
protected void onAttachedToWindow() {
   super.onAttachedToWindow();
   if (!isManualInitEnabled) {
        widgetViewDelegate = createWidgetViewDelegate();
        widgetViewDelegate.onCreate();
   }
}

public void onCreate() {
    //empty. define in descendant class if needed
}

// WidgetViewDelegate
public class WidgetViewDelegate {
public void onCreate() {
   // other logic of widget initialization
   coreWidgetView.onCreate();
}

Reinigen Sie immer die Zuhörer


Wenn das Formular abhängige Felder enthält, benötigen wir möglicherweise onDetachedFromWindow. Stellen Sie sich den folgenden Fall vor: Das Übersetzungsformular enthält viele Felder, unter denen sich eine Dropdown-Liste befindet. Abhängig von dem in der Liste ausgewählten Wert wird möglicherweise ein zusätzliches Formulareingabefeld angezeigt oder ein vorhandenes verschwindet.
Dropdown-Wert zur Auswahl der Art der ÜbersetzungSichtbarkeit des Eingabefelds für den ZahlungszeitraumSichtbarkeit des Eingabefelds der Telefonnummer
Übertragung per Telefonnummerfalschwahr
Zahlungwahrfalsch

In dem oben beschriebenen Fall ist es sehr wichtig, alle Widget-Listener in der onDetachedFromWindow-Methode zu löschen, da alle Listener neu initialisiert werden, wenn Sie das Widget erneut zur Liste hinzufügen.

// TextInputFieldWidget
override fun onCreate() {
     initListeners()
}

override fun onDetachedFromWindow() {
      super.onDetachedFromWindow()
      clearListeners()
}

Behandeln Sie Widget-Ereignisabonnements korrekt


Die Darstellung des Bildschirms mit den Widgets sollte über die Änderungen in der Eingabe jedes Widgets informiert werden. Die naheliegendste Implementierung jedes Widgets mithilfe von Ausgabeereignissen und Abonnieren aller Ereignisse mit einem Bildschirmpräsentator. Das Ereignis muss die Widget-ID und ihre Daten enthalten. Es ist am besten, diese Logik so zu implementieren, dass die aktuellen Eingabewerte im Bildschirmmodell gespeichert werden. Wenn Sie auf die Schaltfläche klicken, werden die fertigen Daten in der Anforderung gesendet. Mit diesem Ansatz ist es einfacher, die Formularvalidierung zu implementieren: Sie tritt auf, wenn auf eine Schaltfläche geklickt wird, und wenn keine Fehler aufgetreten sind, wird die Anforderung mit den zuvor gespeicherten Formulardaten gesendet.

// TextInputFieldWidget
private val defaultTextChangedListener = object : OnMaskedValueChangedListener {
        override fun onValueChanged(value: String) {
            presenter.onTextChange(value, id)
        }
}

// Events.kt
sealed class InputValueType(val id: String)

class TextValue(id: String, val value: String) : InputValueType(id)

class DataEvent(val data: InputValueType)

// TextInputFieldPresenter -  
fun onTextChange(value: String, id: String) {
	rxBus.emitEvent(DataEvent(data = TextValue(id = id, value = value)))
}

// TransferFormPresenter -  
private fun subscribeToEvents() {
	subscribe(rxBus.observeEvents(DataEvent::class.java))
        {
            handleValue(it.data) // handle data
        }
}

private fun handleValue(value: InputValueType) {
	 val id = value.id
	 when (value) {
		 // handle event using its type, saving event value using its id
	 	 is TextValue -> {
       		 	 sm.fieldValuesMap[id] = value.value
       	 	 }
		 else -> {
			// handle other events
		 }
 	 }
}
// TransferScreenModel
class TransferScreenModel : ScreenModel() {
 	 // map for form values: key = input id
	 val fieldValuesMap: MutableMap<String, String> = mutableMapOf()
}

Es gibt eine zweite Implementierungsoption, bei der Ereignisse von Widgets mit ihren Daten erst nach dem Klicken auf die Schaltfläche auftreten und nicht während der Eingabe, dh wir erfassen alle Daten unmittelbar vor dem Senden der Anforderung. Mit dieser Option wird es viel weniger Ereignisse geben, aber es ist erwähnenswert, dass sich diese Implementierung in der Praxis als nicht trivial herausstellen kann und zusätzliche Logik erforderlich ist, um zu überprüfen, ob alle Ereignisse empfangen wurden.

Vereinheitlichen Sie alle Anforderungen


Ich möchte noch einmal darauf hinweisen, dass der beschriebene Fall erst nach Abstimmung der Anforderungen mit dem Backend möglich ist.

Welche Anforderungen müssen vereinheitlicht werden:

  • Feldtypen. Jedes Feld sollte von der mobilen Anwendung für seine korrekte Anzeige und Verarbeitung erwartet werden.
  • — , , , .
  • , .
  • . , : , , — , -, .



Dies ist erforderlich, damit die in der Antwort empfangene Komponente der mobilen Anwendung für die korrekte Anzeige und Verarbeitung der Logik bekannt ist.

Die zweite Nuance besteht darin, dass die Komponenten des Formulars selbst im Allgemeinen unabhängig voneinander sind. Einige Szenarien sind jedoch möglich, wenn beispielsweise die Sichtbarkeit eines Elements vom Zustand eines anderen Elements abhängt, wie oben beschrieben. Um diese Logik zu implementieren, müssen die abhängigen Elemente immer zusammenkommen, und die Antwort muss eine Beschreibung der Logik enthalten, welche Komponenten wie voneinander abhängen. Und all dies sollte natürlich vor Beginn der Entwicklung mit dem Serverteam vereinbart werden.

Fazit


Wenn Sie also selbst einen Standardfall wie das Ausfüllen einer dynamischen Liste implementieren, können Sie nicht immer bei bereits vorhandenen Lösungen Halt machen. Für uns war dies ein neues Konzept, mit dem wir atomare Logik- und Darstellungselemente aus riesigen Bildschirmen auswählen konnten, und es gelang uns, eine funktionierende erweiterbare Lösung zu erhalten, die aufgrund der Ähnlichkeit von Widgets mit anderen Ansichten einfach zu warten ist. In unserer Implementierung wurden Widgets im Hinblick auf das RxPM-Muster entwickelt. Nach dem Hinzufügen von Bindemitteln wurde die Verwendung von Widgets noch bequemer, aber dies ist eine ganz andere Geschichte.

Nützliche Links


  1. Android-Anwendungsentwicklungs- Framework Surf
  2. Widget-Modul
  3. Unsere Implementierung des einfachen Renderns komplexer Listen
  4. PresentationModel-Muster

All Articles