Backend-Driven UI с помощью виджетов

    Рассмотрим особенности данного подхода и нашу реализацию с помощью виджетов, их концепцию, преимущества и отличия от других вью в Android.



    Backend-Driven UI — подход, который позволяет формировать UI-компоненты на основе серверного респонса. Описание API должно содержать типы компонентов и их свойства, а приложение должно отображать нужные компоненты в зависимости от типов и свойств. В общем случае, может быть заложена логика компонентов, и для мобильного приложения они представляют собой черный ящик, так как каждый компонент может обладать независимой от остального приложения логикой и может быть сконфигурирован сервером произвольно, в зависимости от требуемой бизнес-логики. Именно поэтому этот подход часто используют в банковских приложениях: например, когда необходимо отобразить форму перевода с большим количеством динамически задаваемых полей. Приложение заранее не знает состав формы и порядок полей в ней, следовательно, этот подход — единственная возможность написать код без костылей. Помимо этого, он добавляет гибкости: со стороны сервера можно в любой момент времени изменять состав формы, и мобильное приложение будет к этому готово.

    Кейс использования




    Сверху представлены следующие типы компонентов:

    1. Список счетов, доступных для перевода;
    2. Название типа перевода;
    3. Поле для ввода номера телефона (имеет маску для ввода и содержит иконку для возможности выбора контакта с устройства);
    4. Поле для ввода суммы перевода.

    Также на форме возможно любое количество других компонентов, заложенных бизнес-логикой и определяемых на этапе проектирования. Информация о каждом компоненте, который приходит в ответе от сервера, должна соответствовать требованиям, и каждый компонент должен быть ожидаем мобильным приложением для его корректной обработки.



    У разных полей ввода отличаются их маски и правила валидации; кнопка может иметь шиммер-анимацию при загрузке; виджет для выбора счета списания может иметь анимацию при скролле, и так далее.

    Все UI-компоненты независимы друг от друга, и логику можно вынести в отдельные вью с разным зонами ответственности — назовем их виджетами. Каждый виджет получает свою конфигурацию в ответе сервера и инкапсулирует логику отображения и обработки данных.

    При реализации экрана лучше всего подойдет RecyclerView, элементы которого будут содержать виджеты. ViewHolder каждого уникального элемента списка будет инициализировать виджет и отдавать ему необходимые для отображения данные.

    Концепция виджетов


    Рассмотрим виджеты подробнее. По своей сути виджет — это кастомная вью «на максималках». Обычная кастомная вью тоже может содержать данные и логику их отображения, но виджет подразумевает собой нечто большее — он имеет презентер, модель экрана и имеет собственный DI-скоуп.

    Перед тем, как погрузиться в подробности реализации виджетов, обсудим их преимущества:

    • По своей сути виджеты напоминают фрагменты, но можно сказать, что они «легче» фрагментов, потому что они более узко специализированы — их ответственность ограничена одним UI-компонентом на экране, в то время как фрагменты могут содержать сложную верстку и управлять несколькими вью.
    • С помощью виджетов можно реализовать экран с нетривиальной версткой, не имея при этом проблем с производительностью при рендере, если реализовывать ту же верстку с помощью нескольких фрагментов.
    • Наконец, что так же немаловажно — при использовании виджетов презентер экрана содержит минимум логики: загрузку данных, передачу данных вью для их рендера, подписка на события виджетов.

    Базовая реализация


    Для реализации масштабируемых виджетов мы используем базовые классы для наиболее часто используемых ViewGroup, у которых есть свой DI-скоуп. Все базовые классы в свою очередь наследуются от общего интерфейса, который содержит все необходимое для инициализации виджетов.

    Самый простой кейс использования виджетов — статичные вью, указанные прямо в верстке. После реализации классов виджета можно смело добавлять его в XML-макет, не забыв указать при этом его id в верстке (на основе id будет формироваться DI-скоуп виджета).

    В данной статье рассмотрим подробнее динамические виджеты, так как описанный выше кейс формы перевода с произвольным набором полей удобно решается с их помощью.

    Любой виджет, как статический, так и динамический, в нашей реализации почти ничем не отличается от обычной вью в терминах MVP. Как правило, для реализации виджета необходимы 4 класса:

    1. Класс для вью, где происходит инфлейт верстки и отображение контента;

      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. Класс для презентера, где описана основная логика виджета, например:

      1. загрузка данных и передача их вью для рендера;
      2. подписки на различные события и эмит событий изменения инпута виджета;

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

      В нашей реализации класс RxBus представляет собой шину на основе PublishSubject для отправки событий и подписки на них.
    3. Класс для модели экрана, с помощью которого происходит получение презентером данных и передача их для рендера на вью (в терминах паттерна Presentation Model);

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

    4. Класс-конфигуратор для реализации DI, с помощью которого поставляются зависимости для виджета, имеющие нужный скоуп, а также происходит инжект презентера в его вью.

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


    Единственное отличие виджетов от нашей реализации полноценных экранов (Activity, Fragment) состоит в том, что виджет не имеет множества методов жизненного цикла (onStart, onResume, onPause). У него есть только метод onCreate, который показывают, что виджет в данный момент создал свой скоуп, а уничтожение скоупа происходит в методе onDetachedFromWindow. Но для удобства и сохранения единообразия, презентер виджета получает те же методы жизненного цикла, что и остальные экраны. Эти события автоматически передаются ему от родителя. Стоить отметить, что базовый класс презентера виджетов является тем же базовым классом презентеров других экранов.

    Использование динамических виджетов


    Перейдем к реализации кейса, описанного в начале статьи.

    1. В презентере экрана происходит загрузка данных для формы перевода, данные передаются вью для рендера. На данном этапе нам не важно, является вью экрана активити, фрагментом или виджетом. Нас интересует только наличие RecyclerView и рендер динамической формы с его помощью.

      // 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. Данные формы передаются в адаптер списка и рендерятся с помощью виджетов, которые находятся в ViewHolder для каждого уникального элемента формы. Нужный ViewHolder для рендера компонента определяется на основе заранее определенных типов компонентов формы.

      // 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. Инициализация виджета происходит в методе bind у ViewHolder. Помимо передачи данных для рендера, здесь также важно задать уникальный id виджету, на основе которого будет формироваться его DI-скоуп. В нашем случае каждый элемент формы имел уникальный id, который отвечал за назначение инпута и приходил в респонсе помимо типа элемента (типы могут повторяться на форме).

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

    4. Метод initialize инициализирует данные вью виджета, которые затем с помощью метода жизненного цикла onCreate передаются в презентер, где происходит установка значений полей в модель виджета и его рендер.

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


    Подводные камни


    Как видно из описания, реализация очень проста и интуитивно понятна. Тем не менее, существуют нюансы, которые необходимо учесть.

    Учитывайте жизненный цикл виджетов


    Так как базовые классы виджетов являются наследниками знакомых нам часто используемых ViewGroup, жизненный цикл виджетов нам также известен. Как правило, инициализация виджетов происходит в ViewHolder’е путем вызова специального метода, куда передаются данные, как было показано в предыдущем пункте. Прочая инициализация происходит в onCreate (например, установка слушателей клика) — этот метод вызывается после onAttachedToWindow с помощью специального делегата, который управляет ключевыми сущностями логики виджета.

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

    Всегда очищайте листенеры


    При наличии зависимых полей на форме нам может понадобиться onDetachedFromWindow. Рассмотрим следующий кейс: форма перевода имеет множество полей, среди которых есть выпадающий список. В зависимости от значения, выбранного в списке, может стать видимым дополнительное поле ввода формы или исчезнуть существующее.
    значение дропдауна для выбора типа перевода видимость поля ввода периода оплаты видимость поля ввода номера телефона
    перевод по номеру телефона false true
    оплата жку true false

    В описанном выше кейсе очень важно очищать все листенеры виджета в методе onDetachedFromWindow, так как при повторном добавлении виджета в список все листенеры будут проинициализированы заново.

    // TextInputFieldWidget
    override fun onCreate() {
         initListeners()
    }
    
    override fun onDetachedFromWindow() {
          super.onDetachedFromWindow()
          clearListeners()
    }
    

    Правильно обрабатывайте подписки на события виджета


    Презентер экрана, содержащего виджеты, должен оповещаться об изменениях инпута каждого виджета. Наиболее очевидна реализация с помощью эмита событий каждого виджета и подписки на все события презентером экрана. Событие должно содержать id виджета и его данные. Лучше всего реализовать эту логику так, чтобы в модели экрана сохранялись текущие значения инпутов и при нажатии на кнопку готовые данные отправлялись в запросе. При таком подходе проще реализовать валидацию формы: она происходит при нажатии на кнопку, и если не было ошибок, то запрос отправляется с заранее сохраненными данными формы.

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

    Есть и второй вариант реализации, при котором события от виджетов с их данными приходят только после нажатия на кнопку, а не по мере ввода, то есть собираем все данные непосредственно перед отправкой запроса. При таком варианте будет намного меньше событий, но стоит отметить, что данная реализация может оказаться нетривиальной на практике, и понадобится дополнительная логика проверки, все ли события были получены.

    Унифицируйте все требования


    Хочется еще раз отметить, что описанный кейс возможен только после согласования требований с бэкендом.

    Какие требования необходимо унифицировать:

    • Типы полей. Каждое поле должно быть ожидаемо мобильным приложением для его корректного отображения и обработки.
    • Валидация полей — следует согласовать валидацию каждого поля, как она происходит, откуда брать тексты ошибок валидации, которые могут отличаться в зависимости от типа ошибки.
    • Маски и их правила, подойдут ли готовые парсеры масок или придётся их кастомизировать.
    • Стили полей. Помимо указания типа поля, на бэкенде вполне может быть зашито полное описание конкретной формы: например, могут прийти две кнопки, которые имеют разные стили — одна из них имеет цветной фон и по её нажатию происходит отправка данных формы, а вторая является вспомогательной и ведёт на веб-вью, как показано на скрине ниже.



    Это необходимо для того, чтобы полученный в респонсе компонент был известен мобильному приложению для корректного отображения и обработки логики.

    Второй нюанс — сами по себе компоненты формы в общем случае независимы друг от друга, тем не менее, возможны некоторые сценарии, когда например видимость одного элемента зависит от состояния другого, как было описано выше. Для реализации такой логики необходимо, чтобы зависимые элементы всегда приходили вместе, а также респонс должен содержать описание логики, какие компоненты зависят друг от друга и каким образом. И конечно же, все это должно быть согласовано с серверной командой еще перед началом разработки.

    Заключение


    Таким образом, при реализации даже такого стандартного кейса, как заполнение динамического списка, всегда можно не останавливаться на уже существующих решениях. Для нас это была новая концепция, позволяющая выделять из огромных неповоротливых экранов атомарные куски логики и представления, и нам удалось получить рабочее расширяемое решение, которое легко поддерживать из-за сходства виджетов с другими вью. В нашей реализации виджеты получили развитие в терминах паттерна RxPM — после добавления биндингов пользоваться виджетами стало еще удобнее, но это уже совсем другая история.

    Полезные ссылки


    1. Фреймворк Surf для разработки андроид-приложений
    2. Модуль виджетов
    3. Наша реализация простого рендера сложных списков
    4. PresentationModel pattern
    Surf
    Компания

    Комментарии 1

      0
      Достаточно очевидно и не нашел самого интересного. Реализации dynamic actions, т.е. какие то действия с UI компонентами которые так же приходят с сервера, и интерпретируются в мобильном приложении, например какое то условие что если юзер ввел телефон в поле PHONE_INPUT, по выставить disabled=true для поля MONEY.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое