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

這是我之前項目同學遇到的一個問題,現實代碼比較複雜,現在我將盡可能簡單的描述這個問題,並且內容重心會放在預防階段。

一、問題的起源

先看一個非常簡單的model類Boy:

public class Boy {

    public String boyName;
    public Girl girl;

    public class Girl {
        public String girlName;
    }
}

項目中一般都會有非常多的model類,比如界面上的每個卡片,都是解析Server返回的數據,然後解析出一個個卡片model對吧。

對於解析Server數據,大多數情況下,Server返回的是json字符串,而我們客戶端會使用Gson進行解析。

那我們看下上例這個Boy類,通過Gson解析的代碼:

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

非常正常哈,符合我們的預期。

忽然有一天,有個同學給girl類中新增了一個方法getBoyName(),想獲取這個女孩心目男孩的名稱,很簡單:

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呢?

二、令人不解的空指針

return boyName;只能猜測是某對象.boyName,這個某對象是null了。

這個某對象是誰呢?

我們重新看下getBoyName()返回的是boy對象的boyName字段,這個方法更細緻一些寫法應該是:

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

所以,現在問題清楚了,確實是Boy.this這個對象是null。

** 那麼問題來了,爲什麼經過Gson序列化之後需,這個對象爲null呢?**

想搞清楚這個問題,還有個前置問題:

在Girl類裏面爲什麼我們能夠訪問外部類Boy的屬性以及方法?

三、非靜態內部類的一些祕密

探索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。

四、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裏面還能這麼創建對象?

五、如何避免這個問題?

其實最好的方式,會被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

不要使用這種口頭的要求,怎麼能讓團隊的同學都自覺遵守呢,誰不注意就會寫錯,所以一般遇到這類約定性的寫法,最好的方式就是加監控糾錯,不這麼寫,編譯報錯。

六、那就來監控一下?

我在腦子裏面大概想了下,有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~~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章