【iOS】Runloop


前言

先前学习了Runtime的内容,现在来学习一下Runloop

一、Runloop的概念

一般来讲线程一次只能执行一个任务,执行完后就会退出,我们现在想实现一个功能:线程一直在处理事件并且不会退出,这就是我们Runloop的作用,通常的代码逻辑我们用伪代码来表示一下:

int main(void) {
    初始化();
    while (message != 退出) {
        处理事件(message);
        message = 获取下一个事件();
    }
    return 0;
}

这种模型也常常被称作Event Loop,这种机制在很多地方都用到了,例如windows的事件循环,iOS中的runloop

实现这种模型的关键点在于:如何管理事件/消息,如何让线程在没有处理消息时休眠以避免资源占用、在有消息到来时立刻被唤醒。

因此Runloop实际上就是一个对象,只不过这个对象是用来管理需要处理的消息与事件的,并且这个对象提供了一个入口函数执行Event Loop,线程执行函数后会一直进行事件循环,直到接收到退出消息

在iOS中提供了两个EventLoop的具体实现:
NSRunLoopCFRunLoopRef

CFRunLoopRef 是在 CoreFoundation 框架内的,它提供了纯 C 函数的 API,所有这些 API 都是线程安全的。
NSRunLoop 是基于 CFRunLoopRef 的封装,提供了面向对象的 API,但是这些 API 不是线程安全的。

二、RunLoop 与线程的关系

我们在先前的多线程里讲过,基本上所有的线程操作的底层都是对pthread_t的封装

同时回到我们Runloop与线程的关系,苹果不允许直接创建Runloop,它只提供了两个自动获取的函数:CFRunLoopGetMain() 和 CFRunLoopGetCurrent()。 这两个函数内部的逻辑大概是下面这样:

/// 全局的Dictionary,key 是 pthread_t, value 是 CFRunLoopRef
static CFMutableDictionaryRef loopsDic;
/// 访问 loopsDic 时的锁
static CFSpinLock_t loopsLock;
 
/// 获取一个 pthread 对应的 RunLoop。
CFRunLoopRef _CFRunLoopGet(pthread_t thread) {
    OSSpinLockLock(&loopsLock);
    
    if (!loopsDic) {
        // 第一次进入时,初始化全局Dic,并先为主线程创建一个 RunLoop。
        loopsDic = CFDictionaryCreateMutable();
        CFRunLoopRef mainLoop = _CFRunLoopCreate();
        CFDictionarySetValue(loopsDic, pthread_main_thread_np(), mainLoop);
    }
    
    /// 直接从 Dictionary 里获取。
    CFRunLoopRef loop = CFDictionaryGetValue(loopsDic, thread));
    
    if (!loop) {
        /// 取不到时,创建一个
        loop = _CFRunLoopCreate();
        CFDictionarySetValue(loopsDic, thread, loop);
        /// 注册一个回调,当线程销毁时,顺便也销毁其对应的 RunLoop。
        _CFSetTSD(..., thread, loop, __CFFinalizeRunLoop);
    }
    
    OSSpinLockUnLock(&loopsLock);
    return loop;
}
 
CFRunLoopRef CFRunLoopGetMain() {
    return _CFRunLoopGet(pthread_main_thread_np());
}
 
CFRunLoopRef CFRunLoopGetCurrent() {
    return _CFRunLoopGet(pthread_self());

从上面的代码可以看出,线程和 RunLoop 之间是一一对应的,其关系是保存在一个全局的 Dictionary 里
线程刚创建时并没有runloop,我们需要主动获取,系统才会自动帮我们创建runloop并加到字典中

三、Runloop对外的接口

我们在上文说了iOS并不允许我们直接创建Runloop,但其提供了两个函数给我们,CFRunLoopGetCurrent 和 CFRunLoopGetMain

  • CFRunLoopGetCurrent 函数便是获取当前线程的 CFRunLoop 对象,如果不存在的话会则会创建一个。
  • CFRunLoopGetMain 则是获取主线程的 CFRunLoop 对象。

同时我们需要明白我们这两个函数只是创建了线程对应的Runloop,但是创建之后的Runloop并没有运行起来,因此需要程序员让runloop运行起来

在研究如何使 run loop 运行起来和 run loop 运行起来后的行为,需要先了解 run loop 的一些具体结构。

在 CoreFoundation 里面关于 RunLoop 有5个类:

  • CFRunLoopRef
  • CFRunLoopModeRef
  • CFRunLoopSourceRef
  • CFRunLoopTimerRef
  • CFRunLoopObserverRef

一个Runloop中包含了若干个Mode, CFRunLoopModeRef 类并没有对外暴露,只是通过 CFRunLoopRef 的接口进行了封装,他们的关系如下:
在这里插入图片描述
同时通过这张图我们可以看到,每个Mode中游包含了Source/Timer/Observer的集合

每次调用 RunLoop 的主函数时,只能指定其中一个 Mode,这个Mode被称作 CurrentMode

如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。

CFRunLoopSourceRef

CFRunLoopSourceRef 是事件产生的地方。Source有两个版本:Source0 和 Source1。

这里我们首先要明确一个概念,不是所有代码都是通过Runloop处理的,RunLoop主要用于处理异步事件,如用户输入、定时器触发、网络响应等。这些事件通常被封装成事件源,然后由RunLoop在适当的时机调度和处理

比如NSLog函数就不会使用到Runloop进行事件循环

Source0

Source0 只包含了一个回调(函数指针)
它并不能主动触发事件

使用source0时,需要先调用CFRunLoopSourceSignal(source0)将 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件,按钮的点击滑动等都是在此进行处理的

// 假设有一个方法,用于处理按钮点击
- (void)buttonClicked {
    // 手动触发RunLoop的Source0
    CFRunLoopSourceSignal(source0);
    CFRunLoopWakeUp(CFRunLoopGetCurrent()); // 唤醒RunLoop来处理事件
}

// 配置Source0
- (void)setupSource0 {
    CFRunLoopSourceContext context = {0, (__bridge void *)(self), NULL, NULL, NULL, NULL, NULL, NULL, NULL, &callout};
    source0 = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
    CFRunLoopAddSource(CFRunLoopGetCurrent(), source0, kCFRunLoopDefaultMode);
}

// Source0的回调函数
void callout(void *info) {
    NSLog(@"Source0 event triggered.");
}

Source1

Source1 包含了一个 mach_port 和一个回调(函数指针)

被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程,其原理在下面会讲到。

// 配置Source1
- (void)setupSource1 {
    CFRunLoopSourceContext1 context = {0, (__bridge void *)(self), NULL, NULL, NULL, NULL, NULL, &perform, NULL};
    CFMessagePortRef localPort = CFMessagePortCreateLocal(kCFAllocatorDefault, CFSTR("com.example.app.port"), &callback, &context, false);
    CFRunLoopSourceRef source1 = CFMessagePortCreateRunLoopSource(kCFAllocatorDefault, localPort, 0);
    CFRunLoopAddSource(CFRunLoopGetCurrent(), source1, kCFRunLoopCommonModes);
}

// Source1的回调函数
CFDataRef callback(CFMessagePortRef local, SInt32 msgid, CFDataRef data, void *info) {
    NSLog(@"Received message: %d", msgid);
    return NULL;
}

// Source1的事件执行
void perform(void *info) {
    NSLog(@"Performing work in response to external event.");
}

使用场景:
• 处理来自其他进程的数据或信号。
• 监听系统级事件或网络事件。

CFRunLoopTimerRef

CFRunLoopTimerRef 是基于时间的触发器,它和 NSTimertoll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调

CFRunLoopObserverRef

CFRunLoopObserverRefCore Foundation 框架中的一种对象,用于监视和响应 RunLoop 的特定活动。通过 CFRunLoopObserver,开发者可以在 RunLoop 的不同阶段插入自定义的代码来执行特定的任务

可以观测的时间点有以下几个:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry         = (1UL << 0), // 即将进入Loop
    kCFRunLoopBeforeTimers  = (1UL << 1), // 即将处理 Timer
    kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
    kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
    kCFRunLoopAfterWaiting  = (1UL << 6), // 刚从休眠中唤醒
    kCFRunLoopExit          = (1UL << 7), // 即将退出Loop
};

我们在上面讲的 Source/Timer/Observer 被统称为 mode item,一个item被重复添加到同一个mode时不会多次执行,但是如果一个mode中一个item都没有,runloop会自动退出,不会进出循环

四、RunLoop 的 Mode

刚才在上文讲了mode item,modeitem是被加到mode中的,我们现在讲一下Runloop的Mode

CFRunLoopModeCFRunLoop 的结构大致如下:

struct __CFRunLoopMode {
    CFStringRef _name;            // Mode Name, 例如 @"kCFRunLoopDefaultMode"
    CFMutableSetRef _sources0;    // Set
    CFMutableSetRef _sources1;    // Set
    CFMutableArrayRef _observers; // Array
    CFMutableArrayRef _timers;    // Array
    ...
};
 
struct __CFRunLoop {
    CFMutableSetRef _commonModes;     // Set
    CFMutableSetRef _commonModeItems; // Set<Source/Observer/Timer>
    CFRunLoopModeRef _currentMode;    // Current Runloop Mode
    CFMutableSetRef _modes;           // Set
    ...
};

这里有个概念叫CommonModes,一个Mode可以把自己标记成”Common”属性(通过将其 ModeName 添加到 RunLoop 的 “commonModes” 中),每当Runloop的内容发生变化时,RunLoop 都会自动将 _commonModeItems 里的 Source/Observer/Timer 同步到具有 Common标记的所有Mode

应用场景举例
主线程的Runloop中有两个预置的Mode

kCFRunLoopDefaultModeUITrackingRunLoopMode。这两个 Mode 都已经被标记为”Common”属性

应用场景举例:

DefalutMode是App平时所处的状态,TrackingMode是当滑动时所处的状态,当我们创建NSTimer添加到DefalutMode中,Timer会得到重复回调,但是当我们滚动我们的TableView时,Runloop会切换Mode,由DefalutMode切换为TrackingMode,此时Timer会停止同时不会进行回调,也不会影响到滑动的操作

但是如果我们想在滑动的时候NSTimer能够继续运作

  • 一种方法就是将Timer分别加入到两个Mode
  • 另一种方法就是NSTimer加到最顶层的RunLoopcommonModeItems,加入后的ModeItems类型会被Runloop加到具有common属性的Mode中去,也就是直接将Timer同时加到defaultModeTrackMode中去

上面既然讲到了RunLoop中的Mode,我们来分析一下iOS中到底有几种Mode:

苹果公开的三种 RunLoop Mode

  • NSDefaultRunLoopMode(kCFRunloopDefaultMode):默认状态,app通常在这个mode下运行
  • UITrackingRunLoopMode:界面跟踪mode(例如滑动scrollview时不被其他mode影响)
  • NSRunLoopCommonModes(kCFRunLoopCommonModes):是 前两个mode的集合,可以把自定义modeCFRunLoopAddCommonMode函数加入到集合中

还有两种mode,只需做了解即可:

  • GSEventReceiveRunLoopMode:接收系统内部mode,通常用不到
  • UIInitializationRunLoopMode:私有,只在app启动时使用,使用完就不在集合中了

五、Runloop的内部逻辑

来看一张经典的Runloop逻辑图

在这里插入图片描述

Runloop代码整理如下,后面会专门讲一下Runloop的源码

/// 用DefaultMode启动
void CFRunLoopRun(void) {
    CFRunLoopRunSpecific(CFRunLoopGetCurrent(), kCFRunLoopDefaultMode, 1.0e10, false);
}
 
/// 用指定的Mode启动,允许设置RunLoop超时时间
int CFRunLoopRunInMode(CFStringRef modeName, CFTimeInterval seconds, Boolean stopAfterHandle) {
    return CFRunLoopRunSpecific(CFRunLoopGetCurrent(), modeName, seconds, returnAfterSourceHandled);
}
 
/// RunLoop的实现
int CFRunLoopRunSpecific(runloop, modeName, seconds, stopAfterHandle) {
    
    /// 首先根据modeName找到对应mode
    CFRunLoopModeRef currentMode = __CFRunLoopFindMode(runloop, modeName, false);
    /// 如果mode里没有source/timer/observer, 直接返回。
    if (__CFRunLoopModeIsEmpty(currentMode)) return;
    
    /// 1. 通知 Observers: RunLoop 即将进入 loop。
    __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopEntry);
    
    /// 内部函数,进入loop
    __CFRunLoopRun(runloop, currentMode, seconds, returnAfterSourceHandled) {
        
        Boolean sourceHandledThisLoop = NO;
        int retVal = 0;
        do {
 
            /// 2. 通知 Observers: RunLoop 即将触发 Timer 回调。
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
            /// 3. 通知 Observers: RunLoop 即将触发 Source0 (非port) 回调。
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
            /// 执行被加入的block
            __CFRunLoopDoBlocks(runloop, currentMode);
            
            /// 4. RunLoop 触发 Source0 (非port) 回调。
            sourceHandledThisLoop = __CFRunLoopDoSources0(runloop, currentMode, stopAfterHandle);
            /// 执行被加入的block
            __CFRunLoopDoBlocks(runloop, currentMode);
 
            /// 5. 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息。
            if (__Source0DidDispatchPortLastTime) {
                Boolean hasMsg = __CFRunLoopServiceMachPort(dispatchPort, &msg)
                if (hasMsg) goto handle_msg;
            }
            
            /// 通知 Observers: RunLoop 的线程即将进入休眠(sleep)if (!sourceHandledThisLoop) {
                __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
            }
            
            /// 7. 调用 mach_msg 等待接受 mach_port 的消息。线程将进入休眠, 直到被下面某一个事件唤醒。
            /// • 一个基于 port 的Source 的事件。
            /// • 一个 Timer 到时间了
            /// • RunLoop 自身的超时时间到了
            /// • 被其他什么调用者手动唤醒
            __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort) {
                mach_msg(msg, MACH_RCV_MSG, port); // thread wait for receive msg
            }
 
            /// 8. 通知 Observers: RunLoop 的线程刚刚被唤醒了。
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting);
            
            /// 收到消息,处理消息。
            handle_msg:
 
            /// 9.1 如果一个 Timer 到时间了,触发这个Timer的回调。
            if (msg_is_timer) {
                __CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())
            } 
 
            /// 9.2 如果有dispatch到main_queue的block,执行block。
            else if (msg_is_dispatch) {
                __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
            } 
 
            /// 9.3 如果一个 Source1 (基于port) 发出事件了,处理这个事件
            else {
                CFRunLoopSourceRef source1 = __CFRunLoopModeFindSourceForMachPort(runloop, currentMode, livePort);
                sourceHandledThisLoop = __CFRunLoopDoSource1(runloop, currentMode, source1, msg);
                if (sourceHandledThisLoop) {
                    mach_msg(reply, MACH_SEND_MSG, reply);
                }
            }
            
            /// 执行加入到Loop的block
            __CFRunLoopDoBlocks(runloop, currentMode);
            
 
            if (sourceHandledThisLoop && stopAfterHandle) {
                /// 进入loop时参数说处理完事件就返回。
                retVal = kCFRunLoopRunHandledSource;
            } else if (timeout) {
                /// 超出传入参数标记的超时时间了
                retVal = kCFRunLoopRunTimedOut;
            } else if (__CFRunLoopIsStopped(runloop)) {
                /// 被外部调用者强制停止了
                retVal = kCFRunLoopRunStopped;
            } else if (__CFRunLoopModeIsEmpty(runloop, currentMode)) {
                /// source/timer/observer一个都没有了
                retVal = kCFRunLoopRunFinished;
            }
            
            /// 如果没超时,mode里没空,loop也没被停止,那继续loop。
        } while (retVal == 0);
    }
    
    /// 10. 通知 Observers: RunLoop 即将退出。
    __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);
}

可以看到Runloop内部实际就是这样一个函数,其内部一直在进行do-while循环,当你调用 CFRunLoopRun() 时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。

六、Runloop应用

在这里插入图片描述

事件响应

当一个硬件事件(触屏/锁屏/摇晃/加速)发生之后,首先在触摸时会生成一个IOHIDEvent事件,之后由mach port端口转发给App进程

苹果同时也注册了一个source1来接受系统事件,通过回调函数出发Source0,(所以UIEvent实际上基于Source0)的,调用_UIApplicationHandleEventQueue()进行应用内部的分发,_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发
,其实包括点击事件,手势处理等

界面更新

  • 当UI发生改变时(Frame变化,UIView/CALayer结构变化),或手动调用了UIView/CALayer的setNeedsLayout/setNeedsDisplay方法之后,就将这个UIView、CALayer就被标记为待处理
  • 苹果注册了一个用来监听BeforeWaiting和Exit的Observer,在他的回调函数里会遍历所有待处理的UIView/CALayer来执行实际的绘制和调整,并更新UI界面。

AutoreleasePool

主线程注册了两个Observers,他们用来创建与释放AutoreleasePool

  • Observers1 监听Entry事件: 优先级最高,确保在所有的回调前创建释放池,回调内调用 _objc_autoreleasePoolPush()创建自动释放池

  • Observers2监听BeforeWaitingExit事件: 优先级最低,保证在所有回调后释放释放池。BeforeWaiting事件:调用_objc_autoreleasePoolPop()和_objc_autoreleasePoolPush()释放旧池并创建新池,Exit事件: 调用_objc_autoreleasePoolPop(),释放自动释放池

tableView延迟加载图片,保证流畅

我们在快速滑动的图片滑动过的图片会一直加载,但是快速滑动过的图片并不是我们想要的图片,如果进行加载就十分浪费CPU资源,我们现在有一种思路,让我们的tableview滑动时不加载图片

给ImgaeView的加载图片的方法指定只有在DefalutMode下才能加载,滑动时不加载图片,为实现这个功能我们用到了

[self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"imgName.png"] afterDelay:0 inModes:@[NSDefaultRunLoopMode]];

Timer不被ScrollView的滑动影响

我们在上面说了两种方法,一种是将定时器加到trackingMode中,另一种是加到CommonMode集合中,我们来介绍另一种方法

  • GCD创建定时器,GCD创建的定时器不会受RunLoop影响
// 获得队列
    dispatch_queue_t queue = dispatch_get_main_queue();
    
    // 创建一个定时器(dispatch_source_t本质还是个OC对象)
    self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
    
    // 设置定时器的各种属性(几时开始任务,每隔多长时间执行一次)
    // GCD的时间参数,一般是纳秒(1秒 == 10的9次方纳秒)
    // 比当前时间晚1秒开始执行
    dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC));
    
    //每隔一秒执行一次
    uint64_t interval = (uint64_t)(1.0 * NSEC_PER_SEC);
    dispatch_source_set_timer(self.timer, start, interval, 0);
    
    // 设置回调
    dispatch_source_set_event_handler(self.timer, ^{
        NSLog(@"------------%@", [NSThread currentThread]);

    });
    
    // 启动定时器
    dispatch_resume(self.timer);

AFNetworking

在多线程中,线程常常执行完任务就会退出,这意味着如果我们需要反复执行任务例如网络请求,网络监听等,必须要频繁地创建与销毁线程,这样不仅效率低下,而且增加了系统的开销,因此我们希望实现一个常驻线程专门处理这些任务

常驻线程

我们在上面说过,一个RunLoop中如果没有Observer/Timer/Source等items,Runloop会自动退出,因此我们创建一个空的port发送消息给Runloop,以至于Runloop不会退出而是一直常驻

首先创建一个线程属性
在这里插入图片描述

其次验证是否我们的点击事件是在loop中执行且线程不销毁

- (void)viewDidLoad {
    [super viewDidLoad];
    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(runThread) object:nil];
    [self.thread start];}

- (void)runThread {
    NSLog(@"开启子线程:%@", [NSThread currentThread]);
// 子线程的RunLoop创建出来需要手动添加事件输入源和定时器 因为runloop如果没有CFRunLoopSourceRef事件源输入或者定时器,就会立马消亡。
    //下面的方法给runloop添加一个NSport,就是添加一个事件源,也可以添加一个定时器,或者observer,让runloop不会挂掉
    [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] run];
    
    // 测试开始RunLoop
    // 未进入循环就会执行该代码
    NSLog(@"failed");
}

// 同时在我们自己新建立的这个线程中写一下touchesBegan这个方法测试点击空白处会不会在子线程相应方法
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event  {
    [self performSelector:@selector(runTest) onThread:self.thread withObject:nil waitUntilDone:NO];
}
- (void)runTest {
    NSLog(@"子线程点击空白:%@", [NSThread currentThread]);
}

可以看到点击之后出现如下情况
在这里插入图片描述

PerformSelecter

当调用 NSObject 的 performSelecter:afterDelay: 后,其内部会自动创建一个Timer加到Runloop中,当时间到了执行回调,如果当前线程没有Runloop,此方法也会失效
在这里插入图片描述

总结

本文介绍玩Runloop,应该懂得:
Runloop事件上就是一个事件循环,也可以当作一个对象,这个对象实际上就是用来处理消息与事件的,其提供了一个入口函数去执行EventLoop
同时Runloop中又包含五种Mode,其中最常用的是CommonMode,DefaultMode,TrackingMode,Mode中又有ModeItems的集合,如果一个Mode中没有items,那么runloop就会退出,items是指Timer/Source/Observer等,Source中又包括Source0与Source1,Source0主要处理点击事件,Source1主要处理线程之间发送消息等操作,但是Source0必须要手动出发Runloop
同时知道了Runloop的基本逻辑,我们还可以在日常开发中使用它,例如解决定时器不准,实现ImageView延迟加载,AFNetWorking中实现常驻线程等功能,同时在iOS的底层实现界面更新以及事件响应都用到了Runloop

同时Runloop十分重要,后面还会分析Runloop的源码

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值