淺談Android熱更新的來龍去脈 | Android

這是我參與更文挑戰的第1天,活動詳情查看: 更文挑戰java

首先,咱們須要持有如下幾個問題:android

  1. 什麼是熱修復?它能夠幫我解決什麼問題?
  2. 熱修復的產生背景?
  3. 熱修復的基本原理是什麼?
  4. 如何選擇熱修復框架?
  5. 熱修復的注意事項
  6. 熱修復與多渠道?
  7. 自動化構建與熱修復?

上面一共有7個問題,若是是新同窗的話,後面兩條可能不會很瞭解,建議自行補課學習。因而最基本的5個問題,咱們必須明白,這是咱們每一個開發者學習一個新知識的基本須要作到的。git

什麼是熱修復?它能夠幫我解決什麼問題?

其實簡單來講,熱修復就是一種動態加載技術,好比你線上某個產品此時出現了bug:shell

傳統流程:debug->測試->發佈新版 ->用戶安裝(各平臺審覈時間不一,並且用戶須要手動下載或者更新)數組

集成熱修復狀況下:dubug->測試->推送補丁->自動下載補丁修復 (用戶不知狀況,自動下載補丁並修復)緩存

對比下來,咱們不難發現,傳統流程存在這幾大弊端:服務器

  1. 發版代價大
  2. 用戶下載安裝的成本太高
  3. bug修復不及時,取決於各平臺的審覈時間等等

熱修復產生背景?

  • app發版成本高
  • 用H5集成某些常常變更的業務邏輯,但這種方案須要學習成本,並且對於沒法轉爲H5形式的代碼仍舊是沒法修復;
  • Instant Run

上面三個緣由中,咱們主要來談一下 Instant Run:markdown

Android Studio2.0時,新增了一個 Instant Run的功能,而各大廠的熱修復方案,在代碼,資源等方面的實現都是很大程度上參考了Instant Run的代碼。因此能夠說 Instant Run 是推動Android 熱修復的主因。cookie

那 Instant Run內部是如何作到這一點呢?app

  1. 構建一個新的 AssetManager(資源管理框架),並經過反射調用這個 addAssetPath,把這個完整的新資源加入到 AssetManager中,這樣就獲得了一個含有全部新資源的 AssetManager.
  2. 找到全部以前引用到原有AssetManager的地方,經過反射,把引用出替換爲新的AssetManager.

參考自 <深刻探索Android熱修復技術原理>

關於 InstantRun 的更多解釋請參考:

熱修復的原理是什麼?

咱們都知道熱修復都至關於動態加載,那麼動態加載到底動態在哪裏了呢。

說到這個就躲不過一個關鍵點 ClassLoader(類加載器) ,因此咱們先從Java開始。

image-20191124213728766

咱們都知道Java的類加載器有四種,分別爲:

  • Bootstarp ClassLoader
  • Extension ClassLoader
  • App ClassLoader 加載應用ClassLoader
  • Custom ClassLoader 加載本身的class文件

類加載過程以下:

過程: 加載-鏈接(驗證-準備-解析)-初始化

  1. 加載

    將類的信息(字節碼)從文件中獲取並載入到JVM的內存中

  2. 鏈接

    驗證:檢查讀入的結構是否符合JVM規範

    準備:分配一個結構來存儲類的信息

    解析:將類的常量池中的全部引用改變成直接引用

  3. 初始化

    執行靜態初始化程序,把靜態變量初始化成指定的值

其中用到的三個主要機制:

  1. 雙親委託機制
  2. 全盤負責機制
  3. 緩存機制

其實後面的兩個機制都是主要從雙親委託機制延續而來。詳細的Java類加載請參考個人另外一篇博客

在說明了Java 的ClassLoader以後,咱們接下來開始Android的ClassLoader,不一樣於Java的是,Java中的ClassLoader能夠加載 jar 文件和 Class文件,而Android中加載的是Dex文件,這就須要從新設計相關的ClassLoader類。因此Android 的ClassLoader 咱們會說的詳細一點

image-20191125191625071

源碼解析

在這裏,順便提一下,這裏貼的代碼版本是Android 9.0,在8.0之後,PathClassLoader和DexClassLoader並無什麼區別,由於惟一的一個區別參數 optimizedDirectory已經被廢棄。

首先是 loadClass,也就是咱們類加載的核心方法方法:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
        // First, check if the class has already been loaded
        //查找當前類是否被加載過
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                		//查看父加載器是否加載過
                    c = parent.loadClass(name, false);
                } else {
                		//若是沒有加載過,調用根加載器加載,雙親委託模式的實現
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }
						
						//找到根加載器依然爲null,只能本身加載了
            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                c = findClass(name);
            }
        }
        return c;
}
複製代碼

這裏有個問題,JVM雙親委託機制能夠被打破嗎?先保留疑問。

咱們主要去看他的 findClass方法

protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}
複製代碼

這個方法是一個null實現,也就是須要咱們開發者本身去作。

從上面基礎咱們知道,在Android中,是有 PathClassLoader和 DexClassLoader,而它們又都繼承與 BaseDexClassLoader,而這個BaseDexClassLoader又繼承與 ClassLoader,並將findClass方法交給子類本身實現,因此咱們從它的兩個子類 PathClassLoader和 DexClassLoader入手,看看它們是怎麼處理的。

這裏礙於Android Studio沒法查看相關具體實現源碼,因此咱們從源碼網站上查詢:

PathClassLoader

public class PathClassLoader extends BaseDexClassLoader {
    
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }
  
   // dexPath: 須要加載的文件列表,文件能夠是包含了 classes.dex 的 JAR/APK/ZIP,也能夠直接使用 classes.dex 文件,多個文件用 「:」 分割
 // librarySearchPath: 存放須要加載的 native 庫的目錄
 // parent: 父 ClassLoader
    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

複製代碼

由註釋看能夠發現PathClassLoader被用來加載本地文件系統上的文件或目錄,由於它調用的 BaseDexClassLoader的第二個參數爲null,即未傳入優化後的Dex文件。

注意:Android 8.0以後,BaseClassLoader第二個參數爲(optimizedDirectory)爲null,因此DexClassLoader與PathClassLoader並沒有區別

DexClassLoader

image-20191121000739855

public class DexClassLoader extends BaseDexClassLoader {
   // dexPath: 須要加載的文件列表,文件能夠是包含了 classes.dex 的 JAR/APK/ZIP,也能夠直接使用 classes.dex 文件,多個文件用 「:」 分割
 // optimizedDirectory: 存放優化後的 dex,能夠爲空
 // librarySearchPath: 存放須要加載的 native 庫的目錄
 // parent: 父 ClassLoader 
  public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}
複製代碼

DexClassLoader用來加載jar、apk,其實還包括zip文件或者直接加載dex文件,它能夠被用來執行未安裝的代碼或者未被應用加載過的代碼,也就是咱們修復過的代碼。

注意:Android 8.0以後,BaseClassLoader第二個參數爲(optimizedDirectory)爲null,因此DexClassLoader與PathClassLoader並沒有區別

從上面咱們能夠看到,它們都繼承於BaseDexClassLoader,而且它們真正的實現行爲都是調用的父類方法,因此咱們來看一下BaseDexClassLoader.

BaseDexClassLoader

public class BaseDexClassLoader extends ClassLoader {

  private static volatile Reporter reporter = null;
	
  //核心關注點
   private final DexPathList pathList;

	BaseDexClassLoader 構造函數有四個參數,含義以下:

 // dexPath: 須要加載的文件列表,文件能夠是包含了 classes.dex 的 JAR/APK/ZIP,也能夠直接使用 classes.dex 文件,多個文件用 「:」 分割
 // optimizedDirectory: 存放優化後的 dex,能夠爲空
 // librarySearchPath: 存放須要加載的 native 庫的目錄
 // parent: 父 ClassLoader
   public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent) {
      	//classloader,dex路徑,目錄列表,內部文件夾
       this(dexPath, optimizedDirectory, librarySearchPath, parent, false);
   }


   public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent, boolean isTrusted) {
       super(parent);
       this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);

       if (reporter != null) {
           reportClassLoaderChain();
       }
   }
  
  ...
 
    public BaseDexClassLoader(ByteBuffer[] dexFiles, ClassLoader parent) {
        // TODO We should support giving this a library search path maybe.
        super(parent);
        this.pathList = new DexPathList(this, dexFiles);
    }
	
  //核心方法
    @Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
  			//異常處理
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
  			//這裏也只是一箇中轉,關注點在 DexPathList
        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

final class DexPathList {
  //文件後綴
	private static final String DEX_SUFFIX = ".dex";
	private static final String zipSeparator = "!/";

** class definition context */ private final ClassLoader definingContext;

//內部類 Element
private Element[] dexElements;

public DexPathList(ClassLoader definingContext, String dexPath, String librarySearchPath, File optimizedDirectory) {
    this(definingContext, dexPath, librarySearchPath, optimizedDirectory, false);
}

DexPathList(ClassLoader definingContext, String dexPath,
        String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
    if (definingContext == null) {
        throw new NullPointerException("definingContext == null");
    }

    if (dexPath == null) {
        throw new NullPointerException("dexPath == null");
    }

    if (optimizedDirectory != null) {
        if (!optimizedDirectory.exists())  {
            throw new IllegalArgumentException(
                    "optimizedDirectory doesn't exist: "
                    + optimizedDirectory);
        }

        if (!(optimizedDirectory.canRead()
                        && optimizedDirectory.canWrite())) {
            throw new IllegalArgumentException(
                    "optimizedDirectory not readable/writable: "
                    + optimizedDirectory);
        }
    }

    this.definingContext = definingContext;

    ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
    // save dexPath for BaseDexClassLoader
  	//咱們關注這個 makeDexElements 方法
    this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                       suppressedExceptions, definingContext, isTrusted);
    this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
    this.systemNativeLibraryDirectories =
            splitPaths(System.getProperty("java.library.path"), true);
    List<File> allNativeLibraryDirectories = new ArrayList<>(nativeLibraryDirectories);
    allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);

    this.nativeLibraryPathElements = makePathElements(allNativeLibraryDirectories);

    if (suppressedExceptions.size() > 0) {
        this.dexElementsSuppressedExceptions =
            suppressedExceptions.toArray(new IOException[suppressedExceptions.size()]);
    } else {
        dexElementsSuppressedExceptions = null;
    }
}
  
  
  
static class Element {
	//dex文件爲null時表示 jar/dex.jar文件
 private final File path;
	
 //android虛擬機文件在Android中的一個具體實現
 private final DexFile dexFile;

 private ClassPathURLStreamHandler urlHandler;
 private boolean initialized;

 /** * Element encapsulates a dex file. This may be a plain dex file (in which case dexZipPath * should be null), or a jar (in which case dexZipPath should denote the zip file). */
 public Element(DexFile dexFile, File dexZipPath) {
     this.dexFile = dexFile;
     this.path = dexZipPath;
 }

 public Element(DexFile dexFile) {
     this.dexFile = dexFile;
     this.path = null;
 }

 public Element(File path) {
   this.path = path;
   this.dexFile = null;
 }
	
 public Class<?> findClass(String name, ClassLoader definingContext,
               List<Throwable> suppressed) {
   					//核心點,DexFile
           return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext, suppressed)
                   : null;
       }
  
 /** * Constructor for a bit of backwards compatibility. Some apps use reflection into * internal APIs. Warn, and emulate old behavior if we can. See b/33399341. * * @deprecated The Element class has been split. Use new Element constructors for * classes and resources, and NativeLibraryElement for the library * search path. */
 @Deprecated
 public Element(File dir, boolean isDirectory, File zip, DexFile dexFile) {
     System.err.println("Warning: Using deprecated Element constructor. Do not use internal"
             + " APIs, this constructor will be removed in the future.");
     if (dir != null && (zip != null || dexFile != null)) {
         throw new IllegalArgumentException("Using dir and zip|dexFile no longer"
                 + " supported.");
     }
     if (isDirectory && (zip != null || dexFile != null)) {
         throw new IllegalArgumentException("Unsupported argument combination.");
     }
     if (dir != null) {
         this.path = dir;
         this.dexFile = null;
     } else {
         this.path = zip;
         this.dexFile = dexFile;
     }
 }
  ...
}

  
  
 ...
//主要做用就是將 咱們指定路徑中全部文件轉化爲DexFile,同時存到Eelement數組中
//爲何要這樣作?目的就是爲了讓findClass去實現
private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
  List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
  Element[] elements = new Element[files.size()];
  int elementsPos = 0;
  //遍歷全部文件
  for (File file : files) {
      if (file.isDirectory()) {
         	//若是存在文件夾,查找文件夾內部查詢
          elements[elementsPos++] = new Element(file);
        //若是是文件
      } else if (file.isFile()) {
          String name = file.getName();
          DexFile dex = null;
        //判斷是不是dex文件
          if (name.endsWith(DEX_SUFFIX)) {
              // Raw dex file (not inside a zip/jar).
              try {
                	//建立一個DexFile
                  dex = loadDexFile(file, optimizedDirectory, loader, elements);
                  if (dex != null) {
                      elements[elementsPos++] = new Element(dex, null);
                  }
              } catch (IOException suppressed) {
                  System.logE("Unable to load dex file: " + file, suppressed);
                  suppressedExceptions.add(suppressed);
              }
          } else {
              try {
                  dex = loadDexFile(file, optimizedDirectory, loader, elements);
              } catch (IOException suppressed) {
                  /* * IOException might get thrown "legitimately" by the DexFile constructor if * the zip file turns out to be resource-only (that is, no classes.dex file * in it). * Let dex == null and hang on to the exception to add to the tea-leaves for * when findClass returns null. */
                  suppressedExceptions.add(suppressed);
              }

              if (dex == null) {
                  elements[elementsPos++] = new Element(file);
              } else {
                  elements[elementsPos++] = new Element(dex, file);
              }
          }
          if (dex != null && isTrusted) {
            dex.setTrusted();
          }
      } else {
          System.logW("ClassLoader referenced unknown path: " + file);
      }
  }
  if (elementsPos != elements.length) {
      elements = Arrays.copyOf(elements, elementsPos);
  }
  return elements;
}
  
  ---
 private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader, Element[] elements)throws IOException {
     //判斷可複製文件夾是否爲null
     if (optimizedDirectory == null) {
         return new DexFile(file, loader, elements);
     } else {
       	//若是不爲null,則進行解壓後再建立
         String optimizedPath = optimizedPathFor(file, optimizedDirectory);
         return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);
     }
 }
  
  -----
public Class<?> findClass(String name, List<Throwable> suppressed) {
    //遍歷初始化好的DexFile數組,並由Element調用 findClass方法去生成
    for (Element element : dexElements) {
      	//
        Class<?> clazz = element.findClass(name, definingContext, suppressed);
        if (clazz != null) {
            return clazz;
        }
    }

    if (dexElementsSuppressedExceptions != null) {
        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
    }
    return null;
}
複製代碼

上面的代碼有點複雜,我摘取了其中一部分咱們須要關注的點,便於咱們進行分析:

BaseDexClassLoader中,咱們發現最終加載類的是由 DexPathList 來進行的,因此咱們進入了 DexPathList 這個類中,咱們能夠發現 在初始化的時候,有一個關鍵方法須要咱們注意 makeDexElements。而這個方法的主要做用就是將 咱們指定路徑中全部文件轉化爲 DexFile ,同時存到 Eelement 數組中。

而最開始調用的 DexPathList中的findClass() 反而是由Element 調用的 findClass方法,而EmementfindClass方法中實際上又是 DexFile 調用的 loadClassBinaryName 方法,因此帶着這個疑問,咱們進入 DexFile這個類一查究竟。

DexFile

public final class DexFile {
*
 If close is called, mCookie becomes null but the internal cookie is preserved if the close
 failed so that we can free resources in the finalizer.
/
@ReachabilitySensitive
private Object mCookie;

private Object mInternalCookie;
private final String mFileName;
...
DxFile(String fileName, ClassLoader loader, DexPathList.Element[] elements) throws IOException {
     mCookie = openDexFile(fileName, null, 0, loader, elements);
     mInternalCookie = mCookie;
     mFileName = fileName;
     //System.out.println("DEX FILE cookie is " + mCookie + " fileName=" + fileName);
 }
  
 //關注點在這裏
public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
     return defineClass(name, loader, mCookie, this, suppressed);
 }

//
 private static Class defineClass(String name, ClassLoader loader, Object cookie, DexFile dexFile, List<Throwable> suppressed) {
     Class result = null;
     try {
       //這裏調用了一個 JNI層方法
         result = defineClassNative(name, loader, cookie, dexFile);
     } catch (NoClassDefFoundError e) {
         if (suppressed != null) {
             suppressed.add(e);
         }
     } catch (ClassNotFoundException e) {
         if (suppressed != null) {
             suppressed.add(e);
         }
     }
     return result;
 }

  private static native Class defineClassNative(String name, ClassLoader loader, Object cookie, DexFile dexFile) throws ClassNotFoundException, NoClassDefFoundError;
複製代碼

咱們從 loadClassBinaryName 方法中發現,調用了 defineClass 方法,最終又調用了 defineClassNative 方法,而 defineClassNative 方法是一個JNI層的方法,因此咱們沒法得知具體如何。可是咱們思考一下,從開始的 BaseDexClassLoader一直到如今的 DexFile,咱們一直從入口找到了最底下,不難猜想,這個 defineClassNative 方法內部就是 C/C++幫助咱們以字節碼或者別的生成咱們須要的 dex文件,這也是最難的地方所在。

最後咱們再用一張圖來總結一下Android 中類加載的過程。

image-20191125161551841

在瞭解完上面的知識以後,咱們來總結一下,Android中熱修復的原理?

Android中既然已經有了DexClassLoader和 PathClassLoader,那麼我在加載過程當中直接替換我本身的Dex文件不就能夠了,也就是先加載我本身的Dex文件不就好了,這樣不就實現了熱修復。

真的這麼簡單嗎?熱修復的難點是什麼?

  • 資源修復
  • 代碼修復
  • so庫修復

抱着這個問題,如何選用一個最合適的框架,是咱們Android開發者必需要考慮的,下面咱們就分析一下各方案的差異。

如何選擇熱修復框架?

目前市場上的熱修復框架不少,從阿里熱修復網站找了一個圖來對比一下:

平臺 Sophix AndFix Tinker Qzone Robust
即時生效 yes yes no no yes
性能損耗 較小 較小 較大 較大 較小
侵入式打包 無侵入式打包 無侵入式打包 依賴侵入式打包 依賴侵入式打包 依賴侵入式打包
Rom體積 較小 較小 較大 較小 較小
接入複雜度 傻瓜式接入 比較簡單 複雜 比較簡單 複雜
補丁包大小 較小 較小 較小 較大 通常
全平臺支持 yes yes yes yes yes
類替換 yes yes yes yes no
so替換 yes no yes no no
資源替換 yes no yes yes no

簡單劃分就是3大巨頭,阿里,騰訊,美團。並非誰支持的功能多就用誰,在接入方面咱們須要綜合考慮。

詳細的技術對比請參考 Android熱修復技術選型——三大流派解析

以我我的的體驗來講吧:目前體驗了Tinker和 Sophix

Tinker

Tinker的集成有點麻煩,我我的以爲挺簡單,並且補丁管理系統 TinkerPatch是收費的(有免費額度),補丁下發慢,大概須要5分鐘的等待時間。

Tinker有一個免費版後臺,Bugly,補丁管理是免費的,熱修復用的Tinker,集成很那啥。。。em,建議多讀官網教程看視頻,由於有補丁上傳監測,下發一個補丁須要5-10分鐘等待生效,撤回補丁須要10分鐘左右生效,並且一次可能不會生效,後臺觀察日誌須要屢次才能夠實現補丁撤回。(測試設備:小米5s Plus,Android 8.0)

最後總結:

優勢:免費,簡單

缺點:集成麻煩,出現問題沒法第一時間獲得解決方案,畢竟免費的理解一下

性能方法:須要冷啓動以後纔會生效

Sophix

官網教程詳細,徹底傻瓜式,響應快,出現問題,解決效率高,畢竟花了錢的。

性能方面:冷啓動+即時響應(有條件),

有點:功能最多,支持版本最多,解決問題快

缺點:付費

別的框架沒有體驗,也就不妄自評價了。關於以上方案的實現原理,你們能夠點擊Android熱修復技術選型——三大流派解析,或者百度搜索。簡單瞭解並不困難。

熱修復的注意事項

有了熱修復,咱們就能夠隨心所欲了嗎?

開始講騷話

並非,熱修復受限於各類機型設備,並且也有失敗的可能性,因此咱們開發者,對於補丁包一樣也要抱有敬畏之心。

對於熱修復一樣也因爲嚴格的過程,可是咱們平常開發至少要保證如下幾點:

debug-> 打補丁包->開發設備測試->灰度下發(條件下發)->全量下發

下面針對我開發中遇到的問題,給出解決方案。

熱修復與多渠道

多渠道打包使用 美團 的一鍵打包方案。補丁包的話,其實並不會影響,由於補丁包通常改動的代碼相同,但前提是須要保證咱們每一個渠道基準包沒問題。若是改動代碼有區別,那就須要針對這個渠道單獨打補了。

自動化構建與熱修復

Android開發通常集成了 Jenkins 或者別的自動化打包工具,咱們通常基準包都在 app/build/bakApk目錄下,因此咱們能夠經過編寫 shell 命令,在jenkins中打包時,將生成的基準包移動到一個特定的文件夾便可。tinker,Sophix都是支持服務器後臺的,因此咱們也能夠經過自動化構建工具上傳補丁包,若是相應的熱修復框架不支持服務器管理的話,那麼能夠將補丁包上傳的指定的文件夾,而後咱們app打開時,訪問咱們的服務器接口下拉最新的補丁包,而後在service中合成。不過 **Tinker(bugly) **, Sophix 都是支持後臺管理,因此具體使用那種方案咱們自行選擇。

關於熱修復的到這裏就基本寫完了,散散落落竟然寫了這麼多,其實難的不是熱修復,而是Android中類加載的過程及一些基礎相關知識,理解了這些,咱們才能真正明白那些優秀的框架究竟是怎樣去修復的。

若是本文有幫到你的地方,不勝榮幸。若是有什麼地方有錯誤或者疑問,也歡迎你們提出。

相關文章
相關標籤/搜索