带有小部件的后端驱动的UI

考虑这种方法的功能以及我们使用小部件的实现,其概念,优势以及与Android中其他视图的区别。



后端驱动的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的配置程序类,借助该类传递了具有所需作用域的窗口小部件依赖项,并将presenter注入其视图中。

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


窗口小部件与我们实现的完整屏幕(活动,片段)之间的唯一区别是,窗口小部件没有很多生命周期方法(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. 该窗口小部件在ViewHolder的bind方法中初始化。除了传输渲染的数据外,为窗口小部件设置唯一的ID也很重要,在此基础上将形成其DI范围。在我们的例子中,表单的每个元素都有一个唯一的ID,该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。请考虑以下情况:翻译表单有很多字段,其中有一个下拉列表。根据列表中选择的值,附加的表单输入字段可能会变为可见,或者现有的表单输入字段可能会消失。
选择翻译类型的下拉值付款期输入字段的可见性电话号码输入栏可见性
通过电话号码转移真正
付款真正

在上述情况下,清除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. Android应用程序开发框架Surf
  2. 小部件模块
  3. 我们对复杂列表简单渲染的实现
  4. PresentationModel模式

All Articles