插件化之VirtualApk實(shí)戰(zhàn)一:項(xiàng)目配置

(demo地址)

零谊囚、 介紹一下

VirtualApk是滴滴開源的一套插件化方案,其支持四大組件提澎,支持插件宿主之間的交互,兼容性強(qiáng)念链,在滴滴出行APP中有應(yīng)用盼忌。下面是官方文檔中與其他主流插件化框架的對(duì)比(查看原文):

特性 DynamicLoadApk DynamicAPK Small DroidPlugin VirtualAPK
支持四大組件 只支持Activity 只支持Activity 只支持Activity 全支持 全支持
組件無需在宿主manifest中預(yù)注冊(cè) ×
插件可以依賴宿主 ×
支持PendingIntent × × ×
Android特性支持 大部分 大部分 大部分 幾乎全部 幾乎全部
兼容性適配 一般 一般 中等
插件構(gòu)建 部署aapt Gradle插件 Gradle插件

一、配置

1.1 接入主程序

  1. 添加gradle依賴
    在根目錄build.gradle中添加插件
 buildscript {
     dependencies {
         ...
         classpath 'com.didi.virtualapk:gradle:0.9.8.6'
         ...
     }
 }
  1. 引入插件
    在app模塊的build.gradle中添加
    apply plugin: 'com.didi.virtualapk.host'

  2. 添加依賴
    在app模塊的build.gradle中的dependencies中加入
    implementation 'com.didi.virtualapk:core:0.9.8'

  3. 初始化SDK
    選擇一個(gè)合適的時(shí)機(jī)初始化SDK掂墓,一般是在項(xiàng)目的Application類的attachBaseContext方法中完成谦纱。

   override fun attachBaseContext(base: Context?) {
       super.attachBaseContext(base)
       PluginManager.getInstance(base).init()
   }

1.2 接入插件模塊

  1. 添加gradle依賴
    同上面接入主程序環(huán)節(jié)第一步配置,如果插件模塊和主程序在同一個(gè)項(xiàng)目中則可以忽略

  2. 引入插件
    在插件模塊的build.gradle中添加apply plugin: 'com.didi.virtualapk.plugin'
    注意的是:插件模塊也是一個(gè)應(yīng)用項(xiàng)目而非庫項(xiàng)目君编,即apply plugin: 'com.android.application'而不是apply plugin: 'com.android.library'

  3. 聲明插件配置
    在插件模塊的build.gradle底部聲明virtualApk配置

    virtualApk {
        packageId = 0x6f // 資源前綴.
        targetHost = '../app' // 宿主模塊的文件路徑,生成插件會(huì)檢查依賴項(xiàng)跨嘉,分析和排除與宿主APP的共同依賴.
        applyHostMapping = true //optional, default value: true.
    }
    

    其中packageId是資源id的前綴,用來區(qū)分插件資源吃嘿,所以插件之間要使用不同的前綴祠乃。
    這個(gè)前綴不一定要0x6f梦重,正常我們的APP編譯出來的R文件一般像下面這種,可以看出前綴是0x7f亮瓷,理論上這個(gè)packageId的取值范圍應(yīng)為[0x00,0x7f)琴拧,然而0x010x02等等已經(jīng)被系統(tǒng)應(yīng)用占用嘱支,具體占用多少不得而知艾蓝,因此盡量選擇偏大且足夠分配給所有插件使用的數(shù)字。

    public final class R {
        public static final class anim {
            public static final int abc_fade_in=0x7f010000;
            public static final int abc_fade_out=0x7f010001;
            public static final int abc_grow_fade_in_from_bottom=0x7f010002;
        }
    }
    

關(guān)于packageId的官方說明

到這里就已經(jīng)完成了VirtualApk的宿主以及插件模塊的配置斗塘,非常簡(jiǎn)單赢织,可以看出對(duì)我們現(xiàn)有的工程完全幾乎不需要修改,我們依然可以用我們習(xí)慣的模塊化的開發(fā)方式馍盟。

截止發(fā)稿時(shí)的最新版本是0.9.8.6于置,建議大家盡量使用最新版本,畢竟安卓的碎片化這么嚴(yán)重贞岭,而且hook方案多少會(huì)有些不完美的地方八毯,相信滴滴以及gayhub的基友們會(huì)在新版本不停的完善它,而且老版本很可能不會(huì)維護(hù)瞄桨。
一般從官方GitHub項(xiàng)目的releases可以找到當(dāng)前最新版本话速。

這里給大家安利一個(gè)maven構(gòu)件搜索網(wǎng)站https://mvnrepository.com/,在這里可以搜索主流maven倉庫中的構(gòu)件芯侥,比如這里的VirtualApk泊交,可以很方便的查看版本,以及生成maven柱查、gradle等構(gòu)建工具的引用語法廓俭。

二、應(yīng)用

這里以一個(gè)比較典型的場(chǎng)景:宿主APP啟動(dòng)插件中的Activity為例唉工。

2.1 編寫插件

插件模塊和平常的模塊開發(fā)完全一樣研乒,完全感知不到是在開發(fā)一個(gè)插件,因此現(xiàn)有工程的模塊也可以相對(duì)比較容易的轉(zhuǎn)換成插件淋硝。

  1. 新建一個(gè)應(yīng)用模塊pluginA雹熬,按上面的提到的配置方法配好gradle,注意是apply plugin: 'com.android.application'

  2. 取一個(gè)唯一的applicationId谣膳,這里以applicationId "com.huangmb.plugin.a"為例竿报。

  3. 新建一個(gè)Activity,為簡(jiǎn)單起見這里直接選了Studio內(nèi)置的滾動(dòng)視圖模版com.huangmb.plugin.a.ScrollingActivity

    因?yàn)楸旧硎且粋€(gè)應(yīng)用模塊参歹,因此你也可以直接運(yùn)行這個(gè)模塊仰楚,會(huì)看到下面這個(gè)熟悉的界面。


    ScrollingActivity

    這種直接運(yùn)行的方式非常方便我們開發(fā)調(diào)試插件,但這不是我們的最終目的僧界,我們要把它變成一個(gè)插件侨嘀。

  4. 生成插件
    生成插件非常簡(jiǎn)單,運(yùn)行命令./gradlew assemblePlugin或雙擊gradle面板的assemblePlugin即可捂襟。

    gradle命令

    在實(shí)踐中多次遇到過生成的插件運(yùn)行時(shí)閃退咬腕,主要出在id前綴的問題上,這里建議大家在assemble之前最好先clean一遍葬荷。

    運(yùn)行后將會(huì)在build/outputs/plugin/release文件夾能找到生成的插件包涨共,文件名格式一般是"{applicationId}_yyyyMMddHHmmss.apk"。我沒找到配置輸出文件名的地方宠漩,我個(gè)人更傾向于一個(gè)固定的文件名举反,這種動(dòng)態(tài)文件名會(huì)導(dǎo)致每編譯一次就增加一個(gè)文件。

  5. 安裝插件
    安裝插件本質(zhì)上是把插件apk放置到一個(gè)宿主插件能訪問到文件路徑下以便宿主加載扒吁。這里演示為主火鼻,不去設(shè)計(jì)安裝插件的邏輯了,直接把插件重命名為pluginA.apk雕崩,通過Android Studio的Device Explorer工具復(fù)制到宿主應(yīng)用文件夾下魁索,即Android/data/{app_applicationId}/cache。等下宿主APP會(huì)從這個(gè)目錄下讀取插件盼铁。

2.2 宿主APP部分

宿主APP要做的事情很簡(jiǎn)單粗蔚,就是一個(gè)按鈕,在其點(diǎn)擊事件中啟動(dòng)pluginA.apk中的ScrollingActivity饶火。

  1. 根據(jù)前面第一部分1.1節(jié)完成宿主上的插件初始化鹏控。

  2. 加載插件
    一定要確保在啟動(dòng)插件代碼之前的某個(gè)時(shí)機(jī)先加載插件(不然哪有插件的代碼),比如在Application的onCreate中(適合已知插件位置的情況趁窃,比如內(nèi)置插件或者已安裝插件)牧挣,或者在執(zhí)行插件代碼前動(dòng)態(tài)加載。
    為了方便后面的代碼醒陆,這里定義了三個(gè)常量,分別是插件文件名裆针、插件包名和插件的Activity類名刨摩。

      private const val PLUGIN_NAME = "pluginA.apk"
      private const val PLUGIN_PKG = "com.huangmb.plugin.a"
      private const val PLUGIN_ACTIVITY = "com.huangmb.plugin.a.ScrollingActivity"
    

    加載插件的方式為

    val apk = File(externalCacheDir, PLUGIN_NAME)
    PluginManager.getInstance(this).loadPlugin(apk)
    

    在VirtualApk中,插件不允許重復(fù)加載世吨,因此可以封裝一下插件加載方法澡刹,在加載插件前檢驗(yàn)一下插件加載情況

      //檢測(cè)是否已經(jīng)安裝了插件,未安裝則通過loadPlugin安裝
      private fun checkPlugin(): Boolean {
         PluginManager.getInstance(this).getLoadedPlugin(PLUGIN_PKG) ?: return loadPlugin()
         return true
      }
      private fun loadPlugin(): Boolean {
         val apk = File(externalCacheDir, PLUGIN_NAME)
         if (apk.exists()) {
             //加載插件
             val manager = PluginManager.getInstance(this)
             manager.loadPlugin(apk)
             PluginUtil.hookActivityResources(this, PLUGIN_PKG)
             return true
         }
         //插件不存在
         return false
    
     }
    

    在調(diào)用插件代碼前可以先調(diào)用一下checkPlugin方法耘婚,正常加載了插件時(shí)返回true罢浇,否則返回falsegetLoadedPlugin方法會(huì)返回一個(gè)LoadedPlugin對(duì)象,這是一個(gè)很有用的對(duì)象嚷闭,宿主APP要獲取插件中的AndroidManifest信息就通過它攒岛,這個(gè)方法如果返回null則表明插件未安裝。

  3. 跳轉(zhuǎn)插件Activity
    跳轉(zhuǎn)插件Activity也是通過Intent跳轉(zhuǎn)胞锰,不過這里通過插件包名和Activity類名啟動(dòng)灾锯,因?yàn)橐话闼拗黜?xiàng)目不會(huì)依賴插件,這里沒法直接引用到ScrollingActivity.class嗅榕。

   val i = Intent()
   i.setClassName(PLUGIN_PKG, PLUGIN_ACTIVITY)
   startActivity(i)

這就完成了一次插件化實(shí)踐顺饮,來看一下運(yùn)行效果:


運(yùn)行效果

完美

三、原理

上面的的示例中凌那,我們并沒有在宿主的AndroidManifest中注冊(cè)ScrollingActivity兼雄,但是仍然可以通過startActivity來啟動(dòng)它。

這里簡(jiǎn)單介紹下Activity插件化的原理帽蝶,有時(shí)間再單獨(dú)開一篇介紹一下四大組件的插件原理君旦。

實(shí)際上,VirtualApk通過hook了一下系統(tǒng)API嘲碱,模擬了Activity的生命周期金砍。通過PluginManager源碼中我們可以看到這樣的代碼,通過反射替換了系統(tǒng)的Instrument麦锯。

   protected void hookInstrumentationAndHandler() {
        try {
            ActivityThread activityThread = ActivityThread.currentActivityThread();
            Instrumentation baseInstrumentation = activityThread.getInstrumentation();
    
            final VAInstrumentation instrumentation = createInstrumentation(baseInstrumentation);
            Reflector.with(activityThread).field("mInstrumentation").set(instrumentation);
            Handler mainHandler = Reflector.with(activityThread).method("getHandler").call();
            Reflector.with(mainHandler).field("mCallback").set(instrumentation);
            this.mInstrumentation = instrumentation;
            Log.d(TAG, "hookInstrumentationAndHandler succeed : " + mInstrumentation);
        } catch (Exception e) {
            Log.w(TAG, e);
        }
    }

Instrument在自動(dòng)化測(cè)試中我們經(jīng)常見過它的身影恕稠,比如這段單元測(cè)試,通過Instrument啟動(dòng)了Activity扶欣,模擬了一個(gè)Activity運(yùn)行環(huán)境鹅巍。

   Intent intent = new Intent();
        intent.setClassName("com.sample", Sample.class.getName());
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        sample = (Sample) getInstrumentation().startActivitySync(intent);
        text = (TextView) sample.findViewById(R.id.text1);
        button = (Button) sample.findViewById(R.id.button1);

VirtualApk也是基于這個(gè)原理,通過一個(gè)自定義的VAInstrumentation料祠,重載了各個(gè)execStartActivity方法骆捧,將啟動(dòng)插件Activity的Intent做了一些識(shí)別和標(biāo)記,即injectIntent方法髓绽,

  public ActivityResult execStartActivity(Context who, IBinder contextThread, IBinder token, String target, Intent intent, int requestCode, Bundle options) {
        injectIntent(intent);
        return mBase.execStartActivity(who, contextThread, token, target, intent, requestCode, options);
    }
    
    protected void injectIntent(Intent intent) {
        mPluginManager.getComponentsHandler().transformIntentToExplicitAsNeeded(intent);
        // null component is an implicitly intent
        if (intent.getComponent() != null) {
            Log.i(TAG, String.format("execStartActivity[%s : %s]", intent.getComponent().getPackageName(), intent.getComponent().getClassName()));
            // resolve intent with Stub Activity if needed
            this.mPluginManager.getComponentsHandler().markIntentIfNeeded(intent);
        }
    }

并在newActivity方法中做了從插件中加載Activity的邏輯敛苇,在injectActivity方法中通過反射替換了插件Activity中的resources對(duì)象,替換的Resources對(duì)象來自于LoadedPlugin的createResources方法顺呕,將插件安裝包文件夾加入到AssetManager路徑中:

  protected Resources createResources(Context context, String packageName, File apk) throws Exception {
        if (Constants.COMBINE_RESOURCES) {
            return ResourcesManager.createResources(context, packageName, apk);
        } else {
            Resources hostResources = context.getResources();
            AssetManager assetManager = createAssetManager(context, apk);
            return new Resources(assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration());
        }
    }

這樣插件Activity中的getResources.getXXX方法就能從插件中讀取資源了枫攀。
整體思路和Activity的自動(dòng)化測(cè)試差不多。

四株茶、總結(jié)

引入VirtualApk總體還是比較容易的来涨,對(duì)項(xiàng)目的侵入性較小,尤其是插件工程和普通的應(yīng)用工程開發(fā)基本一樣启盛,現(xiàn)有的模塊做一下必要的調(diào)整和業(yè)務(wù)隔離蹦掐,可以比較容易的轉(zhuǎn)換成插件技羔,遷移成本較小。對(duì)插件開發(fā)者來說卧抗,一個(gè)插件就是一個(gè)獨(dú)立的單體應(yīng)用藤滥,這樣有利于進(jìn)行獨(dú)立的開發(fā)測(cè)試,較少開發(fā)環(huán)境的干擾颗味,最后和宿主進(jìn)行聯(lián)調(diào)一下就好了超陆。

當(dāng)然大部分業(yè)務(wù)場(chǎng)景下,插件都很難是完全獨(dú)立的浦马,并不能像上面的demo一樣时呀,一個(gè)按鈕,啟動(dòng)一個(gè)Activity就萬事大吉了晶默。很多時(shí)候谨娜,我們需要通過一定的擴(kuò)展接口邏輯來注入插件,而且插件與插件之間以及插件和宿主之間可能存在一些交互磺陡。這一點(diǎn)趴梢,VirtualApk還有一些高級(jí)玩法可以為這些場(chǎng)景做支撐,比如宿主插件依賴項(xiàng)去重功能币他,可以讓插件依賴一個(gè)由宿主提供的SDK坞靶,而不編譯到最終插件中,這樣插件能通過宿主提供的接口進(jìn)行交互蝴悉。有時(shí)間后面再進(jìn)一步解鎖更多玩法和大家分享一下彰阴。

五、問題

下面整理了下開發(fā)demo過程中遇到的一些問題以及解決方法拍冠。歡迎大家在留言中分享平時(shí)遇到的坑和解決方案尿这。也可以去官方issues提問和解答。

  • 編譯失敗
[INFO][VAPlugin] Evaluating VirtualApk's configurations...

FAILURE: Build failed with an exception.

* What went wrong:
A problem occurred configuring project ':plugina'.
> Failed to notify project evaluation listener.
   > Can't using incremental dexing mode, please add 'android.useDexArchive=false' in gradle.properties of :plugina.
   > Cannot invoke method onProjectAfterEvaluate() on null object

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

解決:新建gradle.properties文件并加入配置android.useDexArchive=false

  • 編譯失敗
FAILURE: Build failed with an exception.

* What went wrong:
Failed to notify task execution listener.
> The dependencies [com.android.support:design:28.0.0, com.android.support:recyclerview-v7:28.0.0, com.android.support:transition:28.0.0, com.android.support:cardview-v7:28.0.0] that will be used in the current plugin must be included in the host app first. Please add it in the host app as well.

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

解決:出現(xiàn)這個(gè)問題是因?yàn)椴寮こ讨幸玫膁esign庫而宿主中沒有庆杜,需要將com.android.support:design:28.0.0加入到宿主APP中并對(duì)宿主APP進(jìn)行assembleRelease射众。這里有一些疑惑,VirtualApk不是支持在插件中單獨(dú)引入依賴的么晃财,難道support包比較特殊?

  • 編譯失敗
FAILURE: Build failed with an exception.

* What went wrong:
Failed to notify task execution listener.
> com/android/build/gradle/internal/scope/TaskOutputHolder$TaskOutputType

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

解決: 可能gradle插件版本過高叨橱,VirtualApk的構(gòu)建原理與gradle插件強(qiáng)依賴,建議使用官方demo工程使用的gradle插件版本拓劝,這里降至3.0.0 就ok了雏逾。classpath 'com.android.tools.build:gradle:3.0.0'

  • 插件未簽名
Caused by: android.content.pm.PackageParser$PackageParserException: Package /storage/emulated/0/Android/data/com.huangmb.virtualapkdemo/cache/pluginA.apk has no certificates at entry AndroidManifest.xml

解決:插件必須有正式簽名。

signingConfigs {
    release {
        storeFile file("...")
        storePassword "..."
        keyAlias "..."
        keyPassword "..."
    }
}
buildTypes {
    release {
        ...
        signingConfig signingConfigs.release
        ...
    }
}
  • 重復(fù)加載插件
java.lang.RuntimeException: plugin has already been loaded : xxx
        at com.didi.virtualapk.internal.LoadedPlugin.<init>(LoadedPlugin.java:172)
        at com.didi.virtualapk.PluginManager.createLoadedPlugin(PluginManager.java:177)
        at com.didi.virtualapk.PluginManager.loadPlugin(PluginManager.java:318)

解決:同一個(gè)插件只能加載一次郑临,可以在加載某個(gè)插件前校驗(yàn)一遍是否已加載過。

val hasLoaded = PluginManager.getInstance(this).getLoadedPlugin(PLUGIN_PKG) != null

其中PLUGIN_PKG是待校驗(yàn)的插件包名屑宠,也就是gradle中的applicationId(可能和AndroidManifest中的package不一樣)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末厢洞,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌躺翻,老刑警劉巖丧叽,帶你破解...
    沈念sama閱讀 218,122評(píng)論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異公你,居然都是意外死亡踊淳,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,070評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門陕靠,熙熙樓的掌柜王于貴愁眉苦臉地迎上來迂尝,“玉大人,你說我怎么就攤上這事剪芥÷⒖” “怎么了?”我有些...
    開封第一講書人閱讀 164,491評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵税肪,是天一觀的道長(zhǎng)溉躲。 經(jīng)常有香客問我,道長(zhǎng)益兄,這世上最難降的妖魔是什么锻梳? 我笑而不...
    開封第一講書人閱讀 58,636評(píng)論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮净捅,結(jié)果婚禮上疑枯,老公的妹妹穿的比我還像新娘。我一直安慰自己灸叼,他們只是感情好神汹,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,676評(píng)論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著古今,像睡著了一般屁魏。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上捉腥,一...
    開封第一講書人閱讀 51,541評(píng)論 1 305
  • 那天氓拼,我揣著相機(jī)與錄音,去河邊找鬼抵碟。 笑死桃漾,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的拟逮。 我是一名探鬼主播撬统,決...
    沈念sama閱讀 40,292評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼敦迄!你這毒婦竟也來了恋追?” 一聲冷哼從身側(cè)響起凭迹,我...
    開封第一講書人閱讀 39,211評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎苦囱,沒想到半個(gè)月后嗅绸,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,655評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡撕彤,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,846評(píng)論 3 336
  • 正文 我和宋清朗相戀三年鱼鸠,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片羹铅。...
    茶點(diǎn)故事閱讀 39,965評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡蚀狰,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出睦裳,到底是詐尸還是另有隱情造锅,我是刑警寧澤,帶...
    沈念sama閱讀 35,684評(píng)論 5 347
  • 正文 年R本政府宣布廉邑,位于F島的核電站哥蔚,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏蛛蒙。R本人自食惡果不足惜糙箍,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,295評(píng)論 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望牵祟。 院中可真熱鬧深夯,春花似錦、人聲如沸诺苹。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,894評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽收奔。三九已至掌呜,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間坪哄,已是汗流浹背质蕉。 一陣腳步聲響...
    開封第一講書人閱讀 33,012評(píng)論 1 269
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留翩肌,地道東北人模暗。 一個(gè)月前我還...
    沈念sama閱讀 48,126評(píng)論 3 370
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像念祭,于是被迫代替她去往敵國(guó)和親兑宇。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,914評(píng)論 2 355

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