Android体系结构中的Zen隔离组件



几年前,我们在Badoo开始使用MVI方法进行Android开发。 它旨在简化复杂的代码库并避免状态错误的问题:在简单的场景中,这很容易,但是系统越复杂,以正确的形式维护它就越困难,而遗漏错误也就越容易。

在Badoo中,所有应用程序都是异步的-不仅是因为用户可以通过UI使用广泛的功能,还因为服务器可以单向发送数据。 使用聊天模块中的旧方法,我们遇到了几个奇怪的难以重现的错误,我们不得不花费很多时间来消除它们。

我们伦敦办公室的同事Zsolt Kocsi( 媒体Twitter )讲述了如何使用MVI构建易于重用的独立组件,使用此方法时有哪些优点以及遇到的不利之处。

这是有关Badoo Android体系结构的系列文章中的第三篇。 链接到前两个:

  1. 基于Kotlin的现代MVI体系结构
  2. 用Kotlin构建反应组件系统

请勿停留在连接不良的组件上。


弱连接被认为比强连接要好。 如果仅依赖接口而不依赖特定的实现,那么替换组件将更容易,无需重写大多数代码即可更轻松地切换到其他实现,从而简化了单元测试。

我们通常在这里结束并说,我们已经在连接方面做了一切可能。

但是,这种方法不是最佳的。 假设您有一个类A,需要使用其他三个类的功能:B,C和D。即使通过接口引用它们,每个以下类的A类也会变得更加困难:

  • 即使不使用它们,他也知道所有接口中的所有方法,它们的名称和返回类型;
  • 在测试A时,您需要配置更多的模拟对象模拟对象 );
  • 在我们没有或不想拥有B,C和D的其他情况下,重复使用A会更加困难。

当然,正是A类必须确定为此所需的最少接口集( SOLID中的接口隔离原理)。 但是,实际上,为了方便起见,我们所有人都必须处理采用不同方法的情况:我们采用实现某些功能的现有类,将其所有公共方法提取到接口中,然后在需要上述类的地方使用此接口。 也就是说,使用该接口不是根据所需的组件,而是根据其他组件可以提供的组件。

使用这种方法,情况会随着时间的推移而恶化。 每当我们添加新功能时,我们的类就会链接到他们需要了解的新接口的网络中。 类的数量在增加,测试变得越来越困难。

结果,当您需要在不同的上下文中使用它们时,几乎没有连接它们的纠结,即使通过接口,也几乎不可能移动它们。 您可以进行类比:您想使用香蕉,而香蕉却是挂在树上的猴子手中的,因此,在香蕉的负载下,您会得到整块丛林。 简而言之,传输过程要花费很多时间,很快您就会开始问自己为什么在实践中很难重用代码。

黑匣子组件


如果我们希望组件易于使用和可重用,那么我们就不需要了解两件事:

  • 关于在其他地方使用它;
  • 关于与其内部实现无关的其他组件。

原因很明确:如果您不了解外界,那么您将不会与外界联系。

我们真正想要的组件是什么:

  • 定义自己的输入(输入)和输出(输出)数据;
  • 不要考虑这些数据来自何处或去向何处;
  • 它必须是自给自足的,这样我们就不必知道使用该组件的内部结构。

您可以将组件视为黑匣子或集成电路。 她具有输入和输出联系人。 您焊接它们-微型电路成为它一无所知的系统的一部分。



到目前为止,假设我们一直在谈论双向数据流:如果类A需要某些东西,它将通过接口B提取一个方法,并以该函数返回的值的形式接收结果。



但是随后A知道了B,因此我们想避免这种情况。

当然,这种方案对于低级别的实现功能是有意义的。 但是,如果我们需要一个可重复使用的组件,其功能就像一个独立的黑盒子,则需要确保它对外部接口,方法名称或返回值一无所知。

我们传递到单向性


但是如果没有接口名称和方法,我们将无法调用任何东西! 剩下的就是使用单向数据流,在其中我们只需获取输入并生成输出:



首先,这看起来像是一个限制,但是这种解决方案具有许多优点,下面将对此进行讨论。

第一篇文章中我们就知道特征(Feature)定义了它们自己的输入数据(Wish)和它们自己的输出数据(State)。 因此,对于他们来说,愿望来自何处或国家何处都无关紧要。



那就是我们所需要的! 可以在可以输入特征的任何地方使用这些特征,并且可以使用输出进行任何所需的操作。 而且,由于功能不能直接与其他组件通信,因此它们是独立的,不相关的模块。

现在,采用View并对其进行设计,使其成为一个独立的模块。

首先,视图应尽可能简单,以便仅处理其内部任务。

什么样的任务? 其中有两个:

  • 渲染ViewModel(输入);
  • 根据用户操作(输出)触发ViewEvents。

为什么要使用ViewModel? 为什么不直接绘制特征状态?

  • (非)在屏幕上显示功能不是实现的一部分。 如果数据来自多个来源,则View应该能够呈现自身。
  • 无需在视图中反映状态的复杂性 ViewModel应该仅包含保持简单所需的现成信息。

此外,View对以下各项不应该感兴趣:

  • 所有这些ViewModel来自何处;
  • 触发ViewEvent时会发生什么;
  • 任何业务逻辑;
  • 分析跟踪
  • 日记
  • 其他任务。

所有这些都是外部任务,View不应与其连接。 让我们停止并总结一下View的简单性:

interface FooView : Consumer<ViewModel>, ObservableSource<Event> { data class ViewModel( val title: String, val bgColor: Int ) sealed class Event { object ButtonClicked : Event() data class TextFocusChanged(val hasFocus: Boolean) : Event() } } 

Android实现应:

  1. 通过ID查找Android视图。
  2. 通过设置ViewModel的值来实现使用者接口的accept方法。
  3. 设置侦听器(ClickListeners)以与UI交互以生成特定事件。

一个例子:

 class FooViewImpl @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, private val events: PublishRelay<Event> = PublishRelay.create<Event>() ) : LinearLayout(context, attrs, defStyle), FooView, // delegate implementing ObservableSource to our Relay ObservableSource<Event> by events { // 1. find the views private val title: TextView by lazy { findViewById<TextView>(R.id.title)} private val panel: ViewGroup by lazy { findViewById<ViewGroup>(R.id.panel)} private val button: Button by lazy { findViewById<Button>(R.id.button)} private val editText: EditText by lazy { findViewById<EditText>(R.id.editText)} // 2. set listeners to trigger Events override fun onFinishInflate() { super.onFinishInflate() button.setOnClickListener { events.accept(Event.ButtonClicked) } editText.setOnFocusChangeListener { _, hasFocus -> events.accept(Event.TextFocusChanged(hasFocus)) } } // 3. render the ViewModel override fun accept(vm: ViewModel) { title.text = vm.title panel.setBackgroundColor(ContextCompat.getColor(context, vm.bgColor)) } } 

如果不限于功能和视图,则此方法将使其他组件看起来像这样:

 interface GenericBlackBoxComponent : Consumer<Input>, ObservableSource<Output> { sealed class Input sealed class Output } 

现在一切都变得清晰了!



团结,团结,团结!


但是,如果我们有不同的组件,并且每个组件都有自己的输入和输出,该怎么办? 我们将连接它们!

幸运的是,这可以使用Binder轻松完成,这也有助于创建正确的范围,正如我们从第二篇文章中了解到的:

 // will automatically dispose of the created rx subscriptions when the lifecycle ends: val binder = Binder(lifecycle) // connect some observable sources to some consumers with element transformation: binder.bind(outputA to inputB using transformer1) binder.bind(outputB to inputA using transformer2) 

第一个优势:无需修改即可轻松扩展


使用仅暂时连接的黑盒形式的无关组件使我们能够添加新功能,而无需修改现有组件。

举一个简单的例子:



在这里,要素(F)和视图(V)相互简单连接。

相应的绑定将是:

 bind(feature to view using stateToViewModelTransformer) bind(view to feature using uiEventToWishTransformer) 


假设我们要向该系统添加一些UI事件的跟踪。

 internal object AnalyticsTracker : Consumer<AnalyticsTracker.Event> { sealed class Event { object ProfileImageClicked: Event() object EditButtonClicked : Event() } override fun accept(event: AnalyticsTracker.Event) { // TODO Implement actual tracking } } 

好消息是,我们可以简单地通过重新使用现有的输出视图通道来做到这一点:



在代码中,它看起来像这样:

 bind(feature to view using stateToViewModelTransformer) bind(view to feature using uiEventToWishTransformer) // +1 line, nothing else changed: bind(view to analyticsTracker using uiEventToAnalyticsEventTransformer) 

只需一行附加绑定即可添加新功能。 现在,我们不仅不能更改一行代码视图,而且甚至不知道输出用于解决新问题。

显然,现在,我们可以轻松避免其他麻烦和不必要的复杂组件。 它们保持简单。 您只需将组件连接到现有组件即可向系统添加功能。

第二个优点:易于反复使用


使用功能和视图的示例,可以清楚地看到,我们可以添加新的输入源或输出数据的使用者,只需一行就可以绑定。 这极大地促进了在应用程序不同部分中组件的重用。

但是,这种方法不限于类。 这种使用界面的方式使我们能够描述任何大小的独立反应组件。

通过将自己限制在某些输入和输出数据上,我们无需了解所有组件的工作原理,因此,我们很容易避免意外地将组件内部与系统的其他部分链接。 而且,无需绑定,您可以轻松,简单地重复使用组件。

我们将在以下文章之一中返回到这一点,并考虑使用此技术连接高层组件的示例。

第一个问题:将绑定放在哪里?


  1. 选择抽象级别。 根据架构的不同,它可能是一个Activity,一个片段或某些ViewController。 我希望您在没有UI的那些部分中仍然具有某种程度的抽象。 例如,在DI上下文树的某些范围内。
  2. 在与UI的此部分相同的级别上为绑定创建一个单独的类。 如果是FooActivity,FooFragment或FooViewController,则可以在其旁边放置FooBindings。
  3. 确保在活动,片段等中使用的相同组件实例中嵌入FooBindings。
  4. 要形成绑定的范围,请使用“活动”或“片段”生命周期。 如果此循环不依赖于Android,则可以手动创建触发器,例如,在创建或销毁DI范围时。 范围的其他示例在第二篇文章中介绍

第二个问题:测试


由于我们的组件对别人一无所知,因此通常不需要存根。 简化了测试,以验证组件对输入数据的正确响应并产生预期结果。

对于功能,这意味着:

  • 测试某些输入数据是否生成预期状态(输出)的能力。

而对于View:

  • 我们可以测试特定的ViewModel(输入)是否导致UI的预期状态;
  • 我们可以测试与UI交互的模拟是否导致预期的ViewEvent(输出)中的初始化。

当然,组件之间的交互不会神奇地消失。 我们只是从组件本身提取了这些任务。 他们仍然需要测试。 但是呢

在我们的案例中,绑定器负责连接组件:

 // this is wherever you put your bindings, depending on your architecture class BindingEnvironment( private val component1: Component1, private val component2: Component2 ) { fun createBindings(lifecycle: Lifecycle) { val binder = Binder(lifecycle) binder.bind(component1 to component2 using Transformer()) } } 

我们的测试应确认以下内容:

1.变形金刚(映射器)。

某些连接具有映射器,您需要确保它们正确转换了元素。 在大多数情况下,一个简单的单元测试就足够了,因为映射器通常也很简单:

 @Test fun testCase1() { val transformer = Transformer() val testInput = TODO() val actualOutput = transformer.invoke(testInput) val expectedOutput = TODO() assertEquals(expectedOutput, actualOutput) } 

2.沟通。

您需要确保正确配置连接。 如果由于某种原因未建立它们之间的联系,那么各个组件和映射器的工作意义何在? 通过使用存根,初始化源以及检查客户端是否收到了预期的结果来设置绑定环境,可以测试所有这些:

 class BindingEnvironmentTest { lateinit var component1: ObservableSource<Component1.Output> lateinit var component2: Consumer<Component2.Input> lateinit var bindings: BindingEnvironment @Before fun setUp() { val component1 = PublishRelay.create() val component2 = mock() val bindings = BindingEnvironment(component1, component2) } @Test fun testBindings() { val simulatedOutputOnLeftSide = TODO() val expectedInputOnRightSide = TODO() component1.accept(simulatedOutputOnLeftSide) verify(component2).accept(expectedInputOnRightSide) } } 

而且尽管尽管测试时您将不得不编写与其他方法相同数量的代码,但是自足的组件使测试各个部分变得更加容易,因为任务是明确分开的。

思考的食物


尽管以黑匣子图的形式描述我们的系统对于一般理解是有益的,但这仅在系统的大小相对较小的情况下才有效。

五到八条装订线是可以接受的。 但是,如果连接更多,将很难理解正在发生的事情:





我们面对这样一个事实,即随着链接数量的增加(甚至比所提供的代码片段更多),情况变得更加复杂。 原因不仅在于行数-可以为不同的方法对某种绑定进行分组和提取-而且还因为使所有内容保持可见变得越来越困难。 这总是一个不好的迹象。 如果许多不同的组件位于同一级别,则无法想象所有可能的交互。

原因是使用组件-黑匣子还是其他?

显然,如果您要描述的范围最初很复杂,那么除非您将系统分成较小的部分,否则没有方法可以使您摆脱上述问题。 即使没有大量的绑定,它也将变得很复杂,但不会那么明显。 另外,如果复杂性被明确表示而不是隐藏起来,那就更好了。 最好能看到越来越多的单行连接列表,这些列表使您想起有多少个独立组件,而不是不知道隐藏在不同方法调用中的类中的那些链接。

由于组件本身很简单(它们是黑匣子,并且没有其他处理程序流入其中),所以将它们分开更容易,这意味着这是朝着正确方向迈出的一步。 我们将难度移至一个位置-绑定列表,一目了然,可让您评估总体情况并开始考虑如何摆脱困境。

寻找解决方案花费了我们很多时间,并且仍在进行中。 我们计划在以下文章中讨论如何解决此问题。 保持联系!

Source: https://habr.com/ru/post/zh-CN463781/


All Articles