FloatWindowUtils 实现及事件冲突解决详解

悬浮窗

悬浮窗即可以显示在宿主应用之外的 View 视图,理论上任何 View 都能以悬浮窗形式展示在宿主应用之外甚至锁屏界面,一般在工具类应用中使用的比较多,通过悬浮窗可以很方便的从外界与宿主应用进行交互,例如金山词霸的锁屏单词功能、AirDroid 的录制屏幕菜单、360优化大师的清理悬浮按钮等。

需要了解的

Window

Window 表示一个窗口的概念,在日常开发中直接接触 Window 的机会并不多,但是在特殊时候我们需要在桌面显示一个类似悬浮窗的东西,那么这种效果就需要用到 Window 来实现。Window 是一个抽象类,它的具体实现是 PhoneWindow。创建一个 Window 非常简单,我们通过 WindowManager 即可完成。 Android 中所有视图都是通过 Window 来呈现的,不管是 Activity、Dialog、还是 Toast,它们的视图实际上都是附加在 Window 上的。

WindowManager

应用程序用于与窗口管理器通信的接口,是外界访问 Window 的入口,使用 Context.getSystemService(Context.WINDOW_SERVICE) 获取它的实例。WindowManager提供了addView(View view, ViewGroup.LayoutParams params),removeView(View view),updateViewLayout(View view, ViewGroup.LayoutParams params)三个方法用来向设备屏幕 添加、移除以及更新 一个 view 。

WindowManager.LayoutParams

通过名字就可以看出来 它是WindowManager的一个内部类,专门用来描述 view 的属性 比如大小、透明度 、初始位置、视图层级等。

DisplayMetrics

该对象用来描述关于显示器的一些信息,例如其大小,密度和字体缩放。例如获取屏幕宽度DisplayMetrics.widthPixels 。

最终效果

example.gif

实现思路

本着实现一个简单的、轻量级的工具类的目的,通过传入一个任意 View 可以将其创建成可自由拖动的悬浮窗

悬浮一个 View

首先我们知道 View 能显示在屏幕上其实是间接通过 Window 管理的,那么我们就可以使用 WindowManager 来管理它,让它具备悬浮的属性,下面代码演示了通过 WindowManager 添加 Window 的过程,非常简单

final Button mBtn = new Button(this);
mBtn.setText("悬浮按钮");
mBtn.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        Toast.makeText(context,"click",Toast.LENGTH_SHORT).show();
    }
});
final WindowManager.LayoutParams mLayoutParams = new WindowManager.LayoutParams(WindowManager.LayoutParams.WRAP_CONTENT
        ,WindowManager.LayoutParams.WRAP_CONTENT,0,0, PixelFormat.TRANSPARENT);
mLayoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
        | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
        | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED;
mLayoutParams.gravity = Gravity.LEFT | Gravity.TOP; //view 处于屏幕的相对位置,注意这里必须是 LEFT & TOP,因为 Android 设备屏幕坐标原点在左上角
mLayoutParams.x = 100; //距离屏幕左侧100px
mLayoutParams.y = 300; //距离屏幕上方300px
mLayoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT; //指定 Window 类型为 TYPE_SYSTEM_ALERT,属于系统级别,就可以显示在系统屏幕上了
final WindowManager mWindowManager = getWindowManager();
mWindowManager.addView(mBtn,mLayoutParams);

别忘了系统级窗口权限

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>

效果如下

使其可以拖动

显然上面的 Button 只是能显示在系统屏幕上而已,并不能拖动,要使其能够拖动就要给它设置一个 View.OnTouchListener 来监听手指在屏幕上滑动的坐标然后根据这个坐标设置其位置,如下实现

mBtn.setOnTouchListener(new View.OnTouchListener() {
    //触摸点相对于view左上角的坐标
    float downX;
    float downY;
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        //获取触摸点相对于屏幕左上角的坐标
        float rowX = event.getRawX();
        float rowY = event.getRawY() - getStatusBarHeight(context);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downX = event.getX();
                downY = event.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                mLayoutParams.x = (int) (rowX - downX);  //计算当前触摸点相对于屏幕左上角的 X 轴位置
                mLayoutParams.y = (int) (rowY - downY);  //计算当前触摸点相对于屏幕左上角的 Y 轴位置
                mWindowManager.updateViewLayout(mBtn, mLayoutParams);  //更新 Button 到相应位置
                break;
            case MotionEvent.ACTION_UP:
                  //actionUp(event);
                break;
            case MotionEvent.ACTION_OUTSIDE:
                  //actionOutSide(event);
                break;
            default:
                break;
        }
        return false;
    }
});

解决点击和滑动的事件冲突

现在这个 Button 虽然可以跟着你的手指移动了,但是你会发现当你拖动一段较小距离时会有很大几率响应它的 Click 事件,这显然不能接受,在拖动这个 Button 的整个过程中会依次触发 ACTION_DOWN、ACTION_MOVE、ACTION_MOVE、... 、ACTION_UP,当 ACTION_MOVE 被触发时 ACTION_DOWN 会被释放,之后松开手指触发 ACTION_UP 是不会响应 Click 事件的, Click 事件的响应条件是 ACTION_DOWN + ACTION_UP,所以当我们拖动一个很小的距离时很容易造成 ACTION_DOWN 与 ACTION_UP 的连续触发而响应了 Click 事件,尤其是在 DPI 较高的设备上,下面是一个根据最小偏移量来判断是否应该响应 Click 事件的一种方式

...
//拖动的最小偏移量
int MIN_OFFSET = 5;
//是否视为 click 事件
boolean isClick = false;
@Override
public boolean onTouch(View v, MotionEvent event) {
    ...
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            isClick = true;
            ...
            break;
        case MotionEvent.ACTION_MOVE:
            ...
            // 通过拖拽的距离是否超过最小偏移量来判断点击事件
            if (Math.abs((rowX - downX)) > MIN_OFFSET && Math.abs((rowY - downY)) > MIN_OFFSET){
                isClick = false;
            }else {
                isClick = true;
            }
            break;
        case MotionEvent.ACTION_UP:
            if (isClick){
                // 执行点击事件
            }
            break;
        default:
            break;
    }
    return false;
}

最终改进

上述方式固然可以解决冲突问题,但是点击事件被放在 ACTION_UP 之下,或需要整个接口在外面调用很不优雅,下面的解决办法是通过父级 View 进行拦截,也就是将所有传进来的 View 先放入一个 ViewGroup 中,给这个 ViewGroup 设置 View.OnTouchListener,重写这个 ViewGroup 的 onInterceptTouchEvent 方法,根据拖拽的意图让它决定是否拦截所有事件不向下传递,从根本上解决冲突,并且把设置 Window 的属性相关也集成进去,外界只需传入一个 View 即可,下面是 FloatWindowUtils 全部实现过程

package cc.skyrin.autojumper.util;

import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.app.AppOpsManager;
import android.content.Context;
import android.content.Intent;
import android.graphics.PixelFormat;
import android.net.Uri;
import android.os.Binder;
import android.os.Build;
import android.provider.Settings;
import android.support.annotation.NonNull;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.LinearLayout;

import java.lang.reflect.Method;

/**
 * Created by skyrin on 2017/3/16.
 */

public class FloatWindow {

    private WindowManager.LayoutParams mLayoutParams;
    private WindowManager mWindowManager;
    private DisplayMetrics mDisplayMetrics;

    /**
     * 触摸点相对于view左上角的坐标
     */
    private float downX;
    private float downY;
    /**
     * 触摸点相对于屏幕左上角的坐标
     */
    private float rowX;
    private float rowY;
    /**
     * 悬浮窗显示标记
     */
    private boolean isShowing;
    /**
     * 拖动最小偏移量
     */
    private static final int MINIMUM_OFFSET = 5;

    private Context mContext;
    /**
     * 是否自动贴边
     */
    private boolean autoAlign;
    /**
     * 是否模态窗口
     */
    private boolean modality;
    /**
     * 是否可拖动
     */
    private boolean moveAble;


    /**
     * 透明度
     */
    private float alpha;

    /**
     * 初始位置
     */
    private int startX;
    private int startY;

    /**
     * View 高度
     */
    private int height;
    /**
     * View 宽度
     */
    private int width;


    /**
     * 内部定义的View,专门处理事件拦截的父View
     */
    private FloatView floatView;
    /**
     * 外部传进来的需要悬浮的View
     */
    private View contentView;

    private FloatWindow(With with) {
        this.mContext = with.context;
        this.autoAlign = with.autoAlign;
        this.modality = with.modality;
        this.contentView = with.contentView;
        this.moveAble = with.moveAble;
        this.startX = with.startX;
        this.startY = with.startY;
        this.alpha = with.alpha;
        this.height = with.height;
        this.width = with.width;

        initWindowManager();
        initLayoutParams();
        initFloatView();
    }

    private void initWindowManager() {
        mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
        //获取一个DisplayMetrics对象,该对象用来描述关于显示器的一些信息,例如其大小,密度和字体缩放。
        mDisplayMetrics = new DisplayMetrics();
        mWindowManager.getDefaultDisplay().getMetrics(mDisplayMetrics);
    }

    @SuppressLint({"ClickableViewAccessibility"})
    private void initFloatView() {
        floatView = new FloatView(mContext);
        if (moveAble) {
            floatView.setOnTouchListener(new WindowTouchListener());
        }
    }

    private void initLayoutParams() {
        mLayoutParams = new WindowManager.LayoutParams();
        mLayoutParams.flags = WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
                | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
                | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
        if (modality) {
            mLayoutParams.flags &= ~WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
            mLayoutParams.flags &= ~WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
        }
        mLayoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
        mLayoutParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
        if (height!=WindowManager.LayoutParams.WRAP_CONTENT){
            mLayoutParams.height = WindowManager.LayoutParams.MATCH_PARENT;
        }
        if (width!=WindowManager.LayoutParams.WRAP_CONTENT){
            mLayoutParams.width = WindowManager.LayoutParams.MATCH_PARENT;
        }
        mLayoutParams.gravity = Gravity.START | Gravity.TOP;
        mLayoutParams.format = PixelFormat.RGBA_8888;
        //此处mLayoutParams.type不建议使用TYPE_TOAST,因为在一些版本较低的系统中会出现拖动异常的问题,虽然它不需要权限
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            mLayoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
        } else {
            mLayoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
        }
        //悬浮窗背景明暗度0~1,数值越大背景越暗,只有在flags设置了WindowManager.LayoutParams.FLAG_DIM_BEHIND 这个属性才会生效
        mLayoutParams.dimAmount = 0.0f;
        //悬浮窗透明度0~1,数值越大越不透明
        mLayoutParams.alpha = alpha;
        //悬浮窗起始位置
        mLayoutParams.x = startX;
        mLayoutParams.y = startY;
    }

    /**
     * 将窗体添加到屏幕上
     */
    @SuppressLint("NewApi")
    public void show() {
        if (!isAppOpsOn(mContext)) {
            return;
        }
        if (!isShowing()) {
            mWindowManager.addView(floatView, mLayoutParams);
            isShowing = true;
        }
    }

    /**
     * 悬浮窗是否正在显示
     *
     * @return true if it's showing.
     */
    private boolean isShowing() {
        if (floatView != null && floatView.getVisibility() == View.VISIBLE) {
            return isShowing;
        }
        return false;
    }

    /**
     * 打开悬浮窗设置页
     * 部分第三方ROM无法直接跳转可使用{@link #openAppSettings(Context)}跳到应用详情页
     *
     * @param context
     * @return true if it's open successful.
     */
    public static boolean openOpsSettings(Context context) {
        try {
            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
                Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + context.getPackageName()));
                context.startActivity(intent);
            } else {
                return openAppSettings(context);
            }
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    /**
     * 打开应用详情页
     *
     * @param context
     * @return true if it's open success.
     */
    public static boolean openAppSettings(Context context) {
        try {
            Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
            Uri uri = Uri.fromParts("package", context.getPackageName(), null);
            intent.setData(uri);
            context.startActivity(intent);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    /**
     * 判断 悬浮窗口权限是否打开
     * 由于android未提供直接跳转到悬浮窗设置页的api,此方法使用反射去查找相关函数进行跳转
     * 部分第三方ROM可能不适用
     *
     * @param context
     * @return true 允许  false禁止
     */
    public static boolean isAppOpsOn(Context context) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            return Settings.canDrawOverlays(context);
        }
        try {
            Object object = context.getSystemService(Context.APP_OPS_SERVICE);
            if (object == null) {
                return false;
            }
            Class localClass = object.getClass();
            Class[] arrayOfClass = new Class[3];
            arrayOfClass[0] = Integer.TYPE;
            arrayOfClass[1] = Integer.TYPE;
            arrayOfClass[2] = String.class;
            Method method = localClass.getMethod("checkOp", arrayOfClass);
            if (method == null) {
                return false;
            }
            Object[] arrayOfObject1 = new Object[3];
            arrayOfObject1[0] = 24;
            arrayOfObject1[1] = Binder.getCallingUid();
            arrayOfObject1[2] = context.getPackageName();
            int m = (Integer) method.invoke(object, arrayOfObject1);
            return m == AppOpsManager.MODE_ALLOWED;
        } catch (Exception ex) {
            ex.getStackTrace();
        }
        return false;
    }

    /**
     * 移除悬浮窗
     */
    public void remove() {
        if (isShowing()) {
            floatView.removeView(contentView);
            mWindowManager.removeView(floatView);
            isShowing = false;
        }
    }

    /**
     * 用于获取系统状态栏的高度。
     *
     * @return 返回状态栏高度的像素值。
     */
    private int getStatusBarHeight(Context ctx) {
        int identifier = ctx.getResources().getIdentifier("status_bar_height",
                "dimen", "android");
        if (identifier > 0) {
            return ctx.getResources().getDimensionPixelSize(identifier);
        }
        return 0;
    }

    class FloatView extends FrameLayout {

        /**
         * 记录按下位置
         */
        int interceptX = 0;
        int interceptY = 0;

        public FloatView(Context context) {
            super(context);
            //这里由于一个ViewGroup不能add一个已经有Parent的contentView,所以需要先判断contentView是否有Parent
            //如果有则需要将contentView先移除
            if (contentView.getParent() != null && contentView.getParent() instanceof ViewGroup) {
                ((ViewGroup) contentView.getParent()).removeView(contentView);
            }

            addView(contentView);
        }

        /**
         * 解决点击与拖动冲突的关键代码
         *
         * @param ev
         * @return
         */
        @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            //此回调如果返回true则表示拦截TouchEvent由自己处理,false表示不拦截TouchEvent分发出去由子view处理
            //解决方案:如果是拖动父View则返回true调用自己的onTouch改变位置,是点击则返回false去响应子view的点击事件
            boolean isIntercept = false;
            switch (ev.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    interceptX = (int) ev.getX();
                    interceptY = (int) ev.getY();
                    downX = ev.getX();
                    downY = ev.getY();
                    isIntercept = false;
                    break;
                case MotionEvent.ACTION_MOVE:
                    //在一些dpi较高的设备上点击view很容易触发 ACTION_MOVE,所以此处做一个过滤
                    isIntercept = Math.abs(ev.getX() - interceptX) > MINIMUM_OFFSET && Math.abs(ev.getY() - interceptY) > MINIMUM_OFFSET;
                    break;
                case MotionEvent.ACTION_UP:
                    break;
                default:
                    break;
            }
            return isIntercept;
        }
    }

    class WindowTouchListener implements View.OnTouchListener {

        @SuppressLint("ClickableViewAccessibility")
        @Override
        public boolean onTouch(View v, MotionEvent event) {

            //获取触摸点相对于屏幕左上角的坐标
            rowX = event.getRawX();
            rowY = event.getRawY() - getStatusBarHeight(mContext);

            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    actionDown(event);
                    break;
                case MotionEvent.ACTION_MOVE:
                    actionMove(event);
                    break;
                case MotionEvent.ACTION_UP:
                    actionUp(event);
                    break;
                case MotionEvent.ACTION_OUTSIDE:
                    actionOutSide(event);
                    break;
                default:
                    break;
            }
            return false;
        }

        /**
         * 手指点击窗口外的事件
         *
         * @param event
         */
        private void actionOutSide(MotionEvent event) {
            //由于我们在layoutParams中添加了FLAG_WATCH_OUTSIDE_TOUCH标记,那么点击悬浮窗之外时此事件就会被响应
            //这里可以用来扩展点击悬浮窗外部响应事件
        }

        /**
         * 手指抬起事件
         *
         * @param event
         */
        private void actionUp(MotionEvent event) {
            if (autoAlign) {
                autoAlign();
            }
        }

        /**
         * 拖动事件
         *
         * @param event
         */
        private void actionMove(MotionEvent event) {
            //拖动事件下一直计算坐标 然后更新悬浮窗位置
            updateLocation((rowX - downX), (rowY - downY));
        }

        /**
         * 更新位置
         */
        private void updateLocation(float x, float y) {
            mLayoutParams.x = (int) x;
            mLayoutParams.y = (int) y;
            mWindowManager.updateViewLayout(floatView, mLayoutParams);
        }

        /**
         * 手指按下事件
         *
         * @param event
         */
        private void actionDown(MotionEvent event) {
//            downX = event.getX();
//            downY = event.getY();
        }

        /**
         * 自动贴边
         */
        private void autoAlign() {
            float fromX = mLayoutParams.x;

            if (rowX <= mDisplayMetrics.widthPixels / 2) {
                mLayoutParams.x = 0;
            } else {
                mLayoutParams.x = mDisplayMetrics.widthPixels;
            }

            //这里使用ValueAnimator来平滑计算起始X坐标到结束X坐标之间的值,并更新悬浮窗位置
            ValueAnimator animator = ValueAnimator.ofFloat(fromX, mLayoutParams.x);
            animator.setDuration(300);
            animator.addUpdateListener(animation -> {
                //这里会返回fromX ~ mLayoutParams.x之间经过计算的过渡值
                float toX = (float) animation.getAnimatedValue();
                //我们直接使用这个值来更新悬浮窗位置
                updateLocation(toX, mLayoutParams.y);
            });
            animator.start();
        }
    }

    public static class With {
        private Context context;
        private boolean autoAlign;
        private boolean modality;
        private View contentView;
        private boolean moveAble;
        private float alpha = 1f;

        /**
         * View 高度
         */
        private int height = WindowManager.LayoutParams.WRAP_CONTENT;
        /**
         * View 宽度
         */
        private int width = WindowManager.LayoutParams.WRAP_CONTENT;

        /**
         * 初始位置
         */
        private int startX;
        private int startY;

        /**
         * @param context     上下文环境
         * @param contentView 需要悬浮的视图
         */
        public With(Context context, @NonNull View contentView) {
            this.context = context;
            this.contentView = contentView;
        }

        /**
         * 是否自动贴边
         *
         * @param autoAlign
         * @return
         */
        public With setAutoAlign(boolean autoAlign) {
            this.autoAlign = autoAlign;
            return this;
        }

        /**
         * 是否模态窗口(事件是否可穿透当前窗口)
         *
         * @param modality
         * @return
         */
        public With setModality(boolean modality) {
            this.modality = modality;
            return this;
        }

        /**
         * 是否可拖动
         *
         * @param moveAble
         * @return
         */
        public With setMoveAble(boolean moveAble) {
            this.moveAble = moveAble;
            return this;
        }

        /**
         * 设置起始位置
         *
         * @param startX
         * @param startY
         * @return
         */
        public With setStartLocation(int startX, int startY) {
            this.startX = startX;
            this.startY = startY;
            return this;
        }

        public With setAlpha(float alpha) {
            this.alpha = alpha;
            return this;
        }

        public With setHeight(int height) {
            this.height = height;
            return this;
        }

        public With setWidth(int width) {
            this.width = width;
            return this;
        }

        public FloatWindow create() {
            return new FloatWindow(this);
        }
    }
}

调用方式

Button mBtn = new Button(this);
mBtn.setText("悬浮按钮");
mBtn.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        Toast.makeText(context,"click",Toast.LENGTH_SHORT).show();
    }
});
FloatWindow floatWindow = new FloatWindow.With(this, layout)
                .setModality(false)
                .setMoveAble(true)
                .setAutoAlign(true)
                .setAlpha(0.5f)
                .setWidth(WindowManager.LayoutParams.WRAP_CONTENT)
                .setHeight(WindowManager.LayoutParams.MATCH_PARENT)
                .create();
// 显示
floatWindow.show();
// 移除
floatWindow.remove();

Demo 源码

GitHub

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

推荐阅读更多精彩内容