[Android翻译]使用Jetpack Compose的模块化导航

原文地址:medium.com/google-deve…

原文作者:hitherejoe.medium.com/

发布时间:2021年4月20日 – 15分钟阅读

大量的移动应用程序都需要某种形式的导航,允许用户在应用程序的不同部分之间移动。当在Android应用程序中实现这些要求时,应用程序要么推出自己的解决方案,要么依赖传统的意图或片段管理器,要么在最近几年探索导航组件的选项。在整个Jetpack Compose的alpha和开发者预览版中,我经常被问到,”那导航呢?”,”有可能在组件之间导航吗?”。有了Jetpack Compose,自由使用组合体的想法引入了无碎片和(大部分)无活动的应用程序的想法,允许我们在显示我们的用户界面时只依靠组合体。

由于导航组件中的导航合成支持,现在这完全是可能的。应用程序可以在他们的应用程序中启动一个单一的活动,依靠Composables来表示构成该应用程序的UI组件。我已经在几个项目中使用了这个组件,并且非常喜欢这种感觉上是一种构建Android应用程序的新方式。然而,在最初从现有的指南和博客文章中在项目中使用这个东西后,我开始发现一些东西,感觉它们会在项目中增加一些摩擦和痛点。

  • 在组合函数中要求引用NavHostController–在很多情况下,我们需要让一个组合函数向另一个组合函数执行导航。我发现在很多情况下,默认的方法是通过组合函数传递这个NavHostController引用。这意味着为了执行导航,我们必须始终有一个对当前NavHostController的引用。这不是很好的可扩展性,并且使我们不得不依赖这个引用来执行导航。
  • 难以测试导航逻辑–当我们的可组合函数直接使用这个NavHostController来触发导航时,就很难测试我们的导航逻辑了。目前,可组合函数是使用仪器测试来测试的。让另一个类来处理我们的导航逻辑(如视图模型)可以让我们在项目的单元测试中测试导航事件。
  • 为 Compose 迁移增加摩擦 – 很多使用 Compose 的项目都是现有的项目,其规模各不相同。在这种情况下,项目很可能不会被完全重写,而会以各种方式迁移到 Compose 中 – 新的功能可能会用 Compose 编写,而现有的组件会慢慢被重写。在这些情况下,可能会被证明很难为这些组合体提供这个NavHostController。例如,也许被重写成Composables的现有组件的隔离方式,使得该NavHostController很难被提供给这些功能。
  • 耦合到导航依赖–要求NavHostController引用来执行导航意味着每一个使用这个的模块都需要对合成导航依赖的引用。同样地,如果你打算使用Hilt Navigation Compose依赖项来为不同的模块提供视图模型,那么该依赖项也将是如此。虽然这感觉像是一个预期的要求,但在解决上述问题时,对这些东西的集中依赖是一个很好的副作用。

虽然这些只是我一直在思考的一些事情,但可能是Compose Navigation让你思考如何将其融入你现有的应用程序,或在你的新项目中进行结构化。

受我几年前读到的一篇关于模块化导航的文章的启发,我想分享一下我在向模块化的安卓应用添加Compose Navigation时做的一些探索。我们将学习。

  • 如何在不依赖NavHostController引用的情况下,在不同的功能模块中导航到组合体
  • 如何将这些模块与组合导航解耦,并通过视图模型在一个集中的位置处理导航问题
  • 如何使用Hilt Navigation Compose为这些组合体提供视图模型,而无需让每个功能模块依赖这种依赖关系
  • 如何通过优化我们的导航逻辑来简化我们的测试方法

在这篇文章中,我们不会涉及复合导航组件的基础,所以如果你想了解复合导航的介绍,请使用以下指南

image.png


模块化的应用程序

为了探讨上述要点,我们将使用我的Minimise项目作为参考,工作中的应用结构看起来像下面这样。

这里,我们有几个模块组成了我们的应用程序。

  • 应用程序模块–这是我们应用程序的基础模块。它包含我们的组合导航图,为我们应用程序的功能模块中包含的不同组合物提供导航。
  • 导航模块–该模块将协调整个项目的导航。它为依赖模块提供了一种触发导航的方法,以及观察这些导航事件的方法。
  • 功能模块 – 包含指定功能的可组合模块。注意:在示例代码中,我们将使用一个以上的功能模块,这里只包括一个,以保持图表的简洁。

从这个图中,我们可以开始看到这些不同模块之间的关系,以及它们如何一起工作以实现我们期望的导航目标。

  • 应用模块使用NavHostController建立了我们的导航图,提供了一种在我们的功能模块中组合和导航到可组合的方式。
  • 导航模块定义了可以在我们的图中导航到的可能的目的地。这些被结构化为命令,根据需要从我们的功能模块中触发。
  • 当通过导航管理器触发时,应用程序模块使用导航模块来观察这些导航命令。当任何事件发生时,NavHostController将被用来在我们的组合物中进行导航。
  • 特征模块使用导航模块从其视图模型中触发这些导航事件,依靠观察这些事件的任何东西来处理实际导航。
  • 视图模型是使用Hilt Navigation Compose从应用模块内提供给可组合功能的,将视图模型的范围扩大到相应NavHostController内的当前backstack条目。

为了让我们能看到这一点是如何实现的,我们将开始构建一些代码来表示上述要求。


可组合的目的地

在我们开始考虑导航到可组合目的地之前,我们需要建立一些可组合目的地。在我的Minimise项目中,我有两个功能;一个认证屏幕和一个仪表板屏幕。用户将从认证界面开始,当他们在应用中成功认证后,将被带到仪表盘。

image.png

我们将从这里开始,定义一个新的可组合函数,名为Authentication,它将获取一个AuthenticationState kotlin类的引用,该类持有这个屏幕的状态。我们不会深入研究这些可组合函数的内部,因为这些代码对本文来说并不重要,我们只看一下它们是由什么组成的。

@Composable
private fun Authentication(
    viewState: AuthenticationState
)
复制代码

这个状态将来自一个用@HiltViewModel注解的ViewModel,这是用Jetpack Hilt集成完成的。

@HiltViewModel
class AuthenticationViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    ...
) : ViewModel() {
    val state: LiveData<AuthenticationState> ...
}
复制代码

你可能已经注意到,我们之前的可组合函数被标记为私有。为了让访问我们的功能模块的人都能组成认证用户界面,我们将添加一个可公开访问的可组合函数。在这个函数中,我们将添加这个ViewModel作为参数,允许我们解耦这个ViewModel的提供方式,同时也为改进我们的可组合测试方法增加空间。

@Composable
fun Authentication(
    viewModel: AuthenticationViewModel
) {
    val state by viewModel.uiState.observeAsState()
    Authentication(state)
}
复制代码

有了这个,我们现在就有了一个可组合的函数来实现我们应用程序的认证功能。为了使我们能够在应用程序的两个部分之间进行导航,我们将继续为第二个功能创建另一个可组合函数,即我们应用程序的仪表板。我们将在这里做同样的事情,我们将创建一个可组合的函数,用来组成我们的Dashboard UI,还有一个ViewModel,用来协调其状态。

@Composable
fun Dashboard(
    viewModel: DashboardViewModel
) {
    val state by viewModel.uiState.observeAsState()
    DashboardContent(state)
}

@HiltViewModel
class DashboardViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    ...
) : ViewModel()
复制代码

有了这些,我们现在有两个由可组合函数组成的功能,每个都有自己的ViewModel。然而,这些功能目前在我们的应用程序中没有做任何事情–所以接下来让我们深入了解一下如何配置在我们应用程序的这两个部分之间移动。


设置导航路线

因为我们将有一个集中的导航模块来控制我们应用程序中的导航,所以我们需要为我们应用程序中支持的导航创建某种形式的合同。我们将以NavigationCommand的形式来创建,它允许我们定义不同的导航事件,这些事件可以被触发,并由持有导航控制器的类来观察。

如果你的项目中还没有Compose Navigation,你需要添加以下依赖。

androidx.navigation:navigation-compose:1.0.0-alpha07
复制代码

image.png

我们将从这里开始定义一个接口,即NavigationCommand。这将定义一个导航事件的要求–目前我只需要它支持一个目的地和要提供的任何参数。如果需要满足其他需求,这个类还有发展的空间。

interface NavigationCommand {

    val arguments: List<NamedNavArgument>

    val destination: String
}
复制代码

有了这个契约,我们现在可以定义一些导航命令,这些命令可以用来在我们应用程序的特定功能之间进行导航–我们将为我们的认证和仪表盘功能进行导航。在这里,我们将为每个功能定义一个新的函数,实现上面的NavigationCommand接口。现在,我们将简单地对导航目的地进行硬编码,以满足我们命令的目的地属性。然后,这个目的地将被我们的导航控制器在计算要导航到什么组合时使用。

object NavigationDirections {

    val authentication  = object : NavigationCommand {

        override val arguments = emptyList<NamedNavArgument>()

        override val destination = "authentication"

    }

    val dashboard = object : NavigationCommand {

        override val arguments = emptyList<NamedNavArgument>()

        override val destination = "dashboard"
    }
}
复制代码

这些目的地目前在进行导航时并不使用导航参数,但我想提供这样的灵活性,因为我的应用程序的其他部分将需要它。当需要时,我们仍然可以在我们的导航模块中集中使用用于Compos导航的参数。使用仪表盘目的地的函数来提供所需的参数,然后这些参数可以被用来建立参数列表。这就保持了我们对导航契约的处理方式,同时仍然给我们模块化导航的灵活性。

object DashboardNavigation {

  private val KEY_USER_ID = "userId"
  val route = "dashboard/{$KEY_USER_ID}"
  val arguments = listOf(
    navArgument(KEY_USER_ID) { type = NavType.StringType }
  )

  fun dashboard(
    userId: String? = null
  ) = object : NavigationCommand {
    
    override val arguments = arguments

    override val destination = "dashboard/$userId"
  }
}
复制代码

有了上述内容,你可以使用对象的路由和参数来配置导航,然后通过使用dashboard()函数触发事件来执行实际导航。使用导航参数不在我现在的要求范围内,但希望上述内容能给我们提供一个大致的例子,说明在这里可以做什么


设置导航图

image.png

现在我们已经定义了我们的导航命令,我们可以继续为我们的应用程序配置导航图–它被用来定义目的地和它们所指向的可组合物。我们将从这里开始,使用rememberNavController组合函数定义一个新的NavHostController引用–它将被用来处理我们图的导航。

val navController = rememberNavController()
复制代码

有了这些,我们就可以继续定义我们的NavHost了–它将被用来包含构成我们的导航图的可组合物,这些可组合物将通过其构建器参数提供。现在我们将提供我们之前定义的NavHostController引用,以及我们的图应该开始的目的地–为此我们将使用认证屏幕,从我们之前定义的NavigationDirections引用中获取其目的地字符串。

NavHost(
    navController,
    startDestination = NavigationDirections.Authentication.destination
) {

}
复制代码

有了这个startDestination的定义,这意味着我们的NavHost将使用它来配置我们的导航图的初始状态–在与认证目的地字符串相匹配的可组合处开始我们的用户。


设置导航目的地

image.png

虽然我们已经为我们的导航图定义了这个初始目的地,但我们还没有定义构成我们图的任何可组合的目的地–所以事情不会像现在这样进行!所以在这里我们将继续为我们的导航图定义初始目的地。所以在这里我们将继续使用NavGraphBuilder.composable函数添加一个新的目的地。我们首先使用我们的NavigationDirections定义中的字符串为可组合的路线提供,这意味着每当这个路线被导航到时,这个可组合的目的地的主体将在我们的用户界面中被组成。在这里,我们为该主体提供我们之前定义的可组合的认证。

composable(NavigationDirections.Authentication.destination) {
    Authentication()
}
复制代码

然后,我们将为我们的仪表盘目的地再次做同样的事情–使用我们之前为主体定义的仪表盘可组合,定义触发这个可组合被导航到的路线。

composable(NavigationDirections.Dashboard.destination) {
    Dashboard()
}
复制代码

有了上述内容,我们现在在导航图中定义了两个可组合的目的地,其中认证可组合被用作我们图中的起始目的地。


为可组合程序提供视图模型

image.png

如果我们回到之前为认证和仪表盘定义的可组合函数,我们会发现上面的声明无法编译–这是因为我们缺少这些可组合函数所需的视图模型参数。为了提供这些参数,我们将利用我们的NavController参考,以及hiltNavGraphViewModel扩展函数。为了获得这些,你需要在你的应用程序中添加以下依赖关系。

androidx.hilt:hilt-navigation-compose:1.0.0-alpha01
复制代码

使用这个扩展函数将为我们提供一个viewmodel参考,它的范围是我们的NavController的所提供的路线。

Authentication(
    navController.hiltNavGraphViewModel(route = NavigationDirections.Authentication.destination)
)
复制代码

虽然这在可组合本身中是可能的,但将其作为一个参数传入允许我们将组合导航+倾斜导航组合的依赖性放在我们的功能模块之外。当涉及到可组合测试和提供模拟引用时,能够通过可组合函数提供ViewModel本身会很有帮助。

如果我们能保证导航图一直存在(并且在提供viewmodel时不需要提供我们自己的路由),那么我们可以使用hiltNavGraphViewModel()函数,它不是NavController上的扩展函数。这个函数不需要提供路由,因为这将从当前的backstack条目中推断出来。

Authentication(
    hiltNavGraphViewModel()
)
复制代码

在为我们的Authentication composable准备好之后,我们将继续为我们的Dashboard composable做同样的事情,以确保它有一个viewmodel提供给它使用。

composable(NavigationDirections.Dashboard.destination) {
    Dashboard(
        hiltNavGraphViewModel()
    )
}
复制代码

处理导航事件

image.png

有了我们的视图模型,我们就可以为我们的导航图触发导航事件来处理了。这一部分的关键是一个集中的位置来触发和观察事件,在这种情况下,这将是一个名为NavigationManager的单子类。这个类需要定义两件事。

  • 一个组件,用于输出先前定义的NavigationCommand事件,允许外部类观察这些事件
  • 一个可用于触发这些NavigationCommand事件的函数,使上述组件的观察者能够处理这些事件。

考虑到这一点,我们有一个NavigationManager类,它看起来是这样的。

class NavigationManager {

    var commands = MutableStateFlow(Default)

    fun navigate(
        directions: NavigationCommand
    ) {
        commands.value = directions
    }

}
复制代码

在这里,命令引用可以被外部类用来观察被触发的NavigationCommands,而navigate函数可以被用来根据提供的NavigationCommand来触发导航。
值得注意的是,这个类必须是一个单子实例。这样我们就可以确保每个与NavigationManager通信的类都引用了同一个实例。在我的应用程序中,我将该类定义在Hilt SingletonComponent中。

@Module
@InstallIn(SingletonComponent::class)
class AppModule {

    @Singleton
    @Provides
    fun providesNavigationManager() = NavigationManager()
}
复制代码

有了这个,我们就可以观察到这些导航事件被我们的导航图所处理。我们之前定义了我们的NavHost参考,它被用来定义我们的导航图,为此我们还提供了一个NavHostController参考。这个NavHostController也可以用来触发不同目的地之间的导航–当我们观察到的NavigationCommand事件发生时,我们可以这样做。我们在这里要做的是注入一个对NavigationManager的引用,然后使用包含的命令来观察导航事件。因为这些命令使用StateFlow,我们可以利用Compose Runtime collectAsState()扩展函数来收集以Compose状态的形式发生的状态流事件。然后我们可以使用这个状态的值来确定要导航的方向,用我们的NavHostController来触发这个。

@Inject
lateinit var navigationManager: NavigationManager

navigationManager.commands.collectAsState().value.also { command ->
    if (command.destination.isNotEmpty()) navController.navigate(command.destination)
}
复制代码

注意:StateFlow目前需要(据我所知)为初始化提供一个值。这就是为什么我们在这里有这个空检查的原因–希望这在将来可以被整理好!


触发导航事件

现在我们有了对导航命令的观察,我们要去触发它们。这将从我们的视图模型中完成,将导航的责任从我们的Composable中移除。

image.png

我们在这里要做的是将我们的NavigationManager添加到我们的视图模型中,通过构造函数提供它。记住,这个引用是一个单子,所以这将是我们的导航图所在的地方所使用的同一个实例。

@HiltViewModel
class AuthenticationViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val authenticate: Authenticate,
    private val sharedPrefs: Preferences,
    private val navigationManager: NavigationManager
)
复制代码

有了这个,我们现在可以直接从我们的viewmodel触发导航事件。也许我们的Composable想手动调用我们的viewmodel来触发一些导航,或者我们想根据某个操作的结果来触发导航。无论怎样,我们可以通过触发导航函数并传入我们想用于导航命令的NavigationDirections来实现。

navigationManager.navigate(NavigationDirections.Dashboard)
复制代码

有了这个导航逻辑现在在我们的视图模型中,我们可以通过验证所需的函数被调用来轻松测试我们的导航管理器的任何模拟实例。

verify(mockNavigationManager).navigate(NavigationDirections.Dashboard)
复制代码

收尾工作

有了以上这些,我们已经能够为Jetpack Compose实现模块化应用的导航。这些变化使我们能够集中我们的导航逻辑,在这样做的同时,我们可以看到一系列的优势,现在已经到位了。

  • 我们不再需要将NavHostController传递给我们的组件,该引用被用来执行导航。将其保留在我们的组件之外,可以消除我们的功能模块对合成导航依赖的需要,同时也简化了测试时的构造器。

  • 我们已经为我们的组合物添加了ViewModel支持,通过我们的导航控制器提供,而且同样不需要为我们的每个功能模块添加Hilt Compose Navigation相关的依赖项–相反,通过Composable函数提供viewmodel。这不仅给了我们这里提到的优势,而且再次简化了我们的可组合的测试–允许我们在测试时轻松提供ViewModel及其嵌套类的模拟实例。

  • 我们已经集中了我们的导航逻辑,并为可以被触发的事情创建了一个契约。除了上面提到的好处之外,这有助于保持我们的应用程序的导航更容易理解和调试。当涉及到理解导航或应用程序支持什么,以及这些东西在哪里被触发时,任何跳入我们应用程序的人都可以体验到减少摩擦。

  • 除了以上几点,我们已经能够以一种有助于减少摩擦的方式来处理导航问题,同时采用Compose。当在一个现有的应用中采用Compose时,开发者很可能会在应用中加入可组合的部分–也许是用一个可组合的部分来代替一个视图,或者是用整个屏幕来代表一个可组合的用户界面。无论采用哪种方法,都有助于保持事情的简单和责任的最小化–这一点,这种模块化的导航方法有助于实现。

这些只是在思考Jetpack Compose的这种导航方法时想到的一些优点。你能想到其他的吗?或者即使如此,有什么缺点吗?让我们知道吧!

Compose中的导航仍然处于早期阶段,所以事情仍然可能发生变化。我目前在我的 Minimise 项目中使用这种方法,但随着我继续学习更多关于 Compose 的知识以及为我的项目构造东西的最佳方式,这肯定会改变。根据你项目的需要,这也可能对你有用。同时,我对这种方法很满意,如果需要更多的导航功能,它肯定还有发展的空间。


www.deepl.com 翻译

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享