这是同学们在之前的项目中遇到的问题。实际代码更复杂。下面我会尽可能简单的描述这个问题,内容会重点探讨为什么会出现这种情况以及后续的监控。1.问题的由来先看一个很简单的模型类Boy:publicclassBoy{publicStringboyName;publicGirlgirl;publicclassGirl{publicStringgirlName;}}项目一般会有很多模型类,比如界面上的每张卡片就是一个解析服务器返回数据,然后一个个解析出卡的型号对不对?对于解析服务器数据,大多数情况下,服务器返回一个json字符串,我们的客户端会使用Gson进行解析。那么我们再看上面例子中的Boy类,Gson解析出来的代码:\"girl\":{\"girlName\":\"lmj\"}}";Boyboy=gson.fromJson(boyJsonStr,Boy.class);System.out.println("boynameis="+boy.boyName+",girlnameis="+boy.girl.girlName);}}运行结果是什么?我们来看一下:boynameis=zhy,girlnameis=lmj很正常,符合我们的预期。突然有一天,一个同学给女生班加了一个方法getBoyName()。要得到女孩心中男孩的名字,很简单:publicclassBoy{publicStringboyName;publicGirlgirl;publicclassGirl{publicStringgirlName;publicStringgetBoyName(){returnboyName;}}}看来代码没什么问题。如果让我在这个基础上加上getBoyName(),代码可能会这样写。但是,这样的代码却埋下了一个很深的坑。什么样的坑?回到刚才我们的测试代码,我们现在尝试解析json字符串,调用girl.getBoyName()::\"zhy\",\"girl\":{\"girlName\":\"lmj\"}}";Boyboy=gson.fromJson(boyJsonStr,Boy.class);System.out.println("boynameis="+boy.boyName+",girlnameis="+boy.girl.girlName);//添加System.out.println(boy.girl.getBoyName());}}很简单,添加一行打印。这一次,你认为运算的结果是什么?还是没问题?当然不是,结果:boynameis=zhy,girlnameis=lmjExceptioninthread"main"java.lang.NullPointerExceptionatcom.example.zhanghongyang.blog01.model.Boy$Girl.getBoyName(Boy.java:12)atcom.example.zhanghongyang.blog01.Test01.main(Test01.java:15)Boy$Girl.getBoyName报npe,girl是否为空?显然不是,我们上面打印了girl.name,boy为null的可能性就更小了。奇怪了,getBoyName里面只有一行代码:publicStringgetBoyName(){returnboyName;//npe}谁为null?二、莫名其妙的空指针returnboyName;我只能猜测它是一个对象。boyName,this对象为空。这个对象是谁?让我们看看getBoyName()返回男孩对象的boyName字段。这个方法应该写的更详细些:publicStringgetBoyName(){returnBoy.this.boyName;}那么,现在问题就清楚了,确实Boy.this对象为null。**那么问题来了,为什么这个对象经过Gson序列化之后是null呢?**为了弄清楚这个问题,还有一个前置问题:为什么我们可以在Girl类Method中访问到外部类Boy的属性和外部类Boy的属性?三、非静态内部类的一些秘密要探究Java代码的秘密,最好的办法就是看字节码。我们再往下看Girl的字节码,看看getBodyName()这个“罪魁祸首”是怎么写的?javap-vGirl.class看getBodyName()的字节码:publicjava.lang.StringgetBoyName();descriptor:()Ljava/lang/String;flags:ACC_PUBLICCode:stack=1,locals=1,args_size=10:aload_01:getfield#1//Fieldthis$0:Lcom/example/zhanghongyang/blog01/model/Boy;4:getfield#3//Fieldcom/example/zhanghongyang/blog01/model/Boy.boyName:Ljava/lang/String;7:areturn可以看到aload_0,肯定是this对象,然后getfield获取this0字段,然后通过this0字段,再通过this0字段,再去getfield通过this0获取boyName字段,即:publicStringgetBoyName(){returnboyName;}等同于:publicStringgetBoyName(){return$this0.boyName;}那么这个$this0是从哪里来的呢?查看Girl字节码的成员变量:finalcom.example.zhanghongyang.blog01.model.Boythis$0;descriptor:Lcom/example/zhanghongyang/blog01/model/Boy;flags:ACC_FINAL,ACC_SYNTHETIC确实有this$0字段,你这时候就懵了,我的代码里不是有吗?我们稍后会解释。看看这个$0可以赋值到哪里?翻了一下字节码,发现Girl的构造方法是这样写的:example/zhanghongyang/blog01/model/Boy;)Vflags:ACC_PUBLICode:stack=2,locals=2,args_size=20:aload_01:aload_12:putfield#1//Fieldthis$0:Lcom/example/zhanghongyang/blog01/model/Boy;5:aload_06:invokespecial#2//Methodjava/lang/Object."":()V9:returnLineNumberTable:line8:0LocalVariableTable:StartLengthSlotNameSignature0100thisLcom/example/zhanghongyang/blog01/model/Boy$Girl;0101this$0Lcom/例子/zhanghongyang/blog01/model/Boy;可以看到这个构造方法包含了一个形参,就是Boy对象,最后会赋值给我们的$this0。还有接下来的贴,我们整体来看一下Girl的字节码:publicclasscom.example.zhanghongyang.blog01.model.Boy$Girl{publicjava.lang.StringgirlName;finalcom.example.zhanghongyang.blog01.model。Boythis$0;publiccom.example.zhanghongyang.blog01.model.Boy$Girl(com.example.zhanghongyang.blog01.model.Boy);publicjava.lang.StringgetBoyName();}它只有一个构造方法,也就是我们刚才说了Boy对象的构造函数需要传入,这里有个小知识,并不是所有没有构造函数的对象都会有一个默认的无参构造函数。也就是说:如果要构造一个普通的Girl对象,理论上必须传入一个Boy对象。所以通常你想要构建一个Girl对象,你必须像这样编写Java代码:publicstaticvoidtestGenerateGirl(){Boy.到这里,我们已经摸清了非静态内部类调用外部类的秘密。我们想一想Java为什么要这样设计?因为Java支持非静态内部类,在这个内部类中可以访问外部类的属性和变量。但是经过编译,内部类实际上会成为一个独立的类对象,如下图:要让另一个类访问另一个类的成员,那么访问的对象必须传入,我觉得肯定是可以传的输入,那么唯一的构造方法是最合适的。可以看到,为了支持一些特性,Java编译器在幕后默默地提供了支持。其实这种支持不仅限于此,在很多地方都可以看到,而且这些在编译时加入的一些变量和方法都会有一个Modifier来修饰:ACC_SYNTHETIC。不信你仔细看看$this0的语句。finalcom.example.zhanghongyang.blog01.model.Boythis$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中构建对象,一个是找到对象的类型,然后找到对应的TypeAdapter处理它。在此示例中,我们的Girl对象最终将转到ReflectiveTypeAdapterFactory.create并返回一个TypeAdapter。我只能再次移植它:#ReflectiveTypeAdapterFactory.create@OverridepublicTypeAdaptercreate(Gsongson,finalTypeTokentype){Classraw=type.getRawType();if(!Object.class.isAssignableFrom(raw)){returnnull;//是原语!}ObjectConstructorconstructor=constructorConstructor.get(type);returnnewAdapter(constructor,getBoundFields(gson,type,raw));}focus赋值一目了然构造函数对象与构造对象相关。#ConstructorConstructor.getpublicObjectConstructorget(TypeTokentypeToken){finalTypetype=typeToken.getType();finalClassrawType=typeToken.getRawType();//...省略部分缓存容器相关代码ObjectConstructordefaultConstructor=newDefaultConstructor(rawType);if(defaultConstructor!=null){returndefaultConstructor;}ObjectConstructordefaultImplementation=newDefaultImplementationConstructor(type,rawType);if(defaultImplementation!=null){returndefault//implementation};finallytryunsareferturnnewUnsafeAllocator(type,rawType);}可以看到这个方法的返回值有3个过程:constructor=rawType.getDeclaredConstructor();if(!constructor.isAccessible()){constructor.setAccessible(true);}returnnewObjectConstructor(){@SuppressWarnings("unchecked")//Tisthesamerawtypeasisrequested@OverridepublicTconstruct(){Object[]args=null;return(T)constructor.newInstance(args);//省略了一些异常处理};}catch(NoSuchMethodExceptione){returnnull;}}如您所见,它非常简单。尝试获取不带参数的构造函数。如果能找到,通过newInstance反射构建对象,按照我们Girl的代码。没有无参数构造,因此将命中NoSuchMethodException并返回null。.返回null将转到newDefaultImplementationConstructor。该方法包含一些集合相关对象的逻辑,直接跳过。那么,最终,我们只能走:newUnsafeAllocator方法。从命名可以看出,这是一个不安全的操作。newUnsafeAllocator最终是如何不安全地构建一个对象的呢?往下看,最后的执行是:publicstaticUnsafeAllocatorcreate(){//tryJVM//publicclassUnsafe{//publicObjectallocateInstance(Class>type);//}try{Class>unsafeClass=Class.forName("sun.misc.不安全");Fieldf=unsafeClass.getDeclaredField("theUnsafe");f.setAccessible(true);finalObjectunsafe=f.get(null);finalMethodallocateInstance=unsafeClass.getMethod("allocateInstance",Class.class);返回newUnsafeAllocator(){@Override@SuppressWarnings("unchecked")publicTnewInstance(Classc)throwsException{assertInstantiable(c);return(T)allocateInstance.invoke(unsafe,c);}};}catch(Exceptionignored){}//trydalvikvm,post-gingerbreaduseObjectStreamClass//trydalvikvm,pre-gingerbread,ObjectInputStream}嗯...上面我们猜错了,Gson其实内部并没有发现一个对象,经过考虑后以非常不安全的方式构造适当的构造函数。更多关于UnSafe的知识可以参考:每日一问|Java中可以这样创建对象吗?5.如何避免这个问题?其实最好的办法就是通过Gson反序列化model对象。可能不必费心编写非静态内部类。在Gsonuserguide中,其实是这样写的:github.com/google/gson...大概意思是如果你有一个case你想写一个非静态内部类,你有两个选择来确保它是正确的:内部类写成静态内部类;自定义InstanceCreator2的示例代码在这里,但我们不建议您使用它。嗯……所以,我简单翻译一下,就是:不要问,加静态就行,不要用这种口头要求。怎样才能让班里的同学自觉遵守呢?稍不注意就会出错,所以一般遇到这种约定俗成的写法,最好的办法就是加监控纠错。如果不这样写,编译的时候会报错。第六,让我们监视它?我在心里想了想,有4种可能的方式。嗯……你也可以选择自己想一想,然后继续往下看。最简单最暴力,编译的时候,扫描模型所在目录,直接读取java源文件,做正则匹配,找到非静态内部类,然后在编译时找一个任务,绑在前面它,你可以做到它在每次编译时运行。GradleTransform,先不说这个,扫描模型所在包下的类class,然后看类名是否包含AB的形式,只有一个构造方法需要构造A和成员变量包含B的形式,构造方法中只有一个一个需要构造A,成员变量包含B的形式,只有一个构造方法需要构造A,成员变量包含this0.AST或lint做语法树分析;运行时匹配也是一样,运行时获取模型对象包路径下的所有类对象,然后进行规则匹配。好吧,以上四种解决方案只是我的临时想法。理论上应该都是可行的,但在实践中未必可行。欢迎您尝试或提出新的解决方案。有新方案,欢迎留言补充知识。鉴于篇幅……不对,其实我一个都没写,也不想全部写出来,所以博客太长了。方案1,大家拍大腿就可以写出来了,不过我觉得1最真实,触发速度极快,不影响研发体验;方案2,大家查一下Transform的基本写法,用javassist,或者ASM,估计问题不大;方案3,AST的语法要自己查,自己写起来也很吃力;方案4是我最后想出来的,写吧。其实选项4,如果看到ARouter早期版本的初始化就明白了。其实就是遍历dex中的所有类,按照包+类名的规则进行匹配,然后推出API。让我们一起写下来。运行的时候需要遍历类,也就是获取dex,如何获取dex?可以通过apk获取,如何获取apk?其实可以通过cotext获取apk路径。publicclassPureInnerClassDetector{privatestaticfinalStringsPackageNeedDetect="com.example.zhanghongyang.blog01.model";publicstaticvoidstartDetect(Applicationcontext){try{finalSetclassNames=newHashSet<>();ApplicationInfoapplicationInfo=context.getPackageManager().getApplicationInfo(context.getPackageName(),0);FilesourceApk=newFile(applicationInfo.sourceDir);DexFiledexfile=newDexFile(sourceApk);EnumerationdexEntries=dexfile.entries();while(dexEntries.hasMoreElements()){StringclassName=dexEntries.nextElement();Log.d("zhy-blog","detect"+className);if(className.startsWith(sPackageNeedDetect)){if(isPureInnerClass(className)){classNames.add(className);}}}if(!classNames.isEmpty()){for(StringclassName:classNames){//crash?Log.e("zhy-blog","编写非静态内部类被发现:"+className);}}}catch(Exceptione){e.printStackTrace();}}privatestaticbooleanisPureInnerClass(StringclassName){if(!className.contains("$")){returnfalse;}try{Class>aClass=Class.forName(className);Field$this0=aClass.getDeclaredField("this$0");if(!$this0.isSynthetic()){returnfalse;}//其他匹配条件returntrue;}catch(Exceptione){e.printStackTrace();returnfalse;}}}启动app:以上只是demo代码,不严谨,需要几十行代码改进,首先通过cotext获取ApplicationInfo,然后获取apk的路径,然后构建DexFile对象,遍历其中的类,找到类,然后进行匹配。