P2M

Project Url: wangdaqi77/P2M
Introduction: 一个简单、高效、安全的 Android 组件化框架。
More: Author   ReportBugs   
Tags:

Hex.pm

一个完整的 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文件中进行声明。

  1. 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

  2. 使用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.applicationcom.android.librarykotlin-androidkotlin-kapt,因此在对应项目目录下的build.gradle文件中要移除这些插件声明,参考示例工程中app 壳的 build.gradle和示例工程中Main 模块的 build.gradle

Module 态

通过声明可以将 Project 态升级为 Module 态,一个 Module 态表示一个模块。

Project 态 Module 态
include ':projectPath' p2m {
module('YourModuleName') {
include(':projectPath')
}
}
image image

模块之间可以建立依赖关系,如果有 A 模块使用 B 模块,则 A 模块依赖 B 模块:

  • 对于 A 来说,B 是依赖项
  • 对于 B 来说,A 是外部模块
  • 此时 B 不能再依赖 A 了,模块之间禁止互相依赖

一个模块包含一个Api 区和一个Source 区,Api 区会暴露给外部模块,Source 区是对外隐藏的。

image

每个模块还支持:

声明模块

假设有一个工程包含帐号功能和其他的功能,所有的功能都存放一个 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

image

模块也可以使用自身的 Api 区:

image

app 壳也支持声明依赖,在settings.gradle文件中:

p2m {
    // ...

    app {                                       // 声明 App 壳
        include(":app")
        dependencies {                          // 声明模块的依赖项
            module("Main")
            module("Account")
        }
    }
}

Api 区

Api 区是模块的一部分,它会暴露给外部模块,主要包含:

  • 启动器,暴露ActivityFragmentService启动器,如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 区的一部分。

启动器

启动器是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需要指定eventOnexternalMutable
    • 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 区

模块初始化按照先后顺序有三个阶段:

  1. 评估自身阶段,意味着准备开始初始化,关联ModuleInit.onEvaluate()
    • 在调用P2M.init()时执行;
    • 用于在本模块注册任务和组织任务的依赖关系,这些任务是为了快速加载数据设计的,这些数据将在初始化完成阶段使用;
    • 每个模块的onEvaluate()运行在单独的子线程。
  2. 执行阶段,意味着开始执行已注册的任务,关联Task.onExecute()
    • 在任务的依赖项执行onExecute完成后且模块的依赖项执行onCompleted完成后执行;
    • 用于输出加载的数据;
    • 每个任务的onExecute运行在单独的子线程。
  3. 初始化完成阶段,意味着初始化完成,关联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模块时,模块初始化的流程图:

image

A任务使用B任务,A任务依赖B任务,模块内的任务运行时流程图:

image

Q&A

如何编译 Api 区?

编写代码时,如果Api 区注解相关的类有增删改操作,手动编译后才能使用 Api 区的内容:

  • 编译单个模块:点击 Android Studio 中的Build > Make Module;
  • 编译所有模块:点击 Android Studio 中的Build > Make Project。

P2M 支持增量编译,第二次编译速度很快。

如何对外暴露数据类?

有时外部模块要使用模块内部的一些数据类,这些类需要添加ApiUse注解:

@ApiUse
data class UserInfo(
    val userId: String,
    val userName: String,
)

编译 Api 区后,外部模块就能使用了。

Activity 启动器如何支持 ResultApi?

  1. Activity使用@ApiLauncher声明启动器且传入一个结果协议activityResultContract,可参考示例工程中Account 模块的修改用户名 Activity:
    @ApiLauncher(launcherName = "ModifyAccountName", activityResultContract = ModifyUserNameActivityResultContract::class)
    class ModifyAccountNameActivity : AppCompatActivity()
    
  2. 编译 Api 区
  3. 使用启动器注册 Result 启动器并启动,可参考示例工程中Main 模块的 MainActivity

    // 注册 ActivityResult 启动器
    private val modifyAccountNameLauncherForActivityResult =
        P2M.apiOf(Account::class.java)
            .launcher
            .activityOfModifyAccountName
            .registerForActivityResult(this) { resultCode, output ->
                // 接收到结果...
            }
    
    // 启动界面
    modifyAccountNameLauncherForActivityResult
        .launchChannel{ /*input*/ }
        .navigation()
    

Activity 启动器如何指定拦截器?

  1. 声明拦截器,使用@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)
            }
        }
    }
    
  2. 通过编译 Account 模块的 Api 区Account模块生成外部模块可访问的AccountLaunchActivityInterceptorForBindPhoneNum,生成类名规则为${moduleName}LaunchActivityInterceptorFor${interceptorName}
  3. 使用@ApiLauncher声明启动器时传入拦截器launchActivityInterceptor,可参考示例工程中Mall 模块的商城 Activity:
    @ApiLauncher(
        launcherName = "Mall",
        launchActivityInterceptor = [
            AccountLaunchActivityInterceptorForBindPhoneNum::class
        ]
    )
    class MallActivity : AppCompatActivity()
    
  4. 编译 Api 区
  5. 使用启动器启动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) { /*当重定向时*/ }
    })
    
  6. 启动拦截器的重定向有三种模式:
    • ChannelRedirectionMode.CONSERVATIVE,该模式在拦截器中重定向就会中断并触发onInterrupt,永远不会触发onRedirect
    • ChannelRedirectionMode.FLEXIBLY,该模式在拦截器中支持重定向,重定向时会触发onRedirect,但是如果在恢复导航(重定向目标界面在销毁时会触发恢复导航)时被同一个拦截器重定向到同一个Channel时将会中断并触发onInterrupt
    • ChannelRedirectionMode.RADICAL,该模式在拦截器中支持重定向,重定向直到导航完成,重定向时会触发onRedirect

更多请参阅拦截器示例

如何单独运行模块?

  1. 打开运行开关,位于工程根目录下的settings.gradle

    p2m {
        module("YourModuleName") {
            // ...
            useRemote = false
            runApp = true
        }
    }
    
  2. 声明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'
                }
            }
        }
    }
    
  3. Sync Project

如何发布模块到仓库?

  1. 配置发布件的属性groupIdversionNameuseRemotemaven仓库,位于工程根目录下的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"
            }
        }
    }
    
  2. 执行命令:

    • linux/mac
      ./gradlew publish${YourModuleName}        // 用于发布单个模块
      ./gradlew publishAllModule                // 用于发布所有的模块
      
    • windows
      .\gradlew publish${YourModuleName}        // 用于发布单个模块
      .\gradlew publishAllModule                // 用于发布所有的模块
      

如何依赖仓库中的模块?

  1. 配置发布件的属性groupIdversionNameuseRemotemaven仓库,位于工程根目录下的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"
            }
        }
    }
    
  2. 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 { *; }
Apps
About Me
GitHub: Trinea
Facebook: Dev Tools