KAE 將被正式移除,如何無縫遷移很洋?

前言

KAE 插件早在 2020 年就已經(jīng)被宣布廢棄了,并且將在 Kotlin 1.8 中被正式移除:Discontinuing Kotlin synthetics for views

如上圖示隧枫,移除 KAE 的代碼已經(jīng)被 Merge 了喉磁,因此如果我們需要升級(jí)到 Kotlin 1.8,則必須要移除 KAE

那么移除 KAE 后官脓,我們?cè)撊绾芜w移呢协怒?

遷移方案

官方的遷移方案如上所示,官方建議我們老項(xiàng)目遷移到 ViewBinding确买,老項(xiàng)目直接遷移到 Jetpack Compose

對(duì)于新代碼我們當(dāng)然可以這么做斤讥,但是對(duì)于大量存量代碼纱皆,我們?cè)撊绾芜w移湾趾?由于 KAE 簡單易用的特性芭商,它在項(xiàng)目中經(jīng)常被大量使用,要遷移如此多的存量代碼搀缠,并不是一個(gè)簡單的工作

存量代碼遷移方案

KAE 存量代碼主要有如圖3種遷移方式

最簡單也最直接的當(dāng)然就是直接手動(dòng)修改铛楣,這種方式的問題在于要遷移的代碼數(shù)量龐大,遷移成本高艺普。同時(shí)手動(dòng)遷移容易出錯(cuò)簸州,也不容易回測,測試不能覆蓋到所有的頁面歧譬,導(dǎo)致引入線上 bug

第二個(gè)方案岸浑,是把 KAE 直接從 Kotlin 源碼中抽取出來單獨(dú)維護(hù),但是 KAE 中也大量依賴了 Kotlin 的源碼瑰步,抽取成本較高矢洲。同時(shí) KAE 中大量使用了 Kotlin 編譯器插件的 API,而這部分 API 并沒有穩(wěn)定缩焦,當(dāng) K2 編譯器正式發(fā)布的時(shí)候很可能還會(huì)有較大的改動(dòng)读虏,而這也帶來較高的維護(hù)成本。

第三個(gè)方案就是本篇要重點(diǎn)介紹的 Kace

Kace 是什么袁滥?

Kace 即 kotlin-android-compatible-extensions盖桥,一個(gè)用于幫助從 kotlin-android-extensions 無縫遷移的框架

目前已經(jīng)開源,開源地址可見:github.com/kanyun-inc/…

相比其它方案题翻,Kace 主要有以下優(yōu)點(diǎn)

  1. 接入方便揩徊,不需要手動(dòng)修改舊代碼,可以真正做到無縫遷移
  2. 與 KAE 表現(xiàn)一致(都支持 viewId 緩存嵌赠,并在頁面銷毀時(shí)清除)靴拱,不會(huì)引入預(yù)期外的 bug
  3. 統(tǒng)一遷移,回測方便猾普,如果存在問題時(shí)袜炕,應(yīng)該是批量存在的,避免手動(dòng)修改可能引入線上 bug 的問題
  4. 通過生成源碼的方式兼容 KAE初家,維護(hù)成本低

快速遷移

使用 Kace 完成遷移主要分為以下幾步

1. 添加插件到 classpath

// 方式 1
// 傳統(tǒng)方式偎窘,在根目錄的 build.gradle.kts 中添加以下代碼
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("com.kanyun.kace:kace-gradle-plugin:1.0.0")
    }
}

// 方式 2
// 引用插件新方式,在 settings.gradle.kts 中添加以下代碼
pluginManagement {
    repositories {
        mavenCentral()
    }
    plugins {
        id("com.kanyun.kace") version "1.0.0" apply false
    }
}

2. 應(yīng)用插件

移除kotlin-android-extensions插件溜在,并添加以下代碼

plugins {
    id("com.kanyun.kace")
    id("kotlin-parcelize") // 可選陌知,當(dāng)使用了`@Parcelize`注解時(shí)需要添加
}

3. 配置插件(可選)

默認(rèn)情況下 Kace 會(huì)解析模塊內(nèi)的每個(gè) layout 并生成代碼,用戶也可以自定義需要解析的 layout

kace {
    whiteList = listOf() // 當(dāng) whiteList 不為空時(shí)掖肋,只有 whiteList 中的 layout 才會(huì)被解析
    blackList = listOf("activity_main.xml") // 當(dāng) blackList 不為空時(shí)仆葡,blackList 中的 layout 不會(huì)被解析
}

經(jīng)過以上幾步,遷移就完全啦~

支持的類型

如上所示,Kace 目前支持了以上四種最常用的類型沿盅,其他 kotlin-android-extensions 支持的類型如 android.app.Fragment, android.app.Dialog, kotlinx.android.extensions.LayoutContainer 等把篓,由于被廢棄或者使用較少,Kace 目前沒有做支持

版本兼容

Kotlin AGP Gradle
最低支持版本 1.7.0 4.2.0 6.7.1

由于 Kace 的目標(biāo)是幫助開發(fā)者更方便地遷移到 Kotlin 1.8腰涧,因此 Kotlin 最低支持版本比較高

原理解析:前置知識(shí)

編譯器插件是什么韧掩?

Kotlin 的編譯過程,簡單來說就是將 Kotlin 源代碼編譯成目標(biāo)產(chǎn)物的過程窖铡,具體步驟如下圖所示:

Kotlin 編譯器插件疗锐,通過利用編譯過程中提供的各種Hook時(shí)機(jī),讓我們可以在編譯過程中插入自己的邏輯费彼,以達(dá)到修改編譯產(chǎn)物的目的滑臊。比如我們可以通過 IrGenerationExtension 來修改 IR 的生成煎源,可以通過 ClassBuilderInterceptorExtension 修改字節(jié)碼生成邏輯

Kotlin 編譯器插件可以分為 Gradle 插件涧偷,編譯器插件凛驮,IDE 插件三部分瓦哎,如下圖所示

kotlin-android-extensions 是怎么實(shí)現(xiàn)的

我們知道摔敛,KAE 是一個(gè) Kotlin 編譯器插件趴酣,當(dāng)然也可以分為 Gradle 插件饱苟,編譯器插件腊凶,IDE 插件三部分芙粱。我們這里只分析 Gradle 插件與編譯器插件的源碼祭玉,它們的具體結(jié)構(gòu)如下:

  1. AndroidExtensionsSubpluginIndicatorKAE插件的入口
  2. AndroidSubplugin用于配置傳遞給編譯器插件的參數(shù)
  3. AndroidCommandLineProcessor用于接收編譯器插件的參數(shù)
  4. AndroidComponentRegistrar用于注冊(cè)如圖的各種Extension

關(guān)于更細(xì)節(jié)的分析可以參閱:kotlin-android-extensions 插件到底是怎么實(shí)現(xiàn)的?

總的來說春畔,其實(shí) KAE 主要做了兩件事

  1. KAE 會(huì)將 viewId 轉(zhuǎn)化為 findViewByIdCached 方法調(diào)用
  2. KAE 會(huì)在頁面關(guān)閉時(shí)清除 viewId cache

那么我們要無縫遷移脱货,就也要實(shí)現(xiàn)相同的效果

Kace 原理解析

第一次嘗試

我們首先想到的是解析 layout 自動(dòng)生成擴(kuò)展屬性,如下圖所示

// 生成的代碼
val AndroidExtensions.button1
    get() = findViewByIdCached<Button>(R.id.button1)

val AndroidExtensions.buttion2
    get() = findViewByIdCached(R.id.button1)

// 給 Activity 添加 AndroidExtensions 接口
class MainActivity : AppCompatActivity(), AndroidExtensions {
    private val androidExtensionImpl by lazy { AndroidExtensionsImpl() }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycle.addObserver(androidExtensionImpl)
    }

    override fun <T : View?> findViewByIdCached(owner: AndroidExtensionsBase, id: Int): T {
        return androidExtensionImpl.findViewByIdCached(id)
    }
}

如上所示律姨,主要做了這么幾件事:

  1. 通過 gradle 插件振峻,自動(dòng)解析 layout 生成AndroidExtensions接口的擴(kuò)展屬性
  2. 給 Activity 添加 AndroidExtensions 接口
  3. 由于需要支持緩存,因此也需要添加一個(gè)全局的變量:androidExtensionImpl
  4. 由于需要在頁面關(guān)閉時(shí)清除緩存择份,因此也需要添加lifecycle Observer
  5. 重寫findViewByIdCached方法扣孟,將具體工作委托給AndroidExtensionsImpl

通過以上步驟,其實(shí) KAE 的功能已經(jīng)實(shí)現(xiàn)了荣赶,我們可以在 Activity 中通過button1凤价,button2等 viewId 獲取對(duì)應(yīng)的 View

但是這樣還是太麻煩了,修改一個(gè)頁面需要添加這么多代碼拔创,還能再優(yōu)化嗎利诺?

第二次嘗試

private inline val AndroidExtensions.button1
    get() = findViewByIdCached<Button>(this, R.id.button1)

val AndroidExtensions.buttion2
    get() = findViewByIdCached(this, R.id.button1)

class MainActivity : AppCompatActivity(), AndroidExtensions by AndroidExtensionsImpl() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }
}
  1. 我們通過委托簡化了代碼調(diào)用,只需要添加一行AndroidExtensions by AndroidExtensionsImpl()就可以實(shí)現(xiàn)遷移
  2. 我們不需要在初始化的時(shí)候手動(dòng)添加lifecycle observer剩燥,這是因?yàn)槲覀冊(cè)谡{(diào)用findViewByIdCached方法時(shí)會(huì)將this傳遞過去慢逾,因此可以在第一次調(diào)用時(shí)初始化,自動(dòng)添加lifecycle observer

可以看出,現(xiàn)在已經(jīng)比較簡潔了侣滩,只需要添加一行代碼就可以實(shí)現(xiàn)遷移口注,但如果項(xiàng)目中有幾百個(gè)頁面使用了 KAE 的話,改起來還是有點(diǎn)痛苦的胜卤,目前還不能算是真正的無縫遷移

那么還能再優(yōu)化嗎?

第三次嘗試

第3次嘗試就是 Kace 的最終方案疆导,結(jié)構(gòu)如圖所示

下面我們就來介紹一下

kace-compiler 實(shí)現(xiàn)

kace-compiler 是一個(gè) Kotlin 編譯器插件赁项,它的作用是給目標(biāo)類型(Activity 或者 Fragment)自動(dòng)添加接口與實(shí)現(xiàn)

如上所示葛躏,kace-compiler 的作用就是通過KaceSyntheticResolveExtension擴(kuò)展添加接口,以及KaceIrGenerationExtension擴(kuò)展添加實(shí)現(xiàn)

處理后的代碼如下所示:

class MainActivity : AppCompatActivity(), AndroidExtensions {
    private val $$androidExtensionImpl by lazy { AndroidExtensionsImpl() }

    override fun <T : View?> findViewByIdCached(owner: AndroidExtensionsBase, id: Int): T {
        return $$androidExtensionImpl.findViewByIdCached(id)
    }
}

你可能還記得悠菜,前面說過由于編譯器插件 API 還沒有穩(wěn)定舰攒,因此將 KAE 抽取出來獨(dú)立維護(hù)成本較高,那么我們這里為什么還使用了編譯器插件呢悔醋?

這是因?yàn)槲覀冞@里使用的編譯器插件是比較少的摩窃,生成的代碼也很簡單,將來維護(hù)起來并不復(fù)雜芬骄,但是可以大幅的降低遷移成本猾愿,實(shí)現(xiàn)真正的無縫遷移

kace-gradle-plugin 生成代碼

kace-gradle-plugin 的主要作用就是解析 layout 然后生成代碼,生成的代碼如下所示

package kotlinx.android.synthetic.debug.activity_main

private inline val AndroidExtensionsBase.button1
    get() = findViewByIdCached<android.widget.Button>(this, R.id.button1)
internal inline val Activity.button1
    get() = (this as AndroidExtensionsBase).button1
internal inline val Fragment.button1
    get() = (this as AndroidExtensionsBase).button1
package kotlinx.android.synthetic.main.activity_main.view

internal inline val View.button1
    get() = findViewById<android.widget.Button>(R.id.button1)
  1. 給 Activity, Fragment, View 等類型添加擴(kuò)展屬性
  2. 給 View 添加的擴(kuò)展屬性目前不支持緩存账阻,而是直接通過finidViewById實(shí)現(xiàn)
  3. 支持根據(jù)不同的variant蒂秘,生成不同的package的代碼,比如debug

Kace 性能優(yōu)化

明確輸入輸出

前面介紹了 kace-gradle-plugin 的主要作用就是解析 layout 然后生成代碼淘太,但是對(duì)于一個(gè)比較大的模塊姻僧,layout 可能有幾百個(gè),如果每次編譯時(shí)都要運(yùn)行這個(gè) Task蒲牧,會(huì)帶來一定的性能損耗

理想情況下撇贺,在輸入輸出沒有發(fā)生變化的情況下,應(yīng)該跳過這個(gè) Task

比如 Gradle 中內(nèi)置的 JavaCompilerTask冰抢,在源碼與 jdk 版本沒有發(fā)生變化的時(shí)候松嘶,會(huì)自動(dòng)跳過(標(biāo)記為 up-to-date)

Gradle 需要我們明確 Task 的輸入與輸出是什么,這樣它才能決定是否可以自動(dòng)跳過這個(gè)Task挎扰,如下所示:

abstract class KaceGenerateTask : DefaultTask() {

    @get:Internal
    val layoutDirs: ConfigurableFileCollection = project.files()

    @get:Incremental
    @get:InputFiles
    @get:PathSensitive(PathSensitivity.RELATIVE)
    internal open val androidLayoutResources: FileCollection = layoutDirs
        .asFileTree
        .matching { patternFilterable ->
            patternFilterable.include("**/*.xml")
        }

    @get:Input
    abstract val layoutVariantMap: MapProperty<String, String>

    @get:Input
    abstract val namespace: Property<String>

    @get:OutputDirectory
    abstract val sourceOutputDir: DirectoryProperty    
}

如上所示喘蟆,通過注解的方式明確了 Task 的輸入輸出,在輸入與輸出都沒有發(fā)生改變的時(shí)候鼓鲁,該 Task 會(huì)被標(biāo)記為 up-to-date 蕴轨,通過編譯避免的方式提高編譯性能

并行 Task

KaceGenerateTask的主要作用其實(shí)就是解析 layout 然后生成代碼,每個(gè) layout 都是相互獨(dú)立的骇吭,在這種情況下就特別適合使用并行 Task

要實(shí)現(xiàn)并行 Task橙弱,首先要將 Task 轉(zhuǎn)化為 Worker API

abstract class KaceGenerateAction : WorkAction<KaceGenerateAction.Parameters> {
    interface Parameters : WorkParameters {
        val destDir: DirectoryProperty
        val layoutFile: RegularFileProperty
        val variantName: Property<String>
        val namespace: Property<String>
    }

    override fun execute() {
        val item = LayoutItem(
            parameters.destDir.get().asFile,
            parameters.layoutFile.get().asFile,
            parameters.variantName.get()
        )
        val namespace = parameters.namespace.get()
        val file = item.layoutFile
        val layoutNodeItems = parseXml(saxParser, file, logger)
        writeActivityFragmentExtension(layoutNodeItems, item, namespace)
        writeViewExtension(layoutNodeItems, item, namespace)
    }
}
  1. 第一步:首先我們需要定義一個(gè)接口來表示每個(gè)Action需要的參數(shù),即KaceGenerateAction.Parameters
  2. 第二步:您需要將自定義Task中為每個(gè)單獨(dú)文件執(zhí)行工作的部分重構(gòu)為單獨(dú)的類,即KaceGenerateAction
  3. 第三步:您應(yīng)該重構(gòu)自定義Task類以將工作提交給 WorkerExecutor棘脐,而不是自己完成工作

接下來就是將KaceGenerateAction提交給WorkerExector

abstract class KaceGenerateTask : DefaultTask() {
    @get:Inject
    abstract val workerExecutor: WorkerExecutor

    @TaskAction
    fun action(inputChanges: InputChanges) {
        val workQueue = workerExecutor.noIsolation()
        // ...
        changedLayoutItemList.forEach { item ->
            workQueue.submit(KaceGenerateAction::class.java) { parameters ->
                parameters.destDir.set(destDir)
                parameters.layoutFile.set(item.layoutFile)
                parameters.variantName.set(item.variantName)
                parameters.namespace.set(namespace)
            }
        }
        workQueue.await() // 等待所有 Action 完成斜筐,計(jì)算耗時(shí)
        val duration = System.currentTimeMillis() - startTime
    }
}
  1. 您需要擁有WorkerExecutor服務(wù)才能提交Action。這里我們添加了一個(gè)抽象的workerExecutor并添加注解蛀缝,Gradle 將在運(yùn)行時(shí)注入服務(wù)
  2. 在提交Action之前顷链,我們需要通過不同的隔離模式獲取WorkQueue,這里使用的是線程隔離模式
  3. 提交Action時(shí)屈梁,指定Action實(shí)現(xiàn)嗤练,在這種情況下調(diào)用KaceGenerateAction并配置其參數(shù)

經(jīng)過測試,在一個(gè)包括 500 個(gè) layout 的模塊中在讶,在開啟并行 Task 前全量編譯耗時(shí)約 4 秒煞抬,而開啟后全量編譯耗時(shí)減少到 2 秒左右,可以有 100% 左右的提升

支持增量編譯

還有一種常見的場景构哺,當(dāng)我們只修改了一個(gè) layout 時(shí)革答,如果模塊內(nèi)的所有 layout 都需要重新解析并生成代碼,也是非常浪費(fèi)性能的

理想情況下曙强,應(yīng)該只需要重新解析與處理我們修改的 layout 就行了残拐,Gradle 同樣提供了 API 供我們實(shí)現(xiàn)增量編譯

abstract class KaceGenerateTask : DefaultTask() {
    @get:Incremental
    @get:InputFiles
    @get:PathSensitive(PathSensitivity.RELATIVE)
    internal open val androidLayoutResources: FileCollection = layoutDirs
        .asFileTree
        .matching { patternFilterable ->
            patternFilterable.include("**/*.xml")
        }

    @TaskAction
    fun action(inputChanges: InputChanges) {
        val changeFiles = getChangedFiles(inputChanges, androidLayoutResources)
        // ...        
    }

    private fun getChangedFiles(
        inputChanges: InputChanges,
        layoutResources: FileCollection
    ) = if (!inputChanges.isIncremental) {
        ChangedFiles.Unknown()
    } else {
        inputChanges.getFileChanges(layoutResources)
            .fold(mutableListOf<File>() to mutableListOf<File>()) { (modified, removed), item ->
                when (item.changeType) {
                    ChangeType.ADDED, ChangeType.MODIFIED -> modified.add(item.file)
                    ChangeType.REMOVED -> removed.add(item.file)
                    else -> Unit
                }
                modified to removed
            }.run {
                ChangedFiles.Known(first, second)
            }
    }
}

通過以下步驟,就可以實(shí)現(xiàn)增量編譯

  1. androidLayoutResources使用@Incremental注解標(biāo)識(shí)碟嘴,表示支持增量處理的輸入
  2. TaskAction方法添加inputChange參數(shù)
  3. 通過inputChanges方法獲取輸入中發(fā)生了更改的文件溪食,如果發(fā)生了更改則重新處理,如果被刪除了則同樣刪除目標(biāo)目錄中的文件臀防,沒有發(fā)生更改的文件則不處理

通過支持增量編譯眠菇,當(dāng)只修改或者添加一個(gè) layout 時(shí),增量編譯耗時(shí)可以減少到 8ms 左右袱衷,大幅減少了編譯耗時(shí)

總結(jié)

本文主要介紹了如何使用 Kace 捎废,以及 Kace 到底是如何實(shí)現(xiàn)的,如果有任何問題致燥,歡迎提出 Issue登疗,如果對(duì)你有所幫助,歡迎點(diǎn)贊收藏 Star ~

開源地址

github.com/kanyun-inc/…

作者:程序員江同學(xué)
鏈接:https://juejin.cn/post/7168256990484332580

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末嫌蚤,一起剝皮案震驚了整個(gè)濱河市辐益,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌脱吱,老刑警劉巖智政,帶你破解...
    沈念sama閱讀 216,692評(píng)論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異箱蝠,居然都是意外死亡续捂,警方通過查閱死者的電腦和手機(jī)垦垂,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,482評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來牙瓢,“玉大人劫拗,你說我怎么就攤上這事》耍” “怎么了页慷?”我有些...
    開封第一講書人閱讀 162,995評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長胁附。 經(jīng)常有香客問我酒繁,道長,這世上最難降的妖魔是什么汉嗽? 我笑而不...
    開封第一講書人閱讀 58,223評(píng)論 1 292
  • 正文 為了忘掉前任欲逃,我火速辦了婚禮找蜜,結(jié)果婚禮上饼暑,老公的妹妹穿的比我還像新娘。我一直安慰自己洗做,他們只是感情好弓叛,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,245評(píng)論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著诚纸,像睡著了一般撰筷。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上畦徘,一...
    開封第一講書人閱讀 51,208評(píng)論 1 299
  • 那天毕籽,我揣著相機(jī)與錄音,去河邊找鬼井辆。 笑死关筒,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的杯缺。 我是一名探鬼主播蒸播,決...
    沈念sama閱讀 40,091評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼萍肆!你這毒婦竟也來了袍榆?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,929評(píng)論 0 274
  • 序言:老撾萬榮一對(duì)情侶失蹤塘揣,失蹤者是張志新(化名)和其女友劉穎包雀,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體亲铡,經(jīng)...
    沈念sama閱讀 45,346評(píng)論 1 311
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡才写,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,570評(píng)論 2 333
  • 正文 我和宋清朗相戀三年劳曹,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片琅摩。...
    茶點(diǎn)故事閱讀 39,739評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡铁孵,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出房资,到底是詐尸還是另有隱情蜕劝,我是刑警寧澤,帶...
    沈念sama閱讀 35,437評(píng)論 5 344
  • 正文 年R本政府宣布轰异,位于F島的核電站岖沛,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏搭独。R本人自食惡果不足惜婴削,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,037評(píng)論 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望牙肝。 院中可真熱鬧唉俗,春花似錦、人聲如沸配椭。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,677評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽股缸。三九已至衡楞,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間敦姻,已是汗流浹背瘾境。 一陣腳步聲響...
    開封第一講書人閱讀 32,833評(píng)論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留镰惦,地道東北人迷守。 一個(gè)月前我還...
    沈念sama閱讀 47,760評(píng)論 2 369
  • 正文 我出身青樓,卻偏偏與公主長得像陨献,于是被迫代替她去往敵國和親盒犹。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,647評(píng)論 2 354

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