Architecture EBA aka pleine réactivité

Je suis venu à Tinkoff il y a quelques années, sur un nouveau projet, Clients et Projets , qui venait juste de démarrer.
Maintenant, je ne me souviens plus de mes sentiments de cette nouvelle architecture pour moi. Mais je me souviens avec certitude: il était inhabituel que Rx soit utilisé ailleurs, en dehors des trajets habituels vers le réseau et la base. Maintenant que cette architecture a déjà franchi une voie évolutive de développement, je veux enfin parler de ce qui s'est passé et de ce qui est arrivé.



À mon avis, toutes les architectures actuellement populaires - MVP, MVVM et même MVI - ont longtemps été dans l'arène et pas toujours bien méritées. N'ont-ils pas de défauts? J'en vois beaucoup. Nous avons décidé chez nous qu’il suffisait de le supporter et (re) inventé une nouvelle architecture asynchrone.


Je décrirai brièvement ce que je n'aime pas dans les architectures actuelles. Certains points peuvent être controversés. Peut-être que vous ne l'avez jamais rencontré, vous écrivez une programmation parfaite et généralement Jedi. Alors pardonne-moi, pécheur.
Donc ma douleur est:


  • Énorme présentateur / ViewModel.
  • Une énorme quantité de boîtier de commutation dans MVI.
  • Incapacité à réutiliser des parties de Presenter / ViewModel et, par conséquent, la nécessité de dupliquer le code.
  • Des tas de variables mutables qui peuvent être modifiées de n'importe où. En conséquence, un tel code est difficile à maintenir et à modifier.
  • Mise à jour de l'écran non décomposé.
  • Il est difficile d'écrire des tests.

Problème


À chaque instant, l'application a un certain état qui définit son comportement et ce que voit l'utilisateur. Cet état inclut toutes les valeurs des variables - des simples drapeaux aux objets individuels. Chacune de ces variables vit sa propre vie et est contrôlée par différentes parties du code. Vous ne pouvez déterminer l'état actuel de l'application qu'en les vérifiant toutes l'une après l'autre.
Un article sur l'architecture moderne Kotlin MVI


Chapitre 1. L'évolution est notre tout


Au départ, nous avons écrit sur MVP, mais un peu muté. C'était un mélange de MVP et MVI. Il y avait des entités de MVP sous la forme d'un présentateur et d'une interface View:


interface NewTaskView { val newTaskAction: Observable<NewTaskAction> val taskNameChangeAction: Observable<String> val onChangeState: Consumer<SomeViewState> } 

Déjà ici, vous pouvez remarquer le hic: Voir ici est très loin des canons de MVP. Il y avait une méthode dans le présentateur:


 fun bind(view: SomeView): Disposable 

À l'extérieur, une implémentation d'interface a été transmise qui s'est abonnée de manière réactive aux modifications de l'interface utilisateur. Et ça sent déjà le MVI!


Plus c'est plus. Dans Presenter, différents interacteurs ont été créés et abonnés aux modifications de la vue, mais ils n'ont pas appelé directement les méthodes d'interface utilisateur, mais ont renvoyé un état global, dans lequel tous les états d'écran possibles étaient:


 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) 

L'activité était le descendant de l'interface SomeViewStateMachine:


 interface SomeViewStateMachine { fun toSuccess(task: SomeUiModel) fun toError(error: String?) fun toProgress() fun changeSomeButton(buttonEnabled: Boolean) } 

Lorsque l'utilisateur a cliqué sur quelque chose à l'écran, un événement est entré dans le présentateur et il a créé un nouveau modèle, qui a été dessiné par une classe spéciale:


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

D'accord, un MVP étrange, et même loin de MVI. À la recherche d'inspiration.


Chapitre 2. Redux



Parlant de ses problèmes avec d'autres développeurs, notre (alors encore) leader Sergey Boishtyan a découvert Redux .


Après avoir regardé Dorfman parler de toutes les architectures et avoir joué avec Redux , nous avons décidé de l'utiliser pour mettre à niveau notre architecture.
Mais d'abord, examinons de plus près l'architecture et examinons ses avantages et ses inconvénients.


Action
Décrit l'action.


Créateur d'action
Il est comme un analyste de systèmes: formate, complète les spécifications des besoins des clients pour que les programmeurs le comprennent.
Lorsque l'utilisateur clique sur l'écran, ActionsCreator forme une action qui va au middleware (une sorte de logique métier). La logique métier nous donne de nouvelles données qu'un réducteur particulier reçoit et tire.


Si vous regardez à nouveau l'image, vous remarquerez peut-être un objet tel que Store. Magasins de magasins Réducteurs. Autrement dit, nous voyons que les frères front-end - frères malheureux - ont deviné qu'un grand objet peut être scié en plusieurs petits, dont chacun sera responsable de sa propre partie de l'écran. Et c'est juste une merveilleuse pensée!


Exemple de code pour des ActionCreators simples (attention, 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 } } 

Réducteur


Actions décrit le fait que quelque chose s'est produit, mais n'indique pas comment l'état de l'application doit changer en réponse, cela fonctionne pour Reducer.

En bref, Reducer sait comment rafraîchir décomposée l'écran / view.


Avantages:


  • Mise à jour de l'écran décomposé.
  • Flux de données unidirectionnel.

Inconvénients:


  • Commutateur préféré à nouveau.
     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 } 
  • Un tas d'objets d'état.
  • Séparation de la logique en ActionCreator et Reducer.

Oui, il nous a semblé que la séparation d'ActionCreator et de Reducer n'est pas la meilleure option pour connecter le modèle et l'écran, car écrire instanceof (is) est une mauvaise approche. Et ici, nous avons inventé NOTRE architecture!


Chapitre 3. EBA



Qu'est-ce qu'Action et ActionCreator dans le contexte de l'ABE:


 typealias Action = () -> Unit typealias ActionMapper<T> = (T) -> Action interface ActionCreator<T> : (T) -> (Observable<Action>) 

Oui, la moitié de l'architecture est constituée de typealias et d'une interface. La simplicité est synonyme d'élégance!


Une action est nécessaire pour appeler quelque chose sans transmettre de données. Depuis ActionCreator renvoie un observable, nous avons dû envelopper Action dans un autre lambda pour transmettre des données. Et il s'est avéré que ActionMapper - une action typée à travers laquelle nous pouvons passer ce dont nous avons besoin pour mettre à jour l'écran / vue.

Postulats de base:

Un ActionCreator - une partie de l'écran

Avec le premier paragraphe, tout est clair: pour qu'il n'y ait pas d'enfer de mises à jour croisées incompréhensibles, nous avons convenu qu'un ActionCreator ne peut mettre à jour que sa partie de l'écran. S'il s'agit d'une liste, il ne met à jour que la liste, si le bouton seulement.


La dague n'est pas nécessaire

Mais, on se demande pourquoi Dagger ne nous a pas plu? Je vous dis.
Une histoire typique est quand un résumé Sergey aka maître poignard aka "Que fait cet abstrait?" Est sur le projet.


Il s'avère que si vous avez expérimenté avec un poignard, vous devez expliquer à chaque fois à chaque nouveau (et pas seulement nouveau) développeur. Ou peut-être vous-même avez déjà oublié ce que fait cette annotation, et vous allez sur Google.


Tout cela complique grandement le processus de création de fonctionnalités sans introduire beaucoup de commodité. Par conséquent, nous avons décidé de créer les choses dont nous avons besoin avec nos mains, donc ce sera plus rapide à assembler, car il n'y a pas de génération de code. Oui, nous passerons cinq minutes supplémentaires à écrire toutes les dépendances avec nos mains, mais nous économiserons beaucoup de temps sur la compilation. Oui, nous n'avons pas partout abandonné le poignard, il est utilisé au niveau mondial, il crée des choses communes, mais nous les écrivons en Java pour une meilleure optimisation, afin de ne pas attirer kapt.


Schéma d'architecture :



Le composant est un analogue du même composant de Dagger, uniquement sans Dagger. Sa tâche est de créer un classeur. Binder relie ActionCreators. De la vue à la reliure, les événements se produisent et de la reliure à la vue, des actions sont envoyées pour mettre à jour l'écran.


Créateur d'action



Voyons maintenant de quel genre de chose il s'agit - ActionCreator. Dans le cas le plus simple, il traite simplement l'action unidirectionnellement. Supposons qu'il existe un tel scénario: l'utilisateur a cliqué sur le bouton "Créer une tâche". Un autre écran devrait s'ouvrir, où nous le décrirons, sans aucune demande supplémentaire.


Pour ce faire, nous nous abonnons simplement au bouton à l'aide de RxBinding de notre bien-aimé Jake et attendons que l'utilisateur clique dessus. Dès qu'un clic se produit, Binder enverra l'événement à un ActionCreator spécifique, qui appellera notre Action, ce qui nous ouvrira un nouvel écran. Notez qu'il n'y avait pas de commutateurs. Ensuite, je montrerai dans le code pourquoi il en est ainsi.
Si nous avons soudainement besoin d'aller sur le réseau ou la base de données, nous faisons ces demandes là, mais via les interacteurs que nous avons transmis au constructeur ActionCreator via l'interface pour les appeler:


Avertissement: le formatage du code n'est pas tout à fait correct ici, j'ai ses règles pour l'article afin que le code soit bien lu.

 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 

Par les mots "par l'interface de leur appel", je voulais dire exactement comment getItems est déclaré (ici ViewTyped est notre interface pour travailler avec des listes). Soit dit en passant, nous avons réutilisé cet ActionCreator dans huit parties différentes de l'application, car il est écrit aussi polyvalent que possible.


Étant donné que les événements sont de nature réactive, nous pouvons assembler une chaîne en y ajoutant d'autres opérateurs, par exemple, startWith (showLoadingAction) pour afficher le chargement et onErrorReturn (errorAction) pour afficher un état d'écran avec une erreur.
Et tout cela est réactif!


Exemple


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

Voyons enfin l'architecture en utilisant le code comme exemple. Pour commencer, j'ai choisi l'un des écrans les plus simples - sur l'application, car il s'agit d'un écran statique.
Pensez à créer un composant:


 val component = AboutComponent( setVersionName = { { appVersion.text = it } }, openPdfAction = { (url, name) -> { openPdf(url, name) } } ) 

Arguments du composant - Actions / ActionMappers - aide à associer View à ActionCreators. Dans ActionMapper'e setVersionName, nous transmettons la version du projet et attribuons cette valeur au texte à l'écran. Dans openPdfAction, une paire de lien vers un document et un nom pour ouvrir l'écran suivant où l'utilisateur peut lire ce document.


Voici le composant lui-même:


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

Permettez-moi de vous rappeler que:


 typealias Action = () -> Unit typealias ActionMapper<T> = (T) -> Action 

OK, passons.


 fun binder(): AboutEventsBinder 

Jetons un œil à AboutEventsBinder plus en détail.


 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 est une autre typealias, afin de ne pas écrire à chaque fois.


 ActionCreator<Observable<*>> 

Dans AboutEventsBinder, nous transmettons ActionCreators et, en les invoquant, nous nous lions à un événement spécifique. Mais pour comprendre comment tout cela se connecte, regardons la classe de base - 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> } 

Nous voyons la méthode familière bindInternal, que nous avons redéfinie dans le successeur. Considérez maintenant la méthode de liaison. Toute la magie est là. Nous acceptons l'héritier de l'interface BaseEvents, le transmettons à bindInternal pour connecter les événements et les actions. Une fois que nous disons que quoi qu'il arrive, nous exécutons sur le flux ui et nous abonnons. Nous voyons également un hack intéressant - takeUntil.


 interface BaseEvents { val unbindEvent: EventObservable } 

Après avoir défini le champ unbindEvent dans BaseEvents pour contrôler la désinscription, nous devons l'implémenter dans tous les héritiers. Ce magnifique champ vous permet de vous désinscrire automatiquement de la chaîne dès la fin de cet événement. C'est tout simplement génial! Maintenant, vous ne pouvez pas suivre et ne vous inquiétez pas du cycle de vie et dormir paisiblement.


 val openPolicyPrivacy = OpenPdfActionCreator(openPdfAction, policyPrivacyUrl) val openProcessingPersonalData = OpenPdfActionCreator(openPdfAction, personalDataUrl) 

Retour au composant. Et ici, vous pouvez déjà voir la méthode de réutilisation. Nous avons écrit une classe qui peut ouvrir l'écran de visualisation PDF, et peu importe pour nous quelle est cette URL. Pas de duplication de code.


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

Le code ActionCreator est également aussi simple que possible, ici nous effectuons juste quelques manipulations de chaînes.


Revenons au composant et considérons le ActionCreator suivant:


 setVersionName.toSimpleActionCreator(moreComponent::currentVersionName) 

Une fois que nous sommes devenus trop paresseux pour écrire les mêmes ActionCreators intrinsèquement simples. Nous avons utilisé le pouvoir de Kotlin et écrit extension'y. Par exemple, dans ce cas, nous avions juste besoin de passer une chaîne statique à 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()) } } } } 

Il y a des moments où nous n'avons pas besoin de transmettre quoi que ce soit, mais appelons seulement une action - par exemple, pour ouvrir l'écran suivant:


 fun Action.toActionCreator(): ActionOnEvent { return object : ActionOnEvent { override fun invoke(event: EventObservable): Observable<Action> { return event.map { this@toActionCreator } } } } 

Donc, avec le composant terminé, revenez au fragment:


 val events = AboutEventsImpl( bindEvent = bindEvent, openPolicyPrivacyEvent = confidentialityPolicy.throttleFirstClicks(), openProcessingPersDataEvent = personalDataProtection.throttleFirstClicks(), unbindEvent = unBindEvent) 

Nous voyons ici la création d'une classe chargée de recevoir les événements de l'utilisateur. Et délier et lier ne sont que des événements de cycle de vie d'écran que nous récupérons à l'aide de la bibliothèque Navi de Trello.


 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) 

L'interface Événements décrit les événements d'un écran particulier et doit hériter de BaseEvents. Ce qui suit est toujours une implémentation de l'interface. Dans ce cas, les événements se sont avérés être un à un avec ceux qui viennent de l'écran, mais il arrive que vous deviez garder deux événements ensemble.


Par exemple, les événements de chargement d'écran à l'ouverture et de rechargement en cas d'erreur doivent être combinés en un seul - il suffit de charger l'écran.


 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 

Nous retournons au fragment et combinons tout ensemble! Nous demandons au composant de créer et de nous renvoyer le classeur, puis nous appelons la méthode bind dessus, où nous passons l'objet qui surveille les événements d'écran.


 component.binder().bind(events) 

Nous écrivons un projet sur cette architecture depuis environ deux ans maintenant. Et il n'y a pas de limite au bonheur des managers dans la rapidité du partage des fonctionnalités! Ils n'ont pas le temps d'en trouver un nouveau, car nous finissons déjà l'ancien. L'architecture est très flexible et vous permet de réutiliser beaucoup de code.
L'inconvénient de cette architecture peut être appelé non conservation de l'état. Nous n'avons pas de modèle complet décrivant l'état de l'écran, comme dans MVI, mais nous pouvons le gérer. Comme - voir ci-dessous.


Chapitre 4. Bonus


Je pense que tout le monde connaît le problème de l'analytique: personne n'aime l'écrire, car il parcourt toutes les couches et défigure les défis. Il y a quelque temps, et nous avons dû y faire face. Mais grâce à notre architecture, une très belle implémentation a été obtenue.


Alors, quelle était mon idée: les analyses partent généralement en réponse aux actions des utilisateurs. Et nous avons juste une classe qui accumule les actions des utilisateurs. Ok, commençons.


Étape 1 Nous modifions légèrement la classe de base BaseEventsBinder en encapsulant les événements dans trackAnalytics:


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

Étape 2 Nous créons une implémentation stable de la variable trackAnalytics afin de maintenir la compatibilité descendante et de ne pas casser les héritiers qui n'ont pas encore besoin d'analyses:


 interface TrackAnalytics<EVENTS : BaseEvents> { operator fun invoke(events: EVENTS): EVENTS } class EmptyAnalyticsTracker<EVENTS : BaseEvents> : TrackAnalytics<EVENTS> { override fun invoke(events: EVENTS): EVENTS = events } 

Étape 3 Nous écrivons l'implémentation de l'interface TrackAnalytics pour l'écran souhaité - par exemple, pour l'écran de la liste des projets:


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

Ici, nous utilisons à nouveau le pouvoir de Kotlin sous la forme de délégués. Nous avons déjà un héritier d'interface créé par nous - dans ce cas, ProjectsEvents. Mais pour certains événements, vous devez redéfinir le déroulement des événements et ajouter une liaison autour d'eux avec l'envoi d'analyses. En fait, trackEvent est juste 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) } 

Étape 4 Reste à le transférer à Binder. Puisque nous le construisons dans un composant, nous avons la possibilité, si vous en avez soudainement besoin, d'ajouter des dépendances supplémentaires au constructeur. Maintenant, le constructeur ProjectsEventsBinder ressemblera à ceci:


 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) 

Vous pouvez consulter d'autres exemples sur GitHub .


Q & A


Comment gardez-vous l'état de l'écran?

Pas question. Nous bloquons l'orientation. Mais nous utilisons également des arguments / intention et y enregistrons la variable OPENED_FROM_BACKSTACK. Et lorsque nous concevons Binder, nous le regardons. Si c'est faux - chargez les données du réseau. Si vrai - à partir du cache. Cela vous permet de recréer rapidement l'écran.


Pour tous ceux qui sont contre le blocage d'orientation: essayez de tester et de déposer des analyses sur la fréquence à laquelle vos utilisateurs retournent le téléphone et combien sont dans une orientation différente. Les résultats peuvent surprendre.


Je ne veux pas écrire de composants, comment puis-je me faire des amis avec le poignard?

Je ne le conseille pas, mais si cela ne vous dérange pas de compiler le temps, vous pouvez également créer un composant via un poignard. Mais nous n'avons pas essayé.


Je n'écris pas en kotlin, quelles sont les difficultés avec l'implémentation en Java?

Tout de même peut être écrit en Java, mais ça n'aura pas l'air si beau.


Si vous aimez l'article, la prochaine partie sera sur la façon d'écrire des tests sur une telle architecture (alors il sera clair pourquoi il y a tant d'interfaces). Spoiler - l'écriture est facile et vous pouvez écrire sur tous les calques à l'exception du composant, mais vous n'avez pas besoin de le tester, cela crée simplement un objet liant.


Merci aux collègues de l'équipe de développement mobile de Tinkoff Business pour leur aide dans la rédaction de cet article.

Source: https://habr.com/ru/post/fr474612/


All Articles