六、 分析工具
工具对比
使用指南
- 需要分析Native代码,选Simpleperf
- 需要分析系统调用,选Systrace
- 需要分析应用流程和耗时,选TraceView / 插桩之后的Systrace
- 需要分析其他应用,选Nanoscope
- 灰度环境分析Java代码,选Rhea
七、 卡顿指标
怎么用一个指标直观反映卡顿呢?
监测指标
流畅Smoothness计算
- 当Vsync信号到达时会会调用HAL层的HWComposer.vsync()函数,通知HWComposer引擎进行GPU渲染和显示,,然后发送Vsync信号给SurfaceFinger处理
- SurfaceFinger接收到Vsync信号后,调用SurfaceFlinger的addResyncSample函数用来处理 Vsync 信号,addResyncSample函数可以将App的渲染帧同步到显示器的刷新时间,以避免出现撕裂和卡顿等问题。
- EventThread通过onVsyncEvent函数将Vsync信号分发给需要使用Vsync信号的App,实现更平滑和流畅的渲染效果
- 当系统收到显示器的 Vsync 信号时,DisplayEventReceiver.onVsync() 函数会被调用,并将时间戳和Displayer物理属性传递给App
- App收到时间戳和Displayer物理属性后,FrameHandler可以帮助应用程序将渲染帧与 Vsync 信号同步,当 FrameHandler 接收到 Vsync 信号时,FrameHandler会调用 sendMessage() 方法,并将帧同步消息作为参数传递给该方法
- 流畅度存在哪些痛点问题?
流畅度存在两个痛点:
第一个痛点是: 数据量大,不方便统计,导致淹没真实卡顿Case。
第二个痛点是: 不能简单使用平均值和方差。因为不同设备的卡顿标准线不一样,我们应该按照设备等级划分标注线
流畅度指标是怎样衡量的?
- 指标一: 流畅度评分
- 压缩数据
- 加权放大卡顿
- 指标二: XPM评分(denzelzhou):离散程度
- 帧绘制时长到标准绘制时长的距离(点到线的距离)
- 距离标准绘制时长越远就越卡顿
八、监测SOP
监测范围
慢函数监测
技术需求: 通过外部配置阈值记录Android慢函数,如果超过阈值,那么将慢函数方法名和耗时间信息记录在本地JSON文件
思路:
- 首先定义一个类 SlowFunctionClassVisitor,继承自 ClassVisitor,用于实现对类的字节码的修改。
- 在 SlowFunctionClassVisitor 中重写 visitMethod 方法,用于实现对方法的字节码的修改。在 visitMethod 方法中,先调用父类的 visitMethod 方法,然后使用 ASM 的 API 生成新的方法字节码,并将原来的方法字节码替换为新的方法字节码。
- 在生成新的方法字节码时,使用 Label 和 JumpInsnNode 等 ASM 的 API 插入代码,实现对方法的耗时进行判断。如果方法耗时超过阈值,则记录慢函数信息,并将其写入本地 JSON 文件中。
- 为了实现记录慢函数信息和将其写入本地 JSON 文件中,使用了 org.json.JSONObject 和 java.io.FileWriter 等相关的 API。
假设我们要记录的慢函数是指执行时间超过100ms的函数,并且阈值的配置方式是通过一个配置文件,其中包含一个键值对 "slow_function_threshold=100",存放在assets目录下的config.json文件中。
首先,在Android Studio中创建一个新的Android项目,并将以下代码添加到build.gradle文件的dependencies块中:
dependencies { implementation 'org.ow2.asm:asm:9.2' implementation 'org.ow2.asm:asm-util:9.2' }
接下来,我们需要创建一个ASM的ClassVisitor,用于在方法调用前后插入代码。
public class SlowFunctionClassVisitor extends ClassVisitor { private String className; public SlowFunctionClassVisitor(ClassVisitor classVisitor) { super(Opcodes.ASM9, classVisitor); } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { className = name; super.visit(version, access, name, signature, superName, interfaces); } @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions); return new SlowFunctionMethodVisitor(Opcodes.ASM9, mv, access, name, descriptor, className); } private static class SlowFunctionMethodVisitor extends AdviceAdapter { private final String methodName; private final String className; protected SlowFunctionMethodVisitor(int api, MethodVisitor mv, int access, String name, String descriptor, String className) { super(api, mv, access, name, descriptor); this.methodName = name; this.className = className; } private static final String startTimeFieldName = "_start_time"; @Override protected void onMethodEnter() { //在方法进入时插入代码 visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); visitLdcInsn("enter " + methodName); visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); visitVarInsn(Opcodes.ALOAD, 0); visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false); visitFieldInsn(Opcodes.PUTFIELD, className, startTimeFieldName, "J"); } @Override protected void onMethodExit(int opcode) { //在方法退出时插入代码 visitVarInsn(Opcodes.ALOAD, 0); visitFieldInsn(Opcodes.GETFIELD, className, startTimeFieldName, "J"); visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false); visitInsn(Opcodes.LSUB); visitVarInsn(Opcodes.LSTORE, 2); Label l1 = new Label(); visitVarInsn(Opcodes.LLOAD, 2); visitLdcInsn(100L); // 100ms visitInsn(Opcodes.LCMP); visitJumpInsn(Opcodes.IFLE, l1); // 超过阈值,记录慢函数信息 visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); visitLdcInsn("exit " + methodName + " cost " + Long.toString(2L) + " ms"); visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); // 加载阈值 visitLdcInsn("slow_function_threshold"); visitMethodInsn(Opcodes.INVOKESTATIC, "android/content/res/Resources", "getSystem", "()Landroid/content/res/Resources;", false); visitMethodInsn(Opcodes.INVOKEVIRTUAL, "android/content/res/Resources", "getAssets", "()Landroid/content/res/AssetManager;", false); visitLdcInsn("config.json"); visitMethodInsn(Opcodes.INVOKEVIRTUAL, "android/content/res/AssetManager", "open", "(Ljava/lang/String;)Ljava/io/InputStream;", false); visitTypeInsn(Opcodes.NEW, "org/json/JSONObject"); visitInsn(Opcodes.DUP); visitTypeInsn(Opcodes.NEW, "java/io/InputStreamReader"); visitInsn(Opcodes.DUP); visitVarInsn(Opcodes.ALOAD, 4); visitMethodInsn(Opcodes.INVOKESPECIAL, "java/io/InputStreamReader", "<init>", "(Ljava/io/InputStream;)V", false); visitMethodInsn(Opcodes.INVOKESPECIAL, "org/json/JSONObject", "<init>", "(Ljava/io/Reader;)V", false); visitLdcInsn("slow_function_threshold"); visitMethodInsn(Opcodes.INVOKEVIRTUAL, "org/json/JSONObject", "optInt", "(Ljava/lang/String;)I", false); // 比较耗时和阈值 visitVarInsn(Opcodes.LLOAD, 2); visitInsn(Opcodes.LCMP); visitVarInsn(Opcodes.ILOAD, 5); Label l2 = new Label(); visitJumpInsn(Opcodes.IF_ICMPLE, l2); // 超过阈值,写入本地JSON文件 visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); visitLdcInsn("write to local json file: " + methodName + " cost " + Long.toString(2L) + " ms"); visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); visitTypeInsn(Opcodes.NEW, "org/json/JSONObject"); visitInsn(Opcodes.DUP); visitVarInsn(Opcodes.ALOAD, 0); visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Class", "getName", "()Ljava/lang/String;", false); visitLdcInsn(methodName); visitVarInsn(Opcodes.LLOAD, 2); visitMethodInsn(Opcodes.INVOKESPECIAL, "org/json/JSONObject", "<init>", "()V", false); visitVarInsn(Opcodes.ASTORE, 6); visitTypeInsn(Opcodes.NEW, "java/io/FileWriter"); visitInsn(Opcodes.DUP); visitLdcInsn("slow_function.json"); visitMethodInsn(Opcodes.INVOKESPECIAL, "java/io/FileWriter", "<init>", "(Ljava/lang/String;)V", false); visitVarInsn(Opcodes.ASTORE, 7); // 将慢函数信息写入JSON文件 visitVarInsn(Opcodes.ALOAD, 7); visitVarInsn(Opcodes.ALOAD, 6); visitMethodInsn(Opcodes.INVOKEVIRTUAL, "org/json/JSONObject", "toString", "()Ljava/lang/String;", false); visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/FileWriter", "write", "(Ljava/lang/String;)V", false); visitVarInsn(Opcodes.ALOAD, 7); visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/FileWriter", "flush", "()V", false); visitVarInsn(Opcodes.ALOAD, 7); visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/FileWriter", "close", "()V", false); visitLabel(l2); } super.visitInsn(opcode); } }
以上是一段使用ASM技术实现记录Android慢函数的代码。它通过在方法的字节码中插入代码来判断方法耗时,如果超过阈值,就记录慢函数信息,并将其写入本地JSON文件中。在实现过程中,使用了ASM的相关API来生成字节码,并在方法执行过程中进行修改。
流畅性监测
Activity、Service、Receiver 组件生命周期的耗时和调用次数也是我们重点关注的性能问题。
例如Activity的onCreate不应该超过 1 秒,不然会影响用户看到页面的时间。
Service 和 Receiver 虽然是后台组件,不过它们的生命周期也是占用主线程的,也是我们需要关注的问题。 对于组件生命周期我们应该采用更严格的监测,可以全量上报各个组件各个生命周期的启动时间和启动次数。 一般的做法是,通过编译时插桩来做到组件的生命周期监测。
FPS监测
我们需要采集的卡顿数据有: 卡顿次数/交互次数比,帧绘制时间采样. 处理方式可以参考下面的内容:
- 定义一个FPS监测类,该类中包含FPS计算的逻辑:
public class FPSMonitor { private static final long ONE_SECOND = 1000000000L; private long lastTime = System.nanoTime(); private int frameCount = 0; private int fps = 0; public void update() { long currentTime = System.nanoTime(); frameCount++; if (currentTime - lastTime >= ONE_SECOND) { fps = frameCount; frameCount = 0; lastTime = currentTime; } } public int getFps() { return fps; } }
使用ASM字节码框架在编译期间对代码进行插桩,将FPS监测的逻辑插入到游戏或应用程序的主循环中:
public class GameLoop { private FPSMonitor fpsMonitor = new FPSMonitor(); public void loop() { while (true) { long startTime = System.nanoTime(); // 游戏或应用程序的主逻辑// ...// 在主循环中插入FPS监测逻辑 fpsMonitor.update(); int fps = fpsMonitor.getFps(); System.out.println("FPS: " + fps); // 控制FPS为60long elapsedTime = System.nanoTime() - startTime; long sleepTime = (1000000000L / 60) - elapsedTime; if (sleepTime > 0) { try { Thread.sleep(sleepTime / 1000000L); } catch (InterruptedException e) { e.printStackTrace(); } } } } }
FPS监测逻辑被插入到了应用程序的主循环中,在每一帧结束时计算FPS,并将计算结果输出到控制台。我们使用插件可以将上述代码转换成字节码文件。转换代码如下:
public class FpsMonitorClassVisitor extends ClassVisitor { public FpsMonitorClassVisitor(ClassVisitor classVisitor) { super(Opcodes.ASM5, classVisitor); } @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); if (name.equals("loop")) { mv = new FpsMonitorMethodVisitor(mv); } return mv; } private static class FpsMonitorMethodVisitor extends MethodVisitor { public FpsMonitorMethodVisitor(MethodVisitor mv) { super(Opcodes.ASM5, mv); } @Override public void visitCode() { super.visitCode(); mv.visitVarInsn(Opcodes.ALOAD, 0); mv.visitFieldInsn(Opcodes.GETFIELD, "GameLoop", "fpsMonitor", "LFPSMonitor;"); mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "FPSMonitor", "update", "()V", false); mv.visitVarInsn(Opcodes.ALOAD, 0); mv.visitFieldInsn(Opcodes.GETFIELD, "GameLoop", "fpsMonitor", "LFPSMonitor;"); mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "FPSMonitor", "getFps", "()I", false); mv.visitFieldInsn(Opcodes.PUTSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitInsn(Opcodes.SWAP); mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(I)V", false); } } }
创建了一个 FpsMonitorClassVisitor
类来处理插桩逻辑,它继承自 ClassVisitor
类。
在 visitMethod
方法中,我们判断当前访问的方法是否为 loop
方法,如果是,则创建一个 FpsMonitorMethodVisitor
对象来处理该方法的插桩逻辑。
在 FpsMonitorMethodVisitor
中,我们将 FPS 监测逻辑插入到了游戏或应用程序的主循环中,在每一帧结束时计算 FPS,并将计算结果输出到控制台。
最后,我们将创建的 FpsMonitorClassVisitor
对象传递给 ASM 的 ClassReader
,并通过 ClassWriter
来生成修改后的字节码。
Thread监测
由于文件IO开销、线程间的竞争或者锁可能会导致主线程空等,从而导致卡顿。我们可以借助BHook对线程进行监测,需要监测以下两点:
线程数量
需要监测线程数量的多少,以及创建线程的方式。例如有没有使用统一的线程池,这块可以通过 hook 线程的 nativeCreate() 函数,主要用于进行线程收敛,减少线程数量。
线程时间
监测线程的用户时间 utime、系统时间 stime 和优先级。主要是看哪些线程 utime+stime 时间比较多,占用了过多的 CPU。
上报时机
超过设置慢函数的阈值,开始收集慢方法函数和时间,开始上报。
业务降级
可通过参数配置平台配置开关,随时回归线上版本
注意事项
插桩方案问题 | 解决方案 |
包大小增加多少 | 过滤简单函数:i++,getter/setter支持黑名单配置包大小控制2% |
只能监测到应用堆栈耗时,无法收集系统堆栈耗时 | 结合Looper方案,同时上报后台根据卡顿key来聚合分析 |
APP性能影响范围 | 非业务模块不要插桩,避免对稳定性造成影响 |
方案优化
为了提高自身性能,我们是不是可以同步线程方式进一步优化获取堆栈性能
getStackTrace
getStackTrace获取堆栈信息有如下两大业务痛点:
- 性能损耗
- 需要暂停主线程运行
为了解决上述业务痛点,我们可以采取ThreadDump和AsyncGetCallTrace两种解决方案。
方案 | 实现 | 特点 |
StackSampler | 依赖SafePoint收集 | 公开API,稳定,有停顿 |
AsyncGetCallTrace | SIGPROF定时器,Native层收集堆栈 | 非公开API,兼容性问题无停顿 |
StackSampler
在SafePoint处,JVM可以确保所有线程都停止执行,从而保证当前状态的一致性。利用SafePoint,我们可以实现无需挂起主线程的异步堆栈采样,从而避免了主线程被挂起的影响。
public class StackSampler { private static final int MAX_STACK_DEPTH = 32; // 最大堆栈深度 private static final int MAX_STACK_SAMPLES = 100; // 最大缓存采样数 private static final long SAMPLE_INTERVAL = 100L; // 采样间隔,单位:毫秒 private final ConcurrentLinkedQueue<String> stackSamples; // 堆栈采样缓存 private final AtomicBoolean profiling; // 采样标志位 public StackSampler() { stackSamples = new ConcurrentLinkedQueue<>(); profiling = new AtomicBoolean(false); } // SafePoint 采样方法 private void sample() { if (profiling.compareAndSet(false,true)) { // 在 SafePoint 处异步采样堆栈信息 new Thread(() -> { // 延迟一段时间,等待所有线程进入 SafePoint try { Thread.sleep(SAMPLE_INTERVAL); } catch (InterruptedException e) { e.printStackTrace(); } // 采样堆栈信息并添加到缓存中 String stackTrace = getStackTrace(); stackSamples.offer(stackTrace); // 重置采样标志位 profiling.set(false); }).start(); } } // 获取当前线程的堆栈信息 private String getStackTrace() { StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); StringBuilder sb = new StringBuilder(); for (int i = 2; i < Math.min(stackTrace.length,,AX_STACK_DEPTH + 2); i++) { sb.append(stackTrace[i].toString()).append('\n'); } return sb.toString(); } // 输出所有采样结果 public void dump() { int count = 0; String stackTrace; while ((stackTrace = stackSamples.poll()) != null && count < MAX_STACK_SAMPLES) { System.out.println(stackTrace); count++; } } public static void main(String[] args) throws InterruptedException { StackSampler sampler = new StackSampler(); sampler.sample(); // 开始采样 Thread.sleep(MAX_STACK_SAMPLES * SAMPLE_INTERVAL); // 等待采样完成 sampler.dump(); // 输出采样结果 } }
用SafePoint实现了异步堆栈采样,并在每次采样时延迟一段时间,等待所有线程进入SafePoint,以确保采样的堆栈信息是当前线程的真实状态。同时,采样的结果存储在一个线程安全的队列中,等待输出。当采样完成后,通过 dump()
方法输出所有的采样。
另外,为了避免频繁地采样堆栈信息导致性能问题,我们可以通过减少采样频率的方式进行优化。例如,可以通过将采样间隔从10毫秒调整为100毫秒,来减少采样的次数,从而降低对系统性能的影响。
另外,为了更高效地采样和处理堆栈信息,我们可以考虑采用缓存和批量处理的策略。例如,可以将采样的结果存储在一个缓存队列中,当队列达到一定大小时再进行批量处理,从而减少对队列的频繁访问和操作,提高程序的执行效率。
AsyncGetCallTrace
通过使用SIGPROF定时器,Native层收集堆栈方式,优化getStackTrace获取堆栈需要暂停主线程运行和相关性能问题
#include <signal.h> #include <sys/time.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <execinfo.h> #define SAMPLE_INTERVAL 100 // 采样间隔,单位:毫秒 #define MAX_STACK_DEPTH 32 // 最大堆栈深度 #define MAX_STACK_SAMPLES 100 // 最大缓存采样数 volatile sig_atomic_t profiling = 0; // 采样标志位 // SIGPROF 信号处理函数 void profiling_handler(int signum) { profiling = 1; // 标记需要采样堆栈信息 } // 开始采样 void start_profiling() { struct sigaction sa; struct itimerval timer; // 注册 SIGPROF 信号处理函数 sa.sa_handler = profiling_handler; sa.sa_flags = SA_RESTART; sigemptyset(&sa.sa_mask); sigaction(SIGPROF, &sa, NULL); // 设置定时器 timer.it_value.tv_sec = SAMPLE_INTERVAL / 1000; timer.it_value.tv_usec = (SAMPLE_INTERVAL % 1000) * 1000; timer.it_interval = timer.it_value; setitimer(ITIMER_PROF, &timer, NULL); } // 停止采样 void stop_profiling() { struct itimerval timer; // 关闭定时器 timer.it_value.tv_sec = 0; timer.it_value.tv_usec = 0; timer.it_interval = timer.it_value; setitimer(ITIMER_PROF, &timer, NULL); } // 收集堆栈信息并输出到标准输出流 void dump_stack() { void *stack[MAX_STACK_DEPTH]; int depth = backtrace(stack, MAX_STACK_DEPTH); if (depth > 0) { backtrace_symbols_fd(stack, depth, STDOUT_FILENO); } } int main() { start_profiling(); // 开始采样 int sample_count = 0; while (sample_count < MAX_STACK_SAMPLES) { if (profiling) { // 如果需要采样堆栈信息 profiling = 0; // 重置采样标志位 dump_stack(); // 收集堆栈信息 sample_count++; // 统计采样数 } } stop_profiling(); // 停止采样 return 0; }
start_profiling()
和 stop_profiling()
函数用于开启和关闭 SIGPROF 定时器,定时器的时间间隔由 SAMPLE_INTERVAL
宏定义指定。
profiling_handler()
函数是 SIGPROF 信号的处理函数,每次接收到信号后,将 profiling
变量置为 1。dump_stack()
函数用于收集堆栈信息,通过 backtrace()
函数获取堆栈信息,并输出到标准输出流中。
在 main()
函数中,循环收集堆栈信息,直到采样数达到最大值为止,最大采样数由 MAX_STACK_SAMPLES
宏定义指定。
通过 SIGPROF 定时器和信号处理函数的方式,可以在 Native 层收集堆栈信息,避免了在 Java 层获取堆栈信息的开销和性能问题。不过需要注意的是,堆栈采样对应用程序的性能有一定影响,需要权衡好采样间隔和采样深度等参数
九、总结展望
当测试提出卡顿问题,测试会新建Bug单给责任人处理。导致卡顿的原因有很多,比如函数非常耗时、I/O 非常慢、线程或锁间竞争等。
随着移动端用户越来越注重产品体验,APM系统也逐渐成为互联网公司重要基础设施。
卡顿是衡量App性能的一个重要指标,建设卡顿APM监测平台是Android卡顿优化长效治理关键。
同时,通过建设卡顿APM监测平台,帮助业务找到卡顿原因也是架构组TL考核评测员工重点OKR指向。
为了解决卡顿标准不明确问题,小木箱今天和大家着重探讨了卡顿监测的方方面面。
如果小木箱的文章对你有所帮助,那么欢迎关注小木箱的公众号: 小木箱成长营。我是小木箱,我们下一篇见~
参考链接