美文网首页Android开发Android开发经验谈Android技术知识
字节跳动学习笔记:Android性能优化之启动优化实战篇

字节跳动学习笔记:Android性能优化之启动优化实战篇

作者: jett老师 | 来源:发表于2020-08-08 13:51 被阅读0次

    前言


    本文将带领大家来看看启动优化相关方面的介绍以及各种优化的方法。希望你在读完本章后会有所收获。

    相信很多同学都听过八秒定律,八秒定律是在互联网领域存在的一个定律,即指用户访问一个网站时,如果等待网页打开的时间超过了8秒,就有超过70%的用户放弃等待。足见启动的时间是多么的重要。放到移动APP中,那就是应用启动的时间不能太久,否则就会造成用户的流失。

    谷歌官方曾给出一篇App startup time的文章,这篇文章详细介绍了关于启动优化的切入点以及思路。感兴趣的同学可以去看下。App Startup Time 这是官方地址。本篇文章也主要是官方思路的一个扩展。

    启动分类


    App的启动主要分为:冷启动、热启动和温启动。

    冷启动:
    耗时最多,也是整个应用启动时间的衡量标准。我们通过一张图来看下冷启动经历的流程:

    热启动:

    启动最快,应用直接由后台切换到前台。

    温启动:

    启动较快,是介于冷启动和热启动之间的一种启动方式,温启动只会执行Activity相关的生命周期方法,不会执行进程的创建等操作。

    我们优化的方向和重点主要是冷启动。因为它才是代表了应用从被用户点击到最后的页面绘制完成所耗费的所有时间。下面我们通过一张流程图来看下冷启动相关的任务流程:

    看上面的任务的流程图,读者朋友们觉得哪些是我们优化的方向呢?其实我们能做的只有Application和Activity的生命周期阶段,因为其他的都是系统创建的我们没法干预,比如:启动App,加载空白Window,创建进程等。这里面加载空白Window我们其实可以做一个假的优化就是使用一张启动图来替换空白Window,具体操作我们在下文中介绍。

    启动的测量方式


    这里主要介绍两种方式:ADB命令和手动打点。下面我们就来看下两者的使用以及优缺点。

    ADB命令:

    在Android Studio的Terminal中输入以下命令

    adb shell am start -W packagename/[packagename].首屏Activity
    

    执行之后控制台中输出如下内容:

    Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.optimize.performance/.MainActivity }
    Status: ok
    Activity: com.optimize.performance/.MainActivity
    ThisTime: 563
    TotalTime: 563
    WaitTime: 575
    Complete
    

    其中主要有三个字端:ThisTime、TotalTime和WaitTime,分别解释下这三个字端的含义:

    ThisTime:最后一个Activity启动耗时

    TotalTime:所有Activity启动耗时

    WaitTime:AMS启动Activity的总耗时

    ThisTime和TotalTime时间相同是因为我们的Demo中没有Splash界面,应用执行完Application后直接就开始了MainActivity了。所以正常情况下的启动耗时应是这样的:ThisTime < TotalTime < WaitTime

    这就是ADB方式统计的启动时间,细心的读者应该能想到了就是这种方式在线下使用很方便,但是却不能带到线上,而且这种统计的方式是非严谨、精确的时间。

    手动打点方式:

    手动打点方式就是启动时埋点,启动结束埋点,取二者差值即可。

    我们首先需要定义一个统计时间的工具类:

    class LaunchRecord {
    ​
        companion object {
    ​
            private var sStart: Long = 0
    
            fun startRecord() {
                sStart = System.currentTimeMillis()
            }
    ​
            fun endRecord() {
                endRecord("")
            }
    ​
            fun endRecord(postion: String) {
                val cost = System.currentTimeMillis() - sStart
                println("===$postion===$cost")
            }
        }
    }
    

    启动时埋点我们直接在Application的attachBaseContext中进行打点。那么启动结束应该在哪里打点呢?这里存在一个误区:网上很多资料建议是在Activity的onWindowFocusChange中进行打点,但是onWindowFocusChange这个回调只是表示首帧开始绘制了,并不能表示用户已经看到页面数据了,我们既然做启动优化,那么就要切切实实的得出用户从点击应用图标到看到页面数据之间的时间差值。所以结束埋点建议是在页面数据展示出来进行埋点。比如页面是个列表那就是第一条数据显示出来,或者其他的任何view的展示。

    
    class MyApplication : Application() {
        override fun attachBaseContext(base: Context?) {
            super.attachBaseContext(base)
            //开始打点
            LaunchRecord.startRecord()
        }
    }
    

    我们分别监听页面view的绘制完成时间和onWindowFocusChanged回调两个值进行对比。

    
    class MainActivity : AppCompatActivity() {
    ​
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
    ​
            mTextView.viewTreeObserver.addOnDrawListener {
                LaunchRecord.endRecord("onDraw")
            }
    ​
        }
    ​
        override fun onWindowFocusChanged(hasFocus: Boolean) {
            super.onWindowFocusChanged(hasFocus)
            LaunchRecord.endRecord("onWindowFocusChanged")
        }
    }
    

    打印的数据为:

    ===onWindowFocusChanged===322
    ===onDraw===328
    
    

    可以很明显看到onDraw所需要的时长是大于onWindowFocusChanged的时间的。因为我们这个只是简单的数据展示没有进行网络相关请求和复杂布局所以差别不大。

    这里需要说明下:addOnDrawListener 需要大于API 16才可以使用,如果为了兼顾老版本用户可以使用addOnPre DrawListener来代替。

    手动打点方式统计的启动时间比较精确而且可以带到线上使用,推荐这种方式。但在使用的时候要避开一个误区就是启动结束的埋点我们要采用Feed第一条数据展示出来来进行统计。同时addOnDrawListener要求API 16,这两点在使用的时候需要注意的。

    优化工具的选择


    在做启动优化的时候我们可以借助三方工具来更好的帮助我们理清各个阶段的方法或者线程、CPU的执行耗时等情况。主要介绍以下两个工具,我在这里就简单介绍下,读者朋友们可以线下自己取尝试下。

    TraceView:

    TraceView是以图形的形式展示执行时间、调用栈等信息,信息比较全面,包含所有线程。

    使用:

    开始:Debug.startMethodTracing("name" )
    结束:Debug.stopMethodTracing("" )
    

    最后会生成一个文件在SD卡中,路径为:Andrid/data/packagename/files。

    因为traceview收集的信息比较全面,所以会导致运行开销严重,整体APP的运行会变慢,这就有可能会带偏我们优化的方向,因为我们无法区分是不是traceview影响了启动时间。

    SysTrace:

    Systrace是结合Android内核数据,生成HTML报告,从报告中我们可以看到各个线程的执行时间以及方法耗时和CPU执行时间等。API 18以上使用,推荐使用TraceCompat,因为这是兼容的API。

    使用:

    开始:TraceCompat.beginSection("tag ")
    结束:TraceCompat.endSection()
    

    然后执行脚本:

    python systrace.py -b 32768 -t 10 -a packagename -o outputfile.html sched gfx view wm am app
    

    给大家解释下各个字端的含义:

    • -b 收集数据的大小
    • -t 时间
    • -a 监听的应用包名
    • -o 生成文件的名称

    Systrace开销较小,属于轻量级的工具,并且可以直观反映CPU的利用率。这里需要说明下在生成的报告中,当你看某个线程执行耗时时会看到两个字端分别好似walltime和cputime,这两个字端给大家解释下就是walltime是代码执行的时间,cputime是代码真正消耗cpu的执行时间,cputime才是我们优化的重点指标。这点很容易被大家忽视。

    优雅获取方法耗时

    上文中主要是讲解了如何监听整体的应用启动耗时,那么我们如何识别某个方法所执行的耗时呢?

    我们常规的做法和上文中一样也是打点,如:

    public class MyApp extends Application {
    ​
        @Override
        public void onCreate() {
            super.onCreate();
    ​
            initFresco();
            initBugly();
            initWeex();
        }
    ​
        private void initWeex(){
            LaunchRecord.Companion.startRecord();
            InitConfig config = new InitConfig.Builder().build();
            WXSDKEngine.initialize(this, config);
            LaunchRecord.Companion.endRecord("initWeex");
        }
    ​
        private void initFresco() {
            LaunchRecord.Companion.startRecord();
            Fresco.initialize(this);
            LaunchRecord.Companion.endRecord("initFresco");
        }
    ​
        private void initBugly() {
            LaunchRecord.Companion.startRecord();
            CrashReport.initCrashReport(getApplicationContext(), "注册时申请的APPID", false);
            LaunchRecord.Companion.endRecord("initBugly");
        }
    }
    

    控制台打印:

    =====initFresco=====278
    =====initBugly=====76
    =====initWeex=====83
    

    但是这种方式导致代码不够优雅,并且侵入性强而且工作量大,不利于后期维护和扩展。

    下面我给大家介绍另外一种方式就是AOP。AOP是面向切面变成,针对同一类问题的统一处理,无侵入添加代码。

    我们主要使用的是AspectJ框架,在使用之前呢给大家简单介绍下相关的API:

    • Join Points 切面的地方:函数调用、执行,获取设置变量,类初始化
    • PointCut:带条件的JoinPoints
    • Advice:Hook 要插入代码的位置。
    • Before:PointCut之前执行
    • After:PointCut之后执行
    • Around:PointCut之前之后分别执行

    具体代码如下:

    @Aspect
    public class AOPJava {
    ​
        @Around("call(* com.optimize.performance.MyApp.**(..))")
        public void applicationFun(ProceedingJoinPoint joinPoint) {
    ​
            Signature signature = joinPoint.getSignature();
            String name = signature.toShortString();
            long time = System.currentTimeMillis();
            try {
                joinPoint.proceed();
            } catch (Throwable throwable) {
                throwable.printStackTrace();
            }
    ​
            Log.d("AOPJava", name + " == cost ==" + (System.currentTimeMillis() - time));
    ​
        }
    }
    

    控制台打印结果如下:

    MyApp.initFresco() == cost ==288
    MyApp.initBugly() == cost ==76
    MyApp.initWeex() == cost ==85
    

    但是我们没有在MyApp中做任何改动,所以采用AOP的方式来统计方法耗时更加方便并且代码无侵入性。具体AspectJ的使用学习后续文章来介绍。

    异步优化


    上文中我们主要是讲解了一些耗时统计的方法策略,下面我们就来具体看下如何进行启动耗时的优化。

    在启动分类中我们讲过应用启动任务中有一个空白window,这是可以作为优化的一个小技巧就是Theme的切换,使用一个背景图设置给Activity,当Activity打开后再将主题设置回来,这样会让用户感觉很快。但其实从技术角度讲这种优化并没有效果,只是感官上的快。

    首先现在res/drawable中新建lanucher.xml文件:

    <layer-list xmlns:android="http://schemas.android.com/apk/res/android"
        android:opacity="opaque">
        <item android:drawable="@android:color/white"/>
        <item>
            <bitmap
                android:src="@mipmap/你的图片"
                android:gravity="fill"/>
        </item>
    </layer-list>
    

    将其设置给第一个打开的Activity,如MainActivity:

    <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>
    

    最后在MainActivity中的onCreate的spuer.onCreate()中将其设置会原来的主题:

    override fun onCreate(savedInstanceState: Bundle?) {
            setTheme(R.style.AppTheme)
            super.onCreate(savedInstanceState)
            }
    ​
        }
    

    这样就完成了Theme主题的切换。

    下面我们说下异步优化,异步优化顾名思义就是采用异步的方式进行任务的初始化。新建子线程(线程池)分担主线称任务并发的时间,充分利用CPU。

    如果使用线程池那么设置多少个线程合适呢?这里我们参考了AsyncTask源码中的设计,获取可用CPU的数量,并且根据这个数量计算一个合理的数值。

        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));
    ​
        @Override
        public void onCreate() {
            super.onCreate();
    ​
            ExecutorService pool = Executors.newFixedThreadPool(CORE_POOL_SIZE);
    ​
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    initFresco(); 
                }
            });
    ​
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    initBugly();
                }
            });
    ​
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    initWeex();
                }
            });
    ​
        }
    

    这样我们就将所有的任务进行异步初始化了。我们看下未异步的时间和异步的对比:

    未异步时间:======210
    异步的时间:======3
    

    可以看出这个时间差还是比较明显的。这里还有另外一个问题就是,比如异步初始化Fresco,但是在MainActivity一加载就要使用而Fresco是异步加载的有可能这时候还没有加载完成,这样就会抛异常了,怎么办呢?这里教大家一个新的技巧就是使用CountDownLatch,如:

       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));
    ​
        //1表示要被满足一次countDown
        private CountDownLatch mCountDownLatch = new CountDownLatch(1);
    ​
        @Override
        public void onCreate() {
            super.onCreate();
    ​
            ExecutorService pool = Executors.newFixedThreadPool(CORE_POOL_SIZE);
    ​
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    initFresco();
                    //调用一次countDown
                    mCountDownLatch.countDown();
                }
            });
    ​
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    initBugly();
                }
            });
    ​
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    initWeex();
                }
            });
    ​
            try {
                //如果await之前没有调用countDown那么就会一直阻塞在这里
                mCountDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    ​
        }
    
    

    这样就会一直阻塞在await这里,直到Fresco初始化完成。

    以上这种方式大家觉得如何呢?可以解决异步问题,但是我的Demo中只有三个需要初始化的任务,在我们真实的项目中可不止,所以在项目中我们需要书写很多的子线程代码,这样显然是不够优雅的。部分代码需要在初始化的时候就要完成,虽然可以使用countDowmLatch,但是任务较多的话,也是比较麻烦的,另外就是如果任务之间存在依赖关系,这种使用异步就很难处理了。

    针对上面这些问题,我给大家介绍一种新的异步方式就是启动器。核心思想就是充分利用CPU多核,自动梳理任务顺序。核心流程:

    • 任务代码Task化,启动逻辑抽象为Task
    • 根据所有任务依赖关系排序生成一个有向无环图
    • 多线程按照排序后的优先级依次执行
    TaskDispatcher.init(PerformanceApp.)TaskDispatcher dispatcher = TaskDispatcher.createInstance()dispatcher.addTask(InitWeexTask())
            .addTask(InitBuglyTask())
            .addTask(InitFrescoTask())
            .start()dispatcher.await()LaunchTimer.endRecord()
    

    最后代码会变成这样,具体的实现有向无环图逻辑因为代码量很多,不方便贴出来,大家可以关注公众号获取。

    使用有向无环图可以很好的梳理出每个任务的执行逻辑,以及它们之间的依赖关系

    延迟初始化


    关于延迟初始化方案这里介绍两者方式,一种是比较常规的做法,另外一个是利用IdleHandler来实现。

    常规做法就是在Feed显示完第一条数据后进行异步任务的初始化。比如:

    override fun onCreate(savedInstanceState: Bundle?) {
            setTheme(R.style.AppTheme)
            super.onCreate(savedInstanceState)
    
            mTextView.viewTreeObserver.addOnDrawListener {
                // initTask()
            }
    ​
        }
    
    

    这里有个问题就是更新UI是在Main线程执行的,所以做初始化任务等耗时操作时会发生UI的卡顿,这时我们可以使用Handler.postDelay(),但是delay多久呢?这个时间是不好控制的。所以这种常规的延迟初始化方案有可能会导致页面的卡顿,并且延迟加载的时机不好控制。

    IdleHandler方式就是利用其特性,只有CPU空闲的时候才会执行相关任务,并且我们可以分批进行任务初始化,可以有效缓解界面的卡顿。代码如下:

    public class DelayInitDispatcher {
    ​
        private Queue<Task> mDelayTasks = new LinkedList<>();
    ​
        private MessageQueue.IdleHandler mIdleHandler = new MessageQueue.IdleHandler() {
            @Override
            public boolean queueIdle() {
                if (mDelayTasks.size() > 0) {
                    Task task = mDelayTasks.poll();
                    new DispatchRunnable(task).run();
                }
                return !mDelayTasks.isEmpty();
            }
        };
    ​
        public DelayInitDispatcher addTask(Task task) {
            mDelayTasks.add(task);
            return this;
        }
    ​
        public void start() {
            Looper.myQueue().addIdleHandler(mIdleHandler);
        }
    ​
    }
    
    

    我们在界面显示的后进行调用:

    override fun onCreate(savedInstanceState: Bundle?) {
            setTheme(R.style.AppTheme)
            super.onCreate(savedInstanceState)
    
            mTextView.viewTreeObserver.addOnDrawListener {
                val delayInitDispatcher = DelayInitDispatcher()
                delayInitDispatcher.addTask(DelayInitTaskA())
                        .addTask(DelayInitTaskB())
                        .start()
            }
        }
    

    这样就可以利用系统空闲时间来延迟初始化任务了。

    懒加载


    懒加载就是有些Task只有在特定的页面才会使用,这时候我们就没必要将这些Task放在Application中初始化了,我们可以将其放在进入页面后在进行初始化。

    其他方案


    提前加载SharedPreferences,当我们项目的sp很大的时候初次加载很耗内存和时间的,我们可以将其提前在初始化Multidex(如果使用的话)之前进行初始化,充分利用此阶段的CPU。

    启动阶段不启动子进程,子进程会共享CPU资源,导致主CPU资源紧张,另外一点就是在Application生命周期中也不要启动其他的组件如:service、contentProvider。

    异步类加载方式,如何确定哪些类是需要提前异步加载呢?这里我们可以自定义classload,替换掉系统的classload,在我们的classload中打印日志,每个类在加载的时候都会触发的log日志,然后在项目中运行一遍,这样就拿到了所有需要加载的类了,这些就是需要我们异步加载的类。

    • Class.forName()只加载类本身及其静态变量的引用类
    • new实例可以额外加载类成员的引用类

    总结


    本文主要是讲解了启动耗时的检测,从整体流程的耗时到各个方法的耗时以及线程的耗时,也介绍了工具的选择和使用,介绍了启动时间的优化,异步加载、延迟加载、懒加载等等,从常规方法到更优解,讲解了很多方式方法,希望能给大家提供一些新的思路和解决问题的方式。也希望大家能在自己的项目中实战总结。

    推荐学习资料


    相关文章

      网友评论

        本文标题:字节跳动学习笔记:Android性能优化之启动优化实战篇

        本文链接:https://www.haomeiwen.com/subject/ngkhdktx.html