【iOS 客户端专场 学习资料二】第四届字节跳动青训营

866 阅读29分钟

第四届字节跳动青训营讲师非常用心给大家整理了课前、中、后的学习内容,同学们自我评估,选择性查漏补缺,便于大家更好的跟上讲师们的节奏,祝大家学习愉快,多多提问交流~

第四节:Objective-C 内存管理

课前准备

@required:

请准备好一个可以运行Xcode工程

@optional

准备好几个Class

Person.h

Friend.h

Dog.h

Part 1. Introduction

1.1 Stack & Heap

Stack 栈

  • First In Last Out (FILO)
  • Stack Pointer:

    • 指向栈顶 Point at the top of the stack;
    • 一般存储在寄存器 Uses a register to store.
  • 由高地址向低地址扩展
  • 栈一般用于存储参数/局部变量/函数调用后返回地址

Heap 堆

  • 与Heap数据结构无关
  • 手动分配
  • 向高地址扩展
  • 存储数组和对象, 线程共享

\

1.2 C 内存管理

用 malloc & free 动态分配内存

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void stackFunc() {
        // they are all in stack
        int b = 10; 
        char s[] = "bb";
        printf("the b is %d\n", b);

}

void heapFunc() {
        // j is in heap
        int* g = malloc(sizeof(int));
        printf("the g is %d\n", *g);
        free(g);


}

int main() {
    stackFunc();
    heapFunc();
 
}

\

1.3 C++ 内存管理

new & delete

  • malloc/free只是动态分配内存空间/释放空间。

new/delete 还会调用构造函数和析构函数进行初始化与清理

  • malloc/free需要手动计算类型大小

new/delete可自动计算类型的大小

  • malloc/free管理内存失败会返回0,

new/delete等的方式管理内存失败会抛出异常。

void useRawPointer()
{
    // Using a raw pointer -- not recommended.
    std::string *pSong = new std::string("Nothing on You");
    int *p = new int[10];
    
    std::cout << *pSong << std::endl;

    // Don't forget to delete!
    delete pSong;
    delete[] p;
}

Smart Pointer 智能指针

  • 保留 -> 和 * 运算符
  • auto_pt & unique_ptr

    • 基于排他所有权模式:两个指针不能指向同一个资源
    • 无法进行左值unique_ptr复制构造,也无法进行左值复制赋值操作,但允许临时右值赋值构造和赋值

  • shared_ptr

    • 可以复制赋值操作

      • 对象+引用计数
    • \

Reference Counting 引用计数

  • 记录了当前内存资源到底有多少指针在引用(可以访问这个资源)
  • 当新增加一个可以访问这个资源的引用,计数器会加1(e.g. Copy),反之会减去1
  • 当引用计数= 0时, 对象会被销毁

Part 2. iOS内存管理

iOS 内存管理的核心是管理「(强)引用计数 (Reference Counting)」

在当前代码中,我们基本只用考虑 ARC

Objective C内存管理

  • 我们需要管理什么?

    • 继承 NSObject 对象会分配在堆里面
    • 任何继承 NSObject 对象都需要进行内存管理
  • 为什么要管理内存?

    • 多个对象之间相互强引用,导致不能释放,无法让系统回收
    • 如果一个程序占用内存过多,系统可能会强制关闭程序,造成crash
    • 如果提前释放指针,会导致野指针,也会造成crash

  • 术语

    • 持有 = retain = 引用计数 +1

    • 释放 = release = 引用计数 -1

    • \

    •   MRC Manual Reference Counting

    • 在OC中,使用引用计数来进行内存管理。每个对象都有一个与其相对应的引用计数器,当持有一个对象,这个对象的引用计数就会递增;当这个对象的某个持有被释放,这个对象的引用计数就会递减。当这个对象的引用计数变为0,那么这个对象就会被系统回收。

    • \

    • “alloc”, “new”, “copy”, or “mutableCopy” 开头的方法创建的对象,引用计数 = 1

      •     // 使用了alloc分配了内存,obj指向了对象,该对象本身引用计数为1,不需要retain 
            id obj = [[NSObject alloc] init]; 
            // 使用了new分配了内存,objc指向了对象,该对象本身引用计数为1,不需要retain 
            id obj = [NSObject new];
            
            
        
    • 对一个对象发送 retain 消息,可以让对象的引用计数 +1,

    • 对一个对象发送 release 消息,可以让对象的引用计数 -1。

    • 当对象的引用计数为 0 时,即将销毁的时候,系统会向对象发送一条dealloc消息 【不能直接调用 dealloc】。

//NSMutableArray通过类方法array产生了对象(并没有使用alloc、new、copy、mutableCopt来产生对象),因此该对象不属于obj自身产生的
// 因此,需要使用retain方法让对象计数器+1,从而obj可以持有该对象(尽管该对象不是他产生的)
id obj1 = [NSMutableArray array];
[obj1 retain];
//NSMutableArray通过类方法array产生了对象(并没有使用alloc、new、copy、mutableCopt来产生对象),因此该对象不属于obj自身产生的
// 因此,需要使用retain方法让对象计数器+1,从而obj可以持有该对象(尽管该对象不是他产生的)
id obj1 = [NSMutableArray array];
[obj1 retain];

// 释放一个不属于自己的对象
id obj = [NSMutableArray array];
    
//obj没有进行retain操作而进行release操作,然后autoreleasePool也会对其进行一次release操作,导致奔溃。
 [obj release];

Autorelease-Pool

  • 对象会放到自动释放池,统一释放
  • autorelease和release的区别是:

    •   release是马上释放对某个对象的强引用;
    •   autorelease是延迟释放某个对象。
  • 在部分场景下,使用Autorelease pool可以降低内存峰值

\

ARC ****Automatic Reference Counting 【这里是课程重点】

  • retain/release都不用考虑

    • 只需要初始化的时候 [NSObject alloc] init]
    • 可以自定义 dealloc
  • 只要有一个强指针在内存指向对象,对象就不能释放

    • ARC 销毁时机是强引用的个数 = 0
    • 而不是引用计数 = 0
  • 默认所有对象变量的指针都是强指针

    • 弱指针需显式声明
    • __weak Person *p2 = [Person new];
      

Part 3. Practical Memory Management

3.1 属性

@property

@property(属性):属性不仅是类的成员变量,还生成了属性的 setter getter方法。

@property … Dog * dog;

||

//Getter
- (Dog *)dog;
//Setter
- (void)setDog:(Dog *)dog;

\

@property 修饰符

strong: 拥有属性对象

  • 强持有
  • keep this in the heap until I(Person) don't point to it anymore

只有 OC 对象才能使用该属性

weak: 不拥有属性对象

  • 弱持有
  • keep this in the heap as long as someone else points to it strongly

只有 OC 对象才能使用该属性

  • 和strong区别:setter内部会调用copy方法

\

\

\

assign: 指定 setter 使用简单赋值

  • 一般用于simple types, e.g. 基本类型/NSInteger/CGRect
  • default 修饰符

\

Code Example

#import <Foundation/Foundation.h>
#import "Dog.h"
NS_ASSUME_NONNULL_BEGIN

@protocol PersonDelegate <NSObject>

- (void)walkDog;

@end 
@interface Person : NSObject

@property (nonatomic, strong) Dog *dog;
@property (nonatomic, copy) NSString *name;
@property (nonatomic, weak) id<PersonDelegate> delegate;
@property (nonatomic, assign) int age;

 // [object new]  vs [[object alloc] init]  后者可以定制init方法
-  ( instancetype )initWithName:( NSString *)name; 

@end

通过property持有

\

3.2 循环引用

Delegate 代理模式

@protocol协议:定义了方法,任何对象都可以继承协议实现方法。这个方法可以有各种各种的实现

对于Person来说 拥有id (weak) ,他不关心delegate是什么具体类;

  • 他只知道delegate 继承了协议,所以他知道delegate实现了遛狗的方法
  • 因此他就能委托deleagte调用方法,也就是委托代理去遛狗

    •  @protocol PersonDelegate <NSObject>
      
      - (void)walkDog;
      
      @end
      
      @interface Person : NSObject
      ...
      @property (nonatomic, weak) id<PersonDelegate> delegate;
      ...
      @end
      

Weak delegate:

    Person *person1 = [[Person alloc] init];
    Dog * dog1 = [Dog new];
    Friend *f1 = [Friend new];

    person1.dog = dog1;
    person1.delegate = f1;
    f1.myFriend = person1;

那如果strongDelegate会发生什么情况?

 @interface Person : NSObject
...
@property (nonatomic, weak) id<PersonDelegate> delegate;

//forbidden!!!!
@property (nonatomic, strong) id<PersonDelegate> delegate;
...
@ end

\

Block:下节课重点

    __ weak __ typeof(self) weakSelf = self;
    self.blockLeak = ^{
        // ... some code
         __ strong __ typeof(weakSelf) strongSelf = weakSelf;
        strongSelf.label.text = @"EnterBlock";
     };

\

3.3 浅拷贝 v.s. 深拷贝 可变 v.s. 不可变

定义

可变不可变

深浅拷贝 Shallow/Deep Copy

\

两种类两种行为

不可变对象

  • copy
  • mutableCopy

可变对象

  • copy
  • mutableCopy

NSString 不可变对象


NSString *stringA = @"stringA";
NSLog(@"本身string的地址:%p",stringA);

NSString *stringB = [stringA copy];
NSLog(@"[NSString copy]的地址:%p",stringB);›

NSString *stringB = [stringA mutableCopy];
NSLog(@"[NSString mutableCopy]的地址:%p",stringB);›

\

NSMutableString *stringM = [[NSMutableString alloc] initWithString:@"stringM"];
NSLog(@"本身mutablString的地址:%@",stringM);

NSString *stringB = [stringM copy];
NSLog(@"[stringM copy]的地址:%p",stringB);

NSMutableString *stringC = [stringM mutableCopy];
NSLog(@"[stringM mutableCopy]的地址:%p",stringC);

传参数时,property的copy/strong

  • 都是浅拷贝
  • 本身stringA是不可变,所以赋值以后不会改变,因此没必要做deep copy

NSMutableString 可变对象

Copy v.s. mutableCopy 总结

不可变对象

  • copy 【深拷贝】
  • mutableCopy【深拷贝】

可变对象

  • copy 【深拷贝】
  • mutableCopy 【深拷贝】

1. 代码中,深拷贝一般是在转换immutable<=>mutable的时候用到

比如 [NSString mutableCopy] 会得到深拷贝 string

并且被转换成mutable了

[NSMutableString copy] 会得到深拷贝 string

并且被转换成immutable了

2. mutableCopy 总是深拷贝,会生成新的可变对象

NSString - Property Attributes: copy v.s. strong

NSString *stringA = @"stringA";
NSLog(@"本身string的地址:%p",stringA);
Person *person1 = [[Person alloc] init];

person1.name = stringA;
NSLog(@"copy string的地址:%p",person1.name);
person1.strongName = stringA;
NSLog(@"strong string的地址:%p",person1.strongName);
 @interface Person : NSObject

@property (nonatomic, copy) NSString *name;
//替换copy修饰符为strong修饰符
@property (nonatomic, strong) NSString *strongName;

@end

  • property的copy/strong

    • copy : 对 stringA 进行copy <-[stringA copy] <-浅拷贝
    • strong : 持有stringA

NSMutableString - Property Attributes: copy v.s. strong

可变对象可以直接赋值给不可变对象属性

NSMutableString *stringM = [[NSMutableString alloc] initWithString:@"stringM"];
NSLog(@"本身mutablString的地址:%@",stringM);
Person *person1 = [[Person alloc] init];

person1.name = stringM;
NSLog(@"copy string的地址:%p",person1.name);
person1.strongName = stringM;
NSLog(@"strong string的地址:%p",person1.strongName);

  • property的copy/strong

    • copy : 对 stringM 进行copy <-[stringA copy] <-深拷贝
    • strong : 持有stringM

属性对象与可变/不可变对象总结

内存问题引起 Crash

野指针

  • 当所指向的对象被提前释放或者收回,但是对该指针没有作任何的修改,以至于该指针仍旧指向已经回收的内存地址,

\

  • 若操作系统将这部分已经释放的内存重新分配给另外一个进程,

\

  • 而原来的程序重新引用现在的野指针,则将产生无法预料的后果。
  • 因为此时野指针所指向的内存现在包含的已经完全是不同的数据。

\

OOM

OOM 其实是Out Of Memory的简称,

指的是在 iOS 设备上当前应用因为内存占用过高

而被操作系统强制终止

iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+_开源_字节跳动技术团队_InfoQ精选文章

\

课后练习

  1. 试着声明一个strong属性的property 并修改值
  1. 自己实现一个拥有可变不可变特性的对象

第五节:闭包与 Objective-C block

课程大纲

image.png

闭包基础

闭包的基本定义

首先,看下维基百科对于计算机科学中闭包的定义:

在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。捕捉时对于值的处理可以是值拷贝,也可以是名称引用,这通常由语言设计者决定,也可能由用户自行指定

这里面有几个概念简单解释下。支持头等函数的编程语言,这里指的是“以函数为头等公民的编程语言”,具体到实现就是函数既可以成为函数的入参/返回值,也可以当做变量使用的编程语言;词法绑定,这里可以理解为是将一系列符号与对应值的对应关系绑定到指定函数上。

简单整理下这段文字的内容

image.png

闭包本身是一个结构体,存储了函数入口地址和与之绑定的一系列环境,环境里面既有其本身内部定义的变量,又包含从外部捕获的变量,捕获变量的处理因实现而异。

闭包在各个语言中的表现形式

大家可以参照自己熟悉的语言简单理解下闭包

C++

C++中闭包的表现形式就是lambda表达式,C++11中正式添加了对其的支持。

#include <algorithm>
#include <cmath>

void abssort(float* x, unsigned n) {
    std::sort(x, x + n,
        // Lambda expression begins
        [](float a, float b) {
            return (std::abs(a) < std::abs(b));
        } // end of lambda expression
    );
}

这是微软官方文档中的一段代码,实现了一个按照绝对值大小排序的功能,这里通过传入lambda表达式实现了定制排序算法的目的。检查下闭包的要素,有函数的入口地址,有约束变量(函数的入参),无自由变量

Python

python中,闭包是以内部函数的形式存在的

def make_multiplier_of(n):
  def multiplier(x):
    return x * n;
  return multiplier;

可以看到在外层make_multiplier_of函数内部定义了一个multiplier函数,它接收外部传入的参数n并持有;当外部函数传入值为π时生成计算指定直径圆周长的函数,当外部函数传入值为4时,则可以计算指定边长的正方形周长。同样,它也没有捕获自由变量

Objective-C

Objective-C中闭包的实现就是我们今天的主角block了

#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
  @autoreleasepool {
    int i = 1024;
    void (^printBlock)(void) = ^{
      printf("%d\n", i);
    };
    printBlock();
  }
  return 0;
}

关于block的标准形式我们会在后面展开讲解,这里简单了解即可。printBlock捕获了一个定义在block外,非入参的局部变量,这个就是我们之前心心念念的自由变量了,最终将对应自由变量的值打印出来。

大家可以参照自己熟悉的语言,简单理解下闭包。整体上闭包和函数很相似,但是在某些场景下会比函数应用起来更灵活和方便。

Block基础

基础概念

  • block是闭包在Objective-C中的实现
  • block可以接受参数也可以有返回值
  • block可以分配在栈和堆上,也可以是全局的。分配到栈上的块可以拷贝到堆中,同标准的Objective-C对象一样,具备引用计数(这里大家可以简单回顾下上节课内容,Objective-C中是如何利用引用计数实现ARC的,会有关联的内容)

标准格式

// block声明
returnType (^blockName)(parameters);
// block赋值
^returnType(parameters) {
  // do something;
};
// 示例
int (^sumBlock)(int a, int b) = ^int(int a, int b) {
  return a + b;
};
int sum = sumBlock(1, 1);

block在声明时有几个组成部分,返回值,block的名称以及block传入的参数;而在给block赋值时,没有block名称这一部分,在对应的位置用返回值代替。

下面是一个求和block的例子,它有两个int型的入参以及一个int型的返回值。

常用简写

省略写法

// 有返回值有入参
int (^sumBlock)(int, int) = ^(int a, int b) {
    return a + b;
};
// 无返回值无入参
void (^printBlock)(void) = ^{
    NSLog(@"Hello World!");
};

对于block的声明来说,唯一可以省略的就是非空入参的变量名(聊胜于无)。而block的赋值无论是否有返回值,都可以将其省略,编译器会自动根据左侧声明检查对应的返回值是否满足要求,当函数没有入参时,还可以进一步简化为^{}的形式。

typedef

typedef returnType (^blockName)(parameters);

typedef int (^SumBlock)(int a, int b);
SumBlock block = ^(int a, int b) {
    return a + b;
};

面对过于冗长的声明,我们可以通过给常用block声明定义别名的形式来简化,只需要在block声明的左边加上关键字typedef即可。需要注意的是,由于我们这里相当于声明了一种类型,所以blockName在typedef时的命名一般与类的命名相同,首字母大写。

以上,就是闭包与block的基础内容

  • 大家再简单回忆下,是否了解了闭包的概念,它有哪些组成部分?
  • block长什么样子,如何书写。可以尝试着不参照例子,自己写个简单的实现两数相减的block
  • block如何简化声明和赋值

Block内存管理

基础分类

类型描述环境
NSGlobalBlock全局Block,保存在数据区(.data段)定义在全局区或者没有访问自动局部变量
NSStackBlock栈Block,保存在栈区访问了自动局部变量
NSMallocBlock堆Block, 保存在堆区__NSStackBlock__调用了copy

Objective-C中block大致分为表中3类,分别存储在不同的内存区域中:从表中可以看出对应内存区域block的产生条件,下面可以看下实际的代码

int main(int argc, char * argv[]) {
    @autoreleasepool {
        // __NSGlobalBlock__
        void(^globalBlock)(void) = ^{
            NSLog(@"Hello, World!");
        };
        NSLog(@"%@", [globalBlock class]);

        // __NSStackBlock__
        int age = 18;
        void(^stackBlock)(void) = ^{
            NSLog(@"Hello, World! %d", age);
        };
        NSLog(@"%@", [stackBlock class]);

        // __NSMallocBlock__
        void(^mallocBlock)(void) = [stackBlock copy];

        NSLog(@"%@", [mallocBlock class]);
    }
    return 0;
}

第4行声明的globalBlock因为没有访问任何自动变量,会被存储在.data段,所以是一个 NSGlobalBlock ;第11行声明的stackBlock引用了临时变量age,所以会被存储在栈上,是一个

NSStackBlock;第17行的mallocBlock因为手动调用了copy方法,所以被存储在了堆上,是一个__NSMallocBlock__。

实际打印看一下

第二个block预期是__NSStackBlock__,这里为什么打印出来的是__NSMallocBlock__?

Xcode 4.2之后,引入了ARC机制,在一些默认的情况下系统会帮你自动调用copy操作:

  • block作为函数返回值
  • 将block赋值给strong指针
  • block作为某些系统方法参数

系统的整体思路是如果block被释放,有潜在的异常风险时,手动”帮你“copy下

变量捕获

- (void)changeValue {
    int value = 1;

    void (^oneBlock)(void) = ^{
        NSLog(@"value = %d", value); // value1:?
    };

    value = 2;
    oneBlock();
    NSLog(@"value = %d", value); // value2:?
}

大家可以停在这里思考下,value1和value2打印出来的值分别是多少?

暂时无法在飞书文档外展示此内容

这里是由于oneBlock在初始化的时候,按值捕获了value,而后,当value更改后,block内捕获的值并没有跟着一起变化;而value2处则是使用的改变后的变量。

如果想要修改传入的值并生效,我该怎么办呢?

- (void)changeValue {
    __block int value = 1;

    void (^oneBlock)(void) = ^{
        NSLog(@"value = %d", value); // value1:? 
        value = 3;
    };

    value = 2;
    oneBlock();
    NSLog(@"value = %d", value); // value2:?
}

上面的代码块和之前的很像,区别在于传入的变量前增加了一个关键字__block,这个关键字标志了该变量允许在block中被修改,那我们再来思考下,这时value1和value2分别会打印什么?

暂时无法在飞书文档外展示此内容

和之前不同,这里block内部对于value的捕获是引用捕获,也就是说block中的value就是外部的value对象,因此对应值的改变也会同步,内外互相影响

拓展一下,以下的代码输出是?

- (void)changeValue {
    NSString *value = @"1";
    void (^oneBlock)(void) = ^{
        NSLog(@"value = %@", value); // value1
    }
    value = @"2";
    oneBlock();
    NSLog(@"value = %@", value); // value2
}

暂时无法在飞书文档外展示此内容

这里很好理解,取了之前的指针的“值”,当value被重新赋值时,block捕获的是上一个指针值

那这里呢,是否和上面是一样的情况?


@property (nonatomic, copy) NSString *name;

- (void)changeValue {
    self.name = @"1";
    void (^oneBlock)(void) = ^{
        NSLog(@"value = %@", self.name); // value1
    }
    self.name = @"2";
    oneBlock();
    NSLog(@"value = %@", self.name); // value2
}

暂时无法在飞书文档外展示此内容

这里不做过多引申,后面大家有机会接触Objective-C的时候会有更深的了解。

首先,oc是一门动态语言,所有方法都是在运行时通过发送消息objc_msgSend实现的,这里block其实只捕获了self对象,至于name,不是取的它的属性,而是向其发送了一个消息,告诉他我想要你的name属性,这里可以通过llvm将oc代码转化为C++代码会看的比较清晰。因此,self指针没变,在block向self发消息时,它就能正确的找到变化后的值。

Block的循环引用

在上一节课中,大家学习了MRC和ARC,也了解了常见的内存泄漏,那么这里讲解下由于block带来的内存泄漏。

// ViewController.m

#import "ViewController.h"

@interface ViewController ()

@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) void (^completionBlock)(void);

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.completionBlock = ^{
        NSLog(@"%@", self.name);
    };
}

@end

Xcode warning:Capturing 'self' strongly in this block is likely to lead to a retain cycle!

之前提到过,在ARC下,系统会在某些场景默认对block执行copy操作,使其变为__NSMallocBlock__,此时block也会有自己的内存引用计数。因此,在上文代码中,VC和Block产生了循环引用,导致了内存泄漏

循环引用的常规解决方案就是打破引用环,使用weak,回忆下上节课的内容,想下可以怎么解决这个问题;有了想法的同学可以和下面的代码对下,检查下自己是否掌握了内存管理的相关知识

// ViewController.m

#import "ViewController.h"

@interface ViewController ()

@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) void (^completionBlock)(void);

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    __weak typeof(self) weakSelf = self;
    self.completionBlock = ^{
        __strong typeof(weakSelf) strongSelf = weakSelf;
        NSLog(@"%@", strongSelf.name);
    };
}

@end
  • 循环引用问题解决了吗?如何解决的?
  • block内为什么不直接应用weakSelf?
  • block内self一定要weak一下吗?

暂时无法在飞书文档外展示此内容

还有一种比较隐式的内存泄漏

// ViewController.m

#import "ViewController.h"

@interface ViewController ()

@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) void (^completionBlock)(void);

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.completionBlock = ^{
        NSLog(@"%@", _name);
    };
}

@end

block内虽然没有直接引用self,但是_name表示viewController的变量,要找到它就需要持有self,因此形成了隐式的内存泄漏。

Block的应用

数组遍历

// block作为入参
[object doSomethingWithBlock:^returnType (varType varName) {
    // do something
}];
// 获取数组中大于2的数字的个数
__block NSInteger count = 0;
NSArray<NSNumber *> *array = @[@0, @1, @2, @3, @4, @5];
NSRange range = NSMakeRange(0, array.count);
[array enumerateObjectsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:range] options:NSEnumerationReverse usingBlock:^(NSNumber * _Nonnull number, NSUInteger idx, BOOL * _Nonnull stop) {
    if (number.integerValue >= 2) {
        ++count;
    }
}];
NSLog(@"%ld", count); // count:4

首先,简单回顾下block作为入参的时候该如何书写;后面再看下Objective-C中是如何做数组遍历操作的,NSArray支持特定的遍历操作,该方法支持传入指定遍历的位置,遍历顺序,以及对每个元素执行的操作。相比于for-in循环,这里将常见的操作进行了封装,代码可读性高一些;option可以选择逆序和并发,在对每个数组元素执行耗时操作时,NSEnumerationConcurrent耗时有所优化

网络请求中数据传递

// AFHTTPSessionManager.h

- (nullable NSURLSessionDataTask *)GET:(NSString *)URLString
                            parameters:(nullable id)parameters
                              progress:(nullable void (^)(NSProgress *downloadProgress)) downloadProgress
                               success:(nullable void (^)(NSURLSessionDataTask *task, id _Nullable responseObject))success
                               failure:(nullable void (^)(NSURLSessionDataTask * _Nullable task, NSError *error))failure;

上图是iOS常用网络库AFNetWorking中的一个接口,它传入url、网络参数以及进度、成功和失败的回调block,这三个block就是主要负责数据传递的。

AFHTTPSessionManager *manager = AFHTTPSessionManager.manager;
    manager.responseSerializer = [AFHTTPResponseSerializer serializer];
manager.responseSerializer.acceptableContentTypes = [NSSet setWithObjects:@"image/jpeg", nil];

__weak typeof(self) weakSelf = self;
NSString *url = @"<http://www.pptbz.com/pptpic/UploadFiles_6909/201203/2012031220134655.jpg>";

// 开始下载
[manager GET:url parameters:nil progress:^(NSProgress *downloadProgress) {
    NSLog(@"progress:%lld",downloadProgress.completedUnitCount);
} success:^(NSURLSessionDataTask *task, id responseObject) {
    NSLog(@"图片下载完成");
    __strong typeof(weakSelf) strongSelf = weakSelf;
    strongSelf.imgView.image = [UIImage imageWithData:(NSData *)responseObject];
} failure:^(NSURLSessionDataTask *task, NSError *error) {
    if (error) {
        NSLog(@"%@",error.userInfo);
    }
}];

上面是一段通过对应接口下载图片的函数,可以看到对应代码逻辑集中在了一块,可读性高。如果换用Objective-C中的其它方式,比如delegate,那这里就需要在调用的地方实现一大堆扰人的delegate方法

实现链式调用

主要还是为了简化代码,提升可读性。假设我们想要实现简单的四则运算,那么下面两种方式,后者可读性明显更高一些。这里因为是演示代码,比较简单,日常调用时会比这个场景更复杂,非链式调用的冗余代码会更多。

看下链式调用,名称加括号的调用方式非常类似于block的调用,那么我们唯一需要考虑的就是如何在调用一个block后能够继续调用下一个。

// 常规调用
float a = 0;
a = a + 2;
a = a - 4;
a = a * 5;
a = a / 3;
// 优化调用
Calculator *calculator = Calculator.new;
calculator.add(2).subtract(4).multiply(5).devideBy(3);
NSLog(@"%f", calculator.result);

下面简单实现了add操作,其余操作也是相同的代码逻辑。整体通过声明了一个返回一个对象本身的block来实现,链式调用就是self不停地调用block

// Calculator.h
@interface Calculator : NSObject

@property (nonatomic) CGFloat result;

- (Calculator *(^)(CGFloat addend))add;

@end
// Calculator.m
#import "Calculator.h"

@implementation Calculator

- (Calculator *(^)(CGFloat addend))add {
    return ^(CGFloat addend) {
        self.result += addend;
        return self;
    };
}

@end

实现异步操作

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.7 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    // do something
});

这里只简单说明下应用,后面关于GCD的详细内容,在下节课会有详细的讲解。

推荐阅读

用大白话🙌带你掌握闭包 - 掘金

OC Block - 掘金

第六节:Objective-C 多线程编程与 GCD

课程大纲

image.png

多线程基础概念

进程与线程

进程:程序运行的一个实例,是资源分配的最小单位,独立地运行在其专用受保护的内存空间内

线程:一个进程可以包含多个线程,线程是操作系统实施调度的最小单位

每个线程之间共享进程的内存空间(代码段、数据段、堆等)及一些进程级的资源(如打开文件等)

每个线程有寄存器、栈、线程局部存储(TLS)等私有数据

串行、并行、并发

串行:任务有序地一个一个地执行,前一个任务执行完之后才能执行下一个任务

并行:多个任务在同一时刻被执行

并发:多个任务在同一时间段内需要被执行,侧重点是这个现象的“发生”

以核酸检测队伍为例,如何理解这些概念?

多线程编程又称为并发编程,让多个 CPU 并发处理多个线程的指令,从而提高程序的执行效率

线程生命周期和线程调度

线程 的生命周期:包括新建、就绪、运行、阻塞、死亡五种状态

线程 调度:当在单个处理器上运行多个线程,操作系统会让这些线程轮流执行一小段时间,宏观上

“看起来”这些线程是同时执行的。不断地在处理器上切换不同线程的行为称为线程调度

RunLoop 和主线程

RunLoop 是事件接收和分发机制的一个实现,可以让线程在适当的时间处理任务不会退出。iOS App中,主线程的 RunLoop 在程序运行时就会启动,其他线程的 RunLoop 需要手动开启。RunLoop 的基本作用有:保持程序持续运行、处理程序中的各种事件、节省 CPU 资源提高程序性能,在必要的时候线程会被唤醒进行工作,否则会进入休眠,休眠时不占用 CPU。

主线程: iOS App 启动时默认会开启一条线程,称为“主线程”。主线程默认开启 RunLoop,使得主线程可以及时刷新 UI 界面和处理 UI 交互事件(如点击、滑动、拖拽等),所以主线程又称为“UI 线程”。耗时操作会妨碍主线程中的主循环的执行,从而引起 App 卡顿问题。通过多线程编程,将耗时操作放到子线程执行,在必要时再回到主线程做刷新操作,可以使程序运行更加流畅。

多线程编程的优缺点

优点:

  • 能适当地提高程序的执行效率
  • 能适当地提高资源利用率

缺点:

  • 创建线程、线程调度都是有开销成本的,如果开启大量线程,会降低程序的性能
  • 需要谨慎处理线程安全问题,增加了程序设计的复杂度

多线程实现方案与GCD

多线程编程实现方案对比

为什么使用 GCD

Grand Central Dispatch( GCD ,是 Apple 提供的异步执行任务的技术之一。线程管理用的代码由 GCD 在系统级实现,让开发者可以将注意力集中在任务的编写上。GCD 抽象出了任务、队列 等概念,将并发编程的范式变为了定义想执行的任务并追加到适当的派发队列

使用 GCD 实现多 线程 编程的好处:

  • 自动使用更多 CPU 内核
  • 自动管理线程的生命周期
  • 提供了易于使用的并发模型
  • 编码更加简洁,无需多余的线程管理任务

GCD 接口介绍

任务与队列

任务:即在线程中执行的代码,在 GCD API 中以 block 的形式提交到队列

队列:任务派发队列(先进先出FIFO,First-In-First-Out),任务追加到派发队列后按照先进先出的次序派发到对应线程进行处理

串行队列和并发队列

串行 队列:队列中的任务在单个线程中顺序执行,执行中的任务结束后才能继续执行下一个任务

并发 队列:队列中的任务在异步执行的情况下可以分发到多个线程下同时执行

获取 队列 的方式有

  • 通过 dispatch_queue_create 创建自定义的队列,通过传入参数 DISPATCH_QUEUE_SERIALDISPATCH_QUEUE_CONCURRENT 来区分串行队列和并行队列
  • 系统标准提供了一个主队列(串行)和四个全局队列(并行,有优先级差异),可以通过 dispatch_get_main_queuedispatch_get_global_queue 分别获取主队列和全局队列

同步执行与异步执行

同步执行(dispatch_sync):

  • 提交任务到指定队列,在该任务执行结束之前会一直等待
  • 提交的任务只能在当前线程执行,不具备开启线程的能力

异步执行(dispatch_async):

  • 提交任务到指定队列,继续往下执行不等待任务执行结束
  • 可以在新的线程中执行任务,具备开启线程的能力

dispatch_sync 和 dispatch_async 的传入参数是队列,同步执行、异步执行和串行队列、并发队列的组合调用分别是什么结果?由于主队列和主线程具有一定的特殊性,以下分析当前线程为主线程的情况下,同步执行、异步执行与并发队列、串行队列、主队列的组合调用情况:

dispatch_after

使用 dispatch_after可以实现延时执行任务的效果,需要注意的是任务会在指定延时后提交到队列,而任务真正的执行的时间点是未知的。在需要大致延时的情况下 dispatch_after 还是比较有效的

dispatch_group

如果希望在一个 Dispatch Queue 中所有任务执行完或者多个 Dispatch Queue 中的所有任务执行完后再执行某任务,可以通过dispatch_groupdispatch_group_notify 实现

使用 dispatch_group_wait 可以设置等待 group 执行的时间上限,当 group 中全部任务执行完或者满足 timeout 条件 dispatch_group_wait 才返回,可通过返回值区分两种返回类型

dispatch_apply

GCD 提供了 dispatch_apply 接口用于实现快速迭代,dispatch_apply 将按照指定的次数将指定的任务追加到派发队列,并等待队列中全部任务执行结束

dispatch_applydispatch_sync 函数类似,会等待队列中的任务执行结束,在主线程中使用可能引起卡顿问题或者发生死锁,尽量在 dispatch_async 函数中非同步地执行 dispatch_apply

dispatch_once

某个操作在应用程序生命周期中只执行一次,这种需求很常见,比如单例模式。dispatch_once 函数可以保证指定的处理在应用程序生命周期中只被执行一次

线程安全和线程同步

原子操作、临界区、线程同步

单个机器指令执行的操作称为原子操作。高级程序语言写出的一条语句,编译出来的往往是多条机器指令,此时称这条语句对应的操作不是原子的。如果多个线程对一个共享数据同时进行读写,会导致意想不到的后果。

临界区指的是不能被并发执行的一段代码(共享数据、代码块)。

线程 同步指的是在一个线程访问临界区的时候,其他线程不能对临界区进行访问,即对临界区的访问变成原子化了。

互斥量(Mutex) 是最简单的一种锁,在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用。由哪个线程获取就由哪个线程释放,常用于临界区的互斥访问。

信号量(Semaphore) 允许多个线程并发访问资源,可以实现线程同步或者控制并发访问的数量。

线程访问资源首先获取信号量:

  • 信号量的值 -1
  • 如果信号量的值小于 0,则线程进入等待状态,否则继续执行

访问完资源后线程释放信号量:

  • 将信号量的值 +1
  • 如果信号量的值大于 0,唤醒等待中的线程

iOS 中常用的锁的性能比较如下所示,dispatch_semaphore 是 GCD 提供的接口,性能较好

死锁

死锁是指多个线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象,若无外力作用,它们都将无法推进下去,此时称系统处于死锁状态或系统产生了死锁。

死锁产生的四个必要条件

互斥条件:资源访问是互斥的

占有且等待条件:线程持有了资源不释放,同时请求其他资源

不可抢占条件:其它线程不能强制夺取资源,只能由占有资源的线程主动释放

循环等待条件:线程等待形成了环路

破坏产生死锁的四个必要条件之一可以预防死锁的发生

atomic、nonatomic

Objective-C 中定义一个类的属性时,可以指定该属性的原子性(atomicnonatomic),默认是 atomic

atomic

  • 对属性 getter、setter 调用是线程安全的
  • 需要耗费资源为属性加锁

nonatomic

  • 访问不是线程安全的
  • 访问效率比 atomic 更高

dispatch_barrier_async

考虑多线程进行数据读写的场景,多个写操作不能并发执行,写操作和读操作不能并发执行,但是多个读操作是可以并发执行的,此时可以使用 dispatch_barrier_async 同时满足提高读操作效率和保证线程安全的要求。dispatch_barrier_async 函数会等队列中的全部任务执行结束后,再将指定的任务 X 追加到队列,之后提交的任务也需要等待 X 执行结束,仿佛 dispatch_barrier_async给队列添加了一道“栅栏”

Dispatch Semaphore

Dispatch Semaphore 是 GCD 提供的信号量接口,Dispatch Semaphore 可以实现成二元信号量或者多元信号量,达到线程同步、控制并发处理数量的效果

使用信号量实现线程同步示例:

使用信号量解决生产者消费者问题示例:

应用场景介绍

典型场景

  • 从网络加载图片

  • 一个页面有多个请求,需要全部请求都返回的时候刷新界面

dispatch_group + dispatch_group_notify

  • 实现一个线程安全的容器类

    • 选用哪种类型的“锁”?
    • 使用 GCD 的接口可以怎么实现?

常见问题

  • 死锁

GCD 能检测出部分死锁场景:

  • Xcode 默认打开 Main Thread Checker,非主线程操作 UI 会有 warning:

  • 线程不安全的容器读写崩溃,结果依赖于具体机器指令执行时序

    • 数组越界
    • 野指针访问

课后习题

习题 1

程序的执行情况可能是?

A、打印 1234

B、打印 1324

C、打印 2134

D、打印 1423

暂时无法在飞书文档外展示此内容

习题 2

程序的执行情况可能是?

A、打印123

B、打印132

C、打印1

D、发生死锁

暂时无法在飞书文档外展示此内容

推荐阅读

iOS 多线程(线程的生命周期) - 掘金

iOS多线程:『GCD』详尽总结 - 掘金

iOS多线程安全-13种线程锁🔒 - 掘金