ArturoYi
iOS

iOS面试题1

先记 Swift 闭包:本质与 copy、变量捕获、循环引用,以及和 Delegate、通知怎么选。

这篇文章按「定义 + 场景 + 典型坑」记面试题。内存、线程、RunLoop、UI 渲染和架构后面接着补。

先记闭包。Swift 里叫 Closure,OC 里叫 Block。日常用 Swift,例子以 Swift 为主。OC 只留面试还会追问的三个词:三种 Block、copy、__block。

怎么考、怎么学

闭包这道题分五层。上一层能脱开笔记讲出来,再进下一层。

  1. 基础层:闭包是什么;Swift 的三种形态、逃逸和非逃逸;OC 的三种 Block 在内存的哪里,什么时候 copy。
  2. 核心层:变量怎么捕获;捕获列表和 __block 各做什么;循环引用为什么出现,怎么断。
  3. 对比层:和 Delegate、通知、函数指针 / Target-Action 比,什么关系用哪个。
  4. 底层深度层:结构体、copy/dispose、forwarding、ARC 和 MRC 的差别。先认名词。细节折在文末,以后再补。
  5. 工程场景层:属性怎么声明;GCD、嵌套闭包、Timer、通知;对象释放后还去调用。

每一层准备三样东西:一句话、一段能自己敲出来的代码、一个坑。讲的时候先说结论,再说谁持有谁。循环引用在纸上画箭头,箭头表示强引用,两边都有箭头才是环。

复习就按这个顺序:

  1. 盖住正文,按上面五层讲下面的二十秒版。
  2. 自己敲「捕获列表」和 makeStepper 两段代码,讲清输出。
  3. 画环,再讲 [weak self]、async、Timer 三者的差别。
  4. 用「几个听众、要不要结果」选回调方式。
  5. 场景题先讲再对照。底层默认不讲,被问到再打开折叠。
闭包是一段能记住外部变量、并且能传来传去的代码,Swift 里是引用类型。先看逃不逃逸:存进属性或异步再调用就是逃逸,上下文在堆上。默认和外面共用变量;捕获列表是创建闭包时把值带进来。对象持有闭包,闭包又强引用 self,就是环,用 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,或放进捕获列表
面试点:Swift 先答逃不逃逸。对方要「三种类型」,再答全局函数、嵌套函数、闭包表达式。对方要是在说 Block,就改口答下面的 Global / Stack / Malloc。

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 复制到堆上:

  1. 手动 copy。
  2. ARC 里赋给 __strong 变量,或者作为返回值。编译器会插入 copy。赋给 strong 属性时同样会 copy。
  3. MRC 里赋给声明成 copy 的属性。
  4. 传给会存起来再用的接口。dispatch_async 内部会 copy。动画完成、通知的 block 观察者,系统会把它留到回调发生。
  5. 被另一个正在 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。

面试点:copy 是栈搬堆。Global 再 copy 还是自己。Swift 用「逃不逃逸」回答同一件事。

变量捕获

闭包默认捕获的是变量本身。外面后来改了,闭包里读到新值;闭包里改了,外面也看得到。

局部变量本来跟着函数走,函数返回就没了。闭包还要在那之后用它,编译器就把变量放进堆上的 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 } 是一件事:解包出来的强引用只活在这一次调用里。调用结束就松开,捕获列表仍然是弱引用。

Swift 默认和外面共用变量。OC 默认把创建时的值拷进 Block,外面再改,Block 里还是旧的,而且不能在 Block 里给这个局部变量赋值。OC 要共用、要修改,加 __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 靠对象和方法名找实现,写错了要运行才发现。闭包和它们的差别,是多了一份会被捕获的上下文,而且签名在编译期就定下来。

面试点:用三个词选。一次回调是闭包,一组回调是 Delegate,一对多是通知。再补一句持有关系:Delegate 靠约定写成 weak,闭包要自己写捕获列表。

底层

底层先讲这三句。对方没往下问就停。

OC 的 Block 是一个结构体:一个函数指针,后面跟着捕获来的变量。copy 到堆上时,copy/dispose 负责持有和释放这些对象。__block 变量带一个 forwarding 指针,从栈上访问和从堆上访问,走到的是同一份数据。

Swift 闭包在运行时是函数指针加一个上下文对象。不捕获任何东西时,可以只剩函数指针。被捕获后还要改的局部变量,放在堆上的 box 里。

字段、helper 和 ARC/MRC 的差别折在下面,后续再补 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 会在编译期查。细节以后补。

Copyright © 2026