模块化 - 现实生活中的例子
通过清楚地了解多模块应用程序应该如何构建,让我们深入了解一个真实的实际例子。
我们将了解架构如何产生清晰的应用程序结构,如何处理导航,如何使用分阶段部署,如何测试所有内容,甚至查看使用此体系结构的生产应用程序。
源代码
此博客文章的所有源代码均可在Github上获得。
这不是一个功能齐全的应用程序,而是一个高度集中的示例,仅专注于演示模块化体系结构。
应用结构
三层应用程序功能库架构的主要优势之一应该是在整个应用程序和源代码中进行清晰的导航。所以让我们调查一下这个承诺是否成立。
查看项目的根文件夹,以下结构变得清晰:
.
├── app
├── features
│ ├── dashboard
│ ├── login
│ └── sharing
└── libraries
├── actions
└── ui-components
简单吧?
有一个应用程序,包括出三个特点:dashboard
,login
,和sharing
。它有几个库支持:actions
和ui-components
。所有功能和库模块分别在功能和库文件夹中分组。
但这些功能本身有什么作用?
我们来看看各自的导航图吧!首先是仪表板:
仪表板功能结构显然,这个应用程序似乎是关于照片!
但导航图看起来有点奇怪(没有目的地),这是因为这不是一个实际的功能图!导航组件不支持使用底部选项卡显示活动的图形(但是?)。
那么让我们更仔细地看看DashboardActivity:
仪表板活动有三个选项卡在这里,主要的仪表板由三个选项卡组成:照片,专辑和社交。
现在让我们看一下Login功能:
登录功能结构在一个视觉概述中,您可以看到登录屏幕由三个屏幕组成,这三个屏幕作为流程链接在一起。导航图甚至可以在顶部显示每个屏幕的中殿,以便您轻松导航到它!
同样,放大共享模块会立即解释此功能的全部内容:
共享功能结构再一次,一张图片说的超过1000行代码!
由于功能模块的定义方式,此体系结构按层次结构拆分应用程序,类似于用户浏览应用程序的方式。这与每个功能(导航图)的可视化表示相结合,有助于理解应用程序结构,屏幕之间的导航以及查找屏幕名称。
导航
由于导航似乎是人们在多模块应用中遇到的关键问题之一,让我们探讨两种不同的导航模式:
- 在一个功能内
- 功能之间
1.在一个功能中
功能中的所有导航都由导航组件处理。要执行此操作,只需NavHostFragment
在Activity
布局中添加一个并使用导航图加载它。
我们来看看LoginActivity
布局:
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/login_graph"/>
请注意fragment属性如何实例化NavHostFragment
从导航图中加载的login_graph.xml
。
此导航图不仅描述了登录功能中的三个屏幕,还定义了屏幕之间导航的操作。
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/login_graph"
app:startDestination="@id/welcomeFragment">
<fragment
android:id="@+id/welcomeFragment"
android:name="modularization.login.WelcomeFragment">
<action
android:id="@+id/action_welcomeFragment_to_loginFragment"
app:destination="@id/loginFragment" />
</fragment>
<fragment
android:id="@+id/loginFragment"
android:name="modularization.login.LoginFragment">
<action
android:id="@+id/action_loginFragment_to_avatarFragment"
app:destination="@id/avatarFragment" />
</fragment>
<fragment
android:id="@+id/avatarFragment"
android:name="modularization.login.AvatarFragment" />
</navigation>
在此图中,该WelcomeFragment
功能的入口点可以通过调用导航操作简单地导航到下一个屏幕。例如,LoginFragment
通过以下方式导航到:
findNavController()
.navigate(R.id.action_welcomeFragment_to_loginFragment)
AvatarFragment
另一方面,是触发导航到另一个功能的最后一个屏幕。
2.功能之间
回想一下,功能是全屏的(入口点是一个Activity
),不允许不同的功能相互依赖。
这意味着登录功能无法使用显式启动仪表板功能Intent
(例如,通过定义Activity
要启动的确切类):
startActivity(Intent(activity, DashboardActivity::class.java))
但是必须使用隐式Intent
代替,在那里你基本上要求一些Activity
可以处理action.opendashboard
:
startActivity(Intent("action.dashboard.open"))
哪个会启动,DashBoardActivity
因为它定义它将响应manifest.xml
该dashboard
功能中的该操作:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="modularization.dashboard">
<application android:theme="@style/AppTheme" >
<activity android:name=".DashboardActivity">
<intent-filter>
<action android:name="action.dashboard.open"/>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
请注意,理论上,可以提供多个活动来处理此操作,从而显示选择器对话框。(例如,多个应用程序可以在要求隐式操作时提供照片
MediaStore.ACTION_IMAGE_CAPTURE
)
但是,仅隐式意图并不能完全解决如何在功能之间导航:
-
String
功能清单中的操作“action.opendashboard” 重复以及想要使用该操作创建意图的每个功能 - 在
Intent
将数据传递到特征中时如何创建附加内容所需的深入知识(例如附加名称) - 另一个应用程序可以定义相同的操作,导致弹出一个选择器对话框(也可能在多个构建版本之间)
前两个可以通过引入一个actions
模块来解决,该模块负责生成格式正确的意图以启动功能Activities
:
object Actions {
fun openLoginIntent() = Intent("action.login.open")
fun openDashboardIntent() = Intent("action.dashboard.open")
fun openSharingIntent() = Intent("action.sharing.open")
}
然后可以通过以下方式简单地开始下一个功能:
activity.startActivity(Actions.openDashboardIntent())
这不仅是一种链接到下一个特征的描述性方法,而且这个原则也可用于将数据传递到新的特征类型中:
object Actions {
fun openDashboardIntent(userId: String) =
Intent(context, "action.dashboard.open")
.putExtra(EXTRA_USER, UserArgs(userId))
}
现在,登录功能不再需要知道数据如何传递到仪表板,只需调用:
activity.startActivity(Actions.openDashboardIntent("userId"))
干净吧?
最后,依赖隐式Intent
s可能会导致选择器对话框弹出。虽然不太可能与第三方应用程序发生冲突,但很容易发生不同的构建风格。
通过将意图限制为当前包,可以轻松避免这种情况:
object Actions {
fun openLoginIntent(context: Context) =
internalIntent(context, "action.login.open")
private fun internalIntent(context: Context, action: String) =
Intent(action).setPackage(context.packageName)
}
功能重写/重构
正如您可能已经知道的那样,我不相信应用程序内的重写。但是,重构本身也可能令人沮丧,并且需要很长时间才能提供结果。那么如何让你的应用程序更好?
如果您可以积极地重构甚至重写部分应用而不必担心风险发布会不是很好?
嗯,这个架构实际上可以让你轻松做到这一点!例如,您可以重写一个全新的登录模块,并在您的应用程序中同时发送旧的和新的登录模块。使用actions模块,您现在可以非常轻松地选择要启动的功能:
object Actions {
fun openLoginIntent() =
if (FeatureFlag.loginRewrite) {
Intent("action.login2.open")
} else { Intent("action.login.open") }
}
通过适当的分析和远程功能切换框架(如Firebase远程配置),您现在可以逐步推出重写。
这使您可以建立对新代码的信心,降低打破关键用户流(例如登录)的风险,从而更积极地重构/重写部分应用程序。
测试
测试该架构的策略包括三个关键部分:
- 单元测试:超快速,单独测试类
- 功能测试:隔离功能的espresso测试
- 应用测试:测试关键用户跨不同功能
首先,应为所有业务逻辑添加单元测试:对于功能模块内部的逻辑和库的所有业务逻辑。应用程序模块可能不需要任何单元测试,因为该模块中没有业务逻辑。
接下来,可以使用Espresso独立于应用程序的其余部分测试所有功能!无需从整个应用程序的开始屏幕步入您想要首先测试的屏幕。只需通过直接启动功能活动即可ActivityTestRule
。
看看生活是多么简单:
class LoginFlowTest {
@Rule
@JvmField
var mActivityTestRule = ActivityTestRule(LoginActivity::class.java)
@Test
fun loginFlowTest() {
onView(withId(R.id.button_login_start)).perform(click())
onView(withId(R.id.button_login_signin)).perform(click())
onView(withId(R.id.button_login_toapp)).check(matches(isDisplayed()))
}
}
这些功能测试速度快,方式更可靠(不会因其他功能中的错误而失败)并且不需要太多设置。
通过对所有业务逻辑单元进行测试并对功能进行单独测试,缺少的链接是根据长而典型的用户流测试“真实世界的应用程序使用情况”。
这些方案在app模块中进行了测试。以用户登录为例进行以下测试,导航到共享屏幕并执行有意义的操作:
class AppFlowTest {
@Rule
@JvmField
var mActivityTestRule = ActivityTestRule(MainActivity::class.java)
@Test
fun test_criticalUserFlow_throughoutEntireApp() {
onView(withId(modularization.login.R.id.button_login_start)).perform(click())
onView(withId(modularization.login.R.id.button_login_signin)).perform(click())
onView(withId(modularization.login.R.id.button_login_toapp)).perform(click())
onView(withId(R.id.action_albums)).perform(click())
onView(withId(R.id.action_sharing)).perform(click())
onView(withId(R.id.button_social_facebook)).perform(click())
onView(withId(R.id.recyclerView_sharing_contacts)).check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
}
}
应用程序模块测试将是最难编写和稳定的测试,但由于功能模块的分离,最大块的测试可以按功能单独运行。因此,这在减少重复性手动测试与控制开发/维护成本之间提供了非常好的平衡。
生产实例
虽然这种架构在理论上听起来不错,而且这个例子在纸上看起来不错,但它仍然不是一个完全保真的应用程序。你确定这会在生产中真正起作用吗?
好吧,我很高兴你问!因为这正是飞利浦Hue应用程序模块化的方式:
模块化的应用程序示例所有功能都是独立的,独立的,并且不相互依赖。只有一个应用程序模块。
请注意,由于飞利浦Hue的大型遗留代码库,它尚未完全迁移到此架构:目前,有8个功能模块和14个库。使用功能切换重写功能并逐步将其逐步推出。
研究在图书馆层面做出的一些决定也很有趣:
- UI组件:跨功能+主题和样式重用的组件
- 分析:大多数“水平服务图层”已作为库从应用程序中提取
- 翻译:理想情况下,每个功能都应包含自己的翻译,但对于飞利浦Hue,这需要动态地从我们的翻译机构中分离每个功能的单片翻译文件。简直不是目前最大的鱼类。
包起来
三层app-features-libraries体系结构解决了一些基本的应用程序/模块化问题:项目结构,导航,分阶段部署和可测试性。
所有源代码都可以在Github上找到。
在下一篇博文中,我们将研究如何开始模块化现有应用。一定要关注我, 不要错过它!
15217452-cd9f395df62e2fd1.png 15217452-f73d303363b14995.jpg