Navegação no aplicativo Android usando coordenadores

Nos últimos anos, desenvolvemos abordagens comuns para a criação de aplicativos Android. Arquitetura pura, padrões de arquitetura (MVC, MVP, MVVM, MVI), padrão de repositório e outros. No entanto, ainda não existem abordagens geralmente aceitas para organizar a navegação no aplicativo. Hoje, quero falar com você sobre o modelo "coordenador" e as possibilidades de sua aplicação no desenvolvimento de aplicativos Android.
O padrão de coordenador é frequentemente usado em aplicativos iOS e foi introduzido por Soroush Khanlou para simplificar a navegação do aplicativo. Acredita-se que o trabalho de Sorush seja baseado na abordagem do Application Controller descrita nos padrões de arquitetura de aplicativos corporativos de Martin Fowler.
O modelo "coordenador" foi projetado para resolver as seguintes tarefas:

  • luta com o problema do Massive View Controller (o problema já estava escrito no cubo - nota do tradutor), que geralmente se manifesta com o advento da Atividade de Deus (atividade com muitas responsabilidades).
  • separação da lógica de navegação em uma entidade separada
  • reutilização de telas de aplicativos (atividade / fragmentos) devido à fraca conexão com a lógica de navegação

Mas, antes de começar a se familiarizar com o modelo e tentar implementá-lo, vamos dar uma olhada nas implementações de navegação usadas nos aplicativos Android.

A lógica de navegação é descrita na atividade / fragmento


Como o SDK do Android requer que o Contexto abra uma nova atividade (ou FragmentManager para adicionar um fragmento à atividade), muitas vezes a lógica de navegação é descrita diretamente na atividade / fragmento. Até os exemplos na documentação do Android SDK usam essa abordagem.

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

No exemplo acima, a navegação está intimamente relacionada à atividade. É conveniente testar esse código? Alguém poderia argumentar que podemos separar a navegação em uma entidade separada e nomeá-la, por exemplo, Navigator, que pode ser implementada. Vamos 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) } } 

Acabou não ruim, mas eu quero mais.

Navegação com MVVM / MVP


Vou começar com a pergunta: onde você colocaria a lógica de navegação ao usar o MVVM / MVP?

Na camada abaixo do apresentador (vamos chamá-lo de lógica de negócios)? Não é uma boa ideia, porque provavelmente você reutilizará sua lógica de negócios em outros modelos de apresentação ou apresentadores.

Na camada de visualização? Tem certeza de que deseja lançar eventos entre a apresentação e o apresentador / modelo de apresentação? Vejamos um exemplo:

 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, se você preferir o MVVM, pode usar 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 vamos colocar um navegador em um modelo de exibição em vez de usar EventObserver, como mostrado no exemplo anterior

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

Observe que essa abordagem pode ser aplicada ao apresentador. Também ignoramos um possível vazamento de memória no navegador se ele mantiver um link para o ativador.

Coordenador


Então, onde colocamos a lógica de navegação? Lógica de negócios? Anteriormente, já consideramos essa opção e chegamos à conclusão de que essa não é a melhor solução. Lançar eventos entre a visualização e o modelo de visualização pode funcionar, mas não parece uma solução elegante. Além disso, a visualização ainda é responsável pela lógica de navegação, mesmo que a tenhamos levado ao navegador. Seguindo o método de exclusão, ainda temos a opção de colocar a lógica de navegação no modelo de apresentação, e essa opção parece promissora. Mas o modelo de visualização deve se preocupar com a navegação? Não é apenas uma camada entre a vista e o modelo? Por isso chegamos à noção de coordenador.

"Por que precisamos de outro nível de abstração?" - você pergunta. Vale a pena a complexidade do sistema? Em pequenos projetos, a abstração pode realmente resultar em abstração; no entanto, em aplicativos complexos ou no caso de usar testes A / B, o coordenador pode ser útil. Suponha que um usuário possa criar uma conta e fazer login. Já temos alguma lógica, na qual devemos verificar se o usuário efetuou login e mostrar a tela de login ou a tela principal do aplicativo. O coordenador pode ajudar com o exemplo dado. Observe que o coordenador não ajuda a escrever menos código, mas ajuda a obter o código da lógica de navegação da visualização ou modelo de visualização.

A ideia do coordenador é extremamente simples. Ele sabe apenas qual tela do aplicativo abrir em seguida. Por exemplo, quando um usuário clica no botão de pagamento de um pedido, o coordenador recebe o evento correspondente e sabe que o próximo passo é abrir a tela de pagamento. No iOS, o coordenador é usado como um localizador de serviço para criar ViewControllers e controlar a pilha traseira. Isso é suficiente para o coordenador (lembre-se do princípio de responsabilidade exclusiva). Em aplicativos Android, o sistema cria atividades, temos muitas ferramentas para implementar dependências e há um backstack para atividades e fragmentos. Agora, voltemos à ideia original do coordenador: o coordenador apenas sabe qual tela será a próxima.

Exemplo: aplicativo de notícias usando um coordenador


Finalmente vamos falar diretamente sobre o modelo. Imagine que precisamos criar um aplicativo de notícias simples. O aplicativo possui 2 telas: “lista de artigos” e “texto do artigo”, que é aberto ao clicar em um item da lista.



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

Um script (Flow) contém uma ou mais telas. No nosso exemplo, o cenário de notícias consiste em 2 telas: "lista de artigos" e "texto do artigo". O coordenador era extremamente simples. Quando o aplicativo é iniciado, chamamos NewsFlowCoordinator # start () para exibir uma lista de artigos. Quando um usuário clica em um item da lista, o método NewsFlowCoordinator # readNewsArticle (id) é chamado e uma tela com o texto completo do artigo é exibida. Ainda estamos trabalhando com o navegador (falaremos sobre isso um pouco mais tarde), ao qual delegamos a abertura da tela. O coordenador não tem estado, não depende da implementação do back-end e implementa apenas uma função: determina para onde ir a seguir.

Mas como conectar o coordenador ao nosso modelo de apresentação? Seguiremos o princípio da inversão de dependência: passaremos o lambda para o modelo de visualização, que será chamado quando o usuário tocar no artigo.

 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 é um lambda que possui um argumento inteiro e retorna Unit. Observe que o lambda pode ser nulo, isso nos permitirá limpar o link para evitar vazamento de memória. O criador do modelo de visualização (por exemplo, uma adaga) deve passar um link para o método coordenador:

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

Anteriormente, mencionamos o navegador, que realiza a alteração de telas. A implementação do navegador fica a seu critério, pois depende de sua abordagem específica e preferências pessoais. Em nosso exemplo, usamos uma atividade com vários fragmentos (uma tela - um fragmento com seu próprio modelo de apresentação). Dou uma implementação ingênua de um 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 } } 

A implementação acima do navegador não é ideal, mas a idéia principal deste post é introduzir um coordenador no padrão. Vale ressaltar que, como o navegador e o coordenador não têm estado, eles podem ser declarados no aplicativo (por exemplo, escopos Singleton em uma adaga) e podem ser instanciados no Aplicativo # onCreate ().

Vamos adicionar autorização ao nosso aplicativo. Definiremos uma nova tela de login (LoginFragment + LoginViewModel, por simplicidade, omitiremos a recuperação e o registro de senhas) e o LoginFlowCoordinator. Por que não adicionar novas funcionalidades ao NewsFlowCoordinator? Não queremos um coordenador de Deus que será responsável por toda a navegação no aplicativo? Além disso, o script de autorização não se aplica ao cenário do leitor de notícias, certo?

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

Aqui vemos que, para cada evento da interface do usuário, existe um lambda correspondente; no entanto, não há lambda para o retorno de chamada de um login bem-sucedido. Este também é um detalhe de implementação e você pode adicionar o lambda correspondente, no entanto, tenho uma ideia melhor. Vamos adicionar um RootFlowCoordinator e assinar as alterações do 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() } } 

Assim, o RootFlowCoordinator será o ponto de entrada da nossa navegação em vez do NewsFlowCoordinator. Vamos nos concentrar no RootFlowCoordinator. Se o usuário estiver logado, verificaremos se ele concluiu a integração (mais sobre isso posteriormente) e iniciamos o script para obter notícias ou integração. Observe que o LoginViewModel não está envolvido nessa lógica. Nós descrevemos o cenário de integração.



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

A integração é iniciada chamando OnboardingFlowCoordinator # start (), que mostra WelcomeFragment (WelcomeViewModel). Depois de clicar no botão “próximo”, o método OnboardingFlowCoordinator # welcomeShown () é chamado. O que mostra a tela a seguir PersonalInterestFragment + PersonalInterestViewModel, na qual o usuário seleciona categorias de notícias interessantes. Após selecionar as categorias, o usuário toca no botão “next” e o método OnboardingFlowCoordinator # onboardingCompleted () é chamado, que procura a chamada para RootFlowCoordinator # onboardingCompleted (), que inicia o NewsFlowCoordinator.
Vamos ver como o coordenador pode simplificar o trabalho com testes A / B. Adicionarei uma tela com uma oferta para fazer uma compra no aplicativo e mostrarei a alguns usuários.



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

Novamente, não adicionamos nenhuma lógica à visualização ou ao seu modelo. Você decidiu adicionar InAppPurchaseFragment à integração? Para fazer isso, você só precisa alterar o coordenador de integração, pois o fragmento de compras e seu modelo de exibição são completamente independentes de outros fragmentos e podemos reutilizá-lo livremente em outros cenários. O coordenador também ajudará a implementar o teste A / B, que compara dois cenários de integração.

Fontes completas podem ser encontradas no github e, para os preguiçosos, preparei uma demonstração em vídeo


Conselho útil: usando o kotlin, você pode criar um dsl conveniente para descrever coordenadores na forma de um gráfico de navegação.

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

Resumo:


O coordenador ajudará a trazer a lógica de navegação para o componente de acoplamento pouco testado. No momento, não há biblioteca pronta para produção, descrevi apenas o conceito de solução do problema. O coordenador é aplicável à sua inscrição? Não sei, depende de suas necessidades e de quão fácil será integrá-lo à arquitetura existente. Pode ser útil escrever um pequeno aplicativo usando um coordenador.

FAQ:

O artigo não menciona o uso de um coordenador com um padrão MVI. É possível usar um coordenador com essa arquitetura? Sim, eu tenho um artigo separado .

O Google introduziu recentemente o Navigation Controller como parte do Android Jetpack. Como o coordenador se relaciona com a navegação do Google? Você pode usar o novo Controlador de Navegação em vez do navegador nos coordenadores ou diretamente no navegador em vez de criar manualmente transações de fragmento.

E se não quiser usar fragmentos / atividade e escrever meu próprio back-end para gerenciar as visualizações - poderei usar o coordenador no meu caso? Eu também pensei sobre isso e estou trabalhando em um protótipo. Vou escrever sobre isso no meu blog. Parece-me que a máquina de estado simplificará bastante a tarefa.

O coordenador está vinculado à abordagem de aplicativo de atividade única? Não, você pode usá-lo em vários cenários. A implementação da transição entre telas está oculta no navegador.

Com a abordagem descrita, você obtém um navegador enorme. Nós meio que tentamos fugir de Deus-Objeto? Não somos obrigados a descrever o navegador em uma classe. Crie vários pequenos navegadores suportados, por exemplo, um navegador separado para cada cenário do usuário.

Como trabalhar com animações de transição contínuas? Descreva animações de transição no navegador; a atividade / fragmento não saberá nada sobre a tela anterior / seguinte. Como o navegador sabe quando iniciar a animação? Suponha que desejemos mostrar uma animação da transição entre os fragmentos A e B. Podemos assinar o evento onFragmentViewCreated (v: View) usando FragmentLifecycleCallback e, quando esse evento ocorre, podemos trabalhar com animações da mesma maneira que fizemos diretamente no fragmento: add OnPreDrawListener aguarde até que esteja pronto e chame startPostponedEnterTransition (). Da mesma maneira, é possível implementar uma transição animada entre atividades usando ActivityLifecycleCallbacks ou entre ViewGroup usando OnHierarchyChangeListener. Não se esqueça de cancelar a inscrição dos eventos posteriormente para evitar vazamentos de memória.

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


All Articles