Interface do usuário orientada a back-end com widgets

Considere os recursos dessa abordagem e nossa implementação usando widgets, seu conceito, vantagens e diferenças em relação a outras visualizações no Android.



Interface do usuário orientada por back-end - uma abordagem que permite criar componentes da interface do usuário com base na resposta do servidor. A descrição da API deve conter os tipos de componentes e suas propriedades, e o aplicativo deve exibir os componentes necessários, dependendo dos tipos e propriedades. Em geral, a lógica dos componentes pode ser estabelecida e, para um aplicativo móvel, eles são uma caixa preta, pois cada componente pode ter lógica independente do restante do aplicativo e pode ser configurado arbitrariamente pelo servidor, dependendo da lógica de negócios necessária. É por isso que essa abordagem é frequentemente usada em aplicativos bancários: por exemplo, quando você precisa exibir um formulário de conversão com um grande número de campos definidos dinamicamente. O aplicativo não conhece antecipadamente a composição do formulário e a ordem dos campos nele, portanto,essa abordagem é a única maneira de escrever código sem muletas. Além disso, adiciona flexibilidade: do lado do servidor, você pode alterar o formulário a qualquer momento, e o aplicativo móvel estará pronto para isso.

Caso de uso




Os seguintes tipos de componentes são apresentados acima:

  1. Lista de contas disponíveis para transferência;
  2. O nome do tipo de tradução;
  3. Campo para inserir um número de telefone (possui uma máscara para inserir e contém um ícone para selecionar contatos do dispositivo);
  4. Campo para inserir o valor da transferência.

Também no formulário, é possível qualquer número de outros componentes incorporados na lógica de negócios e determinados no estágio de design. As informações sobre cada componente que vem na resposta do servidor devem atender aos requisitos e cada componente deve ser esperado pelo aplicativo móvel para o processamento correto.



Campos de entrada diferentes têm máscaras e regras de validação diferentes; o botão pode ter uma animação cintilante no momento da inicialização; um widget para selecionar uma conta de baixa pode ter animação ao rolar e assim por diante.

Todos os componentes da interface do usuário são independentes um do outro, e a lógica pode ser tomada em visões separadas com diferentes áreas de responsabilidade - vamos chamá-los de widgets. Cada widget recebe sua configuração na resposta do servidor e encapsula a lógica de exibição e processamento de dados.

Ao implementar a tela, o RecyclerView é mais adequado, cujos elementos conterão widgets. O ViewHolder de cada item da lista exclusivo inicializará o widget e fornecerá os dados necessários para exibição.

Conceito de widget


Vamos considerar os widgets com mais detalhes. Na essência, um widget é uma visualização personalizada "na velocidade máxima". Uma visualização personalizada comum também pode conter dados e a lógica de sua exibição, mas o widget implica algo mais - ele tem um apresentador, um modelo de tela e seu próprio escopo DI.

Antes de mergulhar nos detalhes da implementação de widgets, discutimos suas vantagens:

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


Para implementar widgets escaláveis, usamos as classes base para os ViewGroups mais usados, que têm seus próprios escopos DI. Todas as classes base, por sua vez, são herdadas da interface comum, que contém tudo o necessário para inicializar widgets.

O caso mais fácil para o uso de widgets são as visualizações estáticas, especificadas diretamente no layout. Após implementar as classes de widget, você pode adicioná-lo com segurança ao layout XML, sem esquecer de especificar seu ID no layout (com base no ID, o escopo de DI de um widget será formado).

Neste artigo, consideramos os widgets dinâmicos com mais detalhes, pois o caso do formulário de conversão descrito acima com um conjunto arbitrário de campos é convenientemente resolvido com a ajuda deles.

Qualquer widget, estático e dinâmico, em nossa implementação quase não difere da visão comum em termos de MVP. Normalmente, são necessárias 4 classes para implementar um widget:

  1. Visualizar classe, onde ocorre o layout do layout e a exibição do conteúdo;

    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. Uma classe para o apresentador, onde a lógica básica do widget é descrita, por exemplo:

    1. carregar dados e transmiti-los para uma renderização;
    2. Assinando vários eventos e emitindo eventos de alterações na entrada do widget;

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

    Em nossa implementação, a classe RxBus é um barramento PublishSubject para enviar eventos e se inscrever neles.
  3. Uma classe para o modelo de tela, com a ajuda da qual o apresentador recebe dados e os transfere para renderização em uma exibição (em termos do padrão Modelo de Apresentação);

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

  4. Uma classe de configurador para implementar DI, com a ajuda de quais dependências para o widget são entregues com o escopo desejado e o apresentador é injetado em sua visualização.

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


A única diferença entre os widgets e nossa implementação de telas completas (Atividade, Fragmento) é que o widget não possui muitos métodos de ciclo de vida (onStart, onResume, onPause). Ele possui apenas o método onCreate, que mostra que o widget criou seu escopo no momento e a coleta é destruída no método onDetachedFromWindow. Mas, por conveniência e consistência, o apresentador do widget obtém os mesmos métodos de ciclo de vida que o restante das telas. Esses eventos são automaticamente transmitidos a ele pelos pais. Deve-se notar que a classe base do apresentador de widgets é a mesma classe base de apresentadores de outras telas.

Usando widgets dinâmicos


Vamos seguir para a implementação do caso descrito no início do artigo.

  1. No apresentador de tela, os dados para o formulário de conversão são carregados, os dados são transmitidos para a visualização para renderização. Nesse estágio, não importa se a exibição da tela de atividades é um fragmento ou widget. Estamos interessados ​​apenas em ter o RecyclerView e renderizar um formulário dinâmico com ele.

    // 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. Os dados do formulário são transferidos para o adaptador de lista e renderizados usando widgets que estão no ViewHolder para cada elemento de formulário exclusivo. O ViewHolder desejado para renderizar o componente é determinado com base em tipos predefinidos de componentes do formulário.

    // 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. O widget é inicializado no método de ligação do ViewHolder. Além de transmitir dados para a renderização, também é importante definir um ID exclusivo para o widget, com base no qual seu escopo de DI será formado. No nosso caso, cada elemento do formulário tinha um ID exclusivo, responsável pela nomeação da entrada e veio em resposta além do tipo de elemento (os tipos podem ser repetidos no formulário).

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

  4. O método initialize inicializa os dados de exibição do widget, que são transmitidos ao apresentador usando o método de ciclo de vida onCreate, onde os valores do campo são definidos para o modelo do widget e sua renderização.

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


Rochas subaquáticas


Como pode ser visto na descrição, a implementação é muito simples e intuitiva. No entanto, existem nuances que precisam ser consideradas.

Considere o ciclo de vida dos widgets


Como as classes base de widgets são herdadas dos ViewGroups comumente usados, também conhecemos o ciclo de vida dos widgets. Normalmente, os widgets são inicializados no ViewHolder chamando um método especial para o qual os dados são transferidos, conforme mostrado no parágrafo anterior. Outra inicialização ocorre no onCreate (por exemplo, configurando listeners de clique) - esse método é chamado após onAttachedToWindow usando um representante especial que controla as principais entidades da lógica do 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();
}

Sempre limpe os ouvintes


Se houver campos dependentes no formulário, podemos precisar de onDetachedFromWindow. Considere o seguinte caso: o formulário de conversão possui muitos campos, entre os quais há uma lista suspensa. Dependendo do valor selecionado na lista, um campo de entrada de formulário adicional pode se tornar visível ou um campo existente pode desaparecer.
valor suspenso para escolher o tipo de traduçãoVisibilidade do campo de entrada do período de pagamentovisibilidade do campo de entrada do número de telefone
transferir por número de telefonefalsoverdade
Forma de pagamentoverdadefalso

No caso descrito acima, é muito importante limpar todos os ouvintes do widget no método onDetachedFromWindow, pois se você adicionar o widget à lista novamente, todos os ouvintes serão reinicializados.

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

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

Manipular assinaturas de eventos de widget corretamente


A apresentação da tela que contém os widgets deve ser notificada das alterações na entrada de cada widget. A implementação mais óbvia de cada widget usando eventos de emissão e assinando todos os eventos com um apresentador de tela. O evento deve conter o ID do widget e seus dados. É melhor implementar essa lógica para que os valores de entrada atuais sejam salvos no modelo de tela e quando você clica no botão, os dados finais são enviados na solicitação. Com essa abordagem, é mais fácil implementar a validação de formulário: ocorre quando um botão é clicado e, se não houver erros, a solicitação é enviada com os dados do formulário salvos com antecedência.

// 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 uma segunda opção de implementação, na qual os eventos dos widgets com seus dados ocorrem somente após clicar no botão, e não enquanto você digita, ou seja, coletamos todos os dados imediatamente antes de enviar a solicitação. Com esta opção, haverá muito menos eventos, mas vale a pena notar que essa implementação pode não ser trivial na prática, e será necessária lógica adicional para verificar se todos os eventos foram recebidos.

Unificar todos os requisitos


Gostaria mais uma vez de observar que o caso descrito é possível somente após a coordenação dos requisitos com o back-end.

Quais requisitos precisam ser unificados:

  • Tipos de campo. Cada campo deve ser esperado pelo aplicativo móvel para sua exibição e processamento corretos.
  • — , , , .
  • , .
  • . , : , , — , -, .



Isso é necessário para que o componente recebido na resposta seja conhecido pelo aplicativo móvel para a exibição e processamento corretos da lógica.

A segunda nuance é que os componentes do formulário são geralmente independentes um do outro, no entanto, alguns cenários são possíveis quando, por exemplo, a visibilidade de um elemento depende do estado de outro, conforme descrito acima. Para implementar essa lógica, é necessário que os elementos dependentes sempre se reúnam, e a resposta deve conter uma descrição da lógica, quais componentes dependem um do outro e como. E, claro, tudo isso deve ser acordado com a equipe do servidor antes de iniciar o desenvolvimento.

Conclusão


Assim, ao implementar mesmo um caso padrão como o preenchimento de uma lista dinâmica, você não pode sempre parar em soluções já existentes. Para nós, esse era um novo conceito, que nos permitia selecionar peças atômicas de lógica e representação em telas enormes e conseguimos obter uma solução extensível e fácil de manter devido à semelhança de widgets com outras visualizações. Em nossa implementação, os widgets foram desenvolvidos em termos do padrão RxPM - após a adição de binders, ficou ainda mais conveniente usar widgets, mas essa é uma história completamente diferente.

Links Úteis


  1. Estrutura de desenvolvimento de aplicativos Android Surf
  2. Módulo de widget
  3. Nossa implementação de renderização simples de listas complexas
  4. Modelo de apresentação

All Articles