自定义View浅析

前言

 Android发展至今,像Compose、Flutter等新UI技术不算更新迭代,但我始终认为,原生UI还是不能完全落下的,所以趁着有时间,简单梳理一下自定义View的过程。

注:以下源码基于Android ADK API 31

 我们都知道,View的绘制要经过三部曲(measure、layout、draw),也就是测量,然后布局,最后才能绘制出来。那我们就跟着这个步骤,通过一个经典的流式布局(ViewGroup),来梳理一下。

 当然了,有一点要清楚,假如我们的View是单纯的View(无法容纳子View),那么只需要去重写onMeasure和onDraw就行,因为布局会由父View来控制;反之,如果我们的View时单纯的ViewGroup(可容纳子View),那我们只需要去重写onMeasure和onLayout就行,因为所容纳的子View会自己执行绘制操作。后面会详细再说,那就让我们开始吧。

onMeasure

 顾名思义,就是用来测量(我们自己)的,是View的一个方法,而且它是有默认实现的:

     /**
     * 测量视图及其内容以确定测量的宽度和测量的高度。此方法由 {@link measure(int, int)} 调用,应由子 
     * 类重写,以提供对其内容的准确有效的测量。
     *
     * 重写此方法时,必须调用 {@link setMeasuredDimension(int, int)} 来存储此视图的测量宽度和高度。 
     * 否则将触发IllegalStateException,由 {@link measure(int, int)} 引发。
     */
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
    }

 通过注释我们可以知道,onMeasure方法确实就是用来测量当前视图宽高的,那么假如当前视图是容器类,那么我们就需要通过容纳的子View来算出自己的宽高值。还有就是我们测量得到当前视图的宽高值之后,要通过setMeasuredDimension来将值存储起来,否则当调用measure方法的时候会异常。measure方法也是View的一个方法,就先不看了,我们只需要知道会在measure方法中回到到onMeasure就行了。

 这里View的默认实现就是直接通过getDefaultSize方法来拿到宽高的最小值设置保存起来,这里就不展开了,等后面说完大家看下源码就清楚了。我们着重看一下widthMeasureSpec和heightMeasureSpec这两个参数,它们是父类传递过来给当前View的一个建议值,即想把当前View的尺寸设置为多少,而这个多少,就存在widthMeasureSpec和heightMeasureSpec里。从这里开始就涉及到MeasureSpec这个重要的知识点了。

MeasureSpec

 你可能会问,onMeasure方法里传来的明明是整形,而不是MeasureSpec类啊。没错,我依然记得当初在开始学自定义View时,同样的懵逼感。其实widthMeasureSpec和heightMeasureSpec它们是由mode+size两部分组成的,将它们转换为二进制(32位)之后,前2位代表模式,后30位则代表建议数值。而MeasureSpec其实是View的一个内部类,它帮我们封装了一系列的转换方法,你可以把它理解为工具类。

 模式有三种:

  • EXACTLY(精确):父view决定子view的确切大小,子view会被限制在给定的边界里而忽视自身的大小。
  • AT_MOST(至多):子view可以随心所欲地大到指定大小。
  • UNSPECIFIED(未指定):父view未对子view施加任何约束,它可以是它想要的任何大小。

 不同模式下,子view就会有不同的大小。我们简单看下MeasureSpec内部实现:

public static class MeasureSpec {
        private static final int MODE_SHIFT = 30;
        private static final int MODE_MASK  = 0x3 << MODE_SHIFT;

        public static int getMode(int measureSpec) {
            //noinspection ResourceType
            return (measureSpec & MODE_MASK);
        }

        public static int getSize(int measureSpec) {
            return (measureSpec & ~MODE_MASK);
        }

        //省却其他方法
}

 可以看到,先存了一个MODE_MASK值(0x3转为二进制就是011,然后左移30位变成11 0000...0000(高2位为1, 后30位为0)),为了后边取高二位和低三十位时更好操作。获取模式时(也就是高2位),通过和MODE_MASK相与,弃掉后30位,则可以得到高2位的值;获取尺寸时(后30位),则可以先把MODE_MASK取反(变成高2位为0,低30位为1),再相于,则可以舍弃高2位,获取到后30位的值。

 通过以下代码我们即可轻松获取到widthMeasureSpec和heightMeasureSpec的模式和数值:

//先通过MeasureSpec来获取父类传给我们的宽高推荐值(mode和size)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)

 那模式有什么用?其实你可能已经猜到了,对应的就是我们在XML布局时,给宽高设置的match_parent、wrap_content以及具体的大小,那它们的对应关系就是:

  • wrap_content => AT_MOST
  • match_parent => EXACTLY
  • 具体值 => EXACTLY

 这样很好理解,匹配父view尺寸和具体值都算是固定的值,那么就属于EXACTLY模式;而适配内容大小并不确定值的大小,那么就属于AT_MOST,在父view的限制内,随心所欲控制自己的大小。比如我们看下这个布局:

<com.example.customdrugview.TagFlowLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent">

</com.example.customdrugview.TagFlowLayout>

 当回到到TagFlowLayout的onMeasure方法时,这时候widthMeasureSpec的模式就是MeasureSpec.EXACTLY(对应match_parent),即占满父view;heightMeasureSpec的模式则为MeasureSpec.AT_MOST(对应wrap_content),在父view的空间限制下,大小不确定。其实,这也就表明了,当模式为MeasureSpec.EXACTLY时,大小为固值,是用户(使用方)指定的,那我们就不应去更改。而当用户把布局设置为wrap_content,那么具体的大小就应该由我们来计算得出了。

 所以整体流程应该就是:

val widthMeasureMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSuggestSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMeasureMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSuggestSize = MeasureSpec.getSize(heightMeasureSpec)

//计算具体宽高(width、height)...

//通过子view测量完自己的尺寸之后,设置给系统
val backWidth = if (widthMeasureMode == MeasureSpec.EXACTLY) widthSuggestSize else width
val backHeight = if (heightMeasureMode == MeasureSpec.EXACTLY) heightSuggestSize else height
setMeasuredDimension(backWidth, backHeight)

 进入正题,既然我们要实现的是一个流式布局,那么我们在测量自己的时候,就应该先知道子view的大小,按我们的需求先去排列子view,然后才能测算出我们本身的尺寸。也就说,当一行排不下的时候,我们就要去换行,最后拿到最宽的行作为我们自身的宽度值,把每行的高度值叠加起来,作为我们自身的高度值。看下计算宽高的实现代码:

       //设置保存当前行宽高值变量
        var lineWith = 0
        var lineHeight = 0
        //总宽和总高
        var width = 0
        var height = 0

        //遍历得到子类的测量值
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            //一样,我们把推荐值传递给子view,让子view测量自己
            //TODO 这里误调了child.measure()导致了问题
            //child.measure(widthMeasureSpec, heightMeasureSpec)
            measureChild(child, widthMeasureSpec, heightMeasureSpec)

            //注意:这里只有子view先测量完了,我们才能拿到它的measuredWidth和measuredHeight
            val childWidth = child.measuredWidth
            val childHeight = child.measuredHeight

            //如果当前行的宽度加上当前子view的宽度超过父类给我们的推荐宽度,那么就需要进行换行
            if (lineWith + childWidth < widthSuggestSize) {
                //不换行
                lineWith += childWidth
                lineHeight = Math.max(lineHeight, childHeight) //取最大行高
            } else {
                //换行
                //记录上一行的数据
                height += lineHeight
                width = Math.max(width, lineWith)
                //重置行高行宽,开启新一行
                lineHeight = childHeight
                lineWith = childWidth
            }
            //因为我们前面是在换行时去累加上一行的数据,所以要在最后一个子view时累加当前行的数据
            if (i == childCount - 1) {
                height += lineHeight
                width = Math.max(width, lineWith)
            }
        }

 逻辑上还是很清晰的,遍历子view来测算我们自身的总宽高。有关于坐标轴,如果不清楚的童鞋自己补一下哈,就不多说了。这里要注意的是,我们只有先去measureChild子view,才能得到子view的测量宽高,也就是说getMeasuredWidth得到的是测量后的值,。可以看到,我一开始写的时候,直接调用了child.measure()而不是measureChild()方法,结果就是导致了实际布局绘制出来有问题。我们来看看measureChild()方法:

protected void measureChild(View child, int parentWidthMeasureSpec,
            int parentHeightMeasureSpec) {
        final LayoutParams lp = child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                mPaddingLeft + mPaddingRight, lp.width);
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                mPaddingTop + mPaddingBottom, lp.height);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

 明显可以看到,measureChild方法先拿到了子view的布局参数,通过getChildMeasureSpec方法重新生成新的宽高推荐值childWidthMeasureSpec、childHeightMeasureSpec,然后再通过child.measure()方法传递给子类。这里就不展开了,getChildMeasureSpec方法其实就是根据子view的布局参数以及父类提供的MeasureSpec不同模式来重新组装推荐的宽高MeasureSpec。这也就是我们上面说的,onMeasure()方法会在measure()中被回调,然后我们就在onMeasure()方法中接收到了父view的宽高推荐值。这样一来,子view就也会接收到我们给它们的推荐值,然后进行自己的measure操作。可以发现,其实整个view树的绘制就是一个递归的过程。

onLayout

 其实布局逻辑上也差不多,不同的是onLayout方法的重心是对子view的布局(因为我们是ViewGroup容器)。onLayout方法继承自ViewGroup,但是它是抽象方法,也就是我们必须要实现的。

@Override
protected abstract void onLayout(boolean changed, int l, int t, int r, int b);

 我们要做的就是把子view一个一个按照我们希望的方式,布局到我们自身所在的范围(容器)内,通过调用View的layout(int l, int t, int r, int b)方法来实现。而这四个参数,就是子view定位自身所在的坐标。left代表距离(父view)Y轴的距离(左边),top代表距离(父view)X轴的距离(上边),right代表距离(父view)Y轴的距离(右边),bottom代表距离(父view)X轴的距离(下边)。这里要注意的是,这四个参数都是相对父容器来说的,我们可以把父view的左边当成当前的Y轴,上边当成当前的X轴。这四条边一组合,是不是就构成了一个矩形区域出来了。还是一样,当前行放不下子view的时候,就需要换行,我们看下代码:

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        //当前行行高
        var lineHeight = 0
        //当前left值
        var curLeft = 0
        //当前top值
        var curTop = 0
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            val childWidth = child.measuredWidth
            val childHeight = child.measuredHeight

            //当子view的测量宽度加上去超过我们本身的测量宽度的话,就需要换行
            if (curLeft + childWidth < measuredWidth) {
                //不换行
                lineHeight = Math.max(lineHeight, childHeight)
            } else {
                //换行
                curLeft = 0 //重置curLeft
                curTop += lineHeight //更新curTop
            }

            val curL = curLeft
            val curT = curTop
            val curR = curL + child.measuredWidth
            val curB = curT + child.measuredHeight
            child.layout(curL, curT, curR, curB)
            curLeft += childWidth
        }
    }

 我们大概理一下,其实还是通过遍历子view,然后一个一个对子view布局。因为当前一个子view布局结束之后,位置也就固定了,所以我们就只需要curLeft、curTop来保存当前子view左边和上边的起始位置就行,甚至会比onMeasure的时候轻松点。以及lineHeight来记录每行的行高,因为每次换行新起下一行的时候,curTop的起始点是要在上一行行高最大的位置开始的。最后确定好子view四条边的位置,去layout就行了,别忘记布局完就去更新curLeft的值,为下次布局做准备。

 我们去layout在当前容器内的子view,那谁来布局我们呢?当然也是当前容器的父类了。而一层一层往上推,顶层的容器是谁来布局的呢?其实是由ViewRootImpl类来处理的,具体可以看Android Window机制解析

 提一下,你会发现,我们这里用得都是前面说的测量之后的值(child.measuredWidth),而其实还有一个比较容易混淆的方法就是getWith()方法,它们有什么区别呢?getMeasuredWidth()获取到的是measure过程结束之后得到的值,通过setMeasuredDimension()来设置;而getWith()则是实际布局也就是layout过程结束之后,通过坐标计算得到的值。一般情况下,两个方法获得的值是一样的,但如果我们在布局的时候不按测量的推荐值来设置坐标,那么两个值就会不一致了。

 大功告成,让我们来康康成果如何:


TagFlowView.png

 啊哈,还是不错的,基本是按照我们的想法实现了(这里我给TextView加了背景样式)。但是吧,贴在一起看着怪怪的,给TextView加点margin看看会不会好一点。加完重新跑一下,居然发现没有变化,这是为什么呢?

 这是因为我们并没有在TagFlowLayout容器内处理子view的外边距(margin),所以给TextView加margin自然是没有用的,这是MarginLayoutParams就派上用场了。

MarginLayoutParams

 我们需要重写三个方法,并返回MarginLayoutParams实例。

/**
     * 如果要支持子view的margin参数,就需要重写一下三个方法
     */
    override fun generateLayoutParams(p: LayoutParams?): LayoutParams {
        return MarginLayoutParams(p)
    }

    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context, attrs)
    }

    override fun generateDefaultLayoutParams(): LayoutParams {
        return MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
    }

 这三个方法都是继承自ViewGroup的,我们看下generateLayoutParams及相关方法:

public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new LayoutParams(getContext(), attrs);
    }

public LayoutParams(Context c, AttributeSet attrs) {
            TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_Layout);
            setBaseAttributes(a,
                    R.styleable.ViewGroup_Layout_layout_width,
                    R.styleable.ViewGroup_Layout_layout_height);
            a.recycle();
 }

protected void setBaseAttributes(TypedArray a, int widthAttr, int heightAttr) {
            width = a.getLayoutDimension(widthAttr, "layout_width");
            height = a.getLayoutDimension(heightAttr, "layout_height");
}

 很明显,从布局中取到宽高的布局参数,然后为我们封装了LayoutParams。而generateDefaultLayoutParams方法就是帮我们封装了宽高都是WRAP_CONTENT模式的LayoutParams实例,这里就不贴了。所以,我们想要拿到子view的margin值,就需要通过返回MarginLayoutParams实例,从布局中获取对应的margin值:

public MarginLayoutParams(Context c, AttributeSet attrs) {
            super();

            TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_MarginLayout);
            setBaseAttributes(a,
                    R.styleable.ViewGroup_MarginLayout_layout_width,
                    R.styleable.ViewGroup_MarginLayout_layout_height);

            int margin = a.getDimensionPixelSize(
                    com.android.internal.R.styleable.ViewGroup_MarginLayout_layout_margin, -1);
            if (margin >= 0) {
                leftMargin = margin;
                topMargin = margin;
                rightMargin= margin;
                bottomMargin = margin;
            } else {
                int horizontalMargin = a.getDimensionPixelSize(
                        R.styleable.ViewGroup_MarginLayout_layout_marginHorizontal, -1);
                int verticalMargin = a.getDimensionPixelSize(
                        R.styleable.ViewGroup_MarginLayout_layout_marginVertical, -1);

                if (horizontalMargin >= 0) {
                    leftMargin = horizontalMargin;
                    rightMargin = horizontalMargin;
                } else {
                    leftMargin = a.getDimensionPixelSize(
                            R.styleable.ViewGroup_MarginLayout_layout_marginLeft,
                            UNDEFINED_MARGIN);
                    if (leftMargin == UNDEFINED_MARGIN) {
                        mMarginFlags |= LEFT_MARGIN_UNDEFINED_MASK;
                        leftMargin = DEFAULT_MARGIN_RESOLVED;
                    }
                    rightMargin = a.getDimensionPixelSize(
                            R.styleable.ViewGroup_MarginLayout_layout_marginRight,
                            UNDEFINED_MARGIN);
                    if (rightMargin == UNDEFINED_MARGIN) {
                        mMarginFlags |= RIGHT_MARGIN_UNDEFINED_MASK;
                        rightMargin = DEFAULT_MARGIN_RESOLVED;
                    }
                }

                startMargin = a.getDimensionPixelSize(
                        R.styleable.ViewGroup_MarginLayout_layout_marginStart,
                        DEFAULT_MARGIN_RELATIVE);
                endMargin = a.getDimensionPixelSize(
                        R.styleable.ViewGroup_MarginLayout_layout_marginEnd,
                        DEFAULT_MARGIN_RELATIVE);

                if (verticalMargin >= 0) {
                    topMargin = verticalMargin;
                    bottomMargin = verticalMargin;
                } else {
                    topMargin = a.getDimensionPixelSize(
                            R.styleable.ViewGroup_MarginLayout_layout_marginTop,
                            DEFAULT_MARGIN_RESOLVED);
                    bottomMargin = a.getDimensionPixelSize(
                            R.styleable.ViewGroup_MarginLayout_layout_marginBottom,
                            DEFAULT_MARGIN_RESOLVED);
                }
           }
            //省略一些不相关代码
            a.recycle();
}

 逻辑也很清晰,帮我们从xml布局中获取了用户设置的margin属性(当然宽高也有),所以这也就是为什么我们要重写generateLayoutParams和generateDefaultLayoutParams方法,并返回MarginLayoutParams实例了。

 那接下来就可以修改一下TagFlowLayout的逻辑,来支持子view的margin属性了。测量的时候简单一点,因为我们知道把margin值算入子view的测量宽高值就行了:

//注意:这里只有子view先测量完了,我们才能拿到它的measuredWidth和measuredHeight
// val childWidth = child.measuredWidth
// val childHeight = child.measuredHeight

//把margin加入计算
val childWidth = child.measuredWidth + layoutParams.leftMargin + layoutParams.rightMargin
val childHeight = child.measuredHeight + layoutParams.topMargin + layoutParams.bottomMargin

 布局的时候稍微麻烦点,还要在layout子view时,把margin加入left和top点的计算,有两个修改点。不过总体也很好理解,因为是子view的外边距,那我们layout子view时就应该把这部分空间预留出来。

for (i in 0 until childCount) {
            val child = getChildAt(i)
            //一样,先拿到子view的margin参数,然后加入计算
            val layoutParams = child.layoutParams as MarginLayoutParams

            //修改点1
            //val childWidth = child.measuredWidth
            //val childHeight = child.measuredHeight
            val childWidth =
                child.measuredWidth + layoutParams.leftMargin + layoutParams.rightMargin
            val childHeight =
                child.measuredHeight + layoutParams.topMargin + layoutParams.bottomMargin

            //当子view的测量宽度加上去超过我们本身的测量宽度的话,就需要换行
            if (curLeft + childWidth < measuredWidth) {
                //不换行
                lineHeight = Math.max(lineHeight, childHeight)
            } else {
                //换行
                curLeft = 0 //重置curLeft
                curTop += lineHeight //更新curTop
            }

            //修改点2
            //val curL = curLeft
            //val curT = curTop
            val curL = curLeft + layoutParams.leftMargin
            val curT = curTop + layoutParams.topMargin
            val curR = curL + child.measuredWidth
            val curB = curT + child.measuredHeight

            child.layout(curL, curT, curR, curB)
            curLeft += childWidth
 }

 再看看效果:


TagFlowView2.png

 可以发现,效果已经展现出来啦!这个时候,有的朋友(无中生友)就会想,那如果我们某个TextView的长度大到超过屏幕宽度,那会不会异常啊,因为我们好像也没处理这种情况。当然不会哈,长度过长TextView就会自动帮我们换行。


TagFlowView3.png

 那有的朋友可能又会问了,那padding呢?一样的,我们只要在测量和布局时加上自身的padding尺寸去算就行。

总结

 到这里,大概流程差不多就讲完了,我们再来简单梳理一下。首先,因为我们自定义的是ViewGroup,所以我们只需要关心测量(onMeasure)和布局(onLayout)。为什么要测量呢,就是需要通过容纳的子view来推算出我们具体的尺寸,储存起来为布局做准备,所最后要调用setMeasuredDimension()方法来保存计算出来的尺寸。这里还涉及到MeasureSpec的模式问题,要好好理解一下。然后就是为子view布局了,因为上一步我们已经测量好自身的尺寸了,那么我们就可以根据自己的需求来对子view进行布局。比如我们要做的是一个流式布局,那么就要一步一步推算出子view的位置,空间不够时就进行换行,然后通过调用子view的layout方法来实现对子view的布局。

 其实关于自定义View有太多太多东西了,但碍于篇幅和本人自己技术的局限,没办法全部展开。总体下来,感觉梳理得还是有点生涩,希望有误的地方大家可以帮忙指正,源码放在gayhub

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

推荐阅读更多精彩内容