0%

转载请标明出处一起玩转Android项目中的字节码

作为Android开发,日常写Java代码之余,是否想过,玩玩class文件?直接对class文件的字节码下手,我们可以做很多好玩的事情,比如:

  • 对全局所有class插桩,做UI,内存,网络等等方面的性能监控
  • 发现某个第三方依赖,用起来不爽,但是不想拿它的源码修改再重新编译,而想对它的class直接做点手脚
  • 每次写打log时,想让TAG自动生成,让它默认就是当前类的名称,甚至你想让log里自动加上当前代码所在的行数,更方便定位日志位置
  • Java自带的动态代理太弱了,只能对接口类做动态代理,而我们想对任何类做动态代理

为了实现上面这些想法,可能我们最开始的第一反应,都是能否通过代码生成技术、APT,抑或反射、抑或动态代理来实现,但是想来想去,貌似这些方案都不能很好满足上面的需求,而且,有些问题不能从Java文件入手,而应该从class文件寻找突破。而从class文件入手,我们就不得不来近距离接触一下字节码!

JVM平台上,修改、生成字节码无处不在,从ORM框架(如Hibernate, MyBatis)到Mock框架(如Mockio),再到Java Web中的常青树Spring框架,再到新兴的JVM语言Kotlin的编译器,还有大名鼎鼎的cglib项目,都有字节码的身影。

字节码相关技术的强大之处自然不用多说,而且在Android开发中,无论是使用Java开发和Kotlin开发,都是JVM平台的语言,所以如果我们在Android开发中,使用字节码技术做一下hack,还可以天然地兼容Java和Kotlin语言。

近来我对字节码技术在Android上的应用做了一些调研和实践,顺便做了几个小轮子,项目地址:Hunter

  • Hunter: 一个插件框架,在它的基础上可以快速开发一个并发、增量的字节码编译插件,帮助开发人员隐藏了Transform和ASM的绝大部分逻辑,开发者只需写少量的ASM code,就可以开发一款编译插件,修改Android项目的字节码。

在上面框架基础上,我还开发了几个小工具

  • OkHttp-Plugin: 可以为你的应用所有的OkhttpClient设置全局 Interceptor / Eventlistener
    (包括第三方依赖里的OkhttpClient),借助这个插件,可以轻松实现全局网络监控。
  • Timing-Plugin: 帮你监控所有UI线程的执行耗时,并且提供了算法,帮你打印出一个带有每步耗时的堆栈,统计卡顿方法分布,你也可以自定义分析卡顿堆栈的方式。
  • LogLine-Plugin: 为你的日志加上行号
  • Debug-Plugin: 只要为指定方法加上某个annotation,就可以帮你打印出这个方法所有输入参数的值,以及返回值和执行时间(其实,JakeWharton的hugo用AspectJ实现了类似功能,而我的实现方式是基于ASM,ASM处理字节码的速度更快)
  • 你可以在这里查看我想继续开发的一些插件 TODO,另外,欢迎你提供你宝贵的idea

今天写这篇文章,分享自己摸索相关技术和开发这个项目过程中的一些积累。

这个项目主要使用的技术是Android gradle插件,Transform,ASM与字节码基础。这篇文章将主要围绕以下几个技术点展开:

  • Transform的应用、原理、优化
  • ASM的应用,开发流,以及与Android工程的适配
  • 几个具体应用案例

所以阅读这篇文章,读者最好有Android开发以及编写简单Gradle插件的背景知识。

话不多说,让我们开始吧。

一、Transform

引入Transform

Transform是Android gradle plugin 1.5开始引入的概念。

我们先从如何引入Transform依赖说起,首先我们需要编写一个自定义插件,然后在插件中注册一个自定义Transform。这其中我们需要先通过gradle引入Transform的依赖,这里有一个坑,Transform的库最开始是独立的,后来从2.0.0版本开始,被归入了Android编译系统依赖的gradle-api中,让我们看看Transform在jcenter上的历个版本。

所以,很久很久以前我引入transform依赖是这样

1
compile 'com.android.tools.build:transform-api:1.5.0'

现在是这样

1
2
//从2.0.0版本开始就是在gradle-api中了
implementation 'com.android.tools.build:gradle-api:3.1.4'

然后,让我们在自定义插件中注册一个自定义Transform,gradle插件可以使用java,groovy,kotlin编写,我这里选择使用java。

1
2
3
4
5
6
7
8
9
10
public class CustomPlugin implements Plugin<Project> {

@SuppressWarnings("NullableProblems")
@Override
public void apply(Project project) {
AppExtension appExtension = (AppExtension)project.getProperties().get("android");
appExtension.registerTransform(new CustomTransform(), Collections.EMPTY_LIST);
}

}

那么如何写一个自定义Transform呢?

Transform的原理与应用

介绍如何应用Transform之前,我们先介绍Transform的原理,一图胜千言

每个Transform其实都是一个gradle task,Android编译器中的TaskManager将每个Transform串连起来,第一个Transform接收来自javac编译的结果,以及已经拉取到在本地的第三方依赖(jar. aar),还有resource资源,注意,这里的resource并非android项目中的res资源,而是asset目录下的资源。这些编译的中间产物,在Transform组成的链条上流动,每个Transform节点可以对class进行处理再传递给下一个Transform。我们常见的混淆,Desugar等逻辑,它们的实现如今都是封装在一个个Transform中,而我们自定义的Transform,会插入到这个Transform链条的最前面。

但其实,上面这幅图,只是展示Transform的其中一种情况。而Transform其实可以有两种输入,一种是消费型的,当前Transform需要将消费型型输出给下一个Transform,另一种是引用型的,当前Transform可以读取这些输入,而不需要输出给下一个Transform,比如Instant Run就是通过这种方式,检查两次编译之间的diff的。至于怎么在一个Transform中声明两种输入,以及怎么处理两种输入,后面将有示例代码。

为了印证Transform的工作原理和应用方式,我们也可以从Android gradle plugin源码入手找出证据,在TaskManager中,有一个方法createPostCompilationTasks.为了避免贴篇幅太长的源码,这里附上链接

TaskManager#createPostCompilationTasks

这个方法的脉络很清晰,我们可以看到,Jacoco,Desugar,MergeJavaRes,AdvancedProfiling,Shrinker,Proguard, JarMergeTransform, MultiDex, Dex都是通过Transform的形式一个个串联起来。其中也有将我们自定义的Transform插进去。

讲完了Transform的数据流动的原理,我们再来介绍一下Transform的输入数据的过滤机制,Transform的数据输入,可以通过Scope和ContentType两个维度进行过滤。

ContentType,顾名思义,就是数据类型,在插件开发中,我们一般只能使用CLASSES和RESOURCES两种类型,注意,其中的CLASSES已经包含了class文件和jar文件

从图中可以看到,除了CLASSES和RESOURCES,还有一些我们开发过程无法使用的类型,比如DEX文件,这些隐藏类型在一个独立的枚举类ExtendedContentType中,这些类型只能给Android编译器使用。另外,我们一般使用TransformManager中提供的几个常用的ContentType集合和Scope集合,如果是要处理所有class和jar的字节码,ContentType我们一般使用TransformManager.CONTENT_CLASS

Scope相比ContentType则是另一个维度的过滤规则,

我们可以发现,左边几个类型可供我们使用,而我们一般都是组合使用这几个类型,TransformManager有几个常用的Scope集合方便开发者使用。
如果是要处理所有class字节码,Scope我们一般使用TransformManager.SCOPE_FULL_PROJECT

好,目前为止,我们介绍了Transform的数据流动的原理,输入的类型和过滤机制,我们再写一个简单的自定义Transform,让我们对Transform可以有一个更具体的认识

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public class CustomTransform extends Transform {

public static final String TAG = "CustomTransform";

public CustomTransform() {
super();
}

@Override
public String getName() {
return "CustomTransform";
}

@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation);
//当前是否是增量编译
boolean isIncremental = transformInvocation.isIncremental();
//消费型输入,可以从中获取jar包和class文件夹路径。需要输出给下一个任务
Collection<TransformInput> inputs = transformInvocation.getInputs();
//引用型输入,无需输出。
Collection<TransformInput> referencedInputs = transformInvocation.getReferencedInputs();
//OutputProvider管理输出路径,如果消费型输入为空,你会发现OutputProvider == null
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
for(TransformInput input : inputs) {
for(JarInput jarInput : input.getJarInputs()) {
File dest = outputProvider.getContentLocation(
jarInput.getFile().getAbsolutePath(),
jarInput.getContentTypes(),
jarInput.getScopes(),
Format.JAR);
//将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了
FileUtils.copyFile(jarInput.getFile(), dest);
}
for(DirectoryInput directoryInput : input.getDirectoryInputs()) {
File dest = outputProvider.getContentLocation(directoryInput.getName(),
directoryInput.getContentTypes(), directoryInput.getScopes(),
Format.DIRECTORY);
//将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了
FileUtils.copyDirectory(directoryInput.getFile(), dest);
}
}

}


@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS;
}

@Override
public Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT;
}

@Override
public Set<QualifiedContent.ContentType> getOutputTypes() {
return super.getOutputTypes();
}

@Override
public Set<? super QualifiedContent.Scope> getReferencedScopes() {
return TransformManager.EMPTY_SCOPES;
}


@Override
public Map<String, Object> getParameterInputs() {
return super.getParameterInputs();
}

@Override
public boolean isCacheable() {
return true;
}


@Override
public boolean isIncremental() {
return true; //是否开启增量编译
}

}

可以看到,在transform方法中,我们将每个jar包和class文件复制到dest路径,这个dest路径就是下一个Transform的输入数据,而在复制时,我们就可以做一些狸猫换太子,偷天换日的事情了,先将jar包和class文件的字节码做一些修改,再进行复制即可,至于怎么修改字节码,就要借助我们后面介绍的ASM了。而如果开发过程要看你当前transform处理之后的class/jar包,可以到
/build/intermediates/transforms/CustomTransform/下查看,你会发现所有jar包命名都是123456递增,这是正常的,这里的命名规则可以在OutputProvider.getContentLocation的具体实现中找到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

public synchronized File getContentLocation(
@NonNull String name,
@NonNull Set<ContentType> types,
@NonNull Set<? super Scope> scopes,
@NonNull Format format) {
// runtime check these since it's (indirectly) called by 3rd party transforms.
checkNotNull(name);
checkNotNull(types);
checkNotNull(scopes);
checkNotNull(format);
checkState(!name.isEmpty());
checkState(!types.isEmpty());
checkState(!scopes.isEmpty());

// search for an existing matching substream.
for (SubStream subStream : subStreams) {
// look for an existing match. This means same name, types, scopes, and format.
if (name.equals(subStream.getName())
&& types.equals(subStream.getTypes())
&& scopes.equals(subStream.getScopes())
&& format == subStream.getFormat()) {
return new File(rootFolder, subStream.getFilename());
}
}
//按位置递增!!
// didn't find a matching output. create the new output
SubStream newSubStream = new SubStream(name, nextIndex++, scopes, types, format, true);

subStreams.add(newSubStream);

return new File(rootFolder, newSubStream.getFilename());
}

Transform的优化:增量与并发

到此为止,看起来Transform用起来也不难,但是,如果直接这样使用,会大大拖慢编译时间,为了解决这个问题,摸索了一段时间后,也借鉴了Android编译器中Desugar等几个Transform的实现,发现我们可以使用增量编译,并且上面transform方法遍历处理每个jar/class的流程,其实可以并发处理,加上一般编译流程都是在PC上,所以我们可以尽量敲诈机器的资源。

想要开启增量编译,我们需要重写Transform的这个接口,返回true。

1
2
3
4
@Override 
public boolean isIncremental() {
return true;
}

虽然开启了增量编译,但也并非每次编译过程都是支持增量的,毕竟一次clean build完全没有增量的基础,所以,我们需要检查当前编译是否是增量编译。

如果不是增量编译,则清空output目录,然后按照前面的方式,逐个class/jar处理
如果是增量编译,则要检查每个文件的Status,Status分四种,并且对这四种文件的操作也不尽相同

  • NOTCHANGED: 当前文件不需处理,甚至复制操作都不用;
  • ADDED、CHANGED: 正常处理,输出给下一个任务;
  • REMOVED: 移除outputProvider获取路径对应的文件。

大概实现可以一起看看下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

@Override
public void transform(TransformInvocation transformInvocation){
Collection<TransformInput> inputs = transformInvocation.getInputs();
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
boolean isIncremental = transformInvocation.isIncremental();
//如果非增量,则清空旧的输出内容
if(!isIncremental) {
outputProvider.deleteAll();
}
for(TransformInput input : inputs) {
for(JarInput jarInput : input.getJarInputs()) {
Status status = jarInput.getStatus();
File dest = outputProvider.getContentLocation(
jarInput.getName(),
jarInput.getContentTypes(),
jarInput.getScopes(),
Format.JAR);
if(isIncremental && !emptyRun) {
switch(status) {
case NOTCHANGED:
break;
case ADDED:
case CHANGED:
transformJar(jarInput.getFile(), dest, status);
break;
case REMOVED:
if (dest.exists()) {
FileUtils.forceDelete(dest);
}
break;
}
} else {
transformJar(jarInput.getFile(), dest, status);
}
}

for(DirectoryInput directoryInput : input.getDirectoryInputs()) {
File dest = outputProvider.getContentLocation(directoryInput.getName(),
directoryInput.getContentTypes(), directoryInput.getScopes(),
Format.DIRECTORY);
FileUtils.forceMkdir(dest);
if(isIncremental && !emptyRun) {
String srcDirPath = directoryInput.getFile().getAbsolutePath();
String destDirPath = dest.getAbsolutePath();
Map<File, Status> fileStatusMap = directoryInput.getChangedFiles();
for (Map.Entry<File, Status> changedFile : fileStatusMap.entrySet()) {
Status status = changedFile.getValue();
File inputFile = changedFile.getKey();
String destFilePath = inputFile.getAbsolutePath().replace(srcDirPath, destDirPath);
File destFile = new File(destFilePath);
switch (status) {
case NOTCHANGED:
break;
case REMOVED:
if(destFile.exists()) {
FileUtils.forceDelete(destFile);
}
break;
case ADDED:
case CHANGED:
FileUtils.touch(destFile);
transformSingleFile(inputFile, destFile, srcDirPath);
break;
}
}
} else {
transformDir(directoryInput.getFile(), dest);
}

}
}
}

这就能为我们的编译插件提供增量的特性。

实现了增量编译后,我们最好也支持并发编译,并发编译的实现并不复杂,只需要将上面处理单个jar/class的逻辑,并发处理,最后阻塞等待所有任务结束即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

private WaitableExecutor waitableExecutor = WaitableExecutor.useGlobalSharedThreadPool();


//异步并发处理jar/class
waitableExecutor.execute(() -> {
bytecodeWeaver.weaveJar(srcJar, destJar);
return null;
});
waitableExecutor.execute(() -> {
bytecodeWeaver.weaveSingleClassToFile(file, outputFile, inputDirPath);
return null;
});


//等待所有任务结束
waitableExecutor.waitForTasksWithQuickFail(true);

接下来我们对编译速度做一个对比,每个实验都是5次同种条件下编译10次,去除最大大小值,取平均时间

首先,在我工作中的项目(Java/kotlin代码量行数百万级别),我们先做一次cleanbuild

1
./gradlew clean assembleDebug --profile

给项目中添加UI耗时统计,全局每个方法(包括普通class文件和第三方jar包中的所有class)的第一行和最后一行都进行插桩,实现方式就是Transform+ASM,对比一下并发Transform和非并发Transform下,Tranform这一步的耗时

可以发现,并发编译,基本比非并发编译速度提高了80%。效果很显著。

然后,让我们再做另一个试验,我们在项目中模拟日常修改某个class文件的一行代码,这时是符合增量编译的环境的。然后在刚才基础上还是做同样的插桩逻辑,对比增量Transform和全量Transform的差异。

1
./gradlew assembleDebug --profile

可以发现,增量的速度比全量的速度提升了3倍多,而且这个速度优化会随着工程的变大而更加显著。

数据表明,增量和并发对编译速度的影响是很大的。而我在查看Android gradle plugin自身的十几个Transform时,发现它们实现方式也有一些区别,有些用kotlin写,有些用java写,有些支持增量,有些不支持,而且是代码注释写了一个大大的FIXME, To support incremental build。所以,讲道理,现阶段的Android编译速度,还是有提升空间的。

上面我们介绍了Transform,以及如何高效地在编译期间处理所有字节码,那么具体怎么处理字节码呢?接下来让我们一起看看JVM平台上的处理字节码神兵利器,ASM!

二、ASM

ASM的官网在这里https://asm.ow2.io/,贴一下它的主页介绍,一起感受下它的强大

JVM平台上,处理字节码的框架最常见的就三个,ASM,Javasist,AspectJ。我尝试过Javasist,而AspectJ也稍有了解,最终选择ASM,因为使用它可以更底层地处理字节码的每条命令,处理速度、内存占用,也优于其他两个框架。

我们可以来做一个对比,上面我们所做的计算编译时间实验的基础上,做如下试验,分别用ASM和Javasist全量处理工程所有class,并且都不开启并发处理的情况下,一次clean build中,transform的耗时对比如下

ASM相比Javasist的优势非常显著,ASM相比其他字节码操作库的效率和性能优势应该毋庸置疑的,毕竟是诸多JVM语言钦定的字节码生成库。

我们这部分将来介绍ASM,但是由于篇幅问题,不会从字节码的基础展开介绍,会通过几个实例的实现介绍一些字节码的相关知识,另外还会介绍ASM的使用,以及ASM解析class文件结构的原理,还有应用于Android插件开发时,遇到的问题,及其解决方案。

ASM的引入

下面是一份完整的gradle自定义plugin + transform + asm所需依赖,注意一下,此处两个gradleApi的区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

dependencies {

//使用项目中指定的gradle wrapper版本,插件中使用的Project对象等等就来自这里
implementation gradleApi()

//使用本地的groovy
implementation localGroovy()

//Android编译的大部分gradle源码,比如上面讲到的TaskManager
implementation 'com.android.tools.build:gradle:3.1.4'

//这个依赖里其实主要存了transform的依赖,注意,这个依赖不同于上面的gradleApi()
implementation 'com.android.tools.build:gradle-api:3.1.4'

//ASM相关
implementation 'org.ow2.asm:asm:5.1'
implementation 'org.ow2.asm:asm-util:5.1'
implementation 'org.ow2.asm:asm-commons:5.1'

}

ASM的应用

ASM设计了两种API类型,一种是Tree API, 一种是基于Visitor API(visitor pattern),

Tree API将class的结构读取到内存,构建一个树形结构,然后需要处理Method、Field等元素时,到树形结构中定位到某个元素,进行操作,然后把操作再写入新的class文件。

Visitor API则将通过接口的方式,分离读class和写class的逻辑,一般通过一个ClassReader负责读取class字节码,然后ClassReader通过一个ClassVisitor接口,将字节码的每个细节按顺序通过接口的方式,传递给ClassVisitor(你会发现ClassVisitor中有多个visitXXXX接口),这个过程就像ClassReader带着ClassVisitor游览了class字节码的每一个指令。

上面这两种解析文件结构的方式在很多处理结构化数据时都常见,一般得看需求背景选择合适的方案,而我们的需求是这样的,出于某个目的,寻找class文件中的一个hook点,进行字节码修改,这种背景下,我们选择Visitor API的方式比较合适。

让我们来写一个简单的demo,这段代码很简单,通过Visitor API读取一个class的内容,保存到另一个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14

private void copy(String inputPath, String outputPath) {
try {
FileInputStream is = new FileInputStream(inputPath);
ClassReader cr = new ClassReader(is);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cr.accept(cw, 0);
FileOutputStream fos = new FileOutputStream(outputPath);
fos.write(cw.toByteArray());
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}

首先,我们通过ClassReader读取某个class文件,然后定义一个ClassWriter,这个ClassWriter我们可以看它源码,其实就是一个ClassVisitor的实现,负责将ClassReader传递过来的数据写到一个字节流中,而真正触发这个逻辑就是通过ClassWriter的accept方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

public void accept(ClassVisitor classVisitor, Attribute[] attributePrototypes, int parsingOptions) {

// 读取当前class的字节码信息
int accessFlags = this.readUnsignedShort(currentOffset);
String thisClass = this.readClass(currentOffset + 2, charBuffer);
String superClass = this.readClass(currentOffset + 4, charBuffer);
String[] interfaces = new String[this.readUnsignedShort(currentOffset + 6)];



//classVisitor就是刚才accept方法传进来的ClassWriter,每次visitXXX都负责将字节码的信息存储起来
classVisitor.visit(this.readInt(this.cpInfoOffsets[1] - 7), accessFlags, thisClass, signature, superClass, interfaces);

/**
略去很多visit逻辑
*/

//visit Attribute
while(attributes != null) {
Attribute nextAttribute = attributes.nextAttribute;
attributes.nextAttribute = null;
classVisitor.visitAttribute(attributes);
attributes = nextAttribute;
}

/**
略去很多visit逻辑
*/

classVisitor.visitEnd();
}

最后,我们通过ClassWriter的toByteArray(),将从ClassReader传递到ClassWriter的字节码导出,写入新的文件即可。这就完成了class文件的复制,这个demo虽然很简单,但是涵盖了ASM使用Visitor API修改字节码最底层的原理,大致流程如图

我们来分析一下,不难发现,如果我们要修改字节码,就是要从ClassWriter入手,上面我们提到ClassWriter中每个visitXXX(这些接口实现自ClassVisitor)都会保存字节码信息并最终可以导出,那么如果我们可以代理ClassWriter的接口,就可以干预最终字节码的生成了。

那么上面的图就应该是这样

我们只要稍微看一下ClassVisitor的代码,发现它的构造函数,是可以接收另一个ClassVisitor的,从而通过这个ClassVisitor代理所有的方法。让我们来看一个例子,为class中的每个方法调用语句的开头和结尾插入一行代码

修改前的方法是这样

1
2
3
4
5

private static void printTwo() {
printOne();
printOne();
}

被修改后的方法是这样

1
2
3
4
5
6
7
8
9

private static void printTwo() {
System.out.println("CALL printOne");
printOne();
System.out.println("RETURN printOne");
System.out.println("CALL printOne");
printOne();
System.out.println("RETURN printOne");
}

让我们来看一下如何用ASM实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static void weave(String inputPath, String outputPath) {
try {
FileInputStream is = new FileInputStream(inputPath);
ClassReader cr = new ClassReader(is);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
CallClassAdapter adapter = new CallClassAdapter(cw);
cr.accept(adapter, 0);
FileOutputStream fos = new FileOutputStream(outputPath);
fos.write(cw.toByteArray());
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}

这段代码和上面的实现复制class的代码唯一区别就是,使用了CallClassAdapter,它是一个自定义的ClassVisitor,我们将ClassWriter传递给CallClassAdapter的构造函数。来看看它的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

//CallClassAdapter.java
public class CallClassAdapter extends ClassVisitor implements Opcodes {

public CallClassAdapter(final ClassVisitor cv) {
super(ASM5, cv);
}

@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces);
}

@Override
public MethodVisitor visitMethod(final int access, final String name,
final String desc, final String signature, final String[] exceptions) {
MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
return mv == null ? null : new CallMethodAdapter(name, mv);
}

}

//CallMethodAdapter.java
class CallMethodAdapter extends MethodVisitor implements Opcodes {

public CallMethodAdapter(final MethodVisitor mv) {
super(ASM5, mv);
}

@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {

mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("CALL " + name);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

mv.visitMethodInsn(opcode, owner, name, desc, itf);

mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("RETURN " + name);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

}

}

CallClassAdapter中的visitMethod使用了一个自定义的MethodVisitor—–CallMethodAdapter,它也是代理了原来的MethodVisitor,原理和ClassVisitor的代理一样。

看到这里,貌似使用ASM修改字节码的大概套路都走完了,那么如何写出上面visitMethodInsn方法中插入打印方法名的逻辑,这就需要一些字节码的基础知识了,我们说过这里不会展开介绍字节码,但是我们可以介绍一些快速学习字节码的方式,同时也是开发字节码相关工程一些实用的工具。

在这之前,我们先讲讲行号的问题

如何验证行号

上面我们给每一句方法调用的前后都插入了一行日志打印,那么有没有想过,这样岂不是打乱了代码的行数,这样,万一crash了,定位堆栈岂不是乱套了。其实并不然,在上面visitMethodInsn中做的东西,其实都是在同一行中插入的代码,上面我们贴出来的代码是这样

1
2
3
4
5
6
7
8
9

private static void printTwo() {
System.out.println("CALL printOne");
printOne();
System.out.println("RETURN printOne");
System.out.println("CALL printOne");
printOne();
System.out.println("RETURN printOne");
}

无论你用idea还是eclipse打开上面的class文件,都是一行行展示的,但是其实class内部真实的行数应该是这样

1
2
3
4
5

private static void printTwo() {
System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne");
System.out.println("CALL printOne"); printOne(); System.out.println("RETURN printOne");
}

idea下可以开启一个选项,让查看class内容时,保留真正的行数

开启后,你看到的是这样

我们可以发现,17行和18行,分别包含了三句代码。

而开启选项之前是这样

那么如何开启这个选项呢?Mac下cmd + shift + A输入Registry,勾选这两个选项

其实无论字节码和ASM的代码上看,class中的所有代码,都是先声明行号X,然后开始几条字节码指令,这几条字节码对应的代码都在行号X中,直到声明下一个新的行号。

ASM code

解析来介绍,如何写出上面生成代码的逻辑。首先,我们设想一下,如果要对某个class进行修改,那需要对字节码具体做什么修改呢?最直观的方法就是,先编译生成目标class,然后看它的字节码和原来class的字节码有什么区别(查看字节码可以使用javap工具),但是这样还不够,其实我们最终并不是读写字节码,而是使用ASM来修改,我们这里先做一个区别,bytecode vs ASM code,前者就是JVM意义的字节码,而后者是用ASM描述的bytecode,其实二者非常的接近,只是ASM code用Java代码来描述。所以,我们应该是对比ASM code,而不是对比bytecode。对比ASM code的diff,基本就是我们要做的修改。

而ASM也提供了一个这样的类:ASMifier,它可以生成ASM code,但是,其实还有更快捷的工具,Intellij IDEA有一个插件
Asm Bytecode Outline,可以查看一个class文件的bytecode和ASM code。

到此为止,貌似使用对比ASM code的方式,来实现字节码修改也不难,但是,这种方式只是可以实现一些修改字节码的基础场景,还有很多场景是需要对字节码有一些基础知识才能做到,而且,要阅读懂ASM code,也是需要一定字节码的的知识。所以,如果要开发字节码工程,还是需要学习一番字节码。

ClassWriter在Android上的坑

如果我们直接按上面的套路,将ASM应用到Android编译插件中,会踩到一个坑,这个坑来自于ClassWriter,具体是因为ClassWriter其中的一个逻辑,寻找两个类的共同父类。可以看看ClassWriter中的这个方法getCommonSuperClass,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

/**
* Returns the common super type of the two given types. The default
* implementation of this method <i>loads</i> the two given classes and uses
* the java.lang.Class methods to find the common super class. It can be
* overridden to compute this common super type in other ways, in particular
* without actually loading any class, or to take into account the class
* that is currently being generated by this ClassWriter, which can of
* course not be loaded since it is under construction.
*
* @param type1
* the internal name of a class.
* @param type2
* the internal name of another class.
* @return the internal name of the common super class of the two given
* classes.
*/
protected String getCommonSuperClass(final String type1, final String type2) {
Class<?> c, d;
ClassLoader classLoader = getClass().getClassLoader();
try {
c = Class.forName(type1.replace('/', '.'), false, classLoader);
d = Class.forName(type2.replace('/', '.'), false, classLoader);
} catch (Exception e) {
throw new RuntimeException(e.toString());
}
if (c.isAssignableFrom(d)) {
return type1;
}
if (d.isAssignableFrom(c)) {
return type2;
}
if (c.isInterface() || d.isInterface()) {
return "java/lang/Object";
} else {
do {
c = c.getSuperclass();
} while (!c.isAssignableFrom(d));
return c.getName().replace('.', '/');
}
}

这个方法用于寻找两个类的共同父类,我们可以看到它是获取当前class的classLoader加载两个输入的类型,而编译期间使用的classloader并没有加载Android项目中的代码,所以我们需要一个自定义的ClassLoader,将前面提到的Transform中接收到的所有jar以及class,还有android.jar都添加到自定义ClassLoader中。(其实上面这个方法注释中已经暗示了这个方法存在的一些问题)

如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

public static URLClassLoader getClassLoader(Collection<TransformInput> inputs,
Collection<TransformInput> referencedInputs,
Project project) throws MalformedURLException {
ImmutableList.Builder<URL> urls = new ImmutableList.Builder<>();
String androidJarPath = getAndroidJarPath(project);
File file = new File(androidJarPath);
URL androidJarURL = file.toURI().toURL();
urls.add(androidJarURL);
for (TransformInput totalInputs : Iterables.concat(inputs, referencedInputs)) {
for (DirectoryInput directoryInput : totalInputs.getDirectoryInputs()) {
if (directoryInput.getFile().isDirectory()) {
urls.add(directoryInput.getFile().toURI().toURL());
}
}
for (JarInput jarInput : totalInputs.getJarInputs()) {
if (jarInput.getFile().isFile()) {
urls.add(jarInput.getFile().toURI().toURL());
}
}
}
ImmutableList<URL> allUrls = urls.build();
URL[] classLoaderUrls = allUrls.toArray(new URL[allUrls.size()]);
return new URLClassLoader(classLoaderUrls);
}

但是,如果只是替换了getCommonSuperClass中的Classloader,依然还有一个更深的坑,我们可以看看前面getCommonSuperClass的实现,它是如何寻找父类的呢?它是通过Class.forName加载某个类,然后再去寻找父类,但是,但是,android.jar中的类可不能随随便便加载的呀,android.jar对于Android工程来说只是编译时依赖,运行时是用Android机器上自己的android.jar。而且android.jar所有方法包括构造函数都是空实现,其中都只有一行代码

1
throw new RuntimeException("Stub!");

这样加载某个类时,它的静态域就会被触发,而如果有一个static的变量刚好在声明时被初始化,而初始化中只有一个RuntimeException,此时就会抛异常。

所以,我们不能通过这种方式来获取父类,能否通过不需要加载class就能获取它的父类的方式呢?谜底就在眼前,父类其实也是一个class的字节码中的一项数据,那么我们就从字节码中查询父类即可。最终实现是这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152

public class ExtendClassWriter extends ClassWriter {

public static final String TAG = "ExtendClassWriter";

private static final String OBJECT = "java/lang/Object";

private ClassLoader urlClassLoader;

public ExtendClassWriter(ClassLoader urlClassLoader, int flags) {
super(flags);
this.urlClassLoader = urlClassLoader;
}

@Override
protected String getCommonSuperClass(final String type1, final String type2) {
if (type1 == null || type1.equals(OBJECT) || type2 == null || type2.equals(OBJECT)) {
return OBJECT;
}

if (type1.equals(type2)) {
return type1;
}

ClassReader type1ClassReader = getClassReader(type1);
ClassReader type2ClassReader = getClassReader(type2);
if (type1ClassReader == null || type2ClassReader == null) {
return OBJECT;
}

if (isInterface(type1ClassReader)) {
String interfaceName = type1;
if (isImplements(interfaceName, type2ClassReader)) {
return interfaceName;
}
if (isInterface(type2ClassReader)) {
interfaceName = type2;
if (isImplements(interfaceName, type1ClassReader)) {
return interfaceName;
}
}
return OBJECT;
}

if (isInterface(type2ClassReader)) {
String interfaceName = type2;
if (isImplements(interfaceName, type1ClassReader)) {
return interfaceName;
}
return OBJECT;
}

final Set<String> superClassNames = new HashSet<String>();
superClassNames.add(type1);
superClassNames.add(type2);

String type1SuperClassName = type1ClassReader.getSuperName();
if (!superClassNames.add(type1SuperClassName)) {
return type1SuperClassName;
}

String type2SuperClassName = type2ClassReader.getSuperName();
if (!superClassNames.add(type2SuperClassName)) {
return type2SuperClassName;
}

while (type1SuperClassName != null || type2SuperClassName != null) {
if (type1SuperClassName != null) {
type1SuperClassName = getSuperClassName(type1SuperClassName);
if (type1SuperClassName != null) {
if (!superClassNames.add(type1SuperClassName)) {
return type1SuperClassName;
}
}
}

if (type2SuperClassName != null) {
type2SuperClassName = getSuperClassName(type2SuperClassName);
if (type2SuperClassName != null) {
if (!superClassNames.add(type2SuperClassName)) {
return type2SuperClassName;
}
}
}
}

return OBJECT;
}

private boolean isImplements(final String interfaceName, final ClassReader classReader) {
ClassReader classInfo = classReader;

while (classInfo != null) {
final String[] interfaceNames = classInfo.getInterfaces();
for (String name : interfaceNames) {
if (name != null && name.equals(interfaceName)) {
return true;
}
}

for (String name : interfaceNames) {
if(name != null) {
final ClassReader interfaceInfo = getClassReader(name);
if (interfaceInfo != null) {
if (isImplements(interfaceName, interfaceInfo)) {
return true;
}
}
}
}

final String superClassName = classInfo.getSuperName();
if (superClassName == null || superClassName.equals(OBJECT)) {
break;
}
classInfo = getClassReader(superClassName);
}

return false;
}

private boolean isInterface(final ClassReader classReader) {
return (classReader.getAccess() & Opcodes.ACC_INTERFACE) != 0;
}


private String getSuperClassName(final String className) {
final ClassReader classReader = getClassReader(className);
if (classReader == null) {
return null;
}
return classReader.getSuperName();
}

private ClassReader getClassReader(final String className) {
InputStream inputStream = urlClassLoader.getResourceAsStream(className + ".class");
try {
if (inputStream != null) {
return new ClassReader(inputStream);
}
} catch (IOException ignored) {
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException ignored) {
}
}
}
return null;
}
}

到此为止,我们介绍了在Android上实现修改字节码的两个基础技术Transform+ASM,介绍了其原理和应用,分析了性能优化以及在Android平台上的适配等。在此基础上,我抽象出一个轮子,让开发者写字节码插件时,只需要写少量的ASM code即可,而不需关心Transform和ASM背后的很多细节。详见

https://github.com/Leaking/Hunter/wiki/Developer-API

万事俱备,只欠写一个插件来玩玩了,让我们来看看几个应用案例。

应用案例

先抛结论,修改字节码其实也有套路,一种是hack代码调用,一种是hack代码实现。

比如修改Android Framework(android.jar)的实现,你是没办法在编译期间达到这个目的的,因为最终Android Framework的class在Android设备上。所以这种情况下你需要从hack代码调用入手,比如Log.i(TAG, “hello”),你不可能hack其中的实现,但是你可以把它hack成HackLog.i(TAG, “seeyou”)。

而如果是要修改第三方依赖或者工程中写的代码,则可以直接hack代码实现,但是,当如果你要插入的字节码比较多时,也可以通过一定技巧减少写ASM code的量,你可以将大部分可以抽象的逻辑抽象到某个写好的class中,然后ASM code只需写调用这个写好的class的语句。

当然上面只是目前按照我的经验做的一点总结,还是有一些更复杂的情况要具体情况具体分析,比如在实现类似JakeWharton的hugo的功能时,在代码开头获取方法参数名时我就遇到棘手的问题(用了一种二次扫描的方式解决了这个问题,可以移步项目主页参考具体实现)。

我们这里挑选OkHttp-Plugin的实现进行分析、演示如何使用Huntet框架开发一个字节码编译插件。

使用OkHttp的人知道,OkHttp里每一个OkHttp都可以设置自己独立的Intercepter/Dns/EventListener(EventListener是okhttp3.11新增),但是需要对全局所有OkHttp设置统一的Intercepter/Dns/EventListener就很麻烦,需要一处处设置,而且一些第三方依赖中的OkHttp很大可能无法设置。曾经在官方repo提过这个问题的issue,没有得到很好的回复,作者之一觉得如果是他,他会用依赖注入的方式来实现统一的Okhttp配置,但是这种方式只能说可行但是不理想,后台在reddit发帖子安利自己Hunter这个轮子时,JakeWharton大佬竟然亲自回答了,虽然面对大佬,不过还是要正面刚!争论一波之后,总结一下他的立场,大概如下

他觉得我说的好像这是okhttp的锅,然而这其实是okhttp的一个feature,他觉得全局状态是一种不好的编码,所以在设计okhttp没有提供全局Intercepter/Dns/EventListener的接口。而第三方依赖库不能设置自定义Intercepter/Dns/EventListener这是它们的锅。

但是,他的观点我不完全同意,虽然全局状态确实是一种不好的设计,但是,如果要做性能监控之类的功能,这就很难避免或多或少的全局侵入。(不过我确实措辞不当,说得这好像是Okhttp的锅一样)

言归正传,来看看我们要怎么来对OkHttp动刀,请看以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

public Builder(){
this.dispatcher = new Dispatcher();
this.protocols = OkHttpClient.DEFAULT_PROTOCOLS;
this.connectionSpecs = OkHttpClient.DEFAULT_CONNECTION_SPECS;
this.eventListenerFactory = EventListener.factory(EventListener.NONE);
this.proxySelector = ProxySelector.getDefault();
this.cookieJar = CookieJar.NO_COOKIES;
this.socketFactory = SocketFactory.getDefault();
this.hostnameVerifier = OkHostnameVerifier.INSTANCE;
this.certificatePinner = CertificatePinner.DEFAULT;
this.proxyAuthenticator = Authenticator.NONE;
this.authenticator = Authenticator.NONE;
this.connectionPool = new ConnectionPool();
this.dns = Dns.SYSTEM;
this.followSslRedirects = true;
this.followRedirects = true;
this.retryOnConnectionFailure = true;
this.connectTimeout = 10000;
this.readTimeout = 10000;
this.writeTimeout = 10000;
this.pingInterval = 0;
this.eventListenerFactory = OkHttpHooker.globalEventFactory;
this.dns = OkHttpHooker.globalDns;
this.interceptors.addAll(OkHttpHooker.globalInterceptors);
this.networkInterceptors.addAll(OkHttpHooker.globalNetworkInterceptors);
}

这是OkhttpClient中内部类Builder的构造函数,我们的目标是在方法末尾加上四行代码,这样一来,所有的OkHttpClient都会拥有共同的Intercepter/Dns/EventListener。我们再来看看OkHttpHooker的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

public class OkHttpHooker {

public static EventListener.Factory globalEventFactory = new EventListener.Factory() {
public EventListener create(Call call) {
return EventListener.NONE;
}
};;

public static Dns globalDns = Dns.SYSTEM;

public static List<Interceptor> globalInterceptors = new ArrayList<>();

public static List<Interceptor> globalNetworkInterceptors = new ArrayList<>();

public static void installEventListenerFactory(EventListener.Factory factory) {
globalEventFactory = factory;
}

public static void installDns(Dns dns) {
globalDns = dns;
}

public static void installInterceptor(Interceptor interceptor) {
if(interceptor != null)
globalInterceptors.add(interceptor);
}

public static void installNetworkInterceptors(Interceptor networkInterceptor) {
if(networkInterceptor != null)
globalNetworkInterceptors.add(networkInterceptor);
}


}

这样,只需要为OkHttpHooker预先install好几个全局的Intercepter/Dns/EventListener即可。

那么,如何来实现上面OkhttpClient内部Builder中插入四行代码呢?

首先,我们通过Hunter的框架,可以隐藏掉Transform和ASM绝大部分细节,我们只需把注意力放在写ClassVisitor以及MethodVisitor即可。我们一共需要做以下几步

1、新建一个自定义transform,添加到一个自定义gradle plugin中
2、继承HunterTransform实现自定义transform
3、实现自定义的ClassVisitor,并依情况实现自定义MethodVisitor

其中第一步文章讲解transform一部分有讲到,基本是一样简短的写法,我们从第二步讲起

继承HunterTransform,就可以让你的transform具备并发、增量的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

final class OkHttpHunterTransform extends HunterTransform {

private Project project;
private OkHttpHunterExtension okHttpHunterExtension;

public OkHttpHunterTransform(Project project) {
super(project);
this.project = project;
//依情况而定,看看你需不需要有插件扩展
project.getExtensions().create("okHttpHunterExt", OkHttpHunterExtension.class);
//必须的一步,继承BaseWeaver,帮你隐藏ASM细节
this.bytecodeWeaver = new OkHttpWeaver();
}

@Override
public void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
okHttpHunterExtension = (OkHttpHunterExtension) project.getExtensions().getByName("okHttpHunterExt");
super.transform(context, inputs, referencedInputs, outputProvider, isIncremental);
}

// 用于控制修改字节码在哪些debug包还是release包下发挥作用,或者完全打开/关闭
@Override
protected RunVariant getRunVariant() {
return okHttpHunterExtension.runVariant;
}

}

//BaseWeaver帮你隐藏了ASM的很多复杂逻辑
public final class OkHttpWeaver extends BaseWeaver {

@Override
protected ClassVisitor wrapClassWriter(ClassWriter classWriter) {
return new OkHttpClassAdapter(classWriter);
}

}




//插件扩展
public class OkHttpHunterExtension {

public RunVariant runVariant = RunVariant.ALWAYS;

@Override
public String toString() {
return "OkHttpHunterExtension{" +
"runVariant=" + runVariant +
'}';
}
}

好了,Transform写起来就变得这么简单,接下来看自定义ClassVisitor,它在OkHttpWeaver返回。

我们新建一个ClassVisitor(自定义ClassVisitor是为了代理ClassWriter,前面讲过)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public final class OkHttpClassAdapter extends ClassVisitor{

private String className;

OkHttpClassAdapter(final ClassVisitor cv) {
super(Opcodes.ASM5, cv);
}

@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces);
this.className = name;
}

@Override
public MethodVisitor visitMethod(final int access, final String name,
final String desc, final String signature, final String[] exceptions) {
MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
if(className.equals("okhttp3/OkHttpClient$Builder")) {
return mv == null ? null : new OkHttpMethodAdapter(className + File.separator + name, access, desc, mv);
} else {
return mv;
}
}

}

我们寻找出okhttp3/OkHttpClient$Builder这个类,其他类不管它,那么其他类只会被普通的复制,而okhttp3/OkHttpClient$Builder将会有自定义的MethodVisitor来处理

我们来看看这个MethodVisitor的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

public final class OkHttpMethodAdapter extends LocalVariablesSorter implements Opcodes {

private boolean defaultOkhttpClientBuilderInitMethod = false;

OkHttpMethodAdapter(String name, int access, String desc, MethodVisitor mv) {
super(Opcodes.ASM5, access, desc, mv);
if ("okhttp3/OkHttpClient$Builder/<init>".equals(name) && "()V".equals(desc)) {
defaultOkhttpClientBuilderInitMethod = true;
}
}

@Override
public void visitInsn(int opcode) {
if(defaultOkhttpClientBuilderInitMethod) {
if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) {

//EventListenFactory
mv.visitVarInsn(ALOAD, 0);
mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalEventFactory", "Lokhttp3/EventListener$Factory;");
mv.visitFieldInsn(PUTFIELD, "okhttp3/OkHttpClient$Builder", "eventListenerFactory", "Lokhttp3/EventListener$Factory;");

//Dns
mv.visitVarInsn(ALOAD, 0);
mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalDns", "Lokhttp3/Dns;");
mv.visitFieldInsn(PUTFIELD, "okhttp3/OkHttpClient$Builder", "dns", "Lokhttp3/Dns;");

//Interceptor
mv.visitVarInsn(ALOAD, 0);
mv.visitFieldInsn(GETFIELD, "okhttp3/OkHttpClient$Builder", "interceptors", "Ljava/util/List;");
mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalInterceptors", "Ljava/util/List;");
mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "addAll", "(Ljava/util/Collection;)Z", true);
mv.visitInsn(POP);

//NetworkInterceptor
mv.visitVarInsn(ALOAD, 0);
mv.visitFieldInsn(GETFIELD, "okhttp3/OkHttpClient$Builder", "networkInterceptors", "Ljava/util/List;");
mv.visitFieldInsn(GETSTATIC, "com/hunter/library/okhttp/OkHttpHooker", "globalNetworkInterceptors", "Ljava/util/List;");
mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "addAll", "(Ljava/util/Collection;)Z", true);
mv.visitInsn(POP);
}
}
super.visitInsn(opcode);
}

}

首先,我们先找出okhttp3/OkHttpClient$Builder的构造函数,然后在这个构造函数的末尾,执行插入字节码的逻辑,我们可以发现,字节码的指令是符合逆波兰式的,都是操作数在前,操作符在后。

至此,我们只需要发布插件,然后apply到我们的项目中即可。

借助Hunter框架,我们很轻松就成功hack了Okhttp,我们就可以用全局统一的Intercepter/Dns/EventListener来监控我们APP的网络了。另外,说个题外话,可能有人可能会说,并非所有网络请求都是通过Okhttp来写,也可能是通过URLConnection来写,可以参考我另一篇文章提到的

http://quinnchen.me/2017/11/18/2017-11-18-android-http-dns/#HttpURLConnection

这篇文章有讲解如何将URLConnection的请求导向自己指定的OkhttpClient.

讲到这里,就完整得介绍了如何使用Hunter框架开发一个字节码编译插件,对第三方依赖库为所欲为。如果对于代码还有疑惑,可以移步项目主页,参考完整代码,以及其他几个插件的实现。有时间再写文章介绍其他几个插件的具体实现。

总结

这篇文章写到这里差不多了,全文主要围绕Hunter展开介绍,分析了如何开发一个高效的修改字节码的编译插件,以及ASM字节码技术的一些相关工作流和开发套路。

也欢迎大家前往Hunter项目主页,欢迎使用Hunter框架开发插件,以及使用现有的几个插件,也欢迎提issue。

参考资料与文章推荐

深入理解Java虚拟机

ASM GUIDE

一份不错的字节码技术PPT

最开始想学sketch是因为想做一个饭否客户端,而要做一个客户端第一步是想好如何设计,难免需要一点设计技能,所以就开始学习sketch,最开始一直无从下手,拖了很久。这两天发现从临摹LOGO开始是个不错的方式。以我的体会,这个临摹过程学习到最重要的两个点是,

  • 熟悉sketch基本操作,与一些图形转化技巧。
  • 学习如何解析一副设计图的结构,设计图大多不是直接画出来的,而是通过各种图形加上各种转化得来的。

快速入门sketch的四步

  • 下载sketch。
  • 花两三个小时看一下sketch文档
  • 找一两个视频看看别人怎么画图标,可以快速了解基本操作
  • 随便找几个图标开始临摹

sketch只支持Mac平台,如果购买正版价钱不便宜,盗版可以前往http://xclient.info/下载。

至于sketch文档,强烈建议看,有中文版

以下是我这个周末的小成果,

近来用Apache POI解析Excel内容,遇到一个解析图片的坑,这篇文章将解释如何正确获取Excel中图片的正确大小和位置。我想,这篇文章很适合和我一样正在用Apache POI解析Excel内容的人阅读,替你避开一些坑,以及让你知道Apache POI中一处解析图片大小的bug。

我们知道,Apache POI中对Excel的图片的大小和位置存储在ClientAnchor对象中,而该对象可以通过Picture获取

1
ClientAnchor anchor = picture.getClientAnchor();

而图片的大小和位置,最终是通过ClientAnchor的以下几个变量进行描述

1
row1, col1, row2, col2, dx1, dy1, dx2, dy2

请看下图,图中标出了以上八个变量的含义

row1, col1代表图片左上角所在的格子,而dx1, dy1表示图片在这个左上角格子的偏移量;
row2, col2代表图片右下角所在的格子,而dx2, dy2表示图片在这个右下角格子的偏移量。

从图中不难发现,要获取图片位置,只需把在row1, col1之前的列宽,行高分别累加起来,然后再分别加上dx1, dy1的偏移量,即可得到图片在sheet中的位置。

而要获取图片大小,从图中对几个变量的注释,也不难理解如何获取图片大小,我们可以使用POI的工具方法获取图片大小org.apache.poi.ss.util.ImageUtils#getDimensionFromAnchor

由于计算图片高度与计算宽度原理一致,所以我们只介绍一下计算宽度的思路。上面图中,图片横跨了4列,我们默认这四列宽度分别为width1, width2, width3, width4。那么

1
图片宽度 = (width1 - dx1) + width2 + width3 + dx2

到此为止貌似一切都很顺利,但是后来上线发现一个问题,当一张图片只占据一个方格时,org.apache.poi.ss.util.ImageUtils#getDimensionFromAnchor 这个方法计算图片大小是有bug的,

我们先来看看,当图片只占据一个方格时的样子

很明显,此时

1
图片宽度 = dx2 - dx1

综上,我们可以得出计算图片大小的结论,我们依然只列出结算宽度的过程

1
2
3
4
5
6
7
如果col1 < col2, 默认这其中横跨的列,它们的列宽分别是width1, width2, width3, ... , widthn,那么

图片宽度 = (width1 - dx1) + width2 + width3 + ... + width(n-1) + dx2

如果col1 = col2,那么

图片宽度 = dx2 - dx1

最后附上实现代码Gist

由于一些ISP的LocalDNS的问题,用户经常会获得一个次优的DNS解析结果,导致网络访问缓慢,其中原因无非三点,第一:ISP的LocalDNS缓存;第二:ISP为了节约成本,转发DNS请求到其他ISP;第三:ISP递归解析DNS时,可能由于NAT解析错误,导致出口IP不对。这些问题也促进了各大互联网公司推出自己的DNS服务,也就是HttpDNS,传统的DNS协议是通过UDP实现,而HttpDNS是通过Http协议访问自己搭建的DNS服务器。关于HttpDNS设计的初衷,推荐阅读这篇文章【鹅厂网事】全局精确流量调度新思路-HttpDNS服务详解

而对于Android应用,我们要如何接入HttpDNS服务呢?首先,你需要找一个可以用的HttpDNS服务器,比如腾讯云的HttpDNS服务器或者阿里云的HttpDNS服务器,这些服务都是让客户端提交一个域名,然后返回若干个IP解析结果给客户端,得到IP之后,如果客户端简单粗暴地将本地的网络请求的域名替代成IP,会面临很多问题:

  • 1、Https如何进行域名验证
  • 2、如何处理SNI的问题,一个服务器使用多个域名和证书,服务器不知道应该提供哪个证书。
  • 3、WebView中的资源请求要如何托管
  • 4、第三方组件中的网络请求,我们要如何为它们提供HttpDNS

以上四点,腾讯云和阿里云的接入文档对前三点都给出了相应的解决方案,然而,不仅仅第四点的问题无法解决,腾讯云和阿里云对其他几点的解决方案也都不算完美,因为它们都有一个共同问题,不能在一个地方统一处理所有网络DNS,需要逐个使用网络请求的地方去相应地解决这些问题,而且这种接入HttpDNS的方式对代码的侵入性太强,缺乏可插拔的便捷性。

有没有其他侵入性更低的方式呢?接下来让我们来探索几种通过Hook的方式来为Android应用提供全局的HttpDNS服务。

Native hook

可以借助dlopen的方式hook系统NDK中网络连接connect方法,在hook实现中处理域名解析(可参考Android hacking: hooking system functions used by Dalvik),我们也确实在很长一段时间里都是使用这种方式处理HttpDNS,但是,从Android 7.0发布后,系统将阻止应用动态链接非公开NDK库,这种库可能会导致您的应用崩溃,可参考Android 7.0 行为变更

根据应用使用的私有原生库及其目标 API 级别 (android:targetSdkVersion),应用预期显示的行为

总结一下,从Android 7.0开始,如果Target API小于等于23,则应用第一次启动时会弹出一个Toast提示(部分国产ROM上没严格遵守这个规定,目前在MIUI上有看到Toast而已),如果Target API大于等于24,则直接Crash。

虽然目前我们的Target API只是23,只会在部分手机上弹出Toast,但是迟早是要面临上面提到Crash的问题,所以我们开始探索使用新的方式进行Hook,native层行不通,那么只能在Java层寻找新的出路。

Java hook

QQ邮箱Android端的网络请求主要分两种,一种走Http流量,比如自己的cgi请求都是Http流量,另一种直接走Socket,这主要是请求外域邮箱(163,126等等),而我们的HttpDNS服务,只提供解析腾讯的域名,不支持解析外部域名,所以,我们其实可以只为Http流量部分提供HttpDNS解析。

让我们分析一下,目前Java层的Http请求是怎么发出的,可以分为两种方式,

  • 直接使用HttpURLConnection,或者基于HttpURLConnection封装的Android-async-http,Volley等第三方库。注意,这里只提HttpURLConnection,为了行文方便,默认包含HttpsURLConnection
  • 使用OkHttp。OkHttp按照Http1.x, Http2.0, SPDY的语义,用刀耕火种的方式,从Socket一步步实现Http(可能你会想,Android 4.4开始,HttpURLConnection的实现不是使用了OkHttp吗?确实是的,不过这个问题按下不表,后面解释)

那么,我们接下来可以针对以上两种方案,提供HttpDNS服务,首先从OkHttp开始吧,

OkHttp

OkHttp开放了如下代码所示的DNS接口,我们可以为每个OkHttpClient设置自定义的DNS服务,如果没有设置,则OkHttpClient将使用一个默认的DNS服务。

我们可以为每个OkHttpClient设置我们的HttpDNS服务,但是这种方式不能一劳永逸,每增加一个OkHttpClient我们都需要手动做相应修改,而且,第三方依赖库中的OkHttpClient我们更是无能为力。换一种思路,我们可以通过反射,替换掉Dns.SYSTEM这个默认的DNS实现,这样就可以一劳永逸了。

以下是Dns接口的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

/**
* A domain name service that resolves IP addresses for host names. Most applications will use the
* {@linkplain #SYSTEM system DNS service}, which is the default. Some applications may provide
* their own implementation to use a different DNS server, to prefer IPv6 addresses, to prefer IPv4
* addresses, or to force a specific known IP address.
*
* <p>Implementations of this interface must be safe for concurrent use.
*/
public interface Dns {
/**
* A DNS that uses {@link InetAddress#getAllByName} to ask the underlying operating system to
* lookup IP addresses. Most custom {@link Dns} implementations should delegate to this instance.
*/
Dns SYSTEM = new Dns() {
@Override public List<InetAddress> lookup(String hostname) throws UnknownHostException {
if (hostname == null) throw new UnknownHostException("hostname == null");
return Arrays.asList(InetAddress.getAllByName(hostname));
}
};

/**
* Returns the IP addresses of {@code hostname}, in the order they will be attempted by OkHttp. If
* a connection to an address fails, OkHttp will retry the connection with the next address until
* either a connection is made, the set of IP addresses is exhausted, or a limit is exceeded.
*/
List<InetAddress> lookup(String hostname) throws UnknownHostException;
}

HttpURLConnection

这里说的HttpURLConnection,除了它本身,也包含了所有基于HttpURLConnection封装的第三方网络库,如Android-async-http,Volley等等。那么,我们要如何统一的处理所有HttpURLConnection的DNS呢?

我们从前面提到的问题开始切入,

Android 4.4开始,HttpURLConnection的实现使用了OkHttp的实现.

那么HttpURLConnection和OkHttp,这两套东西是怎么结合在一起的呢?在这里,先提一个我最开始存在的一个疑问,在很久以前,还没对OkHttp的代码进行阅读,我无知地以为OkHttp也是和其他三俗的网络库一样,也是基于HttpURLConnection进行封装,拓展一下缓存机制,并发管理等等,那Android系统的HttpURLConnection还基于OkHttp实现?岂不是陷入“鸡生蛋,蛋生鸡,先有鸡还是先有蛋”的问题。这个疑问现在看起来很幼稚,最终答案是,OkHttp的实现不是基于HttpURLConnection,而是自己从Socket开始,重新实现的。

回到刚才的问题,HttpURLConnection是通过什么方式,将内核实现切换到OkHttp实现,让我们从代码中寻找答案,我们一般都这样构建一个HttpURLConnection

1
2

HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();

接下来,在URL这个类中寻找,HttpURLConnection是如何被构建出来的,

1
2
3
4
5
6
7
8
9

/**
* The URLStreamHandler for this URL.
*/
transient URLStreamHandler handler;

public URLConnection openConnection() throws java.io.IOException {
return handler.openConnection(this);
}

继续寻找这个URLStreamHandler的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68


static URLStreamHandlerFactory factory;

public static void setURLStreamHandlerFactory(URLStreamHandlerFactory fac) {
synchronized (streamHandlerLock) {
if (factory != null) {
throw new Error("factory already defined");
}
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkSetFactory();
}
handlers.clear();
factory = fac;
}
}

/**
* Returns the Stream Handler.
* @param protocol the protocol to use
*/
static URLStreamHandler getURLStreamHandler(String protocol) {

URLStreamHandler handler = (URLStreamHandler)handlers.get(protocol);
if (handler == null) {

boolean checkedWithFactory = false;

// Use the factory (if any)
if (factory != null) {
handler = factory.createURLStreamHandler(protocol);
checkedWithFactory = true;
}

//...

// Fallback to built-in stream handler.
// Makes okhttp the default http/https handler
if (handler == null) {
try {
if (protocol.equals("file")) {
handler = (URLStreamHandler)Class.
forName("sun.net.www.protocol.file.Handler").newInstance();
} else if (protocol.equals("ftp")) {
handler = (URLStreamHandler)Class.
forName("sun.net.www.protocol.ftp.Handler").newInstance();
} else if (protocol.equals("jar")) {
handler = (URLStreamHandler)Class.
forName("sun.net.www.protocol.jar.Handler").newInstance();
} else if (protocol.equals("http")) {
handler = (URLStreamHandler)Class.
forName("com.android.okhttp.HttpHandler").newInstance();
} else if (protocol.equals("https")) {
handler = (URLStreamHandler)Class.
forName("com.android.okhttp.HttpsHandler").newInstance();
}
} catch (Exception e) {
throw new AssertionError(e);
}
}

//...
}

return handler;

}

到这里,我们找到了OkHttp的影子,Android这里反射获取的com.android.okhttp.HttpHandlercom.android.okhttp.HttpsHandler,可以到AOSP external模块中找到它们,它们都是URLStreamHandler的实现,

URLStreamHandler

URLStreamHandler的职责主要是构建URLConnection。上面getURLStreamHandler的代码,我们可以另外注意到一点,这里有一个URLStreamHandler的工厂实现,也就是URLStreamHandlerFactory factory,这个工厂默认为空,如果我们为它赋予一个实现,则可以让系统通过这个工厂,获取我们自定义的URLStreamHandler,这就是我们统一处理所有HttpURLConnection的关键所在,我们只需为系统提供一个自定义的URLStreamHandlerFactory,在其中返回一个自定义的URLStreamHandler,而这个URLStreamHandler可以返回我们提供了HttpDNS服务的URLConnection。

到此为止,我们大致知道如何统一处理所有HttpURLConnection,接下来需要揣摩的问题有两个:

  • 1、如何实现一个自定义的URLStreamHandlerFactory
  • 2、Android系统会使用了哪个版本的OkHttp呢?

关于如何实现自定义的URLStreamHandlerFactory,可以参考OkHttp其中一个叫okhttp-urlconnection的module,这个module其实就是为了构建了一个基于OkHttp的URLStreamHandlerFactory。

在自定义工厂中,我们都可以为其设置一个自定义的OkhttpClient,所以,我们也可以和前面一样,为OkhttpClient设置自定义的DNS服务,到此为止,我们就实现全局地为HttpURLConenction提供HttpDNS服务了。

另外提一点,okhttp-urlconnection这个模块的核心代码被标记为deprecated

1
2
3
4
5
6
7
8
9

/**
* @deprecated OkHttp will be dropping its ability to be used with {@link HttpURLConnection} in an
* upcoming release. Applications that need this should either downgrade to the system's built-in
* {@link HttpURLConnection} or upgrade to OkHttp's Request/Response API.
*/
public final class OkUrlFactory implements URLStreamHandlerFactory, Cloneable {
//...
}

放心,我们在AOSP的external/okhttp发现,前面提到的com.android.okhttp.HttpHandler也是一样的实现原理,所以这样看来,这种方式还是可以继续用的。上面提到的deprecated,原因不是因为接口不稳定,而是因为OkHttp官方想安利使用标准的OkHttp API。

另一个问题,Android系统会使用哪个版本的OkHttp呢?以下是截止目前AOSP master分支上最新的OkHttp版本

AOSP使用了哪个版本的OkHttp

Android Framework竟然只使用了OkHttp2.6的代码,不知道是出于什么考虑,Android使用的OkHttp版本迟迟没有更新,可以看一下OkHttp的CHANGELOG.md,从2.6版本到如今最新的稳定版3.8.1,已经添加了诸多提高稳定性的bugfix、feature。所以,如果我们为应用提供一个自定义的URLStreamHandlerFactory,还有一个好处,就是可以使HttpURLConnection获得最新的Okhttp优化。

除此之外,还可以做很多事情,比如利用基于责任链机制的Interceptors来做Http流量的抓包工具,或者Http流量监控工具,可以参考chuck.

到目前为止,我们已经可以处理所有的Http流量,为其添加HttpDNS服务,虽然已经满足我们的业务,但是还不够,作为一个通用的解决方案,还是需要为TCP流量也提供HttpDNS服务,也就是,如何处理所有的Socket的DNS,而如果一旦为Socket提供了统一的HttpDNS服务,也就不用再去处理Http流量的DNS,接下来开始介绍我们是如何处理的。

如何全局处理所有Socket的DNS

关于这个问题,我们考虑过两种思路,第一种,使用SocketImplFactory,构建自定义的SocketImpl,这种方式会相对第二种方式复杂一点,这一种方式还没真正执行,不过,这种方式有另外一个强大的地方,就是可以实现全局的流量监控,接下来可能会围绕它来做流量监控。接下来介绍另一种方式。

我们从Android应用默认的DNS解析过程入手,发现默认的DNS解析,都是调用以下getAllByName接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14

public class InetAddress implements java.io.Serializable {

//,,,

static final InetAddressImpl impl = new Inet6AddressImpl();

public static InetAddress[] getAllByName(String host) throws UnknownHostException {
return impl.lookupAllHostAddr(host, NETID_UNSET).clone();
}

//,,,

}

而进入代码,我们可以发现,Inet6AddressImpl就是一个标准的接口类,我们完全可以动态代理它,以添加我们的HttpDNS实现,再将新的Inet6AddressImpl反射设置给上面的InetAddressImpl impl,至此,完美解决问题。

目前,QQ邮箱最新版本使用了自定义URLStreamHandlerFactory的方式,接下来准备迁移到动态代理InetAddressImpl的方式。不过还是会保留自定义URLStreamHandlerFactory,用于引入最新OkHttp特性,以及流量监控。

遇到的问题

简单介绍一下踩到的几个坑

1、X509TrustManager获取失败

这个问题,应该很多人都遇到过,如果只设置了SSLSocketFactory,OkHttp会自定尝试反射获取一个X509TrustManager,而反射的来源,sun.security.ssl.SSLContextImpl在Android上是不存在的,所以最终抛出Unable to extract the trust manager的Crash。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public Builder sslSocketFactory(SSLSocketFactory sslSocketFactory) {
if (sslSocketFactory == null) throw new NullPointerException("sslSocketFactory == null");
X509TrustManager trustManager = Platform.get().trustManager(sslSocketFactory);
if (trustManager == null) {
throw new IllegalStateException("Unable to extract the trust manager on " + Platform.get()
+ ", sslSocketFactory is " + sslSocketFactory.getClass());
}
this.sslSocketFactory = sslSocketFactory;
this.certificateChainCleaner = CertificateChainCleaner.get(trustManager);
return this;
}

//上面提到的Platform.get().trustManager方法
public X509TrustManager trustManager(SSLSocketFactory sslSocketFactory) {
// Attempt to get the trust manager from an OpenJDK socket factory. We attempt this on all
// platforms in order to support Robolectric, which mixes classes from both Android and the
// Oracle JDK. Note that we don't support HTTP/2 or other nice features on Robolectric.
try {
Class<?> sslContextClass = Class.forName("sun.security.ssl.SSLContextImpl");
Object context = readFieldOrNull(sslSocketFactory, sslContextClass, "context");
if (context == null) return null;
return readFieldOrNull(context, X509TrustManager.class, "trustManager");
} catch (ClassNotFoundException e) {
return null;
}
}

为了解决这个问题,应该重写okhttp-urlconnection中的OkHttpsURLConnection类,对以下方法做修改

1
2
3
4
5
6
@Override public void setSSLSocketFactory(SSLSocketFactory sslSocketFactory) {
// This fails in JDK 9 because OkHttp is unable to extract the trust manager.
delegate.client = delegate.client.newBuilder()
.sslSocketFactory(sslSocketFactory) //改为sslSocketFactory(sslSocketFactory, yourTrustManager)
.build();
}

2、Proxy的认证

OkHttp对Proxy的认证信息,是通过一个自定义的Authenticator接口获取的,而非从头部获取,所以在设置Proxy的认证信息时,需要为OkHttpClient添加一个Authenticator用于代理的认证。

3、死循环

如果你的HttpDNS的查询接口,是IP直连的,那么没有这个问题,可以跳过,如果是通过域名访问的,那需要注意,不要对这个域名进行HttpDNS解析,否则会陷入死循环。

总结

本文主要围绕了如何为Android应用所有网络请求提供HttpDNS服务,分析了如何通过hook的方式,实现可插拔地接入方式。并且介绍了从native层到Java层的技术方案的演进,总结遇到的问题和解决方案。

如果有表达不准确的地方,还望指出。

之前使用正则表达式,总对一部分正则语法模棱两可。这周末看了《正则表达式必知必会》,一本100来页的小工具书,非常实用,系统地梳理了正则表达式的语法。本文主要围绕这本书,从易到难介绍正则的使用,用于帮我自己梳理思路。

匹配单个字符

1、直接某个字符匹配该字符

2、使用.匹配任意一个字符

3、使用\匹配元字符,比如\.\\

匹配一组字符

1、在[]中放置一个字符集合,匹配其中的一个,比如[ns],匹配n或者s

2、在[]中放置一个范围,匹配其中的一个,比如[A-Za-z0-9],匹配数字或者字母

3、在[]中可以使用取非操作,比如[^0-9],表示非数字字符

使用元字符

1、可以通过增加\来匹配元字符,比如\[\]\.\\

2、匹配空白字符,即匹配各种控制字符,比如换行(\n),回车(\r)、换页(\f),制表符(\t),垂直制表(\v)等。比较常见是使用\r\n匹配windows系统的换行,Linux系统的换行是\n

3、匹配字符类别

元字符 匹配说明
\d 数字,等价于[0-9]
\D 非数字,等价于[^0-9]
\w 数字字母加下划线,等价于[a-zA-Z0-9_]
\W 非数字字母加下划线,等价于[^a-zA-Z0-9_]
\s 空白字符,等价于[\f\n\r\t\v]
\S 非空白字符,等价于[^\f\n\r\t\v]
POSIX字符集 比如[[:xdigit:]]匹配十六进制数字

重复匹配

1、重复规则

规则 匹配说明
+ 一个或多个,比如\w+,表示至少一个字母数字下划线类型字符
* 零个或多个
? 零个或一个
{3} 指定个数 (注意,这里的数字只是举例,下面出现的集合例子也用一个数字举例)
{3,5} 指定区间,可以是开区间,比如{3, },表示大于等于3

2、贪婪型和懒惰型

重复匹配中的+*{a,}(注意,是开区间),这三个模式都没有一个上限值,所以存在贪婪行为和懒惰行为的区分,默认都是贪婪行为。
二者的区别在于,贪婪行为会尽可能的匹配最长的结果,而懒惰行为则会尽量匹配最小的结果。

为了将以上三种重复匹配切换为懒惰模式,需要做相应转换。

贪婪 懒惰
+ +?
* *?
{3,} {3,}?

位置匹配

1、单词边界,可以使用\bcat\b这样的模式,匹配单词cat,匹配到的字符串必须是一个独立的单词,也就是两侧都是空格,而不能是某个单词的一部分。顾名思义,\bcat和’cat\b’分别表示以’cat’开头和结尾的单词。

2、非单词边界,使用\B-\B将会匹配on your color - coded中的-。(这个用法我还存在疑问)

3、字符串开头匹配,在正则开头加上^,字符串结尾匹配,在正则表达式末尾加上^.*$可以表示任意字符。

4、分行匹配,在模式开头启用(?m)将会使得^$以行为单位进行处理。

子表达式

前面我们学习的匹配字符多次重复的语法,都是针对单个字符的重复,而子表达式是为了处理多个字符的重复,多个字符组成一个子表达式。只需用()包围起来即可成为一个子表达式,包围的内容可以是一个普通字符串,也可以是一个正则表达式。

子表达式除了用于表达字符串的重复,还用于|操作符两侧的条件做出准确的定义,最好对复杂的条件使用子表达式

另外,子表达式支持嵌套使用。

回溯引用:前后一致匹配

可以使用\1\2\3这样的格式,代表一个正则表达式中的第一,二,三个子表达式,这就是回溯引用的意思。

比如以下例子

1
<[hH]([1-6])>.*?</[Hh]\1>

回溯引用

以上例子,会匹配到所有正确的HTML的标题标签,而不会匹配到最后一行错误的<H2>This is not valid line</H3>

回溯引用除了用于文本匹配,也可以用于文本替换,关于文本替换,这里就不列举了,推荐参考原书。

前后查找

前后查找是指对某一些位置的前后进行查找,比如,查找html中<Title></Title>中间的标题内容,而且匹配结果不带上<Title></Title>标签。

向前匹配,使用(?=),比如

1
.*(?=:)

向前查找

向后匹配,使用(?<=),比如

1
(?<=\$)[0-9.]+

向后查找

向前查找和向后查找可以结合使用,这个用法用法在解析HTML时很使用,比如提取<Title></Title>中间的内容。

向前查找和向后查找也可以取非,意义在于,不用特定字符开始或不用特定字符结尾,同样的,这些特定字符不在最终匹配结果中。向前查找取非:(?!),向后查找取非:(?<!)

嵌入条件

嵌入条件用于两种地方,回溯引用和前后查找。

以下是回溯引用嵌入条件的一个例子

1
(\()?\d{3}(?(1)\)|-)\d{3}-\d{4}

回溯引用的嵌入条件

其中精华在于这一段,(?(1)xx|yy),如果回溯引用1成立,则执行xx匹配,否则执行yy匹配

以下是前后查找嵌入条件的一个例子,

1
\d{5}(?(?=-)-\d{4})

前后查找的嵌入条件

如果向前查找(?=-)成立,则需要继续匹配-\d{4}

书的末尾,提到了各个语言的正则表达式解析引擎存在一些大大小小的区别。

最后,推荐一个在线正则匹配的网站,https://regex101.com/

好,到此为止,介绍忘了正则的基本语法,下一节会介绍正则的解析引擎,DFA和NFA,敬请期待。

我们经常通过WebViewClientshouldInterceptRequest方法拦截WebView请求,自己托管页面资源的加载,我们先来看一下这个方法能为我们做什么:

1
2
3
4
5
6
7
8
9
@Deprecated
public WebResourceResponse shouldInterceptRequest(WebView view,
String url) {
return null;
}
public WebResourceResponse shouldInterceptRequest(WebView view,
WebResourceRequest request) {
return shouldInterceptRequest(view, request.getUrl().toString());
}

上面两个重载方法,第二个方法是Android 5.0才支持的方法,相比第一个方法,我们能从第二个方法的新参数WebResourceRequest中获取除了url之外的请求参数,比如mineType,我们也可以发现,如果我们不重载这两个方法,最终这两个方法都是返回null,其中第二个方法是调用了第一个方法。如果返回null,则由WebView处理资源的加载,如果返回非null,那我们则可以从网络或者本地加载资源,然后封装到WebResourceResponse中返回即可,最终WebView就会使用我们返回的资源。所以我们可以借助这个方法托管Webview资源的下载。

接下来我们再看看这两个方法的方法说明.

Notify the host application of a resource request and allow the application to return the data. If the return value is null, the WebView will continue to load the resource as usual. Otherwise, the return response and data will be used. NOTE: This method is called on a thread other than the UI thread so clients should exercise caution when accessing private data or the view system.

我们可以发现这个方法运行在非UI线程,其实还有一个重要的问题上面没有强调的,就是这个方法每次被调用都是同个非UI线程(Chrome_FileThread)执行,所以多个资源并发时,这个方法是阻塞的,而一般同个HTML难以避免会有多个资源存在。

单线程请求页面中的每个资源,这样的页面加载速度不能接受的,接下来我们看看有什么可以突破的地方。我们从WebResourceResponse入手,先看它的构造函数

1
2
3
4
5
6
7
8
9
10
11
public WebResourceResponse(String mimeType, String encoding, InputStream data) {
mMimeType = mimeType;
mEncoding = encoding;
setData(data);
}
public WebResourceResponse(String mimeType, String encoding, int statusCode,
String reasonPhrase, Map<String, String> responseHeaders, InputStream data) {
this(mimeType, encoding, data);
setStatusCodeAndReasonPhrase(statusCode, reasonPhrase);
setResponseHeaders(responseHeaders);
}

我们通过静态代理的方式,代理WebResourceResponse中指向资源的InputStream成员,看看最终WebView处理资源数据时是在哪个线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, final String url) {
Log.i(TAG, String.format("shouldInterceptRequest in thread: %s[%s] url: %s", Thread.currentThread().getName(), Thread.currentThread().getId() + "", url));
return new WebResourceResponse("", "utf-8", new InputStream() {
private InputStream inputStream = null;
@Override
public int read() throws IOException {
Log.i(TAG, String.format("Reading data in thread %s[%s] url %s ", Thread.currentThread().getName(), Thread.currentThread().getId(), url));
if (inputStream == null) {
try {
Request request = new Request.Builder()
.url(url)
.build();
Response response = okHttpClient.newCall(request).execute();
inputStream = response.body().byteStream();
} catch (Exception e) {
Log.e(TAG, "Fail Reading WebResourceResponse url: " + url);
}
}
if (inputStream == null) {
return -1;
} else {
return inputStream.read();
}
}

@Override
public void close() throws IOException {
super.close();
if (inputStream != null) {
inputStream.close();
}
}
});
}
1
2
3
09-14 10:39:52.482 28644-28705/com.tencent.webviewsample I/MainActivity: Reading data from Thread-4[11409] url is https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1505545242&di=8a65cd2cf3995201b8028f853f592c35&imgtype=jpg&er=1&src=http%3A%2F%2Fh.hiphotos.baidu.com%2Fzhidao%2Fpic%2Fitem%2F060828381f30e9240ff2cd434c086e061d95f76a.jpg
09-14 10:39:52.482 28644-29235/com.tencent.webviewsample I/MainActivity: Reading data from Thread-7[11421] url is https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1505545266&di=2eff701f8f336f6427bdd8a50321b016&imgtype=jpg&er=1&src=http%3A%2F%2Fscimg.jb51.net%2Fallimg%2F160131%2F14-1601311A539C3.jpg
09-14 10:39:52.482 28644-28721/com.tencent.webviewsample I/MainActivity: Reading data from Thread-6[11419] url is https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1505544739&di=aab230dbfbc901260fc148e6e87ab059&imgtype=jpg&er=1&src=http%3A%2F%2Fimg3.redocn.com%2F20100521%2FRedocn_2010052023544076.jpg

最终惊奇发现,读取图片数据流时是并发进行的,那么我们就可以突破shouldInterceptRequest单线程执行的问题了,只需要代理WebResourceResponse中指向资源的InputStream成员,当系统读取数据流的第一个字时去执行资源下载。

我们使用以下内容,对比单线程和多线程两种图片加载方式的速度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
<script>
window.addEventListener('load', function(ev) {
App.onLoad('onload'); //Java层注册JavasciptInterface APP,使'load'回调判断图片加载完毕
});
</script>
</head>
<body>
<img onload='console.log(1)' src='https://rescdn.qqmail.com/zh_CN/htmledition/images/webp/logo/qqmail/qqmail_logo_default_35h206ff1.png' />
<img onload='console.log(3)' src='https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1505544739&di=aab230dbfbc901260fc148e6e87ab059&imgtype=jpg&er=1&src=http%3A%2F%2Fimg3.redocn.com%2F20100521%2FRedocn_2010052023544076.jpg' />
<img onload='console.log(4)' src='https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1505545111&di=2d4fc96817efd328d2865bf4ac37559e&imgtype=jpg&er=1&src=http%3A%2F%2Fimg1.pconline.com.cn%2Fpiclib%2F200812%2F22%2Fbatch%2F1%2F19891%2F1229912689801kpqtgqzpxq.jpg' />
<img onload='console.log(5)' src='https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1505545242&di=8a65cd2cf3995201b8028f853f592c35&imgtype=jpg&er=1&src=http%3A%2F%2Fh.hiphotos.baidu.com%2Fzhidao%2Fpic%2Fitem%2F060828381f30e9240ff2cd434c086e061d95f76a.jpg' />
<img onload='console.log(6)' src='https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1505545266&di=2eff701f8f336f6427bdd8a50321b016&imgtype=jpg&er=1&src=http%3A%2F%2Fscimg.jb51.net%2Fallimg%2F160131%2F14-1601311A539C3.jpg' />
<img onload='console.log(7)' src='https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1505545753&di=c72dc06135b35c589bf9fcfbce5ff7e1&imgtype=jpg&er=1&src=http%3A%2F%2Fimg2.niutuku.com%2Fdesk%2Fanime%2F4446%2F4446-8866.jpg' />
<img onload='console.log(8)' src='https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1505545753&di=c72dc06135b35c589bf9fcfbce5ff7e1&imgtype=jpg&er=1&src=http%3A%2F%2Fimg2.niutuku.com%2Fdesk%2Fanime%2F4446%2F4446-8866.jpg' />
<img onload='console.log(9)' src='https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1505545784&di=efb541bc5fe7807ea9097ebb5c21e756&imgtype=jpg&er=1&src=http%3A%2F%2Fscimg.jb51.net%2Fallimg%2F140404%2F10-140404220101L9.jpg' />
</body>
</html>

最终多次试验对比后,数据如下

试验次数(单位:ms) 1 2 3 4 5 6 7 平均
单线程 407 416 364 477 377 317 433 398.7
多线程 298 275 256 255 258 307 306 279.2

多线程的方式比单线程的方式速度上提升了30%。

总结一下,本主介绍了一个简单但是效果显著的方法,解决托管WebView资源下载时的并发问题。

SparseArray和ArrayMap以及其衍生类都是Android独有的集合类,它们的衍生类主要有以下几个:

SparseArray

  • SparseBooleanArray
  • SparseIntArray
  • SparseLongArray
  • LongSparseArray

ArrayMap

  • ArraySet

Android平台设计这些集合类,它们的用途是作为HashMap和HashSet的替代品,但是也并非所有场景都适合使用SparseArray和ArrayMap。接下来我们先分析上面提到的几个类的原理,然后分析其优缺点以及使用场景。

HashMap的原理以及缺点

首先我们先来分析HashMap的原理。

HashMap

上图绘制了HashMap的数据结构,HashMap使用一个数组存储数据,数组的每个成员是一个Entry<K,V>。而Entry<K, V>的数据结构是这样的:

1
2
3
4
5
6
7
8
//参考自Android25最新的源代码
static class HashMapEntry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
HashMapEntry<K,V> next;
int hash;
...
}

其中除了存储keyhashvalue,还有一个next引用,所以这其实是一个链表,即HashMap中的数组存储的每个数据其实是一个由Entry<K, V>组成的链表。(这种方法就是“拉链法”,解决hash冲突的一种方法)。基于以上描述的HashMap的数据结构,查找的数据的流程主要分两步,第一步:输入key,根据某个hash算法求得该key在数组中的位置,取出数组中这个位置的Entry链表,一般链表的第一个结点就是当前key对应的Entry,如果不是,则查找链表的下一个Entry.

HashMap中还有两个重要的成员变量,影响着HashMap的性能,分别是“初始容量“和”加载因子“,“初始容量“代表以上提到的数组的初始长度,当数组的数据填充到一定量,则需要扩展数组(长度增大1倍),而什么触发这个增长呢?这就由”加载因子“决定,”加载因子“默认大小为0.75,代表当数组使用了其中的四分之三时,则需要扩展长度,而且,扩展长度时,需要重新执行一次hash构建。

讲完了HashMap最重要的几个特点,我们再观察上面的图片,正如图中反应的,HashMap的一大缺点就是消耗内存,数组中大多位置都未真正存储数据,特别是当数组需要增长时,增长一倍的长度会带来很大一片内存开销。而这个特点在Android平台上影响不容小觑,毕竟移动平台内存的占用一直是移动开发中需要谨慎把握的一个点。而且每次增长数组都需要重新构建hash。

正因为HashMap的以上特点,Android引入了另一个集合类:ArrayMap

ArrayMap

ArrayMap

ArrayMap维护了两个数组,一个是保存hash值的数组,另一个数组则是保存key对象和value对象的数组,每一对key-value相邻存储在数组中。查找数据的过程大致这样,第一步:通过给定的key计算出对应hash值,找出这个hash值在第一个数组中的位置index,然后在第二个数组中找到2*index的位置,找到当前位置的key和value,如果不是对应的key,则在前后位置继续查找。

ArrayMap相比HashMap的优化点在于两点:第一、直接存储了key对象和value对象,而不用Entry对象;第二、当数组需要增长时,无需重新构建hash。

而ArrayMap相比HashMap的缺点在于,ArrayMap查找过程是两次二分查找法,而二分查找法的时间复杂度是O(logN),这明显低于HashMap,大部分情况下,HashMap中hash冲突是比较少的,所以数据都存储在Entry链表的第一个结点,这种情况下查找复杂度只需一次hash,时间复杂度为O(1)。但是这个时间复杂度的缺点,在查找数据量小于1000时(这个数字是官方推荐的),影响微乎其微。

所以,综上,ArrayMap适用于数据量比较小的时候,替代HashMap。

另外补充一点,ArrayMap相比HashMap的缺点还有一点,ArrayMap没有实现Serializable,不利于在Android中借助Bundle传输。

文章最开始讲到ArraySet是ArrayMap的衍生类,我们介绍ArraySet之前,先介绍一下HashSet,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable {

static final long serialVersionUID = -5024744406713321676L;

private transient HashMap<E,Object> map;

// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();


//...

public boolean add(E e) {
return map.put(e, PRESENT)==null;
}

}

HashSet其实只是简单封装了一个HashMap,然后每个key值都插入同一个value。既然是基于HashMap,那么HashSet也有HashMap的缺点。于是Android平台推出了ArraySet,ArraySet和ArrayMap很相似,主要逻辑都是依靠两个数组,第一个数组保存hash值,第二个数组则不同,只需要保存key值(你可以这样理解,ArraySet和HashSet一样,所以key都对应同一个value)。而ArraySet除了具有ArrayMap节约内存的优点,同时也兼备了ArrayMap查找速度慢的缺点.

SparseArray

讲到这里,我们已经介绍完ArrayMap和ArraySet。接下来介绍其他XXXSparseXXX集合,Sparse的意思是稀疏的,稀少的。它们基本都是为了解决HashMap的auto-boxing的问题,

1
2
3
4
5
6

HashMap hashmap;

//...

hashmap.put(5, object);

比如以上代码中,5这个int类型会被自动装箱为Integer。

所以XXXSparseXXX这一系列集合就是为了在ArrayMap基础上进一步解决auto-boxing的问题。而这一系列集合的实现原理和ArrayMap也相似,都是借助两个数组,不过ArrayMap的第一个数组保存的是key的hash值,而XXXSparseXXX这一系列集合的第一个数组保存的是key值,第二个数组直接保存value值。

以下是SparseArray的代码片段

1
2
3
4
5
6
7
8
9
10
11
12

public class SparseArray<E> implements Cloneable {


//...

private int[] mKeys;
private Object[] mValues;

//...

}

以下是SparseBooleanArray的代码片段

1
2
3
4
5
6
7
8
9
10
public class SparseBooleanArray implements Cloneable {

//...

private int[] mKeys;
private boolean[] mValues;

//...

}

以下做个总结,几个XXXSparseXXX集合类各自的key和value的数据类型。

1
2
3
4
5
SparseArray          <int, Object>
SparseBooleanArray <int, boolean>
SparseIntArray <int, int>
SparseLongArray <int, long>
LongSparseArray <long, Object>

总结

ArrayMap和SparseArray(包括其衍生类)都是围绕时间换空间的方式来解决移动平台内存有限的问题。但是ArrayMap和SparseArray使用场景只能用于数据量比较低的情况,官方推荐不超过1000。而如果key或者value为基础类型,则可以进一步考虑SparseArray的以及其衍生的集合。

参考

https://stackoverflow.com/a/31413003/1290235
https://code.facebook.com/posts/857070764436276/memory-optimization-for-feeds-on-android/
http://www.jianshu.com/p/7b9a1b386265

六月一号了,儿童节快乐。

突然有一个疑问,Java语言为什么是解释性语言而非编译性语言,Java文件不都需要编译成class文件吗?

首先先介绍一下解释性语言和编译性语言。

计算机不能直接理解高级语言,而只能直接理解机器语言,所以我们需要把高级语言翻译成机器语言,计算机才能执行高级语言编写的程序。而将高级语言翻译成机器语言的翻译方式有两种,一种是编译,一种是解释。这两种方式的主要区别在于翻译的时间不同。

编译性语言在被执行之前,需要一个专门的编译过程,把程序编译成为机器语言,比如exe文件,以后要运行的话就不用重新翻译了,直接使用编译的结果就行了(比如exe文件),因为翻译只做了一次,运行时不需要翻译,所以编译型语言的程序执行效率高。

解释性语言在运行程序的时候才翻译,这样解释性语言每执行一次就需要逐行翻译一次,效率比较低。我们常说的脚本语言就是解释性语言。

所以编译性语言所需要的是编译器,而解释性语言需要的是解释器,它们的输入都是源代码,而编译器输出中间代码(或可执行代码),后者直接输出执行结果。

说到这里,我们再来想想,Java语言真的是解释性语言吗?那为什么还需要编译成class文件呢?

实际上很多现代解释器内部是以“编译器+虚拟机”的方式来实现的,先通过编译器将源码转换为字节码,然后由虚拟机去完成实际的执行。所谓解释型语言,并不是不用编译,而只是不需要用户显式去使用编译器得到可执行代码而已。

所以,说Java是解释性语言已经不大合适了,其实用编译性和解释性来区分现代的编程语言其实也已经不大合适了,

前两天用Android官方推荐的一个辅助打计时日志的工具:TimingLogger.这个工具确实挺方便的,可以达到以下效果

代码如下

1
2
3
4
5
6
7
8
TimingLogger timings = new TimingLogger(TAG, "methodA");
// ... do some work A ...
timings.addSplit("work A");
// ... do some work B ...
timings.addSplit("work B");
// ... do some work C ...
timings.addSplit("work C");
timings.dumpToLog();

以上代码会打印如下日志

1
2
3
4
5
D/TAG     ( 3459): methodA: begin
D/TAG ( 3459): methodA: 9 ms, work A
D/TAG ( 3459): methodA: 1 ms, work B
D/TAG ( 3459): methodA: 6 ms, work C
D/TAG ( 3459): methodA: end, 16 ms

但是发现在手头的huawei机器上打不出日志来,结果发现这个TimingLogger工具判断了当前Log所支持的级别是否达到VERBOSE的级别,如果不是,则不会打印最终的结果出来。如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mDisabled = !Log.isLoggable(mTag, Log.VERBOSE);
...
...
public void dumpToLog() {
if (mDisabled) return;
Log.d(mTag, mLabel + ": begin");
final long first = mSplits.get(0);
long now = first;
for (int i = 1; i < mSplits.size(); i++) {
now = mSplits.get(i);
final String splitLabel = mSplitLabels.get(i);
final long prev = mSplits.get(i - 1);
Log.d(mTag, mLabel + ": " + (now - prev) + " ms, " + splitLabel);
}
Log.d(mTag, mLabel + ": end, " + (now - first) + " ms");
}

而最终发现我的华为机器Log.isLoggable(mTag, Log.VERBOSE)返回了false,所以TimingLogger无法打印日志出来,接下来我们试试怎么修改这个日志级别.

Android中的Log每天都接触,我们先来梳理一下基础知识。

一般我们使用这几个方法打Log,Log.v() Log.d() Log.i() Log.w() Log.e()。之所以系统分出不同的打日志方法,是为了方便分析日志,另一个更直观的感受是IDE下会对不同级别的日志显示不同的颜色。

那么各个日志方法有什么区别呢?每个方法都对应一个level值,如下:

1
2
3
4
5
6
public static final int VERBOSE = 2;
public static final int DEBUG = 3;
public static final int INFO = 4;
public static final int WARN = 5;
public static final int ERROR = 6;
public static final int ASSERT = 7;

官方对这几个日志level排序的解释是这样

The order in terms of verbosity, from least to most is ERROR, WARN, INFO, DEBUG, VERBOSE.

意思是从啰嗦程度来排,最不啰嗦的是ERROR,最啰嗦的是VERBOSEVERBOSE这个单词的本来就是啰嗦的意思。

接下来在手头的hauwei mate9上运行以下代码,

1
2
3
4
5
6
7
8
9
10
11
12
Log.v(TAG, "VERBOSE");
Log.d(TAG, "DEBUG");
Log.i(TAG, "INFO");
Log.w(TAG, "WARN");
Log.e(TAG, "ERROR");


Log.i(TAG, "VERBOSE isLoggable " + Log.isLoggable(TAG, Log.VERBOSE));
Log.i(TAG, "DEBUG isLoggable " + Log.isLoggable(TAG, Log.DEBUG));
Log.i(TAG, "INFO isLoggable " + Log.isLoggable(TAG, Log.INFO));
Log.i(TAG, "WARN isLoggable " + Log.isLoggable(TAG, Log.WARN));
Log.i(TAG, "ERROR isLoggable " + Log.isLoggable(TAG, Log.ERROR));

最终输出日志如下

1
2
3
4
5
6
7
8
9
05-03 22:55:18.163 10825-10825/com.example.quinn.apilabs I/MainActivity: INFO
05-03 22:55:18.163 10825-10825/com.example.quinn.apilabs W/MainActivity: WARN
05-03 22:55:18.163 10825-10825/com.example.quinn.apilabs E/MainActivity: ERROR

05-03 22:55:18.163 10825-10825/com.example.quinn.apilabs I/MainActivity: VERBOSE isLoggable false
05-03 22:55:18.163 10825-10825/com.example.quinn.apilabs I/MainActivity: DEBUG isLoggable false
05-03 22:55:18.163 10825-10825/com.example.quinn.apilabs I/MainActivity: INFO isLoggable true
05-03 22:55:18.163 10825-10825/com.example.quinn.apilabs I/MainActivity: WARN isLoggable true
05-03 22:55:18.163 10825-10825/com.example.quinn.apilabs I/MainActivity: ERROR isLoggable true

我们分析一下以上结果,不难得知,VERBOSEDEBUG是打印不出来的,而Log.isLoggable(TAG, Log.VERBOSE)Log.isLoggable(TAG, Log.DEBUG)都是返回false。只要某个levelisLoggable返回false,则这个level以及更低的其他level都打印不了日志。

那么我们怎么修改日志的打印级别呢?可以使用以下命令

1
2
3
4
setprop log.tag.<YOUR_LOG_TAG> <LEVEL>

//比如我们的场景则是:
setprop log.tag.MainActivity VERBOSE

在huawei上,以上命令可以修改Log.isLoggable(TAG, Log.VERBOSE)的返回值,但是打印日志没有起作用,Log.v(TAG, "VERBOSE")依然打不出来。而且执行以上命令后,TimingLogger也依然打印不出来,因为它打印用的是DEBUG级别。

这是为什么呢?Log.isLoggable(TAG, Log.VERBOSE)返回true,理应可以答应VERBOSE级别的日志的。最终发现,针对huawei手机需要这样:拨号*#*#2846579#*#*,进入后台设置–LOG设置–勾选AP日志,这样所有日志都可以打印了,注意,这个设置路径在不同huawei手机可能会有区别。

以上介绍的两种方式,无论是setprop还是拨号设置的方式,开机重启后都不起效果了。

以上就是我在huawei手机上遇到的问题,没有好的解决方法,首先第一步,打开拨号设置,使得所有日志可以输出,第二步,使用setprop修改isLoggable的级别,这点很麻烦,只能挨个TAG修改,目前尝试正则匹配所有TAG貌似不成功。

至于TimingLogger的使用,其原理比较简单,我们可以基于它的基础上重写一个,然后去除Log.isLoggable(TAG, Log.VERBOSE)d的限制,并且修改日志输出级别。