• Android View 的事件分发原理解析


    作为一名 Android 开发者,每天接触最多的就是 View 了。Android View 虽然不是四大组件,但其并不比四大组件的地位低。而 View 的核心知识点事件分发机制则是不少刚入门同学的拦路虎,也是面试过程中基本上都会问的。理解 View 的事件能够让你写出更好自定义 View 以及解决滑动冲突。

    1、 View 事件认识

    1.1 MotionEvent 事件

    当你用手指轻触屏幕,这个过程在 Android 中主要可以分为以下三个过程:

    • ACTION_DOWN:手指刚接触屏幕,按下去的那一瞬间产生该事件

    • ACTION_MOVE:手指在屏幕上移动时候产生该事件

    • ACTION_UP:手指从屏幕上松开的瞬间产生该事件

    从 ACTION_DOWN 开始到 ACTION_UP 结束我们称为一个事件序列

    正常情况下,无论你手指在屏幕上有多么骚的操作,最终呈现在 MotionEvent 上来讲无外乎下面两种动作。

    • 点击(点击后抬起,也就是单击操作):ACTION_DOWN -> ACTION_UP

    • 滑动(点击后再滑动一段距离,再抬起):ACTION_DOWN -> ACTION_MOVE -> ... -> ACTION_MOVE -> ACTION_UP

    1.2  理论知识

    • public boolean dispatchTouchEvent(MotionEvent ev)

        return true: 表示消耗了当前事件,有可能是当前 View 的 onTouchEvent 或者是子 View 的 dispatchTouchEvent 消费了,事件终止,不再传递。 

        return false: 调用父 ViewGroup 或 Activity 的 onTouchEvent。 (不再往下传)。

        return super.dispatherTouchEvent: 则继续往下(子 View )传递,或者是调用当前 View 的 onTouchEvent 方法;

    总结:用来分发事件,即事件序列的大门,如果事件传递到当前 View 的 onTouchEvent 或者是子 View 的 dispatchTouchEvent,即该方法被调用了。 另外如果不消耗 ACTION_DOWN 事件,那么 down, move, up 事件都与该 View 无关,交由父类处理(父类的 onTouchEvent 方法)

    • public boolean onInterceptTouchEvent(MotionEvent ev) 

        return true: ViewGroup 将该事件拦截,交给自己的 onTouchEvent 处理。

        return false: 继续传递给子元素的 dispatchTouchEvent 处理。 

        return super.dispatherTouchEvent: 事件默认不会被拦截。

    总结:在 dispatchTouchEvent 内部调用,顾名思义就是判断是否拦截某个事件。(注:ViewGroup 才有的方法,View 因为没有子View了,所以不需要也没有该方法) 。而且这一个事件序列(当前和其它事件)都只能由该 ViewGroup 处理,并且不会再调用该 onInterceptTouchEvent 方法去询问是否拦截。

    • public boolean onTouchEvent(MotionEvent ev) 

        return true: 事件消费,当前事件终止。 

        return false: 交给父 View 的 onTouchEvent。 

        return super.dispatherTouchEvent: 默认处理事件的逻辑和返回 false 时相同。

    总结:dispatchTouchEvent内部调用 

    上面三个方法之间的调用关系可以用下面的代码表示:

    public boolean dispatchTouchEvent(MotionEvent ev) {
            boolean consume = false;//事件是否被消费
            if (onInterceptTouchEvent(ev)){//调用 onInterceptTouchEvent 判断是否拦截事件
                consume = onTouchEvent(ev);//如果拦截则调用自身的onTouchEvent方法
            }else{
                consume = child.dispatchTouchEvent(ev);//不拦截调用子View的dispatchTouchEvent方法
            }
            return consume;//返回值表示事件是否被消费,true事件终止,false调用父View的onTouchEvent方法

    1.3 事件传递顺序

    对于一个点击事件,Activity 会先收到事件的通知,接着再将其传给 DecorView(根 view),通过 DecorView 在将事件逐级进行传递。具体传递逻辑见下图:

    可以看出事件的传递过程都是从父 View 到子 View,如果都没处理,最终还是会交由 activity 处理。但是这里有三点需要特别强调一下

    • 子 View 可以通过 requestDisallowInterceptTouchEvent 方法干预父 View 的事件分发过程( ACTION_DOWN 事件除外),而这就是我们处理滑动冲突常用的关键方法。

    • 对于 View(注意!ViewGroup 也是 View)而言,如果设置了onTouchListener,那么 OnTouchListener 方法中的 onTouch 方法会被回调。onTouch 方法返回 true,则 onTouchEvent 方法不会被调用(onClick 事件是在 onTouchEvent 中调用)所以三者优先级是 onTouch->onTouchEvent->onClick

    • View 的 onTouchEvent 方法默认都会消费掉事件(返回 true),除非它是不可点击的(clickable 和 longClickable 同时为 false),View 的longClickable 默认为 false,clickable 需要区分情况,如 Button 的 clickable 默认为 true,而TextView的 clickable 默认为 false。

    2、View 事件分发源码

    先从 Activity 中的 dispatchTouchEvent 方法出发:

        @Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
            return super.dispatchTouchEvent(ev);
        }

    Activity 将事件传给父 Activity 来处理,下面看父 Activity 是怎么处理的。

     /**
         * Called to process touch screen events.  You can override this to
         * intercept all touch screen events before they are dispatched to the
         * window.  Be sure to call this implementation for touch screen events
         * that should be handled normally.
         *
         * @param ev The touch screen event.
         *
         * @return boolean Return true if this event was consumed.
         */
        public boolean dispatchTouchEvent(MotionEvent ev) {
            if (ev.getAction() == MotionEvent.ACTION_DOWN) {
                onUserInteraction();
            }
            if (getWindow().superDispatchTouchEvent(ev)) {
                return true;
            }
            return onTouchEvent(ev);
        }

    其中有个 onUserInteraction 方法,该方法是只要用户在 Activity 的任何一处点击或者滑动都会响应,一般不使用。接下去看getWindow().superDispatchTouchEvent(ev) 所代表的具体含义。getWindow() 返回对应的 Activity 的 window。一个Activity 对应一个 Window 也就是 PhoneWindow, 一个 PhoneWindow 持有一个 DecorView 的实例, DecorView 本身是一个 FrameLayout。这句话一定要牢记。

    /**
         * Retrieve the current {@link android.view.Window} for the activity.
         * This can be used to directly access parts of the Window API that
         * are not available through Activity/Screen.
         *
         * @return Window The current window, or null if the activity is not
         *         visual.
         */
        public Window getWindow() {
            return mWindow;
        }

    Window 的源码有说明 The only existing implementation of this abstract class is
    android.view.PhoneWindow,Window 的唯一实现类是 PhoneWindow。那么去看 PhoneWindow 对应的代码。

    public boolean superDispatchTouchEvent(MotionEvent event) {
        return mDecor.superDispatchTouchEvent(event);
    }

    PhoneWindow 又调用了 DecorView 的 superDispatchTouchEvent 方法。而这个 DecorView 就是 Window 的根 View,我们通过 setContentView 设置的 View 是它的子 View(Activity 的 setContentView,最终是调用 PhoneWindow 的 setContentView )

    到这里事件已经被传递到根 View 中,而根 View 其实也是 ViewGroup。那么事件在 ViewGroup 中又是如何传递的呢? 

    2.1 ViewGroup 事件分发

    public boolean dispatchTouchEvent(MotionEvent ev) {
                ......
    
                final int action = ev.getAction();
                final int actionMasked = action & MotionEvent.ACTION_MASK;
           // 当有 down 操作,会把之前的target 以及标志位都复位
                if (actionMasked == MotionEvent.ACTION_DOWN) {
                    cancelAndClearTouchTargets(ev);
    
                    //清除 FLAG_DISALLOW_INTERCEPT,并且设置 mFirstTouchTarget 为 null
                    resetTouchState(){
                        if(mFirstTouchTarget!=null){mFirstTouchTarget==null;}
                        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
                        ......
                    };
                }
                final boolean intercepted;//ViewGroup是否拦截事件
    
                // mFirstTouchTarget是ViewGroup中处理事件(return true)的子View
                //如果没有子View处理则mFirstTouchTarget=null,ViewGroup自己处理
                if (actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null) {
                    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
                    if (!disallowIntercept) {
                        intercepted = onInterceptTouchEvent(ev);//onInterceptTouchEvent
                        ev.setAction(action);
                    } else {
                        intercepted = false;
    
                        //如果子类设置requestDisallowInterceptTouchEvent(true)
                        //ViewGroup将无法拦截MotionEvent.ACTION_DOWN以外的事件
                    }
                } else {
                    intercepted = true;
    
                    //actionMasked != MotionEvent.ACTION_DOWN并且没有子View处理事件,则将事件拦截
                    //并且不会再调用onInterceptTouchEvent询问是否拦截
                }
    
                ......
                ......
    }

    先看标红的代码,这句话的意思是:当 ACTION_DOWN 事件到来时,或者有子元素处理事件( mFirstTouchTarget != null ),如果子 view 没有调用 requestDisallowInterceptTouchEvent 来阻止 ViewGroup 的拦截,那么 ViewGroup 的 onInterceptTouchEvent 就会被调用,来判断是否是要拦截。所以,当子 View 不让父 View 拦截事件的时候,即使父 View onInterceptTouchEvent 中返回true 也没用了。

    这里需要注意的就是:onInterceptTouchEvent 默认返回 false。 当 ACTION_DOWN 事件到来时,此时 mFirstTouchTarget 为 null,此时其实也还未收到子 view requestDisallowInterceptTouchEvent。所以这时候,只要父 view 把 ACTION_DOWN 事件给拦截了,那么子 view 就收不到任何事件消息了。所以,一般在 ACTION_DOWN 的时候,父 view 不作拦截。

    当 ACTION_MOVE 事件来临时,满足某些条件,父 view 想拦截的时候,这时候子 view 可以在 dispatchTouchEvent 中 ACTION_DOWN 事件来临的时候,调用 requestDisallowInterceptTouchEvent 就可以避免被父 view 拦截。

    FLAG_DISALLOW_INTERCEPT 这个标记位就是通过子 View requestDisallowInterceptTouchEvent 方法设置的。 具体可参看如下代码。

        @Override
        public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
    
            if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
                // We're already in this state, assume our ancestors are too
                return;
            }
    
            if (disallowIntercept) {
                mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
            } else {
                mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
            }
    
            // Pass it up to our parent
            if (mParent != null) {
                mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
            }
        }

     同时,如果这个 ViewGroup 有父 View 的时候,还得让父父 View 不能拦截。继续看 ViewGroup 的 dispatchTouchEvent 方法。

     public boolean dispatchTouchEvent(MotionEvent ev) {
            final View[] children = mChildren;
    
            for (int i = childrenCount - 1; i >= 0; i--) {
                final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
                final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);
    
                ......
    
                if (!canViewReceivePointerEvents(child) || !isTransformedTouchPointInView(x, y, child, null)) 
                {
                    ev.setTargetAccessibilityFocus(false);
                    //如果子View没有播放动画,而且点击事件的坐标在子View的区域内,继续下面的判断
                    continue;
                }
                //判断是否有子View处理了事件
                newTouchTarget = getTouchTarget(child);
    
                if (newTouchTarget != null) {
                    //如果已经有子View处理了事件,即mFirstTouchTarget!=null,终止循环。
                    newTouchTarget.pointerIdBits |= idBitsToAssign;
                    break;
                }
    
                if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                    //点击dispatchTransformedTouchEvent代码发现其执行方法实际为
                    //return child.dispatchTouchEvent(event); (因为child!=null)
                    //所以如果有子View处理了事件,我们就进行下一步:赋值
    
                    ......
    
                    newTouchTarget = addTouchTarget(child, idBitsToAssign);
                    //addTouchTarget方法里完成了对mFirstTouchTarget的赋值
                    alreadyDispatchedToNewTouchTarget = true;
    
                    break;
                }
            }
        }
    
        private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
            final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
            target.next = mFirstTouchTarget;
            mFirstTouchTarget = target;
            return target;
        }
    
        private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
                View child, int desiredPointerIdBits) {
                ......
    
                if (child == null) {
                //如果没有子View处理事件,就自己处理
                    handled = super.dispatchTouchEvent(event);
                } else {
               //有子View,调用子View的dispatchTouchEvent方法
                    handled = child.dispatchTouchEvent(event);
    
                ......
    
                return handled;
        }

    上面为 ViewGroup 对事件的分发,主要有 2 点

    • 如果有子 View,则调用子 View 的 dispatchTouchEvent 方法判断是否处理了事件,如果处理了便赋值 mFirstTouchTarget,赋值成功则跳出循环。

    • ViewGroup 的事件分发最终还是调用 View 的 dispatchTouchEvent 方法,具体如上代码所述。

    onTouchEvent

    该方法在 View 中实现,当然你也可以在子类中重写该方法。为了更好的理解 onTouchEvent 中对手势的处理规则。我们需要借助如下图:

     

    图中 A 为根布局容器,B 是依赖于 A 的子布局容器,C 是 B 中的子 View,三者的位置关系是 A->B->C。
      关于处理规则需要注意两点:

    • 多个 View 间的 onTouchEvent 执行顺序
        当在 ViewGroup 中存在多个 View 或 ViewGroup 叠加显示时,gesture 事件将会以自上而下的顺序来执行,即最靠近屏幕的 C 先执行 onTouchEvent 方法,然后依次向下传递。即 C->B->A

    • 返回 Ture 还是 False:
        onTouchEvent 是带 boolean 型的返回参数,其返回值的意义在于是否消耗并处理该事件。在一个完整的 gesture 事件序列中,手势会从 DOWN 事件开始,如果在onTouchEvent 中返回的 true,那么该 gesture 的余下手势事件都会被该 View 处理消耗,不再向下传递相关事件。而当 onTouchEvent 在 DOWN 事件中返回了 false,即表示不关心该 gesture 事件序列,那么该 View 将不会接收到剩余的其它手势事件,并将其传递给下一个 View 进行处理。官方文档中给出的说明如下:

    True if the event was handled, false otherwise.

    onInterceptTouchEvent

    上面说到 onTouchEvent 是至上而下传递的,靠近屏幕的 View 拥有处理手势事件的高优先级,那么有没有方法改变这种优先级顺序呢?比如图上 B 和 C 同时在 DOWN 事件中返回了ture,如何将事件分配给 B 而不是在最顶层的 C。ViewGroup 中提供了 onInterceptTouchEvent 方法,通过该方法,我们可以在 onTouchEvent 方法被调用之前去拦截该手势事件,拦截后的事件会被分配到该 View 下的 onTouchEvent 方法中去处理。与 onTouchEvent 相反,该方法的手势事件传递过程是自下而上,也就是从根 View 开始传递到上层的子 View。同时在 gesture 事件序列中,所有的手势事件都可以通过 onInterceptTouchEvent 方法被拦截,这样对于手势的处理有更大的自由度,而不是像 onTouchEvent 一样局限在 DOWN 事件下。
      通过 onTouchEvent 和 onInterceptTouchEvent 结合使用,手势事件实现了先上后下的一个过程,我们可以通过特定的方法在某个点进行事件的处理。下面总结了一张 Down 事件在上图中的传递过程的图,该过程中事件不被消耗,完整的被执行。

    2.2 View 的事件分发

    public boolean dispatchTouchEvent(MotionEvent event) {  
    
            if (mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED &&  
                    mOnTouchListener.onTouch(this, event)) {  
                return true;  
            } 
            return onTouchEvent(event);  
      }

    上述方法只有以下3个条件都为真,dispatchTouchEvent() 才返回 true;否则执行 onTouchEvent()。

    •  mOnTouchListener != null

    •  (mViewFlags & ENABLED_MASK) == ENABLED

    •  mOnTouchListener.onTouch(this, event)

    这也就说明如果调用了 setOnTouchListener 设置了 listener, 就会先调用 onTouch 方法。没有的话才会去调用 onTouchEvent 方法。接下去,我们看 onTouchEvent 源码。

    public boolean onTouchEvent(MotionEvent event) {  
        final int viewFlags = mViewFlags;  
    
        if ((viewFlags & ENABLED_MASK) == DISABLED) {  
             
            return (((viewFlags & CLICKABLE) == CLICKABLE ||  
                    (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE));  
        }  
      // 如果进行了事件代理,就会被拦截,不会在往下面走了
    if (mTouchDelegate != null) { if (mTouchDelegate.onTouchEvent(event)) { return true; } } // 若该控件可点击,则进入switch判断中 if (((viewFlags & CLICKABLE) == CLICKABLE || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)) { switch (event.getAction()) { // a. 若当前的事件 = 抬起View(主要分析) case MotionEvent.ACTION_UP: boolean prepressed = (mPrivateFlags & PREPRESSED) != 0; ...// 经过种种判断,此处省略 // 执行performClick() ->>分析1 performClick(); break; // b. 若当前的事件 = 按下View case MotionEvent.ACTION_DOWN: if (mPendingCheckForTap == null) { mPendingCheckForTap = new CheckForTap(); } mPrivateFlags |= PREPRESSED; mHasPerformedLongPress = false; postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout()); break; // c. 若当前的事件 = 结束事件(非人为原因) case MotionEvent.ACTION_CANCEL: mPrivateFlags &= ~PRESSED; refreshDrawableState(); removeTapCallback(); break; // d. 若当前的事件 = 滑动View case MotionEvent.ACTION_MOVE: final int x = (int) event.getX(); final int y = (int) event.getY(); int slop = mTouchSlop; if ((x < 0 - slop) || (x >= getWidth() + slop) || (y < 0 - slop) || (y >= getHeight() + slop)) { // Outside button removeTapCallback(); if ((mPrivateFlags & PRESSED) != 0) { // Remove any future long press/tap checks removeLongPressCallback(); // Need to switch from pressed to not pressed mPrivateFlags &= ~PRESSED; refreshDrawableState(); } } break; } // 若该控件可点击,就一定返回true return true; } // 若该控件不可点击,就一定返回false return false; } /** * 分析1:performClick() */ public boolean performClick() { if (mOnClickListener != null) { playSoundEffect(SoundEffectConstants.CLICK); mOnClickListener.onClick(this); return true; // 只要我们通过setOnClickListener()为控件View注册1个点击事件 // 那么就会给mOnClickListener变量赋值(即不为空) // 则会往下回调onClick() & performClick()返回true } return false; }

    从上面的代码我们可以知道,当手指抬起的时候,也就是处于 MotionEvent.ACTION_UP 时,才会去调用 performClick()。而 performClick 中会调用 onClick  方法。

    也就说明了:三者优先级是 onTouch->onTouchEvent->onClick

    至此 View 的事件分发机制讲解完毕。

    总结:

    1. 不要把拦截要做的事情放在 dispatchTouchEvent 中。如果 viewGroup 需要拦截事件,在 onInterceptTouchEvent 中在相应的事件返回 true 即可。拦截的操作写在 onTouchEvent 中。

    2. 如果 viewGroup 在 dispatchTouchEvent 返回 true 。这会导致 viewGroup 中的 dispatchTouchEvent 不执行。引起事件混乱。

    3. 只要 viewGroup 拦截 ACTION_DOWN,就所有的事件都会被拦截,从而响应子 view 的点击事件。

    4. 子 view 在 dispatchTouchEvent 中的 ACTION_DOWN 中调用 getParent().requestDisallowInterceptTouchEvent(true)  可避免被拦截。前提是父 view 不拦截 ACTION_DOWN 事件。

    5. 如果父 view 不拦截 ACTION_DOWN 事件,但拦截其他几个事件,这时候可以在 onTouchEvent 中写拦截后需要的操作。

    6. 父 view 开始不拦截 ACTION_DOWN 事件,然后在 ACTION_MOVE 的时候,进行拦截,这时候,子 view 的 dispatchTouchEvent 会收到 ACTION_CANCEL 事件,表示事件被终止了。

    7. 当父 view 拦截事件以后,其事件流程就跟普通的 view 一致了,不要在将其看做 ViewGroup。如果设置了 OnTouchListener 还会响应 onTouch 事件, 注意返回 false, 返回 true 将不会执行 onTouchEvent 。

    滑动冲突解决方法

    滑动冲突产生的原因:只要在界面中存在内外两层可以同时滑动,就会产生滑动冲突。如下所示:图1是左右滑动和上下滑动冲突,图二是两个view之间的上下滑动冲突;

                        

    解决方案:根据实际情况,判断到底需要谁去响应滑动事件。

    主要解决方式有两种,一种是外部拦截法,一种是内部拦截法。

    外部拦截法:

    看标题就应该可以知道,外部拦截法,就是通过父 view 来解决滑动冲突。 因为父 view 肯定属于 ViewGroup,所以父 view 根据自己需要来判断是否需要拦截事件。对于 ViewGroup,有个 onInterceptTouchEvent 方法,再需要拦截的时候,返回 true 即可。

        public boolean onInterceptTouchEvent(MotionEvent event) {
            boolean intercepted=false;
            int x= (int) event.getX();
            int y= (int) event.getY();
            switch (event.getAction()){
                case MotionEvent.ACTION_DOWN:
                    intercepted=false;//必须不能拦截,否则后续的ACTION_MOME和ACTION_UP事件都会拦截。
                    break;
                case MotionEvent.ACTION_MOVE:
                    if (父容器需要当前点击事件){
                        intercepted=true;
                    }else {
                        intercepted=false;
                    }
                    break;
                case MotionEvent.ACTION_UP:
                    intercepted=false;
                    break;
                default:
                    break;
            }
            mLastXIntercept=x;
            mLastXIntercept=y;
            return intercepted;
        }

    内部拦截法

    既然外部拦截法是子 view 主动处理拦截,那么内部拦截法就是需要子 view 来处理滑动冲突的情况。那么子view应该如何处理呢?首先子 view 在 dispatchTouchEvent 方法内部调用 requestDisallowInterceptTouchEvent 不让父 view 拦截事件,然后再 onTouchEvent 方法中处理需要拦截的情况。不拦截的时候返回 false,将事件交还给父 view 处理。

    // 子 view   
    public boolean dispatchTouchEvent(MotionEvent ev) { int x = (int) ev.getX(); int y = (int) ev.getY(); switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: { getParent().requestDisallowInterceptTouchEvent(true); break; } case MotionEvent.ACTION_MOVE: { int deltaX = x - mLastXIntercept; int deltaY = y - mLastYIntercept; //如果是左右滑动 if (Math.abs(deltaX) > Math.abs(deltaY)) { getParent().requestDisallowInterceptTouchEvent(false); } break; } case MotionEvent.ACTION_UP: { getParent().requestDisallowInterceptTouchEvent(false); break; } } mLastXIntercept = x; mLastYIntercept = y; return super.dispatchTouchEvent(ev); }

    同时为了避免父 view 消费事件,还需要在 DOWN 事件来临的时候,父 view 不会拦截,否则事件就不会传到子 view 了。

    public boolean onInterceptTouchEvent(MotionEvent ev) {
            int action = ev.getAction();
            if (action == MotionEvent.ACTION_DOWN) {
                return false;
            } else {
                return true;
            }
        }

    如果子 view 不处理,  父 view 会再次获得事件的处理权限。

    参考文献:

    1、Android View的事件分发机制和滑动冲突解决

    2、一文读懂Android View事件分发机制

    3、Android事件分发机制详解:史上最全面、最易懂

  • 相关阅读:
    坐标
    firewallcmd常用命令
    sublime text 配置Latex
    winformDataGridView常用设置
    OutLook配置腾讯企业邮箱
    C#4种定时器Timer的用法
    C#监控Enter和Esc事件
    c#使用SqlSugar动态切换数据库
    WinformDataGridViewDataGridViewComboBoxColumn无法获取值问题
    C#无法将“******.dll”复制到“..*****.dll”。超出了重试计数 10。失败。文件被Mirosoft vs2017(10932)锁定
  • 原文地址:https://www.cnblogs.com/huansky/p/9656394.html
Copyright © 2020-2023  润新知