你不能错过的View事件分发机制分析

640?wx_fmt=png&wxfrom=5&wx_lazy=1


今日科技快讯


近日,滴滴外卖正式在无锡上线,靠单日近千万元的补贴迅速拉拢人气,使得美团和饿了么被迫应战。“大战”三天后,滴滴外卖和美团都分别宣称自己日单量第一。随后情况生变,无锡市工商局以涉嫌不正当竞争紧急约谈三家平台。


作者简介


明天就是周六啦,提前祝大家周末愉快!

本篇来自 g小志 的投稿,分享了自己对View的事件分发机制的理解!希望大家喜欢。

g小志  的博客地址:

https://www.jianshu.com/u/70a8f4edb323


前言


扫描二维码关注公众号,回复: 18745 查看本文章

关于自定义View系列的文章,好久没有写了。今天抽空看了下Android开发艺术探索。正好看到了View的事件分发机制,所以将它写成笔记记录下来。

关于View的事件分发,我起初是学习郭神的2篇文章:

https://blog.csdn.net/guolin_blog/article/details/9097463

感觉其实也没有什么。大致也就了解下。不过看完其他很多优秀的文章和书籍后,才知道自己too young too simple。下面我们就一起来分析下Android的时间分发机制。

关于事件分发机制,其实网上的文章已经有很多了。我简单的看了几篇,发现写的都很好。之所以写这篇文章,主要是记录自己的学习过程,其次也想帮助和我一样的的初学者更加理解与掌握,才是本篇的目的。

注:本文源码 API=25 同时本文较长,可以先收藏再好好阅读


概念


在学习事件分发之前我们先来了解下,什么是事件分发。所谓点击事件(Touch)的事件分发,其实就是对MotionEvent(Touch的封装)事件的分发过程,即当一个MotionEvent产生以后,系统需要把这这个事件传递给那个具体的View。这个传递的过程就是事件分发过程。

1. MotionEvent

那么MotionEvent又是什么呢?

这个类就是记录手指接触屏幕后所产生的一系列的事件(也就是说我们事件分发其实就是分发MotionEvent这个对象)。这个类里包含了一系列的事件。事件的类型与含义如下:

640?wx_fmt=png

下面列举2个我们常见的点击事件序列:

  1. 事件序列 : DOWN->UP 点击屏幕后松开 (常见的点击事件)

  2. 事件序列 : DOWN->MOVE->MOVE->...->MOVE->UP 点击屏幕滑动一会 (常见的滑动屏幕)

用图来概括如下:

640?wx_fmt=png

2. 事件分发的顺序

事件分发的顺序是Activity->ViewGroup->View。也就是说在默认情况下。最后消费事件的都是View。虽然我们现在还没有开始深入讲解。但是结合我们日常开发的情况我们可以想到下面这张流程图:

640?wx_fmt=png

这张流程图就算我们没有了解事件分发,通过我们一直的使用规则来看,也是非常容易理解的。细心的小伙伴会发现。为什么Activity向下分发第一个就是ViewGroup,如果我们布局中只有一个简单View控件(如TextView)呢?还记得我们在讲View的绘制流程中介绍的吗?我们布局加载中的顶级View是DecorView(继承FrameLayout),他本是就是一个ViewGroup。不了解的可以回头看下这篇文章:

https://www.jianshu.com/p/f4e880f2b390

3. 事件分发的核心方法

在对事件分发机制概念,以及结合平时我们经验总结出来的原理后。下面我们就来通过源码来去将我们的想法串联起来。不过在看源码之前,我们要先讲下在事件分发机制中三个至关重要的方法。如下:

640?wx_fmt=png

这三个方法就是事件分发机制中的核心三个方法,也是我们下面在源码中重要去分析的三个方法。他们三者之间的关系可以概述如下(注意这是一段伪代码。在任何类中并没有此方法。只是为了对解释三个方法关系):

640?wx_fmt=png

三个方法的解释在加上这段伪代码,就很好理解三者的关系了:对于一个跟ViewGroup,点击事件产生后,首先会传给它,这时它的dispatchTouchEvent就会被调用,开始进行事件的分发,首先会进行判断,判断当前ViewGroup是否进行了拦截。如果进行拦截,那么ev(点击事件)就会交给ViewGroup去处理。不再向下传递。分发结束。如果没设置拦截。那么就会调用ViewGroup中所包含的子控件的dispatchTouchEvent (ev)方法,并将事件ev向下传递。如果子控件还是ViewGroup继续上面的循环。知道将事件最终被处理消费掉。这么一看,这不正好对应了我们前面总结的流程图嘛。看来我们将事件分发的大致流程已经都搞清楚了。


源码分析


从上面来看,好像事件分发机制也就这些东西了。好像我们都掌握了。其实不然,不过如果你上面的都理解了,说你对Android事件分发机制了个整体认识,那就一点都不为过了。不过事件分发还远不止这么简单。里面还是有很多需要注意的点和事件在分发过程中的一些规则。下面我们就从源码的角度来一一探索。

1. Activity事件分发
上面我们说了当一个事件的产生首先是传递个Activity。由Activity来进行事件的分发。那么我就看下Activity#dispatchTouchEvent():

640?wx_fmt=png

这个方法非常短。这里我们重点看第二个if语句。这里的getWindow().superDispatchTouchEvent(ev)点进去是Window#superDispatchTouchEvent()。这是一个抽象方法。不过相信看过我前面文章的小伙伴一定知道这个方法的实现实在PhoneWindow中。那么找到这个方法如下:

640?wx_fmt=png

可以看到最后Activity的分发过程最后就是将事件交给顶级DecorView去进行事件分发。然后它又会调用ViewGroup#dispatchTouchEvent()。OK!到这里我们就将我们的事件由Activity->ViewGroup的传递。并将返回值设置成true。表示这个事件已经被我们消耗掉了。

这里还有一点需要注意。从源码中我们可以看到,假设Activity下的所有View或者我们点击了Window边界以外,那么就会调用Activity#onTouchEvent(ev);这个方法:

640?wx_fmt=png

这部分了解即可,并不是我们的重点。

2. ViewGroup事件分发

通过上面的分析,现在事件已经从Activity->ViewGroup。那么我们就分析ViewGroup#dispatchTouchEvent()方法:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {

   ......

   boolean handled = false;
   if (onFilterTouchEventForSecurity(ev)) {
       final int action = ev.getAction();
       final int actionMasked = action & MotionEvent.ACTION_MASK;

        /**
        * 讲解二
        */

       // Handle an initial down.
       if (actionMasked == MotionEvent.ACTION_DOWN) {
           // Throw away all previous state when starting a new touch gesture.
           // The framework may have dropped the up or cancel event for the previous gesture
           // due to an app switch, ANR, or some other state change.
           cancelAndClearTouchTargets(ev);
           //清空mFirstTouchTarget
           resetTouchState();
       }

       // Check for interception.
       /**
       *讲解一
       */

       //检查是否拦截事件  
       //1.当事件为ACTION_DOWN时
       //2. 当ViewGroup不拦截事件交给子元素处理 条件成立 即mFirstTouchTarget != null
       final boolean intercepted;
       if (actionMasked == MotionEvent.ACTION_DOWN
               || mFirstTouchTarget != null) {
           final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
           if (!disallowIntercept) {
               //调用事件拦截方法
               intercepted = onInterceptTouchEvent(ev);
               ev.setAction(action); // restore action in case it was changed
           } else {
               intercepted = false;
           }
       } else {
           // There are no touch targets and this action is not an initial down
           // so this view group continues to intercept touches.
           intercepted = true;
       }

      ......

       // Check for cancelation.
       final boolean canceled = resetCancelNextUpFlag(this)
               || actionMasked == MotionEvent.ACTION_CANCEL;

       // Update list of touch targets for pointer down, if needed.
       final boolean split = (mGroupFlags & FLAG_SPLIT_MOTION_EVENTS) != 0;
       TouchTarget newTouchTarget = null;
       boolean alreadyDispatchedToNewTouchTarget = false;
       //非MotionEvent.ACTION_CANCEL并且没有拦截事件
       //进入if语句,对ViewGroup的子元素进行遍历
       /**
       *讲解三
       */

       if (!canceled && !intercepted) {

           // If the event is targeting accessiiblity focus we give it to the
           // view that has accessibility focus and if it does not handle it
           // we clear the flag and dispatch the event to all children as usual.
           // We are looking up the accessibility focused host to avoid keeping
           // state since these events are very rare.
           View childWithAccessibilityFocus = ev.isTargetAccessibilityFocus()
                   ? findChildWithAccessibilityFocus() : null;

           if (actionMasked == MotionEvent.ACTION_DOWN
                   || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
                   || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
               final int actionIndex = ev.getActionIndex(); // always 0 for down
               final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
                       : TouchTarget.ALL_POINTER_IDS;

               // Clean up earlier touch targets for this pointer id in case they
               // have become out of sync.
               removePointersFromTouchTargets(idBitsToAssign);

               final int childrenCount = mChildrenCount;
               if (newTouchTarget == null && childrenCount != 0) {
                   final float x = ev.getX(actionIndex);
                   final float y = ev.getY(actionIndex);
                   // Find a child that can receive the event.
                   // Scan children from front to back.
                   final ArrayList<View> preorderedList = buildTouchDispatchChildList();
                   final boolean customOrder = preorderedList == null
                           && isChildrenDrawingOrderEnabled();
                   final View[] children = mChildren;
                   //对子元素进行遍历
                   for (int i = childrenCount - 1; i >= 0; i--) {
                       //1.子元素是否在做动画
                       final int childIndex = getAndVerifyPreorderedIndex(
                               childrenCount, i, customOrder);
                       //2.事件左边是否落在子元素区域内
                       final View child = getAndVerifyPreorderedView(
                               preorderedList, children, childIndex);
                       //接受点击事件的View根据1.2条件判断  
                       //是否能够接受点击事件
                       if (!canViewReceivePointerEvents(child)
                               || !isTransformedTouchPointInView(x, y, child, null)) {
                           ev.setTargetAccessibilityFocus(false);
                           //不符合要求 执行下一个循环
                           continue;
                       }
                       ......
                       //调用此方法进行事件分发处理 如果有子元素
                       //那么参数中的child!=null
                        /**
                         *讲解四
                         */

                       if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                           // Child wants to receive touch within its bounds.
                           mLastTouchDownTime = ev.getDownTime();
                           if (preorderedList != null) {
                               // childIndex points into presorted list, find original index
                               for (int j = 0; j < childrenCount; j++) {
                                   if (children[childIndex] == mChildren[j]) {
                                       mLastTouchDownIndex = j;
                                       //条件满足跳出循环
                                       //这里是跳出内部for循环不是外部的
                                       //其实就是break的用法
                                       break;
                                   }
                               }
                           } else {
                               mLastTouchDownIndex = childIndex;
                           }
                           mLastTouchDownX = ev.getX();
                           mLastTouchDownY = ev.getY();
                       /**
                         *讲解五
                         */

                           //对mFirstTouchTarget 进行赋值
                           newTouchTarget = addTouchTarget(child, idBitsToAssign);
                           alreadyDispatchedToNewTouchTarget = true;
                           break;
                       }

                       // The accessibility focus didn't handle the event, so clear
                       // the flag and do a normal dispatch to all children.
                       ev.setTargetAccessibilityFocus(false);
                   }

                   ......
               }
           }
       }

       /**
       *讲解六
       */

       // Dispatch to touch targets.
       if (mFirstTouchTarget == null) {
           // No touch targets so treat this as an ordinary view.
           handled = dispatchTransformedTouchEvent(ev, canceled, null,
                   TouchTarget.ALL_POINTER_IDS);
       } else {
           ......
       }

       // Update list of touch targets for pointer up or cancel, if needed.
       if (canceled
               || actionMasked == MotionEvent.ACTION_UP
               || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
           //MotionEvent.ACTION_UP 后 清空mFirstTouchTarget
           /**
           *讲解七
           */

           resetTouchState();
       } else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
           final int actionIndex = ev.getActionIndex();
           final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
           removePointersFromTouchTargets(idBitsToRemove);
       }
   }

......
}

关于ViewGroup的事件分发源码(即dispatchTouchEvent()方法),还是比较长的,同时也是难点,下面我们将上面的代码拆分来看,来具体分析。

  • part1

640?wx_fmt=png

讲解一 

这个方法首先会判断,是否拦截当前事件。这个条件第一个好理解。一个事件的系列一定是由DOWN开始的,那么就会进入条件语句。调用onInterceptTouchEvent(ev)开始进行事件拦截。关于第二个代码中有解释。这个方法是在那里还原初始值与赋值请看下面。

讲解二 

事件开始时会调用 resetTouchState();清空mFirstTouchTarget

  • part2
//非MotionEvent.ACTION_CANCEL并且没有拦截事件
//进入if语句,对ViewGroup的子元素进行遍历
/**
*讲解三
*/

if (!canceled && !intercepted) {

           ......省略

           //对子元素进行遍历
           for (int i = childrenCount - 1; i >= 0; i--) {
               //1.子元素是否在做动画
               final int childIndex = getAndVerifyPreorderedIndex(
                       childrenCount, i, customOrder);
               //2.事件左边是否落在子元素区域内
               final View child = getAndVerifyPreorderedView(
                       preorderedList, children, childIndex);
               //接受点击事件的View根据1.2条件判断  
               //是否能够接受点击事件
               if (!canViewReceivePointerEvents(child)
                       || !isTransformedTouchPointInView(x, y, child, null)) {
                   ev.setTargetAccessibilityFocus(false);
                   //不符合要求 执行下一个循环
                   continue;
               }
               ......
               //调用此方法进行事件分发处理 如果有子元素
               //那么参数中的child!=null
                /**
                 *讲解四
                 */

               if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                   // Child wants to receive touch within its bounds.
                   mLastTouchDownTime = ev.getDownTime();
                   if (preorderedList != null) {
                       // childIndex points into presorted list, find original index
                       for (int j = 0; j < childrenCount; j++) {
                           if (children[childIndex] == mChildren[j]) {
                               mLastTouchDownIndex = j;
                               //条件满足跳出循环
                               //这里是跳出内部for循环不是外部的
                               //其实就是break的用法
                               break;
                           }
                       }
                   } else {
                       mLastTouchDownIndex = childIndex;
                   }
                   mLastTouchDownX = ev.getX();
                   mLastTouchDownY = ev.getY();
               /**
                 *讲解五
                 */

                   //对mFirstTouchTarget 进行赋值
                   newTouchTarget = addTouchTarget(child, idBitsToAssign);
                   alreadyDispatchedToNewTouchTarget = true;
                   break;
               }

               // The accessibility focus didn't handle the event, so clear
               // the flag and do a normal dispatch to all children.
               ev.setTargetAccessibilityFocus(false);
           }

           ......
       }
   }
}

讲解三

进入if语句,判断条件为没有对事件进行拦截,同时事件没有结束。对ViewGroup的子元素进行遍历

讲解️四

通过判断,将ViewGroup的子元素进行遍历,找到能够处理点击事件的子元素并调用dispatchTransformedTouchEvent()方法,进行事件的分发。

讲解五

当子元素能够处理点击事件,就调用addTouchTarget()方法,对mFirstTouchTarget()方法进行赋值。那么下次再进入讲解一方法。

  • part3

640?wx_fmt=png

讲解六

讲解三中的if语句不成立表示对事件进行拦截。那么直接走到了讲解六,并且mFirstTouchTarget == null没有在子元素的遍历中赋值,即条件成立。执行dispatchTransformedTouchEvent()方法。

讲解七

在同一事件系列结束后调用resetTouchState();对mFirstTouchTarget清空还原。

这样我们就将ViewGroup#dispatchTouchEvent()方法分析完成了。

在上面的讲解中我们多此提到mFirstTouchTarget与dispatchTransformedTouchEvent()方法。前者已经说明了他的作用与赋值及清空还原的位置。对于后者,这个方法其实就是ViewGroup对事件分发。看下他的源码:

640?wx_fmt=png

是ViewGroup#dispatchTransformedTouchEvent()方法,其他方法省略。可以可看到,如果ViewGroup有子元素同时子元素可以处理点击事件。那么就会调用子元素的child.dispatchTouchEvent(event);方法。如果是child是ViewGroup继续上面的循环,如果子元素是View,那么就或调用View.dispatchTouchEvent(event)方法。关于这个方法我们后面分析。如果child为空(即讲解六),那么就会调用super.dispatchTouchEvent(event)方法,那么就会调用ViewGroup父类的,即View.dispatchTouchEvent(event)方法,ViewGroup自己处理点击事件。最后都会默认(是默认不是一定) 调用onTounchEvent()方法。

通关对源码与这七个重要部分的讲解。我们可以总结如下几点:

  1. 一个事件序列只能一个View进行拦截且消耗。由讲解三我们知道。如果拦截事件,就不会进入if语句对子元素进行遍历与事件分发。同时又讲解六我们知道,如果拦截了某一事件(如MOVE)那么统一事件序列内的所有事件都交给它处理。

  2. 某个View一旦拦截事件,那么这一事件序列只能有它来处理(由1可知)。同时我们知道既然拦截就无法进入讲解三中,那么mFirstTouchTarget就无法被赋值,那么讲解一中的条件就不成立。所以调用onInterceptTouchEvent()不会再被调用。其实通过1.我们也都理解,如果拦截那么同一事件序列的所有事件都间给当前View处理。你拦截就说明你必须全都处理。那我还问你干啥。

  3. dispatchTransformedTouchEvent()方法中,当dispatchTouchEvent()的返回值与dispatchTransformedTouchEvent()返回值相同,由讲解六得知。这样会直接影响ViewGroup#dispatchTouchEvent()返回值(两者相同)。也就是说:View一旦开始处理事件,如果它不消耗ACTION_DOWN事件(onTounchEvent()返回false)。那么统一事件序列的其他事件都不会交给他处理。并会重新交给父元素(注意是父元素,不是父类)去处理,即父元素onTounchEvent()会被调用。

  4. onInterceptTouchEvent()默认返回false,即默认不拦截任何事件。

  5. View没有onInterceptTouchEvent()。一旦事件传递给他,那么他的onTouchEvent就会调用。

关于ViewGroup的源码分析我们也就到这里了。有的啰嗦。不过详细才能跟好的理解与全面

3. View事件分发

由上面dispatchTransformedTouchEvent()方法可知,最后方法无论是ViewGroup消耗还是View消耗都会调用View#dispatchTouchEvent()方法。那么我们就来看这个方法:

640?wx_fmt=png

View的dispatchTouchEvent比较简单。首先判断mOnTouchListener!=null同时li.mOnTouchListener.onTouch(this, event)返回为true那么result = true;。那么下面的 if (!result && onTouchEvent(event)) 中的第一个条件就不会成立所以onTouchEvent(event)永远不会得到执行。有此可见onTouch()优先级要高于onTouchEvent(event)。

下面我们看下默认情况进入onTouchEvent(event)方法中:

public boolean onTouchEvent(MotionEvent event) {
   final float x = event.getX();
   final float y = event.getY();
   final int viewFlags = mViewFlags;
   final int action = event.getAction();

   /**
   *讲解一
   */

   //首先判断当前View是不是DISABLED不可用状态
   if ((viewFlags & ENABLED_MASK) == DISABLED) {
       if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
           setPressed(false);
       }
       // A disabled view that is clickable still consumes the touch
       // events, it just doesn't respond to them.
       //如果不可用 同时当前控件的clickable与long_clickable
       //与CONTEXT_CLICKABLE全是false
       //那么才返回false
       return (((viewFlags & CLICKABLE) == CLICKABLE
               || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
               || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE);
   }
   //如果View有代理会执行这个方法
   if (mTouchDelegate != null) {
       if (mTouchDelegate.onTouchEvent(event)) {
           return true;
       }
   }
   /**
   *讲解二
   */

   //只要控件的clickable与long_clickable
   //与CONTEXT_CLICKABLE 有一个为true 就进入次循环
   if (((viewFlags & CLICKABLE) == CLICKABLE ||
           (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
           (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
       switch (action) {
           case MotionEvent.ACTION_UP:
                   ......

                   if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                       // This is a tap, so remove the longpress check
                       removeLongPressCallback();

                       // Only perform take click actions if we were in the pressed state
                       if (!focusTaken) {
                           // Use a Runnable and post this rather than calling
                           // performClick directly. This lets other visual state
                           // of the view update before click actions start.
                           if (mPerformClick == null) {
                               mPerformClick = new PerformClick();
                           }
                           if (!post(mPerformClick)) {
                            /**
                             *讲解三
                             */

                           //onClickListener监听在此方法中
                               performClick();
                           }
                       }
                   }

                  ......
               }
               break;

           ......
       }
       //默认返回 true
       /**
       *讲解四
       */

       return true;
   }

   return false;
}

这部分代码比较简单。主要的都有注释。如果控件!=DISABLED,那么就会进入同时讲解二判断有一个成立。就会进入switch语句。当接收到MotionEvent.ACTION_UP是。最后执行performClick()方法.这个方法代码如下:

640?wx_fmt=png

可以看到我们的设置setOnclickListener就是在这个赋值,li.mOnClickListener.onClick(this);就会调用我们的onClik方法。

那么有的同学会问View的longClickable默认是false,同时TextView的clickable也为false,那么为何我们给TextView设置setOnclickListener也能生效。我们下来看下TextView源码其他默认clickable=false的控件是一样的

640?wx_fmt=png

可以看到在设置监听时,方法内部已经帮我们设置了。

下面我们在针对onTouchEvent(MotionEvent event)方法来拆分分析下:

  • part1

640?wx_fmt=png

讲解一

由代码可知即使控件是DISABLED状态,只要clickable与longclickable有一个返回true那么此方法就返回true,即事件被消费。但是不会执行onClick()方法。这点通过代码很容易理解。

  • part2
/**
*讲解二
*/

//只要控件的clickable与long_clickable
//与CONTEXT_CLICKABLE 有一个为true 就进入次循环
if (((viewFlags & CLICKABLE) == CLICKABLE ||
       (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
       (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
   switch (action) {
       case MotionEvent.ACTION_UP:
               ......

               if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                   // This is a tap, so remove the longpress check
                   removeLongPressCallback();

                   // Only perform take click actions if we were in the pressed state
                   if (!focusTaken) {
                       // Use a Runnable and post this rather than calling
                       // performClick directly. This lets other visual state
                       // of the view update before click actions start.
                       if (mPerformClick == null) {
                           mPerformClick = new PerformClick();
                       }
                       if (!post(mPerformClick)) {
                        /**
                         *讲解三
                         */

                       //onClickListener监听在此方法中
                           performClick();
                       }
                   }
               }

              ......
           }
           break;

       ......
   }
   //默认返回 true
   /**
   *讲解四
   */

   return true;
}

讲解二 

讲解二中只要有一个条件满足。就会进入switch语句。当接收到MotionEvent.ACTION_UP时(前提MotionEvent.ACTION_DOWN也接收到了)会经过判断最后执行 performClick();方法。

讲解三 

performClick()方法内部会执行我们设置的监听,即onClick()方法。

讲解四

由代码可知只要讲解二中的if语句成立,不管进入switch中的任何ACTION或是都不进入,返回值都是true,即事件消费了。同时讲解四也证明默认情况下是返回true


总结


下面我们用流程图在来总结下:

640?wx_fmt=png

这张图结合文章理解起来简直是so easy。

其实关于Android事件分发机制优秀的文章由很多。如果观看一篇文章无法完全掌握,就多看几篇文章。然后自己总结,结合。反正最后能理解成自己的就算成功了。


欢迎长按下图 -> 识别图中二维码

或者 扫一扫 关注我的公众号

640.png?

640?wx_fmt=jpeg

猜你喜欢

转载自blog.csdn.net/c10WTiybQ1Ye3/article/details/79924579