几年前,我来到Tinkoff的一个新项目“ Customers and Projects” ( 客户和项目 )刚刚开始。
现在,我不记得当时对我的新架构的感受了。 但是我可以肯定地记得:在通常的网络访问和基础访问之外,在其他地方使用Rx是不寻常的。 既然该体系结构已经通过了一些发展的演进路径,那么我想最后谈谈发生了什么以及发生了什么。

以我的观点,所有当前流行的体系结构-MVP,MVVM甚至MVI-长期以来一直处于舞台上,并不总是应有的。 他们没有缺陷吗? 我看到很多。 我们在自己的位置决定足以承受它,并(重新)发明了一种新的异步架构。
我将简要描述我对当前体系结构不满意的地方。 有些观点可能会引起争议。 也许您从未遇到过这种情况,而是编写了完美的,通常来说是绝地的编程。 然后原谅我,一个罪人。
所以我的痛苦是:
- 巨大的Presenter / ViewModel。
- MVI中有大量的开关盒。
- 无法重用Presenter / ViewModel的某些部分,因此,需要复制代码。
- 可以在任何地方修改的可变变量堆。 因此,这种代码难以维护和修改。
- 未分解的屏幕更新。
- 编写测试很难。
发行
在每时每刻,应用程序都具有确定其行为和用户所见内容的特定状态。 此状态包括变量的所有值-从简单标志到单个对象。 这些变量中的每一个都有自己的生命,并由代码的不同部分控制。 您只能逐一检查所有应用程序,才能确定应用程序的当前状态。
关于现代Kotlin MVI架构的文章
第1章。进化就是我们的一切
最初,我们在MVP上进行了编写,但有些改动。 它是MVP和MVI的混合体。 来自MVP的实体具有演示者和View界面的形式:
interface NewTaskView { val newTaskAction: Observable<NewTaskAction> val taskNameChangeAction: Observable<String> val onChangeState: Consumer<SomeViewState> }
已经在这里您可以注意到要注意的问题:这里的视图距离MVP规范很远。 演示者中有一种方法:
fun bind(view: SomeView): Disposable
在外部,传递了一个接口实现,该接口实现以响应方式订阅UI更改。 它已经充满了MVI!
更多就是更多。 在Presenter中,创建了不同的交互器并订阅了View更改,但是它们没有直接调用UI方法,而是返回了一些全局状态,其中存在所有可能的屏幕状态:
compositeDisposable.add( Observable.merge(firstAction, secondAction) .observeOn(AndroidSchedulers.mainThread()) .subscribe(view.onChangeState)) return compositeDisposable
class SomeViewState(val progress: Boolean? = null, val error: Throwable? = null, val errorMessage: String? = error?.message, val result: TaskUi? = null)
活动是SomeViewStateMachine接口的后代:
interface SomeViewStateMachine { fun toSuccess(task: SomeUiModel) fun toError(error: String?) fun toProgress() fun changeSomeButton(buttonEnabled: Boolean) }
当用户单击屏幕上的某些内容时,演示者进入一个事件,他创建了一个新模型,该模型由特殊类绘制:
class SomeViewStateResolver(private val stateMachine: SomeViewStateMachine) : Consumer<SomeViewState> { override fun accept(stateUpdate: SomeViewState) { if (stateUpdate.result != null) { stateMachine.toSuccess(stateUpdate.result) } else if (stateUpdate.error != null && stateUpdate.progress == false) { stateMachine.toError(stateUpdate.errorMessage) } else if (stateUpdate.progress == true) { stateMachine.toProgress() } else if (stateUpdate.someButtonEnabled != null) { stateMachine.changeSomeButton(stateUpdate.someButtonEnabled) } } }
同意,有些奇怪的MVP,甚至离MVI还很远。 寻找灵感。
第2章Redux

在谈论他与其他开发人员的问题时,我们的领导 (当时还是现在)的Sergey Boishtyan了解了Redux 。
看了Dorfman关于所有架构的讨论并与Redux 一起玩了之后,我们决定使用它来升级我们的架构。
但是首先,让我们仔细研究一下架构,并了解其优缺点。
动作片
描述动作。
动作创造者
他就像系统分析师一样:格式化并补充客户需求规范,以便程序员理解他。
当用户在屏幕上单击时,ActionsCreator会形成一个用于中间件的操作(某种业务逻辑)。 业务逻辑为我们提供了特定Reducer接收和提取的新数据。
如果再次查看图片,可能会注意到诸如存储之类的对象。 商店商店减速器。 也就是说,我们看到前端兄弟-不幸的兄弟-猜测可以将一个大对象锯成许多小对象,每个小对象都将负责自己的屏幕部分。 这只是一个很棒的想法!
简单的ActionCreators的示例代码(小心,JavaScript!):
export function addTodo(text) { return { type: ADD_TODO, text } } export function toggleTodo(index) { return { type: TOGGLE_TODO, index } } export function setVisibilityFilter(filter) { return { type: SET_VISIBILITY_FILTER, filter } }
减速器
动作描述了某些事情发生的事实,但是没有指示应用程序的状态应如何响应以进行更改,这对于Reducer来说是可行的。
简而言之,Reducer知道如何分解/刷新屏幕。
优点:
缺点:
- 最喜欢的切换。
function todoApp(state = initialState, action) { switch (action.type) { case SET_VISIBILITY_FILTER: return Object.assign({}, state, { visibilityFilter: action.filter }) case ADD_TODO: return Object.assign({}, state, { todos: [ ...state.todos, { text: action.text, completed: false } ] }) default: return state }
- 一堆状态对象。
- 将逻辑分离到ActionCreator和Reducer中。
是的,在我们看来,ActionCreator和Reducer的分离并不是连接模型和屏幕的最佳选择,因为编写instanceof(is)是一种不好的方法。 在这里,我们发明了我们的架构!
第3章EBA

在EBA中,什么是Action和ActionCreator:
typealias Action = () -> Unit typealias ActionMapper<T> = (T) -> Action interface ActionCreator<T> : (T) -> (Observable<Action>)
是的,架构的一半是类型别名和接口。 简单等于优雅!
为了采取行动而不传送任何数据,需要采取行动。 由于ActionCreator返回一个Observable,因此我们不得不将Action包装在另一个lambda中以传输一些数据。 事实证明,ActionMapper是一种类型化的Action,通过它我们可以传递更新屏幕/视图所需的内容。
基本假设:
一个ActionCreator-屏幕的一部分在第一段中,所有内容都很清楚:为了避免难以理解的交叉更新,我们同意一个ActionCreator只能更新其屏幕部分。 如果是列表,则仅更新列表,如果仅按钮则更新。
不需要匕首但是,一个奇怪的是,为什么匕首不能取悦我们? 我告诉你
一个典型的故事是项目中有一个抽象的谢尔盖又名匕首大师(又称“这个抽象是做什么?”)。
事实证明,如果您尝试使用匕首,则必须每次都向每个新的(不仅是新的)开发人员进行解释。 也许您自己已经忘记了此注释的作用,而您去了google。
所有这些都极大地增加了创建特征的过程,而没有带来太多的便利。 因此,我们决定由我们自己动手创建所需的东西,因为没有代码生成,所以组装起来会更快。 是的,我们将花费额外的五分钟时间来手动编写所有依赖项,但是我们将节省大量的编译时间。 是的,我们并没有到处都抛弃匕首,它在全球范围内使用,它创建了一些通用的东西,但是我们用Java编写它们是为了更好的优化,以免吸引kapt。
建筑方案 :

Component是Dagger中相同组件的类似物,只是没有Dagger。 他的任务是创建活页夹。 活页夹将ActionCreators连接在一起。 从View到Binder事件会发生什么,从Binder到View,将发送更新屏幕的操作。
动作创造者

现在,让我们看看这是什么样的东西-ActionCreator。 在最简单的情况下,它只是单向地处理动作。 假设有这样一种情况:用户单击“创建任务”按钮。 应该打开另一个屏幕,我们将在此处描述它,而无需任何其他请求。
为此,我们只需使用我们心爱的Jake的RxBinding订阅按钮,然后等待用户单击它。 发生点击后,Binder会将事件发送到特定的ActionCreator,后者将调用我们的Action,这将为我们打开一个新屏幕。 请注意,没有开关。 接下来,我将在代码中显示为什么会这样。
如果我们突然需要访问网络或数据库,则可以在此处发出这些请求,但是要通过通过接口调用传递给ActionCreator构造函数的交互器进行调用:
免责声明:这里的代码格式不完全正确,我有本文的规则,因此代码易于阅读。
class LoadItemsActionCreator( private val getItems: () -> Observable<List<ViewTyped>>, private val showLoadedItems: ActionMapper<DiffResult<ViewTyped>>, private val diffCalculator: DiffCalculator<ViewTyped>, private val errorItem: ErrorView, private val emptyItem: ViewTyped? = null) : ActionOnEvent
“通过其调用的接口”一词的意思是确切地声明getItems的声明方式(此处ViewTyped是我们用于处理列表的接口)。 顺便说一下,我们已在应用程序的八个不同部分重用了该ActionCreator,因为它写的尽可能多。
由于事件具有响应性,因此我们可以在其中添加其他运算符来组装一条链,例如,startWith(showLoadingAction)用于显示加载,onErrorReturn(errorAction)用于显示带有错误的屏幕状态。
所有这一切都是反应性的!
例子
class AboutFragment : CompositionFragment(R.layout.fragment_about) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val component = AboutComponent( setVersionName = { { appVersion.text = it } }, openPdfAction = { (url, name) -> { openPdf(url, name) } }) val events = AboutEventsImpl( bindEvent = bindEvent, openPolicyPrivacyEvent = confidentialityPolicy.clicks(), openProcessingPersDataEvent = personalDataProtection.clicks(), unbindEvent = unBindEvent) component.binder().bind(events) }
最后,我们以代码为例来看看该架构。 首先,我选择了最简单的屏幕之一-关于应用程序,因为它是静态屏幕。
考虑创建一个组件:
val component = AboutComponent( setVersionName = { { appVersion.text = it } }, openPdfAction = { (url, name) -> { openPdf(url, name) } } )
组件参数-Actions / ActionMappers-帮助将View与ActionCreators关联。 在ActionMapper'e setVersionName中,我们传递项目的版本,并将此值分配给屏幕上的文本。 在openPdfAction中,一对指向文档的链接和一个名称,以打开用户可以阅读此文档的下一个屏幕。
这是组件本身:
class AboutComponent( private val setVersionName: ActionMapper<String>, private val openPdfAction: ActionMapper<Pair<String, String>>) { fun binder(): AboutEventsBinder { val openPolicyPrivacy = OpenPdfActionCreator(openPdfAction, someUrlString) val openProcessingPersonalData = OpenPdfActionCreator(openPdfAction, anotherUrlString) val setVersionName = setVersionName.toSimpleActionCreator( moreComponent::currentVersionName ) return AboutEventsBinder(setVersionName, openPolicyPrivacy, openProcessingPersonalData) } }
让我提醒您:
typealias Action = () -> Unit typealias ActionMapper<T> = (T) -> Action
好的,让我们继续。
fun binder(): AboutEventsBinder
让我们更详细地了解AboutEventsBinder。
class AboutEventsBinder(private val setVersionName: ActionOnEvent, private val openPolicyPrivacy: ActionOnEvent, private val openProcessingPersonalData: ActionOnEvent) : BaseEventsBinder<AboutEvents>() { override fun bindInternal(events: AboutEvents): Observable<Action> { return Observable.merge( setVersionName(events.bindEvent), openPolicyPrivacy(events.openPolicyPrivacyEvent), openProcessingPersonalData(events.openProcessingPersDataEvent)) } }
ActionOnEvent是另一个类型别名,以免每次都写。
ActionCreator<Observable<*>>
在AboutEventsBinder中,我们传递ActionCreator并调用它们,将其绑定到特定事件。 但是,要了解所有这些如何连接,让我们看一下基类-BaseEventsBinder。
abstract class BaseEventsBinder<in EVENTS : BaseEvents>( private val uiScheduler: Scheduler = AndroidSchedulers.mainThread() ) { fun bind(events: EVENTS) { bindInternal(events).observeOn(uiScheduler) .takeUntil(events.unbindEvent) .subscribe(Action::invoke) } protected abstract fun bindInternal(events: EVENTS): Observable<Action> }
我们看到了熟悉的bindInternal方法,我们在后继方法中对其进行了重新定义。 现在考虑绑定方法。 所有的魔术都在这里。 我们接受BaseEvents接口的继承者,将其传递给bindInternal来连接事件和动作。 一旦我们说了什么,我们就在ui流上执行并订阅。 我们还看到了一个有趣的hack-takeUntil。
interface BaseEvents { val unbindEvent: EventObservable }
在BaseEvents中定义了unbindEvent字段以控制退订后,我们必须在所有继承人中实现它。 这个奇妙的字段使您可以在活动结束后自动取消订阅。 太好了! 现在,您不再关注,也不必担心生命周期并安然入睡。
val openPolicyPrivacy = OpenPdfActionCreator(openPdfAction, policyPrivacyUrl) val openProcessingPersonalData = OpenPdfActionCreator(openPdfAction, personalDataUrl)
返回组件。 在这里您已经可以看到重用的方法。 我们编写了一个可以打开pdf查看屏幕的类,而对我们来说url无关紧要。 没有代码重复。
class OpenPdfActionCreator( private val openPdfAction: ActionMapper<Pair<String, String>>, private val pdfUrl: String) : ActionOnEvent { override fun invoke(event: EventObservable): Observable<Action> { return event.map { openPdfAction(pdfUrl to pdfUrl.substringAfterLast(FILE_NAME_DELIMITER)) } } }
ActionCreator代码也尽可能简单,这里我们只执行一些字符串操作。
让我们回到该组件并考虑以下ActionCreator:
setVersionName.toSimpleActionCreator(moreComponent::currentVersionName)
一旦我们变得太懒了,就无法编写相同且本质上简单的ActionCreators。 我们使用了Kotlin的力量并写了extension'y。 例如,在这种情况下,我们只需要将静态字符串传递给ActionMapper。
fun <R> ActionMapper<R>.toSimpleActionCreator( mapper: () -> R): ActionCreator<Observable<*>> { return object : ActionCreator<Observable<*>> { override fun invoke(event: Observable<*>): Observable<Action> { return event.map { this@toSimpleActionCreator(mapper()) } } } }
有时候,我们根本不需要传输任何东西,而只需要调用某些动作即可,例如,打开以下屏幕:
fun Action.toActionCreator(): ActionOnEvent { return object : ActionOnEvent { override fun invoke(event: EventObservable): Observable<Action> { return event.map { this@toActionCreator } } } }
因此,在组件结束后,返回片段:
val events = AboutEventsImpl( bindEvent = bindEvent, openPolicyPrivacyEvent = confidentialityPolicy.throttleFirstClicks(), openProcessingPersDataEvent = personalDataProtection.throttleFirstClicks(), unbindEvent = unBindEvent)
在这里,我们看到了一个负责接收用户事件的类的创建。 取消绑定和绑定只是我们使用Trello的Navi库获取的屏幕生命周期事件。
fun <T> NaviComponent.observe(event: Event<T>): Observable<T> = RxNavi.observe(this, event) val unBindEvent: Observable<*> = observe(Event.DESTROY_VIEW) val bindEvent: Observable<*> = Observable.just(true) val bindEvent = observe(Event.POST_CREATE)
事件接口描述了特定屏幕的事件,此外它还必须继承BaseEvents。 以下始终是接口的实现。 在这种情况下,事件与屏幕上的事件是一对一的,但是碰巧您需要将两个事件保持在一起。
例如,在发生错误的情况下,打开和重新加载时屏幕加载的事件应合并为一个事件-仅加载屏幕。
interface AboutEvents : BaseEvents { val bindEvent: EventObservable val openPolicyPrivacyEvent: EventObservable val openProcessingPersDataEvent: EventObservable } class AboutEventsImpl(override val bindEvent: EventObservable, override val openPolicyPrivacyEvent: EventObservable, override val openProcessingPersDataEvent: EventObservable, override val unbindEvent: EventObservable) : AboutEvents
我们回到片段,将所有内容组合在一起! 我们要求组件创建绑定器并将绑定器返回给我们,然后在其上调用bind方法,在此我们传递监视屏幕事件的对象。
component.binder().bind(events)
我们已经在这个架构上写了一个项目大约两年了。 共享功能的速度对管理人员的快乐没有任何限制! 他们没有时间提出新的建议,因为我们已经完成了旧的建议。 该体系结构非常灵活,允许您重用大量代码。
这种体系结构的缺点可以称为状态不守恒。 我们没有像MVI中那样描述屏幕状态的完整模型,但是我们可以处理它。 喜欢-见下文。
第4章奖金
我认为每个人都知道分析的问题:没有人喜欢编写它,因为它遍历所有层级并消除了挑战。 前一段时间,我们不得不面对它。 但是由于我们的体系结构,获得了非常漂亮的实现。
因此,我的想法是:分析通常是为了响应用户操作而留下的。 我们只有一个可以累积用户操作的类。 好的,让我们开始吧。
第一步 我们通过将事件包装在trackAnalytics中来稍微更改BaseEventsBinder基类:
abstract class BaseEventsBinder<in EVENTS : BaseEvents>( private val trackAnalytics: TrackAnalytics<EVENTS> = EmptyAnalyticsTracker(), private val uiScheduler: Scheduler = AndroidSchedulers.mainThread()) { @SuppressLint("CheckResult") fun bind(events: EVENTS) { bindInternal(trackAnalytics(events)).observeOn(uiScheduler) .takeUntil(events.unbindEvent) .subscribe(Action::invoke) } protected abstract fun bindInternal(events: EVENTS): Observable<Action> }
第二步 我们创建trackAnalytics变量的稳定实现,以保持向后兼容性,并且不会破坏尚不需要分析的继承人:
interface TrackAnalytics<EVENTS : BaseEvents> { operator fun invoke(events: EVENTS): EVENTS } class EmptyAnalyticsTracker<EVENTS : BaseEvents> : TrackAnalytics<EVENTS> { override fun invoke(events: EVENTS): EVENTS = events }
第三步 我们为所需的屏幕(例如,项目列表屏幕)编写TrackAnalytics接口的实现:
class TrackProjectsEvents : TrackAnalytics<ProjectsEvents> { override fun invoke(events: ProjectsEvents): ProjectsEvents { return object : ProjectsEvents by events { override val boardClickEvent = events.boardClickEvent.trackTypedEvent { allProjectsProjectClick(it.title) } override val openBoardCreationEvent = events.openBoardCreationEvent.trackEvent { allProjectsAddProjectClick() } override val openCardsSearchEvent = events.openCardsSearchEvent.trackEvent { allProjectsSearchBarClick() } } } }
在这里,我们再次以代表的形式使用Kotlin的力量。 我们已经有一个由我们创建的接口继承程序-在这种情况下为ProjectsEvents。 但是对于某些事件,您需要重新定义事件的进行方式,并通过发送分析在事件周围添加绑定。 实际上,trackEvent只是doOnNext:
inline fun <T> Observable<T>.trackEvent(crossinline event: AnalyticsSpec.() -> Unit): Observable<T> = doOnNext { event(analyticsSpec) } inline fun <T> Observable<T>.trackTypedEvent(crossinline event: AnalyticsSpec.(T) -> Unit): Observable<T> = doOnNext { event(analyticsSpec, it) }
第4步 剩下的要转移到活页夹。 由于我们将其构造在组件中,因此如果您突然需要,我们将有机会向构造函数添加其他依赖项。 现在,ProjectsEventsBinder构造函数将如下所示:
class ProjectsEventsBinder( private val loadItems: LoadItemsActionCreator, private val refreshBoards: ActionOnEvent, private val openBoard: ActionCreator<Observable<BoardId>>, private val openScreen: ActionOnEvent, private val openCardSearch: ActionOnEvent, trackAnalytics: TrackAnalytics<ProjectsEvents>) : BaseEventsBinder<ProjectsEvents>(trackAnalytics)
您可以在GitHub上查看其他示例。
问与答
您如何保持屏幕状态?没办法 我们阻止方向。 但是我们也使用参数/ intent并将OPENED_FROM_BACKSTACK变量保存在那里。 在设计活页夹时,我们会加以考虑。 如果为假-从网络加载数据。 如果为true-从缓存。 这使您可以快速重新创建屏幕。
对于反对定向阻止的每个人:尝试测试和存储有关用户翻转手机的频率以及有多少用户处于不同定向的分析。 结果可能令人惊讶。
我不想编写组件,如何用匕首交朋友?我不建议这样做,但是如果您不介意编译时间,也可以通过匕首创建Component。 但是我们没有尝试。
我不是用kotlin编写的,用Java实现有哪些困难?相同的都可以用Java编写,只是看起来不会那么漂亮。
如果您喜欢这篇文章,那么下一部分将是关于如何在这种架构上编写测试的(这将清楚为什么会有这么多的接口)。 Spoiler-编写很容易,您可以在除组件之外的所有层上进行编写,但是您无需测试它,只需创建一个绑定对象。
感谢Tinkoff Business移动开发团队的同事为本文撰写所提供的帮助。