android熱更新

## android更新

## 導(dǎo)航

-? ### 熱更新

>- [x]? 熱修復(fù)簡介

>- [x] 技術(shù)原理及特點(diǎn)

>- [x]? 熱修復(fù)框架分類

>- [x]? 熱修復(fù)框架對(duì)比

>- [x] 熱更新其他的方案

>- [x] Tinker框架解析

>

? - ### 增量更新

#### 熱修復(fù)簡介

熱修復(fù):熱修復(fù)(也稱熱補(bǔ)丁、熱修復(fù)補(bǔ)丁,英語:hotfix)是一種包含信息的獨(dú)立的累積更新包,通常表現(xiàn)為一個(gè)或多個(gè)文件。這被用來解決軟件產(chǎn)品的問題(例如一個(gè)程序錯(cuò)誤)。 —— 維基百科

#### 技術(shù)原理及特點(diǎn)

代碼修復(fù)主要有三個(gè)方案,分別是底層替換方案、類加載方案和Instant Run方案

- ##### 類加載方案

? 類加載方案基于Dex分包方案,什么是Dex分包方案呢?這個(gè)得先從65536限制和LinearAlloc限制說起。

**65536限制**

隨著應(yīng)用功能越來越復(fù)雜,代碼量不斷地增大,引入的庫也越來越多,可能會(huì)在編譯時(shí)提示如下異常:

```

com.android.dex.DexIndexOverflowException: method ID not in [0, 0xffff]: 65536

```

這說明應(yīng)用中引用的方法數(shù)超過了最大數(shù)65536個(gè)。產(chǎn)生這一問題的原因就是系統(tǒng)的65536限制,65536限制的主要原因是DVM Bytecode的限制,DVM指令集的方法調(diào)用指令invoke-kind索引為16bits,最多能引用 65535個(gè)方法。

**LinearAlloc限制**

在安裝時(shí)可能會(huì)提示INSTALL_FAILED_DEXOPT。產(chǎn)生的原因就是LinearAlloc限制,DVM中的LinearAlloc是一個(gè)固定的緩存區(qū),當(dāng)方法數(shù)過多超出了緩存區(qū)的大小時(shí)會(huì)報(bào)錯(cuò)。

為了解決65536限制和LinearAlloc限制,從而產(chǎn)生了Dex分包方案。Dex分包方案主要做的是在打包時(shí)將應(yīng)用代碼分成多個(gè)Dex,將應(yīng)用啟動(dòng)時(shí)必須用到的類和這些類的直接引用類放到主Dex中,其他代碼放到次Dex中。當(dāng)應(yīng)用啟動(dòng)時(shí)先加載主Dex,等到應(yīng)用啟動(dòng)后再動(dòng)態(tài)的加載次Dex,從而緩解了主Dex的65536限制和LinearAlloc限制。

Dex分包方案主要有兩種,分別是Google官方方案、Dex自動(dòng)拆包和動(dòng)態(tài)加載方案。因?yàn)镈ex分包方案不是本章的重點(diǎn),這里就不再過多的介紹,我們接著來學(xué)習(xí)類加載方案。

ClassLoader的加載過程,其中一個(gè)環(huán)節(jié)就是調(diào)用DexPathList的findClass的方法,如下所示。

libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

```

public Class<?> findClass(String name, List<Throwable> suppressed) {

? ? ? ? for (Element element : dexElements) {//1

? ? ? ? ? ? Class<?> clazz = element.findClass(name, definingContext, suppressed);//2

? ? ? ? ? ? if (clazz != null) {

? ? ? ? ? ? ? ? return clazz;

? ? ? ? ? ? }

? ? ? ? }

? ? ? ? if (dexElementsSuppressedExceptions != null) {

? ? ? ? ? ? suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));

? ? ? ? }

? ? ? ? return null;

? ? }

```

Element內(nèi)部封裝了DexFile,DexFile用于加載dex文件,因此每個(gè)dex文件對(duì)應(yīng)一個(gè)Element。

多個(gè)Element組成了有序的Element數(shù)組dexElements。當(dāng)要查找類時(shí),會(huì)在注釋1處遍歷Element數(shù)組dexElements(相當(dāng)于遍歷dex文件數(shù)組),注釋2處調(diào)用Element的findClass方法,其方法內(nèi)部會(huì)調(diào)用DexFile的loadClassBinaryName方法查找類。如果在Element中(dex文件)找到了該類就返回,如果沒有找到就接著在下一個(gè)Element中進(jìn)行查找。

根據(jù)上面的查找流程,我們將有bug的類Key.class進(jìn)行修改,再將Key.class打包成包含dex的補(bǔ)丁包Patch.jar,放在Element數(shù)組dexElements的第一個(gè)元素,這樣會(huì)首先找到Patch.dex中的Key.class去替換之前存在bug的Key.class,排在數(shù)組后面的dex文件中的存在bug的Key.class根據(jù)ClassLoader的雙親委托模式就不會(huì)被加載,這就是類加載方案,如下圖所示。

類加載方案需要重啟App后讓ClassLoader重新加載新的類,為什么需要重啟呢?這是因?yàn)轭愂菬o法被卸載的,因此要想重新加載新的類就需要重啟App,因此采用類加載方案的熱修復(fù)框架是不能即時(shí)生效的。

雖然很多熱修復(fù)框架采用了類加載方案,但具體的實(shí)現(xiàn)細(xì)節(jié)和步驟還是有一些區(qū)別的,比如QQ空間的超級(jí)補(bǔ)丁和Nuwa是按照上面說得將補(bǔ)丁包放在Element數(shù)組的第一個(gè)元素得到優(yōu)先加載。微信Tinker將新舊apk做了diff,得到patch.dex,然后將patch.dex與手機(jī)中apk的classes.dex做合并,生成新的classes.dex,然后在運(yùn)行時(shí)通過反射將classes.dex放在Element數(shù)組的第一個(gè)元素。餓了么的Amigo則是將補(bǔ)丁包中每個(gè)dex 對(duì)應(yīng)的Element取出來,之后組成新的Element數(shù)組,在運(yùn)行時(shí)通過反射用新的Element數(shù)組替換掉現(xiàn)有的Element 數(shù)組。

采用類加載方案的主要是以騰訊系為主,包括微信的Tinker、QQ空間的超級(jí)補(bǔ)丁、手機(jī)QQ的QFix、餓了么的Amigo和Nuwa等等。

- ##### 底層替換方案

與類加載方案不同的是,底層替換方案不會(huì)再次加載新類,而是直接在Native層修改原有類,由于是在原有類進(jìn)行修改限制會(huì)比較多,不能夠增減原有類的方法和字段,如果我們?cè)黾恿朔椒〝?shù),那么方法索引數(shù)也會(huì)增加,這樣訪問方法時(shí)會(huì)無法通過索引找到正確的方法,同樣的字段也是類似的情況。

底層替換方案和反射的原理有些關(guān)聯(lián),就拿方法替換來說,方法反射我們可以調(diào)用java.lang.Class.getDeclaredMethod,假設(shè)我們要反射Key的show方法,會(huì)調(diào)用如下所示。

```

? Key.class.getDeclaredMethod("show").invoke(Key.class.newInstance());

```

Android 8.0的invoke方法,如下所示。

libcore/ojluni/src/main/java/java/lang/reflect/Method.java

```

? ? @FastNative

? ? public native Object invoke(Object obj, Object... args)

? ? ? ? ? ? throws IllegalAccessException, IllegalArgumentException, InvocationTargetException;

```

invoke方法是個(gè)native方法,對(duì)應(yīng)Jni層的代碼為:

art/runtime/native/java_lang_reflect_Method.cc

```

static jobject Method_invoke(JNIEnv* env, jobject javaMethod, jobject javaReceiver,

? ? ? ? ? ? ? ? ? ? ? ? ? ? jobject javaArgs) {

? ScopedFastNativeObjectAccess soa(env);

? return InvokeMethod(soa, javaMethod, javaReceiver, javaArgs);

Method_invoke函數(shù)中又調(diào)用了InvokeMethod函數(shù):

art/runtime/reflection.cc

jobject InvokeMethod(const ScopedObjectAccessAlreadyRunnable& soa, jobject javaMethod,

? ? ? ? ? ? ? ? ? ? jobject javaReceiver, jobject javaArgs, size_t num_frames) {

...

? ObjPtr<mirror::Executable> executable = soa.Decode<mirror::Executable>(javaMethod);

? const bool accessible = executable->IsAccessible();

? ArtMethod* m = executable->GetArtMethod();//1

...

}

```

注釋1處獲取傳入的javaMethod(Key的show方法)在ART虛擬機(jī)中對(duì)應(yīng)的一個(gè)ArtMethod指針,ArtMethod結(jié)構(gòu)體中包含了Java方法的所有信息,包括執(zhí)行入口、訪問權(quán)限、所屬類和代碼執(zhí)行地址等等,ArtMethod結(jié)構(gòu)如下所示。

art/runtime/art_method.h

```

class ArtMethod FINAL {

...

protected:

? GcRoot<mirror::Class> declaring_class_;

? std::atomic<std::uint32_t> access_flags_;

? uint32_t dex_code_item_offset_;

? uint32_t dex_method_index_;

? uint16_t method_index_;

? uint16_t hotness_count_;

struct PtrSizedFields {

? ? ArtMethod** dex_cache_resolved_methods_;//1

? ? void* data_;

? ? void* entry_point_from_quick_compiled_code_;//2

? } ptr_sized_fields_;

}

```

ArtMethod結(jié)構(gòu)中比較重要的字段是注釋1處的dex_cache_resolved_methods_和注釋2處的entry_point_from_quick_compiled_code_,它們是方法的執(zhí)行入口,當(dāng)我們調(diào)用某一個(gè)方法時(shí)(比如Key的show方法),就會(huì)取得show方法的執(zhí)行入口,通過執(zhí)行入口就可以跳過去執(zhí)行show方法。

替換ArtMethod結(jié)構(gòu)體中的字段或者替換整個(gè)ArtMethod結(jié)構(gòu)體,這就是底層替換方案。

AndFix采用的是替換ArtMethod結(jié)構(gòu)體中的字段,這樣會(huì)有兼容問題,因?yàn)閺S商可能會(huì)修改ArtMethod結(jié)構(gòu)體,導(dǎo)致方法替換失敗。Sophix采用的是替換整個(gè)ArtMethod結(jié)構(gòu)體,這樣不會(huì)存在兼容問題。

底層替換方案直接替換了方法,可以立即生效不需要重啟。采用底層替換方案主要是阿里系為主,包括AndFix、Dexposed、阿里百川、Sophix。

- ##### Instant Run方案

除了資源修復(fù),代碼修復(fù)同樣也可以借鑒Instant Run的原理, 可以說Instant Run的出現(xiàn)推動(dòng)了熱修復(fù)框架的發(fā)展。

Instant Run在第一次構(gòu)建apk時(shí),使用ASM在每一個(gè)方法中注入了類似如下的代碼:

```

IncrementalChange localIncrementalChange = $change;//1

if (localIncrementalChange != null) {//2

localIncrementalChange.access$dispatch(

"onCreate.(Landroid/os/Bundle;)V", new Object[] { this,

paramBundle });

return;

}

```

其中注釋1處是一個(gè)成員變量localIncrementalChange ,它的值為$change,$change實(shí)現(xiàn)了IncrementalChange這個(gè)抽象接口。當(dāng)我們點(diǎn)擊InstantRun時(shí),如果方法沒有變化則$change為null,就調(diào)用return,不做任何處理。如果方法有變化,就生成替換類,這里我們假設(shè)MainActivity的onCreate方法做了修改,就會(huì)生成替換類MainActivity$override,這個(gè)類實(shí)現(xiàn)了IncrementalChange接口,同時(shí)也會(huì)生成一個(gè)AppPatchesLoaderImpl類,這個(gè)類的getPatchedClasses方法會(huì)返回被修改的類的列表(里面包含了MainActivity),根據(jù)列表會(huì)將MainActivity的$change設(shè)置為MainActivity$override,因此滿足了注釋2的條件,會(huì)執(zhí)行MainActivity$override的access$dispatch方法,accessdispatch方法中會(huì)根據(jù)參數(shù)&quot;onCreate.(Landroid/os/Bundle;)V&quot;執(zhí)行‘MainActivity dispatch方法中會(huì)根據(jù)參數(shù)&quot;onCreate.(Landroid/os/Bundle;)V&quot;執(zhí)行`MainActivitydispatch方法中會(huì)根據(jù)參數(shù)"onCreate.(Landroid/os/Bundle;)V"執(zhí)行‘MainActivityoverride`的onCreate方法,從而實(shí)現(xiàn)了onCreate方法的修改。

借鑒Instant Run的原理的熱修復(fù)框架有Robust和Aceso。

---------------------

#### 熱修復(fù)框架分類

| 類別? | 成員 |

|-------|:---:|

| 阿里系? | AndFix、Dexposed、阿里百川、Sophix |

| 騰訊系 | 微信的Tinker、QQ空間的超級(jí)補(bǔ)丁、手機(jī)QQ的QFix? |

| 其他? | 美團(tuán)的Robust、餓了么的Amigo、美麗說蘑菇街的Aceso等等? |

---------------------

#### 熱修復(fù)框架對(duì)比

| 特性? | AndFix | Tinker? ? | QQ空間? |Robust/Aceso|

|-------|:---:|:------:|:------:| :-------: |

| 即時(shí)生效? |? &#10003; |? &#10005;? |? &#10005;? ? |? &#10003;? |

| 方法替換? | &#10003; | &#10003;? ? | &#10003; |? &#10003;? |

| 類替換 |? &#10005; | &#10003;? ? ? | &#10003;? |? &#10005; |

| 類結(jié)構(gòu)修改? |? ? &#10005; |? &#10003; |? ? &#10005;? |? &#10005;? |

| 資源替換? |? ? &#10005; |? &#10003;? |? &#10003;? |? &#10005; |

| so替換? |? &#10005;? |? &#10003;? |? ? &#10005;? |? ? &#10005; |

| 支持gralde? |? &#10005;? |? &#10003;? |? ? &#10005;? |? ? &#10005; |

---------------------

- #### Tinker框架解析

![tinker](https://github.com/Tencent/tinker/blob/master/assets/tinker.png)?

https://github.com/xiaolongwuhpu/TestBugly

#### 增量更新

- ##### 準(zhǔn)備

? * **工具**?


? apk文件的差分、合成,可以通過 開源的二進(jìn)制比較工具 bsdiff 來實(shí)現(xiàn),又因?yàn)閎sdiff依賴bzip2,所以我們還需要用到 bzip2

? bsdiff中,bsdiff.c 用于生成差分包,bspatch.c 用于合成文件。?

[bsdiff官網(wǎng)下載地址](http://www.daemonology.net/bsdiff/)?

? ? [bzip2-1.0.6下載地址](http://www.bzip.org/downloads.html)

? * **增量更新三部曲**

? 1. 生成兩個(gè)版本apk的差分包;?

? 2. 在手機(jī)客戶端,使用已安裝的apk與這個(gè)差分包進(jìn)行合成,得到新版的apk;?

? 3. 校驗(yàn)新合成的apk文件是否完整,MD5或SHA1是否正確,如正確,則引導(dǎo)用戶安裝;


? *? **環(huán)境配置**?

創(chuàng)建一個(gè)工程,勾選Include C++ Support,Android Studio會(huì)在main目錄創(chuàng)建cpp文件夾,里邊有個(gè)native-lib.cpp的C++文件;在app目錄還有個(gè)CMakeLists.txt文件;在module的build.gradle中標(biāo)示了采用CMake構(gòu)建方式,并設(shè)置CMakeLists.txt路徑。



*? **過程分析**?

這一步需要在服務(wù)器端來實(shí)現(xiàn),一般來說,每當(dāng)apk有新版本需要提示用戶升級(jí),都需要運(yùn)營人員在后臺(tái)管理端上傳新apk,上傳時(shí)就應(yīng)該由程序生成與之前所有舊版本們與最新版的差分包。

例如: 你的apk已經(jīng)發(fā)布了3個(gè)版,V1.0、V2.0、V3.0,這時(shí)候你要在后臺(tái)發(fā)布V4.0,那么,當(dāng)你在服務(wù)器上傳最新的V4.0包時(shí),服務(wù)器端就應(yīng)該立即生成以下差分包:

V1.0 ——> V4.0的差分包;?

V2.0 ——> V4.0的差分包;?

V3.0 ——> V4.0的差分包;?

生成patch包直接略過...

* **合成新的APK步驟**?

根據(jù)cpp/bspatch.c文件定義的JNI

創(chuàng)建BsPatchJNI.java,用來合成增量文件

public class BsPatchJNI {

? ? static {

? ? ? ? System.loadLibrary("bspatch");

? ? }

? ? /**

? ? * 將增量文件合成為新的Apk

? ? * @param oldApkPath 當(dāng)前Apk路徑

? ? * @param newApkPath 合成后的Apk保存路徑

? ? * @param patchPath 增量文件路徑

? ? * @return

? ? */

? ? public static native int patch(String oldApkPath, String newApkPath, String patchPath);

}

在MainActivity中使用:

public class MainActivity extends AppCompatActivity {

? ? public static final String SDCARD_PATH = Environment.getExternalStorageDirectory() + File.separator;

? ? public static final String PATCH_FILE = "old-to-new.patch";

? ? public static final String NEW_APK_FILE = "new.apk";

? ? @Override

? ? protected void onCreate(Bundle savedInstanceState) {

? ? ? ? super.onCreate(savedInstanceState);

? ? ? ? setContentView(R.layout.activity_main);

? ? ? ? findViewById(R.id.btn_main).setOnClickListener(new View.OnClickListener() {

? ? ? ? ? ? @Override

? ? ? ? ? ? public void onClick(View v) {

? ? ? ? ? ? ? ? //并行任務(wù)

? ? ? ? ? ? ? ? new ApkUpdateTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);

? ? ? ? ? ? }

? ? ? ? });

? ? }

? ? /**

? ? * 合并增量文件任務(wù)

? ? */

? ? private class ApkUpdateTask extends AsyncTask<Void, Void, Boolean> {

? ? ? ? @Override

? ? ? ? protected Boolean doInBackground(Void... params) {

? ? ? ? ? ? String oldApkPath = ApkUtils.getCurApkPath(MainActivity.this);

? ? ? ? ? ? File oldApkFile = new File(oldApkPath);

? ? ? ? ? ? File patchFile = new File(getPatchFilePath());

? ? ? ? ? ? if(oldApkFile.exists() && patchFile.exists()) {

? ? ? ? ? ? ? ? Log("正在合并增量文件...");

? ? ? ? ? ? ? ? String newApkPath = getNewApkFilePath();

? ? ? ? ? ? ? ? BsPatchJNI.patch(oldApkPath, newApkPath, getPatchFilePath());

//? ? ? ? ? ? ? ? //檢驗(yàn)文件MD5值

//? ? ? ? ? ? ? ? return Signtils.checkMd5(oldApkFile, MD5);

? ? ? ? ? ? ? ? Log("增量文件的MD5值為:" + SignUtils.getMd5ByFile(patchFile));

? ? ? ? ? ? ? ? Log("新文件的MD5值為:" + SignUtils.getMd5ByFile(new File(newApkPath)));

? ? ? ? ? ? ? ? return true;

? ? ? ? ? ? }

? ? ? ? ? ? return false;

? ? ? ? }

? ? ? ? @Override

? ? ? ? protected void onPostExecute(Boolean result) {

? ? ? ? ? ? super.onPostExecute(result);

? ? ? ? ? ? if(result) {

? ? ? ? ? ? ? ? Log("合并成功,開始安裝");

? ? ? ? ? ? ? ? ApkUtils.installApk(MainActivity.this, getNewApkFilePath());

? ? ? ? ? ? } else {

? ? ? ? ? ? ? ? Log("合并失敗");

? ? ? ? ? ? }

? ? ? ? }

? ? }

? ? private String getPatchFilePath() {

? ? ? ? return SDCARD_PATH + PATCH_FILE;

? ? }

? ? private String getNewApkFilePath() {

? ? ? ? return SDCARD_PATH + NEW_APK_FILE;

? ? }

? ? /**

? ? * 打印日志

? ? * @param log

? ? */

? ? private void Log(String log) {

? ? ? ? Log.e("MainActivity", log);

? ? }

}

創(chuàng)建ApkUtils.java,用來獲取當(dāng)前Apk路徑和安裝新的Apk文件

public class ApkUtils {

? ? /**

? ? * 獲取當(dāng)前應(yīng)用的Apk路徑

? ? * @param context 上下文

? ? * @return

? ? */

? ? public static String getCurApkPath(Context context) {

? ? ? ? context = context.getApplicationContext();

? ? ? ? ApplicationInfo applicationInfo = context.getApplicationInfo();

? ? ? ? String apkPath = applicationInfo.sourceDir;

? ? ? ? return apkPath;

? ? }

? ? /**

? ? * 安裝Apk

? ? * @param context 上下文

? ? * @param apkPath Apk路徑

? ? */

? ? public static void installApk(Context context, String apkPath) {

? ? ? ? File file = new File(apkPath);

? ? ? ? if(file.exists()) {

? ? ? ? ? ? Intent intent = new Intent(Intent.ACTION_VIEW);

? ? ? ? ? ? intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

? ? ? ? ? ? intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive");

? ? ? ? ? ? context.startActivity(intent);

? ? ? ? }

? ? }

}

創(chuàng)建SignUtils.java,用來校驗(yàn)增量文件和合成的新Apk文件MD5值是否與服務(wù)器給的值相同

public class SignUtils {

? ? /**

? ? * 判斷文件的MD5值是否為指定值

? ? * @param file1

? ? * @param md5

? ? * @return

? ? */

? ? public static boolean checkMd5(File file1, String md5) {

? ? ? ? if(TextUtils.isEmpty(md5)) {

? ? ? ? ? ? throw new RuntimeException("md5 cannot be empty");

? ? ? ? }

? ? ? ? if(file1 != null && file1.exists()) {

? ? ? ? ? ? String file1Md5 = getMd5ByFile(file1);

? ? ? ? ? ? return file1Md5.equals(md5);

? ? ? ? }

? ? ? ? return false;

? ? }

? ? /**

? ? * 獲取文件的MD5值

? ? * @param file

? ? * @return

? ? */

? ? public static String getMd5ByFile(File file) {

? ? ? ? String value = null;

? ? ? ? FileInputStream in = null;

? ? ? ? try {

? ? ? ? ? ? in = new FileInputStream(file);

? ? ? ? ? ? MessageDigest digester = MessageDigest.getInstance("MD5");

? ? ? ? ? ? byte[] bytes = new byte[8192];

? ? ? ? ? ? int byteCount;

? ? ? ? ? ? while ((byteCount = in.read(bytes)) > 0) {

? ? ? ? ? ? ? ? digester.update(bytes, 0, byteCount);

? ? ? ? ? ? }

? ? ? ? ? ? value = bytes2Hex(digester.digest());

? ? ? ? } catch (Exception e) {

? ? ? ? ? ? e.printStackTrace();

? ? ? ? } finally {

? ? ? ? ? ? if (null != in) {

? ? ? ? ? ? ? ? try {

? ? ? ? ? ? ? ? ? ? in.close();

? ? ? ? ? ? ? ? } catch (IOException e) {

? ? ? ? ? ? ? ? ? ? e.printStackTrace();

? ? ? ? ? ? ? ? }

? ? ? ? ? ? }

? ? ? ? }

? ? ? ? return value;

? ? }

? ? private static String bytes2Hex(byte[] src) {

? ? ? ? char[] res = new char[src.length * 2];

? ? ? ? final char hexDigits[] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};

? ? ? ? for (int i = 0, j = 0; i < src.length; i++) {

? ? ? ? ? ? res[j++] = hexDigits[src[i] >>> 4 & 0x0f];

? ? ? ? ? ? res[j++] = hexDigits[src[i] & 0x0f];

? ? ? ? }

? ? ? ? return new String(res);

? ? }

}

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容