UE Tool 分析及改进

对 UE Tool 原理进行分析,以及进行改进,贴合项目需要。

一、 思考

  在日常研发中如何快速定位一个控件布局位置或点击事件处理逻辑?

1.1 、ADB 命令行

  通过 adb shell dumpsys activity | grep top 快速查看布局结构

adb.png

  通过快速查看布局结构,拿到可视化的 Fragment 实例,去找对应的处理逻辑。

  缺点 : Fragment 细分力度不够 , 找到对应的 Fragment ,还需要再次去寻找对应的代码处理逻辑以及布局文件,不能一步到位。

  对应方案快速实现 :

  1.1.1. 在基类创建时,去遍历该 activity 所包含的 fragment , 将 fragment 以 window 的形式呈现出来
  1.1.2. 反射 activityThread , 拿到对应当前可视化的 activity , 再去遍历该 activity 包含的 fragment, 对应代码如下:
public static Activity getCurrentActivity() {
        try {
             Class activityThreadClass = Class.forName("android.app.ActivityThread");
            Method currentActivityThreadMethod = activityThreadClass.getMethod("currentActivityThread");
            Object currentActivityThread = currentActivityThreadMethod.invoke(null);
            Field mActivitiesField = activityThreadClass.getDeclaredField("mActivities");
            mActivitiesField.setAccessible(true);
            Map activities = (Map) mActivitiesField.get(currentActivityThread);
            for (Object record : activities.values()) {
                Class recordClass = record.getClass();
                Field pausedField = recordClass.getDeclaredField("paused");
                pausedField.setAccessible(true);
                if (!(boolean) pausedField.get(record)) {
                    Field activityField = recordClass.getDeclaredField("activity");
                    activityField.setAccessible(true);
                    return (Activity) activityField.get(record);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
1.1 、Layout Inspector

  通过 LayoutInspector 去快速定位控件位置 ,截图如下:


device-2020-09-28-135107.png

  缺点 : 当 Activity 中有嵌套 多个Fragment 时,会导致控件选择不够清楚,无法快速定位到对应的控件位置。如果想找到对应的代码处理逻辑,如点击事件等,LayoutInspector 无法提供相关的功能

二、 UE Tool

  UE Tool 是一个各方人员(设计师、程序员、测试)都可以使用的调试工具。它可以作用于任何显示在屏幕上的 view,比如Activity/Fragment/Dialog/PopupWindow 等等。

2.1 、UE Tool 功能
  • 屏幕上的任意 view,如果重复选中一个 view,将会选中其父 view
  • 查看/修改常用控件的属性,比如修改 TextView 的文本内容、文本大小、文本颜色等等
  • 如果你的项目里正在使用 Fresco 的 DraweeView 来呈现图片,那么 UETool 将会提供更多的属性比如图片 URI、默认占位图、圆角大小等等
  • 你可以很轻松的定制任何 view 的属性,比如你想查看一些额外的业务参数
  • 有的时候 UETool 为你选中的 view 并不是你想要的,你可以选择打开 ValidView,然后选中你需要的 View
  • 显示两个 view 的相对位置关系
  • 显示网格栅栏,方便查看控件是否对齐
  • 支持 Android P
  • 支持显示当前控件所在的 Fragment
  • 显示 Activity 的 Fragment 树
2.2 、实现分析
  2.2.1 获取当前正在展示的 Activity
    public static Activity getCurrentActivity() {
        try {
            Class activityThreadClass = Class.forName("android.app.ActivityThread");
            // 通过反射拿到当前进程的 activityThread 实例
            Method currentActivityThreadMethod = activityThreadClass.getMethod("currentActivityThread");
            Object currentActivityThread = currentActivityThreadMethod.invoke(null);
            // 通过反射拿到当前 Activity 保存的 activity 信息
            Field mActivitiesField = activityThreadClass.getDeclaredField("mActivities");
            mActivitiesField.setAccessible(true);
            Map activities = (Map) mActivitiesField.get(currentActivityThread);
            // 遍历 activity record , 拿到当前正在展示的 activity 信息
            for (Object record : activities.values()) {
                Class recordClass = record.getClass();
                Field pausedField = recordClass.getDeclaredField("paused");
                pausedField.setAccessible(true);
                if (!(boolean) pausedField.get(record)) {
                    Field activityField = recordClass.getDeclaredField("activity");
                    activityField.setAccessible(true);
                    return (Activity) activityField.get(record);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
  2.2.2 从 DecorView 收集该 activity 所有的控件信息
 @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        try {
            final Activity targetActivity = UETool.getInstance().getTargetActivity();
            final WindowManager windowManager = targetActivity.getWindowManager();

          /**
           *
           * 每一个 activity 的启动都必须经过 WindowManagerGlobal
           *
           * WindowManagerImpl 持有 WindowManagerGlobal 引用
           *
           * WindowManagerGlobal 会保存每一个当前正在展示的 window 信息 , 通过 viewList 方式将每一个window 的  decorView 保存起来, 通过
           * 反射可以拿到当前所有的 View 信息
           *
           * 注 : 在 Android P 上,绝大部分的反射调用会报错, 黑名单如下:
           *
           * @see  {https://developer.android.com/distribute/best-practices/develop/restrictions-non-sdk-interfaces?hl=zh-cn}
           *
           * 解决方案 :
           *
           *    {http://weishu.me/2018/06/07/free-reflection-above-android-p/}
           *
           */
          if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN) {
                final Field mGlobalField = Class.forName("android.view.WindowManagerImpl").getDeclaredField("mGlobal");
                mGlobalField.setAccessible(true);

                if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
                    Field mViewsField = Class.forName("android.view.WindowManagerGlobal").getDeclaredField("mViews");
                    mViewsField.setAccessible(true);
                    List<View> views;
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                        views = (List<View>) mViewsField.get(mGlobalField.get(windowManager));
                    } else {
                        views = Arrays.asList((View[]) mViewsField.get(mGlobalField.get(windowManager)));
                    }

                  /**
                   * 对比 Activity Context 和 decorView context , 拿到对应 activity 的布局信息
                   *
                   * 拿到该 Activity 的布局信息后,递归遍历,将 布局信息保存在 elements 中
                   *
                   */
                  for (int i = views.size() - 1; i >= 0; i--) {
                        View targetView = getTargetDecorView(targetActivity, views.get(i));
                        if (targetView != null) {
                            createElements(targetView);
                            break;
                        }
                    }
                } else {
                    ReflectionP.breakAndroidP(new ReflectionP.Func<Void>() {
                        @Override
                        public Void call() {
                            try {
                                Field mRootsField = Class.forName("android.view.WindowManagerGlobal").getDeclaredField("mRoots");
                                mRootsField.setAccessible(true);
                                List viewRootImpls;
                                viewRootImpls = (List) mRootsField.get(mGlobalField.get(windowManager));
                                for (int i = viewRootImpls.size() - 1; i >= 0; i--) {
                                    Class clazz = Class.forName("android.view.ViewRootImpl");
                                    Object object = viewRootImpls.get(i);
                                    Field mWindowAttributesField = clazz.getDeclaredField("mWindowAttributes");
                                    mWindowAttributesField.setAccessible(true);
                                    Field mViewField = clazz.getDeclaredField("mView");
                                    mViewField.setAccessible(true);
                                    View decorView = (View) mViewField.get(object);
                                    WindowManager.LayoutParams layoutParams = (WindowManager.LayoutParams) mWindowAttributesField.get(object);
                                    if (layoutParams.getTitle().toString().contains(targetActivity.getClass().getName())
                                            || getTargetDecorView(targetActivity, decorView) != null) {
                                        createElements(decorView);
                                        break;
                                    }
                                }
                            } catch (Exception e) {
                                e.printStackTrace();
                            }
                            return null;
                        }
                    });
                }
            } else {
                // http://androidxref.com/4.1.1/xref/frameworks/base/core/java/android/view/WindowManagerImpl.java
                Field mWindowManagerField = Class.forName("android.view.WindowManagerImpl$CompatModeWrapper").getDeclaredField("mWindowManager");
                mWindowManagerField.setAccessible(true);
                Field mViewsField = Class.forName("android.view.WindowManagerImpl").getDeclaredField("mViews");
                mViewsField.setAccessible(true);
                List<View> views = Arrays.asList((View[]) mViewsField.get(mWindowManagerField.get(windowManager)));
                for (int i = views.size() - 1; i >= 0; i--) {
                    View targetView = getTargetDecorView(targetActivity, views.get(i));
                    if (targetView != null) {
                        createElements(targetView);
                        break;
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
  2.2.3 处理点击事件

  /**
   * elements 创建的时候,需要根据面积进行排序
   * @param view
   */
  private void createElements(View view) {

        List<Element> elements = new ArrayList<>();
        traverse(view, elements);

        //  面积从大到小排序
        Collections.sort(elements, new Comparator<Element>() {
            @Override
            public int compare(Element o1, Element o2) {
                return o2.getArea() - o1.getArea();
            }
        });

        this.elements.addAll(elements);

    }

  /**
   * 根据坐标查找当前正在展示的 View
   * @param x
   * @param y
   * @return
   */
    protected Element getTargetElement(float x, float y) {
        Element target = null;
        for (int i = elements.size() - 1; i >= 0; i--) {
            final Element element = elements.get(i);
            // 拿到 点击时候的 X , Y , 和 View 的布局边界对比
            // 优先拿 ChildView ,取面积最小的 View
            if (element.getRect().contains((int) x, (int) y)) {
                if (isParentNotVisible(element.getParentElement())) {
                    continue;
                }
                if (element != childElement) {
                    childElement = element;
                    parentElement = element;
                } else if (parentElement != null) {
                    parentElement = parentElement.getParentElement();
                }
                target = parentElement == null ? element : parentElement;
                break;
            }
        }
        if (target == null) {
            Toast.makeText(getContext(), getResources().getString(R.string.uet_target_element_not_found, x, y), Toast.LENGTH_SHORT).show();
        }
        return target;
    }
  2.2.4 收集目标 View 属性

    @Override
    public List<Item> getAttrs(Element element) {
        List<Item> items = new ArrayList<>();

        View view = element.getView();

        items.add(new TextItem("Fragment", Util.getCurrentFragmentName(element.getView()), new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Activity activity = Util.getCurrentActivity();
                if (activity instanceof TransparentActivity) {
                    ((TransparentActivity) activity).dismissAttrsDialog();
                }
                new FragmentListTreeDialog(v.getContext()).show();
            }
        }));
        items.add(new TextItem("ViewHolder", Util.getViewHolderName(element.getView())));
        items.add(new SwitchItem("Move", element, SwitchItem.Type.TYPE_MOVE));
        items.add(new SwitchItem("ValidViews", element, SwitchItem.Type.TYPE_SHOW_VALID_VIEWS));

        IAttrs iAttrs = AttrsManager.createAttrs(view);
        if (iAttrs != null) {
            items.addAll(iAttrs.getAttrs(element));
        }

        items.add(new TitleItem("COMMON"));
        items.add(new TextItem("Class", view.getClass().getName()));
        items.add(new TextItem("Id", Util.getResId(view)));
        items.add(new TextItem("ResName", Util.getResourceName(view.getId())));
        items.add(new TextItem("Tag", Util.getViewTag(view)));
        items.add(new TextItem("layout name",Util.getXmlName(view)));
        items.add(new TextItem("Clickable", Boolean.toString(view.isClickable()).toUpperCase()));
        items.add(new TextItem("OnClickListener", Util.getViewClickListener(view)));
        items.add(new TextItem("Focused", Boolean.toString(view.isFocused()).toUpperCase()));
        items.add(new AddMinusEditItem("Width(dp)", element, EditTextItem.Type.TYPE_WIDTH, px2dip(view.getWidth())));
        items.add(new AddMinusEditItem("Height(dp)", element, EditTextItem.Type.TYPE_HEIGHT, px2dip(view.getHeight())));
        items.add(new TextItem("Alpha", String.valueOf(view.getAlpha())));
        Object background = Util.getBackground(view);
        if (background instanceof String) {
            items.add(new TextItem("Background", (String) background));
        } else if (background instanceof Bitmap) {
            items.add(new BitmapItem("Background", (Bitmap) background));
        }
        items.add(new AddMinusEditItem("PaddingLeft(dp)", element, EditTextItem.Type.TYPE_PADDING_LEFT, px2dip(view.getPaddingLeft())));
        items.add(new AddMinusEditItem("PaddingRight(dp)", element, EditTextItem.Type.TYPE_PADDING_RIGHT, px2dip(view.getPaddingRight())));
        items.add(new AddMinusEditItem("PaddingTop(dp)", element, EditTextItem.Type.TYPE_PADDING_TOP, px2dip(view.getPaddingTop())));
        items.add(new AddMinusEditItem("PaddingBottom(dp)", element, EditTextItem.Type.TYPE_PADDING_BOTTOM, px2dip(view.getPaddingBottom())));

        return items;
    }

/**
   * 通过反射拿到 clickListener 注册信息 ,由于 clickListener 一般都为匿名内部类,所以可以很快定位
   *
   * 到 click 事件具体是在 哪个 presenter 中注册的,很方便定位问题
   *
   * @param view
   * @return
   */
    public static String getViewClickListener(final View view) {
        return ReflectionP.breakAndroidP(new ReflectionP.Func<String>() {
            @Override public String call() {
                try {
                    final Field mListenerInfoField = View.class.getDeclaredField("mListenerInfo");
                    mListenerInfoField.setAccessible(true);
                    final Field mClickListenerField = Class.forName("android.view.View$ListenerInfo").getDeclaredField("mOnClickListener");
                    mClickListenerField.setAccessible(true);
                    OnClickListener listener = (OnClickListener) mClickListenerField.get(mListenerInfoField.get(view));
                    return listener.getClass().getName();
                } catch (Exception e) {
                    return null;
                }
            }
        });
    }
  2.2.5 UE Tool 总结

  可以快速定位 View ,展示 View 的相关属性,并且可以修改 View 的属性,达到实时调试View 的效果。
  改进的点 : 不提供通过此 View 直接定位到对应 XML 的功能,无法立即知道此 View 隶属于哪个 XML 文件。

三、 UE Tool 改进

   此改进主要是为展示属性增加 Layout 字段,作为 View 展示属性的一部分,方便用户直观查看,截图如下:

device-2020-09-28-135107.png
3.1 、实现分析

   Android P 以及以上,系统已经提供了对应的实现,代码如下:

public static String getXmlName(View view) {
    try {
      if (Build.VERSION.SDK_INT >= 29) {

        int layoutId = view.getSourceLayoutResId();

        return view.getResources().getResourceEntryName(layoutId);
      } else {
        return view.getContentDescription() != null ? view.getContentDescription().toString() : "";
      }

    } catch (Exception e) {
      e.printStackTrace();
    }
    return "";
  }

   在 Android P 以下,系统 inflater 阶段并没有保存对应的 layout id ,无从获取。最后尝试在布局加载阶段为每一个 view 设置对应的 contentDescription 字段,将自己所属的 xml 文件保存在自身 contentDescription 中或许。 为什么设置 view 本身 contentDescription 而不是 tag ,是基于以下考虑:


image.png
3.1.1 、创建 LayoutInflater 代理
package com.gifshow.uetools.me.ele.uetool.Hook;

import android.content.Context;
import android.content.res.Resources;
import android.os.Build;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;

/**
   * create by zd
   * <p>
   * Date : 2020-09-07
   * <p>
   * Time : 19:04
   */
  public class LayoutInflaterHack extends LayoutInflater {

    private LayoutInflater mOriginalInflater;
    private String mAppPackageName;

    public LayoutInflaterHack(LayoutInflater original, Context newContext) {
      super(original, newContext);
      mOriginalInflater = original;
      mAppPackageName = getContext().getPackageName();
    }

    @Override
    public LayoutInflater cloneInContext(Context newContext) {
      return new LayoutInflaterHack(mOriginalInflater.cloneInContext(newContext), newContext);
    }

    @Override
    public void setFactory(Factory factory) {
      super.setFactory(factory);
      mOriginalInflater.setFactory(factory);
    }

    @Override
    public void setFactory2(Factory2 factory) {
      super.setFactory2(factory);
      mOriginalInflater.setFactory2(factory);
    }

    @Override
    public View inflate(int resourceId, ViewGroup root, boolean attachToRoot) {
      Resources res = getContext().getResources();

      // 此处 resourceId 即为 xml id ,将 xmlId 保存
      // 通过 xmlId 拿到对应的 xmlName ,并赋值给每一个 view 的 contentDescription 属性
      String packageName = "";
      try {
        packageName = res.getResourcePackageName(resourceId);
      } catch (Exception e) {
      }


      String resName = "";
      try {
        resName = res.getResourceEntryName(resourceId);
      } catch (Exception e) {
      }

      View view = mOriginalInflater.inflate(resourceId, root, attachToRoot);

      if (!mAppPackageName.equals(packageName)) {
        return view;
      }

      View targetView = view;
      if (root != null && attachToRoot) {
        targetView = root.getChildAt(root.getChildCount() - 1);
      }

      targetView.setContentDescription("资源文件名:" + resName);

      traverseViewGroup(targetView , resName);

      return view;
    }

  /**
   * 递归遍历,为每一个 View 设置对应的 contentDescription
   * @param view
   * @param resName
   */
    public void traverseViewGroup(View view , String resName) {
      if (null == view) {
        return;
      }
      if (view instanceof ViewGroup) {

//        changeContentDescription(view , resName);

        //遍历ViewGroup,是子view加1,是ViewGroup递归调用
        for (int i = 0; i < ((ViewGroup) view).getChildCount(); i++) {
          View child = ((ViewGroup) view).getChildAt(i);
          if (child instanceof ViewGroup) {
            traverseViewGroup(((ViewGroup) view).getChildAt(i) , resName);
          } else {
            changeContentDescription(child , resName);
          }
        }
      } else {
        changeContentDescription(view , resName);
      }
    }


  public void changeContentDescription(View view , String resName){

      if (view instanceof ViewStub){
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
          ((ViewStub) view).setLayoutInflater(this);
        }
      }

    String originDes = view.getContentDescription() != null ? view.getContentDescription().toString() : "";

    originDes = originDes + ", layoutName : " + resName;

    view.setContentDescription(originDes);
  }
  }


3.1.2 、Hook LayoutInflater
  //  怎么用代理LayoutInflater替换Activity本身的LayoutInflater.
  //  要解决这个问题需要先弄明白Activity本身的LayoutInflater从何而来。一般而言加载xml有以下几种方法:
  //
  //      Activity.setContentView(...)
  //      LayoutInflater.from(context).inflate(...)
  //      Activity.getLayoutInflater().inflate(...)
  //
  //  先看第一种情况。Activity.setContentView(...)会调用PhoneWindow.setContentView(...)
  //  ,最后会调用PhoneWindow中的成员mLayoutInflater的inflate方法。
  //  对于第二种情况,假定参数context是一个Activity. LayoutInflater.from(context)返回的是context.getSystemService
  //  (Context.LAYOUT_INFLATER_SERVICE)
  //  拿到的LayoutInflater对象。当这里的context是一个Activity时,getSystemService(Context
  //  .LAYOUT_INFLATER_SERVICE)返回的是Activity继承自父类ContextThemeWrapper的成员mInflater.
  //  最后一种情况,Activity.getLayoutInflater()直接返回对应PhoneWindow中的成员mLayoutInflater.
  //      由此可以得出结论:接下来需要做两件事,第一替换Activity继承自父类ContextThemeWrapper的成员mInflater;第二替换Activity
  //      对应PhoneWindow中的成员mLayoutInflater.
  //
  public static void init(Application application) {
    application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
      @Override
      public void onActivityCreated(final Activity activity, Bundle savedInstanceState) {
        try {
          // Replace Activity's LayoutInflater
          Field inflaterField = ContextThemeWrapper.class.getDeclaredField("mInflater");
          inflaterField.setAccessible(true);
          LayoutInflater inflater = (LayoutInflater) inflaterField.get(activity);
          LayoutInflater proxyInflater = null;
          if (inflater != null) {
            proxyInflater = new LayoutInflaterHack(inflater, activity);
            inflaterField.set(activity, proxyInflater);
          }

          final LayoutInflater finalProxyInflater = proxyInflater;
          ReflectionP.breakAndroidP(new ReflectionP.Func<Void>() {
            @Override
            public Void call() {

              try {
                // Replace the LayoutInflater of Activity's Window
                Class phoneWindowClass = Class.forName("com.android.internal.policy.PhoneWindow");
                Field phoneWindowInflater = phoneWindowClass.getDeclaredField("mLayoutInflater");
                phoneWindowInflater.setAccessible(true);
                LayoutInflater inflater =
                    (LayoutInflater) phoneWindowInflater.get(activity.getWindow());
                if (inflater != null && finalProxyInflater != null) {
                  phoneWindowInflater.set(activity.getWindow(), finalProxyInflater);
                }

              } catch (Exception e) {

              }

              return null;
            }
          });
        } catch (Exception e) {
          e.printStackTrace();
        }
      }
    });
  }


四、 使用接入

   目前代码已经上传至 maven ,接入方式如下 :

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