洋仔的博客 洋仔的博客
首页
  • 个人心法总结

    • 价值心法
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • iOS基础知识
  • 前端
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 投资体系
  • 毛选
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

洋仔

奋斗的小青年
首页
  • 个人心法总结

    • 价值心法
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • iOS基础知识
  • 前端
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 投资体系
  • 毛选
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 技术文档

  • GitHub技巧

  • Nodejs

  • 博客搭建

  • iOS基础知识

    • iOS底层相关

    • Runloop系列

    • Runtime系列

    • 内存管理系列

    • Block系列

      • Block系列
        • block 本质
        • Block 类型
          • NSGlobalBlock 全局block
          • NSStackBlock 栈block
          • NSMallocBlock
          • 总结
        • block中关键字的使用
          • __block 的作用
          • __weak 的作用
          • __strong 的作用
        • 拓展知识
          • 思考题
          • Block 数据结构
        • 总结
        • block面试题
          • block和_weak修饰符的区别?
          • block是否能修改外部变量的值
          • Block在ARC跟MRC中的行为和用法有什么区别?
          • block是类吗,有哪些类型?
          • 一个int变量被 __block 修饰与否的区别?block的变量截获
          • 什么时候栈上的Block会被复制到堆呢?
          • block在修改NSMutableArray,需不需要添加__block
          • block怎么进行内存管理的?
          • block可以用strong修饰吗?
          • 解决循环引用时为什么要用_strong、_weak修饰?
          • block发生copy时机?
          • Block访问对象类型的auto变量时,在ARC和MRC下有什么区别?
        • block 的变量捕获机制
          • auto 类型的局部变量
          • static 类型的局部变量
          • 对象类型的局部变量
    • 线程系列

    • KVC跟KVO系列以及通知中心

    • UI系列

    • 离屏渲染系列

    • 组件化系列跟架构

    • OC跟webview交互系列

    • 持久化系列

    • APP编译系列

    • APP性能优化系列

    • cocoapods系列

    • swift系列

    • Git系列

    • 网络相关

    • 三方库系列

    • 系统原理

    • 总结系列

    • 算法系列

    • 数据结构系列

  • 前端

  • 技术
  • iOS基础知识
  • Block系列
洋仔
2023-08-12
目录

Block系列

# block 本质

Block 本质上是一个 Objective-C 的对象,它内部也有一个 isa 指针,它是一个封装了函数及函数调用环境的 Objective-C 对象,可以添加到 NSArray 及 NSDictionary 等集合中,它是基于 C 语言及运行时特性,有点类似标准的 C 函数。但除了可执行代码以外,另外包含了变量同堆或栈的自动绑定。

# Block 类型

# NSGlobalBlock 全局block

void (^exampleBlock)(void) = ^{
    // block
};
NSLog(@"exampleBlock is: %@",[exampleBlock class]);
1
2
3
4

打印日志:exampleBlock is: NSGlobalBlock

如果一个 block 没有访问外部局部变量,或者访问的是全局变量,或者静态局部变量,此时的 block 就是一个全局 block ,并且数据存储在全局区。

# NSStackBlock 栈block

int temp = 100;
void (^exampleBlock)(void) = ^{
    // block
    NSLog(@"exampleBlock is: %d", temp);
};

NSLog(@"exampleBlock is: %@",[exampleBlock class]);
1
2
3
4
5
6
7

打印日志:exampleBlock is: NSMallocBlock???不是说好的 NSStackBlock 的吗?为什么打印的是__NSMallocBlock__ 呢?这里是因为我们使用了 ARC ,Xcode 默认帮我们做了很多事情。

我们可以去 Build Settings 里面,找到 Objective-C Automatic Reference Counting ,并将其设置为 No ,然后再 Run 一次代码。你会看到打印日志是:exampleBlock is: NSStackBlock

如果 block 访问了外部局部变量,此时的 block 就是一个栈 block ,并且存储在栈区。由于栈区的释放是由系统控制,因此栈中的代码在作用域结束之后内存就会销毁,如果此时再调用 block 就会发生问题,( 注: 此代码运行在 MRC 下)如:

void (^simpleBlock)(void);
void callFunc() {
    int age = 10;
    simpleBlock = ^{
        NSLog(@"simpleBlock-----%d", age);
    };
}

int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        callFunc();
        simpleBlock();
        // Setup code that might create autoreleased objects goes here.
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

打印日志:simpleBlock--------41044160

# NSMallocBlock

当一个 NSStackBlock 类型 block 做 copy 操作后,就会将这个 block 从栈上复制到堆上,而堆上的这个 block 类型就是 NSMallocBlock 类型。在 ARC 环境下,编译器会根据情况,自动将 block 从栈上 copy 到堆上。具体会进行 copy 的情况有如下 4 种:

block 作为函数的返回值时; block 赋值给 __strong 指针,或者赋值给 block 类型的成员变量时; block 作为 Cocoa API 中方法名含有 usingBlock 的方法参数时; block 作为 GCD API 的方法参数时;

# 总结

MRC下block类型

类型 环境
_NSConcreteGlobalBlock 只访问了静态变量(包括全局静态变量和局部静态变量)和全局变量
_NSConcreteStackBlock 没访问静态变量和全局变量
_NSConcreteMallocBlock NSStackBlock调用了copy

ARC下block类型

类型 环境
_NSConcreteGlobalBlock 只访问了静态变量(包括全局静态变量和局部静态变量)和全局变量
_NSConcreteMallocBlock 没访问静态变量和全局变量
点击查看
 __weak typeof(self)weakSelf = self;

    static int a = 0;
    void (^block1)(void) =  ^{
        a = 1;
        b = 1; //b为全局变量

    };

    __block int c = 0;
    void (^block2)(void) =  ^{
        NSLog(@"age:%d", weakSelf.age);
        c = 1;
    };

    NSLog(@"block1.class = %@", [block1 class]);
    NSLog(@"block2.class = %@", [block2 class]);
    NSLog(@"block2 copy.class = %@", [[block2 copy] class]);

运行结果如下:
2020-11-14 22:45:54.457496+0800 BlockTestDemo[13178:426318] block1.class = __NSGlobalBlock__
2020-11-14 22:45:54.457616+0800 BlockTestDemo[13178:426318] block2.class = __NSStackBlock__
2020-11-14 22:45:54.457720+0800 BlockTestDemo[13178:426318] block2 copy.class = __NSMallocBlock__
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# block中关键字的使用

# __block 的作用

简单来说,__block 作用是允许 block 内部访问和修改外部变量,在 ARC 环境下还可以用来防止循环引用;

__block int age = 10;
void (^exampleBlock)(void) = ^{
    // block
    NSLog(@"1.age is: %d", age);
    age = 16;
    NSLog(@"2.age is: %d", age);
};
exampleBlock();
NSLog(@"3.age is: %d", age);
1
2
3
4
5
6
7
8
9

__block 主要用来解决 block 内部无法修改 auto 变量值的问题,为什么加上 __block 修饰之后,auto 变量值就能修改了呢?

这是因为,加上 __block 修饰之后,编译器会将 __block 变量包装成一个结构体 __Block_byref_age_0 ,结构体内部 *__forwarding 是指向自身的指针,并且结构体内部还存储着外部 auto 变量。

struct __Block_byref_val_0 {
    void *__isa; // isa指针
    __Block_byref_val_0 *__forwarding; 
    int __flags;
    int __size; // Block结构体大小
    int age; // 捕获到的变量
}
1
2
3
4
5
6
7

Screenshot-2023-08-22-at-20

从上图可以看到,如果 block 是在栈上,那么这个 __forwarding 指针就是指向它自己,当这个 block 从栈上复制到堆上后,栈上的 __forwarding 指针指向的是复制到堆上的 __block 结构体。堆上的 __block 结构体中的 __forwarding 指向的还是它自己,即 age->__forwarding 获取到堆上的 __block 结构体,age->__forwarding->age 会把堆上的 age 赋值为 16 。因此不管是栈上还是堆上的 __block 结构体,最终使用到的都是堆上的 __block 结构体里面的数据。

对于是否可以修改外部变量,我们可以主要集中于这个自加的操作,如果__forwarding永远指向自身那么直接通过i取到i对应的值就可以了为什么中间加一个__forwarding呢?

我们知道, ARC环境下,一旦Block赋值就会触发copy,__block修饰的变量也就会copy到堆上,Block的类型也就变成了__NSMallocBlock。

堆上的Block会持有对象。我们把Block通过copy到了堆上,堆上也会重新复制一份Block,并且该Block也会继续持有该__block修饰的对象。当Block释放的时候,__block修饰的对象因为没有被任何对象引用,也会被释放销毁

__forwarding指针这里的作用就是针对堆的Block,把原来__forwarding指针指向自己,换成指向_NSConcreteMallocBlock上复制之后的__block自己。然后堆上的变量的__forwarding再指向自己。这样不管__block怎么复制到堆上,还是在栈上,都可以通过(i->__forwarding->i)来访问到变量值。

block创建的时候是在栈上的,对block进行赋值操作之后会将block拷贝到堆上。同时也会将block中使用的对象拷贝到堆上。然后将栈上的__block修饰对象的__forwarding指针指向堆上的拷贝之后的对象。这样我们在block内部修改的时候虽然是修改堆上的对象的值,但是因为栈上的对象的__forwarding指针将堆和栈的对象链接起来。因此达到了修改的目的。

# __weak 的作用

简单来说是为了防止循环引用。

Screenshot-2023-08-22-at-20

self 本身会对 block 进行强引用,block 也会对 self 形成强引用,这样就会造成循环引用的问题。我们可以通过使用 __weak 打破循环,使 block 对象对 self 弱引用。

此时我们注意,由于 block 对 self 的引用为 weak 引用,因此有可能在执行 block 时,self 对象本身已经释放,那么我们如何保证 self 对象不在 block 内部释放呢?这就引出了下面__strong 的作用。

# __strong 的作用

简单来说,是防止 block 内部引用的外部 weak 变量被提前释放,进而在 block 内部无法获取 weak 变量以继续使用的情况;

__weak __typeof(self) weakSelf = self;
void (^exampleBlock)(void) = ^{
    __strong __typeof(weakSelf) strongSelf = weakSelf;
    [strongSelf exampleFunc];
};
1
2
3
4
5

这样就保证了在 block 作用域结束之前,block 内部都持有一个 strongSelf 对象可供使用。

但是,即便如此,依然有一个场景,就是执行 __strong __typeof(weakSelf) strongSelf = weakSelf; 之前,weakSelf 对象已经释放,这时如果给 self 对象发送消息,这没有问题,Objective-C 的消息发送机制允许我们给一个 nil 对象发送消息,这不会出现问题。但如果有额外的一些操作,比如说将 self 添加到数组,这时因为 self 为 nil,程序就会 Crash。

我们可以增加一层安全保护来解决这个问题,如:

__weak __typeof(self) weakSelf = self;
void (^exampleBlock)(void) = ^{
    __strong __typeof(weakSelf) strongSelf = weakSelf;
    if (strongSelf) {
        // Add operation here
    }
};
1
2
3
4
5
6
7

# 拓展知识

# 思考题

Block 内修改外部 NSMutableString 、NSMutableArray 、NSMutableDictionary 对象,是否需要添加 __block 修饰?

NSMutableArray *mutableArray = [[NSMutableArray alloc] init];
[mutableArray addObject:@"1"];
void (^exampleBlock)(void) = ^{
    // block
    [mutableArray addObject:@"2"];
};
exampleBlock();
NSLog(@"mutableArray: %@", mutableArray);
1
2
3
4
5
6
7
8

打印日志:mutableArray: ( 1, 2 )

答案是:不需要,因为在 block 内部,我们只是使用了对象 mutableArray 的内存地址,往其中添加内容。并没有修改其内存地址,因此不需要使用 __block 也可以正确执行。当我们只是使用局部变量的内存地址,而不是对其内存地址进行修改时,我们无需对其添加 __block ,如果添加了 __block 系统会自动创建相应的结构体,这种情况冗余且低效。

# Block 数据结构

Block 内部数据结构图如下:

Screenshot-2023-08-22-at-20

struct Block_descriptor {
    unsigned long int reserved;
    unsigned long int size;
    void (*copy)(void *dst, void *src);
    void (*dispose)(void *);
};

struct Block_layout {
    void *isa;
    int flags;
    int reserved; 
    void (*invoke)(void *, ...);
    struct Block_descriptor *descriptor;
    /* Imported variables. */
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Block_layout 结构体成员含义如下:

提示

isa: 指向所属类的指针,也就是 block 的类型

flags: 按 bit 位表示一些 block 的附加信息,比如判断 block 类型、判断 block 引用计数、判断 block 是否需要执行辅助函数等;

reserved: 保留变量;

invoke: block 函数指针,指向具体的 block 实现的函数调用地址,block 内部的执行代码都在这个函数中;

descriptor: 结构体 Block_descriptor,block 的附加描述信息,包含 copy/dispose 函数,block 的大小,保留变量;

variables: 因为 block 有闭包性,所以可以访问 block 外部的局部变量。这些 variables 就是复制到结构体中的外部局部变量或变量的地址;

Block_descriptor 结构体成员含义如下:

提示

reserved: 保留变量;

size: block 的大小;

copy: 函数用于捕获变量并持有引用;

dispose: 析构函数,用来释放捕获的资源;

# 总结

使用 Block 过程中需要我们关注的重点有 4 个:

block 的三种类型; block 避免引起循环引用; block 对 auto 变量的 copy 操作; __block、__weak、__strong 的作用;

# block面试题

# _block和__weak修饰符的区别?

  • __block不管是ARC还是MRC模式下都可以使用,可以修饰对象,还可以修饰基本数据类型。
  • __weak只能在ARC模式下使用,也只能修饰对象,不能修饰基本数据类型。
  • __block对象可以在block中被重新赋值,__weak不可以。

# block是否能修改外部变量的值

Block不允许修改外部变量的值,这里所说的外部变量的值,指的是栈中指针的内存地址。__block 所起到的作用就是只要观察到该变量被 block 所持有,就将“外部变量”在栈中的内存地址放到了堆中。进而在block内部也可以修改外部变量的值。

# Block在ARC跟MRC中的行为和用法有什么区别?

相同点

  • block的本质一样, 都是函数指针
  • 使用__block都可以解决在block中修改外部变量的问题

不同点

解决循环引用的方式不同 MRC中使用__block ARC中使用 __weak

block用什么属性修饰,为什么?

在MRC中, 定义Block属性时, 应该用copy修饰

在ARC中, 定义Block属性时, 系统会自动将其copy, 即复制到堆上. 但习惯上还是会用copy修饰

用copy修饰的原因

block创建时默认是创建在栈上的, 超过作用域后就会被销毁, 只有使用copy才会生成一个堆block, 在作用域外被访问

# block是类吗,有哪些类型?

block也算是个类,因为它有isa指针,block.isa的类型包括

_NSConcreteGlobalBlock 跟全局变量一样,设置在程序的数据区域(.data)中 _NSConcreteStackBlock栈上(前面讲的都是栈上的 block) _NSConcreteMallocBlock 堆上

这个isa可以按位运算

# 一个int变量被 __block 修饰与否的区别?block的变量截获

被__block 修饰与否的区别 用一段示例代码来解答这个问题吧:

__block int a = 10;
int b = 20;

PrintTwoIntBlock block = ^(){
    a -= 10;
    printf("%d, %d\n",a,b);
};

block();//0 20

a += 20;
b += 30;

printf("%d, %d\n",a,b);//20 50

block();/10 20
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

通过__block修饰int a,block体中对这个变量的引用是指针拷贝,它会作为block结构体构造参数传入到结构体中且复制这个变量的指针引用,从而达到可以修改变量的作用.

int b没有被__block修饰,block内部对b是值copy.所以在block内部修改b不影响外部b的变化.

# 什么时候栈上的Block会被复制到堆呢?

  • 调用block的copy函数时。
  • Block作为函数返回值返回时。
  • 将Block赋值给附有__strong修饰符id类型的类或者Block类型成员变量时。
  • 方法中含有usingBlock的Cocoa框架方法或者GCD的API中传递Block时。

什么时候Block被废弃呢?

堆上的Block被释放后,谁都不再持有Block时调用dispose函数。

# block在修改NSMutableArray,需不需要添加__block

  • 如修改NSMutableArray的存储内容的话,是不需要添加__block修饰的。

  • 如修改NSMutableArray对象的本身,那必须添加__block修饰。

# block怎么进行内存管理的?

在上面Block的构造函数__TestClass__testMethods_block_impl_0中的isa指针指向的是&_NSConcreteStackBlock,它表示当前的Block位于栈区中.

block内存操作 存储域/存储位置 copy操作的影响
_NSConcreteGlobalBlock 程序的数据区域 什么也不做
_NSConcreteStackBlock 栈 从栈拷贝到堆
_NSConcreteMallocBlock 堆 引用计数增加
  • 全局Block:_NSConcreteGlobalBlock的结构体实例设置在程序的数据存储区,所以可以在程序的任意位置通过指针来访问,它的产生条件:

    • 记述全局变量的地方有block语法时.

    • block不截获的自动变量.

      以上两个条件只要满足一个就可以产生全局Block. 参考

  • 栈Block:

_NSConcreteStackBlock在生成Block以后,如果这个Block不是全局Block,那它就是栈Block,生命周期在其所属的变量作用域内.(也就是说如果销毁取决于所属的变量作用域).如果Block变量和__block变量复制到了堆上以后,则不再会受到变量作用域结束的影响了,因为它变成了

  • 堆Block.

_NSConcreteMallocBlock将栈block复制到堆以后,block结构体的isa成员变量变成了_NSConcreteMallocBlock。

# block可以用strong修饰吗?

在ARC中可以,因为在ARC环境中的block只能在堆内存或全局内存中,因此不涉及到从栈拷贝到堆中的操作.

在MRC中不行,因为要有拷贝过程.如果执行copy用strong的话会crash, strong是ARC中引入的关键字.如果使用retain相当于忽视了block的copy过程.

# 解决循环引用时为什么要用__strong、__weak修饰?

首先因为block捕获变量的时候 结构体构造时传入了self,造成了默认的引用关系,所以一般在block外部对操作对象会加上__weak,在Block内部使用__strong修饰符的对象类型的自动变量,那么当Block从栈复制到堆的时候,该对象就会被Block所持有,但是持有的是我们上面加了__weak所以行程了比消此长的链条,刚好能解决block延迟销毁的时候对外部对象生命周期造成的影响.如果不这样做很容易造成循环引用.

# block发生copy时机?

在ARC中,编译器将创建在栈中的block会自动拷贝到堆内存中,而block作为方法或函数的参数传递时,编译器不会做copy操作.

  • 调用block的copy函数时。
  • Block作为函数返回值返回时。
  • 将Block赋值给附有__strong修饰符id类型的类或者Block类型成员变量时。
  • 方法中含有usingBlock的Cocoa框架方法或者GCD的API中传递Block时。

# Block访问对象类型的auto变量时,在ARC和MRC下有什么区别?

ARC下会对这个对象强引用,MRC下不会

# block 的变量捕获机制

block 的变量捕获机制,是为了保证 block 内部能够正常访问外部的变量。

变量类型 是否捕获到 block 内部 访问方式
全局变量 否 直接访问
局部变量(auto 类型) 是 值传递
局部变量(static 类型) 是 指针传递

对于全局变量,不会捕获到 block 内部,访问方式为直接访问。作用域的原因,全局变量哪里都可以直接访问,所以不用捕获。而对于局部变量,外部不能直接访问,所以需要捕获。下面我们来看一下 block 对于局部变量的具体捕获机制。

# auto 类型的局部变量

auto 类型的局部变量(我们定义出来的变量,默认都是 auto 类型,只是省略了),block 内部会自动生成一个同类型成员变量,用来存储这个变量的值,访问方式为值传递。auto 类型的局部变量可能会销毁,其内存会消失,block 将来执行代码的时候不可能再去访问那块内存,所以捕获其值。由于是值传递,我们修改 block 外部被捕获变量的值,不会影响到 block 内部捕获的变量值。

//局部变量截获 是值截获
NSInteger num = 3;
NSInteger(^block)(NSInteger) = ^NSInteger(NSInteger n){
    return n*num;
};
num = 1;
NSLog(@"%zd",block(2));
//这里的输出是6而不是2,原因就是对局部变量num的截获是值截获。
//同样,在block里如果修改变量num,也是无效的,甚至编译器会报错
NSMutableArray * arr = [NSMutableArray arrayWithObjects:@"1",@"2", nil];
void(^block)(void) = ^{
    NSLog(@"%@",arr);//局部变量
    [arr addObject:@"4"];
};
[arr addObject:@"3"];
arr = nil;
block();
打印为1,2,3
局部对象变量也是一样,截获的是值,而不是指针,在外部将其置为nil,对block没有影响,而该对象调用方法会影响
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# static 类型的局部变量

static 类型的局部变量,block 内部会自动生成一个同类型成员变量,用来存储这个变量的地址,访问方式为指针传递。static 变量会一直保存在内存中, 所以捕获其地址即可。相反,由于是指针传递,我们修改 block 外部被捕获变量的值,会影响到 block 内部捕获的变量值。

//局部静态变量截获 是指针截获。
static  NSInteger num = 3;
NSInteger(^block)(NSInteger) = ^NSInteger(NSInteger n){
return n*num;
};
num = 1;
NSLog(@"%zd",block(2));
输出为2,意味着num = 1这里的修改num值是有效的,即是指针截获。
同样,在block里去修改变量m,也是有效的。
1
2
3
4
5
6
7
8
9
//全局变量,静态全局变量截获:不截获,直接取值。
/我们同样用clang编译看下结果。
static NSInteger num3 = 300;
NSInteger num4 = 3000;
- (void)blockTest
{
    NSInteger num = 30;
    static NSInteger num2 = 3;
    __block NSInteger num5 = 30000;
void(^block)(void) = ^{
    NSLog(@"%zd",num);//局部变量
    NSLog(@"%zd",num2);//静态变量
    NSLog(@"%zd",num3);//全局变量
    NSLog(@"%zd",num4);//全局静态变量
    NSLog(@"%zd",num5);//__block修饰变量
};
block();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 对象类型的局部变量

对于对象类型的局部变量,block 会连同它的所有权修饰符一起捕获。

  • 如果 block 是在栈上,将不会对对象产生强引用
  • 如果 block 被拷贝到堆上,将会调用 block 内部的 copy(__funcName_block_copy_num)函数,copy 函数内部又会调用 assign(_Block_object_assign)函数,assign 函数将会根据变量的所有权修饰符做出相应的操作,形成强引用(retain)或者弱引用。
  • 如果 block 从堆上移除,也就是被释放的时候,会调用 block 内部的 dispose(_Block_object_dispose)函数,dispose 函数会自动释放引用的变量(release)。

总结1:

MRC 中block 没有引用外部变量, block为 NSGlobalBlock 类型,存储在全局数据区.
MRC 中block 引用外部变量,block为NSStackBlock 类型,存储在栈内存中.
所以, 在block所属的栈作用域外使用block时, 需要将调用copy方法将该block存储在堆区.

总结2:

ARC 中 没有引用外部变量, block为 NSGlobalBlock 类型,存储在全局数据区.
ARC 中 引用外部变量, block为 autoreleased NSMallocBlock 类型,存储在堆内存中.

编辑 (opens new window)
上次更新: 2024/10/23, 23:26:17
内存管理自我总结
线程系列

← 内存管理自我总结 线程系列→

最近更新
01
数组
10-25
02
数组双指针系列之对撞指针
10-25
03
数组双指针系列之快慢指针
10-25
更多文章>
Theme by Vdoing | Copyright © 2019-2024 Evan Xu | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式