面试官:平时开发中有遇到卡顿问题吗?你一般是如何监控的?
来面试的小伙:额...没有遇到过卡顿问题,我平时写的代码质量比较高,不会出现卡顿。
面试官:...
这回答似乎没啥问题,但是如果你在面试中真这样说他们会认为你在卡顿监控以及优化这一块是0经验。
卡顿这个话题,相信大部分两年或以上工作经验的同学都应该能说出个大概。
一般都能说出卡顿的原因:
主要是主线程阻塞。在开发过程中,遇到的造成主线程阻塞的原因可能是:
但是如果问得更深一点:
去过大厂面试的朋友就会知道大厂经常问这样的问题,主要是因为一旦发生卡顿就会被用户直观的感受到,而其他问题很难被及时的发现:比如内存占用高,耗费流量等。用户体验不好就很有可能卸载掉我们的 App,让公司白白付出高昂的用户成本,因此因为性能问题导致用户流失是我们开发人员的失职。
首先,搞客户端开发的同学应该都知道,解决卡顿的过程往往是曲折的,有些并没有我们想的那样简单、浅表。很多时候,大部分卡顿是很难及时发现的,不可重现的卡顿,经常出现在线上用户的真实使用过程中,这种卡顿往往跟机器性能,手机环境,甚至是操作偏好等因素息息相关。
我们平时从用户反馈的“好卡呀”这种描述中很难直接洞察到卡顿的根源。甚至有些连卡顿的场景都不知道,很难准确重现,所以这种卡顿容易让人摸不着头脑。
而内存作为计算机程序运行最重要的资源之一,需要运行过程中做到合理的资源分配与回收,不合理的内存占用轻则使得用户应用程序运行卡顿、ANR、黑屏,重则导致用户应用程序发生 OOM(out of memory)崩溃。
我们需要在各种机器资源上保持优秀的流畅性和稳定性,内存优化是必须要重视的环节。但是我们即使有接入如Bugly的线上异常采集平台,也不能够保证通过异常日志找到OOM的原因。绝大多数的OOM,异常日志显示的只是压倒骆驼的最后一根稻草,而不是直接的原因。
下面总结几种比较流行、有效的卡顿监控方式:
Looper
暴露了一个方法
public void setMessageLogging(@Nullable Printer printer) { mLogging = printer; }
在Looper 的loop方法有这样一段代码
public static void loop() { ... for (;;) { ... // This must be in a local variable, in case a UI event sets the logger final Printer logging = me.mLogging; if (logging != null) { logging.println(">>>>> Dispatching to " + msg.target + " " + msg.callback + ": " + msg.what); }
Looper轮循的时候,每次从消息队列取出一条消息,如果logging不为空,就会调用 logging.println,我们可以通过设置Printer,计算Looper两次获取消息的时间差,如果时间太长就说明Handler处理时间过长,直接把堆栈信息打印出来,就可以定位到耗时代码。不过println 方法参数涉及到字符串拼接,考虑性能问题,所以这种方式只推荐在Debug模式下使用。基于此原理的开源库代表是:BlockCanary,看下BlockCanary核心代码:
类:LooperMonitor
public void println(String x) { if (mStopWhenDebugging && Debug.isDebuggerConnected()) { return; } if (!mPrintingStarted) { //1、记录第一次执行时间,mStartTimestamp mStartTimestamp = System.currentTimeMillis(); mStartThreadTimestamp = SystemClock.currentThreadTimeMillis(); mPrintingStarted = true; startDump(); //2、开始dump堆栈信息 } else { //3、第二次就进来这里了,调用isBlock 判断是否卡顿 final long endTime = System.currentTimeMillis(); mPrintingStarted = false; if (isBlock(endTime)) { notifyBlockEvent(endTime); } stopDump(); //4、结束dump堆栈信息 } } //判断是否卡顿的代码很简单,跟上次处理消息时间比较,比如大于3秒,就认为卡顿了 private boolean isBlock(long endTime) { return endTime - mStartTimestamp > mBlockThresholdMillis; }
原理是这样,比较Looper两次处理消息的时间差,比如大于3秒,就认为卡顿了。细节的话大家可以自己去研究源码,比如消息队列只有一条消息,隔了很久才有消息入队,这种情况应该是要处理的,BlockCanary是怎么处理的呢?
这个我在BlockCanary 中测试,并没有出现此问题,所以BlockCanary 是怎么处理的?简单分析一下源码:
上面这段代码,注释1和注释2,记录第一次处理的时间,同时调用startDump()
方法,startDump()
最终会通过Handler 去执行一个AbstractSampler
类的mRunnable
,代码如下:
abstract class AbstractSampler { private static final int DEFAULT_SAMPLE_INTERVAL = 300; protected AtomicBoolean mShouldSample = new AtomicBoolean(false); protected long mSampleInterval; private Runnable mRunnable = new Runnable() { @Override public void run() { doSample(); //调用startDump 的时候设置true了,stop时设置false if (mShouldSample.get()) { HandlerThreadFactory.getTimerThreadHandler() .postDelayed(mRunnable, mSampleInterval); } } };
可以看到,调用doSample
之后又通过Handler执行mRunnable,等于是循环调用doSample
,直到stopDump
被调用。
doSample方法有两个类实现,StackSampler和CpuSampler,分析堆栈就看StackSampler
的doSample
方法
protected void doSample() { StringBuilder stringBuilder = new StringBuilder(); // 获取堆栈信息 for (StackTraceElement stackTraceElement : mCurrentThread.getStackTrace()) { stringBuilder .append(stackTraceElement.toString()) .append(BlockInfo.SEPARATOR); } synchronized (sStackMap) { // LinkedHashMap中数据超过100个就remove掉链表最前面的 if (sStackMap.size() == mMaxEntryCount && mMaxEntryCount > 0) { sStackMap.remove(sStackMap.keySet().iterator().next()); } //放入LinkedHashMap,时间作为key,value是堆栈信息 sStackMap.put(System.currentTimeMillis(), stringBuilder.toString()); } }
所以,BlockCanary
能做到连续调用几个方法也能准确揪出耗时是哪个方法,是采用开启循环去获取堆栈信息并保存到LinkedHashMap的方式,避免出现误判或者漏判。核心代码就先分析到这里,其它细节大家可以自己去看源码。
这种方式可以了解一下。
通过一个监控线程,每隔1秒向主线程消息队列的头部插入一条空消息。假设1秒后这个消息并没有被主线程消费掉,说明阻塞消息运行的时间在0~1秒之间。换句话说,如果我们需要监控3秒卡顿,那在第4次轮询中,头部消息依然没有被消费的话,就可以确定主线程出现了一次3秒以上的卡顿。
编译过程插桩(例如使用AspectJ),在方法入口和出口加入耗时监控的代码。 原来的方法:
public void test(){ doSomething(); }
通过编译插桩之后的方法类似这样
public void test(){ long startTime = System.currentTimeMillis(); doSomething(); long methodTime = System.currentTimeMillis() - startTime;//计算方法耗时 }
当然,原理是这样,实际上可能需要封装一下,类似这样
public void test(){ methodStart(); doSomething(); methodEnd(); }
在每个要监控的方法的入口和出口分别加上methodStart
和methodEnd
两个方法,类似插桩埋点。
当然,这种插桩的方法缺点比较明显:
需要注意:
监控到了问题就要开始去优化了,针对“性能优化”这个要点,献上一份阿里大佬整理的Android性能优化实战手册,从各个方面对目标产品进行全方位的“优化”,让产品的性能从各个方面得到提升,希望大家喜欢。
这份《Android360°全方面性能调优》一共有722页,4个大点,25个小章节,不仅仅有详细的底层原理的解析,还有大厂的实践案例。
有需要的朋友,文末有免费领取方式~
六大原则
设计模式:结构型模式
设计模式:创建型模式
数据结构
算法
启动速度与执行效率优化
布局检测与优化
内存优化
耗电优化
APK 大小优化
屏幕适配
OOM 问题原理解析
ANR 问题解析
Crash 监控方案
分布式版本控制系统 Git
自动化构建系统 Gradle
Gradle Transform API 的基本使用
自定义插件开发
插件实战
启动速度
流畅度
抖音在 APK 包大小资源优化的实践
优酷响应式布局技术全解析
网络优化
手机淘宝双十一性能优化项目揭秘
高德 APP 全链路源码依赖分析
彻底干掉OOM的实战经验分享
微信 Android终端内存优化实践
如果你也想提升自己移动开发的性能优化技术,或者是正在准备移动开发岗的面试,我觉得这份笔记你必定不能错过。