[Перевод] Навигация в Android-приложении с помощью координаторов

Habrahabr 2

За последние несколько лет мы выработали общие подходы создания Android-приложений. Чистая архитектура, архитектурные шаблоны (MVC, MVP, MVVM, MVI), шаблон “репозиторий” и другие. Однако до сих пор нет общепринятых подходов к организации навигации по приложению. Сегодня я хочу поговорить с вами о шаблоне “координатор” и возможностях его применении в разработке Android-приложений.

Шаблон “координатор” часто используется в iOS-приложениях и был представлен Сорушем Ханлоу (Soroush Khanlou) с целью упростить навигацию по приложению. Есть мнение, что работа Соруша основана на подходе Application Controller, описанном в книге Patterns of Enterprise Application Architecture Мартином Фаулером (Martin Fowler).
Шаблон “координатор” призван решить следующие задачи:
  • борьба с Massive View Controller проблемой (о проблеме уже писали на хабре — прим. переводчика), которая зачастую проявляется с появлением God-Activity (активити с большим количеством обязанностей).
  • выделение логики навигации в отдельную сущность
  • переиспользование экранов приложения (активити/фрагментов) благодаря слабой связи с логикой навигации
Но, прежде чем начать знакомство с шаблоном и попробовать его реализовать, давайте взглянем на используемые реализации навигации в Android-приложениях.

Логика навигации описана в активити/фрагменте

Поскольку Android SDK требует Context для открытия новой активити (или FragmentManager для того, чтобы добавить фрагмент в активити), довольно часто логику навигации описывают непосредственно в активити/фрагменте. Даже примеры в документации к Android SDK используют этот подход.
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)
    }
  }
}

В приведенном примере навигация тесно связана с активити. Удобно ли тестировать подобный код? Можно было бы возразить, что мы можем выделить навигацию в отдельную сущность и назвать ее, к примеру, Navigator, который может быть внедрен. Давайте посмотрим:
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)
  }
}

Получилось неплохо, но хочется большего.

Навигация с MVVM/MVP

Начну с вопроса: где бы вы расположили логику навигации при использовании MVVM/MVP?

В слое под презентером (назовем его бизнес-логикой)? Не очень хорошая идея, потому что скорее всего вы будете повторно использовать вашу бизнес-логику в других моделях представления или презентерах.

В слое представления? Вы действительно хотите перебрасывать события между представлением и презентером/моделью представления? Давайте посмотрим на пример:

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

Или если вы предпочитаете MVVM, можно использовать SingleLiveEvents или 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
  }
}

Или давайте поместим навигатор в модель представления вместо использования EventObserver’а как было показано в предыдущем примере
class ShoppingCartViewModel @Inject constructor(val navigator : Navigator)  : ViewModel() {
  fun checkoutClicked(){
    navigator.showCheckout()
  }
}

Обратите внимание, что это подход может быть применен и к презентеру. Также мы игнорируем возможную утечку памяти в навигаторе в случае если он будет держать ссылку на активити.

Координатор

Так где же нам разместить логику навигации? Бизнес-логика? Ранее мы уже рассмотрели этот вариант и пришли к выводу, что это не лучшее решение. Перебрасывание событий между представлением и моделью представления может сработать, но это не выглядит элегантным решением. Более того, представление все еще отвечает за логику навигации, хоть мы и вынесли ее в навигатор. Следуя методу исключения у нас остается вариант с размещением логики навигации в модели представления и этот вариант кажется перспективным. Но должна ли модель представления заботиться о навигации? Разве это не просто прослойка между представлением и моделью? Вот почему мы пришли к понятию координатора.

“Зачем нам еще один уровень абстракции?” — спросите вы. Стоит ли того усложнение системы? В маленьких проектах действительно может получиться абстракция ради абстракции, однако в сложных приложениях или в случае использования A/B тестов координатор может оказаться полезным. Допустим, пользователь может создать аккаунт и залогиниться. У нас уже появилась некоторая логика, где мы должны проверить залогинился ли пользователь и показать либо экран логина либо главный экран приложения. Координатор может помочь в приведенном примере. Обратите внимание, что координатор не помогает писать меньше кода, он помогает вынести код логики навигации из представления или модели представления.

Идея координатора предельно проста. Он знает только какой экран приложения надо открыть следующим. Например, когда пользователь нажимает на кнопку оплаты заказа, координатор получает соответствующее событие и знает, что далее необходимо открыть экран оплаты. В iOS координатор используется в качестве сервис локатора, для создания ViewController’ов и управления бэкстеком. Это достаточно много для координатора (помним про принцип единственной ответственности). В Android-приложениях система создает активити, у нас множество инструментов для внедрения зависимостей и есть бекстек для активити и фрагментов. А теперь давайте вернемся к оригинальной идее координатора: координатор просто знает какой экран будет следующим.

Пример: Новостное приложение с использованием координатора

Давайте наконец-то поговорим непосредственно о шаблоне. Представим, что нам нужно создать простое новостное приложение. В приложении есть 2 экрана: “список статей” и “текст статьи”, который открывается по клику на элемент списка.

class NewsFlowCoordinator (val navigator : Navigator) {

  fun start(){
    navigator.showNewsList()
  } 

  fun readNewsArticle(id : Int){
    navigator.showNewsArticle(id)
  }
}

Сценарий (Flow) содержит один или несколько экранов. В нашем примере новостной сценарий состоит из 2 экранов: “список статей” и “текст статьи”. Координатор получился предельно простым. При старте приложения мы вызываем NewsFlowCoordinator#start() чтобы показать список статей. Когда пользователь кликает по элементу списка вызывается метод NewsFlowCoordinator#readNewsArticle(id) и показывается экран с полным текстом статьи. Мы все еще работаем с навигатором (об этом мы поговорим немного позже), которому мы делегируем открытие экрана. У координатора отсутствует состояние, он не зависит от реализации бекстека и реализует только одну функцию: определяет куда пойти дальше.

Но как соединить координатор с нашей моделью представления? Мы последуем принципу инверсии зависимостей: мы будем передавать в модель представления лямбду, которая будет вызываться когда пользователь тапает по статье.

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 — это лямбда, у которой один целочисленный аргумент и возвращает Unit. Обратите внимание, что лямбда может быть null, это позволит нам очистить ссылку для того чтобы избежать утечки памяти. Создатель модели представления (например, даггер) должен передать ссылку на метод координатора:
return NewsListViewModel(
  newsRepository = newsRepository,
  onNewsItemClicked = newsFlowCoordinator::readNewsArticle
)

Ранее мы упоминали навигатор, который и осуществляет смену экранов. Реализация навигатора остается на ваше усмотрение, поскольку зависит от вашего конкретного подхода и личных предпочтений. В нашем примере мы используем одну активити с несколькими фрагментами (один экран — один фрагмент со своей моделью представления). Я привожу наивную реализацию навигатора:
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
  }
}

Приведенная реализация навигатора не идеальна, однако основная идея этого поста — введение в паттерн координатора. Стоит отметить, что поскольку навигатор и координатор не имеют состояния, то они могут быть объявлены в рамках приложения (например Singleton скоуп в даггере) и могут быть инстанциированы в Application#onCreate().

Давайте добавим авторизацию в наше приложение. Мы определим новый экран логина (LoginFragment + LoginViewModel, для простоты мы опустим восстановление пароля и регистрацию) и LoginFlowCoordinator. Почему бы не добавить новый функционал в NewsFlowCoordinator? Мы же не хотим получить God-Coordinator, который будет отвечать за всю навигацию в приложении? Также сценарий авторизации не относится к сценарию чтения новостей, верно?

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

Здесь мы видим, что на каждый UI-ивент есть соответствующая лямбда, однако нет лямбды для коллбека успешного логина. Это также деталь реализации и вы можете добавить соответствующую лямбду, однако у меня есть идея получше. Давайте добавим RootFlowCoordinator и подпишемся на изменения модели.
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()
  }
}

Таким образом, RootFlowCoordinator будет входной точкой нашей навигации вместо NewsFlowCoordinator. Давайте остановим наше внимание на RootFlowCoordinator. Если пользователь залогинен, то мы проверяем прошел ли он онбординг (об этом чуть позже) и начинаем сценарий новостей или онбординга. Обратите внимание, что LoginViewModel не принимает участия в этой логике. Опишем сценарий онбординга.

class OnboardingFlowCoordinator(
  val navigator: Navigator,
  val onboardingFinished: () -> Unit // this is RootFlowCoordinator.onboardingCompleted()
) {

  fun start(){
    navigator.showOnboardingWelcome()
  }

  fun welcomeShown(){
    navigator.showOnboardingPersonalInterestChooser()
  }

  fun onboardingCompleted(){
    onboardingFinished()
  }
}

Онбординг запускается вызовом OnboardingFlowCoordinator#start(), который показывает WelcomeFragment (WelcomeViewModel). После клика по кнопке “next” вызывается метод OnboardingFlowCoordinator#welcomeShown(). Который показывает следующий экран PersonalInterestFragment + PersonalInterestViewModel, на котором пользователь выбирает категории интересных новостей. После выбора категорий пользователь тапает по кнопке “next” и вызывается метод OnboardingFlowCoordinator#onboardingCompleted(), который проксирует вызов RootFlowCoordinator#onboardingCompleted(), который запускает NewsFlowCoordinator. Посмотрим как координатор может упростить работу с A/B тестами. Я добавлю экран с предложением совершить покупку в приложении и буду показывать его некоторым пользователям.

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

И снова мы не добавили никакой логики в представление или его модель. Решили добавить InAppPurchaseFragment в онбординг? Для этого понадобится изменить только координатор онбординга, поскольку фрагмент покупок и его viewmodel полностью независим от других фрагментов и мы свободно можем его повторно использовать в других сценариях. Координатор также поможет реализоваnь А/В тест, который сравнивает два сценария онбординга.

Полные исходники можно найти на гитхабе, а для ленивых я подготовил видеодемонстрацию

Полезный совет: используя котлин можно создать удобный dsl для описания координаторов в виде графа навигации.
newsFlowCoordinator(navigator, abTest) {

  start {
    navigator.showNewsList()
  } 

  readNewsArticle { id ->
    navigator.showNewsArticle(id)
  }

  closeNews {
    if (abTest.isB){
      navigator.showInAppPurchases()
    } else {
      navigator.closeNews()
    }
  }
}

Итоги:

Координатор поможет вынести логику навигации в тестируемый слабосвязанный компонент. На данный момент нет production-ready библиотеки, я описал лишь концепцию решения проблемы. Применим ли координатор к вашему приложению? Не знаю, это зависит от ваших потребностей и насколько легко будет интегрировать его в существующую архитектуру. Возможно, будет полезно написать небольшое приложение с использованием координатора.
ЧаВо:
В статье не упоминается использование координатора с шаблоном MVI. Возможно ли использовать координатор с этой архитектурой? Да, у меня есть отдельная статья.

Гугл недавно представил Navigation Controller как часть Android Jetpack. Как координатор соотносится с навигацией от гугла? Вы можете использовать новый Navigation Controller вместо навигатора в координаторах или непосредственно в навигаторе вместо ручного создания транзакций фрагментов.

А если я не хочу использовать фрагменты/активити и хочу написать свой бекстек для управления вьюхами — получится ли использовать координатор в моем случае? Я тоже задумался об этом и работаю над прототипом. Я напишу об этом в своем блоге. Мне кажется, что конечный автомат здорово упростит задачу.

Привязан ли координатор к single-activity-application подходу? Нет, вы можете использовать его в различных сценариях. Реализация перехода между экранами скрыта в навигаторе.

При описанном подходе получится огромный навигатор. Мы же вроде пытались уйти от God-Object’a? Мы не обязаны описывать навигатор в одном классе. Создайте несколько небольших поддерживаемых навигатора, например, отдельный навигатор для каждого пользовательского сценария.

Как работать с анимациями непрерывных переходов? Описывайте анимации переходов в навигаторе, тогда активити/фрагмент не будет ничего знать о предыдущем/следующем экране. Как навигатор узнает когда запускать анимацию? Допустим, мы хотим показать анимацию перехода между фрагментами А и Б. Мы можем подписаться на событие onFragmentViewCreated(v: View) с помощью FragmentLifecycleCallback и при наступлении этого события мы можем работать с анимациями так же, как мы это делали непосредственно в фрагменте: добавить OnPreDrawListener чтобы дождаться готовности и вызвать startPostponedEnterTransition(). Примерно так же можно реализовать анимированный переход между активити с помощью ActivityLifecycleCallbacks или между ViewGroup с помощью OnHierarchyChangeListener. Не забудьте потом отписаться от событий чтобы избежать утечек памяти.