使用协调器在Android应用程序中导航

在过去的几年中,我们开发了用于创建Android应用程序的通用方法。 纯架构,架构模式(MVC,MVP,MVVM,MVI),存储库模式等。 但是,仍然没有公认的方法来组织应用程序内的导航。 今天,我想与您谈谈“协调器”模板及其在Android应用程序开发中的应用可能性。
协调器模式通常用在iOS应用程序中,由Soroush Khanlou引入,目的是简化应用程序的导航。 人们相信Sorush的工作是基于Martin Fowler的企业应用程序架构模式中描述的Application Controller方法。
“协调器”模板旨在解决以下任务:

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

脚本(流程)包含一个或多个屏幕。 在我们的示例中,新闻场景由2个屏幕组成:“文章列表”和“文章文本”。 协调员非常简单。 当应用程序启动时,我们调用NewsFlowCoordinator#start()来显示文章列表。 当用户单击列表项时,将调用NewsFlowCoordinator#readNewsArticle(id)方法,并显示带有文章全文的屏幕。 我们仍在与导航器一起工作(稍后再讨论),我们将其委托给屏幕打开。 协调器没有状态,它不依赖于后端的实现,而仅实现一个功能:它确定下一步要去哪里。

但是如何将协调器与我们的演示模型联系起来? 我们将遵循依赖关系反转的原理:将lambda传递给视图模型,当用户点击文章时将调用该模型。

 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的lambda。 请注意,lambda可能为null,这将使我们清除链接以避免内存泄漏。 视图模型的创建者(例如,匕首)必须将链接传递给coordinator方法:

 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添加新功能? 我们不想让上帝协调员来负责应用程序中的所有导航吗? 另外,授权脚本不适用于新闻阅读器方案,对吗?

 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事件都有一个相应的lambda,但是对于成功登录的回调没有lambda。 这也是一个实现细节,您可以添加相应的lambda,但是我有一个更好的主意。 让我们添加一个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)。 单击“下一步”按钮后,将调用OnboardingFlowCoordinator#welcomeShown()方法。 该屏幕显示以下屏幕PersonalInterestFragment + PersonalInterestViewModel,用户在该屏幕上选择有趣新闻的类别。 选择类别后,用户点击“下一步”按钮,并调用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添加到入门中? 为此,您只需要更改入职协调器,因为购物片段及其视图模型完全独立于其他片段,我们可以在其他场景中自由地重用它。 协调员还将帮助实施A / B测试,该测试比较了两种入门场景。

完整的资源可以在github找到 ,对于懒惰的人,我准备了一个视频演示


有用的建议:使用kotlin,您可以创建方便的dsl,以导航图的形式描述协调器。

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

总结:


协调器将帮助将导航逻辑带到经过测试的松耦合组件。 目前尚无生产就绪的库,我仅描述了解决问题的概念。 协调人是否适用于您的应用程序? 我不知道,这取决于您的需求以及将其集成到现有架构中的难易程度。 使用协调器编写小型应用程序可能会很有用。

常见问题解答:

本文没有提到使用带有MVI模式的协调器。 是否可以在这种体系结构中使用协调器? 是的,我有另一篇文章

Google最近将导航控制器作为Android Jetpack的一部分推出。 协调员与Google导航有何关系? 您可以使用新的Navigation Controller代替协调器中的导航器,也可以直接在导航器中使用,而不是手动创建片段事务。

而且,如果我不想使用片段/活动,而想编写自己的后端来管理视图,那么我可以使用协调器吗? 我也考虑了这一点,并且正在研究原型。 我将在我的博客上写这个。 在我看来,状态机将大大简化任务。

协调员是否隶属于单一活动应用程序方法? 不,您可以在各种情况下使用它。 屏幕之间转换的实现隐藏在导航器中。

使用上述方法,您将获得一个强大的导航器。 我们有点试图逃离上帝对象'? 我们不需要在一个类中描述导航器。 创建几个受支持的小型导航器,例如,为每个用户方案创建一个单独的导航器。

如何使用连续过渡动画? 在导航器中描述过渡动画,那么活动/片段将不了解上一个/下一个屏幕。 导航员如何知道何时开始动画? 假设我们要显示片段A和片段B之间过渡的动画。我们可以使用FragmentLifecycleCallback订阅onFragmentViewCreated(v:View)事件,当此事件发生时,我们可以像直接在片段中一样使用动画:添加OnPreDrawListener等待直到准备就绪,然后调用startPostponedEnterTransition()。 以大致相同的方式,您可以使用ActivityLifecycleCallbacks在活动之间或使用OnHierarchyChangeListener在ViewGroup之间实现动画过渡。 不要忘记稍后取消订阅事件,以避免内存泄漏。

Source: https://habr.com/ru/post/zh-CN416301/


All Articles