从复制粘贴到组件:在不同应用程序中重用代码



Badoo开发了多个应用程序,每个应用程序都是具有自己的特点,管理,产品和工程团队的单独产品。 但是我们大家都在同一个办公室一起工作,解决类似的问题。

每个项目的开发都是以自己的方式进行的。 代码库不仅受到不同时间框架和产品解决方案的影响,还受到开发人员的远见的影响。 最后,我们注意到这些项目具有相同的功能,在实现上根本不同。

然后,我们决定采用一种结构,该结构将使我们有机会在应用程序之间重用功能。 现在,我们不再开发单个项目中的功能,而是创建可集成到所有产品中的通用组件。 如果您对我们的处理方式感兴趣,欢迎光临。

但是首先,让我们先讨论这些问题,解决这些问题会导致创建通用组件。 其中有几个:

  • 在应用程序之间复制粘贴;
  • 将棍棒插入车轮的过程;
  • 不同的项目架构。



本文是我使用AppsConf 2019报告的文本版本,可在此处查看。

问题:复制粘贴


前段时间,当树木更模糊,草更绿,我还不到一岁时,我们经常遇到以下情况。

有一个开发人员,我们称他为Lesha。 他为自己的任务制作了一个很酷的模块,将其告知同事,并将其放在他的应用程序存储库中,供他使用。

问题在于我们所有的应用程序都位于不同的存储库中。



开发人员Andrey目前仅在不同存储库中的另一个应用程序上工作。 他想在他的任务中使用此模块,这与Lesha从事的任务类似。 但是有一个问题:重复使用代码的过程已完全调试。

在这种情况下,Andrei会写出自己的决定(在80%的情况下会发生),也可以复制粘贴Lyosha的解决方案并更改其中的所有内容,以使其适合他的应用程序,任务或心情。



之后,Lesha可以通过为自己的任务添加代码更改来更新其模块。 他不知道其他版本,只会更新他的存储库。

这种情况带来了几个问题。

首先,我们有几个应用程序,每个应用程序都有自己的开发历史。 在处理每个应用程序时,产品团队通常会创建难以将其带入单个结构的解决方案。

其次,独立的团队参与项目,这些项目之间的沟通不畅,因此很少互相通知一个或另一个模块的更新/重用。

第三,应用程序体系结构非常不同:从MVP到MVI,从上帝活动到单一活动。

好吧,“程序的亮点”:应用程序位于不同的存储库中,每个存储库都有自己的进程。

在解决这些问题的开始,我们设定了最终目标:在所有应用程序之间重用我们的最佳实践(逻辑和UI)。

决策:我们建立流程


在上述问题中,有两个与过程有关:

  1. 共享项目的两个存储库之间的墙无懈可击。
  2. 独立的团队,没有建立的沟通和产品应用团队的不同要求。

让我们从第一个开始:我们正在处理两个具有相同模块版本的存储库。 从理论上讲,我们可以使用git-subtree或类似的解决方案,并将通用项目模块放入单独的存储库中。



在修改过程中会出现问题。 与具有稳定的API并通过外部资源分发的开源项目不同,内部项目中经常发生更改,从而破坏了一切。 当使用子树时,每次这样的迁移都变得很痛苦。

来自iOS团队的同事也有类似的经验,但事实并非如此,就像安东·舒肯(Anton Schukin)在去年的Mobius会议上谈到的那样。

在研究和理解了他们的经验之后,我们切换到了单个存储库。 现在,所有Android应用程序都位于一个地方,这给我们带来了一些好处:

  • 您可以使用Gradle模块安全地重用代码;
  • 我们设法使用一种用于构建和测试的基础架构将工具链连接到CI。
  • 这些更改消除了团队之间的生理和心理障碍,因为现在我们可以自由地使用彼此的开发和解决方案。

当然,该解决方案也具有缺点。 我们有一个庞大的项目,有时不受IDE和Gradle约束。 该问题可以通过Android Studio中的“加载/卸载”模块部分解决,但是如果您需要同时在所有应用程序上工作并且经常切换,则很难使用它们。

第二个问题-团队之间的互动-由几个部分组成:

  • 没有建立良好沟通的独立团队;
  • 通用模块职责分配不明确;
  • 产品团队的不同要求。

为了解决这个问题,我们成立了团队,致力于在每个应用程序中实现某些功能:例如,聊天或注册。 除了开发之外,他们还负责将这些组件集成到应用程序中。

产品团队已经掌握了现有组件,可以根据特定项目的需求对其进行改进和定制。

因此,从构思阶段到生产开始,创建可重复使用的组件现在是整个公司流程的一部分。

解决方案:简化架构


我们重用的下一步是简化架构。 我们为什么要这样做?

我们的代码库承载了数年发展的历史遗产。 随着时间和人员的变化,方法也发生了变化。 因此,我们发现自己处于整个建筑动物园的环境中,这导致了以下问题:

  1. 通用模块的集成几乎比编写新模块慢。 除了功能的功能外,还必须忍受组件和应用程序的结构。
  2. 必须在应用程序之间进行切换的开发人员通常会花费大量时间来掌握新方法。
  3. 通常,包装是从一种方法编写到另一种方法的,这相当于模块集成中代码的一半。

最后,我们选择了MVI方法,该方法是在MVICore库( GitHub )中构建的。 我们对其功能之一特别感兴趣-原子状态更新,该功能始终保证有效性。 我们走得更远,合并了逻辑层和表示层的状态,减少了碎片。 因此,我们进入一个结构,其中唯一的实体负责逻辑,并且视图仅显示从状态创建的模型。



通过级别之间模型的转换来实现职责分离。 因此,我们获得了可重复使用形式的好处。 我们从外部连接元素,也就是说,每个元素都不怀疑另一个元素的存在-它们只是放弃了一些模型并对所发生的事情做出反应。 这允许您通过为模型编写适配器来拉出组件并在其他地方使用它们。

让我们看一个简单屏幕的示例,它在现实中的外观。



我们使用基本的RxJava接口来指示元素所使用的类型。 输入由接口Consumer <T>表示,输出由ObservableSource <T>表示。

// input = Consumer<ViewModel> // output = ObservableSource<Event> class View( val events: PublishRelay<Event> ): ObservableSource<Event> by events, Consumer<ViewModel> { val button: Button val textView: TextView init { button.setOnClickListener { events.accept(Event.ButtonClick) } } override fun accept(model: ViewModel) { textView.text = model.text } } 

使用这些接口,我们可以将View表示为Consumer <ViewModel>和ObservableSource <Event>。 请注意,ViewModel仅包含屏幕状态,与MVVM无关。 收到模型后,我们可以显示其中的数据,然后单击按钮将事件发送到外部。

 // input = Consumer<Wish> // output = ObservableSource<State> class Feature: ReducerFeature<Wish, State>( initialState = State(counter = 0), reducer = ReducerImpl() ) { class ReducerImpl: Reducer<Wish, State> { override fun invoke(state: State, wish: Wish) = when (wish) { is Increment -> state.copy(counter = state.counter + 1) } } } 

Feature已经为我们实现了ObservableSource和Consumer; 我们需要在那里转移初始状态(计数器等于0),并指示如何更改此状态。

传递愿望后,将调用Reducer,它根据最后一个状态创建一个新的。 除了Reducer,该逻辑还可以由其他组件描述。 您可以在此处了解有关它们的更多信息。

创建两个元素之后,我们必须将它们连接起来。


 val eventToWish: (Event) -> Wish = { when (it) { is ButtonClick -> Increment } } val stateToModel: (State) -> ViewModel = { ViewModel(text = state.counter.toString()) } Binder().apply { bind(view to feature using eventToWish) bind(feature to view using stateToModel) } 

首先,我们说明如何将一种类型的元素转换为另一种类型。 因此,ButtonClick变为Increment,并且State的counter字段变为文本。

现在,我们可以创建具有所需转换的每个链。 为此,我们使用活页夹。 它允许您在ObservableSource和Consumer之间创建关系,并观察其生命周期。 所有这些都具有很好的语法。 这种类型的连接使我们建立了一个灵活的系统,该系统使我们能够分别拔出和使用元素。

在编写了ObservableSource和Consumer的包装后,MVICore元素与我们的“ zoo”体系结构非常兼容。 例如,我们可以将Wish / State中的Clean Architecture中的Use Case方法包装起来并在链中使用,而不是在Feature中使用。



组成部分


最后,我们继续介绍组件。 他们是什么样的人?

考虑应用程序中的屏幕,并将其分为逻辑部分。



可以区分:

  • 顶部带有徽标和按钮的工具栏;
  • 带有个人资料和徽标的卡片;
  • Instagram部分。

这些部分中的每一个都是可以在完全不同的上下文中重用的组件。 因此,Instagram部分可以成为另一个应用程序中的个人资料编辑的一部分。



在一般情况下,一个组件是多个View,逻辑元素和内部嵌套的组件,它们通过通用功能结合在一起。 随之而来的问题是:如何将它们组装成受支持的结构?

我们遇到的第一个问题是MVICore有助于创建和绑定元素,但不提供通用的结构。 当重用公共模块中的元素时,不清楚将这些部分放在哪里:公共部分内部还是应用程序侧?

在一般情况下,我们绝对不想让应用程序分散。 理想情况下,我们努力寻求一种结构,该结构将使我们能够获得依赖关系并以所需的生命周期将组件作为一个整体进行组装。

最初,我们将组件划分为多个屏幕。 元素的连接是在创建用于活动或片段的DI容器之后进行的。 这些容器已经知道所有依赖关系,可以访问View和生命周期。

 object SomeScopedComponent : ScopedComponent<SomeComponent>() { override fun create(): SomeComponent { return DaggerSomeComponent.builder() .build() } override fun SomeComponent.subscribe(): Array<Disposable> = arrayOf( Binder().apply { bind(feature().news to otherFeature()) bind(feature() to view()) } ) } 

问题从两个地方同时出现:

  1. DI开始使用逻辑,这导致在一类中描述整个组件。
  2. 由于容器连接到活动或片段,并且至少描述了整个屏幕,因此在这样的屏幕/容器上有很多元素,这转化为大量代码以连接该屏幕的所有依赖项。

为了有序解决问题,我们首先将逻辑放在单独的组件中。 因此,我们可以收集此组件内的所有Feature并通过输入和输出与View进行通信。 从接口的角度看,它看起来像一个常规的MVICore元素,但是同时它是由其他几个元素创建的。



解决了这个问题后,我们分担了连接各个元素的责任。 但是我们仍然共享屏幕上的组件,这显然不适合我们,导致在一个地方存在大量依赖项。

 @Scope internal class ComponentImpl @Inject constructor( private val params: ScreenParams, news: NewsRelay, @OnDisposeAction onDisposeAction: () -> Unit, globalFeature: GlobalFeature, conversationControlFeature: ConversationControlFeature, messageSyncFeature: MessageSyncFeature, conversationInfoFeature: ConversationInfoFeature, conversationPromoFeature: ConversationPromoFeature, messagesFeature: MessagesFeature, messageActionFeature: MessageActionFeature, initialScreenFeature: InitialScreenFeature, initialScreenExplanationFeature: InitialScreenExplanationFeature?, errorFeature: ErrorFeature, conversationInputFeature: ConversationInputFeature, sendRegularFeature: SendRegularFeature, sendContactForCreditsFeature: SendContactForCreditsFeature, screenEventTrackingFeature: ScreenEventTrackingFeature, messageReadFeature: MessageReadFeature?, messageTimeFeature: MessageTimeFeature?, photoGalleryFeature: PhotoGalleryFeature?, onlineStatusFeature: OnlineStatusFeature?, favouritesFeature: FavouritesFeature?, isTypingFeature: IsTypingFeature?, giftStoreFeature: GiftStoreFeature?, messageSelectionFeature: MessageSelectionFeature?, reportingFeature: ReportingFeature?, takePhotoFeature: TakePhotoFeature?, giphyFeature: GiphyFeature, goodOpenersFeature: GoodOpenersFeature?, matchExpirationFeature: MatchExpirationFeature, private val pushIntegration: PushIntegration ) : AbstractMviComponent<UiEvent, States>( 

在这种情况下,正确的解决方案是破坏组件。 正如我们在上面看到的,每个屏幕都包含许多逻辑元素,我们可以将它们划分为独立的部分。

经过一番思考,我们来到了一个树形结构,并从现有组件中天真地构建了这个方案:



当然,维护两个树的同步(从View和逻辑)几乎是不可能的。 但是,如果组件负责显示其View,则可以简化此方案。 研究了已经创建的解决方案之后,我们依靠Uber的RIB重新考虑了我们的方法。



这种方法背后的思想与MVICore的基础非常相似。 RIB是一种“黑匣子”,通过依赖项(即输入和输出)中严格定义的接口与之进行通信。 尽管在快速迭代的产品中支持此类接口显然很复杂,但我们仍获得了重用代码的巨大机会。

因此,与以前的迭代相比,我们得到:

  • 组件内部的封装逻辑;
  • 支持嵌套,可以将屏幕分成多个部分;
  • 通过输入/输出的严格接口与其他组件进行交互,并支持MVICore;
  • 组件依赖关系的编译时安全连接(依赖于Dagger作为DI)。

当然,这还不是全部。 GitHub上的存储库包含更详细和最新的描述。

在这里,我们拥有一个完美的世界。 它具有可用来构建完全可重用的树的组件。

但是我们生活在一个不完美的世界中。

欢迎来到现实!


在一个不完美的世界中,我们必须忍受很多事情。 我们担心以下情况:

  • 不同的功能:尽管统一了,但我们仍在处理具有不同要求的单个产品;
  • 支持:如何在A / B测试下没有新功能?
  • 旧版(所有在我们的新体系结构之前编写的内容)。

解决方案的复杂度呈指数级增长,因为每个应用程序都向通用组件添加了自己的东西。

考虑注册过程作为集成到应用程序中的通用组件的示例。 通常,注册是一系列影响整个流程的屏幕。 每个应用程序都有不同的屏幕和自己的UI。 最终目标是制造一个灵活的可重用组件,这也将帮助我们解决上面列出的问题。



杂项要求


每个应用程序都有自己独特的注册变体,无论是从逻辑方面还是从UI方面。 因此,我们开始最小化组件中的功能:通过下载数据并路由整个流程。



这样的容器将数据从服务器传输到应用程序,然后通过逻辑将其转换为完成的屏幕。 唯一的要求是,传递给此类容器的屏幕必须满足依赖关系才能与整个流程的逻辑进行交互。

通过几个应用程序完成了这一技巧后,我们注意到屏幕的逻辑几乎相同。 在理想的世界中,我们将通过自定义视图来创建通用逻辑。 问题是如何自定义它们。

您可以从MVICore的描述中回想起,“视图”和“功能”均基于ObservableSource和Consumer的接口。 使用它们作为抽象,我们可以替换实现而无需更改主要部分。



因此,我们通过划分UI来重用逻辑。 结果,支持变得更加方便。

技术支持


考虑A / B测试中视觉元素的变化。 在这种情况下,我们的逻辑不会改变,这使我们可以用另一个View实现替换ObservableSource和Consumer的现有接口。



当然,有时新的要求与已经编写的逻辑矛盾。 在这种情况下,我们总是可以返回到原始方案,在该方案中,应用程序将提供整个屏幕。 对我们来说,这是一种“黑匣子”,只要观察到容器的界面,对容器传递的内容与容器无关紧要。

整合性


如实践所示,大多数应用程序都将“活动”作为基本单位,人们早就知道它们之间的通信方式。 我们要做的就是学习如何在Activity中包装组件以及如何通过输入和输出传递数据。 事实证明,这种方法适用于片段。

对于单活动应用程序,没有什么变化。 几乎所有框架都提供了RIB组件可以包装的基本元素。

最后


通过这些阶段后,我们大大提高了公司项目之间的代码重用百分比。 目前,组件的数量已接近100,其中大多数组件可同时为多个应用程序实现功能。

我们的经验表明:

  • 尽管设计通用组件的复杂性有所提高,但鉴于不同应用程序的需求,从长远来看,它们的支持要容易得多;
  • 通过彼此隔离地构建组件,我们极大地简化了它们到基于不同原理构建的应用程序中的集成;
  • 流程修订,以及对组件开发和支持的重视,对整体功能的质量产生积极影响。

我的同事Zsolt Kocsi之前曾写过有关MVICore及其背后思想的文章。 我强烈建议阅读他的文章,这些文章已经在我们的博客上进行了翻译( 1,2,3 )。

关于RIB,您可以阅读Uber的原始文章。 对于实践知识,我建议您从我们这里学习一些经验(英语)。

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


All Articles