Compose Navigation用于Android多module项目最佳实践
在本文中,我们将采取同一个项目并扩展它以实现最佳实践。该项目具有文章、设置和关于屏幕的抽屉导航。项目的输出如下所示:
当你有一个多屏幕的项目时,每个屏幕至少必须有自己单独的模块。在我们的例子中,我为每个屏幕创建了三个单独的模块,并在应用程序模块中使用它们来实现基本的导航。
1. 准备工作
1.1 起始项目
我在Github链接上创建了一个起始项目,你可以从仓库中下载代码并从starter文件夹获取项目。Github repo还包含final文件夹,这是实现Navigation Compose最佳实践后的最终项目,本文将逐步介绍如何实现。
1.2 项目结构
为了对起始项目模块结构有一个概述:它每个屏幕有一个单独的模块,并将所有这些模块添加到app模块的依赖项中。下面的图表说明了它。
feature_articles
是用于显示文章列表的模块,feature_article
是用于显示关于单篇文章的详细信息的模块。
图表中的箭头表示依赖关系的使用,这意味着app模块添加了对feature_settings、feature_articles、feature_about和feature_article
模块的依赖项,以实现导航。
1.3 基本的Compose导航
让我们看一下起始项目中Compose导航的实现。
//BasicComposeNavigation.kt
@Composable
fun MainNavigation(
navController: NavHostController = rememberNavController(),
coroutineScope: CoroutineScope = rememberCoroutineScope(),
drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
) {
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
DrawerContent(menus) { route ->
coroutineScope.launch {
drawerState.close()
}
navController.navigate(route)
}
}
}
) {
NavHost(navController = navController, startDestination = MainRoute.Articles.name) {
composable(MainRoute.Articles.name) {
val viewModel: ArticlesViewModel = hiltViewModel()
ArticlesScreen(drawerState, viewModel) {
navController.navigate("article")
}
}
composable(MainRoute.About.name) {
val viewModel: AboutViewModel = hiltViewModel()
AboutScreen(drawerState, viewModel)
}
composable(MainRoute.Settings.name) {
val viewModel: SettingsViewModel = hiltViewModel()
SettingsScreen(drawerState, viewModel)
}
composable(MainRoute.Article.name) {
val viewModel: ArticleViewModel = hiltViewModel()
ArticleScreen(
viewModel = viewModel,
onBackNavigation = {
navController.navigateUp()
}
)
}
}
}
}
为了实现上述所示的导航,我们必须从各个屏幕模块中公开每个屏幕的Composable和ViewModel,以便在app模块中使用它们。例如,对于feature_articles
模块,ArticlesScreen
和ArticlesViewModel
必须对app模块可访问,以构建上述的NavGraph
,其他模块也是类似。
2. 最佳实践
我们将逐步详细说明并使用上述起始项目作为基线来实现最佳实践。
2.1 屏幕Composables接收状态和事件传入。
每个屏幕的Composable
必须接收一个状态对象和事件作为参数。事件可以在模块内部的屏幕Composable中进行处理,而那些无法在屏幕内部处理的事件必须在屏幕Composable之外传递给外部/更高级的NavGraph
来处理。
下面是ArticleScreen的一个示例。
//ArticleScreen.kt
fun ArticleScreen(
viewModel: ArticleViewModel,
onNavigateBack: () -> Unit,
) {
// code
}
viewModel
包含了UI的状态,推荐使用一个独立的状态类作为屏幕Composable
的参数,但这不是本篇文章的重点。onNavigationBack
是从ArticleScreen
中省略的事件,将由更高级的NavGraph
处理。
2.1 按屏幕拆分导航图
为每个屏幕创建一个导航图。我们可以通过在NavGraphBuilder上创建一个扩展方法来实现。
要在我们的屏幕模块中使用NavGraphBuilder,我们需要添加以下Gradle依赖项。
implementation("androidx.navigation:navigation-compose:2.5.3")
让我们以feature_article
模块为例,在feature_article
模块中创建ArticleNavigation.kt
文件,该文件在NavGraphBuilder
上添加了一个扩展方法,如下所示。
//ArticleNavigation.kt
private const val articleIdArg = "articleId"
fun NavGraphBuilder.articleScreen(onNavigateBack: () -> Unit) {
composable("article/{$articleIdArg}") {
val viewModel: ArticleViewModel = hiltViewModel()
ArticleScreen(
viewModel = viewModel,
onNavigateBack = onNavigateBack
)
}
}
需要注意以下几点:
ArticleNavigation.kt
文件和扩展方法将导航逻辑与屏幕逻辑分离。- 它封装了与导航相关的代码,不需要将其暴露给其他部分的代码。
ViewModel
实例和UI状态实例将在此扩展方法中创建。- 无法在
ViewModel
内部处理的事件将传递给NavGraphBuilder
的上层,例如在这种情况下的onNavigateBack
事件。 NavGraphBuilder
上的扩展方法将在模块之外暴露,并且不再需要将ArticleViewModel
、ArticleScreen
暴露给模块之外。- 我们必须为
ArticleScreen
和ArticleViewModel
指定internal
访问修饰符,因为它们不再需要在模块之外被访问。
2.2 为每个屏幕目标提供扩展方法
每个屏幕都必须暴露NavController
扩展方法,以便其他目标可以安全地导航到它。
为传递的参数提供类型安全性。
封装特定于导航的代码。
在ArticleNavigation.kt
文件中为ArticleScreen
创建NavController
扩展方法。
// ArticleNavigation.kt
fun NavController.navigateToArticle(articleId: String) {
this.navigate("article/$articleId")
}
上述扩展方法navigateToArticle
在ArticleNavigation.kt
文件中,该文件封装了如何指定导航路线和所需参数的方式。
2.3 创建类型安全的参数包装器
为了确保从ViewModel
内的SavedStateInstance
正确提取参数类型,我们应该创建一个参数包装器。
以下是用于ArticleScreen
的articleId
的包装器ArticleArgs
。
// ArticleNavigation.kt
//TypeSafeArgs.kt
private const val articleIdArg = "articleId"
internal class ArticleArgs(articleId: String) {
constructor(savedStateHandle: SavedStateHandle) :
this(checkNotNull(savedStateHandle[articleIdArg]) as String)
}
// ArticleViewModel.kt
@HiltViewModel
internal class ArticleViewModel @Inject constructor(
savedStateHandle: SavedStateHandle
): ViewModel() {
val articleArgs = ArticleArgs(savedStateHandle)
}
使用internal访问修饰符来确保ArticleArgs
和ArticleViewModel
不会被模块之外的代码访问。
Navigation Compose无法提供编译时类型安全的代码,但通过这样做,我们可以确保运行时类型安全的代码。
2.4 仅暴露所需的公共API
如前所述和实施,我们必须确保只有所需的API被模块之外的代码访问,此处可以使用internal访问修饰符。
请确保为屏幕组件、viewModel
和Args(例如ArticleViewModel
、ArticlesScreen
和ArticleArgs
)提供internal
修饰符。
NavGraphBuilder
和NavController
上的扩展方法只能在模块之外暴露。
只将第一个目标路由暴露给功能模块之外,以便NavHost可以指定起始目标。
为想要导航到的每个目标在NavController
上添加扩展方法。
以下图示概述了每个模块,显示了模块之外暴露的内容以及模块内部的内容。
2.5 模块结构指导Graph结构
使您的模块结构指导Graph结构。
我们首先需要创建一个主页模块,它将封装抽屉式导航逻辑,例如在我们的情况下,Articles
、Settings
和About
屏幕显示在抽屉中,因此让我们创建一个feature_home
模块,其中包含此类导航逻辑和屏幕/模块依赖项。
创建feature_home
模块后,项目结构应如下图所示。
feature_home
模块使用feature_settings
、feature_articles
和feature_about
模块,并封装了抽屉式导航逻辑,仅公开模块外所需的导航API。
app
模块使用feature_home
和feature_article
模块,从feature_articles
模块导航到feature_article
模块,因此feature_home
将特定的事件传递回app模块,最终将用户导航到特定的文章。
以下是feature_home模块中HomeScreen代码的示例。
//HomeScreen.kt
@Composable
internal fun HomeScreen(
navController: NavHostController = rememberNavController(),
coroutineScope: CoroutineScope = rememberCoroutineScope(),
drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed),
onNavigateToArticle: () -> Unit
) {
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
DrawerContent(menus) { route ->
coroutineScope.launch {
drawerState.close()
}
navController.navigate(route)
}
}
}
) {
NavHost(navController = navController, startDestination = articlesRoute) {
articlesScreen(drawerState, onNavigateToArticle)
settingsScreen(drawerState)
aboutScreen(drawerState)
}
}
}
HomeScreen
被指定为internal
,因为我们不需要将其暴露给模块之外;相反,我们将为NavGraphBuilder
创建一个扩展方法。
//HomeNavigation.kt
const val homeRoute = "home"
fun NavGraphBuilder.homeScreen(
onNavigateToArticle: () -> Unit
) {
composable(homeRoute) {
HomeScreen(
onNavigateToArticle = onNavigateToArticle
)
}
}
现在看看app模块项目中使用homeScreen
扩展方法的MainNavigation
代码。
//MainNavigation.kt
@Composable
fun MainNavigation(
navController: NavHostController = rememberNavController(),
) {
NavHost(navController = navController, startDestination = homeRoute) {
homeScreen {
navController.navigateToArticle("fakeArticleId")
}
articleScreen {
navController.navigateUp()
}
}
}
结论
- 每个屏幕目标必须提供一个带有
state/viewModel
和events
参数的可组合项。 - 每个目标模块必须在
NavGraphBuilder
上提供一个扩展方法,将导航逻辑与代码逻辑分离开来。 NavGraphBuilder
上的扩展方法应解析状态并传递该特定屏幕的事件。- 每个目标屏幕还必须在
NavController
上提供一个扩展方法,指定如何导航到该特定目标,封装导航特定的代码。 - 使用
args
包装器确保传递给目标的参数类型正确,确保运行时类型安全。 - 在模块内部,仅公开所需的API,将
viewModel
和屏幕可组合项保持为internal
。
Github
https://github.com/saqib-github-commits/ComposeNavigationBestPractices