Android--焦点问题以及讨论事件传递机制问题(结合部分相关源码)

还是之前的项目中的一些东西,继续抽出来给大家。

文章结构:(1)展现焦点问题(以及一些体验交互的状态);(2)分析焦点问题,详解两个属性;(3)结合部分相关源码讨论事件传递机制;


一、展现焦点问题:

(1)如果对我下面给的demo不加一些属性处理,效果如下:

这里写图片描述

也就是看不到上面的轮播图,这样的话,就是recyclerview抢占了activity的焦点咯。

那么demo中,我们怎么解决的呢???

<!-- 我就加了一句属性android:focusableInTouchMode="true"  ,就把焦点交给轮播图-->
             <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:orientation="vertical"
                app:layout_collapseMode="pin"
                android:focusableInTouchMode="true">

                <com.bigkoo.convenientbanner.ConvenientBanner
                    android:id="@+id/banner"
                    android:layout_width="match_parent"
                    android:layout_height="200dp"
                    app:canLoop="true" />
            </LinearLayout>

为什么这样做呢???

我何用做是为了给轮播图控件施加焦点嘛,我就在轮播图的父控件设定了android:focusableInTouchMode=true,也就拦截了轮播图默认行为让父控件得到高亮得到焦点,当然也抢夺了recyclerview想要的焦点。


(2)另外,大家在编写自己的登录页面时也经常遇到焦点问题吧??是什么导致的呢?是EditText!!它自动抢夺焦点的!!

EditText这种即使在TouchMode下,依然需要获取焦点的控件
怎么解决呢?
解决:在EditText的父级控件中找一个,设置成
   android:focusable="true"  
   android:focusableInTouchMode="true"
这样,就把EditText默认的行为截断了!!不让它自动夺取焦点。

(3)讲述另外一些体验交互状态

Select

这里写图片描述

Focusable in Touch Mode
也就是我们上面的edittext,点击去获取焦点。
Focus
就是焦点模式咯。

二、分析焦点问题,详解两个属性:

focusableInTouchMode跟focusable有什么区别?

1.要理解这个属性,首先你得知道,Android不是只面向手机的,它还有可能被安装在电视等非触摸输入设备上.即使是在手机上,目前很多手机也都支持键盘输入了。

2.focusable这种属性,更多的是为了解决非触摸输入的,因为你用遥控器或键盘点击控件,就必然要涉及到焦点的问题,只有可以获得焦点的控件才能响应键盘或者遥控器或者轨迹球的确定事件.

3.focusableInTouchMode.这个属性的意思一如字面所述,就是在进入触摸输入模式后,该控件是否还有获得焦点的能力.

什么意思呢??再通俗点,
对于一个拥有触摸屏功能的设备而言, 一旦用户用手点击屏幕, 设备会立刻进入touch mode。这时候被点击的控件只有设置android:focusableInTouchMode为true的时候才会获得focus,比如EditText控件。其他可以触摸的控件比如Button。
然后其android:focusableInTouchMode默认为false, 当被点击的时候不会获取焦点,它们只是简单地执行onClick事件而已。

所以以上两个属性就针对这几种的touch情况啦。交互体验是十分地不同的!!!

所以我们大致看下这份源码的接口声明,我们就可以清晰认知focus跟touch是极大的不同。

public static class ListenerInfo {  
        /** 
         * Listener used to dispatch focus change events. 
         * This field should be made private, so it is hidden from the SDK. 
         * {@hide} 
         */  
        protected OnFocusChangeListener mOnFocusChangeListener;  

        /** 
         * Listeners for layout change events. 
         */  
        private ArrayList<OnLayoutChangeListener> mOnLayoutChangeListeners;  

        /** 
         * Listeners for attach events. 
         */  
        private CopyOnWriteArrayList<OnAttachStateChangeListener> mOnAttachStateChangeListeners;  

        /** 
         * Listener used to dispatch click events. 
         * This field should be made private, so it is hidden from the SDK. 
         * {@hide} 
         */  
        public OnClickListener mOnClickListener;  

        /** 
         * Listener used to dispatch long click events. 
         * This field should be made private, so it is hidden from the SDK. 
         * {@hide} 
         */  
        protected OnLongClickListener mOnLongClickListener;  

        /** 
         * Listener used to build the context menu. 
         * This field should be made private, so it is hidden from the SDK. 
         * {@hide} 
         */  
        protected OnCreateContextMenuListener mOnCreateContextMenuListener;  

        private OnKeyListener mOnKeyListener;  

        private OnTouchListener mOnTouchListener;  

        private OnHoverListener mOnHoverListener;  

        private OnGenericMotionListener mOnGenericMotionListener;  

        private OnDragListener mOnDragListener;  

        private OnSystemUiVisibilityChangeListener mOnSystemUiVisibilityChangeListener;  

        OnApplyWindowInsetsListener mOnApplyWindowInsetsListener;  
    }  

三、讨论事件传递机制

焦点问题也是涉及到事件机制的,所以我们就顺便进一步地去讨论这个android事件机制咯。

关于事件的传递,我们主要是关注几个问题:(1)事件怎么传递?它的传递流程是怎样?(2)事件是怎么消费的??(3)自定义view也事件冲突时,我们怎么处理?(4)源码是怎么定义这个事件机制的??

(1)事件怎么传递?它的传递流程是怎样?

首先由Activity分发,分发给根View,也就是DecorView(DecorView为整个Window界面的最顶层View)。

然后由根View分发到子的ViewGroup,再由各个ViewGroup分发给子View

这里写图片描述

好了看下源码:
//拦截事件  
@Override  
public boolean onInterceptTouchEvent(MotionEvent ev) {  
    // TODO Auto-generated method stub  
    return super.onInterceptTouchEvent(ev);  
}  

//处理事件  
@Override  
public boolean onTouchEvent(MotionEvent ev) {  
    // TODO Auto-generated method stub  
    return super.onTouchEvent(ev);  
}  

//分发事件  
@Override  
public boolean dispatchTouchEvent(MotionEvent ev) {  
    // TODO Auto-generated method stub  
    return super.dispatchTouchEvent(ev);  
} 

我们来仔细讨论ViewGroup事件的传递机制:

这里写图片描述

虽然那个图真的很棒,但是讲得不够清晰呢,下面我将它具体讲述。

(1)当我们点击viewC时,就会触发事件,然后事件传递给viewgroupA,viewgroupA它首先会执行dispatchTouchEvent来调用onInterceptTouchEvent判断本group是否可以处理,return ture则交由onTouchEvent处理事件,return false则使用dispatchTouchEvent往下传递事件。

(2)往下传递过来的事件由viewgroupB的onInterceptTouchEvent拦截,问自己能否处理该事件,能则处理,不能则往下继续传递。同理viewC的这一步流程。

(3)当传递到最终的viewC的时候,如果不能够处理该触发事件,是会重新回传给父控件的!!!!


(2)事件是怎么消费的??

就是dispatchTouchEvent判断自己能处理后就调用自己的onTouchEvent进行处理。

(3)自定义view时,我们怎么处理?

点这里看例子。感谢那位博主的精妙例子。

(4)源码是怎么定义这个事件机制的??

事件即MotionEvent:

(1)MotionEvent.ACTION_DOWN 按下View,是所有事件的开始
(2)MotionEvent.ACTION_MOVE 滑动事件
(3)MotionEvent.ACTION_UP 与down对应,表示抬起
//负责分发事件的这位,很重要的代码
//看之前先记住这句话:mFirstTouchTarget为null就是说Touch事件未被消费.  至于是什么内容请仔细阅读下文
 @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onTouchEvent(ev, 1);
        }

        // If the event targets the accessibility focused view and this is it, start
        // normal event dispatch. Maybe a descendant is what will handle the click.
        if (ev.isTargetAccessibilityFocus() && isAccessibilityFocusedViewOrHost()) {
            ev.setTargetAccessibilityFocus(false);
        }

        boolean handled = false;
        if (onFilterTouchEventForSecurity(ev)) {
            final int action = ev.getAction();
            final int actionMasked = action & MotionEvent.ACTION_MASK;//标记这个为事件暂存值

        /*
            一、因为ACTION_DOWN是一系列事件的开端,当是ACTION_DOWN时进行一些初始化操作. 
        */
            // 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.
                //下面的方法中有一个非常重要的操作:clearTouchTargets();将mFirstTouchTarget设置为null!!!!随后在resetTouchState()中重置Touch状态标识
                cancelAndClearTouchTargets(ev);//清除以往的Touch状态(state)开始新的手势(gesture)  
                resetTouchState();
            }
        /*
            二、检查是否要拦截
        */
            // Check for interception.
            final boolean intercepted;//使用变量intercepted来标记ViewGroup是否拦截Touch事件的传递. 
            // 事件为ACTION_DOWN或者mFirstTouchTarget不为null(即已经找到能够接收touch事件的目标组件)时if成立  
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                //判断disallowIntercept(禁止拦截)标志位    
                //因为在其他地方可能调用了requestDisallowInterceptTouchEvent(boolean disallowIntercept)    
                //从而禁止执行是否需要拦截的判断(有点拗口~其实看requestDisallowInterceptTouchEvent()方法名就可明白) 
                final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
                 //当没有禁止拦截判断时(即disallowIntercept为false)调用onInterceptTouchEvent(ev)方法    
                if (!disallowIntercept) {
                //既然disallowIntercept为false那么就调用onInterceptTouchEvent()方法将结果赋值给intercepted    
                    //常说事件传递中的流程是:dispatchTouchEvent->onInterceptTouchEvent->onTouchEvent    
                    //其实在这就是一个体现,在dispatchTouchEvent()中调用了onInterceptTouchEvent()    
                    intercepted = onInterceptTouchEvent(ev);
                    ev.setAction(action); // restore action in case it was changed
                } else {
                /当禁止拦截判断时(即disallowIntercept为true)设置intercepted = false  
                    intercepted = false;
                }
            } else {
                  //当事件不是ACTION_DOWN并且mFirstTouchTarget为null(即没有Touch的目标组件)时    
                //设置 intercepted = true表示ViewGroup执行Touch事件拦截的操作。 
                // There are no touch targets and this action is not an initial down
                // so this view group continues to intercept touches.
                intercepted = true;
            }

            // If intercepted, start normal event dispatch. Also if there is already
            // a view that is handling the gesture, do normal event dispatch.
            if (intercepted || mFirstTouchTarget != null) {
                ev.setTargetAccessibilityFocus(false);
            }
            /*
                三、检查cancel
            */
            // 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;//获取新的touch事件
            boolean alreadyDispatchedToNewTouchTarget = false;//判别是否分发给新的事件目标
            //不是ACTION_CANCEL并且ViewGroup的拦截标志位intercepted为false(不拦截)  
            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;
                 //处理ACTION_DOWN事件
                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;
                    // 依据Touch坐标寻找子View来接收Touch事件
                    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 = buildOrderedChildList();
                        final boolean customOrder = preorderedList == null
                                && isChildrenDrawingOrderEnabled();
                        final View[] children = mChildren;
                        // 遍历子View判断哪个子View接受Touch事件 
                        for (int i = childrenCount - 1; i >= 0; i--) {
                            final int childIndex = customOrder
                                    ? getChildDrawingOrder(childrenCount, i) : i;
                            final View child = (preorderedList == null)
                                    ? children[childIndex] : preorderedList.get(childIndex);

                            // If there is a view that has accessibility focus we want it
                            // to get the event first and if not handled we will perform a
                            // normal dispatch. We may do a double iteration but this is
                            // safer given the timeframe.
                            if (childWithAccessibilityFocus != null) {
                                if (childWithAccessibilityFocus != child) {
                                    continue;
                                }
                                childWithAccessibilityFocus = null;
                                i = childrenCount - 1;
                            }

                            if (!canViewReceivePointerEvents(child)
                                    || !isTransformedTouchPointInView(x, y, child, null)) {
                                ev.setTargetAccessibilityFocus(false);
                                continue;
                            }

                            newTouchTarget = getTouchTarget(child);
                            if (newTouchTarget != null) {
                            // 找到接收Touch事件的子View!!!!!!!即为newTouchTarget.    
                                // 既然已经找到了,所以执行break跳出for循环   
                                // Child is already receiving touch within its bounds.
                                // Give it the new pointer in addition to the ones it is handling.
                                newTouchTarget.pointerIdBits |= idBitsToAssign;
                                break;
                            }

                            resetCancelNextUpFlag(child);
                            //调用方法dispatchTransformedTouchEvent()将Touch事件传递给子View做,递归处理(也就是遍历该子View的View树) ,将Touch事件传递给特定的子View的onTouchEvent返回true(即Touch事件被消费)那么就满足该if条件.
                            //如果dispatchTransformedTouchEvent()返回true即子View  
                            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;
                                            break;
                                        }
                                    }
                                } else {
                                    mLastTouchDownIndex = childIndex;
                                }
                                mLastTouchDownX = ev.getX();
                                mLastTouchDownY = ev.getY();
                                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 (preorderedList != null) preorderedList.clear();
                    }
                    /**  
                     * 该if条件表示:  
                     * 经过前面的for循环没有找到子View接收Touch事件并且之前的mFirstTouchTarget不为空的时候 ,就返回最初的事件
                     */ 
                    if (newTouchTarget == null && mFirstTouchTarget != null) {
                        // Did not find a child to receive the event.
                        // Assign the pointer to the least recently added target.
                        newTouchTarget = mFirstTouchTarget;
                        while (newTouchTarget.next != null) {
                            newTouchTarget = newTouchTarget.next;
                        }
                        //newTouchTarget指向了最初的TouchTarget
                        newTouchTarget.pointerIdBits |= idBitsToAssign;
                    }
                }
            }
            /**  
             * 分发Touch事件至target(Dispatch to touch targets)  
             *   
             * 经过上面对于ACTION_DOWN的处理后mFirstTouchTarget有两种情况:  
             * 1 mFirstTouchTarget为null  
             * 2 mFirstTouchTarget不为null  
             *   
             * 当然如果不是ACTION_DOWN就不会经过上面较繁琐的流程  
             * 而是从此处开始执行,比如ACTION_MOVE和ACTION_UP  
             */   
            // Dispatch to touch targets.分发事件
            if (mFirstTouchTarget == null) {
                // No touch targets so treat this as an ordinary view.
                /*
                mFirstTouchTarget为null就是说Touch事件未被消费.  
                即没有找到能够消费touch事件的子组件或Touch事件被拦截了。
                则调用ViewGroup的dispatchTransformedTouchEvent()方法处理Touch事件则和普通View一样.
                即子View没有消费Touch事件,那么子View的上层ViewGroup才会调用其onTouchEvent()处理Touch事件.
                也就是说此时ViewGroup像一个普通的View那样调用dispatchTouchEvent(),
                且在dispatchTouchEvent() 中会去调用onTouchEvent()方法
                具体的说就是在调用dispatchTransformedTouchEvent()时第三个参数为null. 
                第三个参数View child为null会做什么样的处理呢?  
                请参见下面dispatchTransformedTouchEvent()的源码分析

                这就是为什么子view对于Touch事件处理返回true那么其上层的ViewGroup就无法处理Touch事件
                这就是为什么子view对于Touch事件处理返回false那么其上层的ViewGroup才可以处理Touch事件
                */
                handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
            } else {
            //mFirstTouchTarget不为null即找到了可以消费Touch事件的子View且后续Touch事件可以传递到该子View  
                // Dispatch to touch targets, excluding the new touch target if we already
                // dispatched to it.  Cancel touch targets if necessary.
                TouchTarget predecessor = null;
                TouchTarget target = mFirstTouchTarget;
                while (target != null) {
                    final TouchTarget next = target.next;
                    if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
                        handled = true;
                    } else {
                        final boolean cancelChild = resetCancelNextUpFlag(target.child)
                                || intercepted;
                         //对于非ACTION_DOWN事件继续传递给目标子组件进行处理,依然是递归调用dispatchTransformedTouchEvent()
                        if (dispatchTransformedTouchEvent(ev, cancelChild,
                                target.child, target.pointerIdBits)) {
                            handled = true;
                        }
                        if (cancelChild) {
                            if (predecessor == null) {
                                mFirstTouchTarget = next;
                            } else {
                                predecessor.next = next;
                            }
                            target.recycle();
                            target = next;
                            continue;
                        }
                    }
                    predecessor = target;
                    target = next;
                }
            }

        /**  
             * 处理ACTION_UP和ACTION_CANCEL  
             * Update list of touch targets for pointer up or cancel, if needed.  
             * 在此主要的操作是还原状态  
             */  
            // Update list of touch targets for pointer up or cancel, if needed.
            if (canceled
                    || actionMasked == MotionEvent.ACTION_UP
                    || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
                resetTouchState();
            } else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
                final int actionIndex = ev.getActionIndex();
                final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
                removePointersFromTouchTargets(idBitsToRemove);
            }
        }

        if (!handled && mInputEventConsistencyVerifier != null) {
            mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
        }
        return handled;
    }

dispatchTransformedTouchEvent源码:这个是把事件交给子view去处理的方法

/*
    第一个参数:传递事件咯
    第二个参数:是否还原状态  
    第三个参数:View child
    在dispatchTouchEvent()中多次调用了dispatchTransformedTouchEvent(),但是有时候第三个参数为null,有时又不是。
    那么这个参数是否为null有什么区别呢?
    在如下dispatchTransformedTouchEvent()源码中可见多次对于child是否为null的判断,如下:
    if (child == null) {
           handled = super.dispatchTouchEvent(event); 
     } else { 
           handled = child.dispatchTouchEvent(event);  
     }

*/
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
            View child, int desiredPointerIdBits) {
        final boolean handled;//是否处理了标记

        // Canceling motions is a special case.  We don't need to perform any transformations
        // or filtering.  The important part is the action, not the contents.
        final int oldAction = event.getAction();
        if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
            event.setAction(MotionEvent.ACTION_CANCEL);
            /*
            当child == null时会将Touch事件传递给该ViewGroup自身的dispatchTouchEvent()处理.  
            当child != null时会调用该子view(当然该view可能是一个View也可能是一个ViewGroup)的dispatchTouchEvent(event)处理.即child.dispatchTouchEvent(event); 
            */
            if (child == null) {
                handled = super.dispatchTouchEvent(event);
            } else {
                handled = child.dispatchTouchEvent(event);
            }
            event.setAction(oldAction);
            return handled;
        }
        //计算传递的指针数
        // Calculate the number of pointers to deliver.
        final int oldPointerIdBits = event.getPointerIdBits();
        final int newPointerIdBits = oldPointerIdBits & desiredPointerIdBits;

        // If for some reason we ended up in an inconsistent state where it looks like we
        // might produce a motion event with no pointers in it, then drop the event.
        //判断事件一致性,传递的数量不一致就返回false
        if (newPointerIdBits == 0) {
            return false;
        }

        // If the number of pointers is the same and we don't need to perform any fancy
        // irreversible transformations, then we can reuse the motion event for this
        // dispatch as long as we are careful to revert any changes we make.
        // Otherwise we need to make a copy.
         //判断事件一致性,一致的话,我们就不用执行些不可逆的转换,只要小心回复我们所做的修改,我们就可以重用该事件。否则就要copy该事件
        final MotionEvent transformedEvent;
        if (newPointerIdBits == oldPointerIdBits) {
            if (child == null || child.hasIdentityMatrix()) {
                if (child == null) {
                 //当child == null时会将Touch事件传递给该ViewGroup自身的dispatchTouchEvent()处理. 
                    handled = super.dispatchTouchEvent(event);
                } else {
                    final float offsetX = mScrollX - child.mLeft;
                    final float offsetY = mScrollY - child.mTop;
                    event.offsetLocation(offsetX, offsetY);

                    handled = child.dispatchTouchEvent(event);

                    event.offsetLocation(-offsetX, -offsetY);
                }
                return handled;
            }
            transformedEvent = MotionEvent.obtain(event);
        } else {
            transformedEvent = event.split(newPointerIdBits);
        }
        //执行一些必要的转换和调度
        // Perform any necessary transformations and dispatch.
        if (child == null) {
            handled = super.dispatchTouchEvent(transformedEvent);
        } else {
            final float offsetX = mScrollX - child.mLeft;
            final float offsetY = mScrollY - child.mTop;
            transformedEvent.offsetLocation(offsetX, offsetY);
            if (! child.hasIdentityMatrix()) {
                transformedEvent.transform(child.getInverseMatrix());
            }

            handled = child.dispatchTouchEvent(transformedEvent);
        }
        //回收事件
        // Done.
        transformedEvent.recycle();
        return handled;
    }

参考博客:

生命壹号
郭朝

源码下载:Android-多列表的项目Rxjava+Rtrofit+Recyclerview+Glide+Adapter封装

好了,Android–焦点问题以及讨论事件传递机制问题讲完了。本博客是这个系列的第四篇,讨论的是我在项目中遇到的一些细节坑,以及它们的相关机制。另外,这个系列还有一些我在外包项目过程中做的优化,以及一些发布签名等等技巧,我会尽快出完给大家,分享经验给大家。欢迎在下面指出错误,共同学习!!你的点赞是对我最好的支持!!

更多内容,可以访问JackFrost的博客

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值