Android开发Android开发经验谈Android开发

关于 Android启动优化你应该了解的知识点

2022-08-04  本文已影响0人  Android技术圈

一、启动优化概念

1.1、为什么要做启动优化?

APP优化是我们进阶高级开发工程师的必经之路,而APP启动速度的优化,也是我们开启APP优化的第一步。用户在使用我们的软件时,交互最多最频繁的也就是APP的启动页面,如果启动页面加载过慢,很可能造成用户对我们APP的印象过差,进而消耗了用户的耐心,更严重可能导致用户的卸载行为。这也是微信始终坚持使用“一个小人望着地球”作为启动页面的背景,并且坚持不添加启动广告的的原因。

1.2、启动分类

冷启动: 特点是耗时最多,同时它也是衡量标准,我们在线上做的各种优化都是以它作为标准,从下面这张图片可以看出冷启动它经历了一系列的流程,所以它的耗时也是最多的。

热启动: 特点是最快,我们所说的热启动是指app从后台切换到前台,它没有application的创建和各种生命周期的调用,所以说这种启动方式是最快的。

温启动: 特点是较快,它的速度介于冷启动和热启动之间,对于这种方式它会重走activity的生命周期,不会重走进程的创建,application的创建和生命周期等流程。

1.3、相关任务

冷启动之前:

  1. 启动App;
  2. 加载空白Window;
  3. 创建进程。

这三个任务都是系统行为,无法进行真正的干预。网上大多介绍启动优化的都是针对第2条,但其实这是一个假的干预,只是对我们肉眼感知上的一个优化。

之后进行的是:

  1. 创建Application;
  2. 启动主线程;
  3. 创建MainActivity;
  4. 加载布局;
  5. 布置屏幕;
  6. 首帧绘制。

我们的优化方向: Application和Activity生命周期的这个阶段,这是开发者真正可以控制的时间。

二、启动时间测量方式

这里介绍两种启动时间的测量方式:

  1. adb命令
  2. 手动打点

2.1、adb命令

这种方式是我们通过在终端输入一条adb命令,然后它会打开我们要测试的app,同时进行结果的输出。具体的命令如下:

adb shell am start -W packagename/首屏Activity(这里需要使用全类名)

这里我以自己写的一个简单的列表展示的Demo工程举例说明:

ThisTime:最后一个Activity启动耗时

TotalTime:所有Activity启动耗时(这里ThisTime和TotalTime值是一致的,因为我的Demo中只有一个MainActivity)

WaitTime:AMS启动Activity的总耗时,对于一个通用的app(包含SplashActivity),ThisTime肯定是小于TotalTime的,即:

ThisTime < TotalTime < WaitTime

总结:这种方式线下使用方便,可以使用这种方式测量竞品为竞品分析提供需要的数据,不能带到线上,并且测量出来的时间也是一个非严谨精确的时间。

2.2、手动打点

这种方式是在app启动开始时埋点,启动结束时埋点,然后计算二者差值。

实际使用中,一般将开始时间这个点埋在Application的attachBaseContext(Context base)这个方法中,这是整个应用所能接收到的最早的回调时机。开始时间有了,那么结束时间该怎么计算呢,也就是我们应该把结束时间这个点埋在什么位置呢?网上很多资料里都会说是在onWindowFocusChanged()这个方法里做启动结束的时间计算,但是实际上写在这里其实是有问题的。

误区:onWindowFocusChanged它只是Activit的首帧时间,是activity首次绘制的时间,并不能代表activity已经展现出来。我们做性能优化的目的是为了改善用户的体验,并不是单纯的为了把启动时间缩短,因为这样做是不准确的,我们需要的是用户真正看到界面的时间,所以正确的情况应该是在真实的数据展示(一般取第一条)出来,才算结束的时间节点。

下面我们就来实战一下该如何在代码中埋点统计启动时间? 首先我们定义一个工具类LaunchTime,用来计算差值时间:

package com.jarchie.performance.utils;
 
import android.util.Log;
 
/**
 * 描述: 打点计算启动时间
 */
public class LaunchTime {
 
    private static long sTime;
 
    public static void startRecord() {
        sTime = System.currentTimeMillis();
    }
 
    public static void endRecord(String msg) {
        long cost = System.currentTimeMillis() - sTime;
        Log.i(msg, "--->cost" + cost);
    }
 
}

然后在Application中埋下开始时间点:

@Override
protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    LaunchTime.startRecord();
}

然后在我们列表适配器中的onBindViewHolder中绑定数据时统计第一条Item展示出来的时间点:

if (position ==0 && !mHasRecorded){
            mHasRecorded = true;
            holder.mAllLayout.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
                @Override
                public boolean onPreDraw() {
                    holder.mAllLayout.getViewTreeObserver().removeOnPreDrawListener(this);
                    LaunchTime.endRecord("FirstShow");
                    return true;
                }
            });
        }

最后我们在MainActivity中的onWindowFocusChanged()方法中统计一下Activity的首帧时间:

@Override
public void onWindowFocusChanged(boolean hasFocus) {
    super.onWindowFocusChanged(hasFocus);
    LaunchTime.endRecord("onWindowFocusChanged");
}

现在来运行我们的程序,看一下最终统计出来的时间值究竟是多少?

由上面的结果可以看出首帧时间是904毫秒,列表数据的第一条展示的时间是1573毫秒,两者之间的时间差值是超过200毫秒的,这也就表明如果我们仅以Activity的首帧时间作为启动结束,那么这个时间明显是偏早的,不符合我们做启动优化的初衷。

三、启动优化工具

以下所介绍的两种方式是互相补充的,我们需要正确认识工具并且能够在不同的场景下选择适合的工具。

3.1、traceview

特点:

使用方式:在代码中需要做性能分析的地方开始位置和结束位置插入以下代码

代码实战:

将我们的项目运行之后(注意开启运行时权限,这部分不是本节重点,我直接到应用里将权限开启了)生成文件如下:

将生成的文件打开,如下图所示,可以看到Threads中是这个应用所有的线程数,我们可以看到线程总数以及对应的每个线程在具体的时间都做了哪些操作,然后下面有四个Tab可以切换,首先来看Call Chart,可以看到在具体的每一行都指向了具体的函数调用,将鼠标移到对应的每一行上面都有具体的执行时间等信息,沿着垂直方向看是具体的调用者,比如a调用b,则a在上方b在下方,而且不同的api它的颜色也是不一样的,对于系统api是橙色的,对于应用自身的函数调用颜色是绿色的,对于第三方api调用颜色是蓝色的(包括java语言的api)。

接着来看Flame Chart(又叫火焰图),它是一个倒置的调用图表,一般来说它的作用没有第一个大,它会收集相同的调用方顺序完全相同的函数,比如a调b调c并且调用了多次,它会将它们收集在一起:

下面这张图是Top Down,它比较直观的展示了函数的调用列表,比如下图中首先main()函数调用了init(),init()又调用了g()等等,相当于Call Chart详细版,并且你将鼠标放在对应函数上右键有个Jump to Source可以跳转到具体的代码中。

Total Time是某个函数执行的总时间,Self Time是该函数体内部自有代码执行的时间,Childre Time是该函数内部调用别的函数所需的时间,后面二者的时间总和一定是等于前面的Total Time的,这点需要注意。Self Time上方有一栏下拉菜单,即我们第一张图中红色标注的菜单栏WallClockTime和ThreadTime,前者是这段代码执行所消耗的时间,后者是CPU执行的时间,一般情况下是前者大于后者,因为一般情况下某个函数消耗的时间并不等于CPU真正消耗的时间。

最后是Bottom Up,这个的作用也是比较小了,它和Top Down是相反的,它会告诉你某个函数具体是谁调用了它:

总结:一般比较关注的是Call Chart和Top Down

3.2、systrace(python脚本)

特点:

使用方式:

我这里放一张我自己运行的示例,仅供参考:

代码实战:

首先将项目运行让我们写的代码生效,然后运行我们的python脚本,启动tracing之后,点击我们的app让它开始收集信息,tracing完成之后到对应的目录就会发现已经生成了我们的Performance.html文件,我们到浏览器中打开,如下图所示:

由上图中左侧可以看到有CPU的核心数,往下滑动还可以看到各个线程名称,然后还可以根据代码中打的Tag来搜索,下方会展示比较详细的trace信息(上图中也举例说明了,需要注意的Wall Time和CPU Time红色部分圈出),点到右侧图中具体的位置都会展示出比较详细的方法名称执行时间等信息。

总结:

关于上述工具的详细使用方法大家可以自行百度或者谷歌查找相关资料,认真学习一下这些分析工具的使用。

四、优雅获取方法耗时

4.1、常规方式

我们在做启动优化的时候通常需要知道启动阶段所有方法的耗时,这样可以有针对性的分析出耗时较多的方法。一般的实现方式就是通过手动埋点来实现,比如在某个方法开始和结束的位置分别插入以下代码:

long time = System.currentTimeMillis();
initJpush();
long cost = System.currentTimeMillis() - time;
//或者可以使用这行代码:SystemClock.currentThreadTimeMillis(); 
//CPU真正执行的时间

当有多个方法需要埋点时,同理这样写就可以获取到每个方法的执行时间了,但是这样操作存在的问题也是显而易见的,当然我相信你肯定也发现了,主要总结为以下几点:

那么针对这种方式的劣势,如何才能更加优雅的实现获取方法的耗时呢?答案就是采用AOP的方式来实现。

4.2、AOP介绍

AOP简介:Aspect Oriented Programming,面向切面编程

AspectJ简介:它就是辅助AOP用来实现切面编程

使用时首先需要添加如下的依赖:

//工程目录下的build.gradle
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.0'
//app module目录下的build.gradle
apply plugin: 'android-aspectjx'
implementation 'org.aspectj:aspectjrt:1.8.14'

添加完了依赖之后,再来介绍一下相关知识点,然后我们再到代码中去真正的使用它。

Join Points:程序运行时的执行点,常用的可以作为切面的地方如下所示:

PointCut:带条件的JoinPoints

Advice:一种Hook,要插入代码的位置

语法简介:

代码实现:

/**
 * 说明:使用AOP方式来统计方法耗时
 */
@Aspect //通过该注解,AOP框架可以知道该类即是需要需要插入的代码
public class PerformanceAop {
 
    @Around("call(* com.jarchie.performance.app.BaseApp.**(..))") //匹配规则
    public void calculateTime(ProceedingJoinPoint joinPoint) {
        Signature signature = joinPoint.getSignature(); //拿到切点签名
        String name = signature.toShortString(); //拿到对应的方法信息
        long time = System.currentTimeMillis();
        try {
            joinPoint.proceed(); //手动执行
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        Log.i("执行时间", name + "--->>>" + (System.currentTimeMillis() - time));
    }
 
}

可以看到这里是新建了一个类采用AOP的方式来获取方法耗时,并没有在BaseApp中添加任何的代码,运行结果如下所示:

总结:

采用AOP实现:

  1. 无侵入性
  2. 修改方便

五、异步优化

5.1、Theme切换

首先需要说明的是这种方式仅仅是给用户感官上的快,just a feeling,对应用真实的启动速度没有任何的影响。它的实现原理是App在打开首屏Activity之前会首先显示出一张图片,当Activity页面真正展示出来之后再把Theme改变回来,因为冷启动中有一步是创建一个空白的Window,这种实现方式正式利用了这个空白的Window。下面来看下具体怎么操作:

首先定义一个背景drawable,这里起名为launcher.drawable:

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
    android:opacity="opaque">
    <item android:drawable="@color/colorPrimary" />
    <item>
        <bitmap
            android:gravity="center"
            android:src="@drawable/liying" />
    </item>
</layer-list>

然后在styles中定义一个主题作为启动主题:

    <style name="Theme.Splash" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="android:windowBackground">@drawable/launcher</item>
        <item name="windowActionBar">false</item>
        <item name="android:windowNoTitle">true</item>
        <item name="windowNoTitle">true</item>
        <item name="android:windowFullscreen">true</item>
    </style>

然后在首屏Activity的清单文件中设置这个主题:

        <activity android:name=".MainActivity"
            android:theme="@style/Theme.Splash">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
 
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

最后在首屏Activity的onCreate()方法中调用父类onCreate()方法之前将设置的启动主题改为默认主题:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        setTheme(R.style.AppTheme);
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ...
    }

来看下我们修改后的效果,如下图所示:

5.2、常规异步优化

核心思想:子线程分担主线程任务,并行减少时间

下面还以Application的onCreate()为例分析常规的异步优化:现在的App一般情况下都是运行在八核的设备上,不同的设备厂商可能分配给应用的核数有的四核有的八核,但是如果像我们这里的代码将所有的初始化工作都放在一个线程中最多占用一个核,别的三个核或者七个核都处于一个浪费状态,那么为了让CPU的利用率达到一个更加高效的状态,这里就需要使用异步初始化了。

说到异步,那大家想到的肯定是要创建子线程了,这里使用线程池来创建线程,这种方式更加优雅,不仅可以在很大程度上避免内存泄露,而且还可以让线程得到复用(这里线程数的设置是参考了Android AsyncTask源码中的设计):

private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
private static final int CORE_POOL_SIZE = Math.max(2,Math.min(CPU_COUNT-1,4));
 
@SuppressLint("MissingPermission")
@Override
public void onCreate() {
    super.onCreate();
    LaunchTime.startRecord();
    mApplication = this;
    ExecutorService service = Executors.newFixedThreadPool(CORE_POOL_SIZE);
    service.submit(this::initDeviceId);
    service.submit(this::initJpush);
    service.submit(this::initBugly);
    LaunchTime.endRecord("AppOnCreate");
}

来看下运行结果:

可以看到时间确实是非常短的,那现在有个问题:是不是以后代码都可以放在子线程中执行呢?答案当然是否定的,有些场景下并不能很好的实现异步的方案,比如:①有些代码必须要在主线程中执行;②有些方法必须在onCreate()方法结束后执行完毕。

针对上面这两种情况,异步的方案其实就不太好解决了,对于第一种情况你只能放弃异步方案,对于第二种情况,我们可以采用CountDownLatch这个类来解决,下面这段代码的含义大致就是:只要countDownLatch不被满足,它将一直处于等待状态,直到被满足1次,因为我们构造函数中传入的数值是1:

private CountDownLatch mCountDownLatch = new CountDownLatch(1);
ExecutorService service = Executors.newFixedThreadPool(CORE_POOL_SIZE);
        service.submit(() -> {
            initBugly();
            mCountDownLatch.countDown();
        });
        try {
            mCountDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

异步优化注意事项:

5.3、启动器

通过上面的常规异步操作过程可以发现还是存在很多问题的,主要有以下几点:

正是因为有上面这些问题的存在,才有了下面的解决方案的产生——启动器。

推荐阿里开源的一个启动器库alpha:github.com/alibaba/alp…

启动器介绍:

核心思想:充分利用CPU多核,自动梳理任务顺序

启动器流程:

启动器流程图:

代码实战:首先构建启动器部分的代码因为这个过程还是有点复杂的,代码相对也不少,这里就不贴了,大家可以自行百度启动器相关的实现代码,这里只针对使用情况做一个说明:

首先我们需要将上面做异步操作的几个方法抽成对应的任务,比如这里InitBuglyTask这个任务就是对应用来解决需要在特定阶段完成初始化的问题,重写needWait()方法设置为true即需要等待,并且MainTask是运行在主线程的:

public class InitBuglyTask extends MainTask {
 
    //解决特定阶段执行完成问题
    @Override
    public boolean needWait() {
        return true;
    }
 
    @Override
    public void run() {
        CrashReport.initCrashReport(mContext, "e296ad7fc8", false);
    }
}

然后定义InitDeviceIdTask这个用来获取设备ID的任务,该任务是在子线程执行的:

public class InitDeviceIdTask extends Task {
    private String mDeviceId;
 
    @SuppressLint("MissingPermission")
    @Override
    public void run() {
        //真正自己的代码
        TelephonyManager tManager = (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE);
        mDeviceId = tManager.getDeviceId();
    }
}

然后定义初始化极光推送的任务InitJpushTask,重写dependsOn()方法用来解决依赖关系的问题,该任务的执行依赖于设备ID:

public class InitJpushTask extends Task {
 
    //解决依赖关系问题
    @Override
    public List<Class<? extends Task>> dependsOn() {
        List<Class<? extends Task>> task = new ArrayList<>();
        task.add(InitDeviceIdTask.class);
        return task;
    }
 
    @Override
    public void run() {
        //推送
        JPushInterface.init(mContext);
    }
}

然后将这些任务添加到启动器里面即可,代码看起来还是比较美观的:

LaunchTime.startRecord();
TaskDispatcher.init(this);
TaskDispatcher dispatcher = TaskDispatcher.createInstance();
dispatcher.addTask(new InitBuglyTask())
    .addTask(new InitJpushTask())
    .addTask(new InitDeviceIdTask())
    .start();
dispatcher.await();
LaunchTime.endRecord("AppOnCreate");

OK,通过上面这几行代码启动器就搞定了,可见它相比较于传统的异步方式还是好处多多啊,最后来看下运行结果吧:

六、延迟初始化

6.1、常规方案

对于实际项目经验较多的朋友你会发现其实在Application或者MainActivity中有些任务它的优先级并不是很高,所以对于这类任务通常都可以将它们进行延迟初始化,一般都是延迟到列表数据展示之后再进行加载。我们首先来看下常规的方案是如何实现的呢?最简单的做法就是将代码移到列表显示之后进行调用,或者是通过new Handler().postDelayed延迟一个时间调用。即:

下面我们在代码中举个栗子说明一下这种方案是如何实现的?

这里首先定义一个回调接口是在列表展示出来之后的回调:

public interface OnFeedShowCallBack {
    void onFeedShow();
}

然后在列表适配器中定义这个接口,并给它一个setXXX()方法,并且在列表item第一条展示出来之后回调这个接口:

private OnFeedShowCallBack mCallBack;
...
public void setOnFeedShowCallBack(OnFeedShowCallBack callBack){
    this.mCallBack = callBack;
}
...
if (mCallBack!=null){
    mCallBack.onFeedShow();
}

接着在MainActivity的onCreate()中设置这个回调,并且让MainActivity实现回调接口重写回调方法,在回调方法中模拟执行两个Task,整个这个流程如果熟悉接口回调机制的兄弟应该很好理解了:

mAdapter.setOnFeedShowCallBack(this);
...
@Override
public void onFeedShow() {
    //模拟执行了两个Task,TaskA和TaskB
    new DispatchRunnable(new DelayInitTaskA()).run();
    new DispatchRunnable(new DelayInitTaskB()).run();
}

以上就是常规方案的实现方法,大家仔细思考一下会发现这其中是有很多问题的:首先,我们的列表展示是发生在主线程中,直接执行mCallBack.onFeedShow()方法,会跑到MainActivity重写的onFeedShow()中,如果模拟的任务执行时间较长,那么主线程就会相应的卡住对应的时长,如果此时用户滑动列表很明显会造成列表滑动卡顿,给用户的体验就很不好了。如果你采用new Handler().postDelayed发送延时消息来处理,当然一定程度上是可以缓解这种卡段,但是这种方案总结下来延时的时机不太好控制并且如果任务数量较多也不易维护,所以我们需要去寻求更加优雅的解决方案。

6.2、优雅实现延迟初始化

核心思想: 对延迟任务进行分批初始化,这里利用IdleHandler特性,空闲执行

针对这种方案我们在代码中来实践一下看看具体该如何操作?

首先来创建一个针对延迟初始化任务执行的启动器:

public class DelayInitDispatcher {
 
    //创建任务队列
    private Queue<Task> mDelayTasks = new LinkedList<>();
 
    //IdleHandler分批处理并在系统空闲时执行
    private MessageQueue.IdleHandler mIdleHandler = new MessageQueue.IdleHandler() {
        @Override
        public boolean queueIdle() { //系统空闲时回调
            if(mDelayTasks.size()>0){
                Task task = mDelayTasks.poll(); //分批执行,每次只取一个Task
                new DispatchRunnable(task).run();
            }
            return !mDelayTasks.isEmpty(); //DelayTasks为空则移除
        }
    };
 
    //添加任务
    public DelayInitDispatcher addTask(Task task){
        mDelayTasks.add(task);
        return this;
    }
 
    //启动
    public void start(){
        Looper.myQueue().addIdleHandler(mIdleHandler);
    }
 
}

具体的代码含义都加了注释了,主要就是利用了IdleHandler的特性在空闲时期执行,接着在onFeedShow()的回调中添加任务并执行即可:

@Override
public void onFeedShow() {
    DelayInitDispatcher delayInitDispatcher = new DelayInitDispatcher();
    delayInitDispatcher.addTask(new DelayInitTaskA())
        .addTask(new DelayInitTaskB())
        .start();
}

通过代码我们来比对一下两种方案的差别:对于常规方案回调接口中有多少个任务都会一次性执行完成,也就意味着主线程会卡在那里对应的时间;对于第二种方案,我们是添加了多个任务进来,执行的时机是在系统空闲的时候进行执行,并且一次只执行一个,所以第二种方案的优点就显而易见了:

七、启动优化其他方案

7.1、优化总方针

注意事项:

  1. wall time和cpu time的区别
  1. 监控的完善
  1. 收敛启动代码修改权限

7.2、启动优化其他方案

这一部分只是简单介绍一下其他的启动优化的方案,有些方案实现起来还是比较复杂的,有需要的朋友可以查找相关资料结合自身项目实践一下。

1. 提前加载SharedPreferences:使用之前会调用getSharedPreference()方法,此时会去异步加载文件中它的配置文件xml并将它load到内存之中,当我们put或者get某个属性时如果load没有完成则会阻塞一直等待

2. 启动阶段不启动子进程

3. 类加载优化:提前异步类加载

4. 启动阶段抑制GC(Native Hook)

OK,写到这里相信你已经对Android启动优化有了自己的了解了,可能我这里介绍的不够全面,因为个人能力有限,所以对于哪些说的不够清楚的地方大家就再查找相关的资料进行更加细致的学习吧。

上一篇下一篇

猜你喜欢

热点阅读