Interfaz de usuario basada en backend con widgets

Considere las características de este enfoque y nuestra implementación utilizando widgets, su concepto, ventajas y diferencias con respecto a otras vistas en Android.



Interfaz de usuario basada en backend: un enfoque que le permite crear componentes de interfaz de usuario basados ​​en la respuesta del servidor. La descripción de la API debe contener los tipos de componentes y sus propiedades, y la aplicación debe mostrar los componentes necesarios según los tipos y propiedades. En general, se puede incorporar la lógica de los componentes, y para una aplicación móvil, son una caja negra, ya que cada componente puede tener una lógica independiente del resto de la aplicación y el servidor puede configurarlo arbitrariamente, dependiendo de la lógica empresarial requerida. Es por eso que este enfoque se usa a menudo en aplicaciones bancarias: por ejemplo, cuando necesita mostrar un formulario de traducción con una gran cantidad de campos definidos dinámicamente. La aplicación no conoce de antemano la composición del formulario y el orden de los campos, por lo tanto,Este enfoque es la única forma de escribir código sin muletas. Además, agrega flexibilidad: desde el lado del servidor, puede cambiar el formulario en cualquier momento, y la aplicación móvil estará lista para esto.

Caso de uso




Los siguientes tipos de componentes se presentan arriba:

  1. Lista de cuentas disponibles para transferir;
  2. El nombre del tipo de traducción;
  3. Campo para ingresar un número de teléfono (tiene una máscara para ingresar y contiene un icono para seleccionar contactos desde el dispositivo);
  4. Campo para ingresar el monto de la transferencia.

También en el formulario, es posible cualquier cantidad de otros componentes integrados en la lógica de negocios y determinados en la etapa de diseño. La información sobre cada componente que viene en la respuesta del servidor debe cumplir con los requisitos, y la aplicación móvil debe esperar que cada componente lo procese correctamente.



Los diferentes campos de entrada tienen máscaras y reglas de validación diferentes; el botón puede tener una animación brillante en el momento del arranque; un widget para seleccionar una cuenta de pago puede tener animación al desplazarse, etc.

Todos los componentes de la interfaz de usuario son independientes entre sí, y la lógica se puede tomar en vistas separadas con diferentes áreas de responsabilidad, llamémoslas widgets. Cada widget recibe su configuración en la respuesta del servidor y encapsula la lógica de visualización y procesamiento de datos.

Al implementar la pantalla, RecyclerView es el más adecuado, cuyos elementos contendrán widgets. El ViewHolder de cada elemento de lista único inicializará el widget y le dará los datos que necesita para mostrar.

Concepto de widget


Consideremos los widgets con más detalle. En esencia, un widget es una vista personalizada "a la máxima velocidad". Una vista personalizada ordinaria también puede contener datos y la lógica de su visualización, pero el widget implica algo más: tiene un presentador, un modelo de pantalla y tiene su propio alcance DI.

Antes de sumergirnos en los detalles de la implementación de widgets, discutimos sus ventajas:

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


Para implementar widgets escalables, utilizamos las clases base para los ViewGroups más utilizados, que tienen sus propios ámbitos DI. Todas las clases base, a su vez, se heredan de la interfaz común, que contiene todo lo necesario para inicializar widgets.

El caso más fácil para usar widgets son las vistas estáticas, especificadas directamente en el diseño. Después de implementar las clases de widgets, puede agregarlo de manera segura al diseño XML, sin olvidar especificar su identificación en el diseño (en función de la identificación, se formará el alcance DI de un widget).

En este artículo, consideramos los widgets dinámicos con más detalle, ya que el caso del formulario de traducción descrito anteriormente con un conjunto arbitrario de campos se resuelve convenientemente con su ayuda.

Cualquier widget, tanto estático como dinámico, en nuestra implementación casi no es diferente de la vista ordinaria en términos de MVP. Por lo general, se necesitan 4 clases para implementar un widget:

  1. Ver clase, donde se produce el diseño del diseño y la visualización del contenido;

    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. Una clase para el presentador, donde se describe la lógica básica del widget, por ejemplo:

    1. cargar datos y transmitirlos para un render;
    2. Suscribirse a varios eventos y emitir eventos de cambios de entrada de widget;

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

    En nuestra implementación, la clase RxBus es un bus basado en PublishSubject para enviar eventos y suscribirse a ellos.
  3. Una clase para el modelo de pantalla, con la ayuda de la cual el presentador recibe datos y los transfiere para representarlos en una vista (en términos del patrón del Modelo de presentación);

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

  4. Una clase de configurador para implementar DI, con la ayuda de qué dependencias para el widget se entregan que tienen el alcance deseado, y el presentador se inyecta en su vista.

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


La única diferencia entre los widgets y nuestra implementación de pantallas completas (Actividad, Fragmento) es que el widget no tiene muchos métodos de ciclo de vida (onStart, onResume, onPause). Solo tiene el método onCreate, que muestra que el widget ha creado actualmente su alcance, y la primicia se destruye en el método onDetachedFromWindow. Pero por conveniencia y consistencia, el presentador del widget obtiene los mismos métodos de ciclo de vida que el resto de las pantallas. Estos eventos se le transmiten automáticamente desde el padre. Cabe señalar que la clase base del presentador de widgets es la misma clase base de presentadores de otras pantallas.

Usando widgets dinámicos


Pasemos a la implementación del caso descrito al comienzo del artículo.

  1. En el presentador de pantalla, se cargan los datos para el formulario de traducción, los datos se transmiten a la vista para su representación. En esta etapa, no nos importa si la vista de la pantalla de actividad es un fragmento o un widget. Solo estamos interesados ​​en tener RecyclerView y renderizar un formulario dinámico con él.

    // 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. Los datos del formulario se transfieren al adaptador de lista y se procesan utilizando widgets que se encuentran en ViewHolder para cada elemento de formulario único. El ViewHolder deseado para representar el componente se determina en función de los tipos predefinidos de componentes de formulario.

    // 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. El widget se inicializa en el método de enlace de ViewHolder. Además de transmitir datos para el render, también es importante establecer una identificación única para el widget, sobre la base de la cual se formará su alcance DI. En nuestro caso, cada elemento del formulario tenía una identificación única, que era responsable de la designación de entrada y respondía además del tipo de elemento (los tipos se pueden repetir en el formulario).

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

  4. El método initialize inicializa los datos de la vista del widget, que luego se transmiten al presentador utilizando el método del ciclo de vida onCreate, donde los valores de campo se establecen para el modelo del widget y su representación.

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


Rocas bajo el agua


Como se puede ver en la descripción, la implementación es muy simple e intuitiva. Sin embargo, hay matices que deben considerarse.

Considere el ciclo de vida de los widgets


Dado que las clases base de widgets son herederos de los ViewGroups de uso común, también conocemos el ciclo de vida de los widgets. Por lo general, los widgets se inicializan en ViewHolder llamando a un método especial donde se transfieren los datos, como se muestra en el párrafo anterior. Otra inicialización tiene lugar en onCreate (por ejemplo, configurando escuchas de clics): este método se llama después de onAttachedToWindow utilizando un delegado especial que controla las entidades clave de la lógica del 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();
}

Siempre limpia a los oyentes


Si hay campos dependientes en el formulario, es posible que necesitemos onDetachedFromWindow. Considere el siguiente caso: el formulario de traducción tiene muchos campos, entre los cuales hay una lista desplegable. Dependiendo del valor seleccionado en la lista, un campo de entrada de formulario adicional puede hacerse visible o uno existente puede desaparecer.
valor desplegable para elegir el tipo de traducciónVisibilidad del campo de entrada del período de pagovisibilidad del campo de entrada del número de teléfono
transferencia por número de teléfonofalsocierto
pagociertofalso

En el caso descrito anteriormente, es muy importante borrar todos los oyentes de widgets en el método onDetachedFromWindow, ya que si agrega el widget nuevamente a la lista, todos los oyentes se reiniciarán.

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

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

Manejar suscripciones de eventos de widget correctamente


La presentación de la pantalla que contiene los widgets debe ser notificada de los cambios en la entrada de cada widget. La implementación más obvia de cada widget usando eventos de emisión y suscribiéndose a todos los eventos con un presentador de pantalla. El evento debe contener la identificación del widget y sus datos. Es mejor implementar esta lógica para que los valores de entrada actuales se guarden en el modelo de pantalla y cuando haga clic en el botón, los datos terminados se envíen en la solicitud. Con este enfoque, es más fácil implementar la validación del formulario: ocurre cuando se hace clic en un botón y, si no hubo errores, la solicitud se envía con los datos del formulario guardados de antemano.

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

Existe una segunda opción de implementación, en la que los eventos de los widgets con sus datos solo se producen después de hacer clic en el botón, y no mientras escribe, es decir, recopilamos todos los datos inmediatamente antes de enviar la solicitud. Con esta opción, habrá muchos menos eventos, pero vale la pena señalar que esta implementación puede no ser trivial en la práctica, y se necesitará una lógica adicional para verificar si se han recibido todos los eventos.

Unificar todos los requisitos


Una vez más, me gustaría señalar que el caso descrito solo es posible después de coordinar los requisitos con el backend.

Qué requisitos deben ser unificados:

  • Tipos de campo La aplicación móvil debe esperar cada campo para su visualización y procesamiento correctos.
  • — , , , .
  • , .
  • . , : , , — , -, .



Esto es necesario para que el componente recibido en la respuesta sea conocido por la aplicación móvil para la correcta visualización y procesamiento de la lógica.

El segundo matiz es que los componentes de la forma en sí son generalmente independientes entre sí, sin embargo, algunos escenarios son posibles cuando, por ejemplo, la visibilidad de un elemento depende del estado de otro, como se describió anteriormente. Para implementar esta lógica, es necesario que los elementos dependientes siempre se unan, y la respuesta debe contener una descripción de la lógica, qué componentes dependen unos de otros y cómo. Y, por supuesto, todo esto debe acordarse con el equipo del servidor antes de comenzar el desarrollo.

Conclusión


Por lo tanto, al implementar incluso un caso estándar como completar una lista dinámica, no puede detenerse en soluciones ya existentes. Para nosotros, este era un nuevo concepto, que nos permitió seleccionar piezas atómicas de lógica y representación de pantallas gigantes y logramos obtener una solución extensible que funciona y que es fácil de mantener debido a la similitud de widgets con otras vistas. En nuestra implementación, los widgets se desarrollaron en términos del patrón RxPM: después de agregar carpetas, se hizo aún más conveniente usar widgets, pero esta es una historia completamente diferente.

Enlaces útiles


  1. Marco de desarrollo de aplicaciones de Android Surf
  2. Módulo de widgets
  3. Nuestra implementación de render simple de listas complejas
  4. Presentación Patrón de modelo

All Articles