作者:leobert-lan
前言
时至今日,Android项目中的组件化大家都已经非常熟悉了,但在各个细节方面还是有一些门门道道的内容,如果没有趁手的中间件支持,推行组件化的过程中还是会遇到阻碍。继2017年逻辑思维得到项目团队开源其组件化方案思路和核心gradle构建插件后,笔者一直投身其中并致力于插件的功能升级和中间件生态完善。
其实在2018年就有部分同学提出了“增加按序初始化组件”的需求,限于个人精力以及需求的优先级,当时被搁置了,这个需求一拖也就拖了两年了。这次终于抽出时间完成了这个中间件,特此写了一篇博客,介绍其中的一些知识点和这个中间件Maat。
方便阅读,导图附上。
注:即使你没有使用上面提到的得到组件化方案(DDComponentForAndroid)或者我们后续维护的JIMU,也不影响本篇文章的理解。
问题的根源
这里我们再花一点时间来了解下问题的根源:组件化的基础是模块化,在做到模块化的同时,模块与模块在编写、编译期间也就达成了完全代码隔离,组件间的交互依靠 底层接口+服务发现(或者服务注册) 或者更加抽象为 “基于协议、隐藏实现”。这带来了编写、编译期间激增的代码耦合。
我知道这样说实在是太晦涩了,一点也不接地气,我们以一个简单的例子来配合说明。
interface IComponent {
fun onCreate()
fun onDestroy()
}
我们定义这样的接口来代表一个组件模型。案例设定为:一个宿主H+两个互无关联的组件A、B
那么有:
class A : IComponent {
override fun onCreate() {
// A初始化逻辑
}
override fun onDestroy() {
}
}
class B : IComponent {
override fun onCreate() {
// B初始化逻辑
}
override fun onDestroy() {
}
}
另有
class H :Application {
override fun onCreate() {
A().onCreate()
B().onCreate()
}
}
我们以最简单的代码演示组件的加载和初始化环节。这里隐藏了一个问题:如果是手工编码,那么是存在代码边界的,编写、编译期间H无法直接访问A和B,我们只能通过反射去实现(否则编译不通过)。当然,也可以通过字节码技术实现
如果我们要让B先于A初始化,那么就调整其顺序,这对于手工编码方式而言,可能就是将编码变为:
XXX.loadComponent("Bpackage.B") //"Bpackage.B"为B的类路径
XXX.loadComponent("Apackage.A")
而利用字节码技术的,则需要增加排序功能或者读取全量配置功能。
案例2: 此时A组件依赖于B,必须等B组件初始化成功并得到结果后才能初始化。
思路1:先加载和初始化B,利用代码同步的特性,再初始化A
思路2:先加载和初始化B,修改组件模型,增加callback作为入参,异步初始化A
思路1存在很大的限制,比如其初始化需要参与网络通信或者数据库操作;思路2对于手工编码来说,会产生回调地狱,而对于字节码技术实现而言,就是一个噩梦
而且,JIMU已经投入使用挺长一段时间了,如果不是毫无选择,对于基类或者接口做无法版本兼容的操作都不应该被采纳
思路2的改进版:增加上下文,使得回调嵌套扁平化。
既然我们决定增加一个上下文,那么将初始化的管理工作进行封装就成了顺理成章的事情
为什么不使用官方StartUp而选择造轮子
在思考这个问题时,我们必须要清楚Startup的设计意图
可在应用启动时简单、高效地初始化组件。 借助 App Startup 库,可在应用启动时简单、高效地初始化组件。库开发者和应用开发者都可以使用 App Startup 来简化启动序列并显式设置初始化顺序。
我们知道,在Startup发布之前,各大SDK采用的初始化方式一般为两种:
- 显式API调用,需要Application实例
- 内部提供一个ContentProvider,并在其中获取Application实例。因为其特性,会在应用启动时被自动加载,而不再需要使用者显式的API调用
一般为了方便开发者,在manifest文件中写入SDK参数配置并利用Context(为了不造成泄漏,使用Application是最好的选择)读取配置的做法更受推荐。所以第二种方式的使用越来越多。
这就带来了一个问题:引入越多的SDK就会引入更多的ContentProvider,他们并不会随着初始化工作完成而消亡,而且加重了应用启动时AMS的负担。
业内存在一个著名的编程范式:约定优于配置,既然使用ContentProvider作为初始化入口已经被广泛接受,那么Google作为生态维护者提供一个官方库,使用统一的初始化入口,使用者只需要按照约定暴露初始化逻辑,并且提供了前置依赖使得任务可排序的功能。
到这里我们就可以明白这样几件事情:
- StartUp中使用异步和其排序加载之间存在“矛盾”
- StartUp不提供依赖有向无环图校验
因为StartUp更主要的是面向SDK,提供统一标准。SDK库之间出现“存在性上的先后关系”的场景本身就非常小,如果有“依赖”,SDK生产者在库内部都处理好了,一般也不会出现代码边界。
所以,Maat并不是一个和StartUp一较长短的功能库,而是为了解决特定问题而编写的功能库。这些问题又恰恰是StartUp所不涉及的
设计思路
相信大家对“同步”和“异步”都有比较深的理解,我们先提出三个参与初始化的角色:
- 任务: 初始化工作的最小单元,清晰的知道自己的所依赖的任务,只有依赖的任务都执行完毕后才能执行,我们以Task=Name[dependency1,dependency2,...]来表示任务,例如 B[] ==> 无前置依赖的任务B, A[B] ==> 任务A、依赖任务B
- 任务集:所有任务的集合,可分析任务的所有前置依赖并判断是否存在循环依赖,对任务进行排序,记为 TaskGroup={Task1,Task2,...}
- 任务调度器:从任务集中取出任务派发执行的调度器
回顾我们最开始给出的例子,组件之前有存在性先后关系,必须要让依赖的组件完成初始化后才能开始加载。 那么任务调度器的工作方式是“同步”的,在“被依赖的任务”执行完毕前,依赖他的任务都必须阻塞等待。
但是思考一个问题:两个互相独立的任务,必须阻塞等待吗?答案显然,不是必须的。
这里举一些例子:
有任务集:{A[],B[],C[A,B]},A和B是无依赖的,C依赖任务A和B,
那么任务调度器可以按照A、B、C的顺序进行调度,
也可以按照B、A、C的顺序进行调度每个任务执行中,任务调度器都阻塞等待,
也可以让AB两个任务并发(需要分配到不同线程)阻塞等待AB均完成后调度C。在第一个版本设计中,我还没有采用这个方案,目前让库保持足够轻量。当存在多组初始化路径时,其复杂程度远大于本处的例子
有向无环图(DAG)
接下来我们适当花一些篇幅来讨论DAG。在我们上面提到的任务集这一角色中,我们使用了DAG来处理拓扑排序和依赖无环校验。
我们将任务看做是图中的顶点,任务的依赖关系看做是边,方向和依赖方向相反,即A[B]意味着有从B到A的边。将所有的任务合并起来后我们将得到一份有向图,显然,成环的依赖是不被允许的。
为了更好的理解,我们人为的添加一个虚拟的顶点Start,作为初始化任务集的第一个任务,将所有无依赖的任务人为添加一个前置依赖:Start。
一个合法的任务集,必然没有成环的依赖,所以一定不是强连通图,在我们添加了虚拟顶点start后,其基图一定是连通图,故而合法的任务集(包含虚拟Start节点)是一个弱连通图
环校验
我们采用DFS方式递归遍历,受益于我们制定的虚拟顶点Start,我们可以直接从这个顶点开始。
定义深度集合 deepPathList,选定起始顶点S, 定义回环顶点列表 loopbackList, 定义路径列表 pathList
直接上代码 getEdgeContainsPoint(startPoint, Type.X) 代表取出所有以startPoint为起始点的边
fun recursive(startPoint: T, pathList: MutableList<T>) {
if (pathList.contains(startPoint)) {
loopbackList.add("${debugPathInfo(pathList)}->${startPoint.let(nameOf)}")
return
}
pathList.add(startPoint)
val edgesFromStartPoint = getEdgeContainsPoint(startPoint, Type.X)
if (edgesFromStartPoint.isEmpty()) {
val descList: ArrayList<T> = ArrayList(pathList.size)
pathList.forEach { path -> descList.add(path) }
deepPathList.add(descList)
}
edgesFromStartPoint.forEach {
recursive(it.to, pathList)
}
pathList.remove(startPoint)
}
如果loopbackList不为空,则代表存在回环,回环的信息就存放在loopbackList中
契合需求的排序方式
上面我们已经提到了深度优先遍历(DFS),但是这种方式作出的拓扑排序不适合我们的需求,他适合寻找最优或者最差路径。而广度优先遍历(BFS)才契合需求。
直接给出代码:
private fun DAG<JOB>.bfs(): JobChunk {
val zeroDeque = ArrayDeque<JOB>()
val inDegrees = HashMap<JOB, Int>().apply {
putAll(this@bfs.inDegreeCache)
}
inDegrees.forEach { (v, d) ->
if (d == 0)
zeroDeque.offer(v)
}
val head = JobChunk.head()
var currentChunk = head
val tmpDeque = ArrayDeque<JOB>()
while (zeroDeque.isNotEmpty() || tmpDeque.isNotEmpty()) {
if (zeroDeque.isEmpty()) {
currentChunk = currentChunk.append()
zeroDeque.addAll(tmpDeque)
tmpDeque.clear()
}
zeroDeque.poll()?.let { vertex ->
currentChunk.addJob(vertex)
this.getEdgeContainsPoint(vertex, Type.X).forEach { edge ->
inDegrees[edge.to] = (inDegrees[edge.to] ?: 0).minus(edge.weight).apply {
if (this == 0)
tmpDeque.offer(edge.to)
}
}
}
}
return head
}
其中JubChunk是一组无关联的Job 即前文提到的初始化任务,前面提到目前没有让任务的执行可并发,JobChunk是为了可支持并发做准备的
关于DAG的部分我们就不再花篇幅介绍了,有兴趣的同学可以自行查阅相关资料
任务的描述
先上代码:
abstract class JOB {
abstract val uniqueKey: String
abstract val dependsOn: List<String>
abstract val dispatcher: CoroutineDispatcher
internal fun runInit(maat: Maat) {
MainScope().launch {
flow {
init(maat)
emit(true)
}
.flowOn(dispatcher)
.catch {
maat.onJobFailed(this@JOB,it)
}.flowOn(Dispatchers.Main)
.collect {
maat.onJobSuccess(this@JOB)
}
}
}
abstract fun init(maat: Maat)
}
考虑到kotlin已经被官方推荐很长时间了,并且在去年Retrofit已经开始支持协程,姑且认为大部分项目中都已经开始使用协程了。所以很偷懒的直接使用了协程和Flow
- uniqueKey 是当前任务名,需要人为确保唯一性
- dependsOn 是当前任务所依赖的任务的uniqueKey的集合,虽然使用了List,但是顺序无关。
- dispatcher 指定任务执行被分配到的线程类型
- fun init(maat: Maat) 实际初始化逻辑,注意:按需求分析初始化代码块是否需要 “同步、阻塞”,如果部分代码是“异步、基于回调”且无法更改,这个实际场景(必须要异步获取结果,且该结果被另一个组件使用)想来很少见,第一个版本中我没有考虑,下个版本我会加上
示例代码模拟了4个初始化任务,有点长,具体的使用可以看一下Demo
val maat = Maat.init(application = this, printChunkMax = 6,
logger = object : Maat.Logger() {
override val enable: Boolean = true
override fun log(msg: String, throws: Throwable?) {
Log.d("maat", msg, throws)
}
}, callback = Maat.Callback(onSuccess = {}, onFailure = { maat, job, throwable ->
})
)
maat.append(object : JOB() {
override val uniqueKey: String = "a"
override val dependsOn: List<String> = emptyList()
override val dispatcher: CoroutineDispatcher = Dispatchers.IO
override fun init(maat: Maat) {
Log.e(
"maat",
"run:" + uniqueKey + " isMain:" + (Looper.getMainLooper() == Looper.myLooper())
)
//test exception
// throw NullPointerException("just a test")
}
override fun toString(): String {
return uniqueKey
}
}).append(object : JOB() {
override val uniqueKey: String = "b"
override val dependsOn: List<String> = arrayListOf("a")
override val dispatcher: CoroutineDispatcher = Dispatchers.Main /* + Job()*/
override fun init(maat: Maat) {
Log.e(
"maat",
"run:" + uniqueKey + " isMain:" + (Looper.getMainLooper() == Looper.myLooper())
)
}
override fun toString(): String {
return uniqueKey
}
}).append(object : JOB() {
override val uniqueKey: String = "c"
override val dependsOn: List<String> = arrayListOf("a")
override val dispatcher: CoroutineDispatcher = Dispatchers.IO /* + Job()*/
override fun init(maat: Maat) {
Log.e(
"maat",
"run:" + uniqueKey + " isMain:" + (Looper.getMainLooper() == Looper.myLooper())
)
}
override fun toString(): String {
return uniqueKey
}
}).append(object : JOB() {
override val uniqueKey: String = "d"
override val dependsOn: List<String> = arrayListOf("a", "b", "c")
override val dispatcher: CoroutineDispatcher = Dispatchers.Main
override fun init(maat: Maat) {
Log.e(
"maat",
"run:" + uniqueKey + " isMain:" + (Looper.getMainLooper() == Looper.myLooper())
)
}
override fun toString(): String {
return uniqueKey
}
}).start()
在JIMU中使用
JIMU是一种很彻底的组件化方案,意味着编写代码时存在代码边界,即使是空壳宿主和业务组件之间也存在。前面也提到了,JIMU是使用字节码技术织入的组件加载代码(设置为自动加载组件时),而织入的代码是在Application的onCreate最后执行。
这这一前提下,如果通过javasist实现Maat的任务设置部分,他的可维护性将很差。所以我建议将任务设置部分放在组件的初始化入口处,这样可读性和可维护性都相对好一点.
以原先的分享业务组件为例:
public class ShareApplike implements IApplicationLike {
UIRouter uiRouter = UIRouter.getInstance();
@Override
public void onCreate() {
uiRouter.registerUI("share");
Log.e("share","share on create");
Maat.Companion.getDefault().append(new JOB() {
@NotNull
@Override
public String getUniqueKey() {
return "share";
}
@NotNull
@Override
public List<String> getDependsOn() {
return Collections.singletonList("reader");
}
@NotNull
@Override
public CoroutineDispatcher getDispatcher() {
return Dispatchers.getMain();
}
@Override
public void init(@NotNull Maat maat) {
Log.d("share", "模拟初始化share,context:" + maat.getApplication().getClass().getName());
}
@Override
public String toString() {
return getUniqueKey();
}
});
}
@Override
public void onStop() {
uiRouter.unregisterUI("share");
}
}
当然,务必不要忘记在Application的onCreate()中先初始化Maat:
Maat.Companion.init(this, 8, new Maat.Logger() {
@Override
public boolean getEnable() {
return true;
}
@Override
public void log(@NotNull String s, @Nullable Throwable throwable) {
if (throwable != null) {
Log.e("maat",s,throwable);
} else {
Log.d("maat",s);
}
}
}, new Maat.Callback(new Function1<Maat, Unit>() {
@Override
public Unit invoke(Maat maat) {
Maat.Companion.release();
return null;
}
}, new Function3<Maat, JOB, Throwable, Unit>() {
@Override
public Unit invoke(Maat maat, JOB job, Throwable throwable) {
return null;
}
}));
而Maat的启动API调用,自然由javasist织入了。配合最新的gradle插件 build-gradle:1.3.4方可使用,启用开关为:
combuild {
useMaat = true/false
}
非常重要:
请务必分析项目的组件初始化场景,在Maat适用你的应用场景时再使用。
目前Maat保持了轻量化,如果您有一些合理的需求,欢迎留言或者进Android开发群交流!
网友评论