Android 启动优化知识小结

启动速度优化的意义

Android性能优化三把板斧:稳定性、内存、启动速度、包体积,可以没做过,但必须要了解其中的知识

应用的启动速度是用户使用时产生的第一印象,从心理学的角度,对于秒开的应用,用户更容易产生黏性和依赖。如果点击应用图标后,等待很久才看到首帧画面显示,会带来极差的用户体验,甚至流失这个用户。

应用启动流程

根据应用启动前后的状态不同,可分为三种启动方式。

冷启动

点击应用图标,创建应用进程,进行一系列初始化,直至应用首屏界面完全展示。通常我们讨论的启动速度就是指冷启动速度。关于冷启动的过程,可以阅读这篇文章《Android从点击应用图标到首帧画面显示全过程》

过程涉及到Application、Activity的生命周期,这也是我们业务上进行优化的主要对象。

热启动

应用从后台切换回前台。

温启动

应用从后台切换回前台,只重走Activity生命周期,不需要再次创建进程。

启动耗时检测

开发环境的启动耗时检测

Logcat

在Logcat中过滤Displayed,可以查看到应用的冷启动耗时。

adb shell

adb shell am start -W [packageName]/[Activity全路径],如:

bash 复制代码
adb shell am start -W pro.lilei.leiapp/.MainActivity

可以得到3个时间:

  • ThisTime:当前Activity的启动耗时
  • TotalTime:所有Activity的启动耗时------一般以这个时间为准,包括【创建应用进程+应用进程初始化+Activity初始化+界面绘制】
  • WaitTime:AMS启动Activity的总耗时

生产环境的启动耗时检测

手动函数打点

最朴实的实现方式,通过记录函数的开始和结束时间戳,相减得到函数耗时。可以用一个单例对象来管理。

对于应用冷启动来说,记录的时间点:

  • 开始计时:Application.attachBaseContext函数结束处
  • 结束计时(页面初始化完成,但不一定有数据):Activity.onCreate函数结束处

这会统计出Application和Activity的初始化时间,但并不意味着此时用户可以交互or界面有数据显示。如果要记录用户交互时间,可以覆写Activity.onWindowFocusChanged函数,对于RecyclerView的场景,则可以在onBindViewHolder时通过添加ViewTreeObserver,在onPreDraw时作为可操作点,来监听条目内容显示。

java 复制代码
if (helper.getLayoutPosition() == 1 && !mHasRecorded) {
    mHasRecorded = true;
    helper.getView(R.id.item_search_pager_group).getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
        @Override
        public boolean onPreDraw() {
            helper.getView(R.id.item_search_pager_group).getViewTreeObserver().removeOnPreDrawListener(this);
            LogHelper.i("FeedShow");
            return true;
        }
    });
}

AOP打点

利用AspectJ,在关键函数前后打点,有几个关键概念:

  • 切面Aspect:在代码中的横切面,也是我们代码的切入点
  • 连接点JointPoint:被拦截到的字段、方法、构造器等
  • 切入点PointCut:具体切入的位置
  • 通知Advice:发生拦截后要执行的代码,分为前置Before、后置After、环绕Around三种

举个例子,如果我们对Application类中所有函数进行拦截并计算它们的运行耗时:

java 复制代码
@Aspect
public class ApplicationAop {
    @Around("call (* pro.lilie.leiapp.BaseApplication.**(..))")
    public void getTime(ProceddingJointPoint jointPoing) {
        Signature signature = jointPoint.getSignature();
        String name = signature.toShortString(); // 函数名
        long time = System.currentTimeInMillis(); // 函数执行前的时间戳
        try {
            jointPoint.proceed();
        } catch (Throwable t) {
            t.printStackTrace();
        }
        long costTime = System.currentTimeInMillis() - time; // 函数耗时
        Log.i(TAG, name + " cost" + costTime);
    }
}

相比于手动函数计时,AOP的优点是无侵入性,易于管理和维护

启动耗时分析工具

TraceView

使用方式有以下两种:

  1. 代码中控制开始(Debug.startMethodTracing)和结束(Debug.stopMethodTracing),生成.trace文件,在Android Studio的Profiler中进行查看
  2. 直接在AS的Profiler中进行抓取

CPU耗时和方法耗时:前者指执行函数所消耗的CPU时间片,后者指执行函数总耗时。前者<=后者

TraceView工具有如下特点:

  1. 用图形的方式展示执行时间、调用栈
  2. 信息全面,包含所有线程
  3. 运行时工具自身开销严重,并不能真实反映线上状态
  4. 找到最耗费时间的路径:Flame ChartTop Down
  5. 找到最耗费时间的节点:Bottom Up

主要用于热点分析,找出单次执行最耗时的方法,以及执行次数最多的方法。

SysTrace

在系统关键链路(如SystemServer、虚拟机、Binder驱动)插入一些Label,然后通过Label的开始和结束计算某个核心过程执行时间。Android Framework里的一些重要模块都已经插入了Label信息,用户App中也可以添加自定义Label

SysTrace工具有如下特点:

  1. 结合Android内核数据,生成Html报告
  2. 系统版本越高,Framework中的Label就越丰富
  3. 必须手动缩小范围,以聚焦核心流程

可记录系统关键方法耗时,主要用于分析绘制性能方面问题。

启动优化方案

以下是业内常见的启动优化套路,建议根据业务实际情况,组合进行使用:

  1. 主题设置
  2. 懒加载
  3. 异步初始化
  4. 延迟初始化
  5. Multidex预加载优化
  6. 类预加载优化
  7. WebView启动优化
  8. 页面数据预加载
  9. 闪屏、主页绘制优化

1.主题设置

在点击图标后,应用进程初始化完成之前,有时我们会看到界面闪过一个白屏,这是由于应用Activity没有设置主题导致的。为了解决白屏,有两个实现思路,直接用代码体现。

xml 复制代码
<!-- 思路1:透明主题 -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <item name="android:windowFullscreen">true</item>
    <item name="android:windowIsTransulent">true</item>
</style>

<!-- 思路2:闪屏图片 -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <item name="android:windowBackground">@mipmap/launch</item>
    <item name="android:windowFullscreen">true</item>
    <item name="android:windowContentOverlay">@null</item>
</style>

思路1是设置透明主题,这样的好处是在Activity首屏展示出来前,用户看到的都是桌面,但会给用户带来"手机反应慢"的误解。

思路2是设置过度图片,这样应用启动后会立刻展示图片,当Activity初始化完成后显示Activity的内容。

思路1&2都是治标不治本的处理手段,只是主观上让用户感觉启动变"快"了,客观上并没有减少哪怕1s的启动耗时,不仅如此,在中低端机上(<6.0)甚至还会因为设置主题导致启动变得更慢。因此,不建议用主题的方式来提升启动体验,如果一定要使用的话,需要进行版本区分。

2.懒加载

典型实现方式是饱汉式(一生成就吃饱)的单例,只有当用到某个模块时才对其进行初始化。

3.异步初始化

Android开发中常用的异步手段有多种,按照推荐顺序排列:协程、线程池、AsyncTask、IntentService、HandlerThread、Thread等。不建议直接使用线程,因为线程的创建、销毁、切换都会占用大量的CPU时间,产生性能损耗。

常用的实现方式是,对于IO密集型任务、CPU密集型任务各创建一个线程池,这两个线程池采取不同的配置。

  • IO密集型:文件读写、网络请求等,不占用CPU,并发量可以很大
  • CPU密集型:复杂的计算、循环操作,核心池取决于CPU核心数
配置 IO密集型任务线程池 CPU密集型任务线程池
核心线程数 0 CPU核心数-1
最大线程数 Integer.MAX CPU核心数-1
线程保活时间(s) 60 5
任务队列 SynchronousQueue LinkedBlockingQueue

使用线程时应当注意,核心思想是线程收敛,使用线程池进行操作,同时对每个线程设置其name,便于追溯,避免单独创建线程对象。

异步启动器的设计思想

要设计一个异步启动器,需要解决以下核心问题:

  • 任务抽象 :把每一个初始化任务抽象成一个Task
  • 组织有向无环图:梳理全部任务,哪些之间存在先后依赖,以及每个任务是要在主线程还是子线程执行
  • 依赖关系:将存在先后依赖的任务添加到同一个子线程
  • 主线程&子线程:使用MainHandler把任务传递给主线程,使用线程池来处理子线程任务
  • 并行:对于不存在依赖关系的初始化任务,放在子线程并行处理

如果存在多重依赖(任务C同时依赖并发的两个任务A、B),可以使用CountDownLatch锁来实现。

4.延迟初始化

对于一些必须要在主线程执行的初始化任务,将它们从Application.onCreate中抽出,放在Application的其它生命周期中进行。

常规方案

在闪屏页的停留时间(如广告页)进行初始化,优点是实现简单,缺点是时机不好控制,用户可以点击"跳过"来减少闪屏页的停留,如果任务被推迟到了首页时候,用户会有明显卡顿(如滑动列表)。

优化方案:延迟启动器

核心思想是利用IdleHandler特性,在CPU空闲时执行延迟任务初始化。Android主线程是运行在一个无限的循环当中的,当界面处于闪屏广告页时,用户操作极为有限,消息消息队列大概率处于Idle状态,此时正是执行初始化任务的好时机。

对于IdleHandler原理,可以阅读这篇文章掘金 IdleHandler的原理分析和妙用

以下就是典型的在闪屏页添加IdleTask的代码,onWindowFocusChanged是页面绘制完成可以交互的时间点。

java 复制代码
@Override
public void onWindowFocusChanged(boolean hasFocus) {
    super.onWindowFocusChanged(hasFocus);
    GlobalHandler.getInstance().getHandler().post((Runnable) () -> {
        if (hasFocus) {
            DelayInitDispatcher delayInitDispatcher = new DelayInitDispatcher();
            delayInitDispatcher.addTask(new InitOtherTask())
                    .start();
        }
    });
}

5.MultiDex预加载优化

MultiDex使用

Android 5.0之前不支持加载多个Dex,所以google提供了MultiDex支持在运行时加载和使用多个Dex,开启MultiDex的步骤是:

  1. 在项目的gradle文件中开启开关
  2. 让自定义的Application继承MultiDexApplication
  3. attachBashContext()中执行MultiDex.install(this)
gradle 复制代码
android {
    defaultConfig {
        multiDexEnabled true
    }
}
xml 复制代码
<application
    android:name="androidx.multidex.MultiDexApplication"
    ... />
</application>

MultiDex原理

APK编译时,由dx工具将.class文件编译成dex文件,期间使用--multi-dex参数拆分成多个dex文件,最终一起打包到APK当中。

在运行时,执行MultiDex.install(this),会进行当前虚拟机版本判断:

  • Android 4.4及以下 :系统层面不支持多dex文件处理,将2级dex文件解压缩到应用特定目录,得到2级dex列表,注入到PathClassLoader
  • Android 5.0及以上 :应用安装时,多个dex被合并成一个oat文件,运行时直接使用oat文件

Dalvik首次冷启动的过程中,生成ZIPODEX文件的过程都是比较耗时的,如果一个 APP 中有很多个 Secondary DEX文件,就会加剧这一问题。尤其是生成ODEX的过程,Dalvik 虚拟机会把DEX格式的文件进行遍历扫描和优化重写处理,从而转换为ODEX文件,这就是其中最大的耗时瓶颈。

MultiDex优化思路

  • 模块拆分:将首页用到的类放入1级dex,将二级页面放入2级dex,调用时先判断是否完成install
  • 多线程加载:对于4.4及以下,启动工作线程执行odex任务,同时主线程Application进入while循环,监听odex任务是否完成
  • 抖音BoostMultiDex方案:通过hook dalvik虚拟机代码,在冷启动时直接使用原始dex,同时另启动一个进程进行dex的opt工作。提速4.4及以下版本的冷启动速度达80%

6.类预加载优化

在Application中提前异步装载耗时较长的类,可以通过替换系统ClassLoader来记录类的加载时间,从而找出哪些类耗时较长。

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

7.WebView启动优化

WebView的首次创建耗时较多,因此可以提前初始化,通过WebView对象池来减少新建过程带来的消耗的消耗。

8.页面数据预加载

  • 在闪屏时预加载主页数据
  • 在主页时预加载二级页数据

9.闪屏、主页绘制优化

  • 减少层级嵌套,避免过度绘制
  • 合理使用merge节点(将子节点直接添加到parent中,可以减少中间一个层级)
    • 只能用在布局xml的根元素
    • 必须指定一个ViewGroup作为父元素,并且设置attachToRoot=true
  • 使用ViewStub进行布局懒加载,仅当布局可见时才加载它
    • 注意:ViewStub只能加载一次,之后ViewStub对象会被置为空,不适用于按需显示隐藏的场景
    • 只能用来加载一个布局文件,不是某个View

启动优化面试问答思路

Q:讲讲你对启动速度优化的理解?

(总-先上结论和答题框架)启动速度是应用性能指标里非常重要的一环,我做性能优化时在这方面有过一些实践和探索,以下是我的经验和理解。

(分-明确概念)通常我们讲启动速度,指的是冷启动的速度,具体的话就是从Application.attachBaseContextActivity.onCreate完成所消耗的时间。(引出问题-启动流程)

(分-优化思路)首先,在优化前应当记录启动耗时,在开发环境可以通过Logcat查看,在生产环境则需要借助打点来计算。然后,使用TraceViewSysTrace这样的工具,分析过程中耗时的方法。找出耗时的方法后,就是采取一些手段进行实际优化。常用的方式有主题设置、懒加载、异步初始化、延迟初始化、MultiDex预加载优化、类预加载优化、页面数据预拉取等。

(总-引出下文)接下来我想讲一下我们的一次具体实践。(引出下一问题)

Q:在你们实践当中是如何做的?

(强调工程意识,数据驱动)首先进行任何性能优化的依据,一定是数据驱动。我们先通过埋点、AOP框架等,对应用启动耗时进行统计。结合排查代码后,发现主线程中初始化的任务太多,对这些任务进行梳理后,把一些放在异步线程中去进行初始化(引导提问-异步初始化实现思路)。此外,我们还发现有些任务的优先级并不高,所以把这些任务放在闪屏页进行延迟初始化(引导提问-延迟初始化,IdleHandler方案)。通过IdleHandler我们实现了一个延迟初始化的框架,在主线程空闲时间进行初始化,以减少启动耗时导致的卡顿现象。

最后,为了长期保持效果,防止启动速度劣化,我们还开发了一个启动器框架,结合埋点进行线上数据监控。

Q:讲一讲异步初始化的设计思路?

最初我们采用的是普通的异步方案,直接new出Thread,并设置线程的优先级,在Application.onCreate函数中进行异步初始化。但由于这样无法对线程创建进行约束和复用,我们后面使用线程池的方式对创建进行管理。但还有一些复杂的场景,比如初始化任务之间存在先后依赖关系,我们又思考新的一个解决方案。能够完美滴解决我们前面遇到的这些问题。

这个方案就是我们现在使用的启动器,在这个框架里,把每一个启动任务抽象成Task,它有几个关键属性,比如所运行的线程、存在哪些依赖等。所有Task构成了一个有向无环图,接着用一个异步队列进行,这个异步队列关联到的线程数是跟处理器核心数强相关的,从而最大程度保证我们的主线程、工作线程都能充分利用CPU。

Q:期间使用了哪些工具?

  • CPU Profiler:可以在AS中开启,能通过可视化的方式展示函数调用次数和时间,主要可以用来找出启动过程中哪些函数调用的次数最多、耗时最多;以及哪些调用路径的耗时最多
  • SysTrace:系统默认地在关键链路中插入一些Label,进而计算出某个核心过程的耗时。Android Framework一些核心模块都已经插入了Label,用户也可以在自己的应用中自定义添加一些Label。可以生成Html报告,重要用于分析绘制性能方面的问题
  • AspectJ:是切面化框架,可以无痛集成到项目中,统计一些关键函数的耗时,比如Application、Activity中各个onXXX的函数

Q:有没有遇到过疑难问题?

首先是延迟初始化的方案,一开始我们是采用在界面显示后用MainHandlerpost一个Runnable的初始化任务,单这里有个参数很难界定,就是delay时长,因为不同的手机性能不同、网络条件不同,我们希望是在闪屏广告页进行延迟初始化,但实际上可能要等到首页加载出来后才去执行这个初始化逻辑。这会导致一个问题,就是用户滑动首页列表时,由于初始化任务正在跑,让用户滑动发生了卡顿。因此我们采用IdleHandler来实现当CPU空闲时候才执行耗时任务,提升用户体验,避免因启动耗时任务而导致的页面卡顿。

(引出MultiDex优化方案)此外,对于低版本手机的初始化我也有一些技术上的理解,低版本手机上应用安装或者更新后,首次冷启动非常慢,这是因为系统进行dexopt优化导致的。有延迟加载、异步加载和字节的Boost方案几种处理方式。

最后,性能优化尤其是启动速度优化是一个需要长期坚持的工作,需要一些机制来保证后续版本迭代,启动速度不会因为新功能的引入而劣化。有以下几个方式。

  1. 【上线前】限制对重点类的修改,如Application,跟修改人确认新引入的代码耗时多少、能否异步、能否延迟或者懒加载
  2. 【上线后】完善线上监控,不仅是整体的启动时间,粒度还要细化,比如Application中onCreate、onAttachBaseContext的耗时,Activity启动到页面可交互之间的耗时等等。

参考资料

相关推荐
阑梦清川4 小时前
在鱼皮的模拟面试里面学习有感
学习·面试·职场和发展
鱼跃鹰飞13 小时前
大厂面试真题-简单说说线程池接到新任务之后的操作流程
java·jvm·面试
程序员清风15 小时前
浅析Web实时通信技术!
java·后端·面试
测试199816 小时前
外包干了2年,快要废了。。。
自动化测试·软件测试·python·面试·职场和发展·单元测试·压力测试
mingzhi6117 小时前
渗透测试-快速获取目标中存在的漏洞(小白版)
安全·web安全·面试·职场和发展
嚣张农民17 小时前
一文简单看懂Promise实现原理
前端·javascript·面试
Liknana19 小时前
Android 网易游戏面经
android·面试
威哥爱编程1 天前
MongoDB面试专题33道解析
数据库·mongodb·面试
程序猿进阶1 天前
Redis 基础数据改造
java·开发语言·数据库·redis·后端·面试·架构