原文地址:https://neyoufan.github.io/2017/03/10/android/NAPM%20Android%20SDK/

NAPM 是网易的应用性能管理平台,采用非侵入的方式获取应用性能数据,可以实时展示多个维度的分析结果。本文主要给大家分享一下Android端SDK的实现原理。

前言

APM(Application Performance Management),应用性能管理,主要是为了解决应用上线之后,性能问题难以发现、难以定位的问题,通过接入APM,可以实时了解应用在运行过程中的性能表现,快速定位和修复问题。

目前国内外有不少的应用性能管理平台,例如国外的 New Relic、AppDynamics,国内的听云、OneAPM,国内各大公司也都有自己的性能监控体系。

我们也开发了自己的平台 NAPM 供公司内部的产品使用,移动端目前主要采集了网络性能、交互性能和数据(数据库、JSON、Image)处理性能数据,网络性能目前主要采集了Http请求过程中的一些性能指标,比如响应时间、首包时间、DNS时间等,同时再结合机型、版本、地理位置、运营商、网络环境等多个维度,就可以使用户方便地了解应用在各种状态下的性能表现,从而及时发现问题,做出适当的调整,达到优化用户体验的目的。

下图是NAPM平台某个应用的多维分析展示界面

Alt pic

接下来主要给大家分享一下网易NAPM Android端SDK的实现原理。

Android APM基本原理

简单来说,一个APM平台的工作流程大致如下:在各端(移动端、前端、后端)采集性能数据,然后上传到后端进行建模、存储,由平台进行分析、挖掘,最后通过可视化的方式展示给用户。

移动端SDK实际上只是一个数据采集系统,负责收集并上传终端上产生的性能数据,大致可以划分为三个模块,最底层是数据采集模块,负责采集各种性能数据,采集到的数据经过简单的处理之后存储在内存或者数据库中,最上层是数据的消费模块,通常会将采集到的数据上传到后台,供平台存储、分析和展示,同时我们也支持将采集到的性能数据交给用户处理,方便用户挖掘有用信息。

Alt pic

这里我们使用到了数据库,主要是因为存在一些情况,会导致采集到的数据不能实时发送至后台

  • 当网络状态较差,上传失败
  • 当前无可用网络连接,无法上传
  • 当前网络状态不满足上传条件(用户可以设置,比如仅在wifi的状态下上传数据)

因此我们需要将数据进行存储,在合适的时机上传到后台,尽量保证数据的完整。

APM SDK的难点是数据的采集,手动埋点的方式无疑是行不通的,一方面代价太大且容易产生错误,另一方面对于没有源代码的第三方库我们无法直接修改,因而不能满足我们的需求。参考New Relic,我们选择在应用构建期间通过修改字节码的方式来进行代码插桩。

首先我们看一下应用构建的过程:

Alt pic

可以看到,应用中所有的class文件包括引用的第三方库中的class,都会经由dex过程,被转化为一个或者多个dex文件,正因为所有的class文件都会在dex这一步被处理,所以我们选择在这里进行字节码插桩。

javaagent + Instrumentation

dex的过程是在dx程序中进行,而dx程序是由java实现的,这里我们使用到了javaagent技术,它可以使我们在JVM加载class文件前对字节码作出修改,这里简单介绍一下用法,主要分为两步

  1. 实现一个javaagent
  2. 加载javaagent

实现javaagent

javaagent的形式是一个jar包,根据javaagent的不同加载方式,对它的实现也有不同的要求。

如果javaagent是在虚拟机启动之后加载的,我们需要在它的manifest文件中指定Agent-Class属性,它的值是javaagent的实现类,这个实现类需要实现一个agentmain方法

public static void agentmain(String agentArgs, Instrumentation instrumentation) {
//xxx
}

agentmain会成为javaagent的入口,它会在javaagent被加载时调用。

但是如果javaagent是在JVM启动时通过命令行参数加载的,情况会不太一样,需要在它的manifest文件中指定Premain-Class属性,它的值是javaagent的实现类,这个实现类需要实现一个premain方法。

public static void premain(String agentArgs, Instrumentation instrumentation) {
//xxx
}

我们知道,一个java程序的入口是main方法,而如果javaagent是在JVM启动时通过命令行参数加载的,虚拟机会在应用的main方法执行之前调用javaagent的premain方法,这应该也是premain方法名字的由来吧。

如果要支持两种加载方式,那么上述的条件需要同时满足。并且如果通过命令行参数在JVM启动时加载,agentmain方法不会再被调用。而在这个时候,应用中的类还没有被加载到虚拟机,所以给我们修改字节码带来了便利,因为一个类被加载之后,修改它的字节码会比较麻烦。

我们看到premain方法的第二个参数是一个Instrumentation的实例,Instrumentation接口有一个方法

void addTransformer(ClassFileTransformer transformer, boolean canRetransform)

它会在虚拟机中注册一个ClassFileTransformer,transformer会在类加载时对类进行处理,ClassFileTransformer接口只定义了一个方法

byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException

而这个方法的作用就是修改一个类的字节码,className是这个类的名称,classfileBuffer是这个类原本的字节码,而返回值是修改过后的字节码,如果没有修改,可以直接返回null。

因此,如果我们想在程序运行前改变一个类的字节码,可以在javaagent的premain方法中调用Instrumentation的实例的addTransformer方法,添加一个自定义的ClassFileTransformer。伪代码如下:

//实现一个javaagent,注册自定义的ClassFileTransformer
public class MyJavaAgent {
public static void premain(String agentArgs, Instrumentation inst)
throws ClassNotFoundException, UnmodifiableClassException {
inst.addTransformer(new MyTransformer());
}
} //实现一个 ClassFileTransformer,对xxx.xxx.xxx类的字节码进行修改
public class MyTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader classLoader, String className, Class<?> clazz,
ProtectionDomain protectionDomain, byte[] bytes) throws IllegalClassFormatException {
if(name.equals("xxx.xxx.xxx")) {
return changeByteCode(bytes);
}
return null;
}
}

加载javaagent

前边已经提到了javaagent有两种加载方式

1) JVM启动时通过命令行参数加载javaagent

  • manifest中需要指定Premain-Class属性
  • 需要实现premain方法
  • premain方法会在程序的main方法之前执行
  • agentmain方式不会被调用

    通过命令行加载javaagent的形式如下:

    -javaagent:jarpath[=options]

    一个示例如下:

    java -javaagent:/path/to/myagent.jar -jar myapp.jar

2) JVM启动后动态加载javaagent

  • manifest中需要指定Agent-Class属性
  • 需要实现agentmain方法
  • agentmain方法会在javaagent被加载时执行

    一般运行时加载agent的方法如下:

    String nameOfRunningVM = ManagementFactory.getRuntimeMXBean().getName();
    int p = nameOfRunningVM.indexOf('@');
    String pid = nameOfRunningVM.substring(0, p); String jarFilePath = "/the/path/to/the/agent/jar"; try {
    VirtualMachine vm = VirtualMachine.attach(pid);
    vm.loadAgent(jarFilePath);
    vm.detach();
    } catch (Exception e) {
    throw new RuntimeException(e);
    }

具体使用细节可参考VirtualMachine介绍http://docs.oracle.com/javase/7/docs/jdk/api/attach/spec/com/sun/tools/attach/VirtualMachine.html

借助javaagent,我们可以将代码插桩的工作分为两个步骤:首先是获取到应用中所有的字节码,然后是对应用的字节码进行修改。

获取应用字节码

首先从要解决的问题出发,上边提到我们会在dex的这一步去获取字节码,通过查看dx程序的代码,我们发现,在dex的过程中所有的class文件会经由com.android.dx.command.dexer.MainprocessClass()方法进行处理,processClass()的代码如下:

/**
* Processes one classfile.
*
* @param name {@code non-null;} name of the file, clipped such that it
* <i>should</i> correspond to the name of the class it contains
* @param bytes {@code non-null;} contents of the file
* @return whether processing was successful
*/
private boolean processClass(String name, byte[] bytes) { if (! args.coreLibrary) {
checkClassName(name);
} try {
new DirectClassFileConsumer(name, bytes, null).call(
new ClassParserTask(name, bytes).call());
} catch(Exception ex) {
throw new RuntimeException("Exception parsing classes", ex);
} return true;
}

第一个参数是应用中一个类的名字,第二个参数就是这个类的字节码了,应用中所有的类,都会经过这个函数进行处理。

所以我们打算修改com.android.dx.command.dexer.MainprocessClass()方法,从而获取到应用中的字节码,那么现在的问题就变成了如何修改com.android.dx.command.dexer.MainprocessClass()方法。

掌握了javaagent,想要修改dx程序中com.android.dx.command.dexer.Main的字节码就变得比较容易了,我们需要实现一个javaagent,在其中注册一个ClassFileTransformer,在ClassFileTransformer的transform()方法中对com.android.dx.command.dexer.Main的字节码进行修改,最后在dx程序启动时将这个javaagent加载进去就好了。

//实现一个 ClassFileTransformer,对com.android.dx.command.dexer.Main类的字节码进行修改
public class MainTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader classLoader, String className, Class<?> clazz,
ProtectionDomain protectionDomain, byte[] bytes) throws IllegalClassFormatException {
if(name.equals("com/android/dx/command/dexer/Main")) {
return changeMainClassByteCode(bytes);
}
return null;
}
} byte[] changeMainByteCode(byte[] bytes) {
//修改Main的 processClass() 方法
//返回修改后Main的字节码
}

如果你是通过命令行来手动构建应用的,到这里已经可以用上边的方式获取到应用中的字节码了,然而大多数人在开发Android的时候,并不会通过命令行去手动构建,而是通过使用一些构建工具,来完成自动化构建,而dx程序则是由构建工具启动的,所以我们面临的问题就是如何将javaagent加载到dx进程。

我们目前支持了ant构建和gradle构建,通过查看ant和gradle的代码,我们发现最终它们都会通过java.lang.ProcessBuilderstart()方法来启动dx进程。

通过查看java.lang.ProcessBuilder的代码,我们发现它有一个成员

private List<String> command;

它是用来保存的是启动目标进程的命令和参数,我们需要做的就是在调用start()方法启动dx进程时,将加载javaagent的参数(-javaagent:jarpath[=options])添加到command中。

这里我们仍然使用javaagent来完成这个工作,我们需要实现另外一个javaagent,在其中注册一个另一个ClassFileTransformer,在它的transform方法中对java.lang.ProcessBuilder的字节码进行修改。

//实现一个 ClassFileTransformer,对com.android.dx.command.dexer.Main类的字节码进行修改
public class ProcessBuilderTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader classLoader, String className, Class<?> clazz,
ProtectionDomain protectionDomain, byte[] bytes) throws IllegalClassFormatException {
if(name.equals("java/lang/ProcessBuilder")) {
return changeProcessBuilderClassByteCode(bytes);
}
return null;
}
} byte[] changeProcessBuilderClassByteCode(byte[] bytes) {
//修改ProcessBuilder的 start() 方法
//返回修改后ProcessBuilder的字节码
}

那么最终问题就变成了如何把这个javaagent加载到ant进程和gradle进程。

它们对应到了javaagent的两种加载方式

  • ant构建-JVM启动时加载

    export ANT_OPTS="-javaagent:/path/to/agent.jar"(mac os环境,windows不太一样)

    在ant构建前进行上述配置,可以在启动ant时加载指定的javaagent,这里使用的是在JVM启动时通过命令行参数加载javaagent的方式。

  • gradle构建 -JVM启动后加载

    我们会编写一个gradle插件来完成javaagent的加载,当我们的插件被加载时,gradle进程已经运行起来了,因此只能通过动态的方式加载javaagent。

因此,获取字节码的流程,大致如下图所示:
Alt pic

这个过程中主要使用了两个javaagent,一个用来修改ProcessBuilder类,另一个用来修改Main类,涉及到的进程是ant构建进程或者gradle构建进程,以及由它们启动的dx进程。

对于gradle构建方式,需要注意一点,gradle plugin 在2.1.0之后的版本,支持dx in-process,它使得dx的过程可以直接在当前的gradle进程中执行,而不需要额外启动一个dx进程,从而缩短应用构建的时间。如果你在使用Android Studio构建应用的时候看到To run dex in process, the Gradle daemon needs a larger heap. It currently has 910 MB这样的一句话,它就是指导用户通过配置gradle daemon进程的堆大小来开启dx in-process特性的。

而这个新的特性,会给我们设置javaagent带来麻烦,不启动dx进程使得我们无法对dx进程设置javaagent,而在gradle进程中动态加载javaagent时,com.android.dx.command.dexer.Main类早已经加载过了,所以通过javaagent方式来获取字节码会变得十分困难。

幸运的是,gradle plugin 在1.5.0之后,提供了一个Transform API,它允许第三方插件操作编译后的class文件,而修改的时机正是在将这些字节码转换为dex文件之前,这里就不在展开讲解了,感兴趣的同学可以参考下这篇文章http://blog.csdn.net/sbsujjbcy/article/details/50839263

修改应用字节码

通过javaagent修改com.android.dx.command.dexer.Mainjava.lang.ProcessBuilder,以及最终修改应用的字节码进行插桩,都需要对.class文件的格式以及java虚拟机有比较深入的了解,另外需要使用字节码操作工具来帮助我们对字节码进行改造,这里不详细讲解,只是推荐一些有用的的字节码操作框架和工具,后边可能会有同事做相关的分享。

  • ASM是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。

  • Javassist是一个开源的分析、编辑和创建Java字节码的类库,它提供了源码级别的API以及字节码级别的API,源码级别的API,直接使用java编码的形式,而不需要深入了解虚拟机指令,就能动态改变类的结构或者动态生成类。

  • Bytecode Outline plugin for Eclipse是一个非常有用的eclipse 插件,可以查看当前正在编辑的java文件或者class文件的字节码。

  • 如果需要逆向APK,查看字节码修改的效果,除了dex2jar外,再给大家推荐一个google的逆向工具enjarify

小结

本文重点介绍了使用javaagent在应用打包过程中修改com.android.dx.command.dexer.Mainjava.lang.ProcessBuilder的字节码,从而获取到应用的字节码,进行插桩的基本原理,并没有涉及so hook相关的原理,以后有机会的话会再做一次分享。

网易NAPM Andorid SDK实现原理--转的更多相关文章

  1. 呼叫到达率100%,网易云信信令SDK免费上线!

    近期,网易云信推出一款稳定可靠.到达率高.扩展性较强的信令通道产品--信令SDK.它能够提供可靠的消息通道,可用于搭建音视频场景下的呼叫邀请机制.信令SDK目前兼容市面上所有主流的音视频SDK,呼叫到 ...

  2. 子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践

    本文原文内容来自InfoQ的技术分享,本次有修订.勘误和加工,感谢原作者的分享. 1.前言 自从2018年8月20日子弹短信在锤子发布会露面之后(详见<老罗最新发布了“子弹短信”这款IM,主打熟 ...

  3. 微信小程序开发中的二三事之网易云信IMSDK DEMO

    本文由作者邹永胜授权网易云社区发布. 简介 为了更好的展示我们即时通讯SDK强悍的能力,网易云信IM SDK微信小程序DEMO的开发就提上了日程.用产品的话说就是: 云信 IM 小程序 SDK 的能力 ...

  4. 爆款AR游戏如何打造?网易杨鹏以《悠梦》为例详解前沿技术

    本文来自网易云社区. 7月31日,2018云创大会游戏论坛在杭州国际博览中心103B圆满举行.本场游戏论坛聚焦探讨了可能对游戏行业发展有重大推动的新技术.新实践,如AR.区块链.安全.大数据等. 网易 ...

  5. 【Win10 UWP】微信SDK基本使用方法和基本原理

    上回讲到,作为一个长期散播温暖,散播希望的小清新无公害WP开发者,继QQ SDK之后,又把UWP微信SDK这茬了结了,仅供学习交流. 1.安装微信SDK for UWP 微信官方此前明确说明短时间内暂 ...

  6. 关于U3D的.SDK对接

    1,SDK对接原理:https://www.cnblogs.com/msxh/p/7220741.html 2,Unity ADS对接:https://blog.csdn.net/chenluwolf ...

  7. 使用命令行+代理更新Android SDK

    在无桌面的Linux上面安装Jenkins,要配置成Andorid 的持续集成环境Jenkins持续集成Android项目,需要在无桌面的Linux(ubuntu,centos)上安装Android ...

  8. 【移动自动化】【一】环境依赖:android sdk 环境配置(windows + linux)

    Android自动化前提依赖 android sdk 模拟器: mumu模拟器, 逍遥模拟器 真机 windows 环境下Android SDK 配置 配置java环境 去官网下载jdk http:/ ...

  9. Appium基础教程

    目录 Appium教程 Appium简介 App自动化测试工具对比 Appium实现原理 环境搭建 Andorid介绍 基本架构 常见布局/视图 基本控件 控件常见属性 Adb介绍 Adb常用命令 A ...

随机推荐

  1. 读书笔记7-浪潮之巅(part2)

    浪潮之巅 ——成功的公司各有各的绝招,而失败的公司倒有不少的共同之处 奔腾的芯(Intel) 前身:在处理器性能还很平庸的年代,站在科技前沿的计算机公司都是集中在工作站级处理器领域的,而同IBM.DE ...

  2. 【Oracle】ORA-00054: resource busy and acquire with NOWAIT specified or timeout expired

    出现此错误的原因是因为事务等待造成的,找出等待的事务,kill即可. 下面是我当时遇到的错误: ---删除表t1时出现错误 SCOTT@GOOD> drop table t1; drop tab ...

  3. MySQL_基本操作

    sql语句 Sql语句主要用于存取数据,查询数据,更新数据和管理数据库系统. #Sql语句分为3种类型 #1.DDL语句:数据库定义语言: 数据库.表.视图.索引.存储过程,例如CREATE DROP ...

  4. Nginx+Php-fpm运行原理

    一.代理与反向代理 现实生活中的例子 1.正向代理:访问google.com 如上图,因为google被墙,我们需要vpnFQ才能访问google.com. vpn对于“我们”来说,是可以感知到的(我 ...

  5. HH的项链 树状数组动态维护前缀

    #include<cstdio> #include<algorithm> #include<cstring> using namespace std; const ...

  6. 洛谷P2827 蚯蚓 队列 + 观察

    我们不难发现先被切开的两半一定比后被切开的两半大,这样就天然的生成了队列的单调性,就可以省去一个log.所以,我们开三个队列,分别为origin,big,smallorigin, big, small ...

  7. 用Navicat Prenium12连接Oracle数据库(oracle11g版本)时报错ORA-28547:connection to server failed,probable Oracle Net admin error.解决办法

    上网一查原来是oci.dll版本不对.因为Navicat是通过Oracle客户端连接Oracle服务器的,Oracle的客户端分为两种,一种是标准版,一种是简洁版,即Oracle Install Cl ...

  8. github下载报错:Permission denied (publickey). fatal: Could not read from remote repository.

    Permission denied (publickey). fatal: Could not read from remote repository. 博主在github上下载tiny face的的 ...

  9. -1 深度学习基础caffe

    一.反思 二.反向传播 三.ubuntu安装caffe 四.追踪关键词

  10. [tyvj-1194]划分大理石 二进制优化多重背包

    突然发现这个自己还不会... 其实也不难,就和快速幂感觉很像,把物品数量二进制拆分一下,01背包即可 我是咸鱼 #include <cstdio> #include <cstring ...