iOS面试题1
这篇文章按「定义 + 场景 + 典型坑」记面试题。内存、线程、RunLoop、UI 渲染和架构后面接着补。
先记闭包。Swift 里叫 Closure,OC 里叫 Block。日常用 Swift,例子以 Swift 为主。OC 只留面试还会追问的三个词:三种 Block、copy、__block。
怎么考、怎么学
闭包这道题分五层。上一层能脱开笔记讲出来,再进下一层。
- 基础层:闭包是什么;Swift 的三种形态、逃逸和非逃逸;OC 的三种 Block 在内存的哪里,什么时候 copy。
- 核心层:变量怎么捕获;捕获列表和
__block各做什么;循环引用为什么出现,怎么断。 - 对比层:和 Delegate、通知、函数指针 / Target-Action 比,什么关系用哪个。
- 底层深度层:结构体、copy/dispose、forwarding、ARC 和 MRC 的差别。先认名词。细节折在文末,以后再补。
- 工程场景层:属性怎么声明;GCD、嵌套闭包、Timer、通知;对象释放后还去调用。
每一层准备三样东西:一句话、一段能自己敲出来的代码、一个坑。讲的时候先说结论,再说谁持有谁。循环引用在纸上画箭头,箭头表示强引用,两边都有箭头才是环。
复习就按这个顺序:
- 盖住正文,按上面五层讲下面的二十秒版。
- 自己敲「捕获列表」和
makeStepper两段代码,讲清输出。 - 画环,再讲
[weak self]、async、Timer 三者的差别。 - 用「几个听众、要不要结果」选回调方式。
- 场景题先讲再对照。底层默认不讲,被问到再打开折叠。
weak 断开闭包这边。丢给全局队列的 async 只会把对象留到任务结束。一次回调用闭包,一组回调用 Delegate,广播用通知。本质和类型
闭包是自带上下文的函数。定义它的那个环境里的变量,可以跟着它走,离开原作用域还能用。
Swift 里闭包是引用类型。两个变量指向同一个闭包时,捕获到的状态也是同一份。
语言里把它分成三种形态:
| 形态 | 有没有名字 | 会不会捕获 |
|---|---|---|
| 全局函数 | 有 | 不捕获周围的变量 |
| 嵌套函数 | 有 | 可以捕获外层函数的变量 |
| 闭包表达式 | 没有,写在调用处 | 可以捕获 |
日常写的是第三种。尾随闭包、$0 只是写法,面试不问这个。
let double: (Int) -> Int = { $0 * 2 }
func load(done: @escaping (Data) -> Void) {
done(Data())
}
load { data in
print(data.count)
}
面试更常把闭包分成逃逸和非逃逸。函数参数上的闭包默认非逃逸:这个函数返回之前就会把它调用完。要存进属性、异步再调、或者交给别人稍后用,参数标 @escaping。写在属性上的函数类型本来就会留到函数外面,不用再标。
非逃逸闭包里访问自己的成员,可以不写 self。逃逸闭包要写 self,或者把 self 放进捕获列表。编译器是在提醒:这里会把对象留住。捕获列表写成 [self] 或 [unowned self] 之后,体内访问成员可以不带 self.。[weak self] 里的 self 仍然是可选的。
非逃逸 逃逸
函数返回前一定调用完 存进属性、异步、作为返回值
上下文可以留在栈上 上下文放到堆上
访问成员可以不写 self 要写 self,或放进捕获列表
OC 的三种 Block
OC 按 Block 当时住在哪分类。调试器里的类名是 __NSGlobalBlock__、__NSStackBlock__、__NSMallocBlock__。
| 类型 | 在哪 | 什么时候是它 | 再 copy |
|---|---|---|---|
| Global Block | 数据段 | 不用局部变量 | 还是它自己 |
| Stack Block | 栈 | 用了局部变量,刚创建出来 | 搬到堆上,变成 Malloc Block |
| Malloc Block | 堆 | Stack Block 被 copy 之后 | 引用计数加一 |
只用全局变量或静态变量,是 Global。一旦用到函数里的局部变量(包括 self),创建时就是 Stack。
int n = 1;
void (^block)(void) = ^{
NSLog(@"%d", n);
};
// ARC:赋给 strong 变量时,编译器已经 copy,调试器里多半是 Malloc Block
// MRC:这里还是 Stack Block,函数返回后再调用,栈帧已经没了
什么时候 copy
copy 做的事:把栈上的 Block 复制到堆上,好在创建它的函数返回之后继续用。
这些时候会把栈 Block 复制到堆上:
- 手动
copy。 - ARC 里赋给
__strong变量,或者作为返回值。编译器会插入 copy。赋给strong属性时同样会 copy。 - MRC 里赋给声明成
copy的属性。 - 传给会存起来再用的接口。
dispatch_async内部会 copy。动画完成、通知的 block 观察者,系统会把它留到回调发生。 - 被另一个正在 copy 的 Block 捕获。外层搬到堆上时,内层跟着 copy。
这次 copy 会把捕获的 __block 变量一起搬到堆上。
Global Block 再 copy,得到的仍是原来的指针。已经在堆上的 Malloc Block 再 copy,只是引用计数加一。只在当前函数里同步调用、没有存下来的 Stack Block,留在栈上,函数返回就结束。MRC 里属性写成 retain,栈 Block 还停在栈上,函数返回后再调用就是野指针。
Swift 没有 Block_copy 这套 API。闭包要逃出当前函数时,编译器把上下文放到堆上,对应上面的 2、4、5。被捕获之后还要修改的局部变量,会放进堆上的一块存储(box),闭包和外面共用这一份。非逃逸闭包只在这次调用期间执行,闭包本身可以留在栈上。优化器要是能证明那块 box 不会逃出函数,也可以再放回栈。面试先讲:逃逸才必须上堆,可变捕获用 box。
变量捕获
闭包默认捕获的是变量本身。外面后来改了,闭包里读到新值;闭包里改了,外面也看得到。
局部变量本来跟着函数走,函数返回就没了。闭包还要在那之后用它,编译器就把变量放进堆上的 box。闭包持有这块 box,所以函数返回后计数还在。
func makeStepper() -> () -> Int {
var i = 0
return {
i += 1
return i
}
}
let a = makeStepper()
let b = a
let c = makeStepper()
a() // 1
b() // 2,和 a 是同一个闭包
c() // 1,另一次调用,另一份 i
let b = a 没有复制一份新的 i。闭包是引用类型,a 和 b 指向同一个闭包。
同一次函数调用里的两个闭包,捕获的是同一个变量:
func shared() -> (() -> Int, () -> Int) {
var i = 0
return (
{ i += 1; return i },
{ i += 10; return i }
)
}
let (inc, jump) = shared()
inc() // 1
jump() // 11
inc() // 12
捕获列表
捕获列表在闭包创建时求值,把当时的值带进来。
var n = 0
let snap = { [n] in n }
let live = { n }
n = 10
snap() // 0
live() // 10
值类型是拷进闭包的。这个名字不能再赋值,编译器会说 n is an immutable capture。
类实例拷的是引用,对象不会被深拷贝。[box] 持有创建闭包时的那个对象。外面把变量换成另一个对象,闭包里还是原来的。对象的属性仍然可以改。
final class Box {
var value: Int
init(_ value: Int) { self.value = value }
}
var box = Box(1)
let liveBox = { box.value }
let snapBox = { [box] in box.value }
box = Box(2)
liveBox() // 2,捕获的是变量 box
snapBox() // 1,持有创建时那个对象
weak 和 unowned 只写在捕获列表里,用来决定这份引用怎么持有对象。列表里可以写多个,每个名字各算各的。
[weak self] 之后,闭包里的 self 是可选的。guard let self else { return } 和 guard let self = self else { return } 是一件事:解包出来的强引用只活在这一次调用里。调用结束就松开,捕获列表仍然是弱引用。
__block。Swift 要创建时的那一份,写捕获列表。__block int n = 1;
void (^block)(void) = ^{
n = 2;
};
block();
// 外面的 n 也是 2
| 创建之后外面改了变量 | 闭包里能不能改这个局部变量 | 对象怎么留 | |
|---|---|---|---|
| Swift 默认 | 闭包里是新值 | 能,和外面是同一份 | 类实例被强引用 |
Swift [n] | 闭包里是创建时的值 | 这份拷贝不能再赋值 | 类实例是再持有当时那个对象 |
Swift [weak self] | — | — | 不持有,对象没了就是 nil |
| OC 默认 | Block 里仍是旧值 | 不能 | copy 到堆上时 retain |
OC __block | 两边是同一份 | 能 | ARC 下对象仍会 retain |
snap / live 这段,说明捕获列表在创建时取值。再补一句:这和 OC 的默认捕获是反过来的,__block 才接近 Swift 的默认行为。循环引用
闭包容易成环,是三件事叠在一起:
- 闭包是引用类型,存进属性就会被强引用。
- 闭包默认强引用它用到的对象。
- 业务里又经常把回调存在对象自己身上。
控制器 ──强引用──▶ onFinish
▲ │
└────强捕获 self───┘
两条都是强引用,控制器的引用计数下不来,deinit 不走。Delegate 习惯写成 weak var delegate,闭包没有这个约定,所以同样的关系更容易漏。
final class Screen {
var onFinish: (() -> Void)?
func setup() {
onFinish = {
self.refresh()
}
}
func refresh() {}
deinit { print("deinit") }
}
setup() 之后,这个 Screen 不会释放。
非逃逸闭包在函数返回前就调用完,属性上留不住它,构不成这种环。结构体是值:逃逸闭包不能把正在修改的 self 借走,编译器直接报 escaping closure captures mutating 'self' parameter。要带数据走,用捕获列表把当时的值拷进去。会和闭包互相持有的是类。
怎么断
断的是闭包对对象的那条强引用,或者断对象对闭包的那条。
[weak self]。 闭包不持有对象。对象可能先没,用之前先解包。网络回调、动画完成、跨页面回传,用这个。
onFinish = { [weak self] in
guard let self else { return }
self.refresh()
}
解包出来的 self 在这次 refresh 期间是强引用。refresh 返回后松开。onFinish 的捕获仍然是弱的,环不在。
[unowned self]。 也不持有对象,调用时直接当对象还在。对象已经释放再进来,进程崩溃,信息是 Attempted to read an unowned reference but object was already deallocated。闭包的寿命被对象包住、不会被单独传出去时用它,比如只存在对象自己的 lazy 属性里。拿不准对象会不会先释放,用 weak。
一次性回调,用完把属性清空。 先取出来,再置 nil,然后调用。属性这条强引用先断,回调里再给 onFinish 赋值也不会和旧的搅在一起。
func finish() {
let done = onFinish
onFinish = nil
done?()
}
捕获列表里写 [self] 是显式的强引用,环还在。写了方括号,不代表已经拆环。
延寿和环
DispatchQueue.main.async {
self.refresh()
}
主队列持有闭包,闭包持有控制器。控制器不持有主队列。任务跑完,闭包释放,控制器只是多活到这次刷新结束。
控制器自己的属性持有队列时,任务还排着的那一阵,控制器 → 队列 → 闭包 → 控制器 连得上。任务结束,队列放掉闭包,这条链断开。一直不结束的定时源才会挂住。
lazy、Timer、通知
lazy 闭包第一次被访问时创建,存在属性里,又捕获了 self:
final class Profile {
let name: String
lazy var label: () -> String = {
"用户 \(self.name)"
}
init(name: String) { self.name = name }
}
Profile → label → Profile。这段闭包只从 Profile 身上拿,用 [unowned self]。它可能被别人存下来、在对象释放后调用,用 [weak self]。
重复触发的 Timer:RunLoop 持有 Timer,Timer 持有闭包。闭包再强引用 self,而 self 持有 Timer,环就成立。deinit 走不到,写在 deinit 里的 invalidate 也不会执行。
final class Clock {
var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
}
func tick() {}
deinit {
timer?.invalidate()
}
}
[weak self] 让 deinit 走得到,invalidate 再让 RunLoop 放掉 Timer。只有 weak、从不 invalidate 的话,对象能释放,Timer 仍会一直触发,只是 self 已是 nil。
通知的 block 观察者由通知中心持有。闭包强引用控制器时,控制器走不到 deinit。
final class Player {
var token: NSObjectProtocol?
func listen() {
token = NotificationCenter.default.addObserver(
forName: Notification.Name("playback"),
object: nil,
queue: .main
) { [weak self] note in
self?.handle(note)
}
}
func handle(_ note: Notification) {}
deinit {
if let token {
NotificationCenter.default.removeObserver(token)
}
}
}
queue: .main 是 OperationQueue。[weak self] 让对象能释放。观察者还在中心里,通知来了仍会进闭包。removeObserver 才把它卸掉。
OC 里用 selector 注册的观察者,从 iOS 9 起会在观察者释放时自动移除。block 版不会。
weak 断的是闭包对对象的持有;解包后的 self 只在本次调用里变强。全局队列的 async 是延寿。Timer 和通知的 block 要把「弱引用」和「注销」两件事都做。和 Delegate、通知怎么选
先看关系,再看谁持有谁。
| 闭包 | Delegate | 通知 | Target-Action | |
|---|---|---|---|---|
| 关系 | 调用方手里的一段逻辑 | 一对一 | 一对多 | 控件对一个对象的一个方法 |
| 签名 | 编译期检查 | 协议,编译期检查 | 通知名,发送方不知道听众 | selector,运行时才知道方法在不在 |
| 返回值 | 可以有 | 可以有 | 没有 | 一般没有 |
| 谁持有谁 | 存成属性就强引用闭包,闭包默认强引用 self | 习惯 weak var delegate | 通知中心持有 block | 控件持有 target |
| 适合 | 一次完成,逻辑就在调用旁边 | 一组相关回调 | 跨模块广播 | 按钮点击 |
一次结果、只有一个处理方,用闭包。回调是好几件相关的事(列表点进详情、下拉、出错),用 Delegate,让编译器检查方法齐不齐。听众有哪些事先不知道、也不用回传结果,用通知。
闭包属性一旦多到要编号,调用顺序和错误处理会散开。这时收成一个协议,或者收成一个小类型。
函数指针没有上下文,捕获不了局部变量。Selector 靠对象和方法名找实现,写错了要运行才发现。闭包和它们的差别,是多了一份会被捕获的上下文,而且签名在编译期就定下来。
weak,闭包要自己写捕获列表。底层
底层先讲这三句。对方没往下问就停。
OC 的 Block 是一个结构体:一个函数指针,后面跟着捕获来的变量。copy 到堆上时,copy/dispose 负责持有和释放这些对象。__block 变量带一个 forwarding 指针,从栈上访问和从堆上访问,走到的是同一份数据。
Swift 闭包在运行时是函数指针加一个上下文对象。不捕获任何东西时,可以只剩函数指针。被捕获后还要改的局部变量,放在堆上的 box 里。
字段、helper 和 ARC/MRC 的差别折在下面,后续再补 SIL 和调试步骤。
先对上这几项。flags 每一位以后再补,现在记住三个:BLOCK_IS_GLOBAL(全局)、BLOCK_NEEDS_FREE(在堆上)、BLOCK_HAS_COPY_DISPOSE(带 helper)。
isa 指向 Global / Stack / Malloc 的类
flags 在不在堆上、有没有 helper、是不是全局
invoke 真正执行的函数
descriptor 结构体大小、签名、copy/dispose
捕获的变量 跟在结构体后面
Block 捕获了对象或另一个 Block 时,descriptor 里会带两个 helper。
copy helper 在搬到堆上时运行:捕获的对象 retain,捕获的 Block 再 copy。dispose helper 在 Block 销毁时运行,对应 release。
所以「copy 到堆上之后对象被持有」,是 helper 做的,不是把对象拷贝了一份。
__block 变量外面还有一层结构。里面有 forwarding,访问变量都先走它。
刚创建时结构在栈上,forwarding 指向自己。Block 被 copy 时,这份结构搬到堆上:堆上的 forwarding 指向自己,栈上那个改成指向堆。之后无论从栈上的旧地址还是堆上的新地址进去,改的是同一份值。
这就是 __block 能让 Block 里面和外面互相看见修改的原因。
MRC 下,栈 Block 要自己 copy。属性写 copy。写 retain 不会把栈 Block 搬到堆上,函数返回后再调用就是野指针。__block 修饰的对象默认不 retain,老代码才用 __block 保存 self 来拆环。
ARC 下,返回 Block、赋给 __strong,编译器都会 copy。属性写 strong 也会 copy,面试仍习惯说属性用 copy。__block 对象会被 retain。再写 __block typeof(self) weakSelf = self 拆不掉环,要写 __weak。
不带上下文的函数是薄函数(thin):只有函数指针,接近 Global Block,也接近 @convention(c) 的函数指针。
捕获了变量的闭包是厚函数(thick):函数指针加一个上下文对象。编译器把闭包打包出来的动作叫 partial_apply。被闭包和外面一起改的局部变量,用 alloc_box 放到堆上。
后续补的时候,对着一段 SIL 把这三个名字对上源码即可。
场景题
先盖住答案。每题按三句讲:结论、谁持有谁、怎么改。
页面为什么不走 deinit
完成回调存在控制器的属性里,闭包里又写了 self。控制器持有闭包,闭包持有控制器。
改成 [weak self],或者这个回调只会走一次,调用前把属性置 nil。
weak 和 unowned 怎么选
对象可能先释放,用 weak,调用时是可选的。能保证调用发生时对象还在,用 unowned,对象先没了再调用会崩溃。
只挂在对象自己的 lazy 属性上、不会被别人保存,可以用 unowned。其余回调用 weak。
主队列 async 里写了 self,算环吗
主队列持有闭包,闭包持有 self。self 不持有主队列。任务结束就松开,对象多活到那一次执行完。
self 的属性里持有这条队列,并且任务一直不结束,才会一直连着。
外层已经 weak,里面又存了一个闭包
捕获列表只管写它的那一层。
onDone = { [weak self] in
guard let self else { return }
self.later = {
self.refresh()
}
}
内层没有自己的捕获列表,它强引用 guard 解出来的 self,又存在 later 上。环是 self → later → self。内层也要写 [weak self],或者不要把内层存进 self。
内层如果只是 DispatchQueue.main.async,主队列用完会放掉闭包,那是延寿。
lazy 闭包为什么会环
lazy var label: () -> String = { self.name }:对象持有闭包,闭包持有对象。第一次访问这个属性时环就成立。
闭包不会离开对象,用 [unowned self]。会离开,用 [weak self]。
Timer 停不掉
重复 Timer 被 RunLoop 持有,Timer 持有闭包。闭包强引用 self,self 又持有 Timer 时,deinit 不走,写在里面的 invalidate 没机会执行。
[weak self] 加上 deinit 里 invalidate。invalidate 要在安装 Timer 的那条线程上调用。主线程创建的,回到主线程再停。
通知把控制器留住了
addObserver(forName:object:queue:using:) 的闭包由通知中心持有。闭包强引用 self 时,self 释放不了。
[weak self],并把返回的 token 留着,在 deinit 里 removeObserver。selector 版从 iOS 9 起会跟着观察者一起移除,block 版要自己移除。
捕获列表什么时候取值
创建闭包时,不是调用时。
var n = 1
let f = { [n] in print(n) }
let g = { print(n) }
n = 2
f() // 1
g() // 2
f 里的 n 不能再 +=。g 和外面改的是同一个 n。
属性为什么要 copy
OC 的 Block 属性写成 copy,是为了存进去的时候已经在堆上:
@property (nonatomic, copy) void (^onFinish)(void);
nonatomic 表示赋值不加锁,和 copy 是两件事。MRC 下写成 retain 或 assign,栈 Block 出了函数再调用,就是野指针。
Swift 的闭包属性是普通强引用,逃逸时上下文已经在堆上:
var onFinish: (() -> Void)?
Swift 里和野指针对上的坑是 unowned:对象已经释放,闭包还被别人调用。unowned(unsafe) 连运行时检查都没有,业务代码不用。
结构体里的 self 为什么不成环
结构体是值。在 mutating 方法里返回一个用到 self 的逃逸闭包,编译器会拒绝。捕获列表可以把当时的字段拷走,拷走的是值,和原来的结构体没有互相持有。
struct Counter {
var n = 0
mutating func schedule() -> () -> Void {
{ [n] in print(n) }
}
}
一次请求,闭包、Delegate、通知选哪个
请求方要这一次的结果,用闭包,成功和失败放在同一个 Result 里。
这一屏有加载、点击、空态好几件相关的事,用 Delegate。
登录态变了,设置页、首页、播放器都要知道,用通知。发送方不用认识这些页面。
追问
两个闭包改同一个局部变量,结果串了。 它们是同一次函数调用里捕获的同一个变量,box 只有一块。要各算各的,分成两次函数调用,或者在捕获列表里各自拷一份。
闭包赋值之后,捕获状态被共享。 let b = a 复制的是引用。要一份新的状态,再调用一次创建闭包的函数。
@autoclosure。 调用处写普通表达式,函数里把它当成闭包,执行到那一行才求值。assert 的条件就是这样。业务接口少用,对方问再答。
非逃逸闭包交不出去。 非逃逸参数不能直接传给 @escaping 参数。确实只在当前函数返回前使用,可以用 withoutActuallyEscaping 做一次限定范围内的转换,函数返回前必须用完。
跨线程、跨 actor。 闭包要离开当前隔离域时,它自己和捕获的值都要能安全传递(Sendable)。被闭包共享、还能改的可变状态,是这里会被追问的点。Swift 6 会在编译期查。细节以后补。