P2M
一个完整的 Android 组件化框架库。
以下环境经测试可正常运行:
APG 版本 | Gradle 版本 | Kotlin 版本 |
3.4.0+ | 6.1.1+ | 1.3.20~1.4.20 |
4.0.0+ | ||
4.1.0+ | 6.5+ | |
4.2.0+ | 6.7.1+ | |
7.0.3 | 7.0.2 | 1.5.10~1.6.0 |
7.1.2 | 7.2 |
阅读本文档时结合示例工程效果更佳。
P2M 是什么?
P2M 是完整的组件化工具,它的所有功能集成在P2M 插件,使用插件可以将 Project 态升级为Module 态:
P2M 插件
P2M 插件的全名为p2m-android
,它需要在settings.gradle
文件中进行声明。
在
settings.gradle
文件中声明:buildscript { repositories { google() mavenCentral() maven { url 'https://jitpack.io' } } dependencies { classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.10' // Kotlin 支持 1.3.20+ classpath 'com.android.tools.build:gradle:4.0.2' // AGP 支持 3.4.0+,gradle 支持 6.1.1+ classpath 'com.github.wangdaqi77.P2M:p2m-plugin:$lastVersion' // P2M 插件 } } // 声明插件 apply plugin: "p2m-android"
在根目录下的 build.gradle中声明依赖的插件在
settings.gradle
文件中也必须声明依赖,插件依赖参考示例中工程中根目录下的 settings.gradle和根目录下的 build.gradle。使用
p2m { }
配置组件化项目,在settings.gradle
文件中:// ... apply plugin: "p2m-android" p2m { app { // 声明一个 app 壳,至少声明一个,可声明多个 include(":projectPath") { // 声明 project 描述 projectDir = new File("your project path") // 声明 project 文件夹路径,如 project 文件夹路径与 settings.gradle 同一层级可不用配置 } dependencies { // 声明模块依赖项,可依赖多个 module("YourModuleName1") module("YourModuleName2") } } module("YourModuleName") { // 声明一个模块,驼峰命名,可声明多个 include(":projectPath") { // 声明 project 描述 projectDir = new File("your project path") // 声明 project 文件夹路径,如 project 文件夹路径与 settings.gradle 同一层级可不用配置 } dependencies { // 声明模块依赖项,可依赖多个 module("YourModuleName1") module("YourModuleName2") } groupId = "com.repo" // 组,默认值模块名。用于发布模块到仓库或者使用仓库中的模块 artifactId = "module-yourmodulename" // 发布件 id,默认值 module-${小写的 YourModuleName}。用于发布模块到仓库或者使用仓库中的模块 versionName = "0.0.1" // 版本,默认值 unspecified。用于发布模块到仓库或者使用仓库中的模块 useRemote = false // 使用远程仓库开关,默认 false。true 表示使用仓库,false 表示使用源码 runApp = false // 运行 app 开关,默认值 false,true 表示可以运行 app,false 表示作为模块,applicationId 等配置在./projectPath/build.gradle 中的 p2mRunAppBuildGradle{} } p2mMavenRepository { // 声明 maven 仓库, 默认 rootProjectPath/repo url = "your maven repository url" // 仓库地址 credentials { // 认证信息 username = "your user name" password = "your password" } } }
P2M 插件将根据配置自动对 app 壳和模块引用插件
com.android.application
、com.android.library
、kotlin-android
和kotlin-kapt
,因此在对应项目目录下的build.gradle
文件中要移除这些插件声明,参考示例工程中app 壳的 build.gradle和示例工程中Main 模块的 build.gradle。
Module 态
通过声明可以将 Project 态升级为 Module 态,一个 Module 态表示一个模块。
Project 态 | Module 态 |
---|---|
include ':projectPath' | p2m { module('YourModuleName') { include(':projectPath') } } |
模块之间可以建立依赖关系,如果有 A 模块使用 B 模块,则 A 模块依赖 B 模块:
- 对于 A 来说,B 是依赖项;
- 对于 B 来说,A 是外部模块;
- 此时 B 不能再依赖 A 了,模块之间禁止互相依赖。
一个模块包含一个Api 区和一个Source 区,Api 区会暴露给外部模块,Source 区是对外隐藏的。
每个模块还支持:
声明模块
假设有一个工程包含帐号功能和其他的功能,所有的功能都存放一个 project 中,它的 project 文件夹名称是app
,工程的文件结构大致如下:
├── app
│ ├── src
│ └── build.gradle
├── build.gradle
└── settings.gradle
此时,settings.gradle
文件中声明:
include ":app"
接下来将帐号功能剥离出来作为单独的模块,并称其为帐号模块,它的 project 文件夹命名为module-account
,此时工程的文件结构大致如下:
├── app // app 壳
│ ├── src
│ └── build.gradle
├── module-account // 帐号模块
│ ├── src
│ └── build.gradle
├── build.gradle
└── settings.gradle // P2M 配置
帐号模块在 P2M 中命名为Account
,在settings.gradle
文件中声明:
apply plugin: "p2m-android"
p2m {
app { // 声明 app 壳
include(":app")
}
module("Account") { // 声明模块
include(":module-account") // 描述 project
}
}
如果module-account
在更深的文件层级,工程的文件结构大致如下:
├── app
├── modules
│ └── module-account // 帐号模块
└── settings.gradle
此时 P2M 已经无法识别该模块的具体路径,这需要描述该模块的 project 文件夹路径,在settings.gradle
文件中:
p2m {
// ...
module("Account") {
include(":module-account") {
projectDir = new File("./modules/module-account") // 描述 project 文件夹路径
}
}
}
声明模块的依赖项
如果Main
模块使用Account
模块,因此Main
需要依赖Account
,在settings.gradle
文件中声明:
p2m {
module("Account") { // 声明模块
include(":module-account") // 声明 project
}
module("Main") {
include(":module-main")
dependencies { // 声明模块的依赖项,这里表示 Main 依赖 Account
module("Account")
}
}
}
此时Account
的 Api 区暴露给了外部模块Main
:
模块也可以使用自身的 Api 区:
app 壳也支持声明依赖,在settings.gradle
文件中:
p2m {
// ...
app { // 声明 App 壳
include(":app")
dependencies { // 声明模块的依赖项
module("Main")
module("Account")
}
}
}
Api 区
Api 区是模块的一部分,它会暴露给外部模块,主要包含:
- 启动器,暴露
Activity
、Fragment
、Service
启动器,如Account
模块对外暴露登录界面的启动器。 - 服务,暴露方法,如
Account
模块对外暴露退出登录的方法。 - 事件,暴露事件,如
Account
模块对外暴露登录状态的事件。
在编译 Api 区后访问 Api 区:
val accountApi = P2M.apiOf(Account::class.java) // Account 的 api
val launcher = accountApi.launcher // Account 的 api 中的启动器
val service = accountApi.service // Account 的 api 中的服务
val event = accountApi.event // Account 的 api 中的事件
亦可以:
val (launcher, service, event) = P2M.apiOf(Account::class.java)
启动器
启动器是Api 区的一部分,ApiLauncher
注解是为启动器而设计的,同一模块内可注解多个类,需要指定launcherName
:
- 支持注解 Activity 的子类,将为其生成启动器
val activityOf$launcherName() : ActivityLauncher
; - 支持注解 Fragment 的子类,将为其生成启动器
val fragmentOf$launcherName() : FragmentLauncher
; - 支持注解 Service 的子类,将为其生成启动器
val serviceOf$launcherName() : ServiceLauncher
。
例如,外部模块需要使用Account
模块的登录界面,首先在Account
模块声明:
@ApiLauncher(launcherName = "Login")
class LoginActivity: Activity()
编译 Api 区后,在外部模块启动调用:
P2M.apiOf(Account::class.java)
.launcher
.activityOfLogin
.launchChannel(::startActivity)
.navigation()
Activity 的启动器还支持ResultApi、拦截器。
服务
服务是Api 区的一部分,ApiService
注解是为服务而设计的,同一模块内只能注解一个类:
- 被注解类必须是
class
; - 被注解类中的所有公开成员方法将会被提取到 Api 区中。
例如,外部模块需要使用Account
模块的退出登录功能,首先在Account
模块声明:
@ApiService
class AccountService {
fun logout() { // logout()会被提取到 Api 区中
// ...
}
}
编译 Api 区后,在外部模块调用:
P2M.apiOf(Account::class.java)
.service
.logout()
事件
事件是Api 区的一部分,ApiEvent
注解是为模块的事件而设计的,同一模块内只能注解一个类:
- 被注解类必须是
interface
; - 被注解类中所有使用
ApiEventField
注解的成员字段将会转换成可感知生命周期的可观察的事件持有类型(概况一下就是类似 LiveData,但是比 LiveData 适合事件场景),用于发送事件和观察事件。ApiEventField
需要指定eventOn
和externalMutable
:eventOn = EventOn.MAIN
表示在主线程维护和接收事件,eventOn = EventOn.BACKGROUND
表示在后台线程维护和接收事件;externalMutable = false
表示外部模块不可发出事件,为了保证事件的安全性,不推荐外部模块发出事件。- 默认为
@ApiEventField(eventOn = EventOn.MAIN, externalMutable = false)
。 - 模块内部通过调用
P2M.apiOf(${moduleName}::class.java).event.mutable()
发出事件;
例如,外部模块需要登录成功后进行跳转,因此Account
模块需要暴露登录成功的事件,且此事件禁止外部模块更改,首先在Account
模块声明:
@ApiEvent
interface AccountEvent {
@ApiEventField(eventOn = EventOn.BACKGROUND, externalMutable = false)
val loginSuccess: Unit
}
编译 Api 区后,在外部模块观察此事件:
P2M.apiOf(Account::class.java)
.event
.loginSuccess
.observeForeverNoSticky(Observer { _ -> // 相比 LiveData.observeForever,不会收到粘值
// 跳转...
})
Source 区
Source 区是模块的一部分,它是对外隐藏的,主要有两个部分:
- Initialization - 模块初始化,由开发者编码完成;
- Other - 模块内部功能编码区,由开发者编码完成。
Initialization 区
Initialization 区是Source 区的一部分,它是为模块内部初始化而设计的,它至少包含一个初始化类。
在一个模块中必须声明一个初始化类,该类需使用ModuleInitializer
注解且实现ModuleInit
接口,如在示例中的Account
模块中:
@com.p2m.annotation.module.ModuleInitializer
class AccountModuleInit : com.p2m.core.module.ModuleInit {
// 评估自身阶段,意味着准备开始初始化
override fun onEvaluate(context: Context, taskRegister: com.p2m.core.module.task.TaskRegister) {
// 用户本地缓存
val userDiskCache = UserDiskCache(context)
// 注册读取登录状态的任务
taskRegister.register(LoadLoginStateTask::class.java, input = userDiskCache)
// 注册读取登录用户信息的任务
taskRegister.register(LoadLastUserTask::class.java, userDiskCache)
// 执行顺序一定为 LoadLoginStateTask.onExecute() > LoadLastUserTask.onExecute()
.dependOn(LoadLoginStateTask::class.java)
}
// 初始化完成阶段,意味着初始化完成
override fun onCompleted(context: Context, taskOutputProvider: com.p2m.core.module.task.TaskOutputProvider) {
// 获取任务输出-登录状态
val loginState = taskOutputProvider.outputOf(LoadLoginStateTask::class.java) ?: false
// Account 模块初始化完成后,外部模块才可以使用其 Api 区,因此在初始化完成时在其 Api 区一定要准备好必要的数据。
P2M.apiOf(Account::class.java).event.mutable()
.loginState
.setValue(loginState)
}
}
// 读取登录状态的任务,input:UserDiskCache output:Boolean
class LoadLoginStateTask: Task<UserDiskCache, Boolean>() {
// 处于执行阶段
override fun onExecute(context: Context, input: UserDiskCache, taskOutputProvider: TaskOutputProvider): Boolean {
// 输出查询到的登录状态
return input.readLoginState()
}
}
// 读取登录用户信息的任务,input:UserDiskCache output:LoginUserInfo
class LoadLastUserTask: Task<UserDiskCache, LoginUserInfo?>() {
// 处于执行阶段,LoadLoginStateTask 执行完才会执行这里
override fun onExecute(context: Context, input: UserDiskCache, taskOutputProvider: TaskOutputProvider): LoginUserInfo? {
val loginState = taskOutputProvider.outputOf(LoadLoginStateTask::class.java)
// 输出查询到的用户信息
return if (loginState) input.readLoginUserInfo() else null
}
}
更多代码也可以参考示例工程中Account 模块的 Initialization 区。
模块初始化按照先后顺序有三个阶段:
- 评估自身阶段,意味着准备开始初始化,关联
ModuleInit.onEvaluate()
:- 在调用
P2M.init()
时执行; - 用于在本模块注册任务和组织任务的依赖关系,这些任务是为了快速加载数据设计的,这些数据将在初始化完成阶段使用;
- 每个模块的
onEvaluate()
运行在单独的子线程。
- 在调用
- 执行阶段,意味着开始执行已注册的任务,关联
Task.onExecute()
:- 在任务的依赖项执行
onExecute
完成后且模块的依赖项执行onCompleted
完成后执行; - 用于输出加载的数据;
- 每个任务的
onExecute
运行在单独的子线程。
- 在任务的依赖项执行
- 初始化完成阶段,意味着初始化完成,关联
ModuleInit.onCompleted()
:- 在本模块的所有任务执行
onExecute
完成后且模块的依赖项执行onCompleted
完成后执行; - 获取任务输出的数据,将这些数据加载到 Api 区;
- 每个模块的
onCompleted
运行在主线程。
- 在本模块的所有任务执行
模块初始化工作有以下定式:
- 在模块内部,执行顺序一定为
onEvaluate()
>onExecute()
>onCompleted()
。 - 在模块内部,如果
A
任务依赖B
任务,执行顺序一定为B
任务的onExecute()
>A
任务的onExecute()
。 - 如果
A
模块依赖B
模块,执行顺序一定为B
模块的onCompleted()
>A
模块的onExecute()
。 - 如果
A
模块依赖B
模块且B
模块依赖C
模块,执行顺序一定为C
模块的onCompleted()
>A
模块的onExecute()
。
调用P2M.init()
开始初始化所有的模块,初始化的顺序是按照模块的依赖关系,先初始化依赖项再初始化自身。
Main
模块使用Account
模块,所以Main
模块依赖Account
模块时,模块初始化的流程图:
A
任务使用B
任务,A
任务依赖B
任务,模块内的任务运行时流程图:
Q&A
如何编译 Api 区?
编写代码时,如果Api 区注解相关的类有增删改操作,手动编译后才能使用 Api 区的内容:
P2M 支持增量编译,第二次编译速度很快。
如何对外暴露数据类?
有时外部模块要使用模块内部的一些数据类,这些类需要添加ApiUse
注解:
@ApiUse
data class UserInfo(
val userId: String,
val userName: String,
)
在编译 Api 区后,外部模块就能使用了。
Activity 启动器如何支持 ResultApi?
Activity
使用@ApiLauncher
声明启动器且传入一个结果协议activityResultContract
,可参考示例工程中Account 模块的修改用户名 Activity:@ApiLauncher(launcherName = "ModifyAccountName", activityResultContract = ModifyUserNameActivityResultContract::class) class ModifyAccountNameActivity : AppCompatActivity()
- 在编译 Api 区;
使用启动器注册 Result 启动器并启动,可参考示例工程中Main 模块的 MainActivity:
// 注册 ActivityResult 启动器 private val modifyAccountNameLauncherForActivityResult = P2M.apiOf(Account::class.java) .launcher .activityOfModifyAccountName .registerForActivityResult(this) { resultCode, output -> // 接收到结果... } // 启动界面 modifyAccountNameLauncherForActivityResult .launchChannel{ /*input*/ } .navigation()
Activity 启动器如何指定拦截器?
- 声明拦截器,使用
@ApiLaunchActivityInterceptor
并实现接口ILaunchActivityInterceptor
,可参考示例工程中Account 模块的绑定手机号拦截器:@ApiLaunchActivityInterceptor(interceptorName = "BindPhoneNum") class BindPhoneInterceptor : ILaunchActivityInterceptor { // ... override fun process(callback: LaunchActivityInterceptorCallback) { try { if (未绑定) { // 未绑定,重定向到绑定界面 callback.onRedirect( redirectChannel = account.launcher.activityOfBindPhone .launchChannel { context.startActivity(it) } ) } else { // 绑定过,继续 callback.onContinue() } } catch (e: Throwable) { // 异常,中断 callback.onInterrupt(e) } } }
- 通过编译 Account 模块的 Api 区在
Account
模块生成外部模块可访问的AccountLaunchActivityInterceptorForBindPhoneNum
,生成类名规则为${moduleName}LaunchActivityInterceptorFor${interceptorName}
; - 使用
@ApiLauncher
声明启动器时传入拦截器launchActivityInterceptor
,可参考示例工程中Mall 模块的商城 Activity:@ApiLauncher( launcherName = "Mall", launchActivityInterceptor = [ AccountLaunchActivityInterceptorForBindPhoneNum::class ] ) class MallActivity : AppCompatActivity()
- 编译 Api 区;
使用启动器启动
MallActivity
,启动时会执行拦截器功能,可参考示例工程中Main 模块的 MainActivity:P2M.apiOf(Mall::class.java) .launcher .activityOfMall .launchChannel(::startActivity) .redirectionMode(ChannelRedirectionMode.FLEXIBLY) // 设置拦截器的重定向模式 .navigation(object : NavigationCallback { override fun onStarted(channel: Channel) { /*当开始时*/ } override fun onCompleted(channel: Channel) { /*当完成时*/ } override fun onInterrupt(channel: Channel, e: Throwable) { /*当中断时*/ } override fun onRedirect(channel: Channel, redirectChannel: Channel) { /*当重定向时*/ } })
- 启动拦截器的重定向有三种模式:
ChannelRedirectionMode.CONSERVATIVE
,该模式在拦截器中重定向就会中断并触发onInterrupt
,永远不会触发onRedirect
;ChannelRedirectionMode.FLEXIBLY
,该模式在拦截器中支持重定向,重定向时会触发onRedirect
,但是如果在恢复导航(重定向目标界面在销毁时会触发恢复导航)时被同一个拦截器重定向到同一个Channel
时将会中断并触发onInterrupt
;ChannelRedirectionMode.RADICAL
,该模式在拦截器中支持重定向,重定向直到导航完成,重定向时会触发onRedirect
。
更多请参阅拦截器示例
如何单独运行模块?
打开运行开关,位于工程根目录下的
settings.gradle
:p2m { module("YourModuleName") { // ... useRemote = false runApp = true } }
声明
applicationId
等,在该模块文件夹下的build.gradle
声明,参考示例工程中Main 模块的 build.gradle:// 当`runApp=true`时才会应用这里的配置,必须放置在文件底部,以覆盖上面的配置。 p2mRunAppBuildGradle { android { defaultConfig{ applicationId "your.application.package" } sourceSets { debug { // 在这里需要自定义 Application,用于调用 P2M.init() java.srcDirs += 'src/app/java' // 在这里需要指定自定义的 Application,启动界面 manifest.srcFile 'src/app/AndroidManifest.xml' } } } }
Sync Project
如何发布模块到仓库?
配置发布件的属性
groupId
、versionName
、useRemote
和maven
仓库,位于工程根目录下的settings.gradle
:p2m { module("YourModuleName") { // ... groupId = "your.repo.groupId" // 组 versionName = "0.0.1" // 版本 useRemote = false } p2mMavenRepository { // 声明 maven 仓库 url = "your maven repository url" // 仓库地址 credentials { // 仓库的用户认证信息 username = "your user name" password = "your password" } } }
执行命令:
- linux/mac
./gradlew publish${YourModuleName} // 用于发布单个模块 ./gradlew publishAllModule // 用于发布所有的模块
- windows
.\gradlew publish${YourModuleName} // 用于发布单个模块 .\gradlew publishAllModule // 用于发布所有的模块
- linux/mac
如何依赖仓库中的模块?
配置发布件的属性
groupId
、versionName
、useRemote
和maven
仓库,位于工程根目录下的settings.gradle
:p2m { module("YourModuleName") { // ... groupId = "your.repo.groupId" // 组 versionName = "0.0.1" // 版本 useRemote = true // 使用已经发布到仓库中的模块,true 表示使用仓库,false 表示使用源码,默认 false } p2mMavenRepository { // 声明 maven 仓库 url = "your maven repository url" // 仓库地址 credentials { // 仓库的用户认证信息 username = "your user name" password = "your password" } } }
- Sync Project
混淆
-keep class * extends com.p2m.core.module.ModuleCollector { <init>(); }
-keep class * extends com.p2m.core.module.Module { <init>(); }
因本库内部使用了可观察的事件持有对象库,因此需要还增加以下配置:
-dontwarn androidx.lifecycle.LiveData
-keep class androidx.lifecycle.LiveData { *; }
-dontwarn androidx.lifecycle.LifecycleRegistry
-keep class androidx.lifecycle.LifecycleRegistry { *; }