Android 如何优雅地实现@人功能?——仿微博、仿QQ、仿微信、零入侵、高扩展性

最近有个需求:评论@人。网上已经有一些文章分享了类似功能实现逻辑,但是几乎都是扩展EditText类,这种实现方式肯定不能进入我的首发阵容。你以为是因为它不符合面向对象六大原则?错,只因为它不够优雅!不够优雅!不够优雅!

那么,只有饮水机代码怎么办?当然是

read the fuking source code

功夫不负有心人,我读了一遍EditText源码,然后就造出了这个“优雅的”轮子(开玩笑,EditText源码怎么能叫fuking source code,他有一个爸爸叫TextView)。废话不多说,上酸菜。

在此之前,你需要记住一个跟文本相关的思想:一切皆Span

一、添加标签文本样式,并与标签的业务数据绑定

所有人都知道文本样式与Spannable有关。这里同样使用Spannable,我定义了一个DataBindingSpan<T>接口,主要有两个功能:

  1. 让用户提供一个CharSequence对象作为标签,它决定了标签文本的样式和内容
  2. 提供一个方法返回DataBindingSpan<T>对象所绑定的业务数据。
interface DataBindingSpan<T> {
    fun spannedText(): CharSequence
    fun bindingData(): T
}

示例代码:

class SpannableData(private val spanned: String): DataBindingSpan<String> {

    override fun spannedText(): CharSequence {
        return SpannableString(spanned).apply { 
            setSpan(ForegroundColorSpan(Color.RED), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
        }
    }

    override fun bindingData(): String {
        return spanned
    }
}

这个类仅仅包装了一个字符串,spannedText()返回一个改变标签文本颜色为红色的字符串,同时 bindingData()将该字符串作为业务数据返回。

你也可以把它换成其他的,user对象不错。spannedText()返回username,bindingData()返回userId,你就可以轻松实现@人功能业务数据绑定相关的逻辑了。

二、保证文本上绑定的数据的安全可靠

当我们把Span绑定到文本上以后,我们需要在文本发生变化时,保证文本和数据的安全性,可靠性,一致性。

其实从DataBindingSpan开始,我们就在处理这个事情了。正如SpannableData所展现的一样,当spannedText()返回的是一个Spannable对象时,使用Spanned.SPAN_EXCLUSIVE_EXCLUSIVE作为flag。它不能在头部和尾部扩展Span的范围,只允许中间插入。同时,当Span覆盖的文本被删除时,Span也会被删除。也就是说,它天生具有一定数据安全可靠的属性。这会为我们省掉很多事情。

当然,Spanned.SPAN_EXCLUSIVE_EXCLUSIVE并不具备完全的安全性。毕竟它不能阻止中间插入。这个事情得我们自己来做。那么,为了禁止中间插入,我们应该怎么做呢?

这个需求又产生了两个问题:

  1. 当普通文本发生变化后,如何监控一个Span起始位置发生变化?
  2. 如何禁止Span内部插入光标?

对于第一个问题,我在网上看到过一种思路。维护一个Span起始位置管理器SpanRangeManager,然后利用TextWather监听文本变化,文本的任何变化都会导致SpanRangeManager重新测算Span的位置。

当然,如果我使用这种方式,就不会有这篇博客了。其实Android SDK便有一个优秀的Span管理器,那就是SpannableStringBuilder。同时SDK提供了一个侦听器SpanWatcher侦听SpannableStringBuilder中Span的变化。有兴趣的同学可以去看一看他的源码。

第二个问题,我们要保证文本与数据的一致性,禁止光标插入到Span覆盖的文本中间。有三种做法:

  1. 普通文本,当标签文本被破坏(删除、插入、追加文本)时,让绑定的数据失效,这就是微信的做法。
  2. 普通文本,把标签文本作为一个整体,不能对标签内部插入光标,杜绝数据被破坏的情况,这是微博的做法。
  3. 占位符,使用不可分割的Span(如ImageSpan)替换,这是QQ的做法。

微博、微信的方法都必须要对软键盘删除键、文本变化、光标活动、文本选中状态以及span变化进行监听和处理。QQ就简单多了,后面会讲到。

微博的做法

1. 侦听并处理光标活动、选中状态以及Span位置变化

对于光标活动和选中状态侦听,如果采用继承EditText的方式实现标签文本功能,重写onSelectionChanged(int selStart, int selEnd)方法便能够侦听光标活动。但是,这种方式怎么能算优雅呢?

要想“优雅地”实现怎么办?还是那句话:

read the fuking source code

两个角色:

  1. Selection
  2. SpanWatcher

如果有一篇文章叫做《Selection如何管理文本光标活动和选中状态?》,那么它一定能回答这个问题。这里不会详细讲述Selection内部实现,你只需要知道两点:

  1. 选中状态具有起点(start)和终点(end),而start与end反映在文本中,其实是两个NoCopySpan: START, END。
  2. 光标是一种特殊的选中状态,start与end在同一位置;

既然选中状态的实现是Span,它就是与View无关的,而与Spannable有关。也就是说,我们可以不使用EditText自身的API却能够管理它的光标活动和选中状态(请注意这几句话,他是“优雅实现”的基石)。

Selection管理光标活动。那么,SpanWatcher又是什么?前面说了,它是SpannableStringBuidler中用于侦听Span变化的监听器。有个东西和它很像,TextWatcher。没错,他俩有同一个爹NoCopySpan。他俩一个侦听文本变化,一个侦听Span变化。下面是SpanWatcher的源码:

/**
 * When an object of this type is attached to a Spannable, its methods
 * will be called to notify it that other markup objects have been
 * added, changed, or removed.
 */
public interface SpanWatcher extends NoCopySpan {
    /**
     * This method is called to notify you that the specified object
     * has been attached to the specified range of the text.
     */
    public void onSpanAdded(Spannable text, Object what, int start, int end);
    /**
     * This method is called to notify you that the specified object
     * has been detached from the specified range of the text.
     */
    public void onSpanRemoved(Spannable text, Object what, int start, int end); 
    /**
     * This method is called to notify you that the specified object
     * has been relocated from the range <code>ostart…oend</code>
     * to the new range <code>nstart…nend</code> of the text.
     */
    public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart, int nend);
}

我们已经知道光标是一种Span。也就是说,我们可以通过SpanWatcher侦听光标活动,通过Selection实现当光标移动到Span内部时,让它重新移动到Span最近的边缘位置,Span内部永远无法插入光标。这样便能够实现把标签文本(spanned text)看作一个整体的思路。下面是代码实现:

package com.iyao

import android.text.Selection
import android.text.SpanWatcher
import android.text.Spannable
import kotlin.math.abs
import kotlin.reflect.KClass

class SelectionSpanWatcher<T: Any>(private val kClass: KClass<T>): SpanWatcher {
    private var selStart = 0
    private var selEnd = 0
    override fun onSpanChanged(text: Spannable, what: Any, ostart: Int, oend: Int, nstart: Int, nend: Int) {
        if (what === Selection.SELECTION_END && selEnd != nstart) {
            selEnd = nstart
            text.getSpans(nstart, nend, kClass.java).firstOrNull()?.run {
                val spanStart = text.getSpanStart(this)
                val spanEnd = text.getSpanEnd(this)
                val index = if (abs(selEnd - spanEnd) > abs(selEnd - spanStart)) spanStart else spanEnd
                Selection.setSelection(text, Selection.getSelectionStart(text), index)
            }
        }

        if (what === Selection.SELECTION_START && selStart != nstart) {
            selStart = nstart
            text.getSpans(nstart, nend, kClass.java).firstOrNull()?.run {
                val spanStart = text.getSpanStart(this)
                val spanEnd = text.getSpanEnd(this)
                val index = if (abs(selStart - spanEnd) > abs(selStart - spanStart)) spanStart else spanEnd
                Selection.setSelection(text, index, Selection.getSelectionEnd(text))
            }
        }
    }

    override fun onSpanRemoved(text: Spannable?, what: Any?, start: Int, end: Int) {
    }

    override fun onSpanAdded(text: Spannable?, what: Any?, start: Int, end: Int) {
    }
}

现在,我们只需要在setText()之前把这个Span添加到文本上就可以了。

2. 侦听软键盘删除键并处理选中状态

现在已经把Span覆盖的文本作为一个整体,且无法插入光标,但是当我们从Span尾部删除文本,仍是逐字删除。我们的要求是删除Span文本时,能够整体删除整个Span,这就需要监听键盘删除键。

package com.iyao

import android.text.Selection
import android.text.Spannable

class KeyCodeDeleteHelper private constructor(){
    companion object {
        fun onDelDown(text: Spannable): Boolean {
            val selectionStart = Selection.getSelectionStart(text)
            val selectionEnd = Selection.getSelectionEnd(text)
            text.getSpans(selectionStart, selectionEnd, DataBindingSpan::class.java).firstOrNull { text.getSpanEnd(it) == selectionStart }?.run {
                return (selectionStart == selectionEnd).also {
                    val spanStart = text.getSpanStart(this)
                    val spanEnd = text.getSpanEnd(this)
                    Selection.setSelection(text, spanStart, spanEnd)
                }
            }
            return false
        }
    }
}

让我们使用它

editText.setOnKeyListener { v, keyCode, event ->
    if (keyCode == KeyEvent.KEYCODE_DEL && event.action == KeyEvent.ACTION_DOWN) {
        return@setOnKeyListener KeyCodeDeleteHelper.onDelDown((v as EditText).text)
    }
    return@setOnKeyListener false
}

//取数据
val strings = editText.text.let {
    it.getSpans(0, it.length, DataBindingSpan::class.java)
}.map { it.bindingData() }

现在就可以实现微博一样效果了。一切都那么顺利。

然而,当你运行起来会发现,SelectionSpanWatcher完全没有效果。轮子都造好了,你告诉我轴承断了。

并且,当你打印EditText文本上的Span时,你找不到SelectionSpanWatcher。这说明SelectionSpanWatcher在setText()过程中被清除掉了。那我们能不能把它放在setText()之后设置呢?如果你这么做,你会发现一个新问题。setText()添加的文本没有效果。似乎我们不能通过setText()添加内容,只能使用getText()追加内容。不仅如此,我们必须完全禁用setText(),因为每一次调用,都会清除掉SelectionSpanWatcher。

这种方式看起来还不错,但是换一个不熟悉这个特性的人来使用怎么办?告诉他不能用setText()方法?或者用内联方法或继承的方式为EditText新增一个方法? 这些都可以,唯一的缺点是,它不是我想要的优雅。我要让它就像使用普通EditText一样正常使用setText()方法。

需要思考的问题是,SelectionSpanWatcher在哪里消失了?我要重新找回这个轴承。

3. 让轮子优雅实现的轴承:Editable.Factory

SelectionSpanWatcher在setText()方法中消失了。我需要去阅读它的源码。

EditText重写了getText()setText(CharSequence text, BufferType type)方法。

@Override
public Editable getText() {
    CharSequence text = super.getText();
    // This can only happen during construction.
    if (text == null) {
        return null;
    }
    if (text instanceof Editable) {
        return (Editable) super.getText();
    }
    super.setText(text, BufferType.EDITABLE);
    return (Editable) super.getText();
}
 @Override
 public void setText(CharSequence text, BufferType type) {
     super.setText(text, BufferType.EDITABLE);
}

从源码上看,重写的唯一目的是将BufferType设置为BufferType.EDITABLE

我们都知道TextView有三种文本模式:

  1. BufferType.NORMAL 静态文本模式,这种模式的文本无法编辑,也没有富文本样式。
  2. BufferType.SPANNABLE 带文本样式的模式,不可编辑。当TextView.isTextSelectable()返回true时,TextView的文本模式。
  3. BufferType.EDITABLE EditText的文本模式,可编辑,带文本样式。

这里不具体讲这三种模式相关的内容。只需要知道EditText的模式是BufferType.EDITABLE。

那么,BufferType.EDITABLE与“轴承”又有什么关系呢? 确实有关系。

阅读上面的源码片段时,不知道有没有人注意到setText(CharSequence)传入一个CharSequence对象,TextView#getText()返回的是CharSequence对象, EditText#getText()却返回一个Editable对象。它是在什么时候,如何完成的转换呢?它会不会是一个突破口?

从Editable getText()源码看,它是在super.setText(text, BufferType.EDITABLE)中完成转换的。

在TextView源码中,setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen)有这样一个流程分支:

private void setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen) {
    if (type == BufferType.EDITABLE || getKeyListener() != null|| needEditableForNotification) {
        ...
        Editable t = mEditableFactory.newEditable(text);
        text = t;
        ...
    }
    ...
    mBufferType = type;
    setTextInternal(text);
    ...
}

由此可见,我们赋值给EditText的CharSequence对象先经过mEditableFactory转换为Editable对象,最终被真正赋值给EditText,mEditableFactory的类型正是Editable.Factory,这是一个静态内部类。我们看看Editable.Factory的具体实现是什么。

/**
 * Factory used by TextView to create new {@link Editable Editables}. You can subclass
 * it to provide something other than {@link SpannableStringBuilder}.
 *
 * @see android.widget.TextView#setEditableFactory(Factory)
 */
 public static class Factory {
    private static Editable.Factory sInstance = new Editable.Factory();

    /**
     * Returns the standard Editable Factory.
     */
    public static Editable.Factory getInstance() {
        return sInstance;
    }

    /**
     * Returns a new SpannedStringBuilder from the specified
     * CharSequence.  You can override this to provide
     * a different kind of Spanned.
     */
    public Editable newEditable(CharSequence source) {
        return new SpannableStringBuilder(source);
    }
}

很简单的转换,它将CharSequence对象转换为Editable的子类SpannableStringBuilder的对象。我们看一看这个构造器。

public SpannableStringBuilder(CharSequence text, int start, int end) {
    ...
    mText = ArrayUtils.newUnpaddedCharArray(GrowingArrayUtils.growSize(srclen));
    ...
    if (text instanceof Spanned) {
        Spanned sp = (Spanned) text;
        Object[] spans = sp.getSpans(start, end, Object.class);
        for (int i = 0; i < spans.length; i++) {
            if (spans[i] instanceof NoCopySpan) {
                continue;
            }
            ...
            setSpan(false, spans[i], st, en, fl, false);
        }
        restoreInvariants();
    }
}

这就是轴承断掉的原因所在。

前面提到SpanWatcher继承自NoCopySpan,而NoCopySpan是一个标记接口。它的作用就是标记一个Span无法被拷贝。SpannableStringBuilder在构造的时候,会忽略掉所有NoCopySpan及其子类。因此,SelectionSpanWatcher没有被赋值给EditText的文本。

既然NoCopySpan不被复制,那我们等SpannableStringBuilder构造好后重新设置便好了。Editable.Factory的注释让我看到了希望。他可以被重写,并被重新注入EditText。

android.widget.TextView#setEditableFactory(Factory)

下面是重写的Editable.Factory,作用是重新把NoCopySpan设置到SpannableStringBuilder上。

package com.iyao

import android.text.Editable
import android.text.NoCopySpan
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.BackgroundColorSpan

class NoCopySpanEditableFactory(private vararg val spans: NoCopySpan): Editable.Factory() {
    override fun newEditable(source: CharSequence): Editable {
        return SpannableStringBuilder.valueOf(source).apply {
            spans.forEach {
                setSpan(it, 0, source.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
            }
        }
    }
}

没错,算空行一共17行代码。它就是这个轮子的新轴承。现在我们重新使用它。通过editText.setEditableFactory()换上新的轴承,让轮子跑起来。

editText.setEditableFactory(NoCopySpanEditableFactory(SelectionSpanWatcher(DataBindingSpan::class)))
editText.setOnKeyListener { v, keyCode, event ->
    if (keyCode == KeyEvent.KEYCODE_DEL && event.action == KeyEvent.ACTION_DOWN) {
        return@setOnKeyListener KeyCodeDeleteHelper.onDelDown((v as EditText).text)
    }
    return@setOnKeyListener false
}

一个“优雅的”实现诞生了,你可以像微博一样在评论中使用@人了。

微博效果.gif

微信的做法

微信的处理方式要简单一些,他们不禁止在Span覆盖的文本中插入光标,而是当Span覆盖的文本改变后清除Span以及数据。他们同样要监听删除键实现Span整体删除,只是表现上与微博稍有区别。

微信的三部曲。

首先,定义一个接口用来判断Span是否失效。

package com.iyao

import android.text.Spannable

interface RemoveOnDirtySpan {
    fun isDirty(text: Spannable): Boolean
}

其次,让SpannableData实现此接口。当然,你也可以让RemoveOnDirtySpan继承DataBindingSpan,尽管我觉得这样不符合“六大”。

class SpannableData(private val spanned: String): DataBindingSpan<String>, RemoveOnDirtySpan {

    override fun spannedText(): CharSequence {
        return SpannableString(spanned).apply { 
            setSpan(ForegroundColorSpan(Color.RED), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
        }
    }

    override fun bindingData(): String {
        return spanned
    }

    override fun isDirty(text: Spannable): Boolean {
        val spanStart = text.getSpanStart(this)
        val spanEnd = text.getSpanEnd(this)
        return spanStart >= 0 && spanEnd >= 0 && text.substring(spanStart, spanEnd) != spanned
    }
}

最后,重新写一个DirtySpanWatcher用来删除失效的Span

package com.iyao

import android.text.SpanWatcher
import android.text.Spannable

class DirtySpanWatcher(private val removePredicate: (Any) -> Boolean) : SpanWatcher {


    override fun onSpanChanged(text: Spannable, what: Any, ostart: Int, oend: Int, nstart: Int,
                               nend: Int) {
        if (what is RemoveOnDirtySpan && what.isDirty(text)) {
            val spanStart = text.getSpanStart(what)
            val spanEnd = text.getSpanEnd(what)
            text.getSpans(spanStart, spanEnd, Any::class.java).filter {
                removePredicate.invoke(it)
            }.forEach {
                text.removeSpan(it)
            }
        }
    }

    override fun onSpanRemoved(text: Spannable, what: Any, start: Int, end: Int) {
    }

    override fun onSpanAdded(text: Spannable, what: Any, start: Int, end: Int) {
    }


}

现在,我们让微信也跑起来。

editText.setEditableFactory(NoCopySpanEditableFactory(DirtySpanWatcher{
    it is ForegroundColorSpan || it is RemoveOnDirtySpan
}))
editText.setOnKeyListener { v, keyCode, event ->
    if (keyCode == KeyEvent.KEYCODE_DEL && event.action == KeyEvent.ACTION_DOWN) {
        KeyCodeDeleteHelper.onDelDown((v as EditText).text)
    }
    return@setOnKeyListener false
}

需要注意,微信和微博有一点小区别,微博有二次确认删除选中,微信没有。代码上的差别仅仅是微信少了一个return@setOnKeyListener

微信效果.gif

QQ的做法

QQ的做法太简单,我不太想讲它。这里写一个简单的Demo演示一下。
QQ同样需要用到DataBindingSpan<T>,甚至你也可以不用。它的核心是ImageSpan。

class SpannableData(private val spanned: String): DataBindingSpan<String> {

    override fun spannedText(): CharSequence {
        return SpannableString("@$spanned ").apply {
            setSpan(ImageSpan(LabelDrawable("@$spanned", color = Color.LTGRAY), spanned), 0, length-1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
        }
    }

    override fun bindingData(): String {
        return spanned
    }
}

现在只需要实现一个绘制文字的Drawable,这里我取名叫LabelDrawable,也许并不准确。

class LabelDrawable(val text: CharSequence, private val textPaint: TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
    textSize = 42f
    this.color = Color.DKGRAY
    textAlign = Paint.Align.CENTER
}, color: Int): ColorDrawable(color) {


    init {
        calculateBounds()
    }

    override fun draw(canvas: Canvas) {
        super.draw(canvas)
        canvas.drawText(text, 0, text.length, bounds.centerX().toFloat(), bounds.centerY().toFloat() + getBaselineOffset(textPaint.fontMetrics), textPaint)
    }

    private fun calculateBounds() {
        textPaint.getTextBounds(text.toString(), 0, text.length, bounds)
        bounds.inset(-8, -4)
        bounds.offset(8, 0)
    }

    private fun getBaselineOffset(fontMetrics: Paint.FontMetrics): Float {
        return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent
    }
}

就像普通的Span一样使用他就行了。

QQ效果.gif

如果想要做的更好一点,你需要处理多行文本measure、layout、draw等问题。给个小提示,TextView截屏也是一个Drawable。如果有一个View,即使它并未attach到Window上,我们也可以手动调用measure()、layout()、draw()方法获取一个View的截图Drawable用来添加到ImageSpan中使用,不过这样无法响应触摸事件。

三、获取文本中绑定的数据

val strings = editText.text.let {
    it.getSpans(0, it.length, DataBindingSpan::class.java)
}.map { it.bindingData() }

github

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

推荐阅读更多精彩内容