V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
winlee28
V2EX  ›  Android

Android 性能优化之启动优化实战

  •  
  •   winlee28 · 2019-12-02 16:10:19 +08:00 · 4583 次点击
    这是一个创建于 1578 天前的主题,其中的信息可能已经有所发展或是发生改变。

    本文首发于公众号「 Android 开发之旅」,欢迎关注

    前言

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

    相信很多同学都听过八秒定律,八秒定律是在互联网领域存在的一个定律,即指用户访问一个网站时,如果等待网页打开的时间超过了 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 实例可以额外加载类成员的引用类

    总结

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

    扫描下方二维码关注公众号,及时获取文章推送

    二维码

    1 条回复    2019-12-03 13:31:35 +08:00
    carlist
        1
    carlist  
       2019-12-03 13:31:35 +08:00
    从用户小白的理解体会这个概念吧
    冷启动 - 被杀干净的程序通过点击图标的方式到主界面的过程
    热启动 - 按下 home 键打开其他程序然后通过卡片切换回到现有程序
    温启动 - 后台驻留,点击图标打开程序到前台

    除了代码的优化之外更加重要的有几方面
    1 - 用温启动替代冷启动
    2 - 更小的程序包
    3 - 更高的存储读取速度和计算速度

    开始喷
    1 - 我就要自启动,占了内存开了后台预加载程序开的快了,哈哈哈
    2 - SDK 神马的一定要搞活了,要么怎么恰饭?缓存也得加载,好心烦
    3 - 不管多新的配置过一年都变卡,要不怎么换机?

    有良知的开发经理不多,KPI 是最重要的,就这样
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   4596 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 10:05 · PVG 18:05 · LAX 03:05 · JFK 06:05
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.