Android开发笔记(二十九)自定义动画框架-让scrollview里的所有控件随着滑动的距离执行各自的动画

        这次我们做一个动画框架,配置scrollview里包含的控件的自定义属性,就可以实现滑动Scrollview时,里面的控件根据滑动的距离执行各自的动画进度。scrollivew里包含的这些控件可以是任意常用的控件,如 imageView,Button,TextView等。我们将给这些普通的系统控件配置自定义属性!看到这里是不是觉得无法实现,因为系统的ImageView,Button等是无法识别我们自定义的属性值的,系统的控件怎么识别我们随便定义的属性呢。今天我们就来解决这个问题,解决这个问题的意义在于让系统控件能像我们自定义控件一样,配置了属性就可以执行相应的动画。我们先来看一下运行效果:

源码下载地址:https://download.csdn.net/download/gaoxiaoweiandy/11136228

我们还是从界面布局讲起,直观上分析一下。

1. 布局

   activity_main.xml

<com.example.animateframe1.DiscrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res/com.example.animateframe1">
    <com.example.animateframe1.DiscrollViewContent
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="600dp"
            android:background="@android:color/white"
            android:textColor="@android:color/black"
            android:textSize="25sp"
            android:padding="25dp"
            tools:visibility="gone"
            android:gravity="center"
            android:fontFamily="serif"
            android:text="冯绍峰对着倪妮发誓说:‘’如果有一天我离开了你,我就把名字倒着念‘’。倪妮说:‘我也是’’!——尼玛,看着我也是醉了!" />

        <View
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:background="#007788"
            app:discrollve_alpha="true"
             />

        <ImageView
            android:layout_width="200dp"
            android:layout_height="120dp"
            app:discrollve_alpha="true"
            app:discrollve_translation="fromLeft|fromBottom"
            android:src="@drawable/baggage" />

        <View
            android:layout_width="match_parent"
            android:layout_height="200dp"
            app:discrollve_fromBgColor="#ffff00"
            app:discrollve_toBgColor="#88EE66" />

        <ImageView
            android:layout_width="220dp"
            android:layout_height="110dp"
            android:layout_gravity="right"
            android:src="@drawable/camera"
            app:discrollve_translation="fromRight" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="20dp"
            android:fontFamily="serif"
            android:gravity="center"
            android:text="眼见范冰冰与李晨在一起了,孩子会取名李冰冰;李冰冰唯有嫁给范伟,生个孩子叫范冰冰,方能扳回一城。"
            android:textSize="23sp"
            app:discrollve_alpha="true"
            app:discrollve_translation="fromBottom" />

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="20dp"
            android:layout_gravity="center"
            android:src="@drawable/sweet"
            app:discrollve_scaleX="true"
            app:discrollve_scaleY="true"  />
         <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="20dp"
            android:layout_gravity="center"
            android:src="@drawable/camera"
             app:discrollve_translation="fromLeft|fromBottom"
              />

    </com.example.animateframe1.DiscrollViewContent>

</com.example.animateframe1.DiscrollView>

我们可以看出布局结构图如下

DisScrollview(重写了Scrollview) --->DisScrollviewContent(重写了LinearLayout)--->包含的系统控件。系统控件中类似于app:discrollve_translation="fromRight" 的属性都是自定义属性。现在我们来解释下为什么要重写DisScrollview与DisScrollviewContent以及如何让系统控件如Imageview等来识别我们的自定义属性。

1.1  为什么需要自定义DisScrollview

为什么要重写Scrollview,这个是为了重写

protected void onScrollChanged(int l, int t, int oldl, int oldt)函数,用于获取滚动的距离t,对于第一个控件来说的话,t/height(第一个控件的View的高度)就是aplha透明度的比例。当t=height时,aplha=1,为不透明。alpha在0-1之间。

在这里我们先不细谈onScrollChanged里的具体算法,我们现在只需大概知道它是用来获取滑动的距离t的,然后t/控件的height就可以得出一个比例,执行动画(透明度,平移X,平移Y等)的比例。

1.2 为什么需要DisScrollviewContent

这个是关键,为了解决系统控件(如Imageview)不识别我们的自定义属性的问题。

DisScrollviewContent继承于LinearLayout,我们自定义LinearLayout,无非是想改变LinearLayout的行为。那么想改变什么行为呢。我们想利用DisScrollviewContent来获取DisScrollviewContent包含的各个系统控件,并且解析到为系统控件配置的自定义属性值。这个我们很容易用一个for循环获取到各个子控件及相关XML自定义属性的值。但是我们获取到了这些自定义控件属性又能如何,imageview等系统控件又不识别,就不能执行动画。那我们获取这些自定义属性给谁用??

我们可以在imageview外再包裹一个自定义父布局ViewGroup,然后把这些获取到的自定义属性(动画属性值)赋予这个包裹的VIEWGROUP,然后让父布局可以根据属性值来执行动画,那么里面的imageview是不是也就跟着动起来了呢?这个想法应该可以实现,整体布局都执行动画飞了起来,子布局自然就跟着动了起来,相当于我们的系统控件(如Imageview)执行了动画。这是一个瞒天过海的做法,关于如何在LinearLayout addView之前给每一个系统控件包裹VIEWGROUP的事情,就交给了我们自定义的LinearLayout:DisScrollviewContent。这就是我们为什么需要DisScrollviewContent的原因:“”包裹+动画 = 子控件动画 = 瞒天过海“” 现在我们的布局应该是这样子的:

至于代码的具体实现我们后面章节讲解,当然不看讲解,直接去下载源码看也行。

我们总结一下上面的分析:

1. 自定义LinearLayout:DisScrollviewContent,改变布局结构,用自定义VIEWGROUP包裹系统控件如imageview等。

2. 自定义VIEWGROUP(用于包裹)

3. 自定义Scrollview:DisScrollview,根据滑动的距离来计算动画执行的进度比例。

Ok,接下来我们就分析一下上面1,2,3的核心代码是如何实现的。

2. 代码

2.1 DisScrollviewContent ( 自定义LinearLayout )

package com.example.animateframe1;

import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;

public class DiscrollViewContent extends LinearLayout {

	public DiscrollViewContent(Context context, AttributeSet attrs) {
		super(context, attrs);
		setOrientation(VERTICAL);
	}

    /**
     * 这个函数在加载XML布局时自动调用,可以获取到每一个系统控件配置的布局参数,包括自定义参数。
     * 每加载一个系统控件(如Imageview),则调用一次这个函数。
     * @param attrs
     * @return
     */
	@Override
	public LayoutParams generateLayoutParams(AttributeSet attrs) {

	    //从attrs所有参数里提取自定义属性值,并保持在MyLayoutParams对象里,以供“自定义包裹VIEWGROUP"使用并执行动画。
		return new MyLayoutParams(getContext(),attrs);
	}

    /**
     * 这个函数是在generateLayoutParams之后执行,在这里我们可以获取到generateLayoutParams函数返回的MyLayoutParams里的自定义属性值。
     * 然后在addview系统控件(如Imageview)之前,先创建并添加一个“自定义包裹VIEWGROUP"视图,然后将自定义属性赋给这个视图,最后在把系统控件
     * addview到"自定义包裹VIEWGROUP"里,从而实现了在代码中为XML里的每一个系统控件外层包裹一个“自定义包裹VIEWGROUP"视图。
     * @param child
     * @param index
     * @param params
     */
	@Override
	public void addView(View child, int index,
			android.view.ViewGroup.LayoutParams params) {
		MyLayoutParams p = (MyLayoutParams) params;
		if(!isDiscrollvable(p)){  //没有自定义属性的系统控件,我们就不需要外层包裹一个“自定义包裹VIEWGROUP"视图。直接addview即可。
			super.addView(child, index, params);
		}else{
         //有自定义属性的系统控件,我们需要外层包裹一个“自定义包裹VIEWGROUP"视图。
			DiscrollvableView discrollvableView = new DiscrollvableView(getContext());
			discrollvableView.setmDiscrollveAlpha(p.mDiscrollveAlpha);
			discrollvableView.setmDisCrollveTranslation(p.mDisCrollveTranslation);
			discrollvableView.setmDiscrollveScaleX(p.mDiscrollveScaleX);
			discrollvableView.setmDiscrollveScaleY(p.mDiscrollveScaleY);
			discrollvableView.setmDiscrollveFromBgColor(p.mDiscrollveFromBgColor);
			discrollvableView.setmDiscrollveToBgColor(p.mDiscrollveToBgColor);

			//先为child包裹一个外层视图
			discrollvableView.addView(child);
			//然后再把外层父视图添加到LinearLayout里。
			super.addView(discrollvableView, index, params);
		}
	}

	private boolean isDiscrollvable(MyLayoutParams p) {
		// TODO Auto-generated method stub
		return p.mDiscrollveAlpha||
				p.mDiscrollveScaleX||
				p.mDiscrollveScaleY||
				p.mDisCrollveTranslation!=-1||
				(p.mDiscrollveFromBgColor!=-1&&
				p.mDiscrollveToBgColor!=-1);
	}

	public static class MyLayoutParams extends LayoutParams{
		public int mDiscrollveFromBgColor;//背景颜色变化开始值
		public int mDiscrollveToBgColor;//背景颜色变化结束值
		public boolean mDiscrollveAlpha;//透明度
		public int mDisCrollveTranslation;//平移ֵ
		public boolean mDiscrollveScaleX;//宽度缩放
		public boolean mDiscrollveScaleY;//高度缩放

		public MyLayoutParams(Context context, AttributeSet attrs) {
			super(context, attrs);
			// 备份自定义属性值
			TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.DiscrollView_LayoutParams);
			mDiscrollveAlpha = a.getBoolean(R.styleable.DiscrollView_LayoutParams_discrollve_alpha, false);
			mDiscrollveScaleX = a.getBoolean(R.styleable.DiscrollView_LayoutParams_discrollve_scaleX, false);
			mDiscrollveScaleY = a.getBoolean(R.styleable.DiscrollView_LayoutParams_discrollve_scaleY, false);
			mDisCrollveTranslation = a.getInt(R.styleable.DiscrollView_LayoutParams_discrollve_translation, -1);
			mDiscrollveFromBgColor = a.getColor(R.styleable.DiscrollView_LayoutParams_discrollve_fromBgColor, -1);
			mDiscrollveToBgColor = a.getColor(R.styleable.DiscrollView_LayoutParams_discrollve_toBgColor, -1);
			a.recycle();
		}
		
	}
	

}

上面代码已经很详细,我们还是有必要解释下这几个重写函数,以及为什么要重写它们。

2.1.1 generateLayoutParams(atrr):
   是在addview之前执行的。由于我们想要在addview里实现为系统控件(如Imageview)添加外层包裹VIEWGOURP,并且把系统控件里的自定义属性赋给“”自定义包裹VIEWGROUP",为了这个自定义VIEWGROUP将来能根据这些属性带着子控件一起飞起来(执行动画)。为了获取这些自定义属性,所以我们得重写generateLayoutParams函数。这个函数返回的LayoutParams恰好是接下来要调用的addview函数的LayoutParams params参数。因此我们在generateLayoutParams函数里从attr里筛选出自定义属性并保存到自定义的MyLayoutParams对象里,将来传递到addview中的LayoutParams就是MyLayoutParams类型。

2.1.2  addView(View child, int index, android.view.ViewGroup.LayoutParams params)

 addView是在generateLayoutParams之后执行,其中params参数就是2.1.1中generateLayoutParams函数返回的MyLayoutParams参数,从这个参数里可以获取到自定义属性,然后在addview系统控件(如Imageview)之前,先创建并添加一个“自定义包裹VIEWGROUP"视图,并将自定义属性赋给这个视图类,最后在把系统控件(如Imageview) *addview到"自定义包裹VIEWGROUP"里,最终一起添加到LinearLayout里。从而实现了在代码中为XML里的每一个系统控件外层包裹一个“自定义包裹VIEWGROUP"视图。

OK,至此我们已经实现了在系统控件外包裹一层可以识别自定义属性的VIEWGROUP父布局,接下来我们就来看一下这个自定义VIEWGROUP是如何执行动画的。

2.1 自定义VIEWGROUP:  DiscrollvableView

package com.example.animateframe1;
import android.animation.ArgbEvaluator;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.FrameLayout;

public class DiscrollvableView extends FrameLayout implements DiscrollvableInterface{
	private static final int TRANSLATION_FROM_TOP = 0x01;
	private static final int TRANSLATION_FROM_BOTTOM = 0x02;
	private static final int TRANSLATION_FROM_LEFT = 0x04;
	private static final int TRANSLATION_FROM_RIGHT = 0x08;

	//颜色估值器
	private static ArgbEvaluator sArgbEvaluator = new ArgbEvaluator();
	/**
	 *  自定义属性
	 */
	private int mDiscrollveFromBgColor;//背景颜色变化开始值
	private int mDiscrollveToBgColor;//背景颜色变化结束值
	private boolean mDiscrollveAlpha;//是否需要透明度动画
	private int mDisCrollveTranslation;//平移值
	private boolean mDiscrollveScaleX;//是否需要x轴方向缩放
	private boolean mDiscrollveScaleY;//是否需要y轴方向缩放
	private int mHeight;//本view的高度
	private int mWidth;//宽度

	public void setmDiscrollveFromBgColor(int mDiscrollveFromBgColor) {
		this.mDiscrollveFromBgColor = mDiscrollveFromBgColor;
	}

	public void setmDiscrollveToBgColor(int mDiscrollveToBgColor) {
		this.mDiscrollveToBgColor = mDiscrollveToBgColor;
	}

	public void setmDiscrollveAlpha(boolean mDiscrollveAlpha) {
		this.mDiscrollveAlpha = mDiscrollveAlpha;
	}

	public void setmDisCrollveTranslation(int mDisCrollveTranslation) {
		this.mDisCrollveTranslation = mDisCrollveTranslation;
	}

	public void setmDiscrollveScaleX(boolean mDiscrollveScaleX) {
		this.mDiscrollveScaleX = mDiscrollveScaleX;
	}

	public void setmDiscrollveScaleY(boolean mDiscrollveScaleY) {
		this.mDiscrollveScaleY = mDiscrollveScaleY;
	}
	
	@Override
	protected void onSizeChanged(int w, int h, int oldw, int oldh) {
		// TODO Auto-generated method stub
		super.onSizeChanged(w, h, oldw, oldh);
		mWidth = w;
		mHeight = h;
		onResetDiscrollve();
	}
	
	
	public DiscrollvableView(Context context, AttributeSet attrs) {
		super(context, attrs);
		// TODO Auto-generated constructor stub
	}

	public DiscrollvableView(Context context) {
		super(context);
		// TODO Auto-generated constructor stub
	}

	@Override
	public void onDiscrollve(float ratio) {
		// ratio:0~1
		//根据ratio执行动画进度
		if(mDiscrollveAlpha){
			setAlpha(ratio);
		}
		if(mDiscrollveScaleX){
			setScaleX(ratio);
		}
		if(mDiscrollveScaleY){
			setScaleY(ratio);
		}
		

		if(isDiscrollTranslationFrom(TRANSLATION_FROM_BOTTOM)){
			setTranslationY(mHeight*(1-ratio));//mHeight-->0(代表原来的位置)
		}
		if(isDiscrollTranslationFrom(TRANSLATION_FROM_TOP)){
			setTranslationY(-mHeight*(1-ratio));//-mHeight-->0(代表原来的位置)
		}
		if(isDiscrollTranslationFrom(TRANSLATION_FROM_LEFT)){
			setTranslationX(-mWidth*(1-ratio));//-width-->0(代表原来的位置)
		}
		if(isDiscrollTranslationFrom(TRANSLATION_FROM_RIGHT)){
			setTranslationX(mWidth*(1-ratio));//width-->0(代表原来的位置)
		}

		//颜色渐变动画
		if(mDiscrollveFromBgColor!=-1&&mDiscrollveToBgColor!=-1){
			//ratio=0.5 color=中间颜色
			setBackgroundColor((Integer) sArgbEvaluator.evaluate(ratio, mDiscrollveFromBgColor, mDiscrollveToBgColor));
		}
		
	}

	private boolean isDiscrollTranslationFrom(int translationMask) {
		if(mDisCrollveTranslation==-1){
			return false;
		}
		//fromLeft|fromBottom & fromBottom = fromBottom
		return (mDisCrollveTranslation & translationMask)==translationMask;
	}

	@Override
	public void onResetDiscrollve() {
		//控制自身的动画属性
		if(mDiscrollveAlpha){
			setAlpha(0);
		}
		if(mDiscrollveScaleX){
			setScaleX(0);
		}
		if(mDiscrollveScaleY){
			setScaleY(0);
		}

		if(isDiscrollTranslationFrom(TRANSLATION_FROM_BOTTOM)){
			setTranslationY(mHeight);//mHeight-->0(代表原来的位置)
		}
		if(isDiscrollTranslationFrom(TRANSLATION_FROM_TOP)){
			setTranslationY(-mHeight);//-mHeight-->0(代表原来的位置)
		}
		if(isDiscrollTranslationFrom(TRANSLATION_FROM_LEFT)){
			setTranslationX(-mWidth);//-width-->0(代表原来的位置)
		}
		if(isDiscrollTranslationFrom(TRANSLATION_FROM_RIGHT)){
			setTranslationX(mWidth);//width-->0(代表原来的位置)
		}
		
	}

}

我们发现自定义控件里实现了接口DiscrollvableInterface并重写了

public void onDiscrollve(float ratio)  //根据比例,执行动画进度

public void onResetDiscrollve();//逆向动画,恢复到初始状态。

在这两个函数里会根据自定义属性值与ratio来执行 这个“自定义VIEWGROUP包裹”的动画,从而内部包含的系统控件(如Imageview)等也会跟着动起来。那这两个函数是在什么地方调用的,以及ratio是怎么算出来的,那这个与Scrollview的滑动有关系。那我们就来看一下自定义Scrollview。

2.2 自定义Scrollview

DiscrollView.java

package com.example.animateframe1;

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.ScrollView;

public class DiscrollView extends ScrollView {
	String TAG = "DiscrollView";
	
	private DiscrollViewContent mContent;

	public DiscrollView(Context context, AttributeSet attrs) {
		super(context, attrs);
		// TODO Auto-generated constructor stub
	}
	
	@Override
	protected void onFinishInflate() {
		// TODO Auto-generated method stub
		super.onFinishInflate();
		View content = getChildAt(0);
		mContent = (DiscrollViewContent)content;
	}

	@Override
	protected void onSizeChanged(int w, int h, int oldw, int oldh) {
		// TODO Auto-generated method stub
		super.onSizeChanged(w, h, oldw, oldh);
		View first = mContent.getChildAt(0);
		first.getLayoutParams().height = getHeight();
	}
	
	
	@Override
	protected void onScrollChanged(int l, int t, int oldl, int oldt) {
		// TODO Auto-generated method stub
		super.onScrollChanged(l, t, oldl, oldt);
		
		int scrollViewHeight = getHeight();

		//监听滑动----接口---->控制DiscrollViewContent的属性
		for(int i=0;i<mContent.getChildCount();i++){//遍历MyLinearLayout里面所有子控件(MyViewGroup)
			View child = mContent.getChildAt(i);
			if(!(child instanceof DiscrollvableInterface)){
				continue;
			}
			
			//ratio:0~1
			DiscrollvableInterface discrollvableInterface =  (DiscrollvableInterface) child;
			//1.child离scrollview顶部的高度 a
			int discrollvableTop = child.getTop();
			int discrollvableHeight = child.getHeight();

			//2.得到scrollview滑出去的高度  b  就是int t,
			//3.得到child离屏幕顶部的高度  c
			int discrollvableAbsoluteTop = discrollvableTop - t;

			Log.i(TAG,"discrollvableHeight0="+discrollvableHeight+
					"\r\nscrollViewHeight="+scrollViewHeight+",discrollvableAbsoluteTop="+discrollvableAbsoluteTop+"\r\nt="+t+",discrollvableTop="+discrollvableTop);//一屏的高度
			//什么时候执行动画?当child滑进屏幕的时候
			if(discrollvableAbsoluteTop <= scrollViewHeight)
			{


				int visibleGap = scrollViewHeight - discrollvableAbsoluteTop;

				Log.i(TAG,"visibleGap="+visibleGap+",discrollvableHeight="+discrollvableHeight+
						"\r\nscrollViewHeight="+scrollViewHeight+",discrollvableAbsoluteTop="+discrollvableAbsoluteTop+"\r\nt="+t+",discrollvableTop="+discrollvableTop);//一屏的高度

				//确保ratio是在0~1,超过了1 也设置为1
				discrollvableInterface.onDiscrollve(clamp(visibleGap/(float)discrollvableHeight, 1f,0f));
			}else{//否则,就恢复到原来的位置
				discrollvableInterface.onResetDiscrollve();
			}
		}
	}
	
	public static float clamp(float value, float max, float min){
		return Math.max(Math.min(value, max), min);
	}

}

我们主要来看一下onScrollChanged函数:

@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
参数:left:滚动后,滚动条左端水平方向上的x位置。
参数:top:滚动后,滚动条上端垂直方向上的y位置

参数:oldl:滚动前,滚动条左端水平方向上的x位置。
参数:oldt:滚动前,滚动条上端垂直方向上的y位置


   super.onScrollChanged(l, t, oldl, oldt);
   
   int scrollViewHeight = getHeight();这个scrollViewHeight 实质就是一屏的高度。


   //for选好遍历“自定义VIWEGROUP”,根据滑动距离比例,执行动画
   for(int i=0;i<mContent.getChildCount();i++){
     
      View child = mContent.getChildAt(i);
      if(!(child instanceof DiscrollvableInterface)){ 
         continue;
      }
       由于自定义VIEWGROUP包裹都实现了DiscrollvableInterface,所以没有实现的就没有包裹,不用执行动画直接continue。
      
      //ratio:0~1
      DiscrollvableInterface discrollvableInterface =  (DiscrollvableInterface) child;
      //1.child离scrollview顶部的高度 a
      int discrollvableTop = child.getTop();
      int discrollvableHeight = child.getHeight();//执行动画时的总距离,比如平移

      //2.得到scrollview滑出去的高度    就是参数int t,
      //3.得到child离屏幕顶部的高度  
      int discrollvableAbsoluteTop = discrollvableTop - t;//离屏幕顶端的初始高度 减去 滚动的距离就是当前时刻离屏幕顶端的高度

      //当前时刻离屏幕顶端的的距离 如果 小于 1屏的高度时,说明VIEWGROUP已进入屏幕,则开始执行动画
      if(discrollvableAbsoluteTop <= scrollViewHeight)
      {

         表示当上滑屏幕时,VIEWGROUP从屏幕底部探出的高度visibleGap 
         int visibleGap = scrollViewHeight - discrollvableAbsoluteTop;

         //确保ratio是在0~1,超过了1 也设置为1,visibleGap / VIWGROUP控件的总高度就是ratio,执行动画的进度比例
         discrollvableInterface.onDiscrollve(clamp(visibleGap/(float)discrollvableHeight, 1f,0f));
      }else{//否则,就恢复到原来的位置
         discrollvableInterface.onResetDiscrollve();
      }
   }
}

OK,所有流程就分析完了。现在总结一下思路:

1. 自定义LinearLayout的addview,让添加imageview等系统控件前,先在外层包裹一个自定义VIEWGROUP,并赋予它自定义属性的配置。

2. 自定义VIEWGROUP,接收滑动的ratio来执行动画进度

3. 自定义Scrollview,计算滑动的ratio,并调用自定义VIEWGROUP里的执行动画函数。

源码下载地址:https://download.csdn.net/download/gaoxiaoweiandy/11136228

发布了44 篇原创文章 · 获赞 27 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/gaoxiaoweiandy/article/details/89379459