Android 启动优化杂谈 | 另辟蹊径
Posted 涂程
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android 启动优化杂谈 | 另辟蹊径相关的知识,希望对你有一定的参考价值。
作者:究极逮虾户
开篇
先介绍下徐公大佬的文章,如果有前置需要的话建议看下这个系列。
启动优化这个系列都可以好好看看,感谢徐公大佬。
本文将不发表任何关于 有向无环图(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的情况下就会释放当前线程了。
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
的形式,但是这个地方还没完成最后的设计,当前还是有点没想好的。主要是解决组件化情况下,可以更随意一点。
线程池关闭
这里是我个人考虑哦,当整个启动流程结束之后,默认情况下是不是应该考虑把线程池关闭了呢。我发现很多都没有写这些的,会造成一些线程使用的泄漏问题。
fun dispatcherEnd()
if (executor != mExecutor)
KLogger.i(TAG, "auto shutdown default executor")
mExecutor.shutdown()
代码如上,如果当前线程池并不是传入的线程池的情况下,考虑执行完毕之后关闭线程池。
dsl + 锚点
因为我既是开发人员,同时也是框架的使用方。所以我自己在使用的过程中发现原来的设计上问题还是很多的,我自己想要插入一个在所有sdk完成之后的任务非常不方便。
然后我就考虑这部分通过dsl的方式去写了动态添加task。kotlin是真的很香,如果后续开发没糖我估计就是个废人了。
我就是死从这里跳下去,卧槽语法糖真香。
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,所以后续代码生成可以考虑迁移到新方法内。
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逻辑。以前的init
api 被移动到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方面我后续还是会进行迭代的,毕竟第一个版本丑陋不堪主要是想完成数据的手机,而且开发看起来也不是特别显眼,后面可能会把差异部分直接输出。
做大做强,搞一波大新闻。
以上是关于Android 启动优化杂谈 | 另辟蹊径的主要内容,如果未能解决你的问题,请参考以下文章