组件化
(modularization
),也可翻译为模块化
,是在大型APP开发中经常用到的一种技术。根据功能、职责、层级等条件,对项目代码进行拆分,从而得到边界清晰、易于维护、容易复用的各个组件。为避免歧义,本文统一用组件化
指代这种技术。
单一工程架构
通常我们在新建一个简单APP项目时,采用的是单一工程架构,即项目内部只有一个组件(module
),所有页面跳转、接口调用都是直接访问的目标类、接口代码。
单一工程架构的优点如下:
- 结构简单:不存在复杂的依赖关系,代码之间可以在IDE中直接跳转,代码易于阅读
- 整包构建效率高:单个组件的构建速度高于多个组件
- 安全性高:如果接口有变化,编译期间就会发现问题,不会带到线上
但随着应用功能增加、团队规模扩大,单一工程架构也逐渐暴露出难以避免的缺点:
- 功能混杂,边界不清:所有代码都位于同一个组件中,仅仅通过包名区分,难以进行强约束
- 耦合严重:上下游调用关系缺乏约束,甚至会出现环状调用关系
- 并行开发成本高 :所有团队成员向同一个组件提交代码,带来大量合并、解决冲突的成本(比如大家都修改了
gradle
文件) - 无法进行权限管理:所有人都可以直接看到整个项目的代码
- 质量下降:某一功能出了问题,只能回滚整个APP,无法单独回滚出问题的组件
- 无法复用:在应用A里已经开发完成的功能,无法再另一个应用B里直接使用
因此,业界发展出"组件化"的概念,不仅是Android,在iOS开发中同样也要对大型项目进行组件化。
组件化理论概述
- 解决的问题:单一工程项目代码耦合严重、功能无法复用、代码逻辑职责不清、无法进行权限管控
- 遵循的原则:关注点分离,高内聚低耦合,单向依赖
- 采取的手段:厘清组件边界和层级,拆分组件,引入路由组件
组件化的优点
- 代码逻辑清晰:每个组件各司其职,业务逻辑不再分散于APP各处
- 构建效率高 :构建时不需要编译全部代码,而是所改动的,未改动的部分可以直接依赖编译好的
aar
- 稳定性好:组件存在版本的概念,对每个组件都存在一个当前最新的稳定版本,在这个基础上进行开发
- 方便复用:一人开发,多人/多APP使用
- 利于维护:解耦合,灵活添加和移除,利于扩展,避免冗长代码,便于理解
- 权限安全:不同组件通过git仓库权限类似的机制,建立访问制度
进行组件化改造前的思考
组件化最大的难点,就是对组件的拆分和依赖关系处理,通常要先问自己以下几个问题,以明确组件的定位。
- 这个组件是为哪些业务服务的
- 它依赖于哪些更底层的功能,依赖关系是直接的还是间接的
- 它以何种方式对外提供服务/通信,是用于跳转的页面,还是用于调用的接口
- 它传递的参数是基本类型还是对象
- 组件的开发权限对哪些群体开放,源码又对哪些群体开放
- 组件是否要提供给公司内部跨团队使用,是否要发布到外网
- 它的更新、发布频率如何
- 当前是否已经存在具备类似功能的组件
应用组件化实践
以一个常见的电商APP为例,在笔者的实践经验中中,根据职责、依赖关系通常可以分为4
层。
不同层次之间的依赖关系,描述如下:
- 依赖单向传递,上层依赖下层,下层不对上层产生依赖
- 上下层级之间直接依赖,在gradle配置文件中进行管理
- 同一层级各个组件之间没有直接依赖,可以通过路由组件进行间接依赖
APP宿主层
不包含业务逻辑的壳工程,对应单一工程架构里面的整个项目。主要有两项职责:1.作为整个APP的入口
,2.管理各个组件初始化和注册
。例如一个多Tab的首页结构,通常把底Tab的代码放在宿主层。
业务实现层
大部分页面(Activity、Fragment)代码都位于本层,例如推荐页、搜索结果页、用户信息页、购买结果页等等。每一个页面都有一个与之对应的路由Key
,供其它页面向它跳转。
这一层既包含Native的页面,也包含Flutter、H5等页面,跨端技术通常集中在本层以及下一层------业务公共层。
本层的代码使用范围仅限定在当前APP。
业务公共层
登录、支付、下载、安装等公司通用业务
,按钮、弹窗、列表容器等通用UI控件
,以及负责页面跳转和接口调用的路由组件
,都位于本层。
本层的各个组件可以开放给集团/公司/部门内,其它APP共享使用。
基础层
业界开源的三方框架,例如图片(Glide)、网络(OkHttp、Retrofit)、数据库(ROOM、GreenDAO)等基础能力
,微信分享、支付宝、微博分享等第三方SDK
,公司内部的埋点、Crash监测、推送等平台能力
,以及一些工具类和UI库
。
本层的各个组件可以开放给业界各个APP使用(公司内部能力除外)。
组件化实践过程中的注意点/难点
- 依赖冲突:上层两个组件分别依赖了底层同一组件的不同版本
- 资源冲突 :不同组件定义了具有相同名字的资源(
string
、color
、layout
等) - 公共聚合组件:对于不明确归属于现有哪个组件的代码,通常将其统统放入一个公共聚合组件中,会带来它的不断膨胀
- 接口版本管理与兼容性:组件的版本升级策略
注意点------依赖冲突
如果游戏详情页和游戏列表页分别依赖了不同版本的Glide
库,最终构建时生效的是两者中的较高版本,如下图。
当引入的库在不同版本之间API存在不兼容时,这会导致运行时的RuntimeException
,因此,依赖冲突是我们必须处理的问题。
首先需要检测出项目里存在哪些依赖冲突:
bash
./gradlew :app:dependencies --configuration compileCompileClasspath
然后把所有模块依赖的版本强制统一,这里有两种方式,个人推荐第一种。
方式一:在APP宿主层声明组件版本变量,其它组件直接引用该变量
在项目目录 下的build.gradle
中声明组件及版本,其它组件在各自的 build.gradle
里直接引用变量。也可以单独拎出一个文件env.gradle
,用于专门管理版本。
项目env.gradle
gradle
ext {
androidx = [
ktx : 'androidx.core:core-ktx:1.7.0',
appcompat : androidx.appcompat:appcompat:1.3.2'
]
}
组件A、B使用已声明的同一个版本三方库。
组件build.gradle
gradle
dependencies {
implementation androidx.ktx
implementation androidx.appcompat
}
方式二:强制设置各个组件依赖的版本
项目build.gradle
kotlin
// 定义你需要的版本号
def lifecycle_version = "2.2.0"
def fragment_version = "1.2.0"
def exifinterface_version = "1.2.0"
def transition_version = "1.2.0"
configurations.all{
resolutionStrategy.force 'androidx.annotation:annotation:1.1.0'
resolutionStrategy.force "androidx.lifecycle:lifecycle-common:$lifecycle_version"
resolutionStrategy.force "androidx.fragment:fragment:$fragment_version"
resolutionStrategy.force "androidx.exifinterface:exifinterface:$exifinterface_version"
resolutionStrategy.force "androidx.transition:transition:$transition_version"
}
注意点------资源冲突
这里的资源,既包括res
目录下的各个string
、color
等变量,也包含layout
中的xxx.xml
文件。当资源发生冲突时,系统默认采用以下方式处理:
- 上下两个层级的组件,定义了同名的资源------最终生效的是
上层资源
- 同一层级的组件,定义了同名的资源------最终生效的是它们之中
先
被上层引用的那个
要避免在不同模块中定义同名的资源,通常采用在资源名前增加模块前缀来区分,例如对于埋点组件,其资源前增加stats_
前缀,对于图片组件,增加img_
前缀。仅仅靠人为是不够的,官方提供了resourcePrefix
检测手段,在组件的build.gradle
文件中增加如下配置来强制IDE进行资源检查,以library_
前缀为例:
gradle
resourcePrefix 'library_'
如果没有按照规范命名,会出现如下错误:
注意点------公共聚合组件
在版本开发时,会遇到这种场景:需要增加一项底层功能,但不属于已有的任何组件,并且其规模也不足以单独成为一个组件。通常采取的做法是,建立一个common-module
统一收纳此类代码。但随着版本不断迭代,common-module
会不断膨胀,同时也容易导致一种惰性,后面对于新增功能不假思索就往这里面丢。
因此,需要定期对common-module
进行重构,拆分出独立的组件,防止技术债务越积越多。
注意点------接口版本管理与兼容性
- 对外API需保证向后兼容,使用
添加API
的方式扩展现有能力,避免对原有API进行break change
改动或移除 - 使用对象封装传递参数和回调参数,避免对
原有API
进行修改 - 使用
大小版本
来区分,小版本升级需要保证接口向后兼容,大版本升级表示不兼容。如非必要,勿进行大版本升级,这会给依赖方带来极大的更新成本
开源组件之:设计一个路由框架
维基百科:路由(
routing
)是通过互联的网络把信息从源地址传输到目的地址的活动。
在实践中,我们通常把组件之间寻址、唤醒、调用接口等职责交给路由框架处理。
组件的实现过程中,要进行代码隔离,同层级之间的组件是无法直接访问的。在业务中会遇到组件之间跳转、互相调用的需求,此时就要借助于路由框架来实现。常见的开源框架有ARouter
、TheRouter
、WMRouter
等。它们的核心原理是相似的,本文就是基于同一原理实现一个简易的路由框架。
核心概念:查找表
路由框架的核心是查找表HashMap
,可以通过字符串查找到对应的类。
kotlin
object SimpleRouter {
private val routes = HashMap<String, Class<*>>()
fun register(path: String, clazz: String) = routes.apply {
put(path, Class.forName(clazz))
}
fun navigation(path: String) = routes[path]
有了上面的路由表,就可以在字符串
-页面类
之间建立映射关系:
kotlin
SimpleRouter.register("/account/sign_in", "pro.lilei.user.SignInActivity")
当我们想要跳转到登录页时,可以通过字符串/account/sign_in
进行寻址并跳转:
kotlin
SimpleRouter.navigation("/account/sign_in")?.let {
startActivity(Intent(ctx, it))
}
在此基础上,用注解来实现自动注册:
kotlin
@Route(path = "/account/sign_in")
class SignInActivity : AppCompatActivity() {
...
}
通过APT解析注解,完成页面向路由框架的注册过程。
跳转Activity
路由框架最主要的功能是进行页面跳转,在路由表中查询到目标class
后,构造Intent
对象并进行跳转。对于要传递参数的情况,可以把参数封装成一个PostCard
类,借助bundle
参数可以传递任意实现了Parcelable
接口的对象,它内部也是通过Key
-Value
维护的。
kotlin
data class PostCard(
val path: String,
val bundle: Bundle
)
可以把创建Intent
、传递参数、获取返回值的操作都封装在SimpleRouter.navigation()
函数里。
kotlin
object SimpleRouter {
fun navigation(ctx: Context, postCard: PostCard, reqCode: Int = -1) {
val dest = routes[postCard.path] ?: throw IllegalStateException("Not route matches!")
val intent = Intent(ctx, dest).putExtras(postCard.bundle)
when {
requestCode >= 0 -> if (context is Activity) context.startActivityForResult(intent, reqCode)
else -> ctx.startActivity(intent)
}
}
}
之后就可以创建PostCard
对象实现传参数跳转:
kotlin
val postCard = PostCard("/user/login", Bundle().apply {put("email", email)})
SimpleRouter.navigation(ctx, postCard)
也可以把生成PostCard
的过程封装进SimpleRouter.kt
类中,不详述。
创建Fragment
Fragment的使用场景一般是,在Activity中我们创建一个Fragment对象并通过FragmentManager将其加入当前页面。实现思路依然是传入String,查找对照表,得到类对象后通过反射进行实例化。
对此我们要对查找表进行升级,新建一个RouteMeta
类封装跳转目标。
kotlin
data class RouteMeta(
val dest: Class<*>,
val type: RouteType,
)
enum class RouteType {
ACTIVITY, FRAGMENT, UNKNOWN
}
路由表的类型也相应变为HashMap<String, RouteMeta>
。
kotlin
object SimpleRouter {
private val routes = HashMap<String, RouteMeta>()
fun register(path: String, clazz: String) = routes.apply {
val clazzObj = Class.forName(clazz)
val type = when {
Activity::class.java.isAssignableFrom(clazzObj) -> RouteType.ACTIVITY
Fragment::class.java.isAssignableFrom(clazzObj) -> RouteType.FRAGMENT
else -> RouteType.UNKNOWN
}
put(path, RouteMeta(clazzObj, type))
}
}
在此基础上,就可以在跳转时判断目的地。
kotlin
object SimpleRouter {
// ...
private lateinit var application: Application
fun init(application: Application) {
this.application = application
}
fun navigation(ctx: Context? , postcard: Postcard, requestCode: Int = -1): Any? {
val context = ctx ?: application
val routeMeta = routes[postcard.path]
?: throw IllegalStateException("There is no route match the path [${postcard.path}]")
val dest = routeMeta.dest
return when (routeMeta.type) {
RouteType.ACTIVITY -> {
val intent = Intent(context, dest).putExtras(postcard.bundle)
if (context !is Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
if (requestCode >= 0) {
if (context is Activity) {
context.startActivityForResult(intent, requestCode)
}
} else {
context.startActivity(intent)
}
null
}
RouteType.FRAGMENT -> {
val fragmentMeta: Class<*> = dest
try {
val instance = fragmentMeta.getConstructor().newInstance()
if (instance is Fragment) instance.arguments = postcard.bundle
instance
} catch (e: Exception) {
null
}
}
else -> null
}
}
}
在业务模块的Fragment类进行注册
kotlin
// 也可通过注解
SimpleRouter.register("/account/me", "pro.lilei.user.MeFragment")
并在与它无依赖关系的模块里进行跳转
kotlin
val frag = SimpleRouter.build("/account/me").navigation() as? Fragment
调用接口
组件之间互相调用接口并通信,是另一个组件化的重要场景。首先我们需要界定哪些类用于提供模块间通信,对于这样的类,声明一个IProvider
接口,实现该接口的类可进行跨组件调用。
kotlin
interface IProvider {
fun init(ctx: Context)
}
同时,在RouteType中增加接口调用的枚举。
kotlin
enum class RouteType {
ACTIVITY,
FRAGMENT,
PROVIDER,
UNKNOWN
}
当传入PROVIDER
时,就用类似处理FRAGMENT
的方式,进行实例化对象。与Fragment不同的是,对于创建出的对象,把它放入缓存以便后续使用。
这里声明Warehouse
的仓库类,管理路由表以及缓存。
kotlin
object Warehouse {
val routes = HashMap<String, RouteMeta>()
val providers = HashMap<Class<*>, IProvider>()
}
新建LogisticCenter
用于进行服务注册。
kotlin
object LogisticsCenter {
private lateinit var ctx: Application
fun init (application: Application) {
ctx = application
}
fun register(path: String, clazzName: String) {
val clazz = Class.forName(clazzName)
val type = when {
Activity::class.java.isAssignableFrom(clazz) -> RouteType.ACTIVITY
Fragment::class.java.isAssignableFrom(clazz) -> RouteType.FRAGMENT
IProvider::class.java.isAssignableFrom(clazz) -> RouteType.PROVIDER
else -> RouteType.UNKNOWN
}
Warehouse.routes[path] = RouteMeta(clazz, type)
}
}
在路由寻址器SimpleRouter.navigation()
函数中,根据RouteType
不同,执行三选一逻辑。
kotlin
object SimpleRouter {
fun register(path: String, clazzName: String) {
LogisticsCenter.register(path, clazzName)
}
fun navigation(ctx: Context, postcard: PostCard, resquestCode: Int = -1): Any? {
return when (postcard.type) {
RouteType.ACTIVITY -> // 包装Intent进行跳转,支持onResult
RouteType.FRAGMENT -> // 创建Fragment对象并返回
RouteType.PROVIDER -> // 先在缓存查找,找到则返回,否则创建对象刷入缓存然后返回
else -> null
}
}
以下是实践IProvider
跨组件接口调用能力。首先声明一个支持跨组件调用的接口,就叫账号服务。
kotlin
interface AccountService : IProvider {
val isSingIn: Boolean
fun logout()
}
然后在账号组件内实现这个接口:
kotlin
class AccountServiceProvider : AccountService {
override val isSignIn: Boolean
get() = // ... 业务逻辑
override fun logout() {
// ... 业务逻辑
}
override fun init(ctx: Context) {
// ... 业务逻辑
}
}
并在路由表注册该服务,这样其它同级组件可以通过/account/service
来获取AccountService
对象。
kotlin
// 可优化成注解初始化
SipleRouter.register("/account/service", "pro.lilei.account.AccountServiceProvider")
例如,在商户详情页的组件中,可以这样判断当前用户是否登录:
kotlin
val accountService = SimpleRouter.build("/account/service").navigation() as? AccountService
if (accountService?.isSignIn == true) {
// ...
} else {
// ...
}
路由原理小结
我们用一张层次图来说明"注册-调用"AccountService
的过程。
api-module
用于管理所有对外开放的IProvider
接口,代码会经常变更。而router-module
则是路由框架,代码稳定。
下图总结路由组件的页面跳转、方法调用流程:
开源组件之:Jetpack Startup启动注册组件
Startup
是Jetpack
开发包里的Google官方组件,内部通过ContentProvider
实现。支持按顺序初始化
,可以自定义初始化的依赖关系
。
使用方式
1.在项目build.gradle
文件中增加依赖:
gradle
implementation "androidx.startup:startup-runtime:1.1.1"
2.构建组件及依赖关系
需要通过Startup
框架进行初始化的组件,需要实现Initializer
接口。
kotlin
class InitializerA : Initializer<A> {
// 完成组件初始化,返回初始化后的结果
override fun create(context: Context): A {
return A.init(context)
}
// 依赖的组件
override fun dependencies(): List<Class<out Initializer<*>>> {
return listOf(InitializerB::class.java)
}
}
class InitializerB : Initializer<B> {
override fun create(context: Context): B {
return B.init(context)
}
override fun dependencies(): List<Class<out Initializer<*>>> {
return null
}
}
该接口有2个函数需要实现:
create()
: 调用组件初始化函数,并返回初始化结果dependencies()
: 返回它所依赖的组件,Startup
框架会在这些依赖完成初始化后 ,对当前组件进行初始化。它的依赖列表也应当实现Initializer
接口
在上例代码中,会先初始化B,再初始化A。
3-1.在Manifest文件中注册
xml
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge"> // 注意这里用merge
<meta-data
android:name="leavesc.lifecyclecore.core.InitializerA"
android:value="androidx.startup" />
</provider>
有几点需要注意:
- 此处只需要写
InitializerA
即可,因为InitializerB
是A的依赖项,在初始化A之前,会自动初始化B - 必须是
merge
,因为Manifest
里面已经有声明过InitializationProvider
了(在androidx库里),此处声明的需要与之前声明的合并
3-2.在Java代码中手动初始化
如果Initializer
不需要一启动就初始化,则无需在AndroidManifest
中声明,直接在用到的地方懒加载即可。
java
val result = AppInitializer.getInstance(this).initializeComponent(InitializerA::class.java)
有几点注意:
Startup
内部会缓存Initializer
初始化的结果,所以多次调用不会导致多次初始化- 可以用这个方法,获取自动初始化的结果
原理浅析
在《Android从点击应用图标到首帧展示的过程》一文中,我们分析过当APP进程启动时,ActivityThread会执行installContentProviders()
,对AndroidManifest.xml
中声明的各个ContentProvider
进行初始化,执行其onCreate()
函数。对于Startup
框架,在这个过程中它做了这些事:
java
public final class InitializationProvider extends ContentProvider {
@Override
public boolean onCreate() {
Context context = getContext();
if (context != null) {
// 主要逻辑,调用AppInitializer完成
AppInitializer.getInstance(context).discoverAndInitialize();
} else {
throw new StartupException("Context cannot be null");
}
return true;
}
}
java
void discoverAndInitialize() {
ComponentName provider = new ComponentName(mContext.getPackageName(), InitializationProvider.class.getName());
ProviderInfo providerInfo = mContext.getPackageManager().getProviderInfo(provider, GET_META_DATA);
// 获取InitializationProvider下配置的meta-data参数对
Bundle metadata = providerInfo.metaData;
String startup = mContext.getString(R.string.androidx_startup);
if (metadata != null) {
Set<Class<?>> initializing = new HashSet<>();
Set<String> keys = metadata.keySet();
for (String key : keys) {
String value = metadata.getString(key, null);
// 如果value等于androidx.startup
if (startup.equals(value)) {
Class<?> clazz = Class.forName(key);
// 如果key实现了Initializer接口
if (Initializer.class.isAssignableFrom(clazz)) {
Class<? extends Initializer<?>> component = (Class<? extends Initializer<?>>) clazz;
// 加入mDiscovered set
mDiscovered.add(component);
// 初始化这个component
doInitialize(component, initializing);
}
}
}
}
}
discoverAndInitialize
方法的主要作用是:
- 通过PMS检索
Manifest
文件,找出InitializationProvider
下的所有meta-data
参数对。 - 如果
value
等于androidx.startup
,且key
的类实现了Initializer
接口,则加入mDiscovered
set,调用doInitialize
初始化这个component
java
<T> T doInitialize(
@NonNull Class<? extends Initializer<?>> component,
@NonNull Set<Class<?>> initializing) {
// sLock是一个静态对象
synchronized (sLock) {
if (initializing.contains(component)) {
// 如果组件之间的依赖关系,出现了环,则直接抛出异常
String message = String.format("Cannot initialize %s. Cycle detected.", component.getName());
throw new IllegalStateException(message);
}
Object result;
// 判断当前组件是否已被初始化
if (!mInitialized.containsKey(component)) {
// 加入initializing set
initializing.add(component);
try {
// 初始化component对象
Object instance = component.getDeclaredConstructor().newInstance();
Initializer<?> initializer = (Initializer<?>) instance;
List<Class<? extends Initializer<?>>> dependencies =initializer.dependencies();
if (!dependencies.isEmpty()) {
// 初始化所有依赖项
for (Class<? extends Initializer<?>> clazz : dependencies) {
if (!mInitialized.containsKey(clazz)) {
doInitialize(clazz, initializing);
}
}
}
// 初始化当前component
result = initializer.create(mContext);
// 从initializing set中移除component
initializing.remove(component);
// 放入mInitialized map中
mInitialized.put(component, result);
} catch (Throwable throwable) {
throw new StartupException(throwable);
}
} else {
result = mInitialized.get(component);
}
return (T) result;
}
}
这个方法内部采用递归
实现,其主要流程如下:
- 在初始化之前,先判断当前的依赖关系图中是否有
环
,如果有环,直接抛出异常。检验环的方式是,每当处理到一个组件时,将其加入Set
,如果加入时发现它已经存在,则发现环 - 判断当前组件是否已初始化,如果已初始化,直接返回
result
,防止重复初始化 - 初始化组件对象,获取它的
dependencies
,先初始化dependencies
- 所有
dependencies
初始化完成后,调用create
方法初始化组件 - 将初始化结果放入
mInitialized
map
中
使用痛点
Startup
实现还是相对简单的,有以下几个痛点:
- 不能无缝接入,需要修改第三方库的源码,或者等待第三方库接入,才能生效
- 组件
create
方法都是在主线程调用,难以定制
可以自定义实现来规避以上痛点,不在本文详述。
关于组件化的思考
不存在完美的组件化方案,公司和业务的组织架构、人员组成、技术栈都处于时刻的变动中,APP架构也需要随着这个过程进行动态的调整与重构,避免积累大量技术债务。
不同职责的开发人员,关注点也应当有所区别。基础组件开发者关注更小粒度的性能,上层业务开发者则需要关注整体端对端链路的体验。