Compose Navigation用于Android多module项目最佳实践

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模块,ArticlesScreenArticlesViewModel必须对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上的扩展方法将在模块之外暴露,并且不再需要将ArticleViewModelArticleScreen暴露给模块之外。
  • 我们必须为ArticleScreenArticleViewModel指定internal访问修饰符,因为它们不再需要在模块之外被访问。

2.2 为每个屏幕目标提供扩展方法

每个屏幕都必须暴露NavController扩展方法,以便其他目标可以安全地导航到它。
为传递的参数提供类型安全性。
封装特定于导航的代码。
ArticleNavigation.kt文件中为ArticleScreen创建NavController扩展方法。

// ArticleNavigation.kt

fun NavController.navigateToArticle(articleId: String) {
    this.navigate("article/$articleId")
}

上述扩展方法navigateToArticleArticleNavigation.kt文件中,该文件封装了如何指定导航路线和所需参数的方式。

2.3 创建类型安全的参数包装器

为了确保从ViewModel内的SavedStateInstance正确提取参数类型,我们应该创建一个参数包装器。

以下是用于ArticleScreenarticleId的包装器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访问修饰符来确保ArticleArgsArticleViewModel不会被模块之外的代码访问。

Navigation Compose无法提供编译时类型安全的代码,但通过这样做,我们可以确保运行时类型安全的代码。

2.4 仅暴露所需的公共API

如前所述和实施,我们必须确保只有所需的API被模块之外的代码访问,此处可以使用internal访问修饰符。

请确保为屏幕组件、viewModel和Args(例如ArticleViewModelArticlesScreenArticleArgs)提供internal修饰符。
NavGraphBuilderNavController上的扩展方法只能在模块之外暴露。
只将第一个目标路由暴露给功能模块之外,以便NavHost可以指定起始目标。
为想要导航到的每个目标在NavController上添加扩展方法。
以下图示概述了每个模块,显示了模块之外暴露的内容以及模块内部的内容。

2.5 模块结构指导Graph结构

使您的模块结构指导Graph结构。

我们首先需要创建一个主页模块,它将封装抽屉式导航逻辑,例如在我们的情况下,ArticlesSettingsAbout屏幕显示在抽屉中,因此让我们创建一个feature_home模块,其中包含此类导航逻辑和屏幕/模块依赖项。

创建feature_home模块后,项目结构应如下图所示。

feature_home模块使用feature_settingsfeature_articlesfeature_about模块,并封装了抽屉式导航逻辑,仅公开模块外所需的导航API。

app模块使用feature_homefeature_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/viewModelevents参数的可组合项。
  • 每个目标模块必须在NavGraphBuilder上提供一个扩展方法,将导航逻辑与代码逻辑分离开来。
  • NavGraphBuilder上的扩展方法应解析状态并传递该特定屏幕的事件。
  • 每个目标屏幕还必须在NavController上提供一个扩展方法,指定如何导航到该特定目标,封装导航特定的代码。
  • 使用args包装器确保传递给目标的参数类型正确,确保运行时类型安全。
  • 在模块内部,仅公开所需的API,将viewModel和屏幕可组合项保持为internal

Github

https://github.com/saqib-github-commits/ComposeNavigationBestPractices

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Calvin880828

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值