Interface utilisateur pilotée par le backend avec des widgets

Considérez les caractéristiques de cette approche et notre implémentation à l'aide de widgets, leur concept, leurs avantages et leurs différences par rapport aux autres vues d'Android.



Interface utilisateur pilotée par le backend - une approche qui vous permet de créer des composants d'interface utilisateur basés sur la réponse du serveur. La description de l'API doit contenir les types de composants et leurs propriétés, et l'application doit afficher les composants nécessaires en fonction des types et des propriétés. En général, la logique des composants peut être définie, et pour une application mobile, il s'agit d'une boîte noire, car chaque composant peut avoir une logique indépendante du reste de l'application et peut être configuré arbitrairement par le serveur, en fonction de la logique métier requise. C'est pourquoi cette approche est souvent utilisée dans les applications bancaires: par exemple, lorsque vous devez afficher un formulaire de traduction avec un grand nombre de champs définis dynamiquement. L'application ne connaît pas à l'avance la composition du formulaire et l'ordre des champs qu'il contient, par conséquent,cette approche est le seul moyen d'écrire du code sans béquilles. De plus, cela ajoute de la flexibilité: du côté serveur, vous pouvez modifier le formulaire à tout moment, et l'application mobile sera prête pour cela.

Cas d'utilisation




Les types de composants suivants sont présentés ci-dessus:

  1. Liste des comptes disponibles pour le transfert;
  2. Le nom du type de traduction;
  3. Champ pour entrer un numéro de téléphone (il a un masque pour entrer et contient une icône pour sélectionner les contacts de l'appareil);
  4. Champ de saisie du montant du virement.

Sur le formulaire également, un certain nombre d'autres composants intégrés dans la logique métier et déterminés au stade de la conception sont possibles. Les informations sur chaque composant qui viennent dans la réponse du serveur doivent répondre aux exigences, et chaque composant doit être attendu par l'application mobile pour son traitement correct.



Différents champs de saisie ont différents masques et règles de validation; le bouton peut avoir une animation chatoyante au démarrage; un widget pour sélectionner un compte de prélèvement peut avoir une animation lors du défilement, etc.

Tous les composants de l'interface utilisateur sont indépendants les uns des autres, et la logique peut être reprise dans des vues distinctes avec différents domaines de responsabilité - appelons-les widgets. Chaque widget reçoit sa configuration dans la réponse du serveur et encapsule la logique d'affichage et de traitement des données.

Lors de la mise en œuvre de l'écran, RecyclerView est le mieux adapté, dont les éléments contiendront des widgets. Le ViewHolder de chaque élément de liste unique initialisera le widget et lui donnera les données dont il a besoin pour afficher.

Concept de widget


Examinons les widgets plus en détail. À sa base, un widget est une vue personnalisée "à vitesse maximale". Une vue personnalisée ordinaire peut également contenir des données et la logique de leur affichage, mais le widget implique quelque chose de plus - il a un présentateur, un modèle d'écran et a sa propre portée DI.

Avant de plonger dans les détails de l'implémentation des widgets, nous discutons de leurs avantages:

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


Pour implémenter des widgets évolutifs, nous utilisons les classes de base pour les ViewGroups les plus couramment utilisés, qui ont leurs propres étendues DI. Toutes les classes de base, à leur tour, sont héritées de l'interface commune, qui contient tout le nécessaire pour initialiser les widgets.

Le cas le plus simple pour utiliser des widgets est les vues statiques, spécifiées directement dans la mise en page. Après avoir implémenté les classes de widgets, vous pouvez l'ajouter en toute sécurité à la disposition XML, sans oublier de spécifier son identifiant dans la disposition (en fonction de l'identifiant, la portée DI d'un widget sera formée).

Dans cet article, nous considérons les widgets dynamiques plus en détail, car le cas du formulaire de traduction décrit ci-dessus avec un ensemble arbitraire de champs est facilement résolu avec leur aide.

Tout widget, à la fois statique et dynamique, dans notre implémentation n'est presque pas différent de la vue ordinaire en termes de MVP. En règle générale, 4 classes sont nécessaires pour implémenter un widget:

  1. Afficher la classe, où la mise en page et l'affichage du contenu se produisent;

    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. Une classe pour le présentateur, où la logique de base du widget est décrite, par exemple:

    1. charger des données et les transmettre pour un rendu;
    2. Abonnement à divers événements et émission d'événements de modifications d'entrée de widget;

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

    Dans notre implémentation, la classe RxBus est un bus basé sur PublishSubject pour envoyer des événements et s'y abonner.
  3. Une classe pour le modèle d'écran, à l'aide de laquelle le présentateur reçoit des données et les transfère pour le rendu dans une vue (en termes de modèle de modèle de présentation);

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

  4. Une classe de configurateur pour implémenter DI, à l'aide de laquelle sont fournies les dépendances pour le widget qui ont la portée souhaitée, et le présentateur est injecté dans sa vue.

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


La seule différence entre les widgets et notre implémentation d'écrans à part entière (Activity, Fragment) est que le widget n'a pas beaucoup de méthodes de cycle de vie (onStart, onResume, onPause). Il n'a que la méthode onCreate, qui montre que le widget a actuellement créé sa portée, et le scoop est détruit dans la méthode onDetachedFromWindow. Mais pour plus de commodité et de cohérence, le présentateur du widget bénéficie des mêmes méthodes de cycle de vie que les autres écrans. Ces événements lui sont automatiquement transmis par le parent. Il convient de noter que la classe de base du présentateur de widget est la même classe de base des présentateurs d'autres écrans.

Utilisation de widgets dynamiques


Passons à la mise en œuvre du cas décrit au début de l'article.

  1. Dans le présentateur d'écran, les données du formulaire de traduction sont chargées, les données sont transmises à la vue pour le rendu. À ce stade, peu importe que la vue de l'écran d'activité soit un fragment ou un widget. Nous voulons seulement avoir RecyclerView et rendre un formulaire dynamique avec.

    // 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. Les données de formulaire sont transférées vers l'adaptateur de liste et rendues à l'aide de widgets qui se trouvent dans le ViewHolder pour chaque élément de formulaire unique. Le ViewHolder souhaité pour le rendu du composant est déterminé en fonction de types prédéfinis de composants de formulaire.

    // 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. Le widget est initialisé dans la méthode de liaison de ViewHolder. En plus de transmettre des données pour le rendu, il est également important de définir un identifiant unique pour le widget, sur la base duquel sa portée DI sera formée. Dans notre cas, chaque élément du formulaire avait un identifiant unique, qui était responsable de la nomination de l'entrée et est venu en réponse en plus du type d'élément (les types peuvent être répétés sur le formulaire).

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

  4. La méthode d'initialisation initialise les données de vue de widget, qui sont ensuite transmises au présentateur à l'aide de la méthode de cycle de vie onCreate, où les valeurs de champ sont définies sur le modèle de widget et son rendu.

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


Roches sous-marines


Comme le montre la description, la mise en œuvre est très simple et intuitive. Cependant, certaines nuances doivent être prises en compte.

Tenez compte du cycle de vie des widgets


Étant donné que les classes de base des widgets sont les héritiers des ViewGroups couramment utilisés, nous connaissons également le cycle de vie des widgets. En règle générale, les widgets sont initialisés dans le ViewHolder en appelant une méthode spéciale où les données sont transférées, comme indiqué dans le paragraphe précédent. Une autre initialisation a lieu dans onCreate (par exemple, la définition d'écouteurs de clic) - cette méthode est appelée après onAttachedToWindow à l'aide d'un délégué spécial qui contrôle les entités clés de la logique du widget.

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

Nettoyez toujours les auditeurs


S'il y a des champs dépendants sur le formulaire, nous pouvons avoir besoin de onDetachedFromWindow. Prenons le cas suivant: le formulaire de traduction comporte de nombreux champs, parmi lesquels une liste déroulante. Selon la valeur sélectionnée dans la liste, un champ de saisie de formulaire supplémentaire peut devenir visible ou un champ existant peut disparaître.
valeur déroulante pour choisir le type de traductionVisibilité du champ de saisie de la période de paiementvisibilité du champ de saisie du numéro de téléphone
transfert par numéro de téléphonefauxvrai
Paiementvraifaux

Dans le cas décrit ci-dessus, il est très important d'effacer tous les écouteurs de widget dans la méthode onDetachedFromWindow, car si vous ajoutez à nouveau le widget à la liste, tous les écouteurs seront réinitialisés.

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

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

Gérer correctement les abonnements aux événements de widget


La présentation de l'écran contenant les widgets doit être notifiée des changements dans l'entrée de chaque widget. L'implémentation la plus évidente de chaque widget à l'aide d'émettre des événements et de s'abonner à tous les événements avec un présentateur d'écran. L'événement doit contenir l'identifiant du widget et ses données. Il est préférable de mettre en œuvre cette logique afin que les valeurs d'entrée actuelles soient enregistrées dans le modèle d'écran et lorsque vous cliquez sur le bouton, les données finies sont envoyées dans la demande. Avec cette approche, il est plus facile de mettre en œuvre la validation du formulaire: elle se produit lorsqu'un bouton est cliqué, et s'il n'y a pas eu d'erreurs, la demande est envoyée avec les données du formulaire enregistrées à l'avance.

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

Il existe une deuxième option de mise en œuvre, dans laquelle les événements des widgets avec leurs données ne surviennent qu'après avoir cliqué sur le bouton, et non au fur et à mesure que vous tapez, c'est-à-dire que nous collectons toutes les données immédiatement avant d'envoyer la demande. Avec cette option, il y aura beaucoup moins d'événements, mais il convient de noter que cette implémentation peut s'avérer non triviale dans la pratique, et une logique supplémentaire sera nécessaire pour vérifier si tous les événements ont été reçus.

Unifier toutes les exigences


Je voudrais une fois de plus noter que le cas décrit n'est possible qu'après coordination des exigences avec le backend.

Quelles exigences doivent être unifiées:

  • Types de champs. Chaque champ doit être attendu par l'application mobile pour son affichage et son traitement corrects.
  • — , , , .
  • , .
  • . , : , , — , -, .



Ceci est nécessaire pour que le composant reçu dans la réponse soit connu de l'application mobile pour l'affichage et le traitement corrects de la logique.

La deuxième nuance est que les composants de la forme eux-mêmes sont généralement indépendants les uns des autres, cependant, certains scénarios sont possibles lorsque, par exemple, la visibilité d'un élément dépend de l'état d'un autre, comme décrit ci-dessus. Pour implémenter cette logique, il est nécessaire que les éléments dépendants se réunissent toujours, et la réponse doit contenir une description de la logique, quels composants dépendent les uns des autres et comment. Et bien sûr, tout cela doit être convenu avec l'équipe du serveur avant de commencer le développement.

Conclusion


Ainsi, lorsque vous implémentez même un cas standard tel que le remplissage d'une liste dynamique, vous ne pouvez pas toujours vous arrêter aux solutions déjà existantes. Pour nous, c'était un nouveau concept, qui nous a permis de sélectionner des éléments atomiques de logique et de représentation à partir d'énormes écrans et nous avons réussi à obtenir une solution extensible de travail qui est facile à maintenir en raison de la similitude des widgets avec d'autres vues. Dans notre implémentation, les widgets ont été développés en termes de modèle RxPM - après l'ajout de liants, il est devenu encore plus pratique d'utiliser des widgets, mais c'est une histoire complètement différente.

Liens utiles


  1. Cadre de développement d'applications Android Surf
  2. Module Widget
  3. Notre implémentation de rendu simple de listes complexes
  4. PrésentationModèle de modèle

All Articles