「性能优化系列」APP启动优化理论与实践(下)

·  阅读 2061
「性能优化系列」APP启动优化理论与实践(下)

本文已参与掘金创作者训练营第三期,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

性能优化系列:

  • 启动优化
  • 内存优化
  • 布局优化
  • 卡顿优化
  •  apk瘦身优化
  • 电量优化

项目地址:  fuusy/FuPerformance

零、前言

一年多以前写过一篇关于启动优化的文章,见「性能优化系列」APP启动优化理论与实践(上)。每一年都有新的见解,本篇将在前篇的基础上补充说明app的启动优化方案,请结合查看。

本篇内容主要如下:

  • 启动耗时监测实战:手动打点以及AspectJ方式对比;
  • 启动优化实战:有向无环图启动器、IdleHandler启动器以及其他黑科技方案;
  • 优化工具介绍。

一、优化工具

1.1、Traceview(弃用)

TraceView是Android平台一个很好的性能分析的工具,能够以图形的形式显示跟踪日志,但是已弃用。 另外TraceView的性能消耗太大,得到的结果不真实。

1.2、CPU Profiler

代替Traceview便是CPU Profiler。它可以检查通过使用Debug类对应用进行插桩检测而捕获的.trace 文件、记录新方法跟踪信息、保存.trace 文件以及检查应用进程的实时CPU使用情况。 具体使用参考使用CPU性能剖析器检查 CPU 活动

1.3、Systrace + 函数插桩

Systrace 允许你收集和检查设备上运行的所有进程的计时信息。 它包括Androidkernel的一些数据(例如CPU调度程序,IO和APP Thread),并且会生成HTML报告,方便用户查看分析trace内容。但是不支持应用程序代码的耗时分析,如果需要分析程序代码的执行时间,那就要结合函数插桩的方式,对细节进行分析。在下面第二节给出了实战案例,请参考。

二、启动耗时监测

对于启动速度的计算方式有很多种,如手动打点、AOP打点、adb命令、Traceview、Systrace等,在「性能优化系列」APP启动优化理论与实践(上)这篇文章里已经初步说明,这里就不在赘述。下面将从实战方向进行耗时监测处理。

为了监测启动耗时,我在Application的onCreate中初始化了一些第三方框架,比如初始化ARouter、Bugly、LoadSir等,模拟耗时操作。

2.1、如何监测每个方法的执行时间?

2.1.1、方式一:手动打点

在了解到手动打点可监测app启动时间,那是不是可以应用到每个方法中,那就来试一下,我们在每个第三方框架初始化的方法前后都进行打点,

override fun onCreate() {
    super.onCreate()
    //Debug.startMethodTracing("App")
    //TraceCompat.beginSection("onCreate")
    TimeMonitorManager.instance?.startMonitor()
    initRouter()
    TimeMonitorManager.instance?.endMonitor("initRouter")
    
    TimeMonitorManager.instance?.startMonitor()
    initBugly()
    TimeMonitorManager.instance?.endMonitor("initBugly")
    
    TimeMonitorManager.instance?.startMonitor()
    initLoadSir()
    TimeMonitorManager.instance?.endMonitor("initLoadSir")

    //Debug.stopMethodTracing()
    //TraceCompat.endSection()
}
复制代码

按照这个方式,毋庸置疑,每个方法的耗时时间是肯定能计算出来的,但是,每个方法都加上重复的代码,一个方法加两行,那有一百,一千个方法呢?难道一个一个的手敲吗?!!

这种方式太“笨”,并且对源代码的侵入性极强,弃。

那是否有更优雅的方式计算每个方法的执行时间? 答案是当然有。

AOP(面向切面编程),可以通过预编译方式和运行其动态代理实现在不修改源代码的情况下给程序动态统一添加某种特定功能的一种技术。

它的目的主要将日志记录,性能统计,安全控制,事务处理,异常处理等代码从业务逻辑代码中划分出来,通过对这些行为的分离,我们希望可以将它们独立到非指导业务逻辑的方法中,进而改变这些行为的时候不影响业务逻辑的代码。

上面手动打点的方式,与业务逻辑代码耦合性强,而AOP就很好的解决了这个问题。在Android中实现AOP的方式有多种,这里将讲述其中比较常用的实现-AspectJ

2.1.2、方式二、AOP-AspectJ

AspectJ是AOP的具体实现方式之一,它针对于横切关注点进行处理。而作为AOP的具体实现之一的AspectJ,它向Java中加入了连接点(Join Point)这个概念。它向Java语言中加入少许新结构,比如:切点(pointcut)、通知(Advice)、类型间声明(Inter-type declaration)和方面(Aspect)。切点和通知动态地影响程序流程,类型间声明则是静态的影响程序的类等级结构,而方面则是对所有这些新结构的封装。

那么就下来就使用AspectJ进行计算操作。

添加依赖

build.gradle

dependencies {
    classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10'
}
复制代码

app#build.gradle

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'android-aspectjx'
}
...
dependencies {
    implementation 'org.aspectj:aspectjrt:1.8.+'
}
复制代码

新建class类,加上@Aspect注解表示当前类是为一个切面供容器读取。

@Aspect
class PerformanceAOP {
}
复制代码

接下来就开始针对需求,编写逻辑代码。我们的需求是计算每个方法的执行时间,则使用@Around以及JoinPoint对方法做统一处理。

@Around("call(* com.fuusy.fuperformance.App.**(..))")
fun getMethodTime(joinPoint: ProceedingJoinPoint) {
    val signature = joinPoint.signature
    val time: Long = System.currentTimeMillis()
    joinPoint.proceed()
    Log.d(TAG, "${signature.toShortString()} speed time = ${System.currentTimeMillis() - time}")
}
复制代码

运行看看效果:

21:05:44.504 3597-3597/com.fuusy.fuperformance D/PerformanceAOP: App.initRouter() speed time = 2009
21:05:45.104 3597-3597/com.fuusy.fuperformance D/PerformanceAOP: App.initBugly() speed time = 599
21:05:45.112 3597-3597/com.fuusy.fuperformance D/PerformanceAOP: App.initLoadSir() speed time = 8
复制代码

三、启动优化手段

对于app启动速度的优化,应用层所能做的只有干预其Application和Activity里的业务逻辑。比如在Application里,经常在onCreate中初始化第三方框架,这无疑是耗时的。那具体的优化操作该怎么做?

启动优化主要有两个方向,异步执行、延迟执行。

3.1、异步执行

3.1.1、开启子线程

说到异步处理逻辑,第一反应是不是开启子线程?那么就来实战一下吧。还是在Application中模拟耗时操作,这次我会创建一个线程池,在线程池中执行三方框架的初始化。

override fun onCreate() {
        super.onCreate()
 
        TimeMonitorManager.instance?.startMonitor()
        //异步方法一、创建线程池
        val newFixedThreadPool = Executors.newFixedThreadPool(CORE_POOL_SIZE)
        newFixedThreadPool.submit {
            initRouter()
        }
        newFixedThreadPool.submit {
            initBugly()
        }
        newFixedThreadPool.submit {
            initLoadSir()
        }

        TimeMonitorManager.instance?.endMonitor("APP onCreate")
    }
复制代码

看看执行时间

//总时间
com.fuusy.fuperformance D/TimeMonitorManager: APP onCreate: 45
//单个方法执行时间
com.fuusy.fuperformance D/PerformanceAOP: App.initLoadSir() speed time = 8
com.fuusy.fuperformance D/PerformanceAOP: App.initBugly() speed time = 678
com.fuusy.fuperformance D/PerformanceAOP: App.initRouter() speed time = 1768
复制代码

单个方法initLoadSir执行时间为8毫秒,initBugly为678毫秒,initRouter为1768毫秒,而使用线程池后,onCreate执行的总时间只有45毫秒,2400毫秒到45毫秒,速度提升了90%多。这效果无疑是显著的。

但是, 实际项目中业务是复杂的,线程池的方案也cover不住所有的情况,比如一个第三方框架只能在主线程中初始化,比如一个框架只有先在onCreate中初始化完成,才能继续下一步。那么这些情况下又改如何处理?

如果方法只能在主线程中执行,那就只能放弃子线程的方式;

如果方法需要在特定阶段就要完成,可以使用CountDownLatch这么一个同步辅助工具。

CountDownLatch是一种通用的同步工具,可用于多种目的。 计数为 1 的CountDownLatch用作简单的开/关锁存器或门:调用await所有线程在门处等待,直到它被调用countDown的线程countDown 。 初始化为N的CountDownLatch可用于使一个线程等待,直到N 个线程完成某个操作,或者某个操作已完成 N 次。CountDownLatch一个有用属性是它不需要调用countDown线程在继续之前等待计数达到零,它只是阻止任何线程通过await直到所有线程都可以通过。

说的通俗一点,CountDownLatch是用来等待子线程完成,然后才让程序继续下一步操作的工具类。 那么来实战看看。

创建一个CountDownLatch并计数为1,模拟initBugly方法需要等待。

class App : Application() {
    //创建CountDownLatch
    private val countDownLatch: CountDownLatch = CountDownLatch(1)
    
    override fun onCreate() {

          ...
         newFixedThreadPool.submit {
              initBugly()
              //执行countDown
              countDownLatch.countDown()
         }
         //await
         countDownLatch.await()
         TimeMonitorManager.instance?.endMonitor("APP onCreate")
     }
}
复制代码

重新启动APP

com.fuusy.fuperformance D/PerformanceAOP: App.initBugly() speed time = 642
com.fuusy.fuperformance D/TimeMonitorManager: APP onCreate: 667
复制代码

可以看到最后总时间是等待initBugly执行完成后才执行,启动时间也就加长了。

从上面说明就可以知道开启线程池的方式只能应对一般情况,遇到复杂的逻辑就出现弊端了。例如当两个任务之间出现依赖关系,又该如何处理?同时发现,每针对一个方法,都需要提交一个Runnable任务以供执行,这无疑也是在消耗资源。

既能够异步操作、又能解决任务之间的依赖关系,同时执行代码更加优雅的方式有没有?当然有,接下来就提供一种更优雅的异步手段-有向无环图启动器

3.1.2、有向无环图启动器

在实际项目中,任务的执行是有先后顺序的,比如说在进行微信支付SDK初始化时,需要从后台先拿到对应的App密钥,再依据这个密钥进行支付的初始化操作。

对于任务执行顺序的问题,有一种数据结构可以很好解决,那就是有向无环图。先来看一下有向无环图的具体说明。

3.1.2.1、有限无环图(DAG)

有向无环图:若一个有向图中不存在环,则称为有向无环图图,也称为DAG图。

有向无环图图.png

上图就是一个有向无环图图,两个顶点之间不存在相互指向的边。若该图中B->A那么就存在环了,便不是有向无环图。

那么启动优化和这有什么关系?

在上面说过,DAG图所要解决的便是任务之间的依赖关系。而解决这个问题,其实还涉及到一个知识点AOV网(Activity On Vertex Network)

3.1.2.2、AOV网(Activity On Vertex Network)

AOV网是用顶点表示活动的网,是DAG典型的应用之一。用DAG作为一个工程,顶点表示活动,有向边<Vi,Vj>则表示活动Vi必须先于活动Vj进行。如上面有向无环图,B必须在A后面执行,D必须优先于E执行,各顶点之间存在先后执行的关系。

这恰恰和启动任务的依赖关系不谋而合,只要通过AOV网的执行方式去执行启动任务,也就解决了启动任务的依赖关系问题。

在AOV网中,找到任务执行的先后顺序,就要用到拓扑排序

3.1.2.3、拓扑排序

拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面,每个AOV网都有一个或多个拓扑排序。而拓扑排序实现步骤也很简单,如下:

拓扑排序的实现:

  1. 从AOV网中选择一个没有前驱(入度为0)的顶点并输出;
  2. 从网中删除该顶点和所有以它为起点的有向边;
  3. 重复1和2的操作,直到当前AOV网为空或者当前网中不存在无前驱的顶点为止。

举个生活中泡茶的案例。

AOV案例.png

如上图,就是一个泡茶的有向无环图,而对于拓扑排序的实现,我们就按照上述步骤执行:

  1. 找到入度为0的顶点,这里入度为0的顶点只有“准备茶具”和“买茶叶”,随便选择其中一个“准备茶具”;
  2. 去掉“准备茶具”这个顶点且去除以它为起点的边,也就变为下图:

微信截图_20210815222604.png

  1. 这个时候只有“买茶叶”顶点入度为0,则选择该顶点,且重复1和2的操作。

如此反复,最后顶点执行的顺序如下:

输出.png

当然,拓扑排序最后的结果有多种,例如这里一开始可以选择入度为0的“买茶叶”顶点作为初始任务,结果就变了,这里就不做详细讨论。

上面有向无环图、AOV网以及拓扑排序已经说明清楚,接下来就是与启动任务相结合。其实就是按照拓扑排序的规则将任务按顺序执行。

/**
 * 拓扑排序
 */
fun topologicalSort(): Vector<Int> {
    val indegree = IntArray(mVerticeCount)
    for (i in 0 until mVerticeCount) { //初始化所有点的入度数量
        val temp = mAdj[i] as ArrayList<Int>
        for (node in temp) {
            indegree[node]++
        }
    }
    val queue: Queue<Int> = LinkedList()
    for (i in 0 until mVerticeCount) { //找出所有入度为0的点
        if (indegree[i] == 0) {
            queue.add(i)
        }
    }
    var cnt = 0
    val topOrder = Vector<Int>()
    while (!queue.isEmpty()) {
        val u = queue.poll()
        topOrder.add(u)
        for (node in mAdj[u]) { //找到该点(入度为0)的所有邻接点
            if (--indegree[node] == 0) { //把这个点的入度减一,如果入度变成了0,那么添加到入度0的队列里
                queue.add(node)
            }
        }
        cnt++
    }
    check(cnt == mVerticeCount) {  //检查是否有环,理论上拿出来的点的次数和点的数量应该一致,如果不一致,说明有环
        "Exists a cycle in the graph"
    }
    return topOrder
}

复制代码

具体处理启动任务的启动器可直接去github中查看FuPerformance

实现启动器后,在Application或者Activity中的基本使用如下:

  1. 将每个任务单独拎出来,在子线程中执行继承Task抽象类,如初始化ARouter;
class RouterTask() : Task() {
    override fun run() {
        if (BuildConfig.DEBUG) {
            ARouter.openLog()
            ARouter.openDebug()
        }
        ARouter.init(mContext as Application?)
    }
}
复制代码

如果必须在主线程中执行则继承MainTask,如果需要等待该任务执行完毕才能进行下一步,则需要实现needWait方法,返回true。

override fun needWait(): Boolean {
    return true
}
复制代码
  1. 如果任务之间存在依赖关系,则需要实现dependsOn方法,例如微信支付需要依赖于AppId的获取。
class WeChatPayTask :Task(){

    /**
     * 微信支付依赖AppId
     */
    override fun dependsOn(): List<Class<out Task?>?>? {
        val task = mutableListOf<Class<out Task?>>()
        //添加AppID的获取Task
        task.add(LoadAppIdTask::class.java)
        return task
    }

    override fun run() {
        //初始化微信支付
    }
}
复制代码
  1. 将任务分别处理后,最后在Application的onCreate中添加任务队列。
//方式二、启动器
TaskDispatcher.init(this)

TaskDispatcher.newInstance()
    .addTask(RouterTask())
    .addTask(LoadSirTask())
    .addTask(BuglyTask())
    .addTask(LoadAppIdTask())
    .addTask(WeChatPayTask())
复制代码

这就是有向无环图启动器的实现与使用,可以发现它既使得代码变得优雅,又解决了一开始所提到的几个痛点:

  • 子线程中任务的依赖问题;
  • 任务在子线程中执行时必须等待其执行完的问题;
  • 设定在主线程中执行。
  • 代码高耦合且资源浪费的问题。

3.2、延迟执行

第二部分的优化方式就是延迟执行,实现延时执行操作有多种方法:

  • 线程休眠
object : Thread() {
    override fun run() {
        super.run()
        sleep(3000) //休眠3秒
        /**
         * 要执行的操作
         */
    }
}.start()
复制代码
  • Handler#postDelayed
handler.postDelayed(
    Runnable {
        /**
         * 要执行的操作
         */
    }, 3000
)
复制代码
  • TimerTask实现
val task: TimerTask = object : TimerTask() {
    override fun run() {
        /**
         * 要执行的操作
         */
    }
}
val timer = Timer()
timer.schedule(task, 3000) //3秒后执行TimeTask的run方法
复制代码

这三种方式都可以实现延时操作,但应用到启动任务中,它们都有一个共同的痛点-无法确定延时时长。

那如何解决这个痛点?

可以利用Handler中的IdleHandler机制。

3.2.1、IdleHandler

在启动的过程中,其实存在一些任务不是App启动后就必须马上执行,这种情况下就需要我们找到合适的时机再去执行任务。那这个时间该如何查找?Android其实给我们提供了一个很好的机制。在Handler机制中,提供了一种在消息队列空闲时,执行任务的时机-IdleHandler

IdleHandler主要用在当前线程消息队列空闲时。可能你想问,如果消息队列一直不空闲,IdleHandler就一直得不到执行,那又该如何?因为IdleHandler的开始时间的不可控性,实际就需要结合项目业务来使用。

依据IdleHandler的特性,实现一个IdleHandler启动器,如下:

class DelayDispatcher {
    private val mDelayTasks: Queue<Task> = LinkedList<Task>()

    private val mIdleHandler = IdleHandler {
        if (mDelayTasks.size > 0) {
            val task: Task = mDelayTasks.poll()
            DispatchRunnable(task).run()
        }
        !mDelayTasks.isEmpty()
    }

    /**
     * 添加延时任务
     */
    fun addTask(task: Task): DelayDispatcher? {
        mDelayTasks.add(task)
        return this
    }

    fun start() {
        Looper.myQueue().addIdleHandler(mIdleHandler)
    }
}
复制代码

使用

DelayDispatcher().addTask(Task())?.start()
复制代码

3.3、其他方案

  • 提前加载SharedPreferences;
  • 启动阶段不启动子进程;
  • 类加载优化
  • I/O 优化

张邵文在开发高手课中提到:

在负载过高的时候,I/O 性能下降得会比较快。特别是对于低端机,同样的 I/O 操作耗时可能是高端机器的几十倍。启动过程不建议出现网络I/O,而磁盘 I/O优化就要清楚启动过程读了什么文件、多少个字节、Buffer 是多大、使用了多长时间、在什么线程等一系列信息。

  • 类重排

启动过程类加载顺序可以通过复写 ClassLoader得到

class GetClassLoader extends PathClassLoader {
    public Class<?> findClass(String name) {
        // 将 name 记录到文件
        writeToFile(name,"coldstart_classes.txt");
        return super.findClass(name);
    }
}
复制代码

然后利用Facebook开源的Dex优化工具整类在Dex中的排列顺序。

ReDex是一个Android字节码(dex)优化器,最初由Facebook开发。它提供了一个用于读取、写入和分析.dex文件的框架,以及一组使用该框架改进字节码的优化传递.

  • 资源文件重排

关于资源文件重排的原理以及落地方案可参考支付宝App构建优化解析:通过安装包重排布优化 Android 端启动性能

3.4、黑科技

  • 启动阶段抑制GC

支付宝使用了这种方式,可直接参考支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」

  • CPU锁频

CPU的工作频率越高,运算就越快,但是能耗就越高,为了启动速度提升,拉伸CPU频率,速度快了,但是手机能耗也更快了。

四、总结

上文介绍了一些与业务相关的优化手段以及一些与业务无关的黑科技,能够有效的提升App的启动速度。启动优化的方案有很多,但还需要我们结合实际项目情况进行方案判断并落地。

最后,性能优化是一个长期的过程,我将开一个性能优化理论与实践系列,主要涉及启动、内存、卡顿、瘦身、网络等优化,请持续关注。

  • 启动优化

项目地址: fuusy/FuPerformance

参考资料:

支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」
支付宝App构建优化解析:通过安装包重排布优化 Android 端启动性能
国内Top团队大牛带你玩转Android性能分析与优化
Android开发高手课
轻量级APP启动信息构建方案

推荐阅读:

「性能优化系列」APP启动优化理论与实践(上)

分类:
Android
标签: