Android 自定义 View

前言

自定义 View 有几种实现类型,分别为:

  • 继承自 View 完全自定义;

  • 继承自现有控件(如 ImageView)实现特定效果;

  • 继承自 ViewGroup 实现布局类。

比较重要的知识点是 View 的测量与布局、View 的绘制、处理触摸事件、动画等。

一、最为自由的一种实现 — 自定义 View

对于继承自 View 类的自定义控件来说,核心的步骤分别为尺寸测量绘制,对应的函数是 onMeasure()onDraw()。这里我们讨论的 View 类型的子类是非 ViewGroup 类型,属于视图树的叶子节点,因此,它只负责绘制好自身内容即可,而这两步就是完成它职责的所有工作。

下面我们来简单实现一个显示图片的 ImageView,它能根据用户设置的大小将图片缩放,使得图片在任何尺寸下都能够正确显示:

/**
 * 简单的ImageView,用于显示图片
 */
public class SimpleImageView extends View {

    // 画笔
    private Paint mBitmapPaint;
    // 图片drawable
    private Drawable mDrawable;
    // 要绘制的图片
    Bitmap mBitmap;
    // view的宽度
    private int mWidth;
    // view的高度
    private int mHeight;

    public SimpleImageView(Context context) {
        this(context, null);
    }

    public SimpleImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
        //根据属性初始化
        initAttrs(attrs);
        //初始化画笔
        mBitmapPaint = new Paint();
        //抗锯齿
        mBitmapPaint.setAntiAlias(true);
        //设置颜色
        mBitmapPaint.setColor(Color.RED);
    }

    private void initAttrs(AttributeSet attrs) {
        if (attrs != null) {
            TypedArray array = null;
            try {
                //获取自定义View的属性集
                array = getContext().obtainStyledAttributes(attrs, R.styleable.SimpleImageView);
                //根据图片id获取到Drawable对象
                mDrawable = array.getDrawable(R.styleable.SimpleImageView_src);
                //测量Drawable对象的宽和高
                measureDrawable();
            } finally {
                if (array != null) {
                    array.recycle();
                }
            }
        }
    }

首先我们创建一个继承自 ViewSimpleImageView 类,在构造函数中获取该控件的属性,并且初始化要绘制的图片和画笔。我们在 values/attr.xml 中定义这个 View 的属性,attr.xml 中的内容如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="SimpleImageView">
        <attr name="src" format="integer" />
    </declare-styleable>
</resources>

该属性集的名字为 SimpleImageView,里面只有一个名为 src 的整型属性。我们通过这个属性为 SimpleImageView 设置图片的资源 id。代码如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.example.jerry.myapplication.activity.MainActivity">

    <com.example.jerry.myapplication.fjtm.SimpleImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        img:src="@drawable/roger_federer" />
</RelativeLayout>

注意:在使用自定义的属性时,我们需要将该属性所在的命名空间引入到 xml 文件中,命名空间实际上就是该工程的应用包名。因为自定义的属性集最终会编译为 R 类,R 类的完整路径是 应用的包名.R

比如我的应用包名为 com.example.jerry.application,因此,我们引入了一个名为 img 的命名空间,它的格式为:

xmlns:名字="http://schemas.android.com/apk/res/包名"

xmlns:img="http://schemas.android.com/apk/res/com.example.jerry.myapplication"

其实有没有觉得这很麻烦,同一个包名可以指定的命名空间是多种多样的,然而我们还要为不同的命名空间声明同样的值。就连 Android Studio 都看不过眼了:

上面的意思就是说,我们只需要引入

    xmlns:app="http://schemas.android.com/apk/res-auto"

这个命名空间,编译时就会自动帮我们找到对应的命名空间而不用传入具体的包名。如下,将 img:src 改为 app:src

    <com.example.jerry.myapplication.fjtm.SimpleImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:src="@drawable/roger_federer" />

当应用启动时会从这个 xml 布局中解析出 SimpleImageView 的属性,宽度和高度都为 wrap_content,src 属性为 drawable 目录下的 roger_federer,进入构造函数后会调用 initAttrs() 函数进行初始化。

initAttrs() 函数中,我们先读取 SimpleImageView 的属性集 TypedArray;再从该对象中读取 SimpleImageView_src 属性值,该属性是一个 drawable 的资源 id 值。然后我们根据这个 id 从 TypedArray 对象中获取到该 id 对应的 Drawable;最后我们调用 measureDrawable() 函数测量该图片的大小。代码如下:

    private void measureDrawable() {
        if (mDrawable == null) {
            throw new RuntimeException("drawable不能为空!");
        }
        //获取Drawable的固有宽度和高度,返回的单位是dp
        mWidth = mDrawable.getIntrinsicWidth();
        mHeight = mDrawable.getIntrinsicHeight();

        Log.e(VIEW_LOG_TAG, "### width = " + mWidth + ", height = " + mHeight);
    }

我们将图片的宽高设给 SimpleImageView,也就是说图片多大,SimpleImageView 就有多大。注意:getIntrinsicWidth()getIntrinsicHeight() 获取的是 Drawable 的固有宽高,但是它们返回的单位是 dp,所以显示出来的图片有可能比原来的图片大或小。测量结束之后,接下来就是绘制该视图了。代码如下:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //设置View的宽高为图片的宽高
        setMeasuredDimension(mWidth, mHeight);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (mDrawable == null) {
            return;
        }

        // 绘制图片
        canvas.drawBitmap(ImageUtils.drawableToBitamp(mDrawable),
                getLeft(), getTop(), mBitmapPaint);
    }
}

ImageUtils 类是用来将 Drawable 转为 Bitmap 的,代码如下:

public final class ImageUtils {
    private ImageUtils() {

    }

    /**
     * drawable转bitmap
     *
     * @param drawable
     * @return
     */
    public static Bitmap drawableToBitamp(Drawable drawable) {
        if (drawable instanceof BitmapDrawable) {
            BitmapDrawable bd = (BitmapDrawable) drawable;
            return bd.getBitmap();
        }
        int w = drawable.getIntrinsicWidth();
        int h = drawable.getIntrinsicHeight();
        Bitmap bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_4444);
        Canvas canvas = new Canvas(bitmap);
        drawable.setBounds(0, 0, w, h);
        drawable.draw(canvas);
        return bitmap;
    }
}

最后我们来总结一下这个过程:

  1. 继承自 View 创建自定义控件;

  2. 如有需要自定义 View 属性,也就是在 values/attrs.xml 中定义属性集;

  3. 在 xml 中引入命名文件,设置属性;(最新的 Android Studio 已经可以帮我们省去这一步,使用 app 命名空间即可,详情请回顾前文。)

  4. 在代码中读取 xml 中的属性,初始化视图;

  5. 测量视图大小 — onMeasure()

  6. 绘制视图内容 — onDraw():使用 canvas(画布) 和 paint(画笔) 绘制。

疑问?

如果 SimpleImageView 的宽、高设置为 match_parent 会怎么样,设置为指定的大小又会正常显示吗?

总结: 视图的 left,top,right,bottom 的值是针对其父视图的相对位置。

getTop 确实是 View 顶部距离父容器顶部的距离,但是:getBottom 却是 View 底部距离父容器顶部的距离,并不是距离父容器底部。

这里在补充一个知识点:getBottom 的值就等于 getTop + View.getMeasuredHeight()。

二、View 的尺寸测量

对于非 ViewGroup 类型来说,视图布局 — onLayout() 这个步骤是不需要的,因为它并不是一个视图容器。它只需要完成测量尺寸和绘制自身内容的工作,上述 SimpleImageView 就是这样的例子。

但是,SimpleImageView 的尺寸测量只能根据图片的大小进行设置,如果用户想支持 match_parent 和具体的宽高值则不会生效,SimpleImageView 的宽高还是图片的宽高。因此,我们需要根据用户设置的宽高模式来计算 SimpleImageView 的尺寸,而不是一概地使用图片的宽高值作为视图的宽高。

在视图树渲染时,View 系统的绘制流程会从 ViewRoot 的 performTraversals() 方法中开始,在其内部调用 View 的 measure() 方法。measure() 方法接收两个参数:widthMeasureSpecheightMeasureSpec,这两个值分别用于确定视图的宽度、高度的规格和大小。

MeasureSpec 的值由 specSizespecMode 共同组成,其中 specSize 记录的是大小,specMode 记录的是规格。在支持 match_parent、具体宽高值之前,我们需要了解 specMode 的 3 种类型:

模式类型 说明
EXACTLY 表示父视图希望子视图的大小应该是由 specSize 的值来决定的,系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。match_parent、具体的数值(如 100dp)对应的都是这个模式。
AT_MOST 表示子视图最多只能是 specSize 中指定的大小,开发人员应该尽可能小地去设置这个视图,并且保证不会超过 specSize。系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。一般来说 wrap_content 对应这种模式。
UNSPECIFIED 表示开发人员可以将视图按照自己的意愿设置成任意的大小,没有任何限制。这种情况比较少见,不太会用到。

那么这两个 MeasureSpec 又是从哪里来的呢?其实这是从整个视图树的控制类 ViewRootImpl 中创建的,在 ViewRootImpl 的 measureHierarchy() 函数中会调用如下代码获取 MeasureSpec:

        if (!goodMeasure) {
            //获取MeasureSpec
            childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width);
            childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);

            //执行测量过程
            performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
            if (mWidth != host.getMeasuredWidth() || mHeight != host.getMeasuredHeight()) {
                windowSizeMayChange = true;
            }
        }

从上述代码中可以看到,这里调用了 getRootMeasureSpec() 方法来获取 widthMeasureSpecheightMeasureSpec 的值。注意,方法中传入的参数,参数 1 为窗口的宽度或者高度,而 lp.widthlp.height 在创建 ViewGroup 实例时就被赋值了,它们都等于 MATCH_PARENT。然后看一下 getRootMeasureSpec() 方法的源码:

    /**
     * Figures out the measure spec for the root view in a window based on it's
     * layout params.
     *
     * @param windowSize
     *            The available width or height of the window
     *
     * @param rootDimension
     *            The layout params for one dimension (width or height) of the
     *            window.
     *
     * @return The measure spec to use to measure the root view.
     */
    private static int getRootMeasureSpec(int windowSize, int rootDimension) {
        int measureSpec;
        switch (rootDimension) {

        case ViewGroup.LayoutParams.MATCH_PARENT:
            // Window can't resize. Force root view to be windowSize.
            measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
            break;
        case ViewGroup.LayoutParams.WRAP_CONTENT:
            // Window can resize. Set max size for root view.
            measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
            break;
        default:
            // Window wants to be an exact size. Force root view to be that size.
            measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
            break;
        }
        return measureSpec;
    }

从上述程序中可以看到,这里使用了 MeasureSpec.makeMeasureSpec() 方法来组装一个 MeasureSpec。

  • 当 rootDimension 参数等于 MATCH_PARENT 时,MeasureSpec 的 specMode 就等于 EXACTLY

  • 当 rootDimension 等于 WRAP_CONTENT 时,MeasureSpec 的 specMode 就等于 AT_MOST,并且 MATCH_PARENTWRAP_CONTENT 的 specSize 都是等于 windowSize,也就意味着根视图总是会充满全屏的。

  • 如果两者都不是,那就按照开发者自定义的大小。

当构建完根视图的 MeasureSpec 之后就会执行 performMeasure() 函数从根视图开始一层一层测量视图的大小。最终会调用每个 View 的 onMeasure() 函数,在该函数中用户需要根据 MeasureSpec 测量 View 的大小,最终调用 setMeasureDimension() 函数设置该视图的大小。下面我们看看 SimpleImageView 根据 MeasureSpec 设置大小的实现,代码如下:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //获取宽度的模式与大小
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int width = MeasureSpec.getSize(widthMeasureSpec);

        //获取高度的模式与大小
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);

        // 设置View的宽高
        setMeasuredDimension(measureWidth(widthMode, width), measureHeight(heightMode, height));
    }

    private int measureWidth(int mode, int width) {
        switch (mode) {
            case MeasureSpec.UNSPECIFIED:
                Log.e(VIEW_LOG_TAG, "### MeasureSpec.AT_MOST");
                break;

            case MeasureSpec.AT_MOST:
                Log.e(VIEW_LOG_TAG, "### MeasureSpec.AT_MOST");
                break;
            case MeasureSpec.EXACTLY:
                Log.e(VIEW_LOG_TAG, "### MeasureSpec.EXACTLY , width = " + width);
                mWidth = width;
                break;
        }
        return mWidth;
    }

    private int measureHeight(int mode, int height) {
        switch (mode) {
            case MeasureSpec.UNSPECIFIED:
                break;

            case MeasureSpec.AT_MOST:
                break;
            case MeasureSpec.EXACTLY:
                Log.e(VIEW_LOG_TAG, "### MeasureSpec.EXACTLY , height = " + height);
                mHeight = height;
                break;
        }
        return mHeight;
    }


    @Override
    protected void onDraw(Canvas canvas) {
        if (mBitmap == null) {
            mBitmap = Bitmap.createScaledBitmap(ImageUtils.drawableToBitamp(mDrawable), getMeasuredWidth(),
                    getMeasuredHeight(), true);
        }
        // 绘制图片
        canvas.drawBitmap(mBitmap, getLeft(), getTop(), mBitmapPaint);
    }

onMeasure() 函数中我们获取宽、高的模式与大小,然后分别调用 measureWidth()measureHeight() 函数,根据 MeasureSpec 的 mode 与 size 计算 View 的具体大小。在 MeasureSpec.UNSPECIFIEDMeasureSpec.AT_MOST 类型中,我们都将 View 的宽高设置为图片的宽高,而用户指定了具体的大小或 match_parent 时,它的模式则为 EXACTLY,它的值就是 MeasureSpec 中的值。最后在绘制图片时,会根据 View 的大小重新创建一个图片,得到一个与 View 大小一致的 Bitmap,然后绘制到 View 上。三种效果如下图所示:

wrap_content
match_parent
具体值 120dp*180dp

View 的测量是自定义 View 中最为重要的一步,如果不能正确地测量视图的大小,那么将会导致视图显示不完整等情况,这将严重影响 View 的显示效果。因此,理解 MeasureSpec 以及正确的测量方法对于开发人员来说是必不可少的。

三、Canvas 与 Paint(画布和画笔)

对于 Android 来说,整个 View 就是一个画布,也就是 Canvas。开发人员可以通过画笔 Paint 在这张画布上绘制各种各样的图形、元素,例如矩形、圆形、椭圆、文字、圆弧、图片等,通过修改画笔的属性则可以将同一个元素绘制出不同的效果,例如设置画笔的颜色为红色,那么通过该画笔绘制一个矩形时,该矩形的颜色则为红色。

Canvas 的部分重要函数如下表所示:

函数名 作用
drawRect(Rect r, Paint paint) 绘制一个矩形,参数 1 为 RectF 一个区域
drawBitmap(Bitmap bitmap, float left, float top, Paint paint) 绘制一张图片,left 为左边起点,top 为上边起点
drawPath(Path path, Paint paint) 绘制一个路径,参数 1 为 Path 路径对象
drawLine(float startX, float startY, float stopX, float stopY, Paint paint) 绘制线段
drawText(String text, float x, float y, Paint paint) 绘制文本
drawOval(RectF oval, Paint paint) 绘制椭圆
drawCircle(float cx, float cy, float radius, Paint paint) 绘制圆形,参数 1 是中心点的 x 轴,参数 2 是中心点的 y 轴,参数 3 是半径,参数 4 是 Paint 对象
drawArc(RectF oval, float startAngle, float sweepAngle, boolean useCenter, Paint paint) 绘制扇形或弧形,圆形进度条就是使用这个函数不断地绘制扇形或者弧形实现
clipRect(int left, int top, int right, int bottom) 裁剪画布上的一个区域,使得后续的操作只在这个区域上有效
save() 存储当前矩阵和裁剪状态到一个私有的栈中。随后调用 translate,scale,rotate,skew,concat or clipRect,clipPath 等函数还是会正常执行,但是调用了 restore() 之后,这些调用产生的效果就会失效,在 save() 之前的 Canvas 状态就会被恢复。
restore() 恢复到 save() 之前的状态

Paint 的部分重要函数如下表所示:

函数名 作用
setARGB(int a, int r, int g, int b) 设置绘制的颜色,a 代表透明度,r、g、b 代表颜色值
setColor(int color) 设置绘制的颜色,使用颜色值来表示,该颜色值包括透明度和 RGB 颜色
setAntiAlias(boolean aa) 设置是否使用抗锯齿功能,会消耗较大资源,绘制图形速度会变慢
setShader(Shader shader) 设置图像效果,使用 Shader 可以绘制出各种渐变效果
setShadowLayer(float radius, float dx, float dy, int shadowColor) 在图形下面设置阴影层,产生阴影效果,radius 为阴影的角度,dx 和 dy 为阴影在 x 轴和 y 轴上的距离,color 为阴影的颜色
setStyle(Style style) 设置画笔的样式,为 FILL、FILL_OR_STROKE 或 STROKE。Style.FILL:实心;STROKE:空心;FILL_OR_STROKE:同时实心与空心
setStrokeCap(Cap cap) 当画笔样式为 STROKE 或 FILL_OR_STROKE 时,设置笔刷的图像样式,如圆形样式:Cap.ROUND;或方形样式:Cap.SQUARE
setStrokeWidth(float width) 当画笔样式为 STROKE 或 FILL_OR_STROKE 时,设置笔刷的粗细度
setXfermode(Xfermode xfermode) 设置图形重叠时的处理模式,如合并、取交集或并集,经常用来制作橡皮的擦除效果
setTextSize(float textSize) 设置绘制文字的字号大小

onDraw() 方法中我们经常会调用 Canvas 的 save()restore() 方法,这两个方法很重要,那么它们的作用是什么呢?

有时候我们需要使用 Canvas 来绘制一些特殊的效果,在做这些特殊效果之前,我们希望保存原来的 Canvas 状态,此时需要调用 Canvas 的 save() 函数。执行 save() 之后,可以调用 Canvas 的平移、放缩、旋转、skew(倾斜)、裁剪等操作,然后再进行其他的绘制操作。当绘制完毕之后,我们需要调用 restore() 函数来恢复 Canvas 之前保存的状态。Canvas 的 save() 方法和 restore() 方法要配对使用,但要注意的是,restore() 的调用次数可以比 save() 函数少,不能多,否则会引发异常。

例如,我们需要在 SimpleImageView 中绘制一个竖向的文本,而 drawText() 函数默认是横向绘制的,如果直接在 onDraw() 函数中绘制文本,看看会发生什么,代码如下:

    @Override
    protected void onDraw(Canvas canvas) {
        if (mBitmap == null) {
            mBitmap = Bitmap.createScaledBitmap(ImageUtils.drawableToBitamp(mDrawable),
                    getMeasuredWidth(), getMeasuredHeight(), true);
        }
        // 绘制图片
        canvas.drawBitmap(mBitmap, getLeft(), getTop(), mBitmapPaint);
        mBitmapPaint.setColor(Color.RED);
        mBitmapPaint.setTextSize(40);
        canvas.drawText("Roger_Federer", getLeft() + 50, getTop() + 50, mBitmapPaint);
    }

效果如下图:

那我们怎么样才能实现将文字竖向显示呢?通常的思路是:在绘制文本之前将画布旋转一定的角度,使得画布的角度发生变化,此时再在画布上绘制文字,得到的效果就是文字被绘制为竖向的。代码如下:

    @Override
    protected void onDraw(Canvas canvas) {
        if (mBitmap == null) {
            mBitmap = Bitmap.createScaledBitmap(ImageUtils.drawableToBitamp(mDrawable),
                    getMeasuredWidth(), getMeasuredHeight(), true);
        }
        // 绘制图片
        canvas.drawBitmap(mBitmap, getLeft(), getTop(), mBitmapPaint);
        //保存画布状态
        canvas.save();
        //旋转90°
        canvas.rotate(90);
        mBitmapPaint.setColor(Color.RED);
        mBitmapPaint.setTextSize(40);
        //绘制文本
        canvas.drawText("Roger_Federer", getLeft() + 50, getTop() - 50, mBitmapPaint);
        //恢复原来的状态
        canvas.restore();
    }

效果如下图:

实现思路是在绘制文本之前将画布旋转 90°,即沿顺时针方向旋转 90°,然后再在画布上绘制文字,最后将画布 restore 到 save 之前的状态。

注意:
  • 这里的旋转是指坐标轴发生旋转,而不是画布发生旋转。

  • rotate() 的参数:正数代表顺时针,负数代表逆时针。

  • 注意 drawText() 中 x 和 y 的偏移值,什么时候用 + ,什么时候用 -。

  • 上述代码即使没有 save()restore() 也能实现相同的效果,之所以 save()restore() 是因为需要将坐标轴还原回默认的状态,防止之后的操作产生意料之外的结果。

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

推荐阅读更多精彩内容