本篇主要介绍一下针对 post main 阶段的启动优化,通过 启动框架 对启动过程中的任务进行统一调度,不仅是对启动耗时的优化,同时也能提高启动过程代码的可维护性和可扩展性。
启动框架的作用
随着业务需求的不断增多,在启动阶段需要初始化的任务也会越来越多,对启动速度也提出了挑战。同时在启动阶段的任务不断追加,也越来越变得不可维护,因此有必要使用一个启动框架来来管理这个过程。
下面,分别从任务管理、多线程提速、耗时监控以及防劣化等方面,介绍一下使用启动框架给我们解决的问题。
1.任务管理
通常我们将需要在启动阶段初始化的操作放在 AppDelegate 的 didFinishLaunching 方法中,随着业务的发展,AppDelegate 文件中的代码会越来越臃肿,有的项目甚至能劣化到大几千行代码,导致代码的维护性很差。同时也容易导致 App 启动耗时变长,影响用户体验。所以针对任务的管理的优化方向就是:
- 按功能抽离代码,通过任务的形式管理调度,方便维护
- 删除无需在启动阶段初始化的操作,延迟调用或懒加载调用
2.多线程调度任务
当一个项目多名开发共同协作时,每个人都会往 didFinishLaunching 方法中追加自己业务的启动初始化任务,所有任务都是顺序执行的,当任务多了之后无疑会影响启动速度。因此,我们有理由需要框架来管理这些任务,理清它们之间的依赖管理,充分利用硬件资源,充分里利用多线程并发执行任务,加速启动。
假如有A~E 5 启动启动任务,B依赖 A,C 不依赖 A和 B,D 和 E 分别依赖 C。新任务 F 会根据依赖关系追加在最后,或者插入到启动链路中的某个位置,如下图:
如上图中的启动任务的执行顺序,都是顺序执行的,存在的问题有:
-
任务 C 对前面的 A 和 B 依赖,但仍需等A、B 执行完才能执行。
-
同理,任务 D 和 E 分别依赖了 C,E 不依赖 D,却被最后执行
-
若某个版本中 B 的耗时增加了,那后面对此没有依赖的 C、D、E 都被卡主了。
-
若新增了一个任务 F,被插入的位置不同,完全可能会影响整个启动任务链的执行速度。
为了解决项目中遇到的这些问题,先要梳理清楚每个任务的优先级以及依赖关系,构建任务间的有向无环图,充分利用硬件资源快速执行这些任务。将从之前的主线程串行执行,变成多线程并发执行,如下图:
3.埋点监控
如果按照之前任务的顺序执行的方式,很难细颗粒度的监控每个任务的耗时,若再每项任务前后插入耗时统计的代码,随着启动任务地方增多会让程序变得很不可维护。
通过引入启动框架来管理任务,通过对任务的封装,可以细颗粒度的统计每个任务的执行耗时,同时也支持对该任务组的耗时统计,最重要的是可以将耗时监控逻辑与业务的代码逻辑隔离开。总的来说具备以下三点能力:
- 单任务耗时埋点
- 任务组耗时埋点
- 埋点代码隔离
4.防劣化
-
代码评审
通过启动框架调用的启动任务,若有改变或新增,通过 MR 流水线检查指定文件 diff 的修改,进行拦截通知 owner 进行 review。相比拦截之前的 AppDelegate 文件更加的精准、清晰。 -
线下断言提醒
可在启动框架中对单个任务或任务组设置执行耗时阈值,若发生卡顿超过了耗时上限,则在 debug 环境下及时提醒开发进行优化。 -
线上大盘报警
针对所有启动任务建立大盘看板,设置任务的耗时阈值,并配置报警机制。
针对以上的这些能力,输出了一个简单的启动框架,下面对该框架简要的介绍一下。
启动框架的组成
1.整体结构
如上图,整体架构还是比较清晰的,项目只需要通过 Pod 依赖的方式集成到项目中即可,大概逻辑如下:
- 业务方
- 业务方根据需求创建启动任务,通过任务组的方式注册到启动框架内,交由框架管理。
- 然后在合适的时机触发任务组的执行
- 启动框架内
- 框架内对注册的任务进行合法性检查:单向依赖,无环
- 通过调度器按需将任务派发到指定线程执行,并进行耗时监控
2.任务的注册
2.1 创建任务
启动任务创建流程是:创建一个类继承自 XSLaunchBaseTask,重写 executeTask 方法,在该方法内实现启动阶段初始化的操作,源码如下:
class TaskA: XSLaunchBaseTask {
override func executeTask(completion: @escaping XSLaunchTaskCompletion) {
//启动任务的代码
sleep(1);
completion() // 执行完成的回调
}
}
2.2 注册任务
- 初始化
创建完任务之后,需要将任务初始化并注册到启动框架中,由于框架内部会通过多线程去支持任务,因此也可以指定任务是否需要在主线从执行和线程的优先级,并且任务之间是支持设置依赖关系的,源码如下:
class TaskManager {
func registLaunchTasks() {
//初始化
let taskA = TaskA(moduleName: "启动任务A", needMain: true)
let taskB = TaskB(moduleName: "启动任务B")
let taskC = TaskC(moduleName: "启动任务C", priority: .high)
//设置任务间的依赖关系
taskB.addDependency(taskA)
//注册
XSLaunchManager.shared().registTasksInGroup(.didLaunch, tasks: [taskA,taskB,taskC])
}
}
- 注册
将任务注册的触发,放在了在相对较早的 willFinishLaunch 方法中。
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
TaskManager.registLaunchTasks()
}
3.任务的合法性检查
这一步是在注册任务的过程执行的,主要是验证任务的唯一性以及没有循环依赖。
-
任务唯一性
避免多人协同开发的过程重复注册了启动项,避免重复执行而导致bug。 -
无环依赖
由于任务之间是支持设置依赖关系的,避免出现循环依赖导致死循环,当出现以上这些问题后后命中 asset 来提示开发同学进行修改。
4.任务的调度
由于任务是支持指定执行线程的,如主线程或其他线程,并任务之间是存在依赖关系的。为了使主线程中的任务快速被执行,框架内需要及时的去调度被主线程依赖的子任务。
其中可能存在的任务依赖关系有:
- 主线程任务 ->主线程任务
- 主线程 -> 子线程
- 子线程 -> 主线程
- 子线程 -> 子线程
其中子线程的任务依赖扔由系统处理,框架会循环调用主线程的任务。调度逻辑如下图所示:
如上图,主要是针对指定了需要在主线程执行的任务的调度过程:
1.传入主线程任务集,在循环体内触发 ready 的任务
2.过滤出依赖了子线程的任务集
3.再取出子线程的依赖并依次遍历,若该任务需要再主线程执行,则跳过,交给循环体调度。若该任务需要在子线程执行,则阻塞执行。
4.判断循环体的执行次数,若超过 500 次(看情况而定),则进入保护模式。Debug 下则触发断言,Release 下则获取当前所有未完成的任务,依次在主线程顺序执行。
5.耗时监控
在框架内部会对注册的任务进行耗时监控,监控的信息有:
-
每个任务的耗时
-
每个任务组的耗时
如何判断每一组任务全部执行完成了呢?
我们通过新建一个 SeperatorTask 任务,使 SeperatorTask 依赖任务组中中的每一个任务,只有当任务组的任务全部执行完毕才会执行 SeperatorTask,以此来标记任务组的执行结束,以及监控整个任务组的耗时。 -
整个 post main 阶段启动耗时
启动框架内所管理的任务执行完毕即为空时,以此作为启动任务执行完成标志,即启动完成。
注意:耗时埋点的上报一定要等待所有任务执行完毕再统一上报,避免抢占任务的执行线程。
6.触发任务组
启动框架是支持多任务组注册的,因此我们可以将任务分组执行,可将渲染首屏必要的任务放到第一组执行,其他非首屏必要的且需要启动初始化的任务放到第二组,
- 触发第一组
第一组启动任务的触发在 didFinishLaunching 方法中,在设置首屏代码之前。
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
//触发第一组
XSLaunchManager.shared().executeLaunchItems(with: .didLaunch)
//设置首屏
window?.rootViewController = HomeViewController()
return true
}
- 触发第二组
第二组非紧急任务选择放在了首屏渲染完成之后,放在了首屏的 viewDidAppear 方法中。
class HomeViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
//触发第二组任务
XSLaunchManager.shared().executeLaunchItems(with: .freeTime)
}
}
以上代码可参考:XSLauncher Demo
总结
本文简要介绍了一下通过启动框架来管理 post main 阶段启动任务的手段,不仅能够统一代码规范,同时也能更好的监控启动任务的耗时情况从而及时的优化。
综上,通过启动框架来管理启动任务的好处主要有:
- 提升启动效率
- 任务线程管理和优先级设置
- 统计和耗时监控
- 良好的可维护性和扩展性
参考