Android 熱修復原理篇及幾大方案比較

      熱修復說白了就是”即時無感打補丁”,比如你們公司上線一個app,用戶反應有重大bug,需要緊急修復。2015年以來,Android開發領域裏對熱修復技術的討論和分享越來越多,同時也出現了一些不同的解決方案.如果按照通常做法,那就是程序猿加班搞定bug,然後測試,重新打包併發布。這樣帶來的問題就是成本高,效率低。於是,熱修復就應運而生.一般通過事先設定的接口從網上下載無Bug的代碼來替換有Bug的代碼。這樣就省事多了,用 戶體驗也好。目前熱修復儘管有很多坑,做了好多工作,可能吃力不討好,各種適配可能還是沒修復線上的有些Bug。不過呢,對於一個產品有熱修復畢竟是件好事。尤其是對於一個有衆多用戶的app(,一個bug不只是影響到幾個幾十個用戶,一些創業公司的APP,崩潰或者bug可能直接導致用戶卸載和永不使用,所以,就衝它有不用發版也可以解決我們線上的bug,我們的app也要適當考慮加入熱修復。


我們知道Android系統也是仿照java搞了一個虛擬機,不過它不叫JVM,它叫Dalvik/ART VM他們還是有很大區別的(這是不是我們的重點, 點開是個拓展閱讀)。我們只需要知道,Dalvik/ART VM 虛擬機加載類和資源也是要用到ClassLoader,不過Jvm通過ClassLoader加載的class字節碼,而Dalvik/ART VM通過ClassLoader加載則是dex。

Android的類加載器分爲兩種,PathClassLoader和DexClassLoader,兩者都繼承自BaseDexClassLoader

PathClassLoader代碼位於libcore\dalvik\src\main\Java\dalvik\system\PathClassLoader.java 
DexClassLoader代碼位於libcore\dalvik\src\main\java\dalvik\system\DexClassLoader.java 
BaseDexClassLoader代碼位於libcore\dalvik\src\main\java\dalvik\system\BaseDexClassLoader.java

  • PathClassLoader
  • 用來加載系統類和應用類

  • DexClassLoader

    用來加載jar、apk、dex文件.加載jar、apk也是最終抽取裏面的Dex文件進行加載.

    這裏寫圖片描述

2.熱修復機制

熱修復就是利用dexElements的順序來做文章,當一個補丁的patch.dex放到了dexElements的第一位,那麼當加載一個bug類時,發現在patch.dex中,則直接加載這個類,原來的bug類可能就被覆蓋了


看下PathClassLoader代碼

public class PathClassLoader extends BaseDexClassLoader {

    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

    public PathClassLoader(String dexPath, String libraryPath,
            ClassLoader parent) {
        super(dexPath, null, libraryPath, parent);
    }
} 

DexClassLoader代碼

public class DexClassLoader extends BaseDexClassLoader {

    public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
}

兩個ClassLoader就兩三行代碼,只是調用了父類的構造函數.

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }

在BaseDexClassLoader 構造函數中創建一個DexPathList類的實例,這個DexPathList的構造函數會創建一個dexElements 數組

public DexPathList(ClassLoader definingContext, String dexPath, String libraryPath, File optimizedDirectory) {
        ... 
        this.definingContext = definingContext;
        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        //創建一個數組
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions);
        ... 
    }

然後BaseDexClassLoader 重寫了findClass方法,調用了pathList.findClass,跳到DexPathList類中.

/* package */final class DexPathList {
    ...
    public Class findClass(String name, List<Throwable> suppressed) {
            //遍歷該數組
        for (Element element : dexElements) {
            //初始化DexFile
            DexFile dex = element.dexFile;

            if (dex != null) {
                //調用DexFile類的loadClassBinaryName方法返回Class實例
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }       
        return null;
    }
    ...
} 

會遍歷這個數組,然後初始化DexFile,如果DexFile不爲空那麼調用DexFile類的loadClassBinaryName方法返回Class實例. 
歸納上面的話就是:ClassLoader會遍歷這個數組,然後加載這個數組中的dex文件. 
而ClassLoader在加載到正確的類之後,就不會再去加載有Bug的那個類了,我們把這個正確的類放在Dex文件中,讓這個Dex文件排在dexElements數組前面即可.

CLASS_ISPREVERIFIED問題

根據QQ空間談到的在虛擬機啓動的時候,在verify選項被打開的時候,如果static方法、private方法、構造函數等,其中的直接引用(第一層關係)到的類都在同一個dex文件中,那麼該類就會被打上CLASS_ISPREVERIFIED標誌,且一旦類被打上CLASS_ISPREVERIFIED標誌其他dex就不能再去替換這個類。所以一定要想辦法去阻止類被打上CLASS_ISPREVERIFIED標誌。

爲了阻止類被打上CLASS_ISPREVERIFIED標誌,QQ空間開發團隊提出了一個方法是先將一個預備好的hack.dex加入到dexElements的第一項,讓後面的dex的所有類都引用hack.dex其中的一個類,這樣原來的class1.dex、class2.dex、class3.dex中的所有類都引用了hack.dex的類,所以其中的都不會打上CLASS_ISPREVERIFIED標誌。

比如Qzon團隊的 安卓App熱補丁動態修復技術介紹  (這個一定要看!!! 他是熱修復元老級文章,也是重點抄襲對象)

動態加載class文件,然後調用反射完成修復的原理:

Java程序在運行的時候,JVM通過類加載機制(ClassLoader)把class文件加載到內存中,只有class文件被載入內存,才能被其他class引用,使程序正確運行起來.

Java中的ClassLoader有三種.

1. Bootstrap ClassLoader 

由C++寫的,由JVM啓動.

啓動類加載器,負責加載java基礎類,對應的文件是%JRE_HOME/lib/ 目錄下的rt.jar、resources.jar、charsets.jar和class等


2.Extension ClassLoader

Java類,繼承自URLClassLoader

擴展類加載器,對應的文件是 %JRE_HOME/lib/ext 目錄下的jar和class等


3.App ClassLoader

Java類,繼承自URLClassLoader

系統類加載器,對應的文件是應用程序classpath目錄下的所有jar和class等

這裏要注意一點:只有被同一個類加載器實例加載並且文件名相同的class文件才被認爲是同一個class.

下面來一個小例子:

因爲系統的ClassLoader只會加載指定目錄下的class文件,如果你想加載自己的class文件,那麼就可以自定義一個ClassLoader.\

如何自定義ClassLoader

新建一個類繼承自java.lang.ClassLoader,重寫它的findClass方法。--將class字節碼數組轉換爲Class類的實例---調用loadClass方法即可

我先建一個叫Log的類,很簡單,只有一句打印

  1. public class Log {  
  2.  
  3.    public static void main(String[] args) {  
  4.         System.out.println("調用成功");  
  5.    }  
  6. }    

  7. 把這個java文件放到D盤根目錄,然後打開cmd,用javac命令把java文件轉化爲class文件

  1. 然後我新建一個MyClassLoader繼承自ClassLoader
  2. public class MyClassLoader extends ClassLoader {  
  3.    @Override  
  4.    protected Class<?> findClass(String name) throws ClassNotFoundException {  
  5.        Class log = null;  
  6.        // 獲取該class文件字節碼數組  
  7.        byte[] classData = getData();  
  8.  
  9.        if (classData != null) {  
  10.            // 將class的字節碼數組轉換成Class類的實例  
  11.            log = defineClass(name, classData, 0, classData.length);  
  12.        }  
  13.        return log;  
  14.    }  
  15.  
  16.    private byte[] getData() {  
  17.        //指定路徑  
  18.        String path = "D:/Log.class";  
  19.          
  20.        File file = new File(path);  
  21.        FileInputStream in = null;  
  22.        ByteArrayOutputStream out = null;  
  23.        try {  
  24.            in = new FileInputStream(file);  
  25.            out = new ByteArrayOutputStream();  
  26.  
  27.            byte[] buffer = new byte[1024];  
  28.            int size = 0;  
  29.            while ((size = in.read(buffer)) != -1) {  
  30.                out.write(buffer, 0, size);  
  31.            }  
  32.  
  33.        } catch (IOException e) {  
  34.            e.printStackTrace();  
  35.        } finally {  
  36.            try {  
  37.                in.close();  
  38.            } catch (IOException e) {  
  39.  
  40.                e.printStackTrace();  
  41.            }  
  42.        }  
  43.        return out.toByteArray();  
  44.    }  
  45. }  
  46. //最後測試一下,輸出加載這個Log的class文件的加載器,並且利用反射調用它的mian方法.
  47. public class Test {  
  48.  
  49.    public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, NoSuchMethodException, SecurityException, IllegalArgumentException, InvocationTargetException {  
  50.        MyClassLoader myClassLoader = new MyClassLoader();  
  51.        //查找Log這個class文件  
  52.        myClassLoader.findClass("Log");  
  53.        //加載Log這個class文件  
  54.        Class<?> Log = myClassLoader.loadClass("Log");    
  55.          
  56.        System.out.println("類加載器是:"+Log.getClassLoader());    
  57.          
  58.        //利用反射獲取main方法  
  59.        Method method=Log.getDeclaredMethod("main", String[].class) ;    
  60.        Object object=Log.newInstance();  
  61.        String [] arg={"ad"};  
  62.        method.invoke(object, (Object)arg);  
  63.    }  
  64. }  


業界內比較著名的有阿里巴巴的AndFix,HotFix(內測)Dexposed,Qzone的超級補丁和tencent的Tinker(將開源)以及大衆點評的Nuwa,騰訊Bugly,RocooFix

Dex的熱修復總結

Dex的熱修復目前來看基本上有四種方案:

此外,微信的方案是多classloader,這種方式可以解決用multidex方式在部分機型上不生效patch的問題,同時還帶來一個好處,這種多classloader的方式使用的是instant run的代碼,如果存在native library的修復,也會帶來極大的方便。

Native Library熱修復總結

而native libraray的修復,目前來說,基本上有兩種方案。。

  • 類似multidex的dex方式,插入目錄到數組最前面,具體文章見Android熱更新之so庫的熱更新,需要處理系統的兼容性問題,系統分隔線是Android 6.0
  • 第二種方式需要依賴多classloader,在構造BaseDexClassLoader的時候,獲取原classloader的native library,通過環境變量分隔符(冒號),將patch的native library與原目錄進行連接,patch目錄在前,這樣同樣可以達到修復的目的,缺點是需要依賴dex的熱修復,優點是應用native library時不需要處理兼容性問題,當然從patch中釋放出來的時候也需要處理兼容性問題。

上述方案從原理上可以簡單劃分爲3類:

原理 方案
Native hook方案 AndFix
QQ空間提出的Classloader替換類的方案 Nuwa, HotFix, RocooFix
Instant Run的冷插拔原理的Dex替換 Tinker
優缺點分析


測試模塊 AndFix Classloader方案 Tinker
類替換 no yes yes
資源替換 no no yes
是否需要重啓 no yes yes
兼容穩定性 不穩定 最好 穩定



 

下面,我們就分別介紹QQ空間超級熱補丁技術和微信Tinker以及阿里百川的HotFix技術。


一、Qzone超級補丁技術

超級補丁技術基於DEX分包方案,使用了多DEX加載的原理,大致的過程就是:把BUG方法修復以後,放到一個單獨的DEX裏,插入到dexElements數組的最前面,讓虛擬機去加載修復完後的方法。

 

當patch.dex中包含Test.class時就會優先加載,在後續的DEX中遇到Test.class的話就會直接返回而不去加載,這樣就達到了修復的目的。

 

但是有一個問題是,當兩個調用關係的類不在同一個DEX時,就會產生異常報錯。我們知道,在APK安裝時,虛擬機需要將classes.dex優化成odex文件,然後纔會執行。在這個過程中,會進行類的verify操作,如果調用關係的類都在同一個DEX中的話就會被打上`CLASS_ISPREVERIFIED`的標誌,然後纔會寫入odex文件。

 

所以,爲了可以正常地進行打補丁修復,必須避免類被打上`CLASS_ISPREVERIFIED`標誌,具體的做法就是單獨放一個類在另外DEX中,讓其他類調用。

 

我們來逆向手機QQ空間APK看一下具體的實現:

 

先進入程序入口`QZoneRealApplication`,在`attachBaseContext`中進行了兩步操作:修復`CLASS_ISPREVERIFIED`標誌導致的unexpected DEX problem異常、加載修復的DEX。

 

 

 1. 修復Unexpected DEX Problem異常

先看代碼,

 

可以看到,這裏是要加載一個libs目錄下的dalvikhack.jar。在項目的assets/libs找到該文件,解壓得到’classes.dex’文件,逆向打開該DEX文件,

 

 

通過不同的DEX加載進來,然後在每一個類的構造方法中引用其他DEX中的唯一類AnitLazyLoad,避免類被打上CLASS_ISPREVERIFIED標誌。

 

在無修復的情況下,將DO_VERIFY_CLASSES設置爲false,以提高性能。只有在需要修復的時候,才設置爲true。

 

 

至於如何加載進來,與下面第二個步驟基本相同。

 

2. 加載修復的DEX

從loadPatchDex()方法進入,經過幾次跳轉,到達核心的代碼段,`SystemClassLoaderInjector.c()`。由於進行了混淆和多次方法的跳轉,於是將核心代碼段做了如下整理:

 

修復的步驟爲:

1. 可以看出是通過獲取到當前應用的Classloader,即爲BaseDexClassloader

2. 通過反射獲取到他的DexPathList屬性對象pathList

3. 通過反射調用pathList的dexElements方法把patch.dex轉化爲Element[]

4. 兩個Element[]進行合併,把patch.dex放到最前面去

5. 加載Element[],達到修復目的

 

整體的流程圖如下:

 

從流程圖來看,可以很明顯的找到這種方式的特點:

優勢:

  1. 沒有合成整包(和微信Tinker比起來),產物比較小,比較靈活
  2. 可以實現類替換,兼容性高。(某些三星手機不起作用)

不足:

1. 不支持即時生效,必須通過重啓才能生效。

2. 爲了實現修復這個過程,必須在應用中加入兩個dex!dalvikhack.dex中只有一個類,對性能影響不大,但是對於patch.dex來說,修復的類到了一定數量,就需要花不少的時間加載。對手淘這種航母級應用來說,啓動耗時增加2s以上是不能夠接受的事。

3. 在ART模式下,如果類修改了結構,就會出現內存錯亂的問題。爲了解決這個問題,就必須把所有相關的調用類、父類子類等等全部加載到patch.dex中,導致補丁包異常的大,進一步增加應用啓動加載的時候,耗時更加嚴重。


 

二、微信Tinker

微信針對QQ空間超級補丁技術的不足提出了一個提供DEX差量包,整體替換DEX的方案。主要的原理是與QQ空間超級補丁技術基本相同,區別在於不再將patch.dex增加到elements數組中,而是差量的方式給出patch.dex,然後將patch.dex與應用的classes.dex合併,然後整體替換掉舊的DEX文件,以達到修復的目的。

 

我們來逆向微信的APK看一下具體的實現:

先找到應用入口`TinkerApplication`,在`onBaseContextAttached()`調用了`loadTinker()`,

 

進入TinkerLoader的tryLoad()方法中,

 

從方法名可以預見,在tryLoadPatchFilesInternal()中嘗試加載本地的補丁,再經過跳轉進入核心修復功能類SystemClassLoaderAdder.class中。

 

代碼中可以看出,根據Android版本的不同,分別採取具體的修復操作,不過原理都是一樣的。我們以V19爲例,

 

從代碼中可以看到,通過反射操作得到PathClassLoader的DexPatchList,反射調用patchlist的makeDexElements()方法吧本地的dex文件直接替換到Element[]數組中去,達到修復的目的。

 

對於如何進行patch.dex與classes.dex的合併操作,這裏微信開啓了一個新的進程,開啓新進程的服務TinkerPatchService進行合併。

 

整體的流程如下:

 

從流程圖來看,同樣可以很明顯的找到這種方式的特點:

優勢:

  1. 合成整包,不用在構造函數插入代碼,防止verify,verify和opt在編譯期間就已經完成,不會在運行期間進行。
  2. 性能提高。兼容性和穩定性比較高。
  3. 開發者透明,不需要對包進行額外處理。

不足:

1. 與超級補丁技術一樣,不支持即時生效,必須通過重啓應用的方式才能生效。

2. 需要給應用開啓新的進程才能進行合併,並且很容易因爲內存消耗等原因合併失敗。

3. 合併時佔用額外磁盤空間,對於多DEX的應用來說,如果修改了多個DEX文件,就需要下發多個patch.dex與對應的classes.dex進行合併操作時這種情況會更嚴重,因此合併過程的失敗率也會更高。

 


三、阿里百川HotFix

阿里百川推出的熱修復HotFix服務,相對於QQ空間超級補丁技術和微信Tinker來說,定位於緊急BUG修復的場景下,能夠最及時的修復BUG,下拉補丁立即生效無需等待。

 

1、AndFix實現原理

AndFix不同於QQ空間超級補丁技術和微信Tinker通過增加或替換整個DEX的方案,提供了一種運行時在Native修改Filed指針的方式,實現方法的替換,達到即時生效無需重啓,對應用無性能消耗的目的。

原理圖如下:

 

2、AndFix實現過程

 

對於實現方法的替換,需要在Native層操作,經過三個步驟:

 

接下來以Dalvik設備爲例,來分析具體的實現過程:

 

1、setup()

 

 

對於Dalvik來說,遵循JIT即時編譯機制,需要在運行時裝載libdvm.so動態庫,獲取以下內部函數:

1) dvmThreadSelf( ):查詢當前的線程;

2)dvmDecodeIndirectRef( ):根據當前線程獲得ClassObject對象。

 

2、setFieldFlag

 

該操作的目的:把 private、protected的方法和字段都改爲public,這樣纔可被動態庫看見並識別,因爲動態庫會忽略非public屬性的字段和方法。

 

3、replaceMethod

 

 

該步驟是方法替換的核心,替換的流程如下:

 

 

AndFix對ART設備同樣支持,具體的過程與Dalvik相似,這裏不再贅述。

 

從技術原理,不難看出阿里百川HotFix的幾個特點:

 

優勢:

  1. BUG修復的即時性
  2. 補丁包同樣採用差量技術,生成的PATCH體積小
  3. 對應用無侵入,幾乎無性能損耗

不足:

  1. 不支持新增字段,以及修改<init>方法,也不支持對資源的替換。
  2. 由於廠商的自定義ROM,對少數機型暫不支持。


綜合分析如下:

 


  




熱修復技術的坑與解

——————————————————————————————————————————————————————————————————————

我們可以看到,QQ空間超級補丁技術和微信Tinker的修復原理都基於類加載,在功能上已經支持類、資源的替換和新增,功能非常強大。既然已經有了這麼強大的熱修復技術,爲什麼阿里百川還要推出自己的熱修復方案HotFix呢?

 

一、多DEX帶來的性能影響

我們知道,多DEX方案原來是用於解決應用方法數65k的問題,現在google也官方支持了MultiDex的實現方案。超級補丁技術和Tinker卻作爲一種熱修復的方案,平生給應用增加了多個DEX,而多DEX技術最大的問題在於性能上的坑,因此基於這種方案的補丁技術影響應用的性能是無疑的。

1. 啓動加載時間過長

我們可以看到,超級補丁技術和Tinker都選擇在Application的attachBaseContext()進行補丁dex的加載,即時這是加載dex的最佳時機,但是依然會帶來很大的性能問題,首當其衝的就是啓動時間太長。

對於補丁DEX來說,應用啓動時虛擬機會進行dexopt操作,將patch.dex文件轉換成odex文件,這個過程本身非常耗時。而這個過程又要求在主線程中,以同步的方式執行,否則無法成功進行修復。就DEX的加載時間,大概做了以下的時間測試。

 

通過上表可以看到,隨着patch.dex的尺寸增加,在不做任何優化的情況下,啓動時間也直線增長。對於一個應用來說,這簡直是災難性的。

 

2. 易造成應用的ANR和Crash

由於多DEX加載導致了啓動時間變長,這樣更容易引發應用的ANR。我們知道當應用在主線程等待超過5s以後,就會直接導致長時間無響應而退出。超級補丁技術爲保證ART不出現地址錯亂問題,需要將所有關聯的類全部加入到補丁中,而微信Tinker採取一種差量包合併加載的方式,都會使要加載的DEX體積變得很大。這也很大程度上容易導致ANR情況的出現。

 

除了應用ANR以外,多DEX模式也同樣很容易導致Crash情況的出現。在ART設備中爲了保證不出現地址錯亂,需要把修改類的所有相關類全部加入到補丁中,這裏會出現一個問題,爲了保證補丁包的體積最小,能否保證引入全部的關聯類而不引入無關的類呢?一旦沒有引入關聯的類,就會出現以下的異常:

  • NoClassDefFoundError
  • Could Not Find Class
  • Could Not Find Method

出現這些異常,就會直接導致應用的Crash退出。

 

所以,不難看出如果我們需要修復一個不是Crash的BUG,但是因爲未加入相關類而導致了更嚴重的Crash,就更加的得不償失。

 

總的來說,熱修復本質的目的是爲了保證應用更加穩定,而不是爲了更強大的功能引入更大的風險和不穩定性。

 

二、 熱修復 or 插件化?

 

我們經常提到熱修復和插件化,這都是當下熱門的新興技術。在講述之前,需要對這兩個概念進行一下解釋。

 

  • 熱修復:當線上應用出現緊急BUG,爲了避免重新發版,並且保證修復的及時性而進行的一項在線推送補丁的修復方案。
  • 插件化:一個程序劃分爲不同的部分,以插件的形式加載到應用中去,本質上它使用的技術還是熱修復技術,只是加入了更多工程實踐,讓它支持大規模的代碼更新以及資源和SO包的更新。

 

顯然,從概念上我們可以看到,插件化使用場景更多是功能上的,熱修復強調微小的修復。從這個層面來說,插件化必然功能更加強大,能做的事情也更多。QQ空間超級補丁技術和微信Tinker從類、資源的替換和更新上來看,與其說是熱修復,不如說是插件化技術的實踐。

QQ空間超級補丁技術和微信Tinker提供了更加強大的功能,但是對應用的性能和穩定有較大的影響,就BUG修復的這個使用場景上還不夠明確,並且顯得過重。

 

針對應用的性能損耗,我們可以舉例做一個對比:

某APP的啓動載入時間爲3s左右,本身就是基於多DEX模式的實現。

分別接入三種熱修復服務,根據騰訊提供超級補丁技術和Tinker的數據,那麼會變成以下的場景:

1. 阿里百川HotFix:啓動時間幾乎無增加,不增加運行期額外的磁盤消耗。

2. QQ空間超級補丁技術:如果應用有700個類,啓動耗時增加超過2.5s,達到5.5s以上。

3. 微信Tinker:假設應用有5個DEX文件,分別修改了這5個DEX,產生5個patch.dex文件,就要進行5次的patch合併動作,假設每個補丁1M,那麼就要多佔用7.5M的磁盤空間。

顯然對於修復緊急BUG這個場景,阿里百川HotFix的更爲合適,它更加輕量,可以在不重啓的情況下生效,且對性能幾乎沒有影響。


找了很多資料加上看各種文章源碼寫完這個文章,很多地方的瞭解不是那麼深入,很多東西也是拾人牙慧,希望大家批評指正。

參考自:


發佈了77 篇原創文章 · 獲贊 242 · 訪問量 73萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章