Android 启动优化杂谈 | 另辟蹊径

2022-03-06 09:58:35 浏览数 (1)

theme: smartblue

开篇

先介绍下徐公大佬的文章,如果有前置需要的话建议看下这个系列。

启动优化这个系列都可以好好看看,感谢徐公大佬。

本文将不发表任何关于 有向无环图(DAG) 相关,会更细致的说一些我自己的奇怪的观点,以及从一些问题出发,介绍如何做一些有意思的调整。

当前仓库还处于一个迭代状态中,并不是一个特别稳定的状态,所以文章更多的是给大家打开一些小思路。

有想法的同学可以留言啊,我个人感觉一个迭代库才是可以持续演进的啊。

demo 地址 AndroidStartup

demo中很多代码参考了android-startup,感谢这位大佬,u1s1这位大佬很强啊。

Task粒度

这一点其实蛮重要的,相信很多人在接入启动框架之后,更多的事情是把原来可以用的代码,直接用几个Task的把之前的代码包裹起来,之后然后这样就相当于完成了简单的启动框架接入了。

其实这个基本算是违背了启动框架设计的初衷了。我先抛出一个观点,启动框架并不会真实帮你加快多少启动速度,他解决的场景只是让你的sdk的初始化更加的有序,让你可以在长时间的迭代过程中,可以更加稳妥的添加一些新的sdk。

举个栗子,当你的埋点框架依赖了网络库,abtest配置中心也依赖了网络库,然后网络库则依赖了dns等等,之后所有的业务依赖了埋点配置中心图片库等等sdk的初始化完成之后。

当然还是有极限情况下会出现依赖成环问题,这个时候可能就需要开发同学手动的把这个依赖问题给解决了 比如特殊情况网络库需要唯一id,上报库依赖了网络库,而上报库又依赖了唯一id,唯一id又需要进行数据上报

所以我个人的看法启动框架的粒度应该细化到每个sdk的初始化,如果粒度可以越细致当然就越好了。其实一般的启动框架都会对每个task的耗时进行统计的,这样我们后续在跟进对应的问题也会变的更简便,比如查看某些的任务耗时是否增加了啊之类的。

当前我们在设计的时候可能会把一个sdk的初始化拆分成三个部分去做,就是为了去解决这种依赖成环的问题。

子线程间的等待

之前发现项目内的启动框架只保证了放入线程的时候的顺序是按照dag执行的。如果只有主线程和池子大小为1线程池的情况下,这种是ok的。但是如果多线程并发的情况下,这个就变成了一个危险操作了。

所以我们需要在并发场景下加上一个等待的情况下,一定要等到依赖的任务完成了之后,才能继续向下执行初始化代码。

机制的话还是使用CountDownLatch,当依赖的任务都执行完成之后,await会被释放,继续向下执行。而设计上我还是采取了装饰者,不需要使用方更改原始的逻辑就能继续使用了。

代码如下,主要就是一次任务完成的分发,之后发现当前的依赖是有该任务的则latch-1. 当latch到0的情况下就会释放当前线程了。

代码语言:javascript复制
class StartupAwaitTask(val task: StartupTask) : StartupTask {

    private var dependencies = task.dependencies()
    private lateinit var countDownLatch: CountDownLatch
    private lateinit var rightDependencies: List<String>
    var awaitDuration: Long = 0

    override fun run(context: Context) {
        val timeUsage = SystemClock.elapsedRealtime()
        countDownLatch.await()
        awaitDuration = (SystemClock.elapsedRealtime() - timeUsage) / 1000
        KLogger.i(
            TAG, "taskName:${task.tag()}  await costa:${awaitDuration} "
        )
        task.run(context)
    }

    override fun dependencies(): MutableList<String> {
        return dependencies
    }

    fun allTaskTag(tags: HashSet<String>) {
        rightDependencies = dependencies.filter { tags.contains(it) }
        countDownLatch = CountDownLatch(rightDependencies.size)
    }

    fun dispatcher(taskName: String) {
        if (rightDependencies.contains(taskName)) {
            countDownLatch.countDown()
        }
    }

    override fun mainThread(): Boolean {
        return task.mainThread()
    }

    override fun await(): Boolean {
        return task.await()
    }

    override fun tag(): String {
        return task.tag()
    }

    override fun onTaskStart() {
        task.onTaskStart()
    }

    override fun onTaskCompleted() {
        task.onTaskCompleted()
    }

    override fun toString(): String {
        return task.toString()
    }

    companion object {
        const val TAG = "StartupAwaitTask"
    }
}

这个算是一个能力的补充完整,也算是多线程依赖必须要完成的一部分。

同时将依赖模式从class变更成tag的形式,但是这个地方还没完成最后的设计,当前还是有点没想好的。主要是解决组件化情况下,可以更随意一点。

线程池关闭

这里是我个人考虑哦,当整个启动流程结束之后,默认情况下是不是应该考虑把线程池关闭了呢。我发现很多都没有写这些的,会造成一些线程使用的泄漏问题。

代码语言:javascript复制
fun dispatcherEnd() {
    if (executor != mExecutor) {
        KLogger.i(TAG, "auto shutdown default executor")
        mExecutor.shutdown()
    }
}

代码如上,如果当前线程池并不是传入的线程池的情况下,考虑执行完毕之后关闭线程池。

dsl 锚点

因为我既是开发人员,同时也是框架的使用方。所以我自己在使用的过程中发现原来的设计上问题还是很多的,我自己想要插入一个在所有sdk完成之后的任务非常不方便。

然后我就考虑这部分通过dsl的方式去写了动态添加task。kotlin是真的很香,如果后续开发没糖我估计就是个废人了。

我就是死从这里跳下去,卧槽语法糖真香。

代码语言:javascript复制
fun Application.createStartup(): Startup.Builder = run {
    startUp(this) {
        addTask {
            simpleTask("taskA") {
                info("taskA")
            }
        }
        addTask {
            simpleTask("taskB") {
                info("taskB")
            }
        }
        addTask {
            simpleTask("taskC") {
                info("taskC")
            }
        }
        addTask {
            simpleTaskBuilder("taskD") {
                info("taskD")
            }.apply {
                dependOn("taskC")
            }.build()
        }
        addTask("taskC") {
            info("taskC")
        }
        setAnchorTask {
            MyAnchorTask()
        }
        addTask {
            asyncTask("asyncTaskA", {
                info("asyncTaskA")
            }, {
                dependOn("asyncTaskD")
            })
        }
        addAnchorTask {
            asyncTask("asyncTaskB", {
                info("asyncTaskB")
            }, {
                dependOn("asyncTaskA")
                await = true
            })
        }
        addAnchorTask {
            asyncTaskBuilder("asyncTaskC") {
                info("asyncTaskC")
                sleep(1000)
            }.apply {
                await = true
                dependOn("asyncTaskE")
            }.build()
        }
        addTaskGroup { taskGroup() }
        addTaskGroup { StartupTaskGroupApplicationKspMain() }
        addMainProcTaskGroup { StartupTaskGroupApplicationKspAll() }
        addProcTaskGroup { StartupProcTaskGroupApplicationKsp() }
    }
}

这种DSL写法适用于插入一些简单的任务,可以是一些没有依赖的任务,也可以是你就是偷懒想这么写。好处就是可以避免自己用继承等的形式去写过多冗余的代码,然后在这个启动流程内能看到自己做了些什么事情。

一般等到项目稳定之后,会设立几个锚点任务。他们的作用是后续任务只要挂载到锚点任务之后执行即可,定下一些标准,让后续的同学可以更快速的接入。

我们会把这些设置成一些任务组设置成基准,比如说是网络库,图片库,埋点框架,abtest等等,等到这些任务完成之后,别的业务代码就可以在这里进行初始化了。这样就不需要所有人都写一些基础的依赖关系,也可以让开发同学舒服一点点。

怎么又成环了

在之前的排序阶段,存在一个非常鬼畜的问题,如果你依赖的任务并不在当前的图中存在,就会报出依赖成环问题,但是你并不知道是因为什么原因成环的。

这个就非常不方便开发同学调试问题了,所以我增加了前置任务有效性判断,如果不存在的则会直接打印Log日志,也增加了debugmode,如果测试情况下可以直接已任务不存在的崩溃结束。

ksp

我想偷懒所以用ksp生成了一些代码,同时我希望我的启动框架也可以应用于项目的组件化和插件化中,这样反正就是牛逼啦。

启动任务分组

当前完成的一个功能就是通过注解 ksp生成一个启动任务的分组,这次ksp的版本我们采用的是1.5.30的版本,同时api也有了一些变更。

之前在ksp的文章说过process死循环的问题,最近和米忽悠乌蝇哥交流(吹牛)的时候发现,系统提供一个finish方法,因为process的时候只要有类生成就会重新出发process方法,导致stackoverflow,所以后续代码生成可以考虑迁移到新方法内。

代码语言:javascript复制
class StartupProcessor(
    val codeGenerator: CodeGenerator,
    private val logger: KSPLogger,
    val moduleName: String
) : SymbolProcessor {
    private lateinit var startupType: KSType
    private var isload = false
    private val taskGroupMap = hashMapOf<String, MutableList<ClassName>>()
    private val procTaskGroupMap =
        hashMapOf<String, MutableList<Pair<ClassName, ArrayList<String>>>>()

    override fun process(resolver: Resolver): List<KSAnnotated> {
        logger.info("StartupProcessor start")

        val symbols = resolver.getSymbolsWithAnnotation(StartupGroup::class.java.name)
        startupType = resolver.getClassDeclarationByName(
            resolver.getKSNameFromString(StartupGroup::class.java.name)
        )?.asType() ?: kotlin.run {
            logger.error("JsonClass type not found on the classpath.")
            return emptyList()
        }
        symbols.asSequence().forEach {
            add(it)
        }
        return emptyList()
    }

    private fun add(type: KSAnnotated) {
        logger.check(type is KSClassDeclaration && type.origin == Origin.KOTLIN, type) {
            "@JsonClass can't be applied to $type: must be a Kotlin class"
        }

        if (type !is KSClassDeclaration) return

        //class type

        val routerAnnotation = type.findAnnotationWithType(startupType) ?: return
        val groupName = routerAnnotation.getMember<String>("group")
        val strategy = routerAnnotation.arguments.firstOrNull {
            it.name?.asString() == "strategy"
        }?.value.toString().toValue() ?: return
        if (strategy.equals("other", true)) {
            val key = groupName
            if (procTaskGroupMap[key] == null) {
                procTaskGroupMap[key] = mutableListOf()
            }
            val list = procTaskGroupMap[key] ?: return
            list.add(type.toClassName() to (routerAnnotation.getMember("processName")))
        } else {
            val key = "${groupName}${strategy}"
            if (taskGroupMap[key] == null) {
                taskGroupMap[key] = mutableListOf()
            }
            val list = taskGroupMap[key] ?: return
            list.add(type.toClassName())
        }
    }

    private fun String.toValue(): String {
        var lastIndex = lastIndexOf(".")   1
        if (lastIndex <= 0) {
            lastIndex = 0
        }
        return subSequence(lastIndex, length).toString().lowercase().upCaseKeyFirstChar()
    }
       // 开始代码生成逻辑
    override fun finish() {
        super.finish()
        // logger.error("className:${moduleName}")
        try {
            taskGroupMap.forEach { it ->
                val generateKt = GenerateGroupKt(
                    "${moduleName.upCaseKeyFirstChar()}${it.key.upCaseKeyFirstChar()}",
                    codeGenerator
                )
                it.value.forEach { className ->
                    generateKt.addStatement(className)
                }
                generateKt.generateKt()
            }
            procTaskGroupMap.forEach {
                val generateKt = GenerateProcGroupKt(
                    "${moduleName.upCaseKeyFirstChar()}${it.key.upCaseKeyFirstChar()}",
                    codeGenerator
                )
                it.value.forEach { pair ->
                    generateKt.addStatement(pair.first, pair.second)
                }
                generateKt.generateKt()
            }
        } catch (e: Exception) {
            logger.error(
                "Error preparing :"   " ${e.stackTrace.joinToString("n")}"
            )
        }
    }
}


class StartupProcessorProvider : SymbolProcessorProvider {
    override fun create(
        environment: SymbolProcessorEnvironment
    ): SymbolProcessor {
        return StartupProcessor(
            environment.codeGenerator,
            environment.logger,
            environment.options[KEY_MODULE_NAME] ?: "application"
        )
    }
}

fun String.upCaseKeyFirstChar(): String {
    return if (Character.isUpperCase(this[0])) {
        this
    } else {
        StringBuilder().append(Character.toUpperCase(this[0])).append(this.substring(1)).toString()
    }
}

const val KEY_MODULE_NAME = "MODULE_NAME"

其中processor被拆分成两部分,SymbolProcessorProvider负责构造,SymbolProcessor则负责处理ast逻辑。以前的initapi 被移动到SymbolProcessorProvider中了。

逻辑也比较简单,收集注解,然后基于注解的入参生成一个taskGroup逻辑。这个组会被我手动加入到启动流程内。

未完成

另外我想做的一件事就是通过注解来去生成一个Task任务,然后通过不同的注解的排列组合,组合出一个新的task任务。

这部分功能还在设计中,后续完成之后再给大家水一篇好了。

调试组件

这部分是我最近设计的重中之重了。当接了启动框架这个活之后,更多的时候你是需要去追溯启动变慢的问题的,我们把这种情况叫做劣化。如何快速定位劣化问题也是启动框架所需要关心的。

一开始我们打算通过日志上报,之后在版本发布之后重新推导线上的任务耗时,但是因为计算出来的是平均值,而且我们的自动化测试同学每个版本发布前都会跑自动化case,观察启动时间的状况,如果时间均值变长就会来通知我们,这个时候看埋点数据其实挺难发现问题的。

核心原因还是我想偷懒,因为排查问题必须要基于之前的版本和当前版本进行对比,比较各个task之间的耗时状况,我们当前大概应该有30 的启动任务,这尼玛不是要了我老命了吗。

所以我和我大佬沟通了下,就对这部分进行了立项,打算折腾一个调试工具,可以记录下启动任务的耗时,还有启动任务的列表,通过本地对比的形式,可以快速推导出出现问题任务,方便我们快速定位问题。

小贴士 调试工具的开发最好不要有太多的依赖 然后通过debug 的buildtype来加入 所以使用了contentprovider来初始化

启动时间轴

江湖上一直流传着我的外号-ui大湿,在下也不是浪得虚名,ui大湿画出来的图形那叫一个美如画啊。

这部分原理比较简单,我们把当前启动任务的数据进行了收集,然后根据线程名进行分发,记录任务开始和结束的节点,然后通过图形化进行展示。

如果你第一时间看不懂,可以参考下自选股列表,每一列都是代表一个线程执行的时间轴。

启动顺序是否变更

我们会在每次启动的时候将当前启动的顺序进行数据库记录,然后通过数据库找出和当前hashcode不一样的任务,然后比对下用textview的形式展示出来,方便测试同学反馈问题。

这个地方的原理的,我是将整个启动任务通过字符串拼接,然后生成一个字符串,之后通过字符串的hashcode作为唯一标识符,不同字符串生成的hashcode也是不同的。

这里有个傻事就是我一开始对比的是stringbuilder的hashcode,然后发现一样的任务竟然值变更了,我真傻真的。

别问,问就是ui大湿,textview不香?

平均任务耗时

这个地方的数据库设计让我思考了好一会,之后我按照天为维度,之后记录时间和次数,然后在渲染的时候取出均值。

之后把之前的历史数据取出来,然后进行汇总统计,之后重新生成list,一个当前task下面跟随一个历史的task。然后进行牛逼的ui渲染。

这个时候你要喷了啊,为什么你全部都是textview还自称ui大湿啊。

虾扯蛋你听过吗,没错就是这样的。

总结

卷来,天不生我逮虾户,卷道万古长如夜。

与诸君共勉。

真的总结

UI方面我后续还是会进行迭代的,毕竟第一个版本丑陋不堪主要是想完成数据的手机,而且开发看起来也不是特别显眼,后面可能会把差异部分直接输出。

做大做强,搞一波大新闻。

0 人点赞