android gson复杂对象,Android 避坑指南:Gson 又搞了个坑!

原标题:Android 避坑指南:Gson 又搞了个坑!

这是我之前项目同学遇到的一个问题,现实代码比较复杂,现在我将尽可能简单的描述这个问题,并且内容重心会放在预防阶段。

之前还写过一篇,看完可以回顾下下面这篇:

Android避坑指南,发现了一个极度不安全的操作

1

问题的起源

先看一个非常简单的model类Boy:

publicclassBoy{

publicString boyName;

publicGirl gril;

publicclassGirl{

publicString girlName;

}

}

项目中一般都会有非常多的model类,比如界面上的每个卡片,都是解析Server返回的数据,然后解析出一个个卡片model对吧。

对于解析Server数据,大多数情况下,Server返回的是json字符串,而我们客户端会使用Gson进行解析。

那我们看下上例这个Boy类,通过Gson解析的代码:

publicclassTest01 {

publicstatic void main( String[] args) {

Gson gson = newGson;

StringboyJsonStr = "{"boyName ":"zhy ","gril ":{"girlName ":"lmj "}}";

Boy boy = gson.fromJson(boyJsonStr, Boy. class);

System.out.println( "boy name is = "+ boy.boyName + " , girl name is = "+ boy.gril.girlName);

}

}

运行结果是?

我们来看一眼:

boy nameis= zhy , girl nameis= lmj

非常正常哈,符合我们的预期。

忽然有一天,有个同学给Gril类中新增了一个方法getBoyName,想获取这个女孩心目中的男孩的名称,很简单:

publicclassBoy{

publicString boyName;

publicGirl gril;

publicclassGirl{

publicString girlName;

publicString getBoyName{

returnboyName;

}

}

}

看起来,代码也没毛病,要是你让我在这个基础上新增getBoyName,可能代码也是这么写的。

但是,这样的代码埋下了深深的坑。

什么样的坑呢?

再回到我们的刚才测试代码,我们现在尝试解析完成json字符串,调用一下gril.getBoyName:

publicclassTest01{

publicstaticvoidmain( String[] args){

Gson gson = newGson;

String boyJsonStr = "{"boyName":"zhy","gril":{"girlName":"lmj"}}";

Boy boy = gson.fromJson(boyJsonStr, Boy.class);

System. out.println( "boy name is = "+ boy.boyName + " , girl name is = "+ boy.gril.girlName);

// 新增

System. out.println(boy.gril.getBoyName);

}

}

很简单,加了一行打印。

这次,大家觉得运行结果是什么样呢?

还是没问题?当然不是,结果:

boy name is= zhy , girl name is= lmj

Exception inthread "main"java.lang.NullPointerException

at com.example.zhanghongyang.blog01.model.Boy$Girl.getBoyName(Boy.java: 12)

at com.example.zhanghongyang.blog01.Test01.main(Test01.java: 15)

Boy$Girl.getBoyName报出了npe,是gril为null?明显不是,我们上面打印了girl.name,那更不可能是boy为null了。

那就奇怪了,getBoyName里面就一行代码:

publicString getBoyName{

returnboyName; // npe

}

到底是谁为null呢?

2

令人不解的空指针

return boyName;只能猜测是某对象.boyName,这个某对象是null了。

这个某对象是谁呢?

我们重新看下getBoyName返回的是boy对象的boyName字段,这个方法更细致一些写法应该是:

publicString getBoyName {

returnBoy. this.boyName;

}

所以,现在问题清楚了,确实是Boy.this这个对象是null。

那么问题来了,为什么经过Gson序列化之后需,这个对象为null呢?

想搞清楚这个问题,还有个前置问题:

在Gril类里面为什么我们能够访问外部类Boy的属性以及方法?

3

非静态内部类的一些秘密

探索Java代码的秘密,最好的手段就是看字节码了。

我们下去一看Gril的字节码,看看getBodyName这个“罪魁祸首”到底是怎么写的?

看下getBodyName的字节码:

publicjava.lang. StringgetBoyName;

deor: Ljava/lang/ String;

flags: ACC_PUBLIC

Code:

stack= 1, locals= 1, args_size= 1

0: aload_0

1: getfield #1// Field this$0:Lcom/example/zhanghongyang/blog01/model/Boy;

4: getfield #3// Field com/example/zhanghongyang/blog01/model/Boy.boyName:Ljava/lang/String;

7: areturn

可以看到aload_0,肯定是this对象了,然后是getfield获取$this0字段,再通过$this0再去getfield获取boyName字段,也就是说:

publicString getBoyName{

returnboyName;

}

相当于:

publicString getBoyName{

return$this0.boyName;

}

那么这个$this0哪来的呢?

我们再看下Girl的字节码的成员变量:

finalcom.example.zhanghongyang.blog01.model.Boy this$ 0;

deor: Lcom/example/zhanghongyang/blog01/model/Boy;

flags: ACC_FINAL, ACC_SYNTHETIC

我们稍后解释。

翻了下字节码,发现Girl的构造方法是这么写的:

publiccom.example.zhanghongyang.blog01.model.Boy$Girl(com.example.zhanghongyang.blog01.model.Boy);

deor: (Lcom/example/zhanghongyang/blog01/model/Boy;)V

flags: ACC_PUBLIC

Code:

stack= 2, locals= 2, args_size= 2

0: aload_0

1: aload_1

2: putfield # 1// Field this$0:Lcom/example/zhanghongyang/blog01/model/Boy;

5: aload_0

6: invokespecial # 2// Method java/lang/Object."":V

9: return

LineNumberTable:

line 8: 0

LocalVariableTable:

Start Length Slot Name Signature

0100thisLcom/example/zhanghongyang/blog01/model/Boy$Girl;

0101this$ 0Lcom/example/zhanghongyang/blog01/model/Boy;

可以看到这个构造方法包含一个形参,即Boy对象,最终这个会赋值给我们的$this0。

而且我们还发下一件事,我们再整体看下Girl的字节码:

publicclasscom. example. zhanghongyang. blog01. model. Boy$ Girl{

publicjava.lang.String girlName;

finalcom.example.zhanghongyang.blog01.model.Boy this$ 0;

publiccom.example.zhanghongyang.blog01.model.Boy$Girl(com.example.zhanghongyang.blog01.model.Boy);

publicjava.lang. String getBoyName;

}

其只有一个构造方法,就是我们刚才说的需要传入Boy对象的构造方法。

这块有个小知识,并不是所有没写构造方法的对象,都会有个默认的无参构造哟。

也就是说:

如果你想构造一个正常的Gril对象,理论上是必须要传入一个Boy对象的。

所以正常的你想构建一个Girl对象,Java代码你得这么写:

publicclasscom. example. zhanghongyang. blog01. model. Boy$ Girl{

publicstaticvoidtestGenerateGirl{

Boy.Girl gril = newBoy. newGirl;

}

先有body对象才能创建girl对象。

这里,我们搞清楚了非静态内部类调用外部类的秘密了,我们再来想想Java为什么要这么设计呢?

因为Java支持非静态内部类,并且该内部类中可以访问外部类的属性和变量,但是在编译后,其实内部类会变成独立的类对象,例如下图:

55cebe6059975509a835d552cb754c60.png

让另一个类中可以访问另一个类里面的成员,那就必须要把被访问对象传进入了,想一定能传入,那么就是唯一的构造方法最合适了。

可以看到Java编译器为了支持一些特性,背后默默的提供支持,其实这种支持不仅于此,非常多的地方都能看到,而且一些在编译期间新增的这些变量和方法,都会有个修饰符去修饰:ACC_SYNTHETIC。

不信,你再仔细看下$this0的声明。

finalcom.example.zhanghongyang.blog01.model.Boy this$ 0;

deor: Lcom/example/zhanghongyang/blog01/model/Boy;

flags: ACC_FINAL, ACC_SYNTHETIC

到这里,我们已经完全了解这个过程了,肯定是Gson在反序列化字符串为对象的时候没有传入body对象,然后造成$this0其实一直是null,当我们调用任何外部类的成员方法、成员变量是,嗷的一声给你扔个NullPointerException。

4

Gson怎么构造的非静态匿名内部类对象?

现在我就一个好奇点,因为我们已经看到Girl是没有无参构造的,只有一个包含Boy参数的构造方法,那么Girl对象Gson是如何创建出来的呢?

是找到带Body参数的构造方法,然后反射newInstance,只不过Body对象传入的是null?

好像也能讲的通,下面看代码看看是不是这样吧:

这块其实和我之前写的另一个Gson的坑的源码分析类似了:

Android避坑指南,Gson与Kotlin碰撞出一个不安全的操作

我就长话短说了:

Gson里面去构建对象,一把都是通过找到对象的类型,然后找对应的TypeAdapter去处理,本例我们的Gril对象,最终会走走到ReflectiveTypeAdapterFactory.create然后返回一个TypeAdapter。

我只能再搬运一次了:

# ReflectiveTypeAdapterFactory.create

@ Override

public TypeAdapter create(Gson gson, final TypeToken type) {

Class super T> raw = type.getRawType;

if (!Object.class.isAssignableFrom(raw)) {

return null; // it's a primitive!

}

ObjectConstructor constructor= constructorConstructor. get( type);

return new Adapter( constructor, getBoundFields(gson, type, raw));

}

重点看constructor这个对象的赋值,它一眼就知道跟构造对象相关。

# ConstructorConstructor. get

public ObjectConstructor get(TypeToken typeToken) {

finalType type = typeToken.getType;

finalClass superT> rawType = typeToken.getRawType;

// ...省略一些缓存容器相关代码

ObjectConstructor defaultConstructor = newDefaultConstructor(rawType);

if(defaultConstructor != null) {

returndefaultConstructor;

}

ObjectConstructor defaultImplementation = newDefaultImplementationConstructor(type, rawType);

if(defaultImplementation != null) {

returndefaultImplementation;

}

// finally try unsafe

returnnewUnsafeAllocator(type, rawType);

}

可以看到该方法的返回值有3个流程:

newDefaultConstructor

newDefaultImplementationConstructor

newUnsafeAllocator

我们先看第一个newDefaultConstructor

private ObjectConstructor newDefaultConstructor(Class superT> rawType) {

try{

finalConstructor superT> constructor= rawType.getDeclaredConstructor;

if(! constructor.isAccessible) {

constructor.setAccessible( true);

}

returnnew ObjectConstructor {

@SuppressWarnings( "unchecked")// T is the same raw type as is requested

@OverridepublicT construct {

Object[] args = null;

return(T) constructor.newInstance(args);

// 省略了一些异常处理

};

} catch(NoSuchMethodException e) {

returnnull;

}

}

可以看到,很简单,尝试获取了无参的构造函数,如果能够找到,则通过newInstance反射的方式构建对象。

追随到我们的Girl的代码,并没有无参构造,从而会命中NoSuchMethodException,返回null。

返回null会走newDefaultImplementationConstructor,这个方法里面都是一些集合类相关对象的逻辑,直接跳过。

那么,最后只能走:newUnsafeAllocator方法了。

从命名上面就能看出来,这是个不安全的操作。

newUnsafeAllocator最终是怎么不安全的构建出一个对象呢?

往下看,最终执行的是:

publicstaticUnsafeAllocator create{

// try JVM

// public class Unsafe {

// public Object allocateInstance(Class> type);

// }

try{

Class> unsafeClass = Class.forName( "sun.misc.Unsafe");

Field f = unsafeClass.getDeclaredField( "theUnsafe");

f.setAccessible( true);

finalObject unsafe = f.get( null);

finalMethod allocateInstance = unsafeClass.getMethod( "allocateInstance", Class.class);

returnnewUnsafeAllocator {

@Override

@SuppressWarnings( "unchecked")

public T newInstance(Class c)throwsException{

assertInstantiable(c);

return(T) allocateInstance.invoke(unsafe, c);

}

};

} catch(Exception ignored) {

}

// try dalvikvm, post-gingerbread use ObjectStreamClass

// try dalvikvm, pre-gingerbread , ObjectInputStream

}

嗯...我们上面猜测错了,Gson实际上内部在没有找到它认为合适的构造方法后,通过一种非常不安全的方式构建了一个对象。

关于更多UnSafe的知识,可以参考:

每日一问 | Java里面还能这么创建对象?

https://wanandroid.com/wenda/show/13785

5

如何避免这个问题?

其实最好的方式,会被Gson去做反序列化的这个model对象,尽可能不要去写非静态内部类。

在Gson的用户指南中,其实有写到:

https://github.com/google/gson/blob/master/UserGuide.md#TOC-Nested-Classes-including-Inner-Classes-

32cd850fe1e1a68455ac12f3e9279e75.png

大概意思是如果你有要写非静态内部类的case,你有两个选择保证其正确:

内部类写成静态内部类;

自定义 InstanceCreator

2的示例代码在这,但是我们不建议你使用。

嗯...所以,我简化的翻译一下,就是:

别问,问就是加static

不要使用这种口头的要求,怎么能让团队的同学都自觉遵守呢,谁不注意就会写错,所以一般遇到这类约定性的写法,最好的方式就是加监控纠错,不这么写,编译报错。

6

那就来监控一下?

我在脑子里面大概想了下,有4种方法可能可行。

嗯...你也可以选择自己想下,然后再往下看。

最简单、最暴力,编译的时候,扫描 model 所在目录,直接读 java 源文件,做正则匹配去发现非静态内部类,然后然后随便找个编译时的 task ,绑在它前面,就能做到每次编译时都运行了。

Gradle Transform ,这个不要说了,扫描 model 所在包下的 class 类,然后看类名如果包含 A$B 的形式,且构造方法中只有一个需要A的构造且成员变量包含 $this0 拿下。

AST 或者 lint 做语法树分析。

运行时去匹配,也是一样的,运行时去拿到 model 对象的包路径下所有的 class 对象,然后做规则匹配。

好了,以上四个方案是我临时想的,理论上应该都可行,实际上不一定可行,欢迎大家尝试,或者提出新方案。

有新的方案,求留言补充下知识面。

鉴于篇幅...

不,其实我一个都没写过,不太想都写一篇了,这样博客太长了。

方案1,大家拍大腿都能写出来,过,不过我感觉1最实在了,而且触发速度极快,不怎么影响研发体验。

方案2,大家查一下 Transform 基本写法,利用 javassist ,或者 ASM ,估计也问题不大,过。

方案3, AST 的语法我也要去查,我写起来也费劲,过。

方案4,是我最后一个想出来的,写一下吧。

其实方案4,如果你看到ARouter的早期版本的初始化,你就明白了。

其实就是遍历dex中所有的类,根据包+类名规则去匹配,然后就是反射API了。

我们一起写下。

运行时,我们要遍历类,就是拿到dex,怎么拿到dex呢?

可以通过apk获取,apk怎么拿呢?其实通过cotext就能拿到apk路径。

publicclassPureInnerClassDetector{

privatestaticfinalString sPackageNeedDetect = "com.example.zhanghongyang.blog01.model";

publicstaticvoidstartDetect(Application context){

try{

finalSet classNames = newHashSet<>;

ApplicationInfo applicationInfo = context.getPackageManager.getApplicationInfo(context.getPackageName, 0);

File sourceApk = newFile(applicationInfo.sourceDir);

DexFile dexfile = newDexFile(sourceApk);

Enumeration dexEntries = dexfile.entries;

while(dexEntries.hasMoreElements) {

String className = dexEntries.nextElement;

Log.d( "zhy-blog", "detect "+ className);

if(className.startsWith(sPackageNeedDetect)) {

if(isPureInnerClass(className)) {

classNames.add(className);

}

}

}

if(!classNames.isEmpty) {

for(String className : classNames) {

// crash ?

Log.e( "zhy-blog", "编写非静态内部类被发现:"+ className);

}

}

} catch(Exception e) {

e.printStackTrace;

}

}

privatestaticbooleanisPureInnerClass(String className){

if(!className.contains( "$")) {

returnfalse;

}

try{

Class> aClass = Class.forName(className);

Field $this0 = aClass.getDeclaredField( "this$0");

if(!$this0.isSynthetic) {

returnfalse;

}

// 其他匹配条件

returntrue;

} catch(Exception e) {

e.printStackTrace;

returnfalse;

}

}

}

启动app:

c61c843cf8ace916454cc0f8a1622168.png

以上仅为demo代码,并不严谨,需要自行完善。

就几十行代码,首先通过cotext拿ApplicationInfo,那么apk的path,然后构建DexFile对象,遍历其中的类即可,找到类,就可以做匹配了。

责任编辑:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值