Android卡顿优化分析及解决方案

目录

写在前面

一、卡顿介绍及优化工具选择

1.1、卡顿问题介绍

1.2、优化工具选择

二、自动化卡顿检测方案及优化

2.1、为什么需要自动化卡顿检测

2.2、自动化卡顿检测方案原理

2.3、AndroidPerformanceMonitor

三、ANR实战分析

3.1、ANR介绍

3.2、ANR实战分析

3.3、线上ANR监控方案

四、应用界面秒开

4.1、界面秒开率统计

五、优雅监控耗时盲区

5.1、为什么会出现耗时盲区

5.2、耗时盲区监控线下方案

5.3、耗时盲区监控线上方案


写在前面

最近过的有点累,身心俱疲,成年人的世界太难了,庆幸的是自己还是坚持着把今天的内容整理完了😊!

在上一篇中介绍了Android性能优化系列专栏中的布局优化——《你想知道的布局优化都在这里了》,今天就继续来说一下另外一个比较重要的性能优化点,也就是Android中的卡顿优化。

一、卡顿介绍及优化工具选择

1.1、卡顿问题介绍

对于用户来说我们的应用当中的很多性能问题比如内存占用高、流量消耗快等不容易被发现,但是卡顿却很容易被直观的感受到,对于开发者来说,卡顿问题又难以定位,那么它究竟难在哪里呢?

卡顿问题难点:

  • 产生原因错综复杂:代码、内存、绘制、IO等都有可能导致卡顿
  • 不易复现:线上卡顿问题在线下难以复现,这和用户当时的系统环境有很大关系(比如当时用户磁盘空间不足导致的IO写入性能下降从而引发了卡顿,所以我们最好能记录在发生卡顿时用户当时的场景)

1.2、优化工具选择

①、CPU Profiler

  • 图形化的形式展示执行时间、调用栈等
  • 信息全面,包含所有线程
  • 运行时开销严重,整体都会变慢

使用方式:

  • Debug.startMethodTracing("");
  • Debug.stopMethodTracing("");
  • 生成文件在sd卡:Android/data/packagename/files

②、Systrace

  • 监控和跟踪Api调用,线程运行情况,生成Html报告
  • 要求是在API18以上使用,所以这里推荐使用TraceCompat

使用方式:

Systrace优点

  • 轻量级,开销小
  • 直观反映CPU利用率
  • 右侧Alert一栏会给出相关建议

③、StrictMode

  • Android2.3引入的工具类——严苛模式,Android提供的一种运行时检测机制,帮助开发者检测代码中的一些不规范的问题
  • 包含:线程策略和虚拟机策略检测
  • 线程策略:1、自定义的耗时调用,detectCustomSlowCalls() 2、磁盘读取操作,detectDiskReads 3、网络操作,detectNetwork  
  • 虚拟机策略:1、Activity泄露,detectActivityLeaks() 2、Sqlite对象泄露,detectLeakedSqliteObjects 3、检测实例数量,setClassInstanceLimit()

现在到之前的Demo中来实际使用一下,找到我们的Application类,新增一个方法initStrictMode():

private void initStrictMode(){
        if (DEV_MODE) {
            StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
                    .detectCustomSlowCalls() //API等级11,使用StrictMode.noteSlowCode
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()// or .detectAll() for all detectable problems
                    .penaltyLog() //在Logcat 中打印违规异常信息
                    .build());
            StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
                    .detectLeakedSqlLiteObjects()
                    .setClassInstanceLimit(FeedBean.class, 1)
                    .detectLeakedClosableObjects() //API等级11
                    .penaltyLog()
                    .build());
        }
    }

首先在这里加了一个标记位DEV_MODE,也就是只在线下开发的时候才会走到这个方法。对于线程策略使用方式就是StrictMode.setThreadPolicy,然后就是一些配置比如磁盘的读取、写入、网络监控等,如果出现了违规情况我们使用的是penaltyLog()方法在日志中打印出违规信息,这里你也可以选择别的方式。对于虚拟机策略这里是配置需要检测出Sqlite对象的泄露,并且这里还设置某个类的实例数量是x,如果大于x它应该会被检测出不合规。

二、自动化卡顿检测方案及优化

2.1、为什么需要自动化卡顿检测

  • 上面介绍的几种系统工具只适合线下实际问题作针对性分析
  • 线上及测试环节需要自动化检测方案帮助开发者定位卡顿,记录卡顿发生时的场景

2.2、自动化卡顿检测方案原理

  • 消息处理机制,一个线程不管有多少Handler都只会有一个Looper对象存在,主线程中执行的任何代码都会通过Looper.loop()方法执行,loop()函数中有一个mLogging对象
  • mLogging对象在每个message处理前后都会被调用
  • 主线程如果发生卡顿,则一定是在dispatchMessage方法中执行了耗时操作,然后我们可以通过mLogging对象对dispatchMessage执行的时间进行监控

我在这里从Looper.java的loop()方法的源码中截取了一段代码,大家看下:

// This must be in a local variable, in case a UI event sets the logger
if (logging != null) {
    logging.println(">>>>> Dispatching to " + msg.target + " " +
        msg.callback + ": " + msg.what);
}

......
此处省略一大段代码

if (logging != null) {
    logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}            

它在Message执行的前后都打印了一段日志并且是不同的,所以我们可以通过这个来判断Message处理的开始和结束的时机。

具体的实现原理:

  • 使用 Looper.getMainLooper.setMessageLogging()来设置自己的logging
  • 匹配>>>>> Dispatching,阈值之后在子线程中执行任务(获取堆栈及场景信息,比如内存大小、电量、网络状态等)
  • 匹配<<<<< Finished,说明在指定的阈值之内message被执行完成没有发生卡顿,任务启动之前取消掉

2.3、AndroidPerformanceMonitor

下面我们在项目中实际使用一下:

首先在application中进行初始化:

//BlockCanary初始化
BlockCanary.install(this,new AppBlockCanaryContext()).start();

这里入参有一个AppBlockCanaryContext,这个是我们自定义BlockCanary配置的一些信息:

public class AppBlockCanaryContext extends BlockCanaryContext {
  
    public String provideQualifier() {
        return "unknown";
    }

    public String provideUid() {
        return "uid";
    }

    public String provideNetworkType() {
        return "unknown";
    }

    public int provideMonitorDuration() {
        return -1;
    }

    //设置卡顿阈值为500ms
    public int provideBlockThreshold() {
        return 500;
    }

    public int provideDumpInterval() {
        return provideBlockThreshold();
    }

    public String providePath() {
        return "/blockcanary/";
    }

    public boolean displayNotification() {
        return true;
    }

    public boolean zip(File[] src, File dest) {
        return false;
    }

    public void upload(File zippedFile) {
        throw new UnsupportedOperationException();
    }

    public List<String> concernPackages() {
        return null;
    }

    public boolean filterNonConcernStack() {
        return false;
    }

    public List<String> provideWhiteList() {
        LinkedList<String> whiteList = new LinkedList<>();
        whiteList.add("org.chromium");
        return whiteList;
    }

    public boolean deleteFilesInWhiteList() {
        return true;
    }

    public void onBlock(Context context, BlockInfo blockInfo) {
        Log.i("jarchie","blockInfo "+blockInfo.toString());
    }
}

然后在MainActivity中模拟一次卡顿,让当前线程休息2s,然后来看一下这个组件会不会通知我们:

        try {
            Thread.currentThread().sleep(2000);
        }catch (InterruptedException e){
            e.printStackTrace();
        }        

当我们把程序运行之后,会发现手机桌面上出现了一个Blocks的图标,这个玩意和之前我们使用LeakCanary的时候有点像哈,然后点进去果然发现了刚刚的Block信息,如下所示:

这里详细的打出了当前的CPU核心数、进程名、内存情况、block的堆栈信息等等,我们就可以根据这些堆栈找到对应哪个类的哪一行代码出现了问题,然后进行修改即可。

对于这种方案的总结如下:

  • 非侵入式方案:可以监控在主线程中执行的任何方法并且不需要我们手动埋点
  • 方便精准,定位到代码某一行

这种方案网上有很多的使用资料,但是实际上它也是存在一定的问题的,自动检测方案的问题:

  • 确实卡顿了,但卡顿堆栈可能不准确
  • 和OOM一样,最后的堆栈只是表象,不是真正的问题

举个栗子:主线程在T1 T2时间段内发生了卡顿,卡顿检测方案获取卡顿堆栈的信息是T2时刻,但是实际情况可能是整个这一段时间之内某个函数的耗时过长导致的卡顿,捕获堆栈的时机此时该函数已经执行完成,所以在T2时刻捕获的堆栈信息并不能准确的反应现场情况。

自动检测方案优化

  • 获取监控周期内的多个堆栈,而不仅是最后一个,这样如果发生卡顿,由于我们有多个堆栈信息,所以可以推测出整个周期内究竟发生了什么,能够更加清晰的还原卡顿现场

海量卡顿堆栈处理:高频卡顿上报量太大,会导致服务端有压力

  • 分析:一个卡顿下多个堆栈大概率有重复
  • 解决:对一个卡顿下堆栈进行hash排重,找出重复的堆栈
  • 效果:极大的减少展示量同时更高效找到卡顿堆栈

三、ANR实战分析

3.1、ANR介绍

ANR分类:Application Not Responding

  • KeyDispatchTimeout,5s:按键或者触摸事件在特定的时间内无响应
  • BroadcastTimeout,前台10s,后台60s:BroadcastReceiver在特定的时间内没有响应完成
  • ServiceTimeout,前台20s,后台200s:Service在特定的时间内没有处理完成

ANR执行流程:

  • 发生ANR
  • 进程接收异常终止信号,开始写入进程ANR信息(包含当前所有线程的堆栈信息以及CPU、IO等的使用情况)
  • 弹出ANR提示框,告知用户选择关闭还是继续等待(根据ROM不同表现也不同,有些手机厂商可能会去掉)

ANR解决套路:

  • adb pull data/anr/traces.txt存储路径
  • 详细分析:CPU、IO、锁

3.2、ANR实战分析

接下来我们来模拟一次ANR的出现,回到项目中,首先在MainActivity中创建一个线程,并且让它持有当前Activity20秒,然后在主线程中我们再次申请这把锁,让它弹一个吐司,代码也都很简单,如下所示:

将项目跑起来,此时我操作系统的返回键,然后就真的出现了ANR异常,系统弹出了一个弹框询问你是继续等待还是关闭应用,点击关闭应用,此时traces.txt文件已经生成了。其实从代码中分析也可以看出添加的这些代码肯定会造成ANR异常,主线程要申请MainActivity.this这把锁,但此时这把锁是被我们开始创建的异步线程所持有着,必须要等到异步线程执行完成之后才能继续往下执行,意思也就是MainActivity的onCreate()方法要在这里卡顿20s。那么我们该如何将生成的traces.txt文件导出到我们本地进行分析呢?下面一起来看一下:

这里我们使用adb pull data/anr/traces.txt这个命令进行导出,如果你的手机和我的一样是高版本的,可能你会出现以下问题,它说找不到,这个问题真的很蛋疼,然后我使用adb shell命令,进到anr的目录下,查看该文件夹下的文件是有的:

此时我尝试直接pull这个文件名,很遗憾,依然不行,它会告诉你没有权限,这里可能需要root手机,我没有进行尝试,有条件的可以尝试一下root之后是否可以:

最后无奈我们使用adb bugreport这个命令,此命令导出一个zip的压缩包,这个过程可能会有点慢,耐心等待就OK了:

然后它会将文件导出到你命令行所在的当前目录下,找到压缩包解压之后,在FS/data/anr这个目录下就可以看到anr文件了:

然后我将我需要的anr文件打开,然后搜索应用包名,这里就看到了anr发生的地方以及具体的原因:

3.3、线上ANR监控方案

  • 通过FileOberver监控文件变化,如果此文件发生了变化,那就说明发生了ANR,将文件上传服务器进行详细分析,注意这种方式在高版本有权限问题,也就意味着在高版本中我们无法监控此文件的变化
  • 为了解决上面的问题,于是乎有了ANR-WatchDog

①、ANR-WatchDog

现在到Demo中实际应用一下,首先在build.gradle中引入这个库,然后在application中的onCreate()方法中作初始化操作:

new ANRWatchDog().start();

然后运行程序之后我这里按返回键进行按键事件交互,程序直接Crash掉了,这是ANR-WatchDog对于ANR的默认处理,然后来看下日志:

从日志我们可以看到它抛出的异常是在MainActivity的76行,同时告知我们main Thread的状态是blocked的,通过去代码中查找发现第76行正好就是发生锁冲突的地方:

②、ANR-WatchDog源码解析

下面我们跟着源码来看一下这个库它的实现原理是什么?这个库一共就两个类:ANRError和ANRWatchDog:

首先来看ANRWatchDog实际上是继承自Thread类,本质上它是一个线程,对于一个线程来说最重要的就是它的run()方法:

@Override
    public void run() {
        setName("|ANR-WatchDog|");

        int lastTick;
        int lastIgnored = -1;
        while (!isInterrupted()) {
            lastTick = _tick;
            _uiHandler.post(_ticker);
            
            。。。。省略部分代码,见下方分析中的代码
        }
    }

在run()方法中首先是对这个线程进行命名,接着声明了一个变量lastTick,然后进行while循环,在循环中它通过_uiHandler post了一个runnable即_ticker:

private final Runnable _ticker = new Runnable() {
        @Override public void run() {
            _tick = (_tick + 1) % Integer.MAX_VALUE;
        }
    };

这个runnable内部是进行+1的操作,接着这个线程就会sleep一段时间:

try {
    Thread.sleep(_timeoutInterval);
}
catch (InterruptedException e) {
    _interruptionListener.onInterrupted(e);
    return ;
}

之后就会进行检测:

// If the main thread has not handled _ticker, it is blocked. ANR.
            if (_tick == lastTick) {
                if (!_ignoreDebugger && Debug.isDebuggerConnected()) {
                    if (_tick != lastIgnored)
                        Log.w("ANRWatchdog", "An ANR was detected but ignored because the debugger is connected (you can prevent this with setIgnoreDebugger(true))");
                    lastIgnored = _tick;
                    continue ;
                }

                ANRError error;
                if (_namePrefix != null)
                    error = ANRError.New(_namePrefix, _logThreadsWithoutStackTrace);
                else
                    error = ANRError.NewMainOnly();
                _anrListener.onAppNotResponding(error);
                return;
            }

具体的是检测刚刚的runnable是否被执行,其实就是判断“+1”的操作有没有被执行,如果+1成功则判定runnable执行成功,说明主线程未发生卡顿,如果没有+1成功,则判定runnable未被执行,说明主线程已经发生了卡顿,并且它会拿到MainThread,通过主线程拿到堆栈信息,然后返回一个ANRError:

static ANRError NewMainOnly() {
        final Thread mainThread = Looper.getMainLooper().getThread();
        final StackTraceElement[] mainStackTrace = mainThread.getStackTrace();

        return new ANRError(new $(getThreadTitle(mainThread), mainStackTrace).new _Thread(null));
    }

然后通过anrListener调用onAppNotResponding()方法:即:_anrListener.onAppNotResponding(error);

public interface ANRListener {
    public void onAppNotResponding(ANRError error);
}

这个方法的默认实现是直接将这个error给throw出去,这样就会导致程序崩溃:

private static final ANRListener DEFAULT_ANR_LISTENER = new ANRListener() {
        @Override public void onAppNotResponding(ANRError error) {
            throw error;
        }
    };

ANR-WatchDog的实现原理:

从上面我们知道ANR-Watchdog在每次发生ANR的时候,它是直接将异常抛出的,这样做其实是有一个问题的,就是每次发生ANR的时候应用都会异常崩溃掉,这其实给人的用户体验就很不好,那该怎么办呢?如果你继续阅读官方文档你会发现它有一个ANRListener,你可以覆写这个listener,在onAppNotResponding这个回调方法中自己实现ANR事件的定制化处理。好,如此一来我们就可以很方便的拿到ANR的堆栈信息,然后就可以上报服务端,进行日志的统计分析。

总结:①、非侵入式;②、弥补高版本无权限读取trace.txt文件问题;③、两种方式结合使用

区别:

  • AndroidPerformanceMonitor:监控主线程的每一个Message的执行,在主线程每个msg前后加时间戳,然后可以计算出每个msg的具体执行时间(注意:一般情况下msg的执行时间是很短的,还达不到ANR级别)
  • ANR-Watchdog:不管你是如何执行的,只管最终结果,sleep 5s后看值是否修改过,若没被改则发生ANR
  • 前者适合监控卡顿,后者适合补充ANR监控

四、应用界面秒开

应用界面秒开的实现方案:

  • SysTrace查看CPU运行程度,以及启动优化部分的优雅异步+优雅延迟初始化
  • 界面异步Inflate、X2C、绘制优化
  • 提前获取页面数据

4.1、界面秒开率统计

  • onCreate到onWindowFocusChanged
  • 实现特定接口,在具体方法中统计耗时

这里来介绍一个开源方案:Lancet,它是一个轻量级的Android AOP框架:

  • 编译速度快,支持增量编译
  • API简单,没有任何多余代码插入apk(包体积优化)
  • https://github.com/eleme/lancet
  • @Proxy通常用于对系统API调用的Hook
  • @Insert通常用于操作APP与Library的类

下面我们来具体使用一下这个库,我们使用这个库来统计页面的onCreate()方法到onWindowsFocusChanged()方法之间的加载耗时情况:

①、添加依赖

这里大家可以参考github上的使用方式进行依赖的添加,主要是两个部分:工程的build.gradle和app module的build.gradle:

classpath 'me.ele:lancet-plugin:1.0.6' //工程的build.gradle

apply plugin: 'me.ele.lancet' //module的build.gradle
//lancet
compileOnly 'me.ele:lancet-base:1.0.6'

②、编写一个实体类,定义用于上述两个方法时间统计的成员变量:

public class ActivityLive {

    public long mOnCreateTime;
    public long mOnWindowsFocusChangedTime;

}

③、创建统计方法的工具类,在类中分别编写onCreate()和onWindowFocusChanged()方法,关于具体的注解的使用含义详见代码注释:

public class ActivityHooker {

    public static ActivityLive mLive;

    static {
        mLive = new ActivityLive();
    }

    //@Insert:使用自己程序中自己的一些类需要添加,值这里就指定onCreate()方法,
    //可配置项mayCreateSuper是当目标函数不存在的时候可以通过它来创建目标方法
    //@TargetClass:框架知道要找的类是哪个,可配置项Scope.ALL:匹配value所指定的所有类的子类
    @Insert(value = "onCreate",mayCreateSuper = true)
    @TargetClass(value = "androidx.appcompat.app.AppCompatActivity", scope = Scope.ALL)
    protected void onCreate(Bundle savedInstanceState) {
        mLive.mOnCreateTime = System.currentTimeMillis();
        Origin.callVoid(); //无返回值的调用
    }


    //注解含义同上面onCreate()
    @Insert(value = "onWindowFocusChanged",mayCreateSuper = true)
    @TargetClass(value = "androidx.appcompat.app.AppCompatActivity", scope = Scope.ALL)
    public void onWindowFocusChanged(boolean hasFocus) {
        mLive.mOnWindowsFocusChangedTime = System.currentTimeMillis();
        Log.i("onWindowFocusChanged","---"+(mLive.mOnWindowsFocusChangedTime - mLive.mOnCreateTime));
        Origin.callVoid();
    }

}

下面运行程序来看下结果:

界面秒开监控纬度

  • 总体耗时:onCreate()--->onWindowsFocusChanged(),更精确的时间可以通过自定义接口来实现
  • 生命周期耗时:onCreate()、onStart()、onResume()等等
  • 生命周期间隔耗时:各个生命周期耗时时间差

五、优雅监控耗时盲区

5.1、为什么会出现耗时盲区

对于一般的监控方案,它的监控指标只是一个大的范围,只是一个数据,比如:

  • 生命周期的间隔
  • onResume到Feed展示的间隔
  • 举个栗子:比如在Activity的生命周期当中postMessage,很有可能在Feed展示之前执行,如果msg耗时1s,那么Feed展示时间也就相对应的延迟1s,如果是200ms,那么自动化卡顿监测方案实际上就监测不到它,但是你的列表展示就相对应的延时200ms

如下代码所示,我首先在Activity的onCreate()方法中发送了一个msg,并且打印了一条日志

然后在列表展示的第一条同样打印一条日志:

最后输出的结果如下:

从执行结果来看,这个MSG是跑在Feed展示之前的,这个msg模拟的耗时是1s,此时用户看到界面的时间也就被往后延迟了1s。其实这个场景还是很常见的,因为我们可能由于某些业务需求在某个生命周期或者某个阶段及某些第三方的SDK中会做一些handler post的操作,这个操作很有可能会在列表展示之前被执行到,所以出现这种耗时的盲区,既普遍又不好排查。

耗时盲区监控难点

  • 通过细化监控的方式知道盲区时间,但是不清楚在盲区中具体在做什么
  • 线上盲区无从排查

5.2、耗时盲区监控线下方案

这种场景非常适合之前说过的一个工具,你能想到是什么吗?————答案是TraceView:

  • 特别适合一段时间内的盲区监控
  • 线程具体时间做了什么,一目了然

5.3、耗时盲区监控线上方案

  • 方法一:主线程的所有方法都是通过msg来执行的,那么我们是否可以通过mLogging来做盲区监测呢?mLogging确实知道主线程发送了msg执行了一段代码,但是它并不清楚msg具体的调用栈信息,它所能获取到的调用栈信息都是系统回调它的,它并不清楚msg是被谁抛出的,这个只能说可以,但是不太好。
  • 方法二:是否可以通过AOP的方式来切Handler的sendMessage()等方法呢?使用这种方式我们可以知道发送msg的堆栈信息,但是这种方案并不清楚具体的执行时间,你只知道这个msg是在哪里被发送的,你并不知道它会在什么时候执行。

可行性方案:

  • 使用统一的Handler:定制具体方法:sendMessageAtTime()和dispatchMessage(),对于发送消息,不管你使用哪个方法发送,最终都会走到这个sendMessageAtTime(),而处理消息同样的道理,最终都是调用dispatchMessage()
  • 替换项目中所有使用的Handler,将其替换为自己定制的Handler(https://github.com/didi/DroidAssist

嗯,写着写着天儿就黑了,又到了饭点了。OK,关于Android卡顿优化的部分,今天就先到这里了,后面如果有需要补充的,再进行补充吧,拜了个拜,下期再会!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章