在上一篇文章中# Android进阶宝典 — KOOM线上APM监控最全剖析,我具体介绍了关于线上App内存监控的计划战略,其实除了内存指标之外,经常有用户反馈卡顿问题,其实这种问题是最难定位的,由于不像Crash有完整的仓库信息,而且卡顿问题可能转瞬即逝,那么怎么健全完整的线上卡顿监控,可能就需求咱们关于Android体系的音讯处理有一个清晰的认知。

1 Handler音讯机制

这儿我不会完整的从Handler源码来剖析Android的音讯体系,而是从Handler自身的特性引申出线上卡顿监控的战略计划。

1.1 计划承认

首要当咱们发动一个App的时分,是由AMS通知zygote进程fork出主进程,其间主进程的入口便是ActivityThread的main办法,在这个办法中敞开Loop死循环来处理体系音讯。

Looper.loop();

在ActivityThread中,有一个内部类ApplicationThread,这个类是system_server的一个署理目标,负责App主进程与system_server进程的通讯(假定对这块有疑问的,可以看之前的文章都有具体的介绍)。

private class ApplicationThread extends IApplicationThread.Stub {
    private static final String DB_INFO_FORMAT = "  %8s %8s %14s %14s  %s";
    @Override
    public final void bindApplication(String processName, ApplicationInfo appInfo,
            ProviderInfoList providerList, ComponentName instrumentationName,
            ProfilerInfo profilerInfo, Bundle instrumentationArgs,
            IInstrumentationWatcher instrumentationWatcher,
            IUiAutomationConnection instrumentationUiConnection, int debugMode,
            boolean enableBinderTracking, boolean trackAllocation,
            boolean isRestrictedBackupMode, boolean persistent, Configuration config,
            CompatibilityInfo compatInfo, Map services, Bundle coreSettings,
            String buildSerial, AutofillOptions autofillOptions,
            ContentCaptureOptions contentCaptureOptions, long[] disabledCompatChanges,
            SharedMemory serializedSystemFontMap) {
        if (services != null) {
            if (false) {
                // Test code to make sure the app could see the passed-in services.
                for (Object oname : services.keySet()) {
                    if (services.get(oname) == null) {
                        continue; // AM just passed in a null service.
                    }
                    String name = (String) oname;
                    // See b/79378449 about the following exemption.
                    switch (name) {
                        case "package":
                        case Context.WINDOW_SERVICE:
                            continue;
                    }
                    if (ServiceManager.getService(name) == null) {
                        Log.wtf(TAG, "Service " + name + " should be accessible by this app");
                    }
                }
            }
            // Setup the service cache in the ServiceManager
            ServiceManager.initServiceCache(services);
        }
        setCoreSettings(coreSettings);
        AppBindData data = new AppBindData();
        data.processName = processName;
        data.appInfo = appInfo;
        data.providers = providerList.getList();
        data.instrumentationName = instrumentationName;
        data.instrumentationArgs = instrumentationArgs;
        data.instrumentationWatcher = instrumentationWatcher;
        data.instrumentationUiAutomationConnection = instrumentationUiConnection;
        data.debugMode = debugMode;
        data.enableBinderTracking = enableBinderTracking;
        data.trackAllocation = trackAllocation;
        data.restrictedBackupMode = isRestrictedBackupMode;
        data.persistent = persistent;
        data.config = config;
        data.compatInfo = compatInfo;
        data.initProfilerInfo = profilerInfo;
        data.buildSerial = buildSerial;
        data.autofillOptions = autofillOptions;
        data.contentCaptureOptions = contentCaptureOptions;
        data.disabledCompatChanges = disabledCompatChanges;
        data.mSerializedSystemFontMap = serializedSystemFontMap;
        sendMessage(H.BIND_APPLICATION, data);
    }
}

咱们可以看到,每个办法的最终,其实都是调用了sendMessage办法,经过Handler发送音讯;为啥会用到Handler呢,是由于App进程与system_server进程通讯是经过Binder完结的,Binder会开辟Binder线程池,那么此时这个办法的调用是在子线程中完结,像bindApplication终究需求调用Application的onCreate办法,但这个办法是在主线程中,因而需求Handler完结线程切换

所以整个App音讯体系都是经过Handler来支撑起来的,看下图

Android进阶宝典 -- Handler应用于线上卡顿监控

由于Android关于音讯的时效性要求十分高,需求一个高速履行的状态,一旦有音讯履行耗时形成堵塞就会产生卡顿,所以经过Handler来监听音讯的履行速度,经过设定阈值判断是否产生卡顿,从而获取仓库音讯来定位问题。

1.2 Looper源码

咱们先去看下Looper源码,看怎么处理分发音讯的

public static void loop() {
    final Looper me = myLooper();
    if (me == null) {
        throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
    }
    if (me.mInLoop) {
        Slog.w(TAG, "Loop again would have the queued messages be executed"
                + " before this one completed.");
    }
    me.mInLoop = true;
    // Make sure the identity of this thread is that of the local process,
    // and keep track of what that identity token actually is.
    Binder.clearCallingIdentity();
    final long ident = Binder.clearCallingIdentity();
    // Allow overriding a threshold with a system prop. e.g.
    // adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'
    final int thresholdOverride =
            SystemProperties.getInt("log.looper."
                    + Process.myUid() + "."
                    + Thread.currentThread().getName()
                    + ".slow", 0);
    me.mSlowDeliveryDetected = false;
    /**在这儿敞开死循环*/
    for (;;) {
        if (!loopOnce(me, ident, thresholdOverride)) {
            return;
        }
    }
}

在Looper的loop办法中,敞开一个死循环,然后调用了loopOnce办法

private static boolean loopOnce(final Looper me,
        final long ident, final int thresholdOverride) {
        /**第一步,从MessagQueue中取出音讯*/
    Message msg = me.mQueue.next(); // might block
    if (msg == null) {
        // No message indicates that the message queue is quitting.
        return false;
    }
    // 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);
    }
    try {
        /**第二步,调用Handler的dispatchMessage办法*/
        msg.target.dispatchMessage(msg);
        if (observer != null) {
            observer.messageDispatched(token, msg);
        }
        dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
    } catch (Exception exception) {
        if (observer != null) {
            observer.dispatchingThrewException(token, msg, exception);
        }
        throw exception;
    } finally {
        ThreadLocalWorkSource.restore(origWorkSource);
        if (traceTag != 0) {
            Trace.traceEnd(traceTag);
        }
    }
    //......
    /**音讯履行完结的打点*/
    if (logging != null) {
        logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
    }
    return true;
}

这儿咱们需求重视的有两个点:

(1)看音讯是怎么被分发履行的,在注释中,我标示了关键的二步;

(2)从音讯被履行之前,到音讯履行之后,有两处打点信息分别为:Dispatching to和Finished to,这个便是代表音讯履行的整个进程,假定咱们可以拿到这两段之间的耗时,是不是就可以完结咱们的计划战略。

经过源码咱们可以看到,这个Printer是咱们可以自定义传入的,那也便是说,咱们可以在咱们自定义的Printer中刺进计时的代码,就可以监控每个音讯履行的耗时了。

public void setMessageLogging(@Nullable Printer printer) {
    mLogging = printer;
}

1.3 Blockcanary原理剖析

所以根据上面的源码剖析,业界有一款适用于卡顿监控的组件 – Blockcanary

implementation 'com.github.markzhai:blockcanary-android:1.5.0'

运用办法:

BlockCanary.install(this, BlockCanaryContext()).start()

所以咱们看一下Blockcanary的源码,它的思维便是咱们提到的经过setMessageLogging办法注入自己的代码。

public void start() {
    if (!mMonitorStarted) {
        mMonitorStarted = true;
        Looper.getMainLooper().setMessageLogging(mBlockCanaryCore.monitor);
    }
}

在start办法中,便是调用了setMessageLogging办法,传入了一个Printer目标,这个完结类便是LooperMonitor,其间需求完结println办法.

class LooperMonitor implements Printer {
    @Override
    public void println(String x) {
        if (mStopWhenDebugging && Debug.isDebuggerConnected()) {
            return;
        }
        /** mPrintingStarted 默认false */
        if (!mPrintingStarted) {
            mStartTimestamp = System.currentTimeMillis();
            mStartThreadTimestamp = SystemClock.currentThreadTimeMillis();
            mPrintingStarted = true;
            startDump();
        } else {
            final long endTime = System.currentTimeMillis();
            mPrintingStarted = false;
            if (isBlock(endTime)) {
                notifyBlockEvent(endTime);
            }
            stopDump();
        }
    }
    private boolean isBlock(long endTime) {
        return endTime - mStartTimestamp > mBlockThresholdMillis;
    }
 }

咱们知道,在Looper的loop办法中,会调用两次print办法,所以在第一次调用println办法的时分,会记载一个体系时间;第2次进入的时分,会再次记一次体系时间,前后两次时间差假定超越一个阈值mBlockThresholdMillis,那么以为是产生了卡顿。

private void notifyBlockEvent(final long endTime) {
    final long startTime = mStartTimestamp;
    final long startThreadTime = mStartThreadTimestamp;
    final long endThreadTime = SystemClock.currentThreadTimeMillis();
    HandlerThreadFactory.getWriteLogThreadHandler().post(new Runnable() {
        @Override
        public void run() {
            mBlockListener.onBlockEvent(startTime, endTime, startThreadTime, endThreadTime);
        }
    });
}

假定产生了卡顿,那么就会将仓库信息记载到文件傍边,可是这样处理真的可以协助到咱们吗?

1.4 Handler监控的缺点

当然Blockcanary确实可以协助咱们承认卡顿产生的一个大致规模,可是咱们看下面的图

Android进阶宝典 -- Handler应用于线上卡顿监控

当办法B履行完结之后触发了卡顿阈值,这个时分仓库傍边存在办法A的仓库信息和办法B的仓库信息,那么咱们会以为由于办法B的原因产生了卡顿吗?其实不然,假定仓库信息中也包含了其他办法,那么Handler监控其实也只是给出了一个大粒度的规模,剖析起来仍是会有问题。

2 字节码插桩完结办法耗时监控

基于前面咱们关于Blockcanary的剖析,其存在的一个重大弊端便是无法获取细颗粒度的数据,例如每个办法履行的耗时,当打印出仓库信息之后,附加上每个办法的耗时,这样就能准确地定位出耗时办法的存在。

private fun funcA() {
    funcB()
}
private fun funcB() {
    Thread.sleep(400)
    funcC()
}
private fun funcC() {
    funcD()
}
private fun funcD() {
    Thread.sleep(100)
}

例如仍是以500ms为卡顿阈值,那么当履行办法A的时分,体系检测到了卡顿的产生,假定给到一个仓库信息如下:

D办法 耗时100ms
C办法 耗时100ms
B办法 耗时400ms
A办法 耗时500ms

这样是不是就一目了然了,显然是办法B中有一个十分耗时的操作,那么怎么获取每个办法履行的时间呢?

private fun funcA() {
    val startTime = System.currentTimeMillis()
    funcB()
    val deltaTime = System.currentTimeMillis() - startTime
}

上述这种办法可以获取办法耗时,假定咱们仅在测验阶段想测验某个办法耗时可以这么做,可是工程中成千上万的办法,假定靠自己手动这么增加岂不是要累死,所以就需求字节码插桩来帮忙在每个办法中加入上述代码逻辑。

2.1 字节码插桩流程

假定有看过Android进阶宝典 — 从字节码插桩技能了解美团热修正这篇文章的伙伴,可能关于字节码插桩有些了解了。其实字节码插桩,便是在class文件中写代码。

由于不管是Java仍是Kotlin终究都会编译成class字节码,而咱们日常开发中肯定是在Java(Kotlin)层上写代码,而字节码插桩则是在class文件上写代码。

因而整个字节码插桩的流程便是

Android进阶宝典 -- Handler应用于线上卡顿监控

其间难点就在于解分出class文件中包含的信息之后,需求严格依照class字节码的规矩来进行修改,只要有一个地方改错了,那么生成的.class文件就无法运用,所以假定要咱们自己修改显然是很难,因而各路Android大佬考虑到这个问题,就开源出许多结构供给给咱们运用。

2.2 引进ASM完结字节码插桩

首要,咱们先引进ASM依靠

implementation 'org.ow2.asm:asm:9.1'
implementation 'org.ow2.asm:asm-util:9.1'
implementation 'org.ow2.asm:asm-commons:9.1'

咱们可以根据2.1末节的这个流程图,利用ASM中的工具完结字节码插桩。

public class TestFunctionRunTime {
    public TestFunctionRunTime() {
    }
    public void funA() throws InterruptedException {
        Thread.sleep(2000);
    }
}

例如,咱们想在funA中刺进计算耗时的办法,那么首要需求得到这个类的class文件

fun transform() {
    //IO操作,获取文件流
    val fis =
        FileInputStream("/storage/emulated/0/TestFunctionRunTime.class")
    //用于读取class文件中信息
    val cr = ClassReader(fis)
    val cw = ClassWriter(ClassWriter.COMPUTE_MAXS)
    //开端剖析字节码
    cr.accept(
        MyClassVisitor(Opcodes.ASM9, cw),
        ClassReader.SKIP_FRAMES or ClassReader.SKIP_DEBUG
    )
}

首要,获取class文件这儿我作为示例直接经过IO加载某个路径下的class文件,经过ASM中供给的ClassReader和ClassWriter来读取class中的文件信息,然后调用ClassReader的accept办法,开端剖析class文件。

class MyClassVisitor(api: Int, classVisitor: ClassVisitor) : ClassVisitor(api, classVisitor) {
    override fun visitMethod(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        /**这儿假定就对一个办法插桩*/
        return if (name == "funA") {
            val methodVisitor =
                super.visitMethod(access, name, descriptor, signature, exceptions)
            MyMethodVisitor(api, methodVisitor, access, name, descriptor)
        } else {
            super.visitMethod(access, name, descriptor, signature, exceptions)
        }
    }
    override fun visitField(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        value: Any?
    ): FieldVisitor {
        return super.visitField(access, name, descriptor, signature, value)
    }
    override fun visitAnnotation(descriptor: String?, visible: Boolean): AnnotationVisitor {
        return super.visitAnnotation(descriptor, visible)
    }
}

由于在一个类中,会存在许多特点,例如变量、办法、注解等等,所以在ASM中的ClassVisitor类中,供给了这些特点的访问权力,例如visitMethod可以访问办法,假定咱们想要对funA进行插桩,那么就需求做一些自定义的操作,这儿就可以运用ASM供给的AdviceAdapter来完结办法履行进程中代码的刺进。

class MyMethodVisitor(
    val api: Int,
    val methodVisitor: MethodVisitor,
    val mAccess: Int,
    val methodName: String,
    val descriptor: String?
) : AdviceAdapter(api, methodVisitor, mAccess, methodName, descriptor) {
    /**当办法开端履行的时分*/
    override fun onMethodEnter() {
        super.onMethodEnter()
    }
    /**当办法履行完毕的时分*/
    override fun onMethodExit(opcode: Int) {
        super.onMethodExit(opcode)
    }
}

假定咱们关于每个办法,都刺进以下两行代码,那么咱们在操作字节码的时分,需求看一下当这个办法被编译成字节码之后,是什么样的。

public void funA() throws InterruptedException {
    Long startTime = System.currentTimeMillis();
    Thread.sleep(2000L);
    Log.e("TestFunctionRunTime", "duration=>" + (System.currentTimeMillis() - startTime));
}

刺进代码之前的字节码如下:

 public funA()V throws java/lang/InterruptedException
   L0
    LINENUMBER 18 L0
    LDC 2000
    INVOKESTATIC java/lang/Thread.sleep (J)V
   L1
    LINENUMBER 19 L1
    RETURN
   L2
    LOCALVARIABLE this Lcom/lay/mvi/net/TestFunctionRunTime; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1

刺进代码之后的字节码如下:

public funA()V throws java/lang/InterruptedException
   L0
    LINENUMBER 17 L0
    INVOKESTATIC java/lang/System.currentTimeMillis ()J
    INVOKESTATIC java/lang/Long.valueOf (J)Ljava/lang/Long;
    ASTORE 1
   L1
    LINENUMBER 18 L1
    LDC 2000
    INVOKESTATIC java/lang/Thread.sleep (J)V
   L2
    LINENUMBER 19 L2
    LDC "TestFunctionRunTime"
    NEW java/lang/StringBuilder
    DUP
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    LDC "duration=>"
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    INVOKESTATIC java/lang/System.currentTimeMillis ()J
    ALOAD 1
    INVOKEVIRTUAL java/lang/Long.longValue ()J
    LSUB
    INVOKEVIRTUAL java/lang/StringBuilder.append (J)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    INVOKESTATIC android/util/Log.e (Ljava/lang/String;Ljava/lang/String;)I
    POP
   L3
    LINENUMBER 20 L3
    RETURN
   L4
    LOCALVARIABLE this Lcom/lay/mvi/net/TestFunctionRunTime; L0 L4 0
    LOCALVARIABLE startTime Ljava/lang/Long; L1 L4 1
    MAXSTACK = 6
    MAXLOCALS = 2
}

首要咱们看假定依照咱们这种加代码的办法,当然没问题,可是在进行插桩的时分,将会写许多的字节码指令,看下面的代码,我只是贴出L2代码块就需求这么多,写的多通常就会出问题。

visitLdcInsn(methodName)
visitTypeInsn(NEW, "java/lang/StringBuilder")
visitInsn(DUP)
visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false)
visitLdcInsn(""duration=>"")
visitMethodInsn(
    INVOKEVIRTUAL,
    "java/lang/StringBuilder",
    "append",
    "(Ljava/lang/String;)Ljava/lang/StringBuilder",
    false
)
visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false)
visitVarInsn(ALOAD, 1)
visitMethodInsn(INVOKEVIRTUAL, "java/lang/Long", "longValue", "()J", false)
visitInsn(LSUB)
visitMethodInsn(
    INVOKEVIRTUAL,
    "java/lang/StringBuilder",
    "append",
    "(J)Ljava/lang/StringBuilder",
    false
)
visitMethodInsn(
    INVOKEVIRTUAL,
    "java/lang/StringBuilder",
    "toString",
    "()Ljava/lang/String",
    false
)
visitMethodInsn(
    INVOKEVIRTUAL,
    "android/util/Log",
    "e",
    "(Ljava/lang/String;Ljava/lang/String;)I",
    false
)
visitInsn(POP)

所以简单一点便是封装一个办法,由于这个插桩是在编译时将代码刺进,所以不影响

object AppMethodTrace {
    private var startTime: Long = 0L
    fun start() {
        startTime = System.currentTimeMillis()
    }
    fun end(funcName: String) {
        val endTime = System.currentTimeMillis()
        Log.e("AppMethodTrace", "$funcName 耗时为${endTime - startTime}")
    }
}

看这样就变得十分简便了,而且写起来也是十分清晰

public funA()V throws java/lang/InterruptedException
   L0
    LINENUMBER 17 L0
    GETSTATIC com/lay/mvi/net/AppMethodTrace.INSTANCE : Lcom/lay/mvi/net/AppMethodTrace;
    INVOKEVIRTUAL com/lay/mvi/net/AppMethodTrace.start ()V
   L1
    LINENUMBER 18 L1
    LDC 2000
    INVOKESTATIC java/lang/Thread.sleep (J)V
   L2
    LINENUMBER 19 L2
    GETSTATIC com/lay/mvi/net/AppMethodTrace.INSTANCE : Lcom/lay/mvi/net/AppMethodTrace;
    LDC "funA"
    INVOKEVIRTUAL com/lay/mvi/net/AppMethodTrace.end (Ljava/lang/String;)V
   L3
    LINENUMBER 20 L3
    RETURN
   L4
    LOCALVARIABLE this Lcom/lay/mvi/net/TestFunctionRunTime; L0 L4 0
    MAXSTACK = 2
    MAXLOCALS = 1

那么经过onMethodEnter和onMethodExit两个办法的处理,就可以完结对字节码刺进的操作。

class MyMethodVisitor(
    val api: Int,
    val methodVisitor: MethodVisitor,
    val mAccess: Int,
    val methodName: String,
    val descriptor: String?
) : AdviceAdapter(api, methodVisitor, mAccess, methodName, descriptor) {
    /**当办法开端履行的时分*/
    override fun onMethodEnter() {
        super.onMethodEnter()
        visitFieldInsn(
            GETSTATIC,
            "com/lay/mvi/net/AppMethodTrace",
            "INSTANCE",
            "Lcom/lay/mvi/net/AppMethodTrace"
        )
        visitMethodInsn(INVOKEVIRTUAL, "com/lay/mvi/net/AppMethodTrace", "start", "()V", false)
    }
    /**当办法履行完毕的时分*/
    override fun onMethodExit(opcode: Int) {
        super.onMethodExit(opcode)
        visitFieldInsn(
            GETSTATIC,
            "com/lay/mvi/net/AppMethodTrace",
            "INSTANCE",
            "Lcom/lay/mvi/net/AppMethodTrace"
        )
        /**办法名可以动态拿到*/
        visitLdcInsn(methodName)
        visitMethodInsn(
            INVOKEVIRTUAL,
            "com/lay/mvi/net/AppMethodTrace",
            "end",
            "(Ljava/lang/String;)V",
            false
        )
    }
}

终究,经过剖析处理字节码之后,将修改后的字节码重新输出到新的文件,在实践的应用开发中,是需求掩盖之前的字节码文件的。

//输出结果
val bytes = cw.toByteArray()
val fos =
    FileOutputStream("/storage/emulated/0/TestFunctionRunTimeTransform.class")
fos.write(bytes)
fos.flush()
fos.close()

Android进阶宝典 -- Handler应用于线上卡顿监控

假定伙伴们第一次运用,主张仍是了解所有的字节码指令以及ASM的API,这样咱们在写的时分就十分敏捷了。

2.3 Blockcanary的优化战略

经过前面咱们关于Blockcanary的了解,经过Handler尽管可以获取卡登时的仓库信息,可是无法获取到办法的履行耗时,所以经过ASM字节码插桩统计办法耗时配合Handler,就可以精确地定位到卡顿的办法,有时间的伙伴们可以去看下腾讯的Matrix。

最终还要烦琐一下,其实关于字节码插桩,像美团的热修正结构采用的字节码插桩技能便是ASM,但办法并不是只要这一种,像Javassist、kotlinpoet/javapoet都具有插桩的才能;咱们在做线上卡顿监控的时分,其实便是在做一个体系,所以不能从一个点动身,像运用到体系才能之外,同样也会运用到三方结构作为辅助手法,目的便是为了可以达到快速定位、快速响应的才能。