组件化:代码隔离也难不倒组件的按序初始化

5,345 阅读12分钟

前言

时至今日,Android项目中的组件化大家都已经非常熟悉了,但在各个细节方面还是有一些门门道道的内容,如果没有趁手的中间件支持,推行组件化的过程中还是会遇到阻碍。继2017年逻辑思维得到项目团队开源其组件化方案思路和核心gradle构建插件后,笔者一直投身其中并致力于插件的功能升级和中间件生态完善

其实在2018年就有部分同学提出了“增加按序初始化组件”的需求,限于个人精力以及需求的优先级,当时被搁置了,这个需求一拖也就拖了两年了。这次终于抽出时间完成了这个中间件,特此写了一篇博客,介绍其中的一些知识点和这个中间件Maat。Github仓库

大纲导图

方便阅读,导图附上。

注:即使你没有使用上面提到的得到组件化方案(DDComponentForAndroid)或者我们后续维护的JIMU,也不影响本篇文章的理解。

问题的根源

这里我们再花一点时间来了解下问题的根源:组件化的基础是模块化,在做到模块化的同时,模块与模块在编写、编译期间也就达成了完全代码隔离,组件间的交互依靠 底层接口+服务发现(或者服务注册) 或者更加抽象为 “基于协议、隐藏实现”。这带来了编写、编译期间激增的代码耦合*(注:此处语境遗漏,在达成编写、编译期间完全代码隔离的条件下,想要用比较原始的、直面问题的方式解决组件按序初始化问题,例如使用反射+无分支遗漏的逻辑涵盖所有组件组合情况,会导致耦合激增。2020年10月20日补)*。

我知道这样说实在是太晦涩了,一点也不接地气,我们以一个简单的例子来配合说明。

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的设计意图

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保持了轻量化,如果您有一些合理的需求,欢迎留言或者提issue交流