听说你还不懂依赖任务启动框架?带你撸一个

3,544 阅读5分钟

前言

我们在开发应用的时候,一般都会引入 SDK,而大部分 SDK 都要求我们在 Application 中初始化,当我们引入的 SDK 越来越多,就会出现 Application 越来越长,如果 SDK 的初始化任务相互依赖,还要处理很多条件判断,这时,如果再来个异步初始化,相信大家都会崩溃。

有人可能会说,我都在主线程按顺序初始化不就行了,当然行,只要老板不来找你麻烦

「小王啊,咱们的 APP 启动时间怎么这么久?」

开个玩笑,可见,一个优秀的启动框架对于 APP 启动性能而言,是多么的重要!

为什么不用 Google 的 StartUp?

说到启动框架,就不得不提 StartUp,毕竟是 Google 官方出品,现有的启动框架,或多或少都有参考 StartUp,这里不再详细介绍,如果对 StartUp 还不了解,可以参考这篇文章 Jetpack系列之App Startup从入门到出家

StartUp 提供了简便的依赖任务初始化功能,但是对于一个复杂项目来说,StartUp 有以下不足

  1. 不支持异步任务 如果通过 ContentProvider 启动,所有任务都在主线程执行,如果通过接口启动,所有任务都在同一个线程执行

  2. 不支持组件化 通过 Class 指定依赖任务,需要引用依赖的模块

  3. 不支持多进程 无法单独配置任务需要执行的进程

  4. 不支持启动优先级 虽然可以通过指定依赖来设置优先级,但是过于复杂

一个合格的启动框架是怎么样的?

  1. 支持异步任务 减少启动时间的有效手段

  2. 支持组件化 其实就是解耦,一方面是解耦任务依赖,另一方面是解耦 app 和 module 的依赖

  3. 支持任务依赖 可以简化我们的任务调度

  4. 支持优先级 在没有依赖的情况下,允许任务优先执行

  5. 支持多进程 只在需要的进程中执行初始化任务,可以减轻系统负载,侧面提升 APP 启动速度

收集任务

如果要做到完全解耦,我们可以使用 APT 收集任务

首先定义注解,即任务的一些属性

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class InitTask(
    /**
     * 任务名称,需唯一
     */
    val name: String,
    /**
     * 是否在后台线程执行
     */
    val background: Boolean = false,
    /**
     * 优先级,越小优先级越高
     */
    val priority: Int = PRIORITY_NORM,
    /**
     * 任务执行进程,支持主进程、非主进程、所有进程、:xxx、特定进程名
     */
    val process: Array<String> = [PROCESS_ALL],
    /**
     * 依赖的任务
     */
    val depends: Array<String> = []
)
  • name 作为任务唯一标识,类型为 String 主要是解耦任务依赖
  • background 即是否后台执行
  • priority 是在主线程、无依赖场景下的执行顺序
  • process 指定了任务执行的进程,支持主进程、非主进程、所有进程、:xxx、特定进程名
  • depends 指定依赖的任务

任务的属性定义好,还需要一个执行任务的接口

interface IInitTask {
    fun execute(application: Application)
}

任务需要收集的信息已经定义好了,那么看一下一个真正的任务长什么样

@InitTask(
    name = "main",
    process = [InitTask.PROCESS_MAIN],
    depends = ["lib"]
)
class MainTask : IInitTask {
    override fun execute(application: Application) {
        SystemClock.sleep(1000)
        Log.e("WCY", "main1 execute")
    }
}

还是比较简洁清晰的

接下来需要通过 Annotation Processor 收集任务,然后通过 kotlin poet 写入文件

class TaskProcessor : AbstractProcessor() {

    override fun process(annotations: MutableSet<out TypeElement>?, roundEnv: RoundEnvironment): Boolean {
        val taskElements = roundEnv.getElementsAnnotatedWith(InitTask::class.java)
        val taskType = elementUtil.getTypeElement("me.wcy.init.api.IInitTask")

        /**
         * Param type: MutableList<TaskInfo>
         *
         * There's no such type as MutableList at runtime so the library only sees the runtime type.
         * If you need MutableList then you'll need to use a ClassName to create it.
         * [https://github.com/square/kotlinpoet/issues/482]
         */
        val inputMapTypeName =
            ClassName("kotlin.collections", "MutableList").parameterizedBy(TaskInfo::class.asTypeName())

        /**
         * Param name: taskList: MutableList<TaskInfo>
         */
        val groupParamSpec = ParameterSpec.builder(ProcessorUtils.PARAM_NAME, inputMapTypeName).build()

        /**
         * Method: override fun register(taskList: MutableList<TaskInfo>)
         */
        val loadTaskMethodBuilder = FunSpec.builder(ProcessorUtils.METHOD_NAME)
            .addModifiers(KModifier.OVERRIDE)
            .addParameter(groupParamSpec)

        for (element in taskElements) {
            val typeMirror = element.asType()
            val task = element.getAnnotation(InitTask::class.java)
            if (typeUtil.isSubtype(typeMirror, taskType.asType())) {
                val taskCn = (element as TypeElement).asClassName()

                /**
                 * Statement: taskList.add(TaskInfo(name, background, priority, process, depends, task));
                 */
                loadTaskMethodBuilder.addStatement(
                    "%N.add(%T(%S, %L, %L, %L, %L, %T()))",
                    ProcessorUtils.PARAM_NAME,
                    TaskInfo::class.java,
                    task.name,
                    task.background,
                    task.priority,
                    ProcessorUtils.formatArray(task.process),
                    ProcessorUtils.formatArray(task.depends),
                    taskCn
                )
            }
        }

        /**
         * Write to file
         */
        FileSpec.builder(ProcessorUtils.PACKAGE_NAME, "TaskRegister\$$moduleName")
            .addType(
                TypeSpec.classBuilder("TaskRegister\$$moduleName")
                    .addKdoc(ProcessorUtils.JAVADOC)
                    .addSuperinterface(ModuleTaskRegister::class.java)
                    .addFunction(loadTaskMethodBuilder.build())
                    .build()
            )
            .build()
            .writeTo(filer)

        return true
    }
}

看一下生成的文件长什么样

public class TaskRegister$sample : ModuleTaskRegister {
  public override fun register(taskList: MutableList<TaskInfo>): Unit {
    taskList.add(TaskInfo("main2", true, 0, arrayOf("PROCESS_ALL"), arrayOf("main1","lib1"),MainTask2()))
    taskList.add(TaskInfo("main3", false, -1000, arrayOf("PROCESS_ALL"), arrayOf(), MainTask3()))
    taskList.add(TaskInfo("main1", false, 0, arrayOf("PROCESS_MAIN"), arrayOf("lib1"), MainTask()))
  }
}

sample 模块收集到了3个任务,TaskInfo 对任务信息做了聚合。

我们知道 APT 可以生成代码,但是无法修改字节码,也就是说我们在运行时想到拿到注入的任务,还需要将收集的任务注入到源码中。

这里可以借助 AutoRegister 帮我们完成注入。

注入前

internal class FinalTaskRegister {
    val taskList: MutableList<TaskInfo> = mutableListOf()

    init {
        init()
    }

    private fun init() {}

    fun register(register: ModuleTaskRegister) {
        register.register(taskList)
    }
}

将收集到的任务注入到 init 方法中,注入后的字节码

/* compiled from: FinalTaskRegister.kt */
public final class FinalTaskRegister {
    private final List<TaskInfo> taskList = new ArrayList();

    public FinalTaskRegister() {
        init();
    }

    public final List<TaskInfo> getTaskList() {
        return this.taskList;
    }

    private final void init() {
        register(new TaskRegister$sample_lib());
        register(new TaskRegister$sample());
    }

    public final void register(ModuleTaskRegister register) {
        Intrinsics.checkNotNullParameter(register, "register");
        register.register(this.taskList);
    }
}

我们通过 APT 生成的类已经成功的注入到代码中。

小结

至此,我们已经完成了任务的收集,通过 APT 和字节码修改是常见的类收集方案,相比反射,字节码修改没有任何性能的损失。

后来发现 Google 已经推出了新的注解处理框架 ksp,处理速度更快,于是果断尝试了一把,所以有两种注解处理可以选择,GitHub 上有详细介绍。

任务调度

任务调度是启动框架的核心,大家可能听到过

处理依赖任务首先要构建一个「有向无环图」

什么是有向无环图,看下维基百科的介绍

在图论中,如果一个有向图从任意顶点出发无法经过若干条边回到该点,则这个图是一个有向无环图(DAG, Directed Acyclic Graph)。

听起来好像很简单,那么具体怎么实现呢,今天我们抛开高级概念不谈,用代码带大家实现任务的调度。

首先,需要把任务分为两类,有依赖的任务和无依赖的任务。

有依赖的首先检查是否有环,如果有循环依赖,直接 throw,这个可以套用公式 —— 如何判断链表是否有环

如果没有循环依赖,则收集每个任务的被依赖任务,我们称之为子任务,用于当前任务执行完成后,继续执行子任务

无依赖的最简单,直接按照优先级执行即可。

不知道大家是否有疑问:有依赖的任务什么时候启动?

有依赖的任务,依赖链的叶子端点一定是一个无依赖的任务,因此无依赖的任务执行完成后,就可以开始执行有依赖的任务。

下面用一个小例子来介绍

  • A 依赖 BC
  • B 依赖 C
  • C 无依赖

树形结构

image.png

  1. 分组并梳理子任务
  • 有依赖
    • A: 无子任务
    • B: 子任务: [A]
  • 无依赖
    • C: 子任务: [A, B]

image.png

  1. 执行无依赖的任务C
  2. 更新已完成的任务: [C]
  3. 检查 C 的子任务是否可以执行
  • A: 依赖 [B, C],已完成任务中不包含 B,无法启动
  • B: 依赖 [C],已完成任务中包含 C,可以执行
  1. 执行任务 B
  2. 重复步骤 3,直到所有任务执行完成

下面我们就用代码来实现

使用递归检查循环依赖

private fun checkCircularDependency(
    chain: List<String>,
    depends: Set<String>,
    taskMap: Map<String, TaskInfo>
) {
    depends.forEach { depend ->
        check(chain.contains(depend).not()) {
            "Found circular dependency chain: $chain -> $depend"
        }
        taskMap[depend]?.let { task ->
            checkCircularDependency(chain + depend, task.depends, taskMap)
        }
    }
}

梳理子任务

task.depends.forEach {
    val depend = taskMap[it]
    checkNotNull(depend) {
        "Can not find task [$it] which depend by task [${task.name}]"
    }
    depend.children.add(task)
}

执行任务

private fun execute(task: TaskInfo) {
    if (isMatchProgress(task)) {
        val cost = measureTimeMillis {
            kotlin.runCatching {
                (task.task as IInitTask).execute(app)
            }.onFailure {
                Log.e(TAG, "executing task [${task.name}] error", it)
            }
        }
        Log.d(
            TAG, "Execute task [${task.name}] complete in process [$processName] " +
                    "thread [${Thread.currentThread().name}], cost: ${cost}ms"
        )
    } else {
        Log.w( TAG, "Skip task [${task.name}] cause the process [$processName] not match")
    }
    afterExecute(task.name, task.children)
}

如果进程不匹配直接跳过

继续执行下一个任务

private fun afterExecute(name: String, children: Set<TaskInfo>) {
    val allowTasks = synchronized(completedTasks) {
        completedTasks.add(name)
        children.filter { completedTasks.containsAll(it.depends) }
    }
    if (ThreadUtils.isInMainThread()) {
        // 如果是主线程,先将异步任务放入队列,再执行同步任务
        allowTasks.filter { it.background }.forEach {
            launch(Dispatchers.Default) { execute(it) }
        }
        allowTasks.filter { it.background.not() }.forEach { execute(it) }
    } else {
        allowTasks.forEach {
            val dispatcher = if (it.background) Dispatchers.Default else Dispatchers.Main
            launch(dispatcher) { execute(it) }
        }
    }
}

如果子任务的依赖任务都已经执行完毕,就可以执行了

最后还需要提供一个启动任务的接口,为了支持多进程,这里不能使用 ContentProvider

小结

通过层层拆解,将复杂的依赖梳理清楚,用通俗易懂的方法,实现任务调度。

源码

github.com/wangchenyan…

另外,我也在 JitPack 上发布了 alpha 版本,欢迎大家尝试

kapt "com.github.wangchenyan.init:init-compiler:1-alpha.1"
implementation "com.github.wangchenyan.init:init-api:1-alpha.1"

详细使用请移步 GitHub

总结

本文以 StartUp 作为引子,阐述依赖任务启动框架还需要具备哪些能力,通过 APT + 字节码注入进行解耦,支持模块化,通过一个简单的模型来表述任务调度具体的实现方式。

希望本文能够让大家了解依赖任务启动框架的核心思想,如果你有好的建议,欢迎评论。

参考

Kotlin + Flow 实现的 Android 应用初始化任务启动库