android5.1 AlarmManagerService的学习总结(未完)

这里先从一个使用定时器的例子片段开始:这个定时器的功能是当前时间5秒之后启动一个CalledByAlarmActivity,并且以后每10秒启动一次这个Activity(实际上这是做不到的,之后来分析原因)。
				AlarmManager am = (AlarmManager) getSystemService(ALARM_SERVICE);
				Intent intent = new Intent(MainActivity.this,CalledByAlarmActivity.class);	
				intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
				Long calltime=System.currentTimeMillis();
				intent.putExtra("calltime", calltime);
				PendingIntent pi = PendingIntent.getActivity(MainActivity.this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
				am.setRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis()+5*1000,10*1000, pi);

首先看第一行取得这个AlarmManager对象,按道理来说我们应该从systemService取得一个binder对象,但是这里却取得了AlarmManager对象,而AlarmManager并没有继承自binder,那么这个对象是怎么取得的?其实这里是因为在Context的实现类ContextImpl中做了一些操作:

        registerService(ALARM_SERVICE, new ServiceFetcher() {
                public Object createService(ContextImpl ctx) {
                    IBinder b = ServiceManager.getService(ALARM_SERVICE);
                    IAlarmManager service = IAlarmManager.Stub.asInterface(b);
                    return new AlarmManager(service, ctx);
                }});
从上面这一段代码的第三行可以看出来从serviceManager中取得的是一个IBinder对象,然后将它封装进了AlarmManager而已,具体的实现可以参考网上的Binder机制。

  在文章开头给出的例子之中,真正设置定时器的是最后一行代码,即am.setRepeating(---------参数-------);

--------AlarmManager.java

    public void setRepeating(int type, long triggerAtMillis,
            long intervalMillis, PendingIntent operation) {
        setImpl(type, triggerAtMillis, legacyExactLength(), intervalMillis, operation, null, null);
    }
我们看到setRepeating函数调用了setImpl函数,起始AlarmManager中所有的设置定时器函数都会最终调用这个函数,这与网上android2.3的实现略有不同。    

     * @param type One of {@link #ELAPSED_REALTIME}, {@link #ELAPSED_REALTIME_WAKEUP},
     *        {@link #RTC}, or {@link #RTC_WAKEUP}.
     * @param triggerAtMillis time in milliseconds that the alarm should first
     * go off, using the appropriate clock (depending on the alarm type).
     * @param intervalMillis interval in milliseconds between subsequent repeats
     * of the alarm.
     * @param operation Action to perform when the alarm goes off;
     * typically comes from {@link PendingIntent#getBroadcast
     * IntentSender.getBroadcast()}.
     *
这是谷歌源代码对于这些参数的注释,其中第一个参数表示的是闹钟的类型,第二个参数triggerAtMillis表示的触发时间,这个时间是毫秒形式的,计算方式有两种一种是RTC一种是ELAPSED这与第一个参数闹钟类型是相对应的,这个之后说明。第三个参数是重复定时器的时间间隔,即第一次触发到第二次触发的时间间隔,这里也是毫秒的形式。最后一个参数是一个PendingIntent,PendingIntent表示的是一个将来的意图,此文的最后会说下我在使用PendingIntent时候遇到的一个问题及解决办法。接下来我们看被调用的setImpl函数。

--------AlarmManager.java

    private void setImpl(int type, long triggerAtMillis, long windowMillis, long intervalMillis,
            PendingIntent operation, WorkSource workSource, AlarmClockInfo alarmClock) {
        if (triggerAtMillis < 0) {
            /* NOTYET
            if (mAlwaysExact) {
                // Fatal error for KLP+ apps to use negative trigger times
                throw new IllegalArgumentException("Invalid alarm trigger time "
                        + triggerAtMillis);
            }
            */
            triggerAtMillis = 0;
        }

        try {
            mService.set(type, triggerAtMillis, windowMillis, intervalMillis, operation,
                    workSource, alarmClock);
        } catch (RemoteException ex) {
        }
    }
这个函数首先判断triggerAtMillis,如果这个触发时间比0还小,那么就将处罚时间设置为0,并不会取消这个定时器。之后调用mService.set()函数,这里通过binder机制就调用了AlarmManagerService中的setImpl函数,binder机制参考网上资料。

--------AlarmManagerService.java

    void setImpl(int type, long triggerAtTime, long windowLength, long interval,
            PendingIntent operation, boolean isStandalone, WorkSource workSource,
            AlarmManager.AlarmClockInfo alarmClock) {
        if (operation == null) {
            Slog.w(TAG, "set/setRepeating ignored because there is no intent");
            return;
        }

        // Sanity check the window length.  This will catch people mistakenly
        // trying to pass an end-of-window timestamp rather than a duration.
        /*
	   如果设置的时间窗口12个小时还要长,那么就将窗口设置为1个小时
	*/
        if (windowLength > AlarmManager.INTERVAL_HALF_DAY) {
            Slog.w(TAG, "Window length " + windowLength
                    + "ms suspiciously long; limiting to 1 hour");
            windowLength = AlarmManager.INTERVAL_HOUR;
        }

        // Sanity check the recurrence interval.  This will catch people who supply
        // seconds when the API expects milliseconds.
        if (interval > 0 && interval < MIN_INTERVAL) {
            Slog.w(TAG, "Suspiciously short interval " + interval
                    + " millis; expanding to " + (int)(MIN_INTERVAL/1000)
                    + " seconds");
            interval = MIN_INTERVAL;
        }
	//不在系统设定的闹钟类型之中
        if (type < RTC_WAKEUP || type > RTC_POWEROFF_WAKEUP) {
            throw new IllegalArgumentException("Invalid alarm type " + type);
        }
	//如果triggerAtTime设置的比0小的话,那么就将triggerAtTime设置为0
        if (triggerAtTime < 0) {
            final long who = Binder.getCallingUid();
            final long what = Binder.getCallingPid();
            Slog.w(TAG, "Invalid alarm trigger time! " + triggerAtTime + " from uid=" + who
                    + " pid=" + what);
            triggerAtTime = 0;
        }
	//获取当前的elapsed时间
        final long nowElapsed = SystemClock.elapsedRealtime();
	/*
	    triggerAtTime如果是RTC制的时间话,利用这个函数转化成elapsed制的时间
	    如果本身就是elapsed制的时间,那么就不用处理直接返回将这个值设置为正常的触发时间
	*/
        final long nominalTrigger = convertToElapsed(triggerAtTime, type);
        // Try to prevent spamming by making sure we aren't firing alarms in the immediate future
        //设置最短的触发时间
        final long minTrigger = nowElapsed + MIN_FUTURITY;
	//计算触发时间,如果正常的触发时间比最小的触发时间要小,那么就要等待到最小的触发时间才能触发
        final long triggerElapsed = (nominalTrigger > minTrigger) ? nominalTrigger : minTrigger;
	//最大触发时间
        final long maxElapsed;
	//如果windowlength为0那么最大的触发时间就是triggerElapsed
        if (windowLength == AlarmManager.WINDOW_EXACT) {
            maxElapsed = triggerElapsed;
	//如果windowLength小于0的话,通过一个算法来计算这个值
        } else if (windowLength < 0) {
            maxElapsed = maxTriggerTime(nowElapsed, triggerElapsed, interval);
	//如果windowLength大于0的话那么最大触发时间为触发时间+窗口大小
        } else {
            maxElapsed = triggerElapsed + windowLength;
        }

        final int userId = UserHandle.getCallingUserId();

        synchronized (mLock) {
            if (DEBUG_BATCH) {
                Slog.v(TAG, "set(" + operation + ") : type=" + type
                        + " triggerAtTime=" + triggerAtTime + " win=" + windowLength
                        + " tElapsed=" + triggerElapsed + " maxElapsed=" + maxElapsed
                        + " interval=" + interval + " standalone=" + isStandalone);
            }
            setImplLocked(type, triggerAtTime, triggerElapsed, windowLength, maxElapsed,
                    interval, operation, isStandalone, true, workSource, alarmClock, userId);
        }
    }

这里就可以解释为什么每10秒就设置一次定时器是不行的,因为系统设定了两次定时器触发的最小间隔,最小间隔是60秒。

我们可以看到这个函数实际上调用了setImplLocked这个函数。

--------AlarmManagerService.java

/*
<span style="white-space:pre">	</span>第二个参数when是指alarm触发时间有可能是rtc也有可能是elapsed的,
	第三个参数是将when转化成elapsed的触发时间
	第五个参数是将whenElapsed加上了窗口大小之后的最大触发时间
*/
    private void setImplLocked(int type, long when, long whenElapsed, long windowLength,
            long maxWhen, long interval, PendingIntent operation, boolean isStandalone,
            boolean doValidate, WorkSource workSource, AlarmManager.AlarmClockInfo alarmClock,
            int userId) {
        //新建一个定时器
        Alarm a = new Alarm(type, when, whenElapsed, windowLength, maxWhen, interval,
                operation, workSource, alarmClock, userId);
		//remove this alarm if already scheduled(if type==RTC_POWEROFF_WAKEUP)
        removeLocked(operation);
	/*
	<span style="white-space:pre">	</span>返回被匹配的batch的索引,如果没有找到就返回-1。
		isStandalone的值是windowLength == AlarmManager.WINDOW_EXACT,
		我的理解是isStandalone表示的是,这个闹钟是单独的一个即窗口时间为0,
		还是这个闹钟可以和别的闹钟一起在一个时间窗口之中形成一个批次
	*/
        int whichBatch = (isStandalone) ? -1 : attemptCoalesceLocked(whenElapsed, maxWhen);
	/*
		如果whichBatch<0那么就意味着这个alarm没有在任何一个batch中,就新建一个batch并且将
		isStandalone设置为true,并且加入mAlarmBatches中,加入的方式采用二分查找找到起始位置
		比较的方式是利用batch的起始时间。即最终batchs会按照从触发时间由小到大的顺序排列。
	*/
        if (whichBatch < 0) {
            Batch batch = new Batch(a);
            batch.standalone = isStandalone;
            addBatchLocked(mAlarmBatches, batch);
        } else {
            Batch batch = mAlarmBatches.get(whichBatch);
            if (batch.add(a)) {
                // The start time of this batch advanced, so batch ordering may
                // have just been broken.  Move it to where it now belongs.
                /*
			因为加入了新的alarm,所以这个batch的起始时间有可能发生变化,
			那么就需要重新安排这个batch,所以先移出队列,再重新加入到正确的位置。
		*/
                mAlarmBatches.remove(whichBatch);
                addBatchLocked(mAlarmBatches, batch);
            }
        }

        if (alarmClock != null) {
            mNextAlarmClockMayChange = true;
            updateNextAlarmClockLocked();
        }

        if (DEBUG_VALIDATE) {
            if (doValidate && !validateConsistencyLocked()) {
                Slog.v(TAG, "Tipping-point operation: type=" + type + " when=" + when
                        + " when(hex)=" + Long.toHexString(when)
                        + " whenElapsed=" + whenElapsed + " maxWhen=" + maxWhen
                        + " interval=" + interval + " op=" + operation
                        + " standalone=" + isStandalone);
                rebatchAllAlarmsLocked(false);
            }
        }

        rescheduleKernelAlarmsLocked();
    }

最后调用了rescheduleKernelAlarmsLocked这个函数去设置定时器到驱动之中,set(mNativeData, type, alarmSeconds, alarmNanoseconds);从这里看出来设置到底层的闹钟只与类型、和触发的秒数和nano秒数有关,与其他的所有属性都没有关系。设置定时器就说完了。

---------------------------------------------------------定时器的移除包括需要注意的PendingIntent待续---------------------------------------------------------

定时器设置了之后要触发,那么当底层触发了一个定时器的时候,Framework层是怎么响应这个消息的呢?接下来我们看一看定时器的触发机制:既然要触发那就必须要有能接受底层消息的东西,在AlarmManagerService中与底层有关的native函数只有7个,其中WaitForAlarm看名字就好像是等待底层闹钟触发。现在我们就来看看这个函数是什么时候被调用了。则就要从AlarmManagerService的onStart函数说起了,这个函数是在SystemServer启动的时候调用了,具体的可以参看网上关于SystemServer启动过程的分析。

    @Override
    public void onStart() {
    	//打开设备驱动"/dev/alarm"返回一个long型的与fd有关的数
        mNativeData = init();
        mNextWakeup = mNextRtcWakeup = mNextNonWakeup = 0;

        // We have to set current TimeZone info to kernel
        // because kernel doesn't keep this after reboot
        //设置时区
        setTimeZoneImpl(SystemProperties.get(TIMEZONE_PROPERTY));
	//得到powermanager的实例
        PowerManager pm = (PowerManager) getContext().getSystemService(Context.POWER_SERVICE);
	//得到wakelock实例,PARTIAL_WAKE_LOCK:保持CPU 运转,屏幕和键盘灯有可能是关闭的。
        mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "*alarm*"); 
	/*
		这个pendingintent的作用应该是系统中常用的,它用来给发送一个时间改变的broadcast
		intent.ACTION_TIME_TICK,每整数分钟的开始发送一次,应用可以注册对应的receiver来
		干各种事,譬如更新时间显示等等
	*/
        mTimeTickSender = PendingIntent.getBroadcastAsUser(getContext(), 0,
                new Intent(Intent.ACTION_TIME_TICK).addFlags(
                        Intent.FLAG_RECEIVER_REGISTERED_ONLY
                        | Intent.FLAG_RECEIVER_FOREGROUND), 0,
                        UserHandle.ALL);
	/*
			每天的开始发送一次,即00:00的时候发送
	*/
        Intent intent = new Intent(Intent.ACTION_DATE_CHANGED);
        intent.addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING);
        mDateChangeSender = PendingIntent.getBroadcastAsUser(getContext(), 0, intent,
                Intent.FLAG_RECEIVER_REGISTERED_ONLY_BEFORE_BOOT, UserHandle.ALL);
        
        // now that we have initied the driver schedule the alarm
        mClockReceiver = new ClockReceiver();
        mClockReceiver.scheduleTimeTickEvent();
        mClockReceiver.scheduleDateChangedEvent();
		
        mInteractiveStateReceiver = new InteractiveStateReceiver();
		
        mUninstallReceiver = new UninstallReceiver();
		
        mQuickBootReceiver = new QuickBootReceiver();
        
        if (mNativeData != 0) {
            AlarmThread waitThread = new AlarmThread();
            waitThread.start();
        } else {
            Slog.w(TAG, "Failed to open alarm driver. Falling back to a handler.");
        }
		//将alarmservice注册进servicemanager
        publishBinderService(Context.ALARM_SERVICE, mService);
    }
在onstart函数中一个是有一个1分钟一个广播的定时器和00:00到了就发送的定时器,具体的1分钟一个的定时器实现就是每当1分钟到了就再设置一个一分钟的定时器(我看到的源代码是这么写)。 先看下最后一行这个函数publishBinderService(Context.ALARM_SERVICE, mService);这个函数将一个binder对象注册进了ServiceManager,所以我们取出来的时候也是这个binder对象,然后contextImpl封装了一下变成取得一个AlarmManager和用户交互了,这里和android2.3等网上分析的源代码有一个区别,以前是AlarmManagerService直接继承一个IAlarmManager.stub之类的,现在是继承SystemServer然后里面包含一个IAlarmManager.stub的mService,这一点区别。对于响应底层闹钟我们需要看到的是这个waitThread线程。

--------AlarmManagerService.java  AlarmThread内部类

public void run()
        {
            ArrayList<Alarm> triggerList = new ArrayList<Alarm>();

            while (true)
            {	
            	/*
			等待一个底层的RTC闹钟的触发,这个过程应该是同步阻塞的(ioctl)
			这个返回值是有意义的:如果没有猜测错的话,这个返回值表明了alarm的类型
		*/
                int result = waitForAlarm(mNativeData);
				//移除triggerlist中的所有元素
                triggerList.clear();

                if ((result & TIME_CHANGED_MASK) != 0) {
                    if (DEBUG_BATCH) {
                        Slog.v(TAG, "Time changed notification from kernel; rebatching");
                    }
                    removeImpl(mTimeTickSender);
                    rebatchAllAlarms();
		//重新设置scheduletimetickevent这个pendingintent了
                    mClockReceiver.scheduleTimeTickEvent();
                    synchronized (mLock) {
                        mNumTimeChanged++;
                    }
                    Intent intent = new Intent(Intent.ACTION_TIME_CHANGED);
                    intent.addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING
                            | Intent.FLAG_RECEIVER_REGISTERED_ONLY_BEFORE_BOOT
                            | Intent.FLAG_RECEIVER_FOREGROUND);
                    getContext().sendBroadcastAsUser(intent, UserHandle.ALL);
                } // end if((result&TIME_CHANGED_MASK)!=0) 
                
                synchronized (mLock) {
                    final long nowRTC = System.currentTimeMillis();
                    final long nowELAPSED = SystemClock.elapsedRealtime();
                    if (localLOGV) Slog.v(
                        TAG, "Checking for alarms... rtc=" + nowRTC
                        + ", elapsed=" + nowELAPSED);

                    if (WAKEUP_STATS) {
                        if ((result & IS_WAKEUP_MASK) != 0) {
                            long newEarliest = nowRTC - RECENT_WAKEUP_PERIOD;
                            int n = 0;
                            for (WakeupEvent event : mRecentWakeups) {
                                if (event.when > newEarliest) break;
                                n++; // number of now-stale entries at the list head
                            }
                            for (int i = 0; i < n; i++) {
                                mRecentWakeups.remove();
                            }

                            recordWakeupAlarms(mAlarmBatches, nowELAPSED, nowRTC);
                        }
                    }

                    boolean hasWakeup = triggerAlarmsLocked(triggerList, nowELAPSED, nowRTC);

                    if (SystemProperties.getInt("sys.quickboot.enable", 0) == 1) {
                        filtQuickBootAlarms(triggerList);
                    }

                    if (!hasWakeup && checkAllowNonWakeupDelayLocked(nowELAPSED)) {
                        // if there are no wakeup alarms and the screen is off, we can
                        // delay what we have so far until the future.
                        if (mPendingNonWakeupAlarms.size() == 0) {
                            mStartCurrentDelayTime = nowELAPSED;
                            mNextNonWakeupDeliveryTime = nowELAPSED
                                    + ((currentNonWakeupFuzzLocked(nowELAPSED)*3)/2);
                        }
                        mPendingNonWakeupAlarms.addAll(triggerList);
                        mNumDelayedAlarms += triggerList.size();
                        rescheduleKernelAlarmsLocked();
                        updateNextAlarmClockLocked();
                    } else {
                        // now deliver the alarm intents; if there are pending non-wakeup
                        // alarms, we need to merge them in to the list.  note we don't
                        // just deliver them first because we generally want non-wakeup
                        // alarms delivered after wakeup alarms.
                        rescheduleKernelAlarmsLocked();
                        updateNextAlarmClockLocked();
                        if (mPendingNonWakeupAlarms.size() > 0) {
                            calculateDeliveryPriorities(mPendingNonWakeupAlarms);
                            triggerList.addAll(mPendingNonWakeupAlarms);
                            Collections.sort(triggerList, mAlarmDispatchComparator);
                            final long thisDelayTime = nowELAPSED - mStartCurrentDelayTime;
                            mTotalDelayTime += thisDelayTime;
                            if (mMaxDelayTime < thisDelayTime) {
                                mMaxDelayTime = thisDelayTime;
                            }
                            mPendingNonWakeupAlarms.clear();
                        }
                        deliverAlarmsLocked(triggerList, nowELAPSED);
                    }
                }
            }
        }
    }
则个run函数中我们需要关注boolean hasWakeup = triggerAlarmsLocked(triggerList, nowELAPSED, nowRTC); 和deliverAlarmsLocked(triggerList, nowELAPSED);这两个地方。先来看看triggerAlarmsLocked这个函数
    boolean triggerAlarmsLocked(ArrayList<Alarm> triggerList, final long nowELAPSED,
            final long nowRTC) {
        boolean hasWakeup = false;
        // batches are temporally sorted, so we need only pull from the
        // start of the list until we either empty it or hit a batch
        // that is not yet deliverable
        while (mAlarmBatches.size() > 0) {
            Batch batch = mAlarmBatches.get(0);
	/*
		如果第0个batch的开始时间比现在的时间还大,
		说明现在没有合适的定时器需要触发,跳出循环
	*/
            if (batch.start > nowELAPSED) {
                // Everything else is scheduled for the future
                break;
            }

            // We will (re)schedule some alarms now; don't let that interfere
            // with delivery of this current batch
            mAlarmBatches.remove(0);

            final int N = batch.size();
            for (int i = 0; i < N; i++) {
                Alarm alarm = batch.get(i);
                alarm.count = 1;
                triggerList.add(alarm);

                // Recurring alarms may have passed several alarm intervals while the
                // phone was asleep or off, so pass a trigger count when sending them.
                /*
			如果这个定时器是重复性定时器,那么就进入循环
			首先计算它的count值,这个值是用来计算到下次触发说要经过的时间间隔数。
			从而可以计算出下次激发时间,然后将这个重复闹钟重新设置到定时器batch中去
		*/
                if (alarm.repeatInterval > 0) {
                    // this adjustment will be zero if we're late by
                    // less than one full repeat interval
                    alarm.count += (nowELAPSED - alarm.whenElapsed) / alarm.repeatInterval;

                    // Also schedule its next recurrence
                    final long delta = alarm.count * alarm.repeatInterval;
                    final long nextElapsed = alarm.whenElapsed + delta;
                    setImplLocked(alarm.type, alarm.when + delta, nextElapsed, alarm.windowLength,
                            maxTriggerTime(nowELAPSED, nextElapsed, alarm.repeatInterval),
                            alarm.repeatInterval, alarm.operation, batch.standalone, true,
                            alarm.workSource, alarm.alarmClock, alarm.userId);
                }

                if (alarm.wakeup) {
                    hasWakeup = true;
                }

                // We removed an alarm clock. Let the caller recompute the next alarm clock.
                if (alarm.alarmClock != null) {
                    mNextAlarmClockMayChange = true;
                }
            }
        }

        // This is a new alarm delivery set; bump the sequence number to indicate that
        // all apps' alarm delivery classes should be recalculated.
        mCurrentSeq++;
        calculateDeliveryPriorities(triggerList);
        Collections.sort(triggerList, mAlarmDispatchComparator);

        if (localLOGV) {
            for (int i=0; i<triggerList.size(); i++) {
                Slog.v(TAG, "Triggering alarm #" + i + ": " + triggerList.get(i));
            }
        }

        return hasWakeup;
    }
这个函数实际上就是收到消息之后去看看哪些消息要触发了,于是就去batch里面找然后放到一个叫做trrigerList的队列里,然后排个序之类的。于是我们就得到了一个triggerList队列。接下来我们看下deliverAlarmsLocked函数。

void deliverAlarmsLocked(ArrayList<Alarm> triggerList, long nowELAPSED) {
        mLastAlarmDeliveryTime = nowELAPSED;
        for (int i=0; i<triggerList.size(); i++) {
            Alarm alarm = triggerList.get(i);
            try {
                if (localLOGV) {
                    Slog.v(TAG, "sending alarm " + alarm);
                }
                alarm.operation.send(getContext(), 0,
                        mBackgroundIntent.putExtra(
                                Intent.EXTRA_ALARM_COUNT, alarm.count),
                        mResultReceiver, mHandler);
这是这个函数的一个片段,这里我们看到了alarm.operation.send这个地方,这个就是PendingIntent的send方法。
    public void send(Context context, int code, Intent intent,
            OnFinished onFinished, Handler handler, String requiredPermission)
            throws CanceledException {
        try {
            String resolvedType = intent != null ?
                    intent.resolveTypeIfNeeded(context.getContentResolver())
                    : null;
            int res = mTarget.send(code, intent, resolvedType,
                    onFinished != null
                            ? new FinishedDispatcher(this, onFinished, handler)
                            : null,
                    requiredPermission);
            if (res < 0) {
                throw new CanceledException();
            }
        } catch (RemoteException e) {
            throw new CanceledException(e);
        }
    }

这个方法会调用mTarget的send方法,这个mTarget其实就是一个ActivityManagerService里面保存的一个PendingIntentRecorder对象,也就是调用了这个对象的send方法。


  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值