Layout inflate方法解析:由Xml文件生成View Hierarchy的一些细节

太长了,不想看?

LayoutInflater.inflate()方法能将Xml格式的布局文件转换成以父子关系组合的一系列View,转换后的结构也称为View Hierarchy。

我们通常向inflate()方法传入三个参数:布局资源的resId,可为空的ViewGroup:root,以及布尔值attachToRoot。许多初学者对于后两个参数的使用比较生硬,缺乏理解。

我理解的引入root的原因是为了读取Xml文件中最外层标签的布局属性。布局属性就是我们常看到的以<layout->开头的属性,他表示一个View希望在父布局中获得的尺寸以及位置,因此布局属性是提供给父布局读取以便计算尺寸、位置的。布局属性体现在View中就是mLayoutParams变量,这个变量的类型是ViewGroup.LayoutParams。不同的ViewGroup的子类实现了不同的LayoutParams类,用以从Xml中读取自己关心的布局属性,一个View的mLayoutParams的类型必须与其父布局实现的LayoutParams相一致。

对于Xml文件的最外层标签,他所转换成的View并不知道自己的父布局会是什么类型的,因此他不会生成mLayoutParams变量,此时该标签的所有<layout-XXX>属性都没有应用到View中。为了避免这种情况,我们需要告知最外层的View他的父布局是什么类型的,生成对应的LayoutParams储存布局属性。root就能帮助我们生成LayoutParams,而如果root正是我们希望的父布局,那么我们就将attachToRoot设为true,这样我们通过Xml生成的View Hierarchy可以直接加入到root中,我们不需要手动做这步操作了。

如果我们将Xml文件的嵌套结构看作是树状结构的话,逐个标签的解析其实就是树的深度优先遍历,我们在遍历的同时生成了一棵以View为节点,使用父子关系关联的树。

View Hierarchy中每个View的生成有四步:

  1. 由标签生成一个View
  2. 根据View的父布局的类型生成对应的LayoutParams,并将LayoutParams设置给View
  3. 生成View的所有Children
  4. 将View加入他的父布局中

由一个标签转换成一个View的过程其实就是通过ClassLoader加载出标签对应的View.Class文件并获得构造器,相当于调用View(Context context, @Nullable AttributeSet attrs)构造一个实例。因此我们的自定义控件需要实现该构造方法才能在Xml中使用,随后从attrs变量中获得Xml中的属性。

inflate方法介绍

Android开发中,我们使用LayoutInflater.inflate()方法将layout目录下Xml格式的资源文件转换为一个View Hierarchy,并返回一个View对象。

View Hierarchy直译大概就是视图层次,指的是以父子关系关联的一系列View,我们通过View Hierarchy的根节点(root view)可以获得该结构中所有的View对象。

如果让我们自己去设计一个方法将布局文件转换为View Hierarchy,我们可能会想到逐行读取Xml中的标签,利用标签的信息生成一个新的View/ViewGroup,当Xml中出现嵌套关系就意味我们需要使用父子关系关联两个View。而inflate()方法做的就是这么一件事。

我们可以使用Activity.getLayoutInflater()等方法获得一个LayoutInflater的实例,这些方法本质上都是通过getSystemService(Context.LAYOUT_INFLATER_SERVICE)获得一个系统服务,这个方法最终会返回一个PhoneLayoutInflater的实例。

inflate有几种重载方法,但最终都会走到

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot)

第一个参数我们看着比较陌生,但是大部分情况下我们只需要传入一个layout资源文件,Framework会帮我们根据资源获得一个Parser。需要注意的是为了提升使用时的效率,Android会在编译时就去解析layout资源文件并生成Parser,因此我们想通过inflate()方法在使用过程中使用一个单纯的Xml文件(非布局资源)去生成View是不可行的。

后面两个参数会在源码解析过程中介绍。

我们看下官方对这几个参数的定义:

参数 意义
parser XmlPullParser:以Pull的方式解析Xml文件,通过Parser对象方便我们操作
root ViewGroup:可选项。当attachToRoot为true时,他将作为生成出来的View层级的parent。如果attachToRoot为false,那root仅仅为View层级树的根节点提供LayoutParams的值
attachToRoot 配合root使用

inflate()方法的返回值是一个View,如果root不会为空且attachToRoot为true,返回root。否则返回Xml生成的View Hierarchy的根View。

Inflate 方法源码解析

看下inflate阶段的核心代码

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    // 解析根节点
    final String name = parser.getName();
    
    if (TAG_MERGE.equals(name)) {
        if (root == null || !attachToRoot) {
            throw new InflateException("<merge /> can be used only with a valid "
                + "ViewGroup root and attachToRoot=true");
        }
        rInflate(parser, root, inflaterContext, attrs, false);
    } else {
        // 将Xml最外层的标签解析为View对象,记为temp
        final View temp = createViewFromTag(root, name, inflaterContext, attrs);
        
        if (root != null) {
            // 当root不为空时,创建与root相匹配的LayoutParams
            params = root.generateLayoutParams(attrs);
            if (!attachToRoot) {
                // 仅将params提供给temp
                temp.setLayoutParams(params);
            }
        }
        
        // 通过inflate生成temp的所有chiildre
        rInflateChildren(parser, temp, attrs, true);
        
        // root不为空且attachToRoot为true,将temp加入到root中,且用到params
        if (root != null && attachToRoot) {
            root.addView(temp, params);
        }
        
        if (root != null && attachToRoot) 
            return root;
        }else {
            return temp;
        }
    }
}

先不考虑<merge>标签,inflate()方法执行的流程:

  1. 将最外层的标签转换为一个View,记为temp。
  2. 当root不为空时,利用root生成的temp的LayoutParams。
  3. 解析Xml并生成temp的所有子View。
  4. 当root不为空且attachToRoot为true时,将temp添加为root的一个child。
  5. 当root不为空且attachToRoot为true时,返回root,否则返回temp。

这个流程包含了几个细节:

  1. 由Xml标签生成View对象
  2. 根据Xml嵌套结构生成View父子结构
  3. 应用root及attachToRoot

下面,我们由表及里的解析这几个细节。首先来看一下传参中root与attachToRoot两个参数的作用。

root与attachToRoot

root在inflate()方法中的第一个作用就是生成一个LayoutParams。

if (root != null) {
    // 当root不为空时,创建与root相匹配的LayoutParams
    params = root.generateLayoutParams(attrs);
}

LayoutParams保存的是一个控件的布局属性。那我们来看下为什么需要利用root生成LayoutParams。

布局属性与LayoutParams

在Xml文件中,有许多前缀为layout_的属性,比如我们最熟悉的layout_width/layout_height,我们称其为布局属性。View使用布局属性来告知父布局它所希望的尺寸与位置。不同类型的父布局读取的布局属性不同,比如layout_centerInParent属性,父布局为RelativeLayout时会起作用,而父布局为LinearLayout时则无法使用。

Xml中的布局属性保存到View中就是mLayoutParams变量,它的类型是ViewGroup.LayoutParams。实际上ViewGroup的子类都会实现一个扩展自ViewGroup.LayoutParams的嵌套类,这个LayoutParams类决定了他会读取哪些布局属性。我们看下RelativeLayout.LayoutParams源码的一部分:

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

    TypedArray a = c.obtainStyledAttributes(attrs,
            com.android.internal.R.styleable.RelativeLayout_Layout);
    ...
    
    final int N = a.getIndexCount();
    for (int i = 0; i < N; i++) {
        int attr = a.getIndex(i);
        switch (attr) {
            ...
            case com.android.internal.R.styleable.RelativeLayout_Layout_layout_toLeftOf:
                rules[LEFT_OF] = a.getResourceId(attr, 0);
                break;
            case com.android.internal.R.styleable.RelativeLayout_Layout_layout_toRightOf:
                rules[RIGHT_OF] = a.getResourceId(attr, 0);
                break;
            case com.android.internal.R.styleable.RelativeLayout_Layout_layout_above:
                rules[ABOVE] = a.getResourceId(attr, 0);
                break;
            case com.android.internal.R.styleable.RelativeLayout_Layout_layout_centerInParent:
                rules[CENTER_IN_PARENT] = a.getBoolean(attr, false) ? TRUE : 0;
                break;
            ...
        }
    }
    ...
    a.recycle();
}

这段代码跟我们自定义控件时读取Xml中的自定义属性是一样的做法,我们看到RelativeLayout.LayoutParams在创建时读取了一系列布局属性并存储,比如layout_centerInParent,其他的LayoutParams不会读取该属性。

如果对AttributeSet、TypedArray不熟悉可以参考这里:https://blog.csdn.net/lmj623565791/article/details/45022631

我们能在RelativeLayout.onMeasure()方法中找到对LayoutParams的使用。

// RelativeLayout.java
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    ...
    int count = views.length;
    for (int i = 0; i < count; i++) {
        View child = views[i];
        if (child.getVisibility() != GONE) {
            ...
            LayoutParams params = (LayoutParams) child.getLayoutParams();
            int[] rules = params.getRules(layoutDirection);
            ...
        }
    }
    ...
}

当计算每个子View的位置时,需要读取他们的布局属性,此时会将View的LayoutParams强制类型转换为RelativeLayout.LayoutParams,这里可能会报出无法转换类型的错误,所以我们需要保证加入到RelativeLayout的View的LayoutParams类型都是RelativeLayout.LayoutParams

我们总结一下LayoutParams的核心知识点:

  1. LayoutParams保存了Xml中的layout_开头的布局属性
  2. ViewGroup子类通常会实现一个LayoutParams类,用于读取他们需要的布局属性
  3. View的LayoutParams类型必须与其父布局的类型相匹配,否则会在onMeasure过程中报错

回到root与attachToRoot

当我们解析Xml中最外层的标签,也就是View Hierarchy的根View时,程序并不知道它的父布局会是什么类型的,因此不会生成LayoutParams。这时最外层标签中的所有布局属性,包括layout_width/layout_height都不会被记录到View对象中,也就是俗称的“属性失效了”。

但在实际使用中,我们通常能知道Xml生成的View Hierarchy所要加入的父布局或是要加入的父布局的类型。这时候我们传入一个root参数,根据root的类型去读取根View的布局属性并生成对应的LayoutParams。这段代码如下:

if (root != null) {
    // root不为空时,生成与root对应的LayoutParams
    params = root.generateLayoutParams(attrs);
}

public LayoutParams generateLayoutParams(AttributeSet attrs) {
    // 不同的ViewGroup生成LayoutParams的细节不同
    return new LayoutParams(getContext(), attrs);
}

attachToRoot则用于判断root是直接作为parent使用还是仅需要他的类型信息。

if (root != null) {
    if (attachToRoot) {
        root.addView(temp, params);
    }else{
        // Set the layout params for temp if we are not attaching. 
        temp.setLayoutParams(params);
    }
}

递归处理Xml中的所有标签

inflate方法中,除了根标签以外所有剩余标签的解析只使用了一个方法:

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    ...
    // Inflate all children under temp against its context.
    rInflateChildren(parser, temp, attrs, true);            
    ...
}

final void rInflateChildren(XmlPullParser parser, View parent, AttributeSet attrs,
        boolean finishInflate) throws XmlPullParserException, IOException {
    rInflate(parser, parent, parent.getContext(), attrs, finishInflate);
}

这个方法仅仅是调用rInflate方法,没有其他额外的行为。rInflate方法的中r的含义是Recursive,即递归,我们可以猜测这个方法是使用递归的方式去处理Xml中的所有嵌套关系。我们看下rInflate核心部分:

这段方法涉及到XmlPullParser的知识,他将Xml文件转换为一个对象。通过next()方法获得下一个事件,一共有五个事件START_DOCUMENT、START_TAG、TEXT、END_TAG、END_DOCUMENT。并且通过depth取得当前元素嵌套的深度,未读取到START_TAG时depth为0,每次读取到START_TAG时depth加1。细节参考 http://www.xmlpull.org/

void rInflate(XmlPullParser parser, View parent, Context context,
            AttributeSet attrs, boolean finishInflate) {
    
    final int depth = parser.getDepth();
    // while的结束条件:找到该depth的END_TAG(或者文件结束)
    while (((type = parser.next()) != XmlPullParser.END_TAG
        || parser.getDepth() > depth) 
        && type != XmlPullParser.END_DOCUMENT) {
            
        // 只有遇到START_TAG时进行解析
        if (type != XmlPullParser.START_TAG) {
            continue;
        }
    
        final String name = parser.getName();
    
        if (TAG_REQUEST_FOCUS.equals(name)) {
            ...
        } else if (TAG_TAG.equals(name)) {
            parseViewTag(parser, parent, attrs);
        } else if (TAG_INCLUDE.equals(name)) {
            if (parser.getDepth() == 0) {
                throw new InflateException("<include /> cannot be the root element");
            }
            parseInclude(parser, context, parent, attrs);
        } else if (TAG_MERGE.equals(name)) {
            throw new InflateException("<merge /> must be the root element");
        } else {
            // 将当前Tag转换为View
            final View view = createViewFromTag(parent, name, context, attrs);
            // 根据父布局类型读取布局属性
            final ViewGroup viewGroup = (ViewGroup) parent;
            final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
            // 嵌套处理,解析当前View的所有children
            rInflateChildren(parser, view, attrs, true);
            // 将View加入到parent中
            viewGroup.addView(view, params);
        }
    }
}

我们配合例子分析下rInflate方法的流程:

                               <!-- depth -->
<LayoutA >                       <!-- 1 -->
    <ViewB />                    <!-- 2 -->
    <LayoutC >                   <!-- 2 -->
        <ViewC1 />               <!-- 3 -->
    </LayoutC >                  <!-- 2 -->
</LayoutA >                      <!-- 1 -->
1.首先记下当前的depth
2.取Xml中的下一个事件,直到到达文件的结尾,或者到达了当前depth的END_TAG

参照例子,如果rInflate开始时解析到了<LayoutC>,那么当解析到</LayoutC>时就会从while跳出,本次执行结束。

3. while循环中,遇到START_TAG时解析

只解析STAST_TAG是保证每个Tag都只生成一个View,比如在解析到<LayoutC>时生成一个View,解析到</LayoutC>时则不需要。

解析普通的TAG的逻辑如下:

// 将当前Tag转换为View
final View view = createViewFromTag(parent, name, context, attrs);
// 根据父布局类型读取布局属性
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
// 嵌套处理,解析当前View的所有children
rInflateChildren(parser, view, attrs, true);
// 将View加入到parent中
viewGroup.addView(view, params);

这里需要关注的就是那个嵌套的rInflateChildren()方法,他也会走到rInflate,以当前解析出的View作为Parent,解析下一层的内容。

inflate过程推演

我们还是以上面的例子将inflate方法的执行流程推演一遍。

                               <!-- depth -->
<LayoutA >                       <!-- 1 -->
    <ViewB />                    <!-- 2 -->
    <LayoutC >                   <!-- 2 -->
        <ViewC />                <!-- 3 -->
    </LayoutC >                  <!-- 2 -->
</LayoutA >                      <!-- 1 -->
  • inflate方法,取到<LayoutA>,解析出LayoutA对象
  • 通过rInflate方法解析LayoutA的所有children,开始时depth为1
    • 取到<ViewB />,解析出ViewB对象,ViewB无children,它的rInflateChildren会读到ViewB的END_TAG并结束,将ViewB加入到LayoutA中
    • 取到<LayoutC>,解析出LayoutC对象,调用rInflate方法解析Children
      • 取到<ViewC />,解析出ViewC对象,无Children,将ViewC加入到LayoutC中
      • 取到<LayoutC />,LayoutC的rInflateChildren过程结束
      • 将LayoutC对象加入到LayoutA中
    • 取到</LayoutA>,为END_TAG且depth为1,rInflate方法结束
  • View Hierarchy解析完成,配合root及attachToRoot做些处理便可返回

整个流程自上而下的解析了Xml文件中的所有Tag,并生成了对应的View Hierarchy,嵌套关系顺利转换成了父子关系。生成的View Hierarchy是一个树状结构,生成过程跟树的深度优先遍历有相似的感觉。

merge标签解析

解析完rInflate方法后,我们再来看下<merge>标签的解析:

if (TAG_MERGE.equals(name)) {
    if (root == null || !attachToRoot) {
        throw new InflateException("<merge /> can be used only with a valid "
            + "ViewGroup root and attachToRoot=true");
    }
    rInflate(parser, root, inflaterContext, attrs, false);
}

其实就是忽略<merge>这一层,将<merge>的所有内容都直接加入到root中。

由Tag生成对应的View

前面我们介绍了layout Xml如何转换为View Hierarchy,这个过程中的最后一个细节就是单个Tag如何转化成View对象

无论是inflate方法对根View的解析还是rInflate中的嵌套解析,都是调用createViewFromTag()方法,我们看下这个方法的核心部分。

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {
    
    // <view>标签中可以使用class来标注类        
    if (name.equals("view")) {
        name = attrs.getAttributeValue(null, "class");
    }
    
    // 除了<include>以外,ignoreThemeAttr总为ture,读取Xml中的theme并通过Context作用到View上
    if (!ignoreThemeAttr) {
        final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
        final int themeResId = ta.getResourceId(0, 0);
        if (themeResId != 0) {
            context = new ContextThemeWrapper(context, themeResId);
        }
        ta.recycle();
    }

    // 就当是彩蛋吧,let's party
    if (name.equals(TAG_1995)) {
        // Let's party like it's 1995!
        return new BlinkLayout(context, attrs);
    }
    
    // 生成View
    try {
        View view;
        if (mFactory2 != null) {
            view = mFactory2.onCreateView(parent, name, context, attrs);
        } else if (mFactory != null) {
            view = mFactory.onCreateView(name, context, attrs);
        } else {
            view = null;
        }
    
        if (view == null && mPrivateFactory != null) {
            view = mPrivateFactory.onCreateView(parent, name, context, attrs);
        }
    
        if (view == null) {
            final Object lastContext = mConstructorArgs[0];
            // 用于构建View的参数
            // View(Context context, @Nullable AttributeSet attrs)
            // 第一个传参是context
            attrs, int defStyleAttr)
            mConstructorArgs[0] = context;
            try {
                // 使用系统控件时我们可以不带着命名空间,此时name中不包含"."
                if (-1 == name.indexOf('.')) {
                    view = onCreateView(parent, name, attrs);
                } else {
                    view = createView(name, null, attrs);
                }
            } finally {
                mConstructorArgs[0] = lastContext;
            }
        }
    
        return view;
    }
    ...
}    

直接看到生成View的部分

  1. 由Factory2生成
  2. 由Factory生成
  3. 由mPrivateFactory生成
  4. 由onCreateView/createView方法生成

这里的生成方法有先后顺序,View一旦生成就不用走后面的方法。Factory2及Factory可以看做是给我们hook代码的,允许我们按自己的期望去将Tag转换为View,二者的区别是工厂方法的传参不同。

如果没有设置工厂

if (-1 == name.indexOf('.')) {
    view = onCreateView(parent, name, attrs);
} else {
    view = createView(name, null, attrs);
}

Xml中使用系统控件可以不加上命名空间,因此name中没有“.”,在onCreateView方法中会为系统控件加上前缀“"android.view."”并调用createView方法。而我们前面提到了我们实际使用的LayoutInflater通常是PhoneLayoutInflater,他重写了onCreateView方法:

/// PhoneLayoutInflater.java

@Override protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
    for (String prefix : sClassPrefixList) {
        try {
            View view = createView(name, prefix, attrs);
            if (view != null) {
                return view;
            }
        } catch (ClassNotFoundException e) {
            // In this case we want to let the base class take a crack
            // at it.
        }
    }

    return super.onCreateView(name, attrs);
}

private static final String[] sClassPrefixList = {
    "android.widget.",
    "android.webkit.",
    "android.app."
};

/// LayoutInflater.java

protected View onCreateView(String name, AttributeSet attrs)
        throws ClassNotFoundException {
    return createView(name, "android.view.", attrs);
}

这里的操作都是为系统控件补全命名空间,具体的生成View的工作由createView完成,核心代码如下:

public final View createView(String name, String prefix, AttributeSet attrs)
            throws ClassNotFoundException, InflateException {
    ...
    // 使用ClassLoader获得构造器
    clazz = mContext.getClassLoader().loadClass(prefix != null ? (prefix + name) : name).asSubclass(View.class);
    constructor = clazz.getConstructor(mConstructorSignature);
    constructor.setAccessible(true);  
    
    // View的构造器的传参
    Object[] args = mConstructorArgs;
    args[1] = attrs;
    
    // 获得View实例    
    final View view = constructor.newInstance(args);
    return view;
    ...
}

先通过ClassLoader加载类,获得构造器,然后实例化View的子类。具体的构造方法是View(Context context, @Nullable AttributeSet attrs),因此我们的自定义控件也需要实现这个构造方法才能在Xml中正确使用。
`

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