我正在参加「掘金·启航计划」
在模块化代码库中使用插件模式
模块化已经成为大规模移动开发的一个重要部分, 然而它并不简单. 有效模块化的目标之一是保持模块独立和模块图谱扁平. 使用跨模块的插件接口是实现这一目标并获得所需的好处的最有效技术之一. 让我们看看如何使用它.
问题
在模块化过程中, 我们经常面临一个常见的场景--一个模块需要依赖许多其他的功能, 并将它们组合在一起. 想象一下应用程序启动时初始化应用程序的不同部分--在这种情况下, 启动代码必须要访问许多功能.
典型的做法是让我们的功能依赖于所有相关的模块, 以便能够引用代码. 如果我们依赖很少的模块, 这可能是可以的, 然而一旦依赖性增加, 就会引起一些问题.
- 耦合性: 一个功能依赖于许多其他功能.
- 复杂度: 在一个模块中聚集的依赖性.
- 模块图: 一旦我们有了更多的这些聚合功能, 模块图就开始变得更加复杂, 我们最终会有一个难以管理的依赖关系网(为什么重要).
问题是: 图的高度增加了, 而且模块是紧密耦合的.
插件的使用情况
在很多情况下, 你需要许多模块为共同的逻辑做出贡献, 这时插件就提供了一个解决方案. 一些例子:
- 应用程序启动: 许多模块需要某种形式的初始化, 并需要钩住
应用程序.onCreate方法或生命周期中的不同点. - 登录或注销: 应用程序的不同部分需要在用户登录或注销时进行设置或清理.
- 处理推送: 通常一个模块是接收推送信息的入口点, 然而多个模块可能希望接收不同类型的信息.
- 处理Deep Link: 与推送类似, 一个模块作为一个入口点, 而许多模块需要处理不同的深度链接.
- 功能开关: 一个模块可能想声明不同的切换, 然而应用程序可能有一个单一的切换端点供所有模块使用.
- HTTP头: 不同的模块想为应用程序设置共同的头文件.
解决方案
一般的解决方案涉及一个跨模块组成的插件集合, 手动收集或通过依赖注入, 如Dagger. 我们将使用一个使用Dagger的登录插件作为例子, 然而同样的概念也可以通过其他传递依赖关系的方法实现.
插件模式--登录实例.
- 一个功能模块定义了一个公共的插件接口, 并期望收到一个实例的集合, 实现该接口.
让我们想象一个:login-api模块提供一个接口, 这个接口将在许多模块中使用.
interface LoginPlugin {
fun onLogin(user: User)
fun onLogout()
}
:login模块将插件作为一个集合进行消费.
class LoginLogic @Inject constructor(
val loginPlugins: @JvmSuppressWildcards Set<LoginPlugin>)
) {
...
fun onLogin(user: User) {
loginPlugins.forEach { plugin -> plugin.onLogin(user) }
}
fun onLogout() {
loginPlugins.forEach { plugin -> plugin.onLogout() }
}
...
- 其他模块依赖于
:login-api, 并实现他们自己的插件. 例如,:user-theme模块根据用户设置改变主题.
class UserThemePlugin @Inject constructor() : LoginPlugin {
override fun onLogin(user: User) {
setPreferredTheme(user.prefferedTheme)
}
override fun onLogout() {
setDefaultTheme()
}
}
- 最后一块是把事情连接起来, 这可以用Dagger多绑定来完成.
:user-theme模块使用@IntoSet注解来贡献给Set<LoginPlugin>. 所有有绑定的模块然后在:app模块中组成.
@Binds
@IntoSet
fun bindUserThemePlugin(plugin: UserThemePlugin) : LoginPlugin
Set<LoginPlugin>现在将包含UserThemePlugin实例, LoginLogic将开始调用相关方法 - UserThemePlugin"插入"了登录.
为什么这很强大?
:login模块与应用程序的其他部分完全解耦, 不知道任何关于主题的信息.- 我们可以根据我们包含的模块来添加或删除逻辑. 当你有多个应用程序, 想拥有只包括部分代码库的应用程序以实现快速开发或即时应用程序时, 这变得很有用.
- 我们可以为我们的测试提供不同的插件, 使我们能够验证逻辑.
- 我们可以实现完全隔离的功能, 不需要修改出模块 - 只有插件.
例子
这个概念绝对不是新的, 我们可以找到很多现有的例子.
- OkHttp中的Interceptor就像具有责任链的插件. 许多其他的库通过提供他们自己的拦截器来扩展OkHttp.
- ActivityLifecycleCallbacks或FragmentLifecycleCallbacks是一种强大的方式, 可以在不修改任何现有功能的情况下向你的应用程序添加新的逻辑, 例如被Firebase应用内消息或云消息或上下文无关的导航使用.
- PushActionCommand可以展示如何委托推送并使用
@IntoMap注解来选择不同的实现. - OnAppCreate展示了应用程序的启动逻辑, 在许多情况下与 ActivityLifecycleCallbacks相结合, 提供可插拔的功能, 如性能监控或日志记录.
- LinkLauncher展示了与Deep Link导航挂钩的不同模块.
挑战
没有什么是完美的, 插件接口也有自己的缺点, 我们需要记住这些缺点, 以防止受到它们的影响.
- 缺少必要的插件: 很容易忘记添加应用所依赖的插件, 或者错误地绑定了它. 该应用程序将编译和运行正常, 但我们会失去一些功能, 导致错误的行为.
- 解决方案是使用集成测试, 验证功能的行为是否符合预期.
- 错误处理: 简单地将插件的执行包裹在try-catch中可能是很诱人的, 但我们无法知道插件抽象中的哪些逻辑会产生错误. 插件的不完全执行会导致不一致的状态.
- 这个解决方案取决于用例, 但一般的建议是由具体的插件来处理抛出的错误并自行恢复. 如果插件自己不能处理错误, 那么我们应该只是传播异常, 而不是试图在插件集合的层面上处理它.
- 插件之间的依赖关系: 一组插件可能需要按照一定的顺序运行, 并出现隐含的依赖关系.
- 解决方案是设置一个优先级, 然后在使用
@IntoMapDagger注解进行注入后, 我们可以通过优先级对插件进行排序, 或者使用已知实现的枚举作为优先级的关键. 这将把一些实现知识传播到插件声明中, 作为一种权衡, 但当需要对插件进行精确排序时, 这可能是有效的.
- 解决方案是设置一个优先级, 然后在使用
- 低性能的插件: 如果有很多插件, 或者有一个插件在做昂贵的事情, 那么插件的执行可能会变得很慢. 由于插件接口的所有者不能控制和看到贡献插件的实现.
- 解决方案是在应用程序启动等关键部分监测每个插件的执行指标, 以确定潜在的瓶颈.
享受插件的世界
模块化和扁平化模块图谱很难, 因此需要引入某些模块化模式. 基于插件的方法就是这样一种模式, 应用它可以帮助带来模块化和高度模块化代码库的预期好处.
你在你的项目中使用插件或其他模块化方法吗?
祝你模块化愉快!