Хабрахабр

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

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

Есть мнение, что работа Соруша основана на подходе Application Controller, описанном в книге Patterns of Enterprise Application Architecture Мартином Фаулером (Martin Fowler).
Шаблон “координатор” часто используется в iOS-приложениях и был представлен Сорушем Ханлоу (Soroush Khanlou) с целью упростить навигацию по приложению.

Шаблон “координатор” призван решить следующие задачи:

  • борьба с Massive View Controller проблемой (о проблеме уже писали на хабре — прим. переводчика), которая зачастую проявляется с появлением God-Activity (активити с большим количеством обязанностей).
  • выделение логики навигации в отдельную сущность
  • переиспользование экранов приложения (активити/фрагментов) благодаря слабой связи с логикой навигации

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

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

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

class ShoppingCartActivity : Activity() }
}

В приведенном примере навигация тесно связана с активити. Удобно ли тестировать подобный код? Можно было бы возразить, что мы можем выделить навигацию в отдельную сущность и назвать ее, к примеру, 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. Давайте добавим авторизацию в наше приложение. Мы же не хотим получить God-Coordinator, который будет отвечать за всю навигацию в приложении? Почему бы не добавить новый функционал в NewsFlowCoordinator? Также сценарий авторизации не относится к сценарию чтения новостей, верно?

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 вместо навигатора в координаторах или непосредственно в навигаторе вместо ручного создания транзакций фрагментов. Гугл недавно представил Navigation Controller как часть Android Jetpack.

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

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

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

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»