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

2021年09月15日 阅读数:1
这篇文章主要向大家介绍Android 避坑指南:Gson 又搞了个坑!,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

这是我以前项目同窗遇到的一个问题,现实代码比较复杂,如今我将尽量简单的描述这个问题,而且内容重心会放在预防阶段。java

1、问题的起源

先看一个很是简单的model类Boy:android

public class Boy {

    public String boyName;
    public Girl girl;

    public class Girl {
        public String girlName;
    }
}

项目中通常都会有很是多的model类,好比界面上的每一个卡片,都是解析Server返回的数据,而后解析出一个个卡片model对吧。git

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

那咱们看下上例这个Boy类,经过Gson解析的代码:json

public class Test01 {

    public static void main(String[] args) {
        Gson gson = new Gson();
        String boyJsonStr = "{\"boyName\":\"zhy\",\"girl\":{\"girlName\":\"lmj\"}}";
        Boy boy = gson.fromJson(boyJsonStr, Boy.class);
        System.out.println("boy name is = " + boy.boyName + " , girl name is = " + boy.girl.girlName);
    }

}

运行结果是?缓存

咱们来看一眼:安全

boy name is = zhy , girl name is = lmj

很是正常哈,符合咱们的预期。app

突然有一天,有个同窗给girl类中新增了一个方法getBoyName(),想获取这个女孩心目男孩的名称,很简单:ide

public class Boy {

    public String boyName;
    public Girl girl;

    public class Girl {
        public String girlName;

        public String getBoyName() {
            return boyName;
        }
    }
}

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

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

什么样的坑呢?

再回到咱们的刚才测试代码,咱们如今尝试解析完成json字符串,调用一下girl.getBoyName():

public class Test01 {

    public static void main(String[] args) {
        Gson gson = new Gson();
        String boyJsonStr = "{\"boyName\":\"zhy\",\"girl\":{\"girlName\":\"lmj\"}}";
        Boy boy = gson.fromJson(boyJsonStr, Boy.class);
        System.out.println("boy name is = " + boy.boyName + " , girl name is = " + boy.girl.girlName);
        // 新增
        System.out.println(boy.girl.getBoyName());
    }

}

很简单,加了一行打印。

此次,你们以为运行结果是什么样呢?

仍是没问题?固然不是,结果:

boy name is = zhy , girl name is = lmj
Exception in thread "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,是girl为null?明显不是,咱们上面打印了girl.name,那更不多是boy为null了。

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

public String getBoyName() {
    return boyName; // npe
}

究竟是谁为null呢?

2、使人不解的空指针

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

这个某对象是谁呢?

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

public String getBoyName() {
    return Boy.this.boyName;
}

因此,如今问题清楚了,确实是Boy.this这个对象是null。

** 那么问题来了,为何通过Gson序列化以后需,这个对象为null呢?**

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

在Girl类里面为何咱们可以访问外部类Boy的属性以及方法?

3、非静态内部类的一些秘密

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

咱们下去一看Girl的字节码,看看getBodyName()这个“罪魁祸首”究竟是怎么写的?

javap -v Girl.class

看下getBodyName()的字节码:

public java.lang.String getBoyName();
    descriptor: ()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获取 t h i s 0 字 段 , 再 通 过 this0字段,再经过 this0this0再去getfield获取boyName字段,也就是说:

public String getBoyName() {
    return boyName;
}

至关于:

public String getBoyName(){
	return $this0.boyName;
}

那么这个$this0哪来的呢?

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

final com.example.zhanghongyang.blog01.model.Boy this$0;
    descriptor: Lcom/example/zhanghongyang/blog01/model/Boy;
    flags: ACC_FINAL, ACC_SYNTHETIC

其中果真有个this$0字段,这个时候你获取困惑,个人代码里面没有呀?

咱们稍后解释。

再看下这个this$0在哪儿可以进行赋值?

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

public com.example.zhanghongyang.blog01.model.Boy$Girl(com.example.zhanghongyang.blog01.model.Boy);
    descriptor: (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."<init>":()V
         9: return
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   Lcom/example/zhanghongyang/blog01/model/Boy$Girl;
            0      10     1 this$0   Lcom/example/zhanghongyang/blog01/model/Boy;

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

并且咱们还发下一件事,咱们再总体看下Girl的字节码:

public class com.example.zhanghongyang.blog01.model.Boy$Girl {
  public java.lang.String girlName;
  final com.example.zhanghongyang.blog01.model.Boy this$0;
  public com.example.zhanghongyang.blog01.model.Boy$Girl(com.example.zhanghongyang.blog01.model.Boy);
  public java.lang.String getBoyName();
}

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

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

也就是说:

若是你想构造一个正常的Girl对象,理论上是必需要传入一个Boy对象的。

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

public static void testGenerateGirl() {
    Boy.Girl girl = new Boy().new Girl();
}

先有body才能有girl。

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

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

01_01.png 让另外一个类中能够访问另外一个类里面的成员,那就必需要把被访问对象传进入了,想必定能传入,那么就是惟一的构造方法最合适了。

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

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

final com.example.zhanghongyang.blog01.model.Boy this$0;
descriptor: 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去处理,本例咱们的Girl对象,最终会走走到ReflectiveTypeAdapterFactory.create而后返回一个TypeAdapter。

我只能再搬运一次了:

# ReflectiveTypeAdapterFactory.create
@Override 
public <T> TypeAdapter<T> create(Gson gson, final TypeToken<T> type) {
	Class<? super T> raw = type.getRawType();
	
	if (!Object.class.isAssignableFrom(raw)) {
	  return null; // it's a primitive!
	}
	
	ObjectConstructor<T> constructor = constructorConstructor.get(type);
	return new Adapter<T>(constructor, getBoundFields(gson, type, raw));
}

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

# ConstructorConstructor.get
public <T> ObjectConstructor<T> get(TypeToken<T> typeToken) {
    final Type type = typeToken.getType();
    final Class<? super T> rawType = typeToken.getRawType();
	
	// ...省略一些缓存容器相关代码

    ObjectConstructor<T> defaultConstructor = newDefaultConstructor(rawType);
    if (defaultConstructor != null) {
      return defaultConstructor;
    }

    ObjectConstructor<T> defaultImplementation = newDefaultImplementationConstructor(type, rawType);
    if (defaultImplementation != null) {
      return defaultImplementation;
    }

    // finally try unsafe
    return newUnsafeAllocator(type, rawType);
  }

能够看到该方法的返回值有3个流程:

newDefaultConstructor
newDefaultImplementationConstructor
newUnsafeAllocator

咱们先看第一个newDefaultConstructor

private <T> ObjectConstructor<T> newDefaultConstructor(Class<? super T> rawType) {
    try {
      final Constructor<? super T> constructor = rawType.getDeclaredConstructor();
      if (!constructor.isAccessible()) {
        constructor.setAccessible(true);
      }
      return new ObjectConstructor<T>() {
        @SuppressWarnings("unchecked") // T is the same raw type as is requested
        @Override public T construct() {
            Object[] args = null;
            return (T) constructor.newInstance(args);
            
            // 省略了一些异常处理
      };
    } catch (NoSuchMethodException e) {
      return null;
    }
  }

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

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

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

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

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

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

往下看,最终执行的是:

public static UnsafeAllocator 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);
  final Object unsafe = f.get(null);
  final Method allocateInstance = unsafeClass.getMethod("allocateInstance", Class.class);
  return new UnsafeAllocator() {
    @Override
    @SuppressWarnings("unchecked")
    public <T> T newInstance(Class<T> c) throws Exception {
      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里面还能这么建立对象?

5、如何避免这个问题?

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

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

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

01_02.png

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

  1. 内部类写成静态内部类;
  2. 自定义InstanceCreator

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

嗯…因此,我简化的翻译一下,就是:

别问,问就是加static

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

6、那就来监控一下?

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

嗯…你也能够选择本身想下,而后再往下看。

  1. 最简单、最暴力,编译的时候,扫描model所在目录,直接读java源文件,作正则匹配去发现非静态内部类,而后而后随便找个编译时的task,绑在它前面,就能作到每次编译时都运行了。
  2. Gradle Transform,这个不要说了,扫描model所在包下的class类,而后看类名若是包含A B 的 形 式 , 且 构 造 方 法 中 只 有 一 个 需 要 A 的 构 造 且 成 员 变 量 包 含 B的形式,且构造方法中只有一个须要A的构造且成员变量包含 BAthis0拿下。
  3. AST 或者lint作语法树分析;
  4. 运行时去匹配,也是同样的,运行时去拿到model对象的包路径下全部的class对象,而后作规则匹配。

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

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

鉴于篇幅…

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

  • 方案1,你们拍大腿都能写出来,过,不过我感受1最实在了,并且触发速度极快,不怎么影响研发体验;
  • 方案2,你们查一下Transform基本写法,利用javassist,或者ASM,估计也问题不大,过;
  • 方案3,AST的语法我也要去查,我写起来也费劲,过;
  • 方案4,是我最后一个想出来的,写一下吧。

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

其实就是遍历dex中全部的类,根据包+类名规则去匹配,而后就是发射API了。

咱们一块儿写下。

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

能够经过apk获取,apk怎么拿呢?其实经过cotext就能拿到apk路径。

public class PureInnerClassDetector {
    private static final String sPackageNeedDetect = "com.example.zhanghongyang.blog01.model";

    public static void startDetect(Application context) {

        try {
            final Set<String> classNames = new HashSet<>();
            ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
            File sourceApk = new File(applicationInfo.sourceDir);
            DexFile dexfile = new DexFile(sourceApk);
            Enumeration<String> 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();
        }
    }

    private static boolean isPureInnerClass(String className) {
        if (!className.contains("$")) {
            return false;
        }
        try {
            Class<?> aClass = Class.forName(className);
            Field $this0 = aClass.getDeclaredField("this$0");
            if (!$this0.isSynthetic()) {
                return false;
            }
            // 其余匹配条件
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

}

启动app:

01_03.png

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

就几十行代码,首先经过cotext拿ApplicationInfo,那么apk的path,而后构建DexFile对象,遍历其中的类便可,找到类,就能够作匹配了。

over~~