Android导航研究案例
本文旨在寻找一种更好的方法来建立不同功能模块之间的导航,使模块之间的耦合度低,易于维护和测试。
导航框架
示例考虑以下情况:
所有的功能模块和组合根,使用jetpack导航,基本上每个模块都有自己的导航图,上面声明了所有的片段、对话……然后我们把所有的特征导航文件都包含到主导航文件中,主导航文件位于组合根目录(应用模块)中。
然后,jetpack导航将仅作为导航路径和处理每个导航事务的真实来源。
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="<http://schemas.android.com/apk/res/android">
xmlns:app="<http://schemas.android.com/apk/res-auto">
android:id="@+id/app_navigation"
app:startDestination="@id/home_navigation">
<include app:graph="@navigation/home_navigation"/>
<include app:graph="@navigation/restaurant_navigation"/>
<include app:graph="@navigation/checkout_navigation"/>
</navigation>
我相信所有的选项都可以在没有jetpack导航的情况下实现,所以如果我们想改变导航系统,我们所要做的就是在界面实现上替换导航。我们在Scalable Navigation文章中有一个类似的组合根的方法,请查看参考资料以了解更多细节,但是此人向我们展示了一个使用FragmentManager而不是jetpack导航的更细粒度的实现。
使用jetpack导航的一个很好的理由是合成导航是基于它的,所以当我们尝试它时,它将更容易支持。
内部导航
对于内部导航,仅考虑使用组合根的选项,我认为我们仍然会在复合根层上添加委托的实现,因为我们不知道在功能模块内部应该去哪里。
因此,如果你有一个屏幕a,可以导航到屏幕B(同一模块的一部分)和屏幕C(另一个模块的一部分),我们需要使用ISP将代表分成内部和外部,这有意义吗?欢迎讨论,但我宁愿有一个单一的界面和一切遵循相同的模式。
测试解决方案
深层联系/意图;
源模块知道目标模块;
源模块知道目标模块的组成根;
组成根与授权;
深层链接
我不喜欢这个解决方案,因为我们需要使用URI进行导航,这意味着源特性需要知道目标特性的详细信息,例如,特性A需要知道特性B的URI才能导航到它。
我们可以在导航模块上使用深层链接,这会导致缓存失效的问题,因为所有的URI都在这里。或者每个特性在公共模块中都有自己的URI。
通常情况下,人们倾向于在多个活动中使用这种方法或意向式导航,理想情况是活动更少,每个模块只有片段。这种方法的实现与其他方法没有太大区别,基本上在最后我们只需要将一个URI传递给navController,所以我们可以使用feature module选项的组合来实现它。但上述问题依然存在。
Source knows target
对于这种方法,每个模块将声明一个导航接口,如:
interface ICheckoutNavigator {
fun openCheckout(args: CheckoutArgs, context: Fragment)
}
在public模块上,所有想要导航到checkout特性的模块都需要将:checkout:public模块声明为依赖项才能访问此接口或导航所需的任何参数。
这个接口的实现将驻留在checkout impl模块中。
class CheckoutNavigator : ICheckoutNavigator {
override fun openCheckout(args: CheckoutArgs, context: Fragment) {
context.findNavController().navigate(R.id.checkout_navigation, bundleOf("args" to args))
}
}
我发现这种方法存在一些问题:
我发现这种方法有一些问题:我们将有关签出的详细信息公开给使用此接口的任何其他功能,例如,如果我们有更多屏幕,源功能模块将知道签出包含的所有屏幕。
所有的屏幕都需要知道导航规则,所以我们将这些屏幕与导航或应用程序的详细信息结合起来,每个功能模块/屏幕都应该足够独立于系统独立运行。基本上,每一个屏幕都应该有她触发的某种事件,并且知道导航规则的人处理该事件并导航。
对于这种方法,我们还可以有一个导航模块,在这个模块中,我们为每个模块定义参数和导航接口,但是我们最终会遇到相同的问题,即featurex会使整个模块的缓存失效,可能还有其他依赖项。这可能适用于小型项目,但一旦项目开始增长,您就需要开始考虑其他解决方案。
Source knows target with composition root
这种方法与前一种方法相同,但不是在每个功能模块上实现导航接口,而是在组合根(应用程序模块)内部。
一般情况下,复合根用户知道每个特性,那么她可能是接收来自屏幕的事件的层,但是,我们仍然存在屏幕知道导航细节以及依赖于导航的每个屏幕的问题。
带委托的composition根
在我看来,这是我们应该进一步探索和尝试采用的解决办法。它是一种控制反转,但有导航组件。
基本上,每个屏幕都将声明一个委托,其中包含一个事件契约,组合根层将监听这些事件并导航到其他屏幕。
interface IRestaurantCatalogDelegate {
fun checkoutButtonClick(args: CheckoutArgs)
fun itemDetailsClick()
}
class RestaurantCatalogFragment : Fragment {
private val delegate by inject<IRestaurantCatalogDelegate> { parametersOf(activity) }
// ...
}
有了这个合同,我们可以避免视图的责任,知道她需要去哪里,或任何其他功能的细节。因为她所要做的就是调用一个方法,其他人将负责导航或任何相关的逻辑。也可以使用带有密封类的事件库来实现此委托,例如:
sealed class RestaurantCatalogEvents {
data class CheckoutClick(args: CheckoutArgs) : RestaurantCatalogEvents()
object DetailsClick : RestaurantCatalogEvents()
}
委托将有一个方法接收事件作为参数,这取决于事件的数量这种方法可能是一个问题,有一个when与许多情况。每个事件只有一个方法,我们可以使用ISP将接口拆分成多个。
这种方法的另一个好处是,如果您的项目有一个演示应用程序(一个可以单独运行特性的模块),您可以出于测试目的更改导航的行为,而无需更改功能模块中的任何代码
在构图的根上,我们要做的就是:
class RestaurantCatalogDelegate(private val activity: AppCompatActivity) :
INavigatorProvider by DefaultNavigatorProvider(
activity
), IRestaurantCatalogDelegate {
override fun checkoutClick(args: CheckoutArgs) {
controller.navigate(checkout.id.checkout_navigation, bundleOf("args" to args))
}
override fun itemDetailsClick() {
controller.navigate(restaurant.id.itemDetailFragment)
}
}
导航参数
这里我们有一个注意点,就是CheckoutArgs对象正在餐厅特性中使用,将一个特性耦合到另一个特性,这个对象存在于checkout特性的公共模块中。
我们可以做些什么来避免这个问题,例如,创建某种映射器,它将在导航到签出屏幕之前在合成根目录上执行。所以我们会有一些类似的东西:
// Restaurant module
data class RestaurantCatalogDTO(val items: Int)
// Composition root
...
override fun checkoutButtonClick(args: RestaurantCatalogDTO) {
val checkoutArgs = RestaurantMapper.dtoToCheckoutArgs(args)
controller.navigate(checkout.id.checkout_navigation, bundleOf("args" to checkoutArgs))
}
...
这样做,我们就可以避免在餐厅功能模块中存在结帐的公共依赖性。
问题是,在餐厅内结账时使用args对项目/架构有害吗?而且,我们是应该对委托的实现进行解析,还是应该在导航之前找到一个更合适的层呢。我们还有另一个选择,那就是停止使用复杂的对象在屏幕之间导航,并使用语言类型,如餐厅ID(字符串)、项目ID(Int)…这样我们可以更好地支持深层链接,因为通常我们只有东西的ID来加载屏幕。
但是,这带来了一些问题,比如:
增加API的负载以检索对象;
有某种本地缓存,所以您可以在屏幕之间检索它,并处理缓存策略。。。
撰写
如果您希望将jetpack compose与composition根方法一起使用,我们可以轻松地创建一个支持组合布局的活动,或者在现有项目中,只创建一个片段,它将为视图返回一个可组合的函数。
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = setContent {
BuildOrderTrackerScreen()
}
@Preview
@Composable
fun BuildOrderTrackerScreen() {
Column(
modifier = Modifier
.fillMaxSize(),
Arrangement.Center,
Alignment.CenterHorizontally,
) {
Text("Hello from Compose")
}
}
其余的都是一样的,创建jetpack导航图并在其中包含这个片段,我们要导航到它只需调用导航,就像我们在其他示例中所做的那样。
如果您决定使用compose构建整个特性,我相信我们可以使用这个片段作为整个特性的主干,不需要为每个屏幕创建一个新的片段,因为compose不需要片段。