Navegaci贸n en la aplicaci贸n de Android usando coordinadores

En los 煤ltimos a帽os, hemos desarrollado enfoques comunes para crear aplicaciones de Android. Arquitectura pura, patrones arquitect贸nicos (MVC, MVP, MVVM, MVI), el patr贸n de repositorio y otros. Sin embargo, todav铆a no hay enfoques generalmente aceptados para organizar la navegaci贸n dentro de la aplicaci贸n. Hoy quiero hablar con ustedes sobre la plantilla de "coordinador" y las posibilidades de su aplicaci贸n en el desarrollo de aplicaciones de Android.
El patr贸n coordinador se usa a menudo en aplicaciones iOS y fue introducido por Soroush Khanlou para simplificar la navegaci贸n de la aplicaci贸n. Se cree que el trabajo de Sorush se basa en el enfoque del Controlador de aplicaciones descrito en los Patrones de arquitectura de aplicaciones empresariales de Martin Fowler.
La plantilla de "coordinador" est谩 dise帽ada para resolver las siguientes tareas:

  • lucha con el problema de Massive View Controller (el problema ya estaba escrito en el centro - nota del traductor), que a menudo se manifiesta con el advenimiento de God-Activity (actividad con muchas responsabilidades).
  • separaci贸n de la l贸gica de navegaci贸n en una entidad separada
  • reutilizaci贸n de pantallas de aplicaciones (actividad / fragmentos) debido a una conexi贸n d茅bil con la l贸gica de navegaci贸n

Pero, antes de comenzar a familiarizarse con la plantilla e intentar implementarla, echemos un vistazo a las implementaciones de navegaci贸n utilizadas en las aplicaciones de Android.

La l贸gica de navegaci贸n se describe en la actividad / fragmento


Dado que el SDK de Android requiere que Context abra una nueva actividad (o FragmentManager para agregar un fragmento a la actividad), con bastante frecuencia la l贸gica de navegaci贸n se describe directamente en la actividad / fragmento. Incluso los ejemplos en la documentaci贸n para el SDK de Android utilizan este enfoque.

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

En el ejemplo anterior, la navegaci贸n est谩 estrechamente relacionada con la actividad. 驴Es conveniente probar dicho c贸digo? Se podr铆a argumentar que podemos separar la navegaci贸n en una entidad separada y nombrarla, por ejemplo, Navigator, que se puede implementar. A ver:

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

Result贸 que no est谩 mal, pero quiero m谩s.

Navegaci贸n con MVVM / MVP


Comenzar茅 con la pregunta: 驴d贸nde ubicar铆a la l贸gica de navegaci贸n al usar MVVM / MVP?

驴En la capa debajo del presentador (llam茅moslo l贸gica de negocios)? No es una buena idea, porque lo m谩s probable es que reutilice su l贸gica de negocios en otros modelos de presentaci贸n o presentadores.

En la capa de vista? 驴Est谩 seguro de que desea lanzar eventos entre la presentaci贸n y el presentador / modelo de presentaci贸n? Veamos un ejemplo:

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

O si prefiere MVVM, puede usar SingleLiveEvents o 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 } } 

O pongamos un navegador en un modelo de vista en lugar de usar EventObserver como se muestra en el ejemplo anterior

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

Tenga en cuenta que este enfoque se puede aplicar al presentador. Tambi茅n ignoramos una posible p茅rdida de memoria en el navegador si mantiene un enlace al activador.

Coordinador


Entonces, 驴d贸nde ubicamos la l贸gica de navegaci贸n? L贸gica de negocios? Anteriormente, ya consideramos esta opci贸n y llegamos a la conclusi贸n de que esta no es la mejor soluci贸n. Lanzar eventos entre la vista y el modelo de vista puede funcionar, pero no parece una soluci贸n elegante. Adem谩s, la vista sigue siendo responsable de la l贸gica de navegaci贸n, a pesar de que la llevamos al navegador. Siguiendo el m茅todo de exclusi贸n, todav铆a tenemos la opci贸n de colocar la l贸gica de navegaci贸n en el modelo de presentaci贸n, y esta opci贸n parece prometedora. 驴Pero el modelo de vista deber铆a preocuparse por la navegaci贸n? 驴No es solo una capa entre la vista y el modelo? Por eso llegamos a la noci贸n de coordinador.

"驴Por qu茅 necesitamos otro nivel de abstracci贸n?" - usted pregunta 驴Vale la pena la complejidad del sistema? En proyectos peque帽os, la abstracci贸n realmente puede resultar en aras de la abstracci贸n, sin embargo, en aplicaciones complejas o en el caso de usar pruebas A / B, el coordinador puede ser 煤til. Supongamos que un usuario puede crear una cuenta e iniciar sesi贸n. Ya tenemos algo de l贸gica, donde debemos verificar si el usuario ha iniciado sesi贸n y mostrar la pantalla de inicio de sesi贸n o la pantalla principal de la aplicaci贸n. El coordinador puede ayudar con el ejemplo dado. Tenga en cuenta que el coordinador no ayuda a escribir menos c贸digo; ayuda a obtener el c贸digo de la l贸gica de navegaci贸n de la vista o modelo de vista.

La idea del coordinador es extremadamente simple. Solo sabe qu茅 pantalla de aplicaci贸n abrir a continuaci贸n. Por ejemplo, cuando un usuario hace clic en el bot贸n de pago de un pedido, el coordinador recibe el evento correspondiente y sabe que el siguiente paso es abrir la pantalla de pago. En iOS, el coordinador se usa como un localizador de servicios para crear ViewControllers y controlar la pila posterior. Esto es suficiente para el coordinador (recuerde el principio de responsabilidad exclusiva). En las aplicaciones de Android, el sistema crea actividades, tenemos muchas herramientas para implementar dependencias y hay una pila de actividades y fragmentos. Ahora volvamos a la idea original del coordinador: el coordinador solo sabe qu茅 pantalla ser谩 la siguiente.

Ejemplo: aplicaci贸n de noticias usando un coordinador


Finalmente hablemos directamente sobre la plantilla. Imagine que necesitamos crear una aplicaci贸n de noticias simple. La aplicaci贸n tiene 2 pantallas: "lista de art铆culos" y "texto del art铆culo", que se abre haciendo clic en un elemento de la lista.



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

Un script (Flow) contiene una o m谩s pantallas. En nuestro ejemplo, el escenario de noticias consta de 2 pantallas: "lista de art铆culos" y "texto del art铆culo". El coordinador fue extremadamente simple. Cuando se inicia la aplicaci贸n, llamamos a NewsFlowCoordinator # start () para mostrar una lista de art铆culos. Cuando un usuario hace clic en un elemento de la lista, se llama al m茅todo NewsFlowCoordinator # readNewsArticle (id) y se muestra una pantalla con el texto completo del art铆culo. Todav铆a estamos trabajando con el navegador (hablaremos de esto un poco m谩s adelante), al que delegamos la apertura de la pantalla. El coordinador no tiene estado, no depende de la implementaci贸n del back-end e implementa una sola funci贸n: determina a d贸nde ir despu茅s.

驴Pero c贸mo conectar al coordinador con nuestro modelo de presentaci贸n? Seguiremos el principio de inversi贸n de dependencia: pasaremos la lambda al modelo de vista, que se llamar谩 cuando el usuario toque el art铆culo.

 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 es una lambda que tiene un argumento entero y devuelve Unit. Tenga en cuenta que lambda puede ser nulo, esto nos permitir谩 borrar el enlace para evitar p茅rdidas de memoria. El creador del modelo de vista (por ejemplo, una daga) debe pasar un enlace al m茅todo coordinador:

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

Anteriormente, mencionamos el navegador, que realiza el cambio de pantallas. La implementaci贸n del navegador queda a su discreci贸n, ya que depende de su enfoque espec铆fico y sus preferencias personales. En nuestro ejemplo, utilizamos una actividad con varios fragmentos (una pantalla, un fragmento con su propio modelo de presentaci贸n). Doy una implementaci贸n ingenua de un navegador:

 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 implementaci贸n anterior del navegador no es ideal, pero la idea principal de esta publicaci贸n es introducir un coordinador en el patr贸n. Vale la pena se帽alar que, dado que el navegador y el coordinador no tienen estado, pueden declararse dentro de la aplicaci贸n (por ejemplo, alcances Singleton en una daga) y pueden instanciarse en la Aplicaci贸n # onCreate ().

Agreguemos autorizaci贸n a nuestra aplicaci贸n. Definiremos una nueva pantalla de inicio de sesi贸n (LoginFragment + LoginViewModel, por simplicidad, omitiremos la recuperaci贸n y el registro de la contrase帽a) y LoginFlowCoordinator. 驴Por qu茅 no agregar nuevas funcionalidades a NewsFlowCoordinator? 驴No queremos obtener un coordinador de Dios que sea responsable de toda la navegaci贸n en la aplicaci贸n? Adem谩s, el script de autorizaci贸n no se aplica al escenario del lector de noticias, 驴verdad?

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

Aqu铆 vemos que para cada evento de IU hay una lambda correspondiente, sin embargo, no hay lambda para la devoluci贸n de llamada de un inicio de sesi贸n exitoso. Este tambi茅n es un detalle de implementaci贸n y puede agregar el lambda correspondiente, sin embargo, tengo una mejor idea. Agreguemos un RootFlowCoordinator y suscr铆base a los cambios del modelo.

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

Por lo tanto, RootFlowCoordinator ser谩 el punto de entrada de nuestra navegaci贸n en lugar de NewsFlowCoordinator. Centr茅monos en RootFlowCoordinator. Si el usuario ha iniciado sesi贸n, verificamos si ha completado la incorporaci贸n (m谩s sobre esto m谩s adelante) y comenzamos el script para noticias o incorporaci贸n. Tenga en cuenta que LoginViewModel no est谩 involucrado en esta l贸gica. Describimos el escenario de incorporaci贸n.



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

La incorporaci贸n se inicia llamando a OnboardingFlowCoordinator # start (), que muestra WelcomeFragment (WelcomeViewModel). Despu茅s de hacer clic en el bot贸n "siguiente", se llama al m茅todo OnboardingFlowCoordinator # welcomeShown (). Que muestra la siguiente pantalla PersonalInterestFragment + PersonalInterestViewModel, en la que el usuario selecciona categor铆as de noticias interesantes. Despu茅s de seleccionar las categor铆as, el usuario toca el bot贸n "siguiente" y se llama al m茅todo OnboardingFlowCoordinator # onboardingCompleted (), que representa la llamada a RootFlowCoordinator # onboardingCompleted (), que lanza NewsFlowCoordinator.
Veamos c贸mo el coordinador puede simplificar el trabajo con las pruebas A / B. Agregar茅 una pantalla con una oferta para realizar una compra en la aplicaci贸n y se la mostrar茅 a algunos usuarios.



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

Nuevamente, no hemos agregado ninguna l贸gica a la vista o su modelo. 驴Has decidido agregar InAppPurchaseFragment a la incorporaci贸n? Para hacer esto, solo necesita cambiar el coordinador de incorporaci贸n, ya que el fragmento de compra y su modelo de vista son completamente independientes de otros fragmentos y podemos reutilizarlo libremente en otros escenarios. El coordinador tambi茅n ayudar谩 a implementar la prueba A / B, que compara dos escenarios de incorporaci贸n.

Las fuentes completas se pueden encontrar en el github , y para los perezosos he preparado una demostraci贸n de video


Consejos 煤tiles: usando kotlin puede crear dsl conveniente para describir coordinadores en forma de un gr谩fico de navegaci贸n.

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

Resumen:


El coordinador ayudar谩 a llevar la l贸gica de navegaci贸n al componente probado acoplado libremente. Por el momento no hay una biblioteca lista para producci贸n, describ铆 solo el concepto de resolver el problema. 驴El coordinador es aplicable a su solicitud? No s茅, depende de sus necesidades y de lo f谩cil que ser谩 integrarlo en la arquitectura existente. Puede ser 煤til escribir una peque帽a aplicaci贸n usando un coordinador.

FAQ:

El art铆culo no menciona el uso de un coordinador con un patr贸n MVI. 驴Es posible utilizar un coordinador con esta arquitectura? S铆, tengo un art铆culo separado .

Google present贸 recientemente el Controlador de navegaci贸n como parte de Android Jetpack. 驴C贸mo se relaciona el coordinador con la navegaci贸n de Google? Puede usar el nuevo Controlador de navegaci贸n en lugar del navegador en los coordinadores o directamente en el navegador en lugar de crear manualmente fragmentos de transacciones.

Y si no quiero usar fragmentos / actividad y quiero escribir mi propio back-end para administrar las vistas, 驴funcionar谩 en mi caso usar un coordinador? Tambi茅n pens茅 en esto y estoy trabajando en un prototipo. Escribir茅 sobre esto en mi blog. Me parece que la m谩quina de estados simplificar谩 enormemente la tarea.

驴Est谩 el coordinador apegado al enfoque de aplicaci贸n de actividad 煤nica? No, puedes usarlo en varios escenarios. La implementaci贸n de la transici贸n entre pantallas est谩 oculta en el navegador.

Con el enfoque descrito, obtienes un gran navegador. 驴Intentamos escaparnos de Dios-Objeto? No estamos obligados a describir el navegador en una clase. Cree varios navegadores peque帽os compatibles, por ejemplo, un navegador separado para cada escenario de usuario.

驴C贸mo trabajar con animaciones de transici贸n continua? Describa las animaciones de transici贸n en el navegador, luego la actividad / fragmento no sabr谩 nada sobre la pantalla anterior / siguiente. 驴C贸mo sabe el navegador cu谩ndo comenzar la animaci贸n? Supongamos que queremos mostrar una animaci贸n de la transici贸n entre los fragmentos A y B. Podemos suscribirnos al evento onFragmentViewCreated (v: View) usando FragmentLifecycleCallback y cuando ocurre este evento podemos trabajar con animaciones de la misma manera que lo hicimos directamente en el fragmento: agregue OnPreDrawListener esperar hasta que est茅 listo y llamar a startPostponedEnterTransition (). Aproximadamente de la misma manera, puede implementar una transici贸n animada entre la actividad usando ActivityLifecycleCallbacks o entre ViewGroup usando OnHierarchyChangeListener. No olvide darse de baja de los eventos m谩s adelante para evitar p茅rdidas de memoria.

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


All Articles