《Android 开发艺术探索》笔记5--View工作原理

View工作原理.png

ViewRoot和DecorView

这是在View三大流程之前(measure, layout, draw),需要了解的概念.

ViewRoot对应于ViewRootImpl, 它是连接WindowManagerDecorView的纽带. View的三大流程都是通过ViewRoot来完成的. 当一个Activity对象在ActivityThread被创建后. 会将DecorView添加到Window中, 同时会创建ViewRootImp对象, 并将ViewRootImpl对象和DecorView建立关联.

View绘制流程是从ViewRoot的PerformTraversals()开始的. 经过三大流程才能将一个View绘制出来.

PerformTraversals()会依次调用performMeasure, performLayout, performDraw. 而前两种内部的调用基本一致,都是先调用measure()/layout(),然后再调用onMeasure()/onLayout()在这个方法中会对所有子元素进行测量和绘制.依次向内部传递. performDraw()有点不同是在draw调用的dispatchDraw().

  • measure过程: 决定了View宽高, measure后可以通过getMeasureWidth和getMeasureHeight来获取View的宽高. 一般情况下是最终宽高.
  • layout过程: 决定了View的顶点坐标和实际View的宽高. 完成后通过getTop, getBottom, getLeft, getRight获得四个顶点, 通过getWidth,和getHeight获得宽高
  • draw过程: 只有draw()方法完成之后View的内容才会显示出来.

setContentView(R.layout.activity_inside_intercept);

((ViewGroup) getWindow().getDecorView().findViewById(android.R.id.content)).getChildAt(0);

上面第一行可以说无时无刻不存在. 而下面这行在上一章说过就是获得我们设置的布局.那DecorView布局究竟是怎么样的, 下图.

image

DecorView就是一个FrameLayout. 而一般情况下它的布局就如上面图那样(具体和主题有关系). 而我们经常setContentView(xxx). 就是把我们编写的xml的布局添加到了DecorViewandroid.R.id.content的控件布局中. 所以也就能说通为什么getChildAt(0)会获得我们的的布局.
并且为什么我们用的关联布局的方法是setContent…

总结图:

measure流程.png

MeasureSpec

很大程度上决定一个View的尺寸规格, 之所以不是绝对, 是因为这个过程还受父容器的影响.

理解MeasureSpec

MeasureSpec本身是一个32位的int值, 但是却表示了两种信息.

  • 高2位: 代表了SpecMode, 测量模式
  • 低30位: 代表了SpecSize, 在上述测量模式中的大小

public static class MeasureSpec {

    private static final int MODE_SHIFT = 30;

    private static final int MODE_MASK  = 0x3 << MODE_SHIFT;

    public static final int UNSPECIFIED = 0 << MODE_SHIFT;

    public static final int EXACTLY     = 1 << MODE_SHIFT;

    public static final int AT_MOST     = 2 << MODE_SHIFT;

    public static int makeMeasureSpec(int size, int mode) {

      if (sUseBrokenMakeMeasureSpec) {

          return size + mode;

      } else {

          return (size & ~MODE_MASK) | (mode & MODE_MASK);

      }

    }

    public static int makeSafeMeasureSpec(int size, int mode) {

      if (sUseZeroUnspecifiedMeasureSpec && mode == UNSPECIFIED) {

          return 0;

      }

      return makeMeasureSpec(size, mode);

    }

    public static int getMode(int measureSpec) {

      return (measureSpec & MODE_MASK);

    }

    public static int getSize(int measureSpec) {

      return (measureSpec & ~MODE_MASK);

   }

   .....

}

是不是挺有意思. 三种类型分别高二位01, 00, 10来代表. 直接利用位运算. 来实现可以让频繁计算的东西使用最接近计算机的运算方式. 不需要额外的转换. 也避免了过多的对象内存分配.

说一下SpecMode的三种模式

  • UNSPECIFIED: 父容器不对View有任何的限制,要多大就给多大, 这种情况一般用于系统内部,表示一中测量状态
  • EXACTLY: 父容器已经检测出View所需要的精确大小, 这个时候View的最终大小就是SpecSize所指定的值. 对应着LayoutParams中的match_parent和具体的数值.
  • AT_MOST: 父容器制定了一个可用的大小及SpecSize, View的大小不能超过这个值, 它对应与LayoutParams中的wrap_content
image.png

MeasureSpec和LayoutParams关系

通常设置的LayoutParams,系统会在父容器的的约束下转换成对应的MeasureSpec,然后根据这个MeasureSpec来确定View测量后的宽高. 所以View自身的MeasureSpec是需要LayoutParams和父容器一起组合生成的.

上面讲述的是普通View, 但是顶级View(DecorView)有所不同. DecorView是物理窗口尺寸和自身的LayoutParams决定的. 具体在ViewRootImpl类measureHierarchy()进行生成的.

MeasureSpec一旦确定, onMeasure中就可以测量View的宽高.

对于我们日常操作的View

View的measure过程是由ViewGroup传递而来的. 看ViewGroup#measureChildWithMargins()方法

protected void measureChildWithMargins(View child,

          int parentWidthMeasureSpec, int widthUsed,

          int parentHeightMeasureSpec, int heightUsed) {

      final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

      final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,

              mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin

                      + widthUsed, lp.width);

      final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,

              mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin

                      + heightUsed, lp.height);

      child.measure(childWidthMeasureSpec, childHeightMeasureSpec);

  }

上面会对子元素进行measure, 而在此之前,会通过getChildMeasureSpec()来得到子元素的MeasureSpec. 通过调用方法传入的参数看到. 生成View的MeasureSpec和父容器的MeasureSpec, View自身方向的padding``margin, 和自身的LayoutParams这三个因素相关联.

而其中的getChildmeasureSpec()方法: 就是根据父容器的MeasureSpec同时结合View自身的LayoutParams来确定子元素的MeasureSpec.这个方法总结如下:

  • dp/px: 不管父容器的MeasureSpec是什么. View都是EXACTLY(精确模式), 而大小遵循自身LayoutParams的大小.
  • match_parent: 如果父容器是EXACTLY(精确模式),那么子View也是EXACTLY(精确模式)并且大小是父容器的剩余空间. 如果父容器是AT_MOST(最大模式),那么子View也是AT_MOST(最大模式)并且大小不会超过父容器的剩余空间.
  • wrap_content: 不管父容器是什么. View都是AT_MOST(最大模式), 并且大小不能超过父容器剩余空间.

上述没有说明UNSPECIFIEDmatch_parentwrap_content中. 因为这个模式主要用于系统多次Measure的情形,一般来说不需要关注.

View的工作流程

主要指measure, layout, draw三大流程. 即测量,布局,绘制.

measure过程

这里面存在两种场景:

  • View: 通过了measure方法就完成了测量过程
  • ViewGroup: 除了测量自己,还会遍历去调用所有子元素的measure方法. 各个子元素在递归去执行这个流程

View的measure过程

View的measure过程由其measure()方法来完成, measure()方法是一个final类型, 而在内部调用了onMeasure()这个可不是final, 所以也可以自定义的时候复写. 看一下内部.

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

   setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),

           getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));

}

setMeasureDimension()会设置View宽高的测量值.

这里需要看一下getDefaultSize()这个方法.


public static int getDefaultSize(int size, int measureSpec) {

   int result = size;

   int specMode = MeasureSpec.getMode(measureSpec);

   int specSize = MeasureSpec.getSize(measureSpec);

   switch (specMode) {

   case MeasureSpec.UNSPECIFIED:

       result = size;

       break;

   case MeasureSpec.AT_MOST:

   case MeasureSpec.EXACTLY:

       result = specSize;

       break;

   }

   return result;

}

看到如果这个view是EXACTLY(精准模式), 那么返回的大小就是SpecSize. UNSPECIFIED一般用于系统测量先不说. 而AT_MOST(最大模式)的时候. 虽然是不同模式但是默认情况下和精确模式是一样的结果.

getSuggestedMinimumWidth()getSuggestedMinimumHeight(). 看一下实现.

protected int getSuggestedMinimumWidth() {

    return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());

}

protected int getSuggestedMinimumHeight() {

    return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());

}

首先会看是否设置了背景.

  • 无背景: 那么宽度为mMinWidth,这个值对应布局中的android:minWidth属性,默认为0.
  • 有背景: 那么取mMinWidthmBackground.getMinimumHeight()最大值.

getMinimumHeight()根据看一下:


public int getMinimumHeight() {

   final int intrinsicHeight = getIntrinsicHeight();

   return intrinsicHeight > 0 ? intrinsicHeight : 0;

}

原来getMinimumHeight()返回的就是Drawable的原始高度. 如果没有就返回0. 关于原始高度举个例子ShapeDrawable无原始宽高, BitmapDrawble有原始宽高就是图片的尺寸.

整理getDefaultSize(): 直接继承View的自定义控件需要重写onMeasure()方法并设置wrap_content时的自身大小,否则在布局中使用wrap_content虽然View自身的MeasureSpec的低30位保存了父容器计算自身的剩余大小. 但是在自定义的时候如果不进行处理wrap_content,那么就会调用默认setMeasureDimension()方法. 而默认中方法的实参传递的是getDefaultSize()这个方法中对AT_MOST这种模式没有处理. 直接沿用和精确模式的大小(相当于设置了wrap_content却得到了match_parent的显示结果)

可以针对这个问题, 做出对应的编码进行解决:


@Override

   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

       super.onMeasure(widthMeasureSpec, heightMeasureSpec);

       int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);

       int widthSpaceMode = MeasureSpec.getMode(widthMeasureSpec);

       int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);

       int heightSpaceMode = MeasureSpec.getMode(heightMeasureSpec);

       //设置两个默认值宽高

       int defaultHeight = 100;

       int defaultWidth = 100;

       // 针对AT_MOST模式进行特殊处理

       if (widthSpaceMode == MeasureSpec.AT_MOST 

               && heightSpaceMode == MeasureSpec.AT_MOST){

           setMeasuredDimension(defaultWidth, defaultHeight);

       }else if (widthSpaceMode == MeasureSpec.AT_MOST){

           setMeasuredDimension(defaultWidth, heightSpaceSize);

       }else if (heightSpaceMode == MeasureSpec.AT_MOST){

           setMeasuredDimension(widthMeasureSpec, defaultHeight);

       }

   }

ViewGroup的Measure

对于ViewGroup不光会测量自己,还会遍历调用所有的子元素的measure(). 和View不同的是ViewGroup是一个抽象类,它没有重写onMeasure,但提供了measureChildren()的方法.

这个measureChildren()方法内部比较简单就是遍历自己的孩子然后调用->measureChild()

这个measureChild()这个方法前面贴过源码. 就是取出子元素的LayoutParams,并调用->getChildMeasureSpec(). 通过传入子元素的LayoutParams里面的宽高属性, 子元素的padding和margin, 父元素当前(当前ViewGroup)的MeasureSpec属性来计算出子元素的MeasureSpec最后调用->child.measure()传入之前计算的测量规格.

ViewGroup为什么没有定义测量的具体过程? 因为具体的测量过程需要交给子类去实现的. 比如LinearLayout,RelativeLayout.

看一下LinearLayoutonMeasure()是如何定义的.


@Override

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

   if (mOrientation == VERTICAL) {

       measureVertical(widthMeasureSpec, heightMeasureSpec);

   } else {

       measureHorizontal(widthMeasureSpec, heightMeasureSpec);

   }

}

根据设置的排列方式这里分之了两种测量方法. 稍微看一下大概轮廓,选择measureVertical()不贴源码了这个方法300行呢!

首先这个方法会遍历每个子元素并执行->measureChildBeforeLayout()方法.这个方法内部会调用子元素的measure(), 这样子元素会依次测量. 并且会通过mTotalLenght这个变量来存储LinearLayout在竖直方向上的初步高度, 每测量一个就会增加. 当子元素测量完之后,LinearLayout会测量自己的大小.


在对自己进行测量的时候. 如果布局中的高度采用的是match_parent或者具体数值, 那么它的测量过程和View一样,即高度为specSize. 如果布局中采用wrap_content那么高度就是所有的子元素总和但是不能超过父元素剩余空间, 还有竖直方向LinearLayout的padding. 具体可参考resolveSizeAndState()的实现.

到这里基本上measure测量过程已经做了比较详细的分析. 这个过程也是三大过程中最复杂的一个. 在measure完成之后就可以通过getMeasuredWidth/Height方法获取View的测量宽高. 但是请注意:某些极端情况下,measure可能执行多次. 所以尽量在onLayout()方法中去获得最终宽高.

image.png

正确获取宽高方法

首先明确一点:View的measure和Activity的生命周期方法不是同步执行.所以无法保证在某个生命周期(onCreate,onStart)获取到正确的测量宽高

  • onWindowFocusChanged()
  • view.post(runnable)
  • ViewTreeObserve
  • view.measure()
  1. onWindowFocusChanged():View已经初始化完毕,宽高已经准备好. 这里需要注意只要Activity的焦点发生变化此方法就会被调用.所以如果你的界面会频繁的进行onPauseonResume.并且里面有很多关联依赖的方法. 那就请注意这不是一个好办法.
  2. 通过post可以将一个runnable投递到消息队列的尾部,然后等待Looper调用此runnable的时候.View已经初始化完毕.
  3. 使用ViewTreeObserver. 当View的可见性发生了改变的时候.onGlobalLayout()将发生回调.注意伴随着View树的状态改变等,这个回调方法可能会被调用多次. 使用代码如下

ViewTreeObserver viewTreeObserver = tv_main.getViewTreeObserver();

       viewTreeObserver.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {

           @Override

           public void onGlobalLayout() {

               tv_main.getViewTreeObserver().removeOnGlobalLayoutListener(this);

               tv_main.getMeasuredHeight();

               tv_main.getMeasuredWidth();

           }

       });

  1. view.measure(widthMeasureSpec, heightMeasureSpec)

也可以手动进行测量,但是需要分情况处理.

match_parent

当View是此属性的时候无法使用measure(),首先使用这种方法需要的参数,是通过父容器和子元素组合来生成的子元素的MeasureSpec属性. 所以在外部我们不知道父元素的参数值得时候只能处理不需要父元素数据就可以生成子元素的MeasureSpec的模式

所以很清楚, 这个match_patch这个模式,在给其子元素构造MeasureSpec的时候需要得值parentSize,所以得到的也是无效.

具体数值px/dx

假设这里是100px, 首先构成宽高对应的MeasureSpec属性

int widthSpec = View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY);
tv_main.measure(widthSpec, heightSpec);

wrap_content

int widthSpec = View.MeasureSpec.makeMeasureSpec(((1 << 30)-1), View.MeasureSpec.AT_MOST);
int heightSpec = View.MeasureSpec.makeMeasureSpec(((1 << 30)-1), View.MeasureSpec.AT_MOST);
tv_main.measure(widthSpec, heightSpec);

通过(1<<30)-1 可以构成一个MeasureSpec低30位的最大值. 用理论上View能支持的最大值去构造

关于网上一些在make的使用传入UNSPECIFIED,属于违背了内部实现的规范.不用最好

关于网上另一种measure()直接传入LayoutParams.WRAP_CONTENT. 其实也只有当子元素为wrap_content和子元素为match_parent并且父元素是wrap_conetnt时会碰巧有效.

layout过程

ViewGroup中会先通过layout()方法确定本身的位置. 然后调用onLayout()方法遍历所有的子元素,并调用子元素的layout()方法确定子元素的位置…依次循环.

提出Viewlayout方法, 这里抽取部分代码

public void layout(int l, int t, int r, int b) {

       int oldL = mLeft;

       int oldT = mTop;

       int oldB = mBottom;

       int oldR = mRight;

       boolean changed = isLayoutModeOptical(mParent) ?

               setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);

       if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) 

       { onLayout(changed, l, t, r, b);}

   }

这样来看,大致流程通过setFrame()方法来设定View的四个顶点的位置, 即mLeft,mTop,mBottom,mRight,这四个顶点一旦确定.当前View的位置也就确定. 然后会调用onLayout()方法. 这个方法是确定子元素的View位置.

这里的和onMeasure()类似, onLayout()具体实现和具体的布局有关, 所以View和ViewGroup均没有真正实现onLayout()方法.

看一下LinearLayoutonLayout()源码

@Override

protected void onLayout(boolean changed, int l, int t, int r, int b) {

   if (mOrientation == VERTICAL) {

       layoutVertical(l, t, r, b);

   } else {

       layoutHorizontal(l, t, r, b);

   }

}

onMeasure()一样分支,接下来跟进layoutVertical()贴出主要代码

void layoutVertical(int left, int top, int right, int bottom) {

           //省略一部分...

       for (int i = 0; i < count; i++) {

           final View child = getVirtualChildAt(i);

           if (child == null) {

               childTop += measureNullChild(i);

           } else if (child.getVisibility() != GONE) {

               final int childWidth = child.getMeasuredWidth();

               final int childHeight = child.getMeasuredHeight();

               final LinearLayout.LayoutParams lp =

                       (LinearLayout.LayoutParams) child.getLayoutParams();

               int gravity = lp.gravity;

               if (gravity < 0) {

                   gravity = minorGravity;

               }

                //省略一部分...

               if (hasDividerBeforeChildAt(i)) {

                   childTop += mDividerHeight;

               }

               childTop += lp.topMargin;

               setChildFrame(child, childLeft, childTop + getLocationOffset(child),

                       childWidth, childHeight);

               childTop += childHeight + lp.bottomMargin + getNextLocationOffset(child);

               i += getChildrenSkipCount(child, i);

           }

       }

   }

上面代码大体逻辑: 首先遍历所有孩子并调用setChildFrame()来为子元素指定对应的位置. 其中childTop会逐渐增大, 这就意味着后面的子元素会被放置在靠下的位置. 而setChildFrame()内部仅有一行代码, 就是调用子元素的layout()并传入它自身应该存放的位置.

private void setChildFrame(View child, int left, int top, int width, int height) {        

     child.layout(left, top, left + width, top + height);

 }

而在setChildFrame()中传入的宽高就是子元素的测量宽高.

而在子元素的layout()中通过setFrame()来设置元素的四个顶点.

getWidth()layout中的宽 和getMeasureWidth()中的宽永远一样么?

在一般情况下,测量measure和layout时候的值是完全一样的. 因为layout()中接受的参数就是通过测量的结果获取到的. 并且内部直接通过setFrame()赋值到自己的四个成员变量上. 但是如果对layout()进行了复写.如下

 @Override

protected void layout(int l, int t, int r, int b) {

   super.layout( l,  t+200,  r,  b+200);

}

如果进行了这样的复写, 那么最终宽高永远会与测量的出来的值相差200.

layout流程.png

draw过程

这个过程只是将View绘制到屏幕上面.

  1. 绘制背景background.draw(canvas)
  2. 绘制自己onDraw()
  3. 绘制childrendispatchDraw()
  4. 绘制装饰onDrawScrollBars()

View绘制过程传递是通过dispatchDraw()实现的. 传递了自己的画布. 这个方法会遍历子元素并且调用元素的draw()

View一个特有的方法setWillNotDraw(), 这个方法是设置了true那么系统会进行相应的优化. 在View中默认是关闭的. 而ViewGroup默认是开启的. 如果我们继承了自定义ViewGroup如果还需要绘制自己的内容那么需要显示的关闭此标记.

draw过程.png

参看文章

《Android 开发艺术探索》书集
《Android 开发艺术探索》 04-View的工作原理
View的绘制-measure流程详解
View的绘制-layout流程详解
View的绘制-draw流程详解

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