Navigation dans l'application Android à l'aide de coordinateurs

Au cours des dernières années, nous avons développé des approches communes pour la création d'applications Android. Architecture pure, modèles architecturaux (MVC, MVP, MVVM, MVI), modèle de référentiel et autres. Cependant, il n'existe toujours pas d'approche généralement acceptée pour organiser la navigation dans l'application. Aujourd'hui, je veux vous parler du modèle «coordinateur» et des possibilités de son application dans le développement d'applications Android.
Le modèle de coordinateur est souvent utilisé dans les applications iOS et a été introduit par Soroush Khanlou afin de simplifier la navigation de l'application. On pense que le travail de Sorush est basé sur l’approche du contrôleur d’application décrite dans les modèles d’architecture d’application d’entreprise de Martin Fowler.
Le modèle «coordinateur» est conçu pour résoudre les tâches suivantes:

  • lutte avec le problème Massive View Controller (le problème était déjà écrit sur le hub - note du traducteur), qui se manifeste souvent avec l'avènement de God-Activity (activité avec beaucoup de responsabilités).
  • séparation de la logique de navigation en une entité distincte
  • réutilisation des écrans d'application (activité / fragments) en raison d'une faible connexion avec la logique de navigation

Mais, avant de vous familiariser avec le modèle et d'essayer de l'implémenter, jetons un coup d'œil aux implémentations de navigation utilisées dans les applications Android.

La logique de navigation est décrite dans l'activité / le fragment


Étant donné que le SDK Android nécessite Context pour ouvrir une nouvelle activité (ou FragmentManager afin d'ajouter un fragment à l'activité), la logique de navigation est souvent décrite directement dans l'activité / le fragment. Même les exemples de la documentation du SDK Android utilisent cette approche.

class ShoppingCartActivity : Activity() { override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { val intent = Intent(this, CheckoutActivity::class.java) startActivity(intent) } } } 

Dans l'exemple ci-dessus, la navigation est étroitement liée à l'activité. Est-il pratique de tester un tel code? On pourrait faire valoir que nous pouvons séparer la navigation en une entité distincte et la nommer, par exemple, Navigateur, qui peut être implémenté. Voyons voir:

 class ShoppingCartActivity : Activity() { @Inject lateinit var navigator : Navigator override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { navigator.showCheckout(this) } } } class Navigator { fun showCheckout(activity : Activity){ val intent = Intent(activity, CheckoutActivity::class.java) activity.startActivity(intent) } } 

Cela s'est avéré pas mal, mais j'en veux plus.

Navigation avec MVVM / MVP


Je vais commencer par la question: où placeriez-vous la logique de navigation lors de l'utilisation de MVVM / MVP?

Dans la couche sous le présentateur (appelons cela la logique métier)? Ce n'est pas une bonne idée, car vous réutiliserez très probablement votre logique métier dans d'autres modèles de présentation ou présentateurs.

Dans la couche d'affichage? Voulez-vous vraiment lancer des événements entre la présentation et le modèle présentateur / présentation? Regardons un exemple:

 class ShoppingCartActivity : ShoppingCartView, Activity() { @Inject lateinit var navigator : Navigator @Inject lateinit var presenter : ShoppingCartPresenter override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { presenter.checkoutClicked() } } override fun navigateToCheckout(){ navigator.showCheckout(this) } } class ShoppingCartPresenter : Presenter<ShoppingCartView> { ... override fun checkoutClicked(){ view?.navigateToCheckout(this) } } 

Ou si vous préférez MVVM, vous pouvez utiliser SingleLiveEvents ou EventObserver

 class ShoppingCartActivity : ShoppingCartView, Activity() { @Inject lateinit var navigator : Navigator @Inject lateinit var viewModel : ViewModel override fun onCreate(b : Bundle?){ super.onCreate(b) setContentView(R.layout.activity_shopping_cart) val checkoutButton = findViewById(R.id.checkoutButton) checkoutButton.setOnClickListener { viewModel.checkoutClicked() } viewModel.navigateToCheckout.observe(this, Observer { navigator.showCheckout(this) }) } } class ShoppingCartViewModel : ViewModel() { val navigateToCheckout = MutableLiveData<Event<Unit>> fun checkoutClicked(){ navigateToCheckout.value = Event(Unit) // Trigger the event by setting a new Event as a new value } } 

Ou plaçons un navigateur dans un modèle de vue au lieu d'utiliser EventObserver comme indiqué dans l'exemple précédent

 class ShoppingCartViewModel @Inject constructor(val navigator : Navigator) : ViewModel() { fun checkoutClicked(){ navigator.showCheckout() } } 

Veuillez noter que cette approche peut être appliquée au présentateur. Nous ignorons également une éventuelle fuite de mémoire dans le navigateur s'il conserve un lien vers l'activateur.

Coordinateur


Alors, où plaçons-nous la logique de navigation? Logique métier? Plus tôt, nous avons déjà envisagé cette option et sommes arrivés à la conclusion que ce n'était pas la meilleure solution. Lancer des événements entre la vue et le modèle de vue peut fonctionner, mais cela ne ressemble pas à une solution élégante. De plus, la vue est toujours responsable de la logique de navigation, même si nous l'avons apportée au navigateur. Suite à la méthode d'exclusion, nous avons toujours la possibilité de placer la logique de navigation dans le modèle de présentation, et cette option semble prometteuse. Mais le modèle de vue doit-il se soucier de la navigation? N'est-ce pas juste une couche entre la vue et le modèle? C'est pourquoi nous sommes arrivés à la notion de coordinateur.

"Pourquoi avons-nous besoin d'un autre niveau d'abstraction?" - demandez-vous. Vaut-il la complexité du système? Dans les petits projets, l'abstraction peut vraiment s'avérer utile pour l'abstraction, cependant, dans les applications complexes ou dans le cas de l'utilisation de tests A / B, le coordinateur peut être utile. Supposons qu'un utilisateur puisse créer un compte et se connecter. Nous avons déjà une certaine logique, où nous devons vérifier si l'utilisateur s'est connecté et afficher soit l'écran de connexion, soit l'écran principal de l'application. Le coordinateur peut vous aider avec l'exemple donné. Notez que le coordinateur n'aide pas à écrire moins de code; il aide à obtenir le code de la logique de navigation à partir de la vue ou du modèle de vue.

L'idée du coordinateur est extrêmement simple. Il ne sait que quel écran d'application ouvrir ensuite. Par exemple, lorsqu'un utilisateur clique sur le bouton de paiement d'une commande, le coordinateur reçoit l'événement correspondant et sait que l'étape suivante consiste à ouvrir l'écran de paiement. Dans iOS, le coordinateur est utilisé comme localisateur de services pour créer des ViewControllers et contrôler la pile arrière. Cela suffit pour le coordinateur (rappelez-vous le principe de la responsabilité exclusive). Dans les applications Android, le système crée des activités, nous avons de nombreux outils pour implémenter les dépendances et il y a un backstack pour les activités et les fragments. Revenons maintenant à l'idée originale du coordinateur: le coordinateur sait simplement quel écran sera le prochain.

Exemple: application d'actualités utilisant un coordinateur


Parlons enfin directement du modèle. Imaginez que nous devons créer une application de nouvelles simple. L'application dispose de 2 écrans: «liste d'articles» et «texte d'article», qui s'ouvre en cliquant sur un élément de la liste.



 class NewsFlowCoordinator (val navigator : Navigator) { fun start(){ navigator.showNewsList() } fun readNewsArticle(id : Int){ navigator.showNewsArticle(id) } } 

Un script (Flow) contient un ou plusieurs écrans. Dans notre exemple, le scénario de l'actualité se compose de 2 écrans: «liste d'articles» et «texte d'article». Le coordinateur était extrêmement simple. Lorsque l'application démarre, nous appelons NewsFlowCoordinator # start () pour afficher une liste d'articles. Lorsqu'un utilisateur clique sur un élément de la liste, la méthode NewsFlowCoordinator # readNewsArticle (id) est appelée et un écran avec le texte intégral de l'article s'affiche. Nous travaillons toujours avec le navigateur (nous en reparlerons un peu plus tard), auquel nous déléguons l'ouverture de l'écran. Le coordinateur n'a pas d'état, il ne dépend pas de l'implémentation du back-end et implémente une seule fonction: il détermine où aller ensuite.

Mais comment connecter le coordinateur à notre modèle de présentation? Nous suivrons le principe de l'inversion de dépendance: nous passerons le lambda au modèle de vue, qui sera appelé lorsque l'utilisateur tapotera sur l'article.

 class NewsListViewModel( newsRepository : NewsRepository, var onNewsItemClicked: ( (Int) -> Unit )? ) : ViewModel() { val newsArticles = MutableLiveData<List<News>> private val disposable = newsRepository.getNewsArticles().subscribe { newsArticles.value = it } fun newsArticleClicked(id : Int){ onNewsItemClicked!!(id) // call the lambda } override fun onCleared() { disposable.dispose() onNewsItemClicked = null // to avoid memory leaks } } 

onNewsItemClicked: (Int) -> Unit est un lambda qui a un argument entier et renvoie Unit. Veuillez noter que lambda peut être nul, cela nous permettra d'effacer le lien afin d'éviter une fuite de mémoire. Le créateur du modèle de vue (par exemple, une dague) doit transmettre un lien vers la méthode coordinatrice:

 return NewsListViewModel( newsRepository = newsRepository, onNewsItemClicked = newsFlowCoordinator::readNewsArticle ) 

Plus tôt, nous avons mentionné le navigateur, qui effectue le changement d'écrans. La mise en œuvre du navigateur est à votre discrétion, car elle dépend de votre approche spécifique et de vos préférences personnelles. Dans notre exemple, nous utilisons une activité avec plusieurs fragments (un écran - un fragment avec son propre modèle de présentation). Je donne une implémentation naïve d'un navigateur:

 class Navigator{ var activity : FragmentActivity? = null fun showNewsList(){ activty!!.supportFragmentManager .beginTransaction() .replace(R.id.fragmentContainer, NewsListFragment()) .commit() } fun showNewsDetails(newsId: Int) { activty!!.supportFragmentManager .beginTransaction() .replace(R.id.fragmentContainer, NewsDetailFragment.newInstance(newsId)) .addToBackStack("NewsDetail") .commit() } } class MainActivity : AppCompatActivity() { @Inject lateinit var navigator : Navigator override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) navigator.activty = this } override fun onDestroy() { super.onDestroy() navigator.activty = null // Avoid memory leaks } } 

La mise en œuvre ci-dessus du navigateur n'est pas idéale, mais l'idée principale de ce message est d'introduire un coordinateur dans le modèle. Il convient de noter que, puisque le navigateur et le coordinateur n'ont pas d'état, ils peuvent être déclarés dans l'application (par exemple, les étendues Singleton dans un poignard) et peuvent être instanciés dans Application # onCreate ().

Ajoutons une autorisation à notre application. Nous allons définir un nouvel écran de connexion (LoginFragment + LoginViewModel, pour plus de simplicité, nous allons omettre la récupération et l'enregistrement des mots de passe) et LoginFlowCoordinator. Pourquoi ne pas ajouter de nouvelles fonctionnalités à NewsFlowCoordinator? Nous ne voulons pas avoir un God-Coordinator qui sera responsable de toute la navigation dans l'application? De plus, le script d'autorisation ne s'applique pas au scénario du lecteur de news, non?

 class LoginFlowCoordinator( val navigator: Navigator ) { fun start(){ navigator.showLogin() } fun registerNewUser(){ navigator.showRegistration() } fun forgotPassword(){ navigator.showRecoverPassword() } } class LoginViewModel( val usermanager: Usermanager, var onSignUpClicked: ( () -> Unit )?, var onForgotPasswordClicked: ( () -> Unit )? ) { fun login(username : String, password : String){ usermanager.login(username, password) ... } ... } 

Ici, nous voyons que pour chaque événement d'interface utilisateur, il existe un lambda correspondant, mais il n'y a pas de lambda pour le rappel d'une connexion réussie. C'est aussi un détail d'implémentation et vous pouvez ajouter le lambda correspondant, mais j'ai une meilleure idée. Ajoutons un RootFlowCoordinator et souscrivons aux modifications du modèle.

 class RootFlowCoordinator( val usermanager: Usermanager, val loginFlowCoordinator: LoginFlowCoordinator, val newsFlowCoordinator: NewsFlowCoordinator, val onboardingFlowCoordinator: OnboardingFlowCoordinator ) { init { usermanager.currentUser.subscribe { user -> when (user){ is NotAuthenticatedUser -> loginFlowCoordinator.start() is AuthenticatedUser -> if (user.onBoardingCompleted) newsFlowCoordinator.start() else onboardingFlowCoordinator.start() } } } fun onboardingCompleted(){ newsFlowCoordinator.start() } } 

Ainsi, RootFlowCoordinator sera le point d'entrée de notre navigation au lieu de NewsFlowCoordinator. Concentrons-nous sur le RootFlowCoordinator. Si l'utilisateur est connecté, nous vérifions s'il a terminé l'intégration (plus d'informations à ce sujet plus tard) et commençons le script pour les actualités ou l'intégration. Veuillez noter que LoginViewModel n'est pas impliqué dans cette logique. Nous décrivons le scénario d'intégration.



 class OnboardingFlowCoordinator( val navigator: Navigator, val onboardingFinished: () -> Unit // this is RootFlowCoordinator.onboardingCompleted() ) { fun start(){ navigator.showOnboardingWelcome() } fun welcomeShown(){ navigator.showOnboardingPersonalInterestChooser() } fun onboardingCompleted(){ onboardingFinished() } } 

Onboarding est démarré en appelant OnboardingFlowCoordinator # start (), qui affiche WelcomeFragment (WelcomeViewModel). Après avoir cliqué sur le bouton "Suivant", la méthode OnboardingFlowCoordinator # welcomeShown () est appelée. Ce qui montre l'écran suivant PersonalInterestFragment + PersonalInterestViewModel, sur lequel l'utilisateur sélectionne des catégories de nouvelles intéressantes. Après avoir sélectionné les catégories, l'utilisateur appuie sur le bouton «Suivant» et la méthode OnboardingFlowCoordinator # onboardingCompleted () est appelée, qui procède par proxy à l'appel à RootFlowCoordinator # onboardingCompleted (), qui lance NewsFlowCoordinator.
Voyons comment le coordinateur peut simplifier le travail avec les tests A / B. J'ajouterai un écran avec une offre pour effectuer un achat dans l'application et le montrerai à certains utilisateurs.



 class NewsFlowCoordinator ( val navigator : Navigator, val abTest : AbTest ) { fun start(){ navigator.showNewsList() } fun readNewsArticle(id : Int){ navigator.showNewsArticle(id) } fun closeNews(){ if (abTest.isB){ navigator.showInAppPurchases() } else { navigator.closeNews() } } } 

Encore une fois, nous n'avons ajouté aucune logique à la vue ou à son modèle. Avez-vous décidé d'ajouter InAppPurchaseFragment à l'intégration? Pour ce faire, il vous suffit de changer le coordinateur d'intégration, car le fragment de magasinage et son modèle de vue sont complètement indépendants des autres fragments et nous pouvons le réutiliser librement dans d'autres scénarios. Le coordinateur aidera également à mettre en œuvre le test A / B, qui compare deux scénarios d'intégration.

Des sources complètes peuvent être trouvées sur le github , et pour les paresseux j'ai préparé une démo vidéo


Conseil utile: en utilisant kotlin, vous pouvez créer un dsl pratique pour décrire les coordinateurs sous la forme d'un graphique de navigation.

 newsFlowCoordinator(navigator, abTest) { start { navigator.showNewsList() } readNewsArticle { id -> navigator.showNewsArticle(id) } closeNews { if (abTest.isB){ navigator.showInAppPurchases() } else { navigator.closeNews() } } } 

Résumé:


Le coordinateur aidera à apporter la logique de navigation au composant faiblement couplé testé. Pour le moment, il n'y a pas de bibliothèque prête pour la production, je n'ai décrit que le concept de résolution du problème. Le coordinateur est-il applicable à votre candidature? Je ne sais pas, cela dépend de vos besoins et de la facilité avec laquelle il sera intégré à l'architecture existante. Il pourrait être utile d'écrire une petite application en utilisant un coordinateur.

FAQ:

L'article ne mentionne pas l'utilisation d'un coordinateur avec un modèle MVI. Est-il possible d'utiliser un coordinateur avec cette architecture? Oui, j'ai un article séparé .

Google a récemment introduit le contrôleur de navigation dans le cadre d'Android Jetpack. Quel est le lien entre le coordinateur et la navigation Google? Vous pouvez utiliser le nouveau contrôleur de navigation au lieu du navigateur dans les coordinateurs ou directement dans le navigateur au lieu de créer manuellement des transactions de fragments.

Et si je ne veux pas utiliser de fragments / activité et que je veux écrire mon propre back-end pour gérer les vues - pourrai-je utiliser le coordinateur dans mon cas? J'y ai également réfléchi et je travaille sur un prototype. J'écrirai à ce sujet sur mon blog. Il me semble que la machine d'état simplifiera considérablement la tâche.

Le coordinateur est-il attaché à l'approche de candidature à une seule activité? Non, vous pouvez l'utiliser dans différents scénarios. L'implémentation de la transition entre les écrans est masquée dans le navigateur.

Avec l'approche décrite, vous obtenez un énorme navigateur. Nous avons en quelque sorte essayé de nous éloigner de Dieu-Object'a? Nous ne sommes pas tenus de décrire le navigateur dans une classe. Créez plusieurs petits navigateurs pris en charge, par exemple, un navigateur distinct pour chaque scénario utilisateur.

Comment travailler avec des animations de transition continue? Décrivez les animations de transition dans le navigateur, puis l'activité / le fragment ne saura rien de l'écran précédent / suivant. Comment le navigateur sait-il quand démarrer l'animation? Supposons que nous voulons montrer une animation de la transition entre les fragments A et B. Nous pouvons souscrire à l'événement onFragmentViewCreated (v: View) à l'aide de FragmentLifecycleCallback et lorsque cet événement se produit, nous pouvons travailler avec des animations de la même manière que nous l'avons fait directement dans le fragment: ajouter OnPreDrawListener d'attendre d'être prêt et d'appeler startPostponedEnterTransition (). De la même manière, vous pouvez implémenter une transition animée entre une activité à l'aide de ActivityLifecycleCallbacks ou entre ViewGroup à l'aide de OnHierarchyChangeListener. N'oubliez pas de vous désabonner des événements ultérieurement pour éviter les fuites de mémoire.

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


All Articles