Android Hotfix 新方案——Amigo 源碼解讀

現(xiàn)在 hotfix 框架有很多,原理大同小異败许,基本上是基于qq空間這篇文章 或者微信的方案王带。可惜的是微信的 Tinker 以及 QZone 都沒有將其具體實現(xiàn)開源出來市殷,只是在文章中分析了現(xiàn)有各個 hotfix 框架的優(yōu)缺點以及他們的實現(xiàn)方案愕撰。Amigo 原理與 Tinker 基本相同,但是在 Tinker 的基礎(chǔ)上醋寝,進一步實現(xiàn)了 so 文件搞挣、資源文件、Activity音羞、BroadcastReceiver 的修復(fù)柿究,幾乎可以號稱全面修復(fù),不愧 Amigo(朋友)這個稱號黄选,能在危急時刻送來全面的幫助。

庫地址:https://github.com/eleme/Amigo
Amigo 是來自餓了么團隊的 JackCho 所寫,他的 github 地址:https://github.com/JackCho办陷。本文是對 Amigo 源碼的解讀貌夕。

首先我們先來看看如何使用這個庫。

用法
----
在 project 的build.gradle

dependencies {
  classpath 'me.ele:amigo:0.0.3'
}

在 module 的build.gradle

apply plugin: 'me.ele.amigo'

就這樣輕松的集成了 Amigo民镜。

生效補丁包

補丁包生效有兩種方式可以選擇:

  • 稍后生效補丁包
如果不想立即生效而是用戶第二次打開 App 時才打入補丁包啡专,則可以將新的 Apk 放到 `/data/data/{your pkg}/files/amigo/demo.apk`,第二次打開時就會自動生效制圈∶峭可以通過這個方法

```Java
File hotfixApk = Amigo.getHotfixApk(context);
```

獲取到新的 Apk。
同時鲸鹦,你也可以使用 Amigo 提供的工具類將你的補丁包拷貝到指定的目錄當中慧库。
FileUtils.copyFile(yourApkFile, amigoApkFile);
  • 立即生效補丁包
如果想要補丁包立即生效,調(diào)用以下兩個方法之一馋嗜,App 會立即重啟齐板,并且打入補丁包。

```Java
Amigo.work(context);
```

```Java
Amigo.work(context, apkFile);
```

刪除補丁包

如果需要刪除掉已經(jīng)下好的補丁包葛菇,可以通過這個方法

Amigo.clear(context);

提示:如果apk 發(fā)生了變化甘磨,Amigo 會自動清除之前的apk。

自定義界面

在熱修復(fù)的過程中會有一些耗時的操作眯停,這些操作會在一個新的進程中的 Activity 中執(zhí)行济舆,所以你可以通過以下方式來自定義這個 Activity。

<meta-data
  android:name="amigo_layout"
  android:value="{your-layout-name}" />

<meta-data
  android:name="amigo_theme"
  android:value="{your-theme-name}" />

組件修復(fù)

Amigo 目前能夠支持增加 Activity 和 BroadcastReceiver莺债。只需要將新的 Activity 和 BroadcastReceiver 加到新的 Apk 包中就可以了滋觉。Service 和 ContentProvider 將會在未來的版本中支持更新。

集成 Amigo 十分簡單九府,但是明白 Amigo 的實現(xiàn)更加重要椎瘟。

源碼分析
----

Amigo這個類中實現(xiàn)了主要的修復(fù)工作。我們一起追追看侄旬,到底是怎樣的實現(xiàn)肺蔚。

檢查補丁包

****Amigo.java****

...

if (demoAPk.exists() && isSignatureRight(this, demoAPk)) {
  SharedPreferences sp = getSharedPreferences(SP_NAME, MODE_MULTI_PROCESS);
  String demoApkChecksum = checksum(demoAPk);
  boolean isFirstRun = !sp.getString(NEW_APK_SIG, "").equals(demoApkChecksum);
...

這段代碼中,首先檢查是否有補丁包儡羔,并且簽名正確宣羊,如果正確,則通過檢驗校驗和是否與之前的檢驗和相同汰蜘,不同則為檢測到新的補丁包仇冯。

釋放Apk

當這是新的補丁包時,首先第一件事就是釋放族操。ApkReleaser.work(this, layoutId, themeId)在這個方法中最終會去開啟一個 ApkReleaseActivity苛坚,而這個 Activity 的layout 和 theme 就是之前從配置中解析出來比被,在 work 方法中傳進來的layoutId 和 themeId。

****ApkReleaseActivity.java****

@Override
protected void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
 ...

   new Thread() {
       @Override
       public void run() {
           super.run();

           DexReleaser.releaseDexes(demoAPk.getAbsolutePath(), dexDir.getAbsolutePath());
           NativeLibraryHelperCompat.copyNativeBinaries(demoAPk, nativeLibraryDir);
           dexOptimization();

           handler.sendEmptyMessage(WHAT_DEX_OPT_DONE);
       }
   }.start();
}

在 ApkReleaseActivity 的 onCreate() 方法中會開啟一個線程去進行一系列的釋放操作泼舱,這些操作十分耗時等缀,目前在不同的機子上測試,從幾秒到二十幾秒之間不等娇昙,如果就這樣黑屏在用戶前面未免太不優(yōu)雅尺迂,所以 Amigo 開啟了一個新的進程,啟動這個 Activity冒掌。
在這個線程中噪裕,做了三件微小的事情:

  • 釋放 Dex 到指定目錄

  • 拷貝 so 文件到 Amigo 的指定目錄下
    拷貝 so 文件是通過反射去調(diào)用 NativeLibraryHelper這個類的nativeCopyNativeBinaries()方法,但這個方法在不同版本上有不同的實現(xiàn)股毫。

    • 如果版本號在21以下

    ****NativeLibraryHelper****

    public static int copyNativeBinariesIfNeededLI(File apkFile, File sharedLibraryDir) {
           final String cpuAbi = Build.CPU_ABI;
           final String cpuAbi2 = Build.CPU_ABI2;
           return nativeCopyNativeBinaries(apkFile.getPath(), sharedLibraryDir.getPath(), cpuAbi,
                   cpuAbi2);
       }
    

    會去反射調(diào)用這個方法膳音,其中系統(tǒng)會自動判斷出 primaryAbi 和 secondAbi。

  • 如果版本號在21以上
    copyNativeBinariesIfNeededLI(file, file)這個方法已經(jīng)被廢棄了皇拣,需要去反射調(diào)用這個方法

NativeLibraryHelper

public static int copyNativeBinaries(Handle handle, File sharedLibraryDir, String abi) {
       for (long apkHandle : handle.apkHandles) {
           int res = nativeCopyNativeBinaries(apkHandle, sharedLibraryDir.getPath(), abi,
                   handle.extractNativeLibs, HAS_NATIVE_BRIDGE);
           if (res != INSTALL_SUCCEEDED) {
               return res;
           }
       }
       return INSTALL_SUCCEEDED;
   }

所以首先得去獲得一個NativeLibraryHelper$Handle類的實例严蓖。之后就是找 primaryAbi。Amigo 先對機器的位數(shù)做了判斷氧急,如果是64位的機子颗胡,就只找64位的 abi,如果是32位的吩坝,就只找32位的 abi毒姨。然后將 Handle 實例當做參數(shù)去調(diào)用NativeLibraryHelperfindSupportedAbi來獲得primaryAbi。最后再去調(diào)用copyNativeBinaries去拷貝 so 文件钉寝。

對于 so 文件加載的原理可以參考這篇文章

  • 優(yōu)化 dex 文件

****ApkReleaseActivity.java****

private void dexOptimization() {
 ...
       for (File dex : validDexes) {
           new DexClassLoader(dex.getAbsolutePath(), optimizedDir.getAbsolutePath(), null, DexUtils.getPathClassLoader());
           Log.e(TAG, "dexOptimization finished-->" + dex);
       }
   }

DexClassLoader 沒有做什么事情弧呐,只是調(diào)用了父類構(gòu)造器,他的父類是 BaseDexClassLoader嵌纲。在 BaseDexClassLoader 的構(gòu)造器中又去構(gòu)造了一個DexPathList 對象俘枫。
DexPathList類中,有一個 Element 數(shù)組

****DexPathList****

/** list of dex/resource (class path) elements */
private final Element[] dexElements;

Element 就是對 Dex 的封裝逮走。所以一個 Element 對應(yīng)一個 Dex鸠蚪。這個 Element 在后文中會提到。

優(yōu)化 dex 只需要在構(gòu)造 DexClassLoader 對象的時候?qū)?dex 的路徑傳進去师溅,系統(tǒng)會在最后會通過DexFile

****DexFile.java****

native private static int openDexFile(String sourceName, String outputName,
      int flags) throws IOException;

來這個方法來加載 dex茅信,加載的同時會對其做優(yōu)化處理。

這三項操作完成之后墓臭,通知優(yōu)化完畢蘸鲸,之后就關(guān)閉這個進程,將補丁包的校驗和保存下來窿锉。這樣第一步釋放 Apk 就完成了酌摇。之后就是重頭戲替換修復(fù)膝舅。

替換修復(fù)

替換classLoader

Amigo 先行構(gòu)造一個AmigoClassLoader對象,這個AmigoClassLoader是一個繼承于PathClassLoader的類妙痹,把補丁包的 Apk 路徑作為參數(shù)來構(gòu)造AmigoClassLoader對象铸史,之后通過反射替換掉 LoadedApk 的 ClassLoader。這一步是 Amigo 的關(guān)鍵所在怯伊。

替換Dex

之前提到,每個 dex 文件對應(yīng)于一個PathClassLoader判沟,其中有一個 Element[]理茎,Element 是對于 dex 的封裝靶庙。

****Amigo.java****

private void setDexElements(ClassLoader classLoader) throws NoSuchFieldException, IllegalAccessException {
   Object dexPathList = getPathList(classLoader);
   File[] listFiles = dexDir.listFiles();

   List<File> validDexes = new ArrayList<>();
   for (File listFile : listFiles) {
       if (listFile.getName().endsWith(".dex")) {
           validDexes.add(listFile);
       }
   }
   File[] dexes = validDexes.toArray(new File[validDexes.size()]);
   Object originDexElements = readField(dexPathList, "dexElements");
   Class<?> localClass = originDexElements.getClass().getComponentType();
   int length = dexes.length;
   Object dexElements = Array.newInstance(localClass, length);
   for (int k = 0; k < length; k++) {
       Array.set(dexElements, k, getElementWithDex(dexes[k], optimizedDir));
   }
   writeField(dexPathList, "dexElements", dexElements);
}

在替換dex時,Amigo 將補丁包中每個 dex 對應(yīng)的 Element 對象拿出來,之后組成新的 Element[]谱仪,通過反射,將現(xiàn)有的 Element[] 數(shù)組替換掉仔燕。
在 QZone 的實現(xiàn)方案中裂垦,他們是通過將新的 dex 插到 Element[] 數(shù)組的第一個位置,這樣就會先加載新的 dex 斯入,微信的方案是下發(fā)一個 DiffDex砂碉,然后在運行時與舊的 dex 合成一個新的 dex。但是 Amigo 是下發(fā)一個完整的 dex直接替換掉了原來的 dex刻两。與其他的方案相比增蹭,Amigo 因為直接替換原來的 dex ,兼容性更好,能夠支持修復(fù)的方面也更多磅摹。但是這也導(dǎo)致了 Amigo 的補丁包會較大滋迈,當然,也可以發(fā)一個利用 BsDiff 生成的差分包户誓,在本地合成新的 apk 之后再放到 Amigo 的指定目錄下饼灿。

替換動態(tài)鏈接庫

****Amigo.java****

private void setNativeLibraryDirectories(AmigoClassLoader hackClassLoader)
            throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException, NoSuchFieldException {
   injectSoAtFirst(hackClassLoader, nativeLibraryDir.getAbsolutePath());
   nativeLibraryDir.setReadOnly();
   File[] libs = nativeLibraryDir.listFiles();
   if (libs != null && libs.length > 0) {
       for (File lib : libs) {
           lib.setReadOnly();
       }
   }
}

so 文件的替換跟 QZone 替換 dex 原理相差不多,也是利用 ClassLoader 加載 library 的時候帝美,將新的 library 加到數(shù)組前面碍彭,保證先加載的是新的 library。但是這里會有幾個小坑证舟。

****DexUtils.java****

public static void injectSoAtFirst(ClassLoader hackClassLoader, String soPath) throws NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException, InstantiationException {
        Object[] baseDexElements = getNativeLibraryDirectories(hackClassLoader);
        Object newElement;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            Constructor constructor = baseDexElements[0].getClass().getConstructors()[0];
            constructor.setAccessible(true);
            Class<?>[] parameterTypes = constructor.getParameterTypes();
            Object[] args = new Object[parameterTypes.length];
            for (int i = 0; i < parameterTypes.length; i++) {
                if (parameterTypes[i] == File.class) {
                    args[i] = new File(soPath);
                } else if (parameterTypes[i] == boolean.class) {
                    args[i] = true;
                }
            }

            newElement = constructor.newInstance(args);
        } else {
            newElement = new File(soPath);
        }
        Object newDexElements = Array.newInstance(baseDexElements[0].getClass(), 1);
        Array.set(newDexElements, 0, newElement);
        Object allDexElements = combineArray(newDexElements, baseDexElements);
        Object pathList = getPathList(hackClassLoader);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            writeField(pathList, "nativeLibraryPathElements", allDexElements);
        } else {
            writeField(pathList, "nativeLibraryDirectories", allDexElements);
        }
    }

注入 so 文件到數(shù)組時硕旗,會發(fā)現(xiàn)在不同的版本上封裝 so 文件的是不同的類,在版本23以下女责,是File

****DexPathList.java****

/** list of native library directory elements */
private final File[] nativeLibraryDirectories;

在23以上卻是改成了Element

****DexPathList.java****

/** List of native library path elements. */
private final Element[] nativeLibraryPathElements;

因此在23以上漆枚,Amigo 通過反射去構(gòu)造一個 Element 對象。之后就是將 so 文件插到數(shù)組的第一個位置就行了抵知。
第二個小坑是nativeLibraryDir要設(shè)置成readOnly墙基。

****DexPathList.java****

public String findNativeLibrary(String name) {
   maybeInit();
   if (isDirectory) {
       String path = new File(dir, name).getPath();
       if (IoUtils.canOpenReadOnly(path)) {
           return path;
       }
   } else if (zipFile != null) {
       String entryName = new File(dir, name).getPath();
       if (isZipEntryExistsAndStored(zipFile, entryName)) {
         return zip.getPath() + zipSeparator + entryName;
       }
   }
   return null;
}

在ClassLoader 去尋找本地庫的時候软族,如果 so 文件沒有設(shè)置成ReadOnly的話是會不會返回路徑的,這樣就會報錯了残制。

替換資源文件

****Amigo.java****

...
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = getDeclaredMethod(AssetManager.class, "addAssetPath", String.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, demoAPk.getAbsolutePath());
setAPKResources(assetManager)
...

想要更新資源文件立砸,只需要更新Resource中的 AssetManager 字段。AssetManager提供了一個方法addAssetPath初茶。將新的資源文件路徑加到AssetManager中就可以了颗祝。在不同的 configuration 下,會對應(yīng)不同的 Resource 對象恼布,所以通過 ResourceManager 拿到所有的 configuration 對應(yīng)的 resource 然后替換其 assetManager螺戳。

替換原有 Application

****Amigo.java****

...
Class acd = classLoader.loadClass("me.ele.amigo.acd");
String applicationName = (String) readStaticField(acd, "n");
Application application = (Application) classLoader.loadClass(applicationName).newInstance();
Method attach = getDeclaredMethod(Application.class, "attach", Context.class);
attach.setAccessible(true);
attach.invoke(application, getBaseContext());
setAPKApplication(application);
application.onCreate();
...

在編譯過程中,Amigo 的插件將 app 的 application 替換成了 Amigo折汞,并且將原來的 application 的 name 保存在了一個名為acd的類中倔幼,該修復(fù)的都修復(fù)完了是時候?qū)⒃瓉淼?application 替換回來了。拿到原有 Application 名字之后先調(diào)用 application 的attach(context)爽待,然后將 application 設(shè)回到 loadedApk 中损同,最后調(diào)用oncreate(),執(zhí)行原有 Application 中的邏輯鸟款。
這之后膏燃,一個修復(fù)完的 app 就出現(xiàn)在用戶面前。優(yōu)秀的庫~

Amigo 插件

前文提到 Amigo 在編譯期利用插件替換了 app 原有的 application欠雌,那這一個操作是怎么實現(xiàn)的呢蹄梢?

****AmigoPlugin.groovy****

File manifestFile = output.processManifest.manifestOutputFile
                        def manifest = new XmlParser().parse(manifestFile)
                        def androidTag = new Namespace("http://schemas.android.com/apk/res/android", 'android')
                        applicationName = manifest.application[0].attribute(androidTag.name)
                        manifestFile.text = manifestFile.text.replace(applicationName, "me.ele.amigo.Amigo")

首先,Amigo Plugin 將 AndroidManifest.xml 文件中的applicationName 替換成 Amigo富俄。

****AmigoPlugin.groovy****

Node node = (new XmlParser()).parse(manifestFile)
Node appNode = null
for (Node n : node.children()) {
   if (n.name().equals("application")) {
       appNode = n;
       break
   }
}
Node hackAppNode = new Node(appNode, "activity")
hackAppNode.attributes().put("android:name", applicationName)
manifestFile.text = XmlUtil.serialize(node)

之后禁炒,Amigo Plugin 做了很 hack 的一步,就是在 AndroidManifest.xml 中將原來的 application 做為一個 Activity 霍比。我們知道 MultiDex 分包的規(guī)則中幕袱,一定會將 Activity 放到主 dex 中,Amigo Plugin 為了保證原來的 application 被替換后仍然在主 dex 中悠瞬,就做了這個十分 hack 的一步们豌。機智的少年。

接下來會再去判斷是否開啟了混淆浅妆,如果有混淆的話望迎,查找 mapping 文件,將 applicationName 字段換成混淆后的名字凌外。

下一步會去執(zhí)行 GenerateCodeTask辩尊,在這個 task 中會生成一個 Java 文件,這個文件就是上文提到過得acd.java康辑,并且將模板中的 appName 替換成applicationName摄欲。
然后執(zhí)行 javaCompile task轿亮,編譯 Java 代碼。
最后還要做一件事胸墙,就是修改 maindexlist.txt我注。被定義在這個文件中的類會被加到主 dex 中,所以 Amigo plugin 在collectMultiDexInfo方法中掃描加到主 dex 的類迟隅,然后再在掃描的結(jié)果中加上 acd.class但骨,把這些內(nèi)容全部加到 maindexlist.txt。到此Amigo plugin 的任務(wù)就完成了玻淑。
Amigo plugin 的主要目的是在編譯期用 amigo 替換掉原來的 application嗽冒,但是還得保存下來這個 application,因為之后還得在運行時將這個 application 替換回來补履。

總結(jié)
----
Amigo 幾乎實現(xiàn)了全方位的修復(fù),通過替換 ClassLoader剿另,直接全量替換 dex 的思路箫锤,保證了兼容性,成功率雨女,但是可能下發(fā)的補丁包會比較大谚攒。還有一點 Amigo 的精彩之處就是利用 Amigo 替換了 app 原有的 application,這一點保證了 Amigo 連 application 都能修復(fù)氛堕。以后可能唯一不能修復(fù)的就是 Amigo 自身了馏臭。

最后我們比較下目前幾個 hotfix 方案:

| Amigo | Tinker | nuwa/QZone | AndFix | Dexposed
---|------|---|---|----|---|
類替換 | yes | yes| yes |no| no
lib替換|yes | yes| no | no | no
資源替換|yes|yes|yes|no|no
全平臺支持|yes|yes|yes|yes|no
即時生效|optional|no|no|yes|yes
性能損耗|無|較小|較大|較小|較小
補丁包大小|較大|較小|較大|一般|一般
開發(fā)透明|yes|yes|yes|no|no
復(fù)雜度|無|較低|較低|復(fù)雜|復(fù)雜
gradle支持|yes|yes|yes|no|no
接口文檔|豐富|豐富|一般|一般|較少
占Rom體積|較大|較大|較小|較小|較小
成功率|100%|較好|很高|一般|一般

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市讼稚,隨后出現(xiàn)的幾起案子括儒,更是在濱河造成了極大的恐慌,老刑警劉巖锐想,帶你破解...
    沈念sama閱讀 218,036評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件帮寻,死亡現(xiàn)場離奇詭異,居然都是意外死亡赠摇,警方通過查閱死者的電腦和手機固逗,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,046評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來藕帜,“玉大人烫罩,你說我怎么就攤上這事∏⒐剩” “怎么了贝攒?”我有些...
    開封第一講書人閱讀 164,411評論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長收津。 經(jīng)常有香客問我饿这,道長浊伙,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,622評論 1 293
  • 正文 為了忘掉前任长捧,我火速辦了婚禮嚣鄙,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘串结。我一直安慰自己哑子,他們只是感情好,可當我...
    茶點故事閱讀 67,661評論 6 392
  • 文/花漫 我一把揭開白布肌割。 她就那樣靜靜地躺著卧蜓,像睡著了一般。 火紅的嫁衣襯著肌膚如雪把敞。 梳的紋絲不亂的頭發(fā)上弥奸,一...
    開封第一講書人閱讀 51,521評論 1 304
  • 那天,我揣著相機與錄音奋早,去河邊找鬼盛霎。 笑死,一個胖子當著我的面吹牛耽装,可吹牛的內(nèi)容都是我干的愤炸。 我是一名探鬼主播,決...
    沈念sama閱讀 40,288評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼掉奄,長吁一口氣:“原來是場噩夢啊……” “哼规个!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起姓建,我...
    開封第一講書人閱讀 39,200評論 0 276
  • 序言:老撾萬榮一對情侶失蹤诞仓,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后引瀑,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體狂芋,經(jīng)...
    沈念sama閱讀 45,644評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,837評論 3 336
  • 正文 我和宋清朗相戀三年憨栽,在試婚紗的時候發(fā)現(xiàn)自己被綠了帜矾。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,953評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡屑柔,死狀恐怖屡萤,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情掸宛,我是刑警寧澤死陆,帶...
    沈念sama閱讀 35,673評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響措译,放射性物質(zhì)發(fā)生泄漏别凤。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,281評論 3 329
  • 文/蒙蒙 一领虹、第九天 我趴在偏房一處隱蔽的房頂上張望规哪。 院中可真熱鬧,春花似錦塌衰、人聲如沸诉稍。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,889評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽杯巨。三九已至,卻和暖如春努酸,著一層夾襖步出監(jiān)牢的瞬間服爷,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,011評論 1 269
  • 我被黑心中介騙來泰國打工获诈, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留层扶,地道東北人。 一個月前我還...
    沈念sama閱讀 48,119評論 3 370
  • 正文 我出身青樓烙荷,卻偏偏與公主長得像,于是被迫代替她去往敵國和親檬寂。 傳聞我的和親對象是個殘疾皇子终抽,可洞房花燭夜當晚...
    茶點故事閱讀 44,901評論 2 355

推薦閱讀更多精彩內(nèi)容