一、APP启动优化
===========================================================================
1、 你对 APP 的启动有过研究吗? 有做过相关的启动优化吗?
之前做热修复的时候研究过 Application 的启动原理。项目中也做过一些启动优化。
面试官:
哦,你之前研究过热修复? (这个时候有可能就会深入的问问热修复的原理,这里咱们就不讨论热修复原理) 那你说说对启动方面都做了哪些优化?
程序员:
- 我发现程序在冷启动的时候,会有 1s 左右的白屏闪现,低版本是黑屏的现象,在这期间我通过翻阅系统主题源码,发现了系统 AppTheme 设置了一个
,由此推断就是这个属性捣的鬼,开始我是通过设置windowBackground
透明属性,发现虽然没有了白屏,但是中间还是有一小段不可见,这个用户体验还是不好的。最后我观察了市面上大部分的 Android 软件在冷启动的时候都会有一个windowIsTranslucent
的广告页,同时在增加一个倒数的计时器,最后才进入到登录页面或者主页面。我最后也是这样做的,原因是这样做的好处可以让用户先基于广告对本 APP 有一个基本认识,而且在倒数的时候也预留给咱们一些对插件和一些必须或者耗时的初始化做一些准备。Splash
Ps:这里会让面试官感觉你是一个注重用户体验的
- 通过翻阅 Application 启动的源码,当我们点击桌面图标进入我们软件应用的时候,会由 AMS 通过 Socket 给 Zygote 发送一个 fork 子进程的消息,当 Zygote fork 子进程完成之后会通过反射启动 ActivityThread##main 函数,最后又由 AMS 通过 aidl 告诉 ActivityThread##H 来反射启动创建Application 实例,并且依次执行
、attachBaseContext
生命周期,由此可见我们不能在这 2 个生命周期里做主线程耗时操作。onCreate
Ps: 这里会让面试官感觉你对 App 应用的启动流程研究的比较深,有过真实的翻阅底层源码,而并不是背诵答案。
- 知道了 attachBaseContext 、onCreate 在应用中最先启动,那么我们就可以通过 TreceView 等性能检测工具,来检测具体函数耗时时间,然后来对其做具体的优化:
1、项目不及时需要的代码通过异步加载。
2、将对一些使用率不高的初始化,做懒加载。
3、将对一些耗时任务通过开启一个 IntentService来处理。
4、还通过 redex 重排列 class 文件,将启动阶段需要用到的文件在 APK 文件中排布在一起,尽可能的利用 Linux 文件系统的 pagecache 机制,用最少的磁盘 IO 次数,读取尽可能多的启动阶段需要的文件,减少 IO 开销,从而达到提升启动性能的目的。
5、通过抖音发布的文章知晓在 5.0 低版本可以做 MultiDex 优化,在第一次启动的时候,直接加载没有经过 OPT 优化的原始 DEX,先使得 APP 能够正常启动。然后在后台启动一个单独进程,慢慢地做完 DEX 的 OPT 工作,尽可能避免影响到前台 APP 的正常使用。
Ps:1. 面试官这里会觉得你对启动优化确实了解的不错,有一定的启动优化经验。
2.在第五点面试官会觉得你比较关注该圈子的动态,发现好的解决方案,并能用在自己项目上。这一点是加分项!
- Application 启动完之后,AMS 会找出前台栈顶待启动的 Activity , 最后也是通过 AIDL 通知 ActivityThread#H 来进行对 Activity 的实例化并依次执行生命周期
、onCreate
、onStart
函数,那么这里由于 onCreate 生命周期中如果调用了onRemuse
函数,底层就会通过将 XML2View 那么这个过程肯定是耗时的。所以要精简 XML 布局代码,尽可能的使用setContentView
、ViewStub
、include
标签来优化布局。接着在 onResume 声明周期中会请求 JNI 接收 Vsync (垂直同步刷新的信号) 请求,16ms 之后如果接收到了刷新的消息,那么就会对merge
绘制。最后才是将 Activity 的根布局 DecorView 添加到 Window 并交于 SurfaceFlinger 显示。DecorView 进行 onMeasure->onLayout->onDraw
所以这一步除了要精简 XML 布局,还有对自定义 View 的测量,布局,绘制等函数不能有耗时和导致 GC 的操作。最后也可以通过 TreaceView 工具来检测这三个声明周期耗时时间,从而进一步优化,达到极限。
这一步给面试官的感觉你对整个 Activity 的启动和 View 的绘制还有刷新机制都有深入的研究,那么此刻你肯定给面试官留了一个好印象,说明你平时对这些源码级别的研究比较广泛,透彻。
最后我基于以上的优化减少了 50% 启动时间。
面试官:
嗯,研究的挺深的,源码平时不少看吧。
二、App稳定性优化
============================================================================
1、你们做了哪些稳定性方面的优化?
随着项目的逐渐成熟,用户基数逐渐增多,DAU持续升高,我们遇到了很多稳定性方面的问题,对于我们技术同学遇到了很多的挑战,用户经常使用我们的App卡顿或者是功能不可用,因此我们就针对稳定性开启了专项的优化,我们主要优化了三项:
- Crash专项优化(=>2)
- 性能稳定性优化(=>2)
- 业务稳定性优化(=>3)
通过这三方面的优化我们搭建了移动端的高可用平台。同时,也做了很多的措施来让App真正地实现了高可用。
2、性能稳定性是怎么做的?
- 全面的性能优化:启动速度、内存优化、绘制优化
- 线下发现问题、优化为主
- 线上监控为主
- Crash专项优化
我们针对启动速度,内存、布局加载、卡顿、瘦身、流量、电量等多个方面做了多维的优化。
我们的优化主要分为了两个层次,即线上和线下,针对于线下呢,我们侧重于发现问题,直接解决,将问题尽可能在上线之前解决为目的。而真正到了线上呢,我们最主要的目的就是为了监控,对于各个性能纬度的监控呢,可以让我们尽可能早地获取到异常情况的报警。
同时呢,对于线上最严重的性能问题性问题:Crash,我们做了专项的优化,不仅优化了Crash的具体指标,而且也尽可能地获取了Crash发生时的详细信息,结合后端的聚合、报警等功能,便于我们快速地定位问题。
3、业务稳定性如何保障?
- 数据采集 + 报警
- 需要对项目的主流程与核心路径进行埋点监控,
- 同时还需知道每一步发生了多少异常,这样,我们就知道了所有业务流程的转换率以及相应界面的转换率
- 结合大盘,如果转换率低于某个值,进行报警
- 异常监控 + 单点追查
- 兜底策略
移动端业务高可用它侧重于用户功能完整可用,主要是为了解决一些线上一些异常情况导致用户他虽然没有崩溃,也没有性能问题,但是呢,只是单纯的功能不可用的情况,我们需要对项目的主流程、核心路径进行埋点监控,来计算每一步它真实的转换率是多少,同时呢,还需要知道在每一步到底发生了多少异常。
这样我们就知道了所有业务流程的转换率以及相应界面的转换率,有了大盘的数据呢,我们就知道了,如果转换率或者是某些监控的成功率低于某个值,那很有可能就是出现了线上异常,结合了相应的报警功能,我们就不需要等用户来反馈了,这个就是业务稳定性保障的基础。
同时呢,对于一些特殊情况,比如说,开发过程当中或代码中出现了一些catch代码块,捕获住了异常,让程序不崩溃,这其实是不合理的,程序虽然没有崩溃,当时程序的功能已经变得不可用,所以呢,这些被catch的异常我们也需要上报上来,这样我们才能知道用户到底出现了什么问题而导致的异常。
此外,线上还有一些单点问题,比如说用户点击登录一直进不去,这种就属于单点问题,其实我们是无法找出其和其它问题的共性之处的,所以呢,我们就必须要找到它对应的详细信息。
最后,如果发生了异常情况,我们还采取了一系列措施进行快速止损。(=>4)
4、如果发生了异常情况,怎么快速止损?
- 功能开关
- 统跳中心
- 动态修复:热修复、资源包更新
- 自主修复:安全模式
首先,需要让App具备一些高级的能力,我们对于任何要上线的新功能,要加上一个功能的开关,通过配置中心下发的开关呢,来决定是否要显示新功能的入口。如果有异常情况,可以紧急关闭新功能的入口,那就可以让这个App处于可控的状态了。
然后,我们需要给App设立路由跳转,所有的界面跳转都需要通过路由来分发,如果我们匹配到需要跳转到有bug的这样一个新功能时,那我们就不跳转了,或者是跳转到统一的异常正处理中的界面。如果这两种方式都不可以,那就可以考虑通过热修复的方式来动态修复,目前热修复的方案其实已经比较成熟了,我们完全可以低成本地在我们的项目中添加热修复的能力,当然,如果有些功能是由RN或WeeX来实现就更好了,那就可以通过更新资源包的方式来实现动态更新。而这些如果都不可以的话呢,那就可以考虑自己去给应用加上一个自主修复的能力,如果App启动多次的话,那就可以考虑清空所有的缓存数据,将App重置到安装的状态,到了最严重的等级呢,可以阻塞主线程,此时一定要等App热修复成功之后才允许用户进入。
三、有做过相关的内存优化吗?
================================================================================
程序员:
有做过,目前的项目内存优化还是挺多的,要不我先说一下优化内存有什么好处吧?咱们不能盲目的去优化!
有的时候对于自己熟悉的领域,一定要主动出击,自己主导这场面试。
面试官:
可以。
Ps:这里大多数面试官会同意你的请求,除非遇见装B的。
程序员:
好处:
- 减少 OOM ,可以提高程序的稳定性。
- 减少卡顿,提高应用流畅性。
- 减少内存占用,提高应用后台存活性。
- 减少程序异常,降低应用 Crash 率, 提高稳定性。
那么我基于这四点,我的程序做了如下优化:
-
1.减少 OOM
在应用开发阶段我比较喜欢用 LeakCanary 这款性能检测工具,好处是它能实时的告诉我具体哪个类发现了内存泄漏(如果你对 LeakCanary 的原理了解的话,可以说一说它是怎么检测的)。
还有我们要明白为什么应用程序会发送 OOM ,又该怎么去避免它?
发生 OOM 的场景是当申请 1M 的内存空间时,如果你要往该内存空间存入 2M 的数据,那么此时就会发生 OOM。
在应用程序中我们不仅要避免直接导致 OOM 的场景还要避免间接导致 OOM 的场景。间接的话也就是要避免内存泄漏的场景。
内存泄漏的场景是这个对象不再使用时,应用完整的执行最后的生命周期,但是由于某些原因,对象虽然已经不再使用,仍然会在内存中存在而导致 GC 不会去回收它,这就意味着发生了内存泄漏。(这里可以介绍下 GC 回收机制,回收算法,知识点尽量往外扩展而不脱离本题)
最后在说一下在实际开发中避免内存泄漏的场景:
- 资源型对象未关闭: Cursor,File
- 注册对象未销毁: 广播,回调监听
- 类的静态变量持有大数据对象
- 非静态内部类的静态实例
- Handler 临时性内存泄漏: 使用静态 + 弱引用,退出即销毁
- 容器中的对象没清理造成的内存泄漏
- WebView: 使用单独进程
-
2.减少卡顿
怎么减少卡顿? 那么我们可以从 2 个原理方面来探讨卡顿的根本原因,第一个原理方面是绘制原理,另一个就是刷新原理。
- 绘制原理:
- 刷新原理:
View 的 requestLayout 和 ViewRootImpl##setView 最终都会调用 ViewRootImpl 的 requestLayout 方法,然后通过 scheduleTraversals 方法向 Choreographer 提交一个绘制任务,然后再通过 DisplayEventReceiver 向底层请求 vsync 垂直同步信号,当 vsync 信号来的时候,会通过 JNI 回调回来,在通过 Handler 往消息队列 post 一个异步任务,最终是 ViewRootImpl 去执行绘制任务,最后调用 performTraversals 方法,完成绘制。
详细流程可以参考下面流程图:
卡顿的根本原因:
从刷新原理来看卡顿的根本原理是有两个地方会造成掉帧:
一个是主线程有其它耗时操作,导致doFrame 没有机会在 vsync 信号发出之后 16 毫秒内调用;
还有一个就是当前doFrame方法耗时,绘制太久,下一个 vsync 信号来的时候这一帧还没画完,造成掉帧。
既然我们知道了卡顿的根本原因,那么我们就可以监控卡顿,从而可以对卡顿优化做到极致。我们可以从下面四个方面来监控应用程序卡顿:
- 基于 Looper 的 Printer 分发消息的时间差值来判断是否卡顿。
//1\. 开启监听
Looper.myLooper().setMessageLogging(new
LogPrinter(Log.DEBUG, "ActivityThread"));
//2\. 只要分发消息那么就会在之前和之后分别打印消息
public static void loop() {
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread."); } final MessageQueue queue = me.mQueue; ...
for (;;) {
Message msg = queue.next(); // might block
...
//分发之前打印
final Printer logging = me.mLogging; if (logging != null) { logging.println(">>>>> Dispatching to " + msg.target + " " + msg.callback + ": " + msg.what); }
...
try {
//分发消息
msg.target.dispatchMessage(msg);
...
//分发之后打印
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
}
}
- 基于 Choreographer 回调函数 postFrameCallback 来监控
- 基于开源框架 BlockCanary 来监控
- 基于开源框架 rabbit-client 来监控
怎么避免卡顿:
一定要避免在主线程中做耗时任务,总结一下 Android 中主线程的场景:
- UI 生命周期的控制
- 系统事件的处理
- 消息处理
- 界面布局
- 界面绘制
- 界面刷新
- …
还有一个最重要的就是避免内存抖动,不要在短时间内频繁的内存分配和释放。
基于这几点去说卡顿肯定是没有问题的。
-
3.减少内存占用
可以从如下几个方面去展开说明:
- AutoBoxing(自动装箱): 能用小的坚决不用大的。
- 内存复用
- 使用最优的数据类型
- 枚举类型: 使用注解枚举限制替换 Enum
- 图片内存优化(这里可以从 Glide 等开源框架去说下它们是怎么设计的)
- 选择合适的位图格式
- bitmap 内存复用,压缩
- 图片的多级缓存
- 基本数据类型如果不用修改的建议全部写成 static final,因为 它不需要进行初始化工作,直接打包到 dex 就可以直接使用,并不会在 类 中进行申请内存
- 字符串拼接别用 +=,使用 StringBuffer 或 StringBuilder
- 不要在 onMeause, onLayout, onDraw 中去刷新 UI
- 尽量使用 C++ 代码转换 YUV 格式,别用 Java 代码转换 RGB 等格式,真的很占用内存
-
4.减少程序异常
减少程序异常那么我们可以从稳定性和 Crash 来分别说明。
这个我们将在第四点会详细的介绍程序的稳定性和 Crash 。
如果说出这些,再实际开发中举例说明一下怎么解决的应该是没有问题的。
歇会儿
=====================================================================
四、App绘制优化
===========================================================================
1、你在做布局优化的过程中用到了哪些工具?
我在做布局优化的过程中,用到了很多的工具,但是每一个工具都有它不同的使用场景,不同的场景应该使用不同的工具。下面我从线上和线下两个角度来进行分析。
比如说,我要统计线上的FPS,我使用的就是Choreographer这个类,它具有以下特性:
- 1、能够获取整体的帧率。
- 2、能够带到线上使用。
- 3、它获取的帧率几乎是实时的,能够满足我们的需求。
同时,在线下,如果要去优化布局加载带来的时间消耗,那就需要检测每一个布局的耗时,对此我使用的是AOP的方式,它没有侵入性,同时也不需要别的开发同学进行接入,就可以方便地获取每一个布局加载的耗时。如果还要更细粒度地去检测每一个控件的加载耗时,那么就需要使用LayoutInflaterCompat.setFactory2这个方法去进行Hook。
此外,我还使用了LayoutInspector和Systrace这两个工具,Systrace可以很方便地看到每帧的具体耗时以及这一帧在布局当中它真正做了什么。而LayoutInspector可以很方便地看到每一个界面的布局层级,帮助我们对层级进行优化。
2、布局为什么会导致卡顿,你又是如何优化的?
分析完布局的加载流程之后,我们发现有如下四点可能会导致布局卡顿:
- 1、首先,系统会将我们的Xml文件通过IO的方式映射的方式加载到我们的内存当中,而IO的过程可能会导致卡顿。
- 2、其次,布局加载的过程是一个反射的过程,而反射的过程也会可能会导致卡顿。
- 3、同时,这个布局的层级如果比较深,那么进行布局遍历的过程就会比较耗时。
- 4、最后,不合理的嵌套RelativeLayout布局也会导致重绘的次数过多。
对此,我们的优化方式有如下几种:
- 1、针对布局加载Xml文件的优化,我们使用了异步Inflate的方式,即AsyncLayoutInflater。它的核心原理是在子线程中对我们的Layout进行加载,而加载完成之后会将View通过Handler发送到主线程来使用。所以不会阻塞我们的主线程,加载的时间全部是在异步线程中进行消耗的。而这仅仅是一个从侧面缓解的思路。
- 2、后面,我们发现了一个从根源解决上述痛点的方式,即使用X2C框架。它的一个核心原理就是在开发过程我们还是使用的XML进行编写布局,但是在编译的时候它会使用APT的方式将XML布局转换为Java的方式进行布局,通过这样的方式去写布局,它有以下优点:
1、它省去了使用IO的方式去加载XML布局的耗时过程。
2、它是采用Java代码直接new的方式去创建控件对象,所以它也没有反射带来的性能损耗。这样就从根本上解决了布局加载过程中带来的问题。
- 3、然后,我们可以使用ConstraintLayout去减少我们界面布局的嵌套层级,如果原始布局层级越深,它能减少的层级就越多。而使用它也能避免嵌套RelativeLayout布局导致的重绘次数过多。
- 4、最后,我们可以使用AspectJ框架(即AOP)和LayoutInflaterCompat.setFactory2的方式分别去建立线下全局的布局加载速度和控件加载速度的监控体系。
3、做完布局优化有哪些成果产出?
- 1、首先,我们建立了一个体系化的监控手段,这里的体系还指的是线上加线下的一个综合方案,针对线下,我们使用AOP或者ARTHook,可以很方便地获取到每一个布局的加载耗时以及每一个控件的加载耗时。针对线上,我们通过Choreographer.getInstance().postFrameCallback的方式收集到了FPS,这样我们可以知道用户在哪些界面出现了丢帧的情况。
- 2、然后,对于布局监控方面,我们设立了FPS、布局加载时间、布局层级等一系列指标。
- 3、最后,在每一个版本上线之前,我们都会对我们的核心路径进行一次Review,确保我们的FPS、布局加载时间、布局层级等达到一个合理的状态。
4、你是怎么做卡顿优化的?
从项目的初期到壮大期,最后再到成熟期,每一个阶段都针对卡顿优化做了不同的处理。各个阶段所做的事情如下所示:
- 1、系统工具定位、解决
- 2、自动化卡顿方案及优化
- 3、线上监控及线下监测工具的建设
我做卡顿优化也是经历了一些阶段,最初我们的项目当中的一些模块出现了卡顿之后,我是通过系统工具进行了定位,我使用了Systrace,然后看了卡顿周期内的CPU状况,同时结合代码,对这个模块进行了重构,将部分代码进行了异步和延迟,在项目初期就是这样解决了问题。但是呢,随着我们项目的扩大,线下卡顿的问题也越来越多,同时,在线上,也有卡顿的反馈,但是线上的反馈卡顿,我们在线下难以复现,于是我们开始寻找自动化的卡顿监测方案,其思路是来自于Android的消息处理机制,主线程执行任何代码都会回到Looper.loop方法当中,而这个方法中有一个mLogging对象,它会在每个message的执行前后都会被调用,我们就是利用这个前后处理的时机来做到的自动化监测方案的。同时,在这个阶段,我们也完善了线上ANR的上报,我们采取的方式就是监控ANR的信息,同时结合了ANR-WatchDog,作为高版本没有文件权限的一个补充方案。在做完这个卡顿检测方案之后呢,我们还做了线上监控及线下检测工具的建设,最终实现了一整套完善,多维度的解决方案。
5、你是怎么样自动化的获取卡顿信息?
我们的思路是来自于Android的消息处理机制,主线程执行任何代码它都会走到Looper.loop方法当中,而这个函数当中有一个mLogging对象,它会在每个message处理前后都会被调用,而主线程发生了卡顿,那就一定会在dispatchMessage方法中执行了耗时的代码,那我们在这个message执行之前呢,我们可以在子线程当中去postDelayed一个任务,这个Delayed的时间就是我们设定的阈值,如果主线程的messaege在这个阈值之内完成了,那就取消掉这个子线程当中的任务,如果主线程的message在阈值之内没有被完成,那子线程当中的任务就会被执行,它会获取到当前主线程执行的一个堆栈,那我们就可以知道哪里发生了卡顿。
经过实践,我们发现这种方案获取的堆栈信息它不一定是准确的,因为获取到的堆栈信息它很可能是主线程最终执行的一个位置,而真正耗时的地方其实已经执行完成了,于是呢,我们就对这个方案做了一些优化,我们采取了高频采集的方案,也就是在一个周期内我们会多次采集主线程的堆栈信息,如果发生了卡顿,那我们就将这些卡顿信息压缩之后上报给APM后台,然后找出重复的堆栈信息,这些重复发生的堆栈大概率就是卡顿发生的一个位置,这样就提高了获取卡顿信息的一个准确性。
6、卡顿的一整套解决方案是怎么做的?
首先,针对卡顿,我们采用了线上、线下工具相结合的方式,线下工具我们册中医药尽可能早地去暴露问题,而针对于线上工具呢,我们侧重于监控的全面性、自动化以及异常感知的灵敏度。
同时呢,卡顿问题还有很多的难题。比如说有的代码呢,它不到你卡顿的一个阈值,但是执行过多,或者它错误地执行了很多次,它也会导致用户感官上的一个卡顿,所以我们在线下通过AOP的方式对常见的耗时代码进行了Hook,然后对一段时间内获取到的数据进行分析,我们就可以知道这些耗时的代码发生的时机和次数以及耗时情况。然后,看它是不是满足我们的一个预期,不满足预期的话,我们就可以直接到线下进行修改。同时,卡顿监控它还有很多容易被忽略的一个盲区,比如说生命周期的一个间隔,那对于这种特定的问题呢,我们就采用了编译时注解的方式修改了项目当中所有Handler的父类,对于其中的两个方法进行了监控,我们就可以知道主线程message的执行时间以及它们的调用堆栈。
对于线上卡顿,我们除了计算App的卡顿率、ANR率等常规指标之外呢,我们还计算了页面的秒开率、生命周期的执行时间等等。而且,在卡顿发生的时刻,我们也尽可能多地保存下来了当前的一个场景信息,这为我们之后解决或者复现这个卡顿留下了依据。
五、说说你在项目中网络优化?
================================================================================
程序员:
有,这一点其实可以通过 OKHTTP 连接池和 Http 缓存来说一下(当然这里不会再展开分析 OKHTTP 源码了)
面试官:
那你具体说一下吧
程序员