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閱讀 230,362評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,577評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,486評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,852評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,600評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,944評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,944評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,108評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,652評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,385評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,616評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,111評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,798評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,205評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,537評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,334評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,570評論 2 379

推薦閱讀更多精彩內容