Android FrameWork——Touch事件派发过程详解

对于Android的窗口window管理,一直感觉很混乱,总想找个时间好好研究,却不知如何入手,现在写的Touch事件派发过程详解,其实跟android的窗口window管理服务WindowManagerService存在紧密联系,所以从这里入手切入到WindowManagerService的研究,本blog主要讲述一个touch事件如何从用户消息的采集,到WindowManagerService对Touch事件的派发,再到一个Activity窗口touch事件的派发,并着重讲了Activity窗口touch事件的派发,因为这个的理解对我们写应用很好地处理touch事件很重要

一.用户事件采集到WindowManagerService和派发

--1.WindowManagerService,顾名思义,它是是一个窗口管理系统服务,它的主要功能包含如下:
        --窗口管理,绘制
        --转场动画--Activity切换动画
        --Z-ordered的维护,Activity窗口显示前后顺序
        --输入法管理
        --Token管理
        --系统消息收集线程
        --系统消息分发线程
这里,我关注的是系统消息的收集和系统消息的分发,其他功能,当我对WindowManagerService有一个完整的研究后在发blog

--2.系统消息收集和分发线程的创建
这个的从WindowManagerService服务的创建说起,与其他系统服务一样,WindowManagerService在systemServer中创建的:
ServerThread.run
-->WindowManagerService.main
   -->WindowManagerService.WMThread.run(构建一个专门线程负责WindowManagerService)
      -->WindowManagerService s = new WindowManagerService(mContext, mPM,mHaveInputMethods);
         --mQueue = new KeyQ();//消息队列,在构造KeyQ中会创建一个InputDeviceReader线程去读取用户输入消息
         --mInputThread = new InputDispatcherThread();//创建一个消息分发线程,读取并处理mQueue中消息

整个过程处理原理很简单,典型的生产者消费者模型,我先画个图,后面针对代码进一步说明

--3.InputDeviceReader线程,KeyQ构建时,会启动一个线程去读取用户消息,具体代码在KeyInputQueue.mThread,在构造函数中,mThread会start,接下来,接研究一下mThread.run:
    //用户输入事件消息读取线程
    Thread mThread = new Thread("InputDeviceReader") {
        public void run() {
            RawInputEvent ev = new RawInputEvent();
            while (true) {//开始消息读取循环
                try {
                    InputDevice di;
                    //本地方法实现,读取用户输入事件
                    readEvent(ev);
                    //根据ev事件进行相关处理
                    ...
                    synchronized (mFirst) {//mFirst是keyQ队列头指针
                    ...
                    addLocked(di, curTimeNano, ev.flags,RawInputEvent.CLASS_TOUCHSCREEN, me);
                    ...
                    }
                }
        }
       }
函数我也没有看大明白:首先调用本地方法readEvent(ev);去读取用户消息,这个消息包括按键,触摸,滚轮等所有用户输入事件,后面不同的事件类型会有不同的处理,不过最后事件都要添加到keyQ的队列中,通过addLocked函数

--4队列添加和读取函数addLocked,getEvent
addLocked函数比较简单,就分析一下,有助于对消息队列KeyQ的数据结构进行理解:
    //event加入inputQueue队列
    private void addLocked(InputDevice device, long whenNano, int flags,
            int classType, Object event) {
        boolean poke = mFirst.next == mLast;//poke为true表示消息队列为空
        //从QueuedEvent缓存QueuedEvent获取一个QueuedEvent对象,并填入用户事件数据,包装成一个QueuedEvent
        QueuedEvent ev = obtainLocked(device, whenNano, flags, classType, event);
        QueuedEvent p = mLast.prev;//队列尾节点为mLast,把ev添加到mlast前
        while (p != mFirst && ev.whenNano < p.whenNano) {
            p = p.prev;
        }
        ev.next = p.next;
        ev.prev = p;
        p.next = ev;
        ev.next.prev = ev;
        ev.inQueue = true;

        if (poke) {//poke为true,意味着在空队列中添加了一个QueuedEvent,这时系统消息分发线程可能在wait,需要notify一下
            long time;
            if (MEASURE_LATENCY) {
                time = System.nanoTime();
            }
            mFirst.notify();//唤醒在 mFirst上等待的线程
            mWakeLock.acquire();
            if (MEASURE_LATENCY) {
                lt.sample("1 addLocked-queued event ", System.nanoTime() - time);
            }
        }
    }
很简单,使用mFirst,mLast实现的指针队列,addLocked是QueuedEvent对象添加函数,对应在系统消息分发线程中会有一个getEvent函数来读取inputQueue队列的消息,我在这里也先讲一下:
    QueuedEvent getEvent(long timeoutMS) {
        long begin = SystemClock.uptimeMillis();
        final long end = begin+timeoutMS;
        long now = begin;
        synchronized (mFirst) {//获取mFirst上同步锁
            while (mFirst.next == mLast && end > now) {
                try {//mFirst.next == mLast意味队列为空,同步等待mFirst锁对象
                    mWakeLock.release();
                    mFirst.wait(end-now);
                }
                catch (InterruptedException e) {
                }
                now = SystemClock.uptimeMillis();
                if (begin > now) {
                    begin = now;
                }
            }
            if (mFirst.next == mLast) {
                return null;
            }
            QueuedEvent p = mFirst.next;//返回mFirst的下一个节点为处理的QueuedEvent
            mFirst.next = p.next;
            mFirst.next.prev = mFirst;
            p.inQueue = false;
            return p;
        }
    }

通过上面两个函数得知,消息队列是通过mFirst,mLast实现的生产者消费模型的同步链表队列

--5.InputDispatcherThread线程
InputDispatcherThread处理InputDeviceReader线程存放在KeyInputQueue队列中的消息,分发到具体的一个客户端的IWindow
InputDispatcherThread.run
-->windowManagerService.process{               
            ...
            while (true) {               
                // 从mQueue(KeyQ)获取一个用户输入事件,正上调用我上面提到的getEvent方法,若队列为空,线程阻塞挂起
                QueuedEvent ev = mQueue.getEvent(
                    (int)((!configChanged && curTime < nextKeyTime)
                            ? (nextKeyTime-curTime) : 0));
                ...
                try {
                    if (ev != null) {
                        ...
                        if (ev.classType == RawInputEvent.CLASS_TOUCHSCREEN) {//touch事件
                            eventType = eventType((MotionEvent)ev.event);
                        } else if (ev.classType == RawInputEvent.CLASS_KEYBOARD ||
                                    ev.classType == RawInputEvent.CLASS_TRACKBALL) {//键盘输入事件
                            eventType = LocalPowerManager.BUTTON_EVENT;
                        } else {
                            eventType = LocalPowerManager.OTHER_EVENT;//其他事件
                        }
                        ...
                        switch (ev.classType) {
                            case RawInputEvent.CLASS_KEYBOARD:
                                ...
                                dispatchKey((KeyEvent)ev.event, 0, 0);//键盘输入,派发key事件
                                mQueue.recycleEvent(ev);
                                break;
                            case RawInputEvent.CLASS_TOUCHSCREEN:
                                dispatchPointer(ev, (MotionEvent)ev.event, 0, 0);//touch事件,派发touch事件
                                break;
                            case RawInputEvent.CLASS_TRACKBALL:
                                dispatchTrackball(ev, (MotionEvent)ev.event, 0, 0);//滚轮事件,派发Trackball事件
                                break;
                            case RawInputEvent.CLASS_CONFIGURATION_CHANGED:
                                configChanged = true;
                                break;
                            default:
                                mQueue.recycleEvent(ev);//销毁事件
                            break;
                        }

                    }
                } catch (Exception e) {
                    Slog.e(TAG,
                        "Input thread received uncaught exception: " + e, e);
                }
            }       
   }

WindowManagerService.dispatchPointer,一旦判断QueuedEvent为屏幕点击事件,就调用函数WindowManagerService.dispatchPointer进行处理:
WindowManagerService.dispatchPointer
-->WindowManagerService.KeyWaiter.waitForNextEventTarget(获取touch事件要派发的目标windowSate)
   -->WindowManagerService.KeyWaiter.findTargetWindow(从一个一个WindowSate的z-order顺序列表mWindow中获取一个能够接收当前touch事件的WindowSate)
-->WindowSate target = waitForNextEventTarget返回的WindowSate对象
-->target.mClient.dispatchPointer(ev, eventTime, true);(往目标window派发touch消息
target.mClient是一个IWindow代理对象IWindow.Proxy,它对应的代理类是ViewRoot.W,通过远程代理调用,WindowManagerService把touch消息派发到了对应的Activity的PhoneWindow
之后进一步WindowManagerService到Activity消息的派发在下文中说明

二WindowManagerService派发Touch事件到当前top Activity

--1.先我们看一个system_process的touch事件消息调用堆栈,在WindowManagerService中的函数dispatchPointer,通过一个IWindow的客户端代理对象把消息发送到相应的IWindow服务端,也就是一个IWindow.Stub子类。
Thread [<21> InputDispatcher] (Suspended (breakpoint at line 321 in IWindow$Stub$Proxy))       
        IWindow$Stub$Proxy.dispatchPointer(MotionEvent, long, boolean) line: 321       
        WindowManagerService.dispatchPointer(KeyInputQueue$QueuedEvent, MotionEvent, int, int) line: 5270              
        WindowManagerService$InputDispatcherThread.process() line: 6602       
        WindowManagerService$InputDispatcherThread.run() line: 6482  

--2.通过IWindow.Stub.Proxy代理对象把消息传递给IWindow.Stub对象。code=TRANSACTION_dispatchPointer,IWindow.Stub对象被ViewRoot拥有(成员mWindow,它是一个ViewRoot.W类对象)

--3.在case TRANSACTION_dispatchPointer会调用IWindow.Stub子类的实现方法dispatchPointer

--4.IWindow.Stub.dispatchPointer
        -->ViewRoot.W.dispatchPointer
                -->ViewRoot.dispatchPointer
    public void dispatchPointer(MotionEvent event, long eventTime,
            boolean callWhenDone) {
        Message msg = obtainMessage(DISPATCH_POINTER);
        msg.obj = event;
        msg.arg1 = callWhenDone ? 1 : 0;
        sendMessageAtTime(msg, eventTime);
    }

--5.ViewRoot继承自handle,在handleMessage函数的case-DISPATCH_POINTER会调用mView.dispatchTouchEvent(event),
mView是一个PhoneWindow.DecorView对象,在PhoneWindow.openPanel方法会创建一个ViewRoot对象,并设置ViewRoot对象的mView为一个PhoneWindow.decorView成员,PhoneWindow.DecorView是真正的root view,它继承自FrameLayout,这样调用mView.dispatchTouchEvent(event)
其实就是调用PhoneWindow.decorView的dispatchTouchEvent方法:
        @Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
            final Callback cb = getCallback();
            return cb != null && mFeatureId < 0 ? cb.dispatchTouchEvent(ev) : super
                    .dispatchTouchEvent(ev);
        } 

--6.分析上面一段红色代码,可以写成return (cb != null) && (mFeatureId < 0 ? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev)).当cb不为null执行后面,如果mFeatureId<0,执行cb.dispatchTouchEvent(ev),否则执行super.dispatchTouchEvent(ev),也就是FrameLayout.dispatchTouchEvent(ev),那么callback cb是什么呢?是Window类的一个成员mCallback,我下面给一个图你可以看到何时被赋值的:
setCallback(Callback) : void - android.view.Window
        -->attach(Context, ActivityThread, Instrumentation, IBinder, int, Application, Intent, ActivityInfo, CharSequence, Activity, String, Object, HashMap<String, Object>, Configuration) : void - android.app.Activity
               --> performLaunchActivity(ActivityRecord, Intent) : Activity - android.app.ActivityThread
performLaunchActivity我们很熟识,因为我前面在讲Activity启动过程详解时候讲过,在启动一个新的Activity会执行该方法,在该方法里面会执行attach方法,找到attach方法对应代码可以看到:
        mWindow = PolicyManager.makeNewWindow(this);
        mWindow.setCallback(this);
mWindow就是一个PhoneWindow,它是Activity的一个内部成员,通过调用mWindow的setCallback(this),把新建立的Activity设置为PhoneWindow一个mCallback成员,这样我们就清楚了,前面的cb就是拥有这个PhoneWindow的Activity,cb.dispatchTouchEvent(ev)也就是执行:Activity.dispatchTouchEvent
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
            onUserInteraction();
        }
        //getWindow()返回的就是PhoneWindow对象,执行superDispatchTouchEvent,就是执行PhoneWindow.superDispatchTouchEvent
        if (getWindow().superDispatchTouchEvent(ev)) {
            return true;
        }
        //执行Activity.onTouchEvent方法
        return onTouchEvent(ev);
    }

--7.再看PhoneWindow.superDispatchTouchEvent:
    @Override
    public boolean superDispatchTouchEvent(MotionEvent event) {
        return mDecor.superDispatchTouchEvent(event);
                -->        public boolean superDispatchTouchEvent(MotionEvent event) {
                                    return super.dispatchTouchEvent(event);//FrameLayout.dispatchTouchEvent
        }
    }
superDispatchTouchEvent调用super.dispatchTouchEvent,我前面讲过mDector是一个PhoneWindow.DecorView,它是一个真正Activity的root view,它继承了FrameLayout,通过super.dispatchTouchEvent他会把touchevent派发给各个activity的子view,也就是我们再Activity.onCreat方法中setContentView时设置的view,touch event时间如何在Activity各个view中进行派发的我后面再作详细说明,但是从上面我们可以看出一点若Activity下面的子view拦截了touchevent事件(返回true),Activity.onTouchEvent就不会执行。

--8.这部分,我再画一个静态类结构图把前面讲到的一些类串起来看一下:

我用红色箭头线把整个消息派发过程过程给串起来,然后system_process进程和ap进程分别用虚线椭圆圈起,这样以后相信你更理解各个类之间关系。

对应的对象空间图如下,与上面图是对应的,只是从不同角度去看:

--9.其实上面所讲的大部分已经是在客户端ap中执行了,也就是在ap进程中,只是执行逻辑基本是框架代码中,还没有到达我们使用layout.xml布局的view中来,这里我先在我们的一个view中onTouchEvent插入一个断点看一看消息从WindowManagerService到达Activity.PhoneWindow后执行堆栈情况(我插入的断点在Launcher2的HandleView中),后面继续讲解:
Thread [<1> main] (Suspended (breakpoint at line 4280 in View))       
        HandleView(View).onTouchEvent(MotionEvent) line: 4280       
        HandleView.onTouchEvent(MotionEvent) line: 71       
        HandleView(View).dispatchTouchEvent(MotionEvent) line: 3766       
        RelativeLayout(ViewGroup).dispatchTouchEvent(MotionEvent) line: 863       
        DragLayer(ViewGroup).dispatchTouchEvent(MotionEvent) line: 863       
        FrameLayout(ViewGroup).dispatchTouchEvent(MotionEvent) line: 863       
        PhoneWindow$DecorView(ViewGroup).dispatchTouchEvent(MotionEvent) line: 863       
        PhoneWindow$DecorView.superDispatchTouchEvent(MotionEvent) line: 1671       
        PhoneWindow.superDispatchTouchEvent(MotionEvent) line: 1107       
        ForyouLauncher(Activity).dispatchTouchEvent(MotionEvent) line: 2086       
        PhoneWindow$DecorView.dispatchTouchEvent(MotionEvent) line: 1655       
        ViewRoot.handleMessage(Message) line: 1785       
        ViewRoot(Handler).dispatchMessage(Message) line: 99       
        Looper.loop() line: 123       
        ActivityThread.main(String[]) line: 4634

三.Activity中View中的Touch事件派发

--1.首先我画一个Activity中的view层次结构图:

前面我讲过,来自windowManagerService的touch消息最终会派发到到Decorview,Decorview继承子FrameLayout,它只有一个子view就是mContentParent,我们写ap的view全部添加到到mContentParent。

--2.了解了Activity中的view的层次结构,那先从DecorView开始看touch事件是如何被派发的,前面讲过最终消息会派发到FrameLayout.dispatchTouchEvent也就是ViewGroup.dispatchTouchEvent(FrameLayout也没有覆盖该方法),
同样mContentParent也是执行ViewGroup.dispatchTouchEvent来派发touch消息,那我们就详细看一下ViewGroup.dispatchTouchEvent(若要很好掌握应用程序touch事件处理,这部分要重点看):
    public boolean dispatchTouchEvent(MotionEvent ev) {
        ......
        boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;//计算是否禁止touch Intercept
        if (action == MotionEvent.ACTION_DOWN) {//按下事件,也就是touch开始
            if (mMotionTarget != null) {
                mMotionTarget = null;//清除mMotionTarget,也就是说每次touch开始,mMotionTarget要被重新设置
            }
            if (disallowIntercept || !onInterceptTouchEvent(ev)) {//判断消息是否需要被viewGroup拦截
                // 消息不被viewGroup拦截,找到相应的子view进行touch事件派发
                ev.setAction(MotionEvent.ACTION_DOWN);//重新设置event 为action_down
              
                final int scrolledXInt = (int) scrolledXFloat;
                final int scrolledYInt = (int) scrolledYFloat;
                final View[] children = mChildren;//获取viewgroup所有的子view
                final int count = mChildrenCount;
                for (int i = count - 1; i >= 0; i--) {
                    final View child = children[i];
                    if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE
                            || child.getAnimation() != null) {//若子view可见或者有动画在执行的,才能够接收touch事件
                        child.getHitRect(frame);//获取子view的布局坐标区域
                        if (frame.contains(scrolledXInt, scrolledYInt)) {//若子view 区域包含当前touch点击区域
                            // offset the event to the view's coordinate system
                            final float xc = scrolledXFloat - child.mLeft;
                            final float yc = scrolledYFloat - child.mTop;
                            ev.setLocation(xc, yc);
                            child.mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
                            if (child.dispatchTouchEvent(ev))  {//派发TouchEvent给包含这个touch区域的子view
                                // 若该子view消费了对应的touch事件
                                mMotionTarget = child;//设置viewgroup消息派发的目标子view
                                return true;//返回true,该touch事件被消费掉
                            }
                        }
                    }
                }
            }
          //若touch事件被拦截,mMotionTarget = null,后面touch消息不再派发给子view
        }

        boolean isUpOrCancel = (action == MotionEvent.ACTION_UP) ||//计算是up或者cancel
                (action == MotionEvent.ACTION_CANCEL);

        if (isUpOrCancel) {
            mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
        }

      
        final View target = mMotionTarget;
        if (target == null) {
            //target为null,意味着在ACTION_DOWN时没有找到能消费touch消息的子view或者在ACTION_DOWN时消息被拦截了,这个时候
            //调用父类view的dispatchTouchEvent消息进行派发,也就是说,此时viewgroup处理touch消息跟普通view一致。
            ev.setLocation(xf, yf);
            if ((mPrivateFlags & CANCEL_NEXT_UP_EVENT) != 0) {
                ev.setAction(MotionEvent.ACTION_CANCEL);
                mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
            }
            return super.dispatchTouchEvent(ev);
        }

        //target!=null,意味在ACTION_DOWN时touch消息没有被拦截,而且子view target消费了ACTION_DOWN消息,需要再判断消息是否被拦截
        if (!disallowIntercept && onInterceptTouchEvent(ev)) {
            //消息被拦截,而前面ACTION_DOWN时touch消息没有被拦截,所以需要发送ACTION_CANCEL通知子view target
            final float xc = scrolledXFloat - (float) target.mLeft;
            final float yc = scrolledYFloat - (float) target.mTop;
            mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
            ev.setAction(MotionEvent.ACTION_CANCEL);
            ev.setLocation(xc, yc);
            if (!target.dispatchTouchEvent(ev)) {
                // 派发消息ACTION_CANCEL给子view target
            }
            // mMotionTarget=null,后面消息不再派发给子view
            mMotionTarget = null;
            return true;
        }

        if (isUpOrCancel) {
            //isUpOrCancel,设置mMotionTarget=null,后面消息不再派发给子view
            mMotionTarget = null;
        }

        ......
        //没有被拦截继续派发消息给子view target
        return target.dispatchTouchEvent(ev);
    }

--3.ViewGroup.dispatchTouchEvent我查看了一下所有子类,只有PhoneWindow.DecorView覆盖了该方法,该方法前面讲DecorView消息派发时提过,它会找到对应包含这个PhoneWindow.DecorView对象的Activity把消息交给Activity去处理,其它所有viewGroup的子类均没有覆盖dispatchTouchEvent,也就是说所有包含子view的父view对于touch消息派发均采用上面的逻辑,当然,必要的时候我们可以覆盖该方法实现自己的touch消息派发逻辑,如Launcher2中的workspace类就是重新实现的该dispatchTouchEvent方法,从上面的dispatchTouchEvent函数逻辑其实我们也可以总结几条touch消息派发逻辑:
(1).onInterceptTouchEvent用来定义是否截取touch消息逻辑,若在groupview中想截取touch消息,必须覆盖viewgroup中该方法
(2).消息在整个dispatchTouchEvent过程中,若子view.dispatchTouchEvent返回true,父view中将不再处理该消息,但前提是该消息没有被父view截取,在整个touch消息处理过程中,若处理函数返回true,我们称之为消费了该touch事件,并且后面的父view将不再处理该消息。
(3).在整个touch事件过程中,从action_down到action_up,若父ViewGroup的函数onInterceptTouchEvent一旦返回true,消息将不再派发给子view,细分可为两种情况,若是在action_down时onInterceptTouchEvent返回true,不会派发任何消息给子view,并且后面onInterceptTouchEvent函数将不再会被执行若是action_down时onInterceptTouchEvent返回false ,而后面touch过程中onInterceptTouchEvent==true,父viewGroup会把action_cancel派发给子view,也之后不再派发消息给子view,并且onInterceptTouchEvent函数后面将不再被执行。

--4.为了更清楚的理解viewGroup消息的派发流程,我画一个流程图如下:

--5.上面我只是讲了父view与子view之间当有touch事件的消息派发流程,对于view的消息是怎么派发的(也包裹viewGroup没有子view或者有子view但是不消费该touch消息情况),因为从继承结构上看viewgroup继承了view,viewgroup覆盖了view的dispatchTouchEvent方法,不过从上面流程图也可以看到当mMotionTarget为Null它会执行父类view.dispatchTouchEvent,其他view的子类都是执行view.dispatchTouchEvent派发touch事件,不过若我们自定义view是可以覆盖该方法的。下面就仔细研究一下view.dispatchTouchEvent方法的代码:
    public final boolean dispatchTouchEvent(MotionEvent event) {
        //mOnTouchListener是被View.setOnTouchListener设置的,(mViewFlags & ENABLED_MASK)计算view是否可被点击
        //当view可被点击并且mOnTouchListener被设置,执行mOnTouchListener.onTouch
        if (mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED &&
                mOnTouchListener.onTouch(this, event)) {
            return true;//若mOnTouchListener.onTouch返回true,函数返回true
        }
        return onTouchEvent(event);//若mOnTouchListener.onTouch返回false,调用onToucheEvent
    }
函数逻辑很简单,前面的viewGroup touch事件流程图中我已经画出的,为区别我把它着色成青绿色,总结一句话若mOnTouchListener处理了touch消息,不执行onTouchEvent,否则交给onTouchEvent进行处理。
不知道是否讲清楚的,要清楚掌握估计还得写些例子测试一下是否是我上面所说的流程,不过我想了解事件的派发流程,对写应用的事件处理相信很有用,比如我以前碰到一个问题是手指点击屏幕到底是子view执行onclick还是执行父view的view移动,这个时候就需要深入了解viewde touch事件派发流程,该响应点击的时候响应子view的点击,该父view移动的时候拦截touch事件交给父view进行处理。


文章转自: http://blog.csdn.net/stonecao/article/details/6759189

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值