TornadoFX编程指南,第3章,组件

译自《Components》

组件

JavaFX使用戏剧类比来组织一个包含StageScene组件的Application。 TornadoFX通过提供ViewControllerFragment组件也构建在此类比基础之上。 虽然TornadoFX也使用StageScene,但ViewControllerFragment引入了可以简化开发的新概念。 这些组件多数被自动维护为单例(singletons),并且可以通过简单的依赖注入(dependency injections)和其他方式相互通信。

您还可以选择使用FXML,稍后会讨论。 但首先,让我们继承App来创建用于启动TornadoFX应用程序的入口点。

App和View的基础知识

要创建TornadoFX应用程序,您必须至少有一个继承了App的类。App是应用程序的入口点,并指定初始View。 实际上它继承了JavaFX的Application ,但是您不一定需要指定一个start()main()方法。

但首先,让我们继承App来创建自己的实现,并将主视图(primary view)指定为构造函数的第一个参数。

class MyApp: App(MyView::class)

视图(View)包含显示逻辑以及节点(Nodes)的布局,类似于JavaFX的Stage。 它被作为单例(singleton)来自动管理。 当您声明一个View,您必须指定一个root属性,该属性可以是任何Node类型,并且将保存视图(View)的内容。

在同一个Kotlin文件或在另一个新文件中,从View继承出来一个新类。 覆盖其抽象root属性并赋值VBox,或您选择的任何其他Node

class MyView: View() {
    override val root = VBox()
}

但是,我们可能想填充这个VBox,作为root控件。 使用初始化程序块 (initializer block),让我们添加一个JavaFX的Button和一个Label。 您可以使用 “plus assign” +=运算符将子项添加到任何Pane类型,包括这里的VBox

class MyView: View() {
    override val root = VBox()

    init {
        root += Button("Press Me")
        root += Label("")
    }
}

虽然从查看上述代码来看,很清楚发生了什么,但TornadoFX还提供了一个构建器语法(builder syntax),可以进一步简化您的UI代码,并可通过查看代码来更轻松地推导出最终的UI。 我们将逐渐转向构建器语法,最后在下一章中全面介绍构建器(builders)。

虽然我们会向您介绍新概念,但您可能有时还会看到没有使用最佳做法的代码。 我们这样做是为了向您介绍这些概念,并让您更深入地了解底层发生的情况。 逐渐地,我们将会以更好的方式介绍更强大的结构来解决这个问题。

接下来我们将看到如何运行这个应用程序。

启动TornadoFX应用程序

较新版本的JVM知道如何在没有main()方法的情况下启动JavaFX应用程序。 JavaFX应用程序(TornadoFX应用程序是其扩展),是继承javafx.application.Application的任何类。 由于tornadofx.App继承了javafx.application.Application ,TornadoFX应用程序没有什么不同。 因此,您将通过引用com.example.app.MyApp启动该应用程序,并且您不一定需要一个main()函数,除非您需要提供命令行参数。 在这种情况下,您将需要添加一个包级别的主函数到MyApp.kt文件:

fun main(args: Array<String>) {
  Application.launch(MyApp::class.java, *args)
}

这个主函数将被编译进com.example.app.MyAppKt - 注意最后的Kt。 当您创建包级别的主函数时,它将始终具有完全限定包的类名,加上文件名,附加Kt

对于启动和测试App ,我们将使用Intellij IDEA。 导航到Run→Edit Configurations (图3.1)。

图3.1

单击绿色“+”符号并创建一个新的应用程序配置(图3.2)。

图3.2

指定 “主类(Main class)” 的名称,这应该是您的App类。 您还需要指定它所在的模块(module)。给配置一个有意义的名称,如 “Launcher”。 之后点击 “OK”(图3.3)。

图3.3

您可以通过选择Run→Run 'Launcher'或任何您命名的配置来运行 TornadoFX应用程序(图3.4)。

图3.4

您现在应该看到您的应用程序启动了(图3.5)

图3.5

恭喜! 您已经编写了您的第一个(虽然简单)TornadoFX应用程序。 现在看起来可能不是很好,但是当我们涵盖更多TornadoFX的强大功能时,我们将创建大量令人印象深刻的用户界面,几乎没有多少代码,而且只需要很少时间。 但首先让我们来更好地了解AppView之间发生的情况。

了解视图(View)

让我们深入了解View的工作原理以及如何使用它。 看看我们刚刚构建的AppView类。

class MyApp: App(MyView::class)

class MyView: View() {
    override val root = VBox()

    init {
        with(root) {
            this += Button("Press Me")
            this += Label("Waiting")
        }
    }
}

View包含JavaFX节点的层次结构,并在它被调用的位置通过名称注入。 在下一节中,我们将学习如何利用强大的构建器(powerful builders)来快速创建这些Node层次结构。TornadoFX维护的MyView只有一个实例,有效地使其成为单例。TornadoFX还支持范围(scopes),它们可以将ViewFragmentController的集合组合在一个单独的命名空间中,如果你愿意的话,那么View只能是该范围内的单例。 这对于多文档接口应用程序(Multiple-Document Interface applications)和其他高级用例非常有用。 稍后再说。

使用inject()和嵌入视图(Embedding Views)

您也可以将一个或多个视图注入另一个View 。 下面我们将TopViewBottomView嵌入到MasterView。 请注意,我们使用inject()代理属性(delegate property)来懒惰地注入TopViewBottomView实例。 然后我们调用每个child Viewroot来赋值给BorderPane(图3.6)。

class MasterView: View() {
    val topView: TopView by inject()
    val bottomView: BottomView by inject()

    override val root = borderpane {
        top = topView.root
        bottom = bottomView.root
    }
}

class TopView: View() {
    override val root = label("Top View")
}

class BottomView: View() {
    override val root = label("Bottom View")
}
图3.6

如果您需要在视图间彼此沟通,您可以在每个child View中创建一个属性来保存parent View

class MasterView : View() {
    override val root = BorderPane()

    val topView: TopView by inject()
    val bottomView: BottomView by inject()

    init {
        with(root) {
            top = topView.root
            bottom = bottomView.root
        }

        topView.parent = this
        bottomView.parent = this
    }
}

class TopView: View() {
    override val root = Label("Top View")
    lateinit var parent: MasterView
}

class BottomView: View() {
    override val root = Label("Bottom View")
    lateinit var parent: MasterView
}

更通常地,您将使用ControllerViewModel在视图之间进行通信,稍后我们将访问此主题。

使用find()来注入

inject()代理(delegate)将懒惰地将一个给定的组件赋值给一个属性。 第一次调用该组件时,它将被检索。 或者,不使用inject()代理,您可以使用find()函数来检索View或其他组件的单例实例。

class MasterView : View() {
    override val root = BorderPane()

    val topView = find(TopView::class)
    val bottomView = find(BottomView::class)

    init {
        with(root) {
            top = topView.root
            bottom = bottomView.root
        }
    }
}

class TopView: View() {
    override val root = Label("Top View")
}

class BottomView: View() {
    override val root = Label("Bottom View")
}

您可以使用find()inject() ,但是使用inject()代理是执行依赖注入的首选方法。

虽然我们将在下一章更深入地介绍构建器(builders),但现在是时候来揭示上述示例可以用更加简洁明了的语法来编写了:

class MasterView : View() {
    override val root = borderpane {
        top(TopView::class)
        bottom(BottomView::class)
    }
}

我们不是先注入TopViewBottomView,然后将它们各自的root节点赋值给BorderPanetopbottom属性,而是使用构建器语法(builder syntax,全部小写)来指定BorderPane,然后声明性地告诉TornadoFX拉入两个子视图,并使他们自动赋值到topbottom属性。 我们希望您会认同,这是很具表现力的,具有少得多的样板(boiler plate)。 这是TornadoFX试图以此为生的最重要的原则之一:减少样板(boiler plate),提高可读性。 最终的结果往往是更少的代码和更少的错误。

控制器(Controllers)

在许多情况下,将UI分为三个不同的部分被认为是一种很好的做法:

    1. 模型(Model) - 拥有核心逻辑和数据的业务代码层。
    1. 视图(View)- 具有各种输入和输出控件的视觉显示。
    1. 控制器(Controller) - “中间人(middleman)” 介入(mediating)模型和视图之间的事件。

还有其他的MVC流派,例如MVVM和MVP,所有这些都可以在TornadoFX中使用。

尽管您可以将模型和控制器的所有逻辑放在视图之中,但是最好将这三个部分清楚地分开,以便最大程度地实现可重用性。 一个常用的模式是MVC模式。 在TornadoFX中,可以注入一个Controller来支持View

这里给出一个简单的例子。 使用一个TextField创建一个简单的View ,当一个Button被点击时,其值被写入到一个“数据库”。 我们可以注入一个处理与写入数据库的模型交互的Controller 。 由于这个例子是简化的,所以不会有实际的数据库,但打印的消息将作为占位符(图3.7)。

class MyView : View() {
    val controller: MyController by inject()
    var inputField: TextField by singleAssign()

    override val root = vbox {
        label("Input")
        inputField = textfield()
        button("Commit") {
            action {
                controller.writeToDb(inputField.text)
                inputField.clear()
            }
        }
    }
}

class MyController: Controller() {
    fun writeToDb(inputValue: String) {
        println("Writing $inputValue to database!")
    }
}
图3.7

当我们构建UI时,我们确保添加对inputField的引用,以便以后可以在“Commit”按钮的onClick事件处理程序中引用。 当单击“Commit”按钮时,您将看到控制器向控制台打印一行。

Writing Alpha to database!

重要的是要注意,虽然上述代码是可工作的,甚至可能看起来也不错,但是很好的做法是要避免直接引用其他UI元素。 如果您将UI元素绑定到属性并操作属性,那么您的代码将更容易重构。 稍后我们将介绍ViewModel,它提供了更简单的方法来处理这种类型的交互。

长时间运行的任务

每当您在控制器中调用函数时,需要确定该函数是否立即返回,或者执行潜在的长时间运行的任务。 如果您在JavaFX应用程序线程中调用函数,则UI将在响应完成之前无响应。 无响应的UI是用户感知(user perception)的杀手,因此请确保您在后台运行昂贵的操作。 TornadoFX提供了runAsync功能来帮助您。

放置在一个runAsync块内的代码将在后台运行。 如果后台调用的结果需要更新您的UI,则必须确保您在JavaFX的应用程序线程中应用更改。ui区块正是这样。

val textfield = textfield()
button("Update text") {
    action {
        runAsync {
            myController.loadText()
        } ui { loadedText ->
            textfield.text = loadedText
        }
    }
}

当单击按钮时,将运行action构建器(将ActionEvent代理给setAction方法)中的操作。 它调用myController.loadText(),并当它返回shi将结果应用于textfieldtext属性。 当控制器功能运行时,UI保持响应。

在表面以下, runAsync会创建一个JavaFX的Task对象,并将创建一个单独的线程以在Task里运行你的调用。 您可以将此Task赋值给变量,并将其绑定到UI,以在运行时显示进度。

事实上,这是很常见的,为此还有一个名为TaskStatus的默认ViewModel,它包含runningmessagetitleprogress等可观察值。 您可以使用TaskStatus对象的特定实例来提供runAsync调用,或使用默认值。

TornadoFX源代码在AsyncProgressApp.kt文件中包含一个示例用法。

还有一个名为runAsyncWithProgressrunAsync版本, runAsync在长时间运行的操作运行时,以进度指示器来覆盖当前节点。

singleAssign()属性代理

在上面的例子中,我们用singleAssign代理初始化了inputField属性。 如果要保证只赋值一次值,可以使用singleAssign()代理代替Kotlin的lateinit关键字。 这将导致第二个赋值引发错误,并且在赋值之前过早访问时也会出错。

您可以在附录A1中详细查看有关singleAssign()的更多信息,但是现在知道它保证只能赋值一次给var。 它也是线程安全的,有助于减轻可变性(mutability)问题。

您还可以使用控制器向View提供数据(图3.8)。

class MyView : View() {
    val controller: MyController by inject()

    override val root = vbox {
        label("My items")
        listview(controller.values)
    }
}

class MyController: Controller() {
    val values = FXCollections.observableArrayList("Alpha","Beta","Gamma","Delta")
}
图3.8

VBox包含一个Label和一个ListViewControllervalues属性被赋值给ListViewitems属性。

无论他们是读数据还是写数据,控制器都可能会执行长时间运行的任务,从而不能在JavaFX线程上执行任务。 本章后面您将学习如何使用runAsync构造来轻松地将工作卸载到工作线程。

分段(Fragment)

您创建的任何View都是单例,这意味着您通常只能在一个地方一次使用它。 原因是在JavaFX应用程序中View的根节点(root node)只能具有单个父级。 如果你赋值另一个父级,它将从它的先前的父级消失。

但是,如果您想创建一个短暂(short-lived)的UI,或者可以在多个地方使用,请考虑使用Fragment。 片段(Fragment)是可以有多个实例的特殊类型的View。 它们对于弹出窗口或更大的UI甚至是单个ListCell都特别有用。 稍后我们将会看到一个名为ListCellFragment的专门的片段。

ViewFragment支持openModal()openWindow()openInternalWindow() ,它将在单独的窗口(Window)中打开根节点。

class MyView : View() {
    override val root = vbox {
        button("Press Me") {
            action {
                find(MyFragment::class).openModal(stageStyle = StageStyle.UTILITY)
            }
        }
    }
}

class MyFragment: Fragment() {
    override val root = label("This is a popup")
}

您也可以将可选参数传递给openModal()以修改其一些行为。

openModal()的可选参数

参数 类型 描述
stageStyle StageStyle 定义·Stage·可能的枚举样式之一。 默认值: ·StageStyle.DECORATED·
modality Modality 定义Stage一个可能的枚举模式类型。 默认值: Modality.APPLICATION_MODAL
escapeClosesWindow Boolean 设置ESC键调用closeModal() 。 默认值: true
owner Window 指定此阶段的所有者窗口
block Boolean 阻止UI执行,直到窗口关闭。 默认值: false

InternalWindow

尽管openModal在一个新的Stage打开, openInternalWindow却在当前的根节点(current root node)或任何你指定的其他节点上打开:

 button("Open editor") {
        action {
            openInternalWindow(Editor::class)
        }
    }
图3.9

内部窗口(internal window)的一个很好的用例是单舞台(single stage)环境(如JPro),或者如果要自定义窗口,修剪该窗口使其看起来更符合你的应用程序的设计。 内部窗口(Internal Window)可以使用CSS样式。 有关样式可更改(styleable)属性的更多信息,请查看InternalWindow.Styles类。

内部窗口(internal window)API在一个重要方面与模态/窗口(modal/window)不同。 由于窗口(window)在现有节点上打开,您通常会在你想要其在上打开的View中调用openInternalWindow()。 您提供要显示的视图(View),您也可以选择通过owner参数提供要在其上打开的节点(node)。

openInternalWindow()的可选参数

参数 类型 描述
view UIComponent 组件将是新窗口的内容
view KClass 或者,您可以提供视图的类而不是实例
icon Node 可选的窗口图标
scope Scope 如果指定视图类,则还可以指定用于获取视图的作用域
modal Boolean 定义在内部窗口处于活动状态时是否应该禁用被覆盖节点。 默认值: true
escapeClosesWindow Boolean 设置ESC键调用close() 。 默认值: true
owner Node 指定此窗口的所有者节点。 默认情况下,该窗口将覆盖此视图的根节点

关闭模式窗口

使用openModal()openWindow()openInternalWindow()打开的任何Component都可以通过调用closeModal()关闭。 如果需要使用findParentOfType(InternalWindow::class)也可以直接访问InternalWindow实例。

更换视图和对接事件(Replacing Views and Docking Events)

使用TornadoFX,可以使用replaceWith()方便地与当前View进行交换,并可选择添加一个转换(transition)。 在下面的示例中,每个View上的Button将切换到另一个视图,可以是MyView1MyView2 (图3.10)。

class MyView1: View() {
    override val root = vbox {
        button("Go to MyView2") {
            action {
                replaceWith(MyView2::class)
            }
        }
    }
}

class MyView2: View() {
    override val root = vbox {
        button("Go to MyView1") {
            action {
                replaceWith(MyView1::class)
            }
        }
    }
}
图3.10

您还可以选择为两个视图之间的转换指定一个精巧的动画。

replaceWith(MyView1::class, ViewTransition.Slide(0.3.seconds, Direction.LEFT)

这可以通过用另一个Viewroot替换给定View上的rootView具有两个函数可以重载(override),用于在其root Node连接到父级( onDock() )以及断开连接( onUndock() )时。 每当View进入或退出时,您可以利用这两个事件进行“连接”和“清理”。 运行下面的代码时您会注意到,每当View被交换时,它将取消(undock )上一个View并停靠(dock )新的。 您可以利用这两个事件来管理初始化(initialization)和处理(disposal)任务。

class MyView1: View() {
    override val root = vbox {
        button("Go to MyView2") {
            action {
                replaceWith(MyView2::class)
            }
        }
    }

    override fun onDock() {
        println("Docking MyView1!")
    }

    override fun onUndock() {
        println("Undocking MyView1!")
    }
}

class MyView2: View() {
    override val root = vbox {
        button("Go to MyView1") {
            action {
                replaceWith(MyView1::class)
            }
        }
    }

    override fun onDock() {
        println("Docking MyView2!")
    }
    override fun onUndock() {
        println("Undocking MyView2!")
    }
}

将参数传递给视图

在视图之间传递信息的最佳方式通常是注入ViewModel。 即使如此,可以将参数传递给其他组件仍然是很便利的。 find()inject()函数支持Pair<String, Any>这样的varargs,就可以用于此目的。 考虑在一个客户列表中,为选定的客户项打开客户信息编辑器的情形。 编辑客户信息的操作可能如下所示:

fun editCustomer(customer: Customer) {
    find<CustomerEditor>(mapOf(CustomerEditor::customer to customer).openWindow())
}

这些参数作为映射传递,其中键(key)是视图中的属性(property),值(value)是您希望的属性的任何值。 这为您提供了一种配置目标视图参数的安全方式。

这里我们使用Kotlin的to语法来创建参数。 如果你愿意,这也可以写成Pair(CustomerEditor::customer, customer)。 编辑器现在可以这样访问参数:

class CustomerEditor : Fragment() {
    val customer: Customer by param()

}

如果要检查参数,而不是盲目依赖它们是可用的,您可以将其声明为可空(nullable),或参考其params映射:

class CustomerEditor : Fragment() {
    init {
        val customer = params["customer"] as? Customer
        if (customer != null) {
            ...
        }
    }
}

如果您不关心类型安全性,还可以将参数作为mapOf("customer" to customer)传递,但是如果在目标视图中重命名属性,则会错过自动重构(automatic refactoring)。

访问主舞台(primary stage)

View具有一个名为primaryStage的属性,允许您操作支持它的Stage的属性,例如窗口大小。 通过openModal()打开的任何ViewFragment也将有一个modalStage属性可用。

访问场景(scene)

有时需要从ViewFragment获取当前场景。 这可以通过root.scene来实现,或者如果你位于一个类型安全的构建器(type safe builder)内部,还有一个更短的方法,只需使用scene

访问资源(resources)

许多JavaFX API将资源作为URLURLtoExternalForm。 要检索资源url,通常会如下所写:

val myAudioClip = AudioClip(MyView::class.java.getResource("mysound.wav").toExternalForm())

每个Component都有一个resources对象,可以检索resources的外部形式url(external form url),如下所示:

val myAudiClip = AudioClip(resources["mysound.wav"])

如果您需要一个实际的URL,可以这样检索:

 val myResourceURL = resources.url( "mysound.wav" ) 

resources助手还有一些其他有用的功能,可帮助您将相对于Component的文件转换为所需类型的对象:

val myJsonObject = resources.json("myobject.json")
val myJsonArray = resources.jsonArray("myarray.json")
val myStream = resources.stream("somefile")

值得一提的是, jsonjsonArray函数也可以在InputStream对象上使用。

资源与Component相对应,但您也可以通过完整路径,从/开始检索资源。

动作的快捷键和组合键

您可以在键入某些组合键时触发动作(fire actions)。 这是用shortcut函数完成的:

shortcut(KeyCombination.valueOf("Ctrl+Y")) {
    doSomething()
}

还有一个字符串版本的shortcut函数与此相同,但是不太冗长:

shortcut("Ctrl+Y")) {
    doSomething()
}

您还可以直接向按钮操作添加快捷方式:

button("Save") {
    action { doSave() }
    shortcut("Ctrl+S")
}

触摸支持

JavaFX对触摸的支持开箱即用,现在唯一需要改进的地方就是以更方便的方式处理shortpresslongpress。 它由两个类似于action的函数组成,可以在任何Node上进行配置:

shortpress { println("Activated on short press") }
longpress { println("Activated on long press") }

这两个函数都接受consume参数,默认情况下为false。 将其设置为true将防止按压事件(press event)发生事件冒泡(event bubbling)。longpress函数还支持一个threshold参数,用于确定longpress积累的时间。 默认为700.millis

总结

TornadoFX充满了简单,直观而又强大的注入工具来管理视图和控制器(Views and Controllers)。 它还使用Fragment简化对话框和其他小型UI。 尽管迄今为止,我们构建的应用程序非常简单,但希望您能欣赏到TornadoFX给JavaFX引入的简化概念。 在下一章中,我们将介绍可以说是TornadoFX最强大的功能:Type-Safe Builders

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,761评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,953评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,998评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,248评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,130评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,145评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,550评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,236评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,510评论 1 291
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,601评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,376评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,247评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,613评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,911评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,191评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,532评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,739评论 2 335

推荐阅读更多精彩内容