2behavior原理解析

上篇文章中,我们简单介绍了一下Behavior,今天对他的原理做进一步分析。主要介绍behavior如何自定义,behavior的构造,onPreDraw,fab为何随snackbar变化的相关的知识。

自定义behavior

先看个例子,上篇文章主要是重点分析了下,为什么snackbar出现和消失的时候,fab会做出相应变化,那我们能否修改这种变化呢?
比如我想要snackbar出现的时候,fab往上移动100,snackbar消失的时候fab再往上移动100,能否实现呢?
当然可以,代码也很简单,自定义一个MyBehavior,注意必须加入一个MyBehavior的构造器,带有Context和AttributeSet参数,原因后文会说。

public class MyBehavior extends CoordinatorLayout.Behavior<View> {

    //此构造函数必须加入
    public MyBehavior(Context context, AttributeSet attrs) {
        super(context,attrs);
    }

    //child就是绑定此behavior的view,dependency是发送变化的view
    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
        return dependency instanceof Snackbar.SnackbarLayout;
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
        //此处child 就是fab,dependency是被依赖的view
        if (dependency instanceof Snackbar.SnackbarLayout) {
            //SnackbarLayout 变化了,A该如何变化在这里写
            child.setTranslationY(child.getTranslationY() - 100);
            return true;
        }
        return false;
    }

    @Override
    public void onDependentViewRemoved(CoordinatorLayout parent, View child, View dependency) {
        if (dependency instanceof Snackbar.SnackbarLayout) {
            //SnackbarLayout 变化了,fab该如何变化在这里写
            child.setTranslationY(child.getTranslationY() - 100);
        }
    }
}

然后在xml内配置behavior,其实就是加入了一行代码 app:layout_behavior="com.fish.a2.MyBehavior"

    <android.support.design.widget.FloatingActionButton
        android:id="@+id/fab"
        app:layout_behavior="com.fish.a2.MyBehavior"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_margin="@dimen/fab_margin"
        android:src="@android:drawable/ic_dialog_email" />

看效果

所以说自定义Behavior也是很简单的事情

原理分析

behavior到底是什么

从上边的代码看来,behavior像是view的一个属性,其实他是view的LayoutParam的一个属性,就像宽高一样。当然不是任何一个view的LayoutParam都有这个属性的,只有LayoutParam为android.support.design.widget.CoordinatorLayout.LayoutParams才有这个属性,说白了,就是只有CoordinatorLayout的子view的LayoutParam可以设置behavior。我们看看CoordinatorLayout.LayoutParams的代码,可以发现Behavior变量的确在里面。

  public static class LayoutParams extends ViewGroup.MarginLayoutParams {
        Behavior mBehavior;
        boolean mBehaviorResolved = false;
        ...
        final Rect mLastChildRect = new Rect();
        }

再来看看app:layout_behavior="com.fish.a2.MyBehavior"这行代码是怎么导致LayoutParams内的mBehavior被赋值的
我们知道infate的时候,会根据xml去构造LayoutParams,所以我们看CoordinatorLayout.LayoutParams的构造函数

LayoutParams(Context context, AttributeSet attrs) {
    super(context, attrs);

    final TypedArray a = context.obtainStyledAttributes(attrs,
            R.styleable.CoordinatorLayout_LayoutParams);
    ...
    mBehaviorResolved = a.hasValue(
            R.styleable.CoordinatorLayout_LayoutParams_layout_behavior);
    if (mBehaviorResolved) {
        mBehavior = parseBehavior(context, attrs, a.getString(
                R.styleable.CoordinatorLayout_LayoutParams_layout_behavior));
    }

    a.recycle();
}

当我们构造fab的LayoutParams时,走到L7,查一下是否存在layout_behavior值,如果存在,那就parseBehavior并且赋值给mBehavior。parseBehavior就是把字符串com.fish.a2.MyBehavior变成一个对象,用反射的方法。主要代码如下所示

// 这里是指定的Behavior构造器的参数类型
static final Class<?>[] CONSTRUCTOR_PARAMS = new Class<?>[] {
        Context.class,
        AttributeSet.class
};

...

static Behavior parseBehavior(Context context, AttributeSet attrs, String name) {
    ...
    try {
        Map<String, Constructor<Behavior>> constructors = sConstructors.get();
        if (constructors == null) {
            constructors = new HashMap<>();
            sConstructors.set(constructors);
        }
        Constructor<Behavior> c = constructors.get(fullName);
        
        if (c == null) {
            final Class<Behavior> clazz = (Class<Behavior>) Class.forName(fullName, true,
                    context.getClassLoader());
            //获取特定参数的构造器        
            c = clazz.getConstructor(CONSTRUCTOR_PARAMS);
            c.setAccessible(true);
            constructors.put(fullName, c);
        }
        return c.newInstance(context, attrs);
    } catch (Exception e) {
        throw new RuntimeException("Could not inflate Behavior subclass " + fullName, e);
    }
}

主要L23,这里是用特定参数的构造器来c.newInstance的,CONSTRUCTOR_PARAMS就是一个Context,一个AttributeSet,为什么我们开头的时候说自定义Behavior必须带一个这种类型的构造器的,现在应该有答案了。
那么现在问题来了,在上一篇文章中,我们根本就没有设置layout_behavior,那这个LayoutParams里面的mBehavior是在哪里复制的呢?
我们再来看看, FloatingActionButton有个注解,CoordinatorLayout.DefaultBehavior(FloatingActionButton.Behavior.class),在这里指定了默认的Behavior

@CoordinatorLayout.DefaultBehavior(FloatingActionButton.Behavior.class)
public class FloatingActionButton extends VisibilityAwareImageButton 

在CoordinatorLayout的onMeasure的时候会调用prepareChildren,进而调用getResolvedLayoutParams,在getResolvedLayoutParams里会把注解里的默认Behavior赋值给mBehavior,主要代码如下

    LayoutParams getResolvedLayoutParams(View child) {
        final LayoutParams result = (LayoutParams) child.getLayoutParams();
        //如果xml内写了behavior,此时result.mBehaviorResolved就为true,不会进去
        if (!result.mBehaviorResolved) {
            Class<?> childClass = child.getClass();
            DefaultBehavior defaultBehavior = null;
            while (childClass != null &&
                    (defaultBehavior = childClass.getAnnotation(DefaultBehavior.class)) == null) {
                childClass = childClass.getSuperclass();
            }
            if (defaultBehavior != null) {
                try {
                    result.setBehavior(defaultBehavior.value().newInstance());
                } catch (Exception e) {
                    Log.e(TAG, "Default behavior class " + defaultBehavior.value().getName() +
                            " could not be instantiated. Did you forget a default constructor?", e);
                }
            }
            result.mBehaviorResolved = true;
        }
        return result;
    }

所以到了现在,我们知道设置一个view的behavior有2种方式,xml内指定,或者注解里指定,xml优先级高。xml内指定的话,是在inflate的时候对mBehavior赋值的,在注解里指定的话,是在onMeasure内赋值的,稍有不同。

behavior如何发挥作用

前面说了如何给view配置behavior,那配了behavior又有什么用呢?为何behavior能够监测到另一个view的变化情况,这都是CoordinatorLayout的功劳。

onMeasure

我们再来看看onMeasure的代码,主要看prepareChildren和ensurePreDrawListener

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        prepareChildren();
        ensurePreDrawListener();
        。。。

先看prepareChildren

    private void prepareChildren() {
         //清空mDependencySortedChildren
        mDependencySortedChildren.clear();
        for (int i = 0, count = getChildCount(); i < count; i++) {
            final View child = getChildAt(i);

            final LayoutParams lp = getResolvedLayoutParams(child);
            lp.findAnchorView(this, child);
            //加入child
            mDependencySortedChildren.add(child);
        }
        // We need to use a selection sort here to make sure that every item is compared
        // against each other
        //排序
        selectionSort(mDependencySortedChildren, mLayoutDependencyComparator);
    }

prepareChildren内做了什么,主要是搞出来一个mDependencySortedChildren,根据依赖关系对child进行排序。首先L3把mDependencySortedChildren clear,然后遍历子view,全部加入到mDependencySortedChildren内,最后对mDependencySortedChildren进行排序。注意每次measure都会调用prepareChildren来搞出一个mDependencySortedChildren。
我们在看看排序的代码(用的冒泡),看mLayoutDependencyComparator就行了,看下边代码可以知道,被依赖的view放前面,比如我们fab依赖于snackbar,那么snackbar必然放在fab的前边。这么排序有什么用?其实是提高一点效率,后文会说的。

    final Comparator<View> mLayoutDependencyComparator = new Comparator<View>() {
        @Override
        public int compare(View lhs, View rhs) {
            if (lhs == rhs) {
                return 0;
            } else if (((LayoutParams) lhs.getLayoutParams()).dependsOn(
                    CoordinatorLayout.this, lhs, rhs)) {
                return 1;
            } else if (((LayoutParams) rhs.getLayoutParams()).dependsOn(
                    CoordinatorLayout.this, rhs, lhs)) {
                return -1;
            } else {
                return 0;
            }
        }
    };

再看ensurePreDrawListener

在prepareChildren确定mDependencySortedChildren之后,会执行ensurePreDrawListener,在这里写判断下CoordinatorLayout的子view是否存在依赖关系,如果存在的话就hasDependencies为true,后边会加入PreDrawListener。

    void ensurePreDrawListener() {
         //判断是否存在依赖关系
        boolean hasDependencies = false;
        final int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = getChildAt(i);
            if (hasDependencies(child)) {
                hasDependencies = true;
                break;
            }
        }

        if (hasDependencies != mNeedsPreDrawListener) {
            if (hasDependencies) {
                //加入PreDrawListener
                addPreDrawListener();
            } else {
                removePreDrawListener();
            }
        }
    }

PreDrawListener是什么?看下边代码,简单,就是在onPreDraw的时候调用dispatchOnDependentViewChanged。

    void addPreDrawListener() {
        if (mIsAttachedToWindow) {
            // Add the listener
            if (mOnPreDrawListener == null) {
                mOnPreDrawListener = new OnPreDrawListener();
            }
            final ViewTreeObserver vto = getViewTreeObserver();
            vto.addOnPreDrawListener(mOnPreDrawListener);
        }

        // Record that we need the listener regardless of whether or not we're attached.
        // We'll add the real listener when we become attached.
        mNeedsPreDrawListener = true;
    }
    class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener {
        @Override
        public boolean onPreDraw() {
            dispatchOnDependentViewChanged(false);
            return true;
        }
    }

onPreDraw这个回调和onGlobalLayout类似的,他们的对象是ViewTreeObserver,而不是某个view。在即将绘制的时候,会调用mTreeObserver.dispatchOnPreDraw(),然后分发到各个OnPreDrawListener,在回调onPreDraw的。简单的说,就是在重绘之前,会调用onPreDraw。我们在onPreDraw里面调用了dispatchOnDependentViewChanged,这个函数是CoordinatorLayout非常重要的函数。Behavior的主要行为都是写在这里面的。我们先总结下ensurePreDrawListener做了什么,判断子view是否有依赖行为,如果有的话注册一个onPreDraw监听

dispatchOnDependentViewChanged

这里传进来的fromNestedScroll为false,遍历mDependencySortedChildren,查一下每个view的rect是否发生了变化,如果发生了变化(假设变化的view为A),就遍历后边的view,判断后边view是否依赖于A(L33),如果依赖就做出相应变化(L36)。看到L33和L36,终于舒了口气,和Behavior里的行为扯上了关系。

    void dispatchOnDependentViewChanged(final boolean fromNestedScroll) {
        final int layoutDirection = ViewCompat.getLayoutDirection(this);
        final int childCount = mDependencySortedChildren.size();
        for (int i = 0; i < childCount; i++) {
            final View child = mDependencySortedChildren.get(i);
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();

            // Check child views before for anchor
            for (int j = 0; j < i; j++) {
                final View checkChild = mDependencySortedChildren.get(j);

                if (lp.mAnchorDirectChild == checkChild) {
                    offsetChildToAnchor(child, layoutDirection);
                }
            }

            // Did it change? if not continue
            final Rect oldRect = mTempRect1;
            final Rect newRect = mTempRect2;
            getLastChildRect(child, oldRect);
            getChildRect(child, true, newRect);
            if (oldRect.equals(newRect)) {
                continue;
            }
            recordLastChildRect(child, newRect);

            // Update any behavior-dependent views for the change
            for (int j = i + 1; j < childCount; j++) {
                final View checkChild = mDependencySortedChildren.get(j);
                final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams();
                final Behavior b = checkLp.getBehavior();
                    //这里调用了behavior的layoutDependsOn
                if (b != null && b.layoutDependsOn(this, checkChild, child)) {
                。。。
                //这里调用了behavior的onDependentViewChanged
                    final boolean handled = b.onDependentViewChanged(this, checkChild, child);
                    ...
                }
            }
        }
    }

这里再说几点,怎么知道哪些view发生了变化,代码如下,就是看oldRect和 newRect 是否一致,getChildRect就是获取view的当前rect,而getLastChildRect是获取view的旧的rect,这个比较奇怪,居然知道旧的rect。

            final Rect oldRect = mTempRect1;
            final Rect newRect = mTempRect2;
            getLastChildRect(child, oldRect);
            getChildRect(child, true, newRect);
            if (oldRect.equals(newRect)) {
                continue;
            }
            recordLastChildRect(child, newRect);

看看getLastChildRect的代码,原来CoordinatorLayout的LayoutParams里面存储了mLastChildRect。看上边的L8可以知道,会记录newRect到LayoutParams里。

    void getLastChildRect(View child, Rect out) {
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        out.set(lp.getLastChildRect());
    }

还有个问题,比如我们知道子view A发生了变化,可能有B依赖于A,C依赖于A,怎么去找B,C呢,看上边L28,只要遍历A后边的代码就可以了,为什么?看看前文的mDependencySortedChildren的排序规则就知道了,B,C绝对是在A的后边。可以省去找前面的view,这就是mDependencySortedChildren排序的作用。

再后边代码就是,先判断下b.layoutDependsOn是否返回true,然后执行b.onDependentViewChanged

                if (b != null && b.layoutDependsOn(this, checkChild, child)) {
                。。。
                //这里调用了behavior的onDependentViewChanged
                    final boolean handled = b.onDependentViewChanged(this, checkChild, child);
                    ...
                }
            }

好了,behavior的原理基本分析完了。有点绕,但不复杂。
我个人认为,这个实现过程还可以优化一下,比如上文第一个for循环,是遍历了所有的子view,实际上只要遍历被依赖的子view 就好了。而第二个for循环,是遍历了child(rect变化的view)之后的所有子view,其实也没这个必要,因为依赖关系是早就定好的,可以建一个数组存储哪些view依赖了child,这样只要遍历这个数组就可以了。
如果是我来写,我会给每个view设计一个依赖者数组,比如Aview的依赖者数组内有B,C,就代表B依赖于A,C依赖于A。 那第一个for循环遍历依赖者数组非空的view即可,而第二个for循环遍历依赖者数组就好。
还有一点,view的rect发生变化肯定在onLayout之后就知道了,如果在onLayout里把发生变化的view记录下来,那么第一个for循环就可以更简单了,也没必要在LayoutParam里面设计一个mLastChildRect了。
以上是我的个人想法,如有不对,欢迎指正,可能代码google认为反正CoordinatorLayout的子view很小,所以没必要搞那么复杂。

onDependentViewRemoved

我们开篇自定布局的时候还写了onDependentViewRemoved,那这个onDependentViewRemoved是在哪里被调用的呢?
ViewGroup内有个mOnHierarchyChangeListener,view结构发生变化时会触发OnHierarchyChangeListener回调。

protected OnHierarchyChangeListener mOnHierarchyChangeListener;
    public interface OnHierarchyChangeListener {
        /**
         * Called when a new child is added to a parent view.
         *
         * @param parent the view in which a child was added
         * @param child the new child view added in the hierarchy
         */
        void onChildViewAdded(View parent, View child);

        /**
         * Called when a child is removed from a parent view.
         *
         * @param parent the view from which the child was removed
         * @param child the child removed from the hierarchy
         */
        void onChildViewRemoved(View parent, View child);
    }

再看CoordinatorLayout内自己定义了一个HierarchyChangeListener,在onChildViewRemoved的时候会调用dispatchDependentViewRemoved,这个HierarchyChangeListener在构造函数内set。所以有view被remove调的时候回回调到
dispatchDependentViewRemoved。


    private class HierarchyChangeListener implements OnHierarchyChangeListener {
        @Override
        public void onChildViewAdded(View parent, View child) {
            if (mOnHierarchyChangeListener != null) {
                mOnHierarchyChangeListener.onChildViewAdded(parent, child);
            }
        }

        @Override
        public void onChildViewRemoved(View parent, View child) {
            dispatchDependentViewRemoved(child);

            if (mOnHierarchyChangeListener != null) {
                mOnHierarchyChangeListener.onChildViewRemoved(parent, child);
            }
        }
    }

dispatchDependentViewRemoved的代码也很简单,会根据需要触发onDependentViewRemoved

   void dispatchDependentViewRemoved(View view) {
        final int childCount = mDependencySortedChildren.size();
        boolean viewSeen = false;
        for (int i = 0; i < childCount; i++) {
            final View child = mDependencySortedChildren.get(i);
            if (child == view) {
                // We've seen our view, which means that any Views after this could be dependent
                viewSeen = true;
                continue;
            }
            if (viewSeen) {
                CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams)
                        child.getLayoutParams();
                CoordinatorLayout.Behavior b = lp.getBehavior();
                if (b != null && lp.dependsOn(this, child, view)) {
                    b.onDependentViewRemoved(this, child, view);
                }
            }
        }
    }

上述代码都是为了监听某个view被remove而加的,那为什么增加一个view的时候没这么麻烦,删除一个view就这么麻烦呢。因为增加了一个view,那这个view,必然在mDependencySortedChildren内,而删除了一个view,这个view在mDependencySortedChildren就找不到了,所以加了这一堆代码

泛型类Behavior

要知道Behavior其实是个泛型类

    public static abstract class Behavior<V extends View> 

所以自定义Behavior可以这么写,这样更优雅,免去了强转

    public static class Behavior extends CoordinatorLayout.Behavior<FloatingActionButton> {
        // We only support the FAB <> Snackbar shift movement on Honeycomb and above. This is
        // because we can use view translation properties which greatly simplifies the code.
        private static final boolean SNACKBAR_BEHAVIOR_ENABLED = Build.VERSION.SDK_INT >= 11;

        private ValueAnimatorCompat mFabTranslationYAnimator;
        private float mFabTranslationY;
        private Rect mTmpRect;

        @Override
        public boolean layoutDependsOn(CoordinatorLayout parent,
                FloatingActionButton child, View dependency) {
            // We're dependent on all SnackbarLayouts (if enabled)
            return SNACKBAR_BEHAVIOR_ENABLED && dependency instanceof Snackbar.SnackbarLayout;
        }

        @Override
        public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionButton child,
                View dependency) {
            if (dependency instanceof Snackbar.SnackbarLayout) {
                updateFabTranslationForSnackbar(parent, child, dependency);
            } else if (dependency instanceof AppBarLayout) {
                // If we're depending on an AppBarLayout we will show/hide it automatically
                // if the FAB is anchored to the AppBarLayout
                updateFabVisibility(parent, (AppBarLayout) dependency, child);
            }
            return false;
        }

        @Override
        public void onDependentViewRemoved(CoordinatorLayout parent, FloatingActionButton child,
                View dependency) {
            if (dependency instanceof Snackbar.SnackbarLayout) {
                updateFabTranslationForSnackbar(parent, child, dependency);
            }
        }

总结

1、view的behavior有2种方式,xml内指定,或者注解里指定,xml优先级高。xml内指定的话,是在inflate的时候对mBehavior赋值的,在注解里指定的话,是在onMeasure内赋值的,稍有不同。
2、behavior能够检测到view的尺寸变化以及view被remove
3、CoordinatorLayout内的mDependencySortedChildren里,被依赖的view放前面,比如我们fab依赖于snackbar,那么snackbar必然放在fab的前边。

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

推荐阅读更多精彩内容