1.Runloop 的流程,底层实现,项目里实际使用,监控卡顿监听的具体是哪个状态,Source0 和 Source1 区别
2.局部对象的释放时机;autoreleasepool的实现,weak 存在哪里
3.SDK 构建提效,有没有二进制化的经验,缓存
4.feed 流滑动卡顿可能有哪些原因;离屏渲染为什么会影响;那些异步的库,不也是在另一个屏幕缓冲区处理的吗?
5.首屏优化,有没有针对各个模块的,比如卡线程了,怎么检测,怎么优化
6.IM Socket 的流程
7.FMDB 的内部实现,是线程安全的吗?和其他如 WCDB 效率的比较;
8.用过哪些锁?效率比较;@synchronized 的作用是什么?
9.上传大图,怎么分片,怎么控制线程最大并发数,怎么告诉web上传进度?上传进度细颗粒度,每片都有哪些状态,现有上传单个图片的方法,怎么实时同步进度
10.SDWebImage 加载图片的流程,缓存机制,LRU怎么实现的?加载图片传入了 UIImageView 下载完图片后怎么拿到这个对象,因为是异步的
11.OOM 怎么避免?
12.MVCS 与 SectionProvider 怎么实现的
参考答案
1.Runloop 的流程,底层实现,项目里实际使用,监控卡顿监听的具体是哪个状态,Source0 和 Source1 区别
(1)1. RunLoop 的流程
RunLoop 是一个事件循环机制,内部主要流程可以简化为:
- 通知 Observers:进入 Loop 前,会发通知(如 kCFRunLoopEntry)。
- 处理 Timer:检查是否有已到期的 Timer 任务。
- 处理 Source0:非基于端口的事件(UI 事件、Block 回调等),需要主动唤醒。
- 处理 Source1:基于 Mach port 的事件(系统事件、IPC、触摸事件)。
- 进入休眠:调用 mach_msg_trap 等底层系统调用,线程进入休眠状态。
- 被唤醒:
- 有事件到来(Source、Timer、GCD dispatch)。
- 外部手动唤醒(CFRunLoopWakeUp)。
- 通知 Observers:将要处理事件(kCFRunLoopBeforeSources / kCFRunLoopBeforeWaiting / kCFRunLoopAfterWaiting)。
- 循环往复:直到 CFRunLoopStop 被调用。
(2) 底层实现
• 核心结构:CFRunLoop 和 CFRunLoopMode,每个线程对应一个 RunLoop(存储在 pthread 的 TLS 中)。
• 事件驱动:通过 Mach port 和 内核态通信。比如触摸事件从 IOKit -> SpringBoard -> App -> RunLoop Mach port。
• 休眠/唤醒:利用系统调用 mach_msg_trap 进入内核,等待消息;有事件时唤醒。
• RunLoopObserver:内部维护一组回调,挂在不同状态点(Entry、BeforeTimers、BeforeSources、BeforeWaiting、AfterWaiting、Exit)。
(3)项目里的实际使用
- 定时器:NSTimer / CADisplayLink / performSelector:afterDelay: 本质依赖 RunLoop。
- 常驻线程:如 AFNetworking 的网络请求线程,使用 RunLoop 保持线程不退出。
- 事件响应:触摸事件、UI 更新都跑在主线程 RunLoop 中。
- GCD 与 RunLoop:dispatch_async 提交到主队列的 block,本质通过 CFRunLoopSource 执行。
- 性能优化:比如在 NSRunLoopCommonModes 下添加任务,避免因 UI 滑动(UITrackingRunLoopMode)而阻塞。
(4)卡顿监控监听的状态
常见的 卡顿监控(UI 卡顿监控) 原理就是利用 RunLoop Observer,在关键状态点打点:
• 监听 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting。
• 在 BeforeSources 说明即将处理事件。
• 在 AfterWaiting 说明刚被唤醒,准备进入事件处理。
• 如果这两个状态之间持续很久没有切换,说明 RunLoop 卡在某个任务(主线程卡住)。
具体做法:
1. 在主线程 RunLoop 添加 Observer,监听这两个状态。
2. 同时开一个子线程用 dispatch_semaphore_wait + 超时机制判断。
3. 超过阈值(如 > 200ms)没有状态切换,判定为卡顿。
4. 结合堆栈采样(backtrace)就能定位具体卡住的位置。
(5)source0 与 source1 区别
Source0
• 定义:非基于内核端口(port)的事件源。
• 特点:
• 纯用户态,不会主动唤醒 RunLoop,需要外部手动唤醒。
• 常用于 App 内部事件分发,例如 performSelector:onThread:。
• 触发方式:
• 调用 CFRunLoopSourceSignal(source) 标记为待处理。
• 再调用 CFRunLoopWakeUp(runloop) 唤醒 RunLoop。
• 例子:
• performSelector:onThread:
• 手动创建的 CFRunLoopSource0Source1
• 定义:基于 Mach port 的事件源。
• 特点:
• 内核态驱动,系统事件到达时可以直接唤醒 RunLoop。
• 主要用来处理 系统消息(如触摸、系统回调、CFMachPort)。
• 触发方式:
• 当端口有消息时,内核会唤醒 RunLoop,执行对应的回调。
• 例子:
• 系统的触摸事件、屏幕旋转事件。
• CFMachPort、CFSocket
2.局部对象的释放时机;autoreleasepool的实现,weak 存在哪里
(1)局部对象的释放时机
在 ARC 下,局部变量对象(比如方法里的
NSObject *obj = [[NSObject alloc] init];
)的释放时机主要取决于 作用域 + 引用计数。编译器会在合适的位置插入 objc_release。
通常规则:
- 如果是强引用(strong),在超出作用域时编译器插入 release。
- 如果放在了 @autoreleasepool 内,且对象通过 autorelease 创建(如 [NSString stringWithFormat:@”xx”]),则会在 pool 清空时统一 release。
- 如果没有显式放进 autoreleasepool,那么局部对象的 autorelease 会挂在当前线程的默认 autoreleasepool page 上(RunLoop 每次循环会清理一次)。
- 所以:
- alloc/init 的对象:离开作用域立即释放。
- 工厂方法 (stringWith…) 返回的对象:通常是 autorelease,释放时机取决于最近的 autoreleasepool drain。
(2) autoreleasepool 的实现
- @autoreleasepool {} 在编译后会被转化为:
1
2
3
4
5void* context = objc_autoreleasePoolPush();
{ // pool 内部作用域
...
}
objc_autoreleasePoolPop(context);
• 底层实现依赖 AutoreleasePoolPage:
• 每个线程维护一个栈状的双向链表结构。
• objc_autorelease(obj) 会把对象的指针放入当前 page。
• pool 被 pop 时,会遍历这个 page,依次对对象发送 release。
• RunLoop 机制:
• 主线程的 RunLoop 每次事件循环开始会 push 一个 pool,结束时 pop。
• 这保证了常见的 UIKit API 里 autorelease 对象能在一次事件处理后被释放。
(3)weak 的存储位置
• weak 指针并不是单纯的内存地址,它需要 运行时的弱引用表(weak_table_t) 管理:
• weak_table_t 是一个 hash 表,key 是对象地址(被引用的对象),value 是指向该对象的所有 weak 指针地址集合。
• 当对象的引用计数归零时,dealloc 流程会调用 objc_destroyWeak,把所有指向该对象的 weak 指针置为 nil。
• 所以 weak 指针存储在:
• 指针本身依然在栈上 / 堆上(取决于变量声明位置),
• 但 runtime 维护了一份全局的弱引用表,用来统一管理 对象 ↔ weak 指针列表 的关系。
• 注意:
• weak 本身是一个普通指针,只不过 runtime 在对象销毁时会把它自动清空。
• 所以 weak 查找和赋值都会经过 runtime 的弱引用表操作,有一定性能开销。
3.SDK 构建提效,有没有二进制化的经验,缓存
模块化/组件化
SDK 内部拆分为稳定依赖(binary、SPM precompiled)+ 高频改动部分(源码),避免全量编译。
构建缓存
CI 里缓存 DerivedData、Pods、SPM 构建产物。
4.Feed 流滑动卡顿可能有哪些原因;离屏渲染为什么会影响;那些异步的库,不也是在另一个屏幕缓冲区处理的吗?
(1) Feed 流滑动卡顿的常见原因
卡顿一般是因为 主线程在 16.67ms(60fps 下)或者 8.3ms(120fps 下)没有完成一次绘制提交。常见原因:
- 布局计算过重
- 大量 autoLayout 约束计算
- cell 高度动态计算不缓存
主线程阻塞
- 大量 JSON 解析、IO、图片解码放在主线程
- 复杂业务逻辑或锁竞争
绘制负担过重
- 大量圆角、阴影、mask、group opacity 等触发离屏渲染
- 大图缩放、解码延迟
频繁创建/销毁对象
- cell 重复 init,而不是重用
图片处理问题
- 未做预解码、下采样,导致 GPU/CPU 开销大
(2)为什么离屏渲染会影响滑动流畅度
什么是离屏渲染 (Offscreen Rendering)
GPU 本来在 On-Screen Buffer(屏幕缓冲区)里画东西,顺序执行即可。
一旦遇到圆角 + mask、阴影、layer.shouldRasterize 等情况,GPU 会:
- 先新建一个 Offscreen Buffer
- 把内容画到这个 buffer
- 再拷贝回 On-Screen Buffer
开销在哪
- 新建 buffer 本身消耗内存
- GPU 切换上下文(Context Switch)有性能开销
- 结果要 copy 回主屏幕 buffer,增加带宽占用
为什么会卡顿
- 这不是“异步”的,而是 GPU 的额外工作,依然要卡在 渲染管线(Display Link vsync 同步)。
- 如果离屏渲染量大,GPU 跟不上 vsync 节奏,就掉帧 → feed 滑动卡顿。
(3)“异步绘制库” 和离屏渲染的区别
你说的 “异步的库”(比如 YYAsyncLayer, Texture (AsyncDisplayKit))其实跟 Core Animation 的 Offscreen Rendering 不一样:
异步绘制库
- 把文本排版、图片合成、UI 绘制逻辑放到 后台线程的 CGContext 里完成
- 得到一张最终的位图(Bitmap)
- 主线程只把这张图交给 layer.contents
- → 避免了主线程阻塞 & 避免 GPU 临时建 buffer
GPU 离屏渲染
- 是 渲染阶段 GPU 为了实现特效而“不得不”建一个临时 buffer
- 发生在绘制提交之后、硬件管线中
- → 无法用“放后台线程”解决,因为是 GPU 硬件的需求
可以这样理解:
- 异步绘制库:CPU 先画好 → 减轻主线程 & GPU 压力
- 离屏渲染:GPU 临时建 buffer → 加重 GPU 压力
总结:
- Feed 卡顿大多是 主线程阻塞 + GPU 负担过重。
- 离屏渲染会卡,是因为 GPU 在渲染管线上多了额外 buffer 开销。
- 异步绘制库的“异步 buffer”不是 GPU 的 Offscreen Buffer,而是 CPU 侧先生成位图,两者概念完全不同。
5.首屏优化,有没有针对各个模块的,比如卡线程了,怎么检测,怎么优化
卡线程检测方法
1. Runloop 卡顿监控
• 利用 CFRunLoopObserver 监听主线程 RunLoop 的状态(kCFRunLoopBeforeSources、kCFRunLoopAfterWaiting 等)。
• 当 RunLoop 某次循环超过阈值(如 >200ms)还未完成,就认为发生了卡顿。
• 工具类库:YYAsyncLayer、FBRetainCycleDetector + 自己封装卡顿监控。
2. 堆栈采样 (Stack Sampling)
• 使用子线程定时采样主线程调用栈(比如每 50ms 取一次)。
• 当检测到主线程长时间无响应时,打印采样栈,可快速定位阻塞点。
• 开源方案:KSCrash、PLCrashReporter。
3. 系统工具
• Instruments → Time Profiler:定位函数耗时。
• Instruments → Main Thread Checker:发现 UI API 是否在子线程调用。
• Instruments → System Trace:更底层地看线程调度与锁等待。
常见模块的首屏优化点:
(1)启动阶段(冷启动)
- 优化检测
利用 DYLD_PRINT_STATISTICS 环境变量观察动态库加载耗时。
- Xcode 的 App Launch 模板可分析启动阶段。
优化手段
- 减少动态库数量,合并 Pod。
- 用 App Clips 或延迟加载来减少首次启动体积。
- +load 和 +initialize 里避免做耗时操作。
(2) 主线程耗时操作
检测
- Runloop 卡顿监控 + Instruments。
优化
- UI 布局计算移到子线程(AutoLayout 的约束尽量减少层级)。
- 图片解码、JSON 解析放子线程(YYImage / YYModel 的异步方案)。
- 渲染复杂 UI 时用 CALayer 或离屏渲染优化。
(3)网络模块
检测
- 在首屏接口加埋点(发起时间 - 响应时间)。
- Charles / Wireshark 抓包分析耗时。
优化
核心数据接口提前并发请求,不要串行。
使用缓存(磁盘缓存、内存缓存、预加载)。
大图延迟加载,必要时用占位图。
(4) 图片加载
检测
- Instruments → Allocations/Time Profiler 观察解码与内存情况。
优化
- 预解码(SDWebImage、YYImage)。
- 降低分辨率,避免原图直接展示。
- 使用 WebP/HEIF 格式。
(5)渲染与绘制
- 检测
Instruments → Core Animation 检测 FPS、离屏渲染。
- GPU Driver template 观察 GPU 是否被打满。
优化
- 减少视图层级,避免过度使用透明度/圆角/阴影。
- 尽量用 CAShapeLayer 替代复杂绘制。
- 提前渲染静态页面的快照。
(6)数据处理 & 本地 IO
检测
- Time Profiler 定位磁盘/数据库耗时。
优化
- 首页不要做大量磁盘读写。
- CoreData/SQLite 的查询下沉到子线程。
- UserDefaults 批量写时要避免阻塞。
6.IM Socket 的流程
1.建立连接
使用 CFStream(CoreFoundation)、CFSocket、或者三方库(如 CocoaAsyncSocket)来建立 TCP 连接。
或者直接用 WebSocket(iOS 13+ 推荐 URLSessionWebSocketTask,早期可用 SocketRocket)。
流程:
- 客户端发起连接请求(IP + 端口)。
- 服务器返回是否成功。
- 成功后,进入可读写状态。
2.登录/鉴权
- 建立连接后,通常第一步是发送 登录包 / 鉴权信息(例如 userId、token、设备信息)。
- 服务器验证通过后,会返回 登录成功 ACK。
- 只有在鉴权成功后,Socket 才允许继续收发消息。
3.维持长连接
- 长连接需要保持心跳(心跳包 ping/pong)。
- 客户端定时(如 30s/60s)发送 心跳包,服务器回应 心跳 ACK。
- 如果心跳超时(连续几次未响应),认为连接断开,需要自动重连。
4.消息收发
发送消息:
- 将消息序列化成约定的数据格式(JSON / Protobuf / 二进制协议)。
- 封装消息头(包含消息类型、长度、消息 ID 等)。
- 通过 Socket write/send 发出。
接收消息:
- Socket 输入流回调或代理触发。
- 先读消息头,解析数据包长度。
- 再按协议读取完整消息体。
- 反序列化成业务层可用的模型对象。
5.消息确认(ACK机制)
为了保证消息可靠性,通常采用 消息回执机制:
- 客户端发送消息 → 服务器收到后,返回 ACK(确认包,包含消息 ID)。
- 如果客户端在一定时间内未收到 ACK,则重发消息。
6.断线重连
网络变化(WiFi/4G切换)、App 后台 → 前台,都会导致连接中断。
需要实现 自动重连机制:
- 发现连接断开 → 进入重连流程。
- 指数退避重试(如 1s → 2s → 4s → 8s…,有上限)。
- 连接成功后,重新鉴权、同步离线消息。
7.离线消息同步
- 断线期间可能会有消息未收到。
- 重新连接成功后,客户端需要调用接口获取 离线消息,再和本地消息队列合并。
8.退出/释放
- 用户主动退出登录时,发送 退出包。
- 关闭 Socket,释放资源。
7.FMDB 的内部实现,是线程安全的吗?和其他如 WCDB 效率的比较;
(1)FMDB 的线程安全性
- FMDatabase
- FMDatabase 本身 不是线程安全的。
- 官方明确建议:一个 FMDatabase 实例只能在单一线程中使用,不能跨线程共享。
- FMDatabaseQueue
为了保证线程安全,FMDB 提供了 FMDatabaseQueue。
内部实现方式:
- 维护一个 dispatch_queue(串行队列)。
- 通过 inDatabase: 或 inTransaction: 方法,将所有对数据库的操作 block 串行提交到这个队列里执行。
- 这样可以保证同一时间只有一个线程在访问数据库,从而避免 SQLite 的并发写入问题(SQLite 本身写操作也是串行化的)。
- 维护一个 dispatch_queue(串行队列)。
- FMDatabasePool
- 为了优化多读场景,FMDB 还提供 FMDatabasePool。
- 内部维护多个数据库连接(通常用于读操作)。
- 读操作可以并发分配给不同的连接,写操作仍然会被串行化。
结论:
- 单独使用 FMDatabase → 线程不安全
- 使用 FMDatabaseQueue/Pool → 线程安全
8.用过哪些锁?效率比较;@synchronized 的作用是什么?
- OSSpinLock(已废弃)
- 自旋锁,忙等,会不断轮询等待锁释放。
- 性能很高,但存在优先级反转问题(高优先级线程可能被低优先级线程“饿死”),Apple 已经不推荐使用。
- iOS 10+ 建议用 os_unfair_lock 代替。
- os_unfair_lock
- 自旋锁的替代品,会在等待时挂起线程,避免优先级反转。
- 适合短时间、高频加锁的场景。
- 性能比 pthread_mutex 更好。
- pthread_mutex(互斥锁)
- POSIX 标准互斥锁。
- 比较通用,支持递归锁(PTHREAD_MUTEX_RECURSIVE)。
- 开销比 os_unfair_lock 大一些,因为需要进入内核等待。
- NSLock
- Objective-C 封装,内部基于 pthread_mutex。
- 提供了面向对象的 API,简单易用。
- 性能略低于 os_unfair_lock。
- NSRecursiveLock
- 递归锁,允许同一线程多次获得同一把锁,避免死锁。
- 底层也是 pthread_mutex(recursive)。
- NSCondition / NSConditionLock
- 条件锁,用于线程间的条件同步(等待某个条件满足才继续)。
- 常用于“生产者—消费者”模型。
- dispatch_semaphore
- GCD 提供的信号量机制。
- 可用于资源计数、并发数控制,也可以当成锁来用。
- 性能和可控性较好。
- @synchronized
- Objective-C 关键字,基于 objc_sync_enter/objc_sync_exit 实现。
- 内部使用哈希表管理对象锁。
- 使用简单,但性能比 NSLock 差一些,因为额外做了对象管理和异常处理。
- 适合快速实现线程安全,但不推荐在性能敏感场景中使用。
9.上传大图,怎么分片,怎么控制线程最大并发数,怎么告诉web上传进度?上传进度细颗粒度,每片都有哪些状态,现有上传单个图片的方法,怎么实时同步进度
(1)分片策略
分片大小:一般 2MB5MB 一片比较合理(20M → 104 片)。
- 分片太小:请求数太多,开销大;
- 分片太大:失败重传成本高。
分片切割:用 NSData 的 subdataWithRange: 或者 InputStream,把整张图片按顺序拆成多个 NSData。
示例:1
2
3
4
5
6
7
8
9
10
11
12NSData *imageData = UIImageJPEGRepresentation(image, 0.9);
NSUInteger chunkSize = 2 * 1024 * 1024; // 2MB
NSUInteger length = [imageData length];
NSUInteger offset = 0;
NSMutableArray *chunks = [NSMutableArray array];
while (offset < length) {
NSUInteger thisChunkSize = MIN(chunkSize, length - offset);
NSData* chunk = [imageData subdataWithRange:NSMakeRange(offset, thisChunkSize)];
[chunks addObject:chunk];
offset += thisChunkSize;
}
(2)控制并发数
- 不要一次性全部并发上传(可能耗尽带宽/内存/线程)。
- 推荐用 NSOperationQueue 或 GCD 信号量来限制最大并发数。
例子:最多并发 3 个分片上传
1 | dispatch_semaphore_t semaphore = dispatch_semaphore_create(3); // 最大并发 3 |
如果要更优雅,可以用 NSOperationQueue,设置 maxConcurrentOperationCount = 3。
(3)上传进度
方式 A: NSURLSessionUploadTask + progress
- 每个分片创建一个 NSURLSessionUploadTask,监听其 NSProgress 对象。
- NSProgress 能返回当前上传的字节数,结合所有分片,汇总出整体进度。
方式B: NSURLSession delegate (更灵活)
汇总整体进度 所有分片进度的加权平均
- 每个分片有状态(Pending/Uploading/Success/Failed)。
- 上传时监听 NSURLSession 的进度回调,实时拿到字节数。
- 汇总所有分片进度得到整体进度。
- 再把整体进度同步给 UI 或 Web。
10.SDWebImage 加载图片的流程,缓存机制,LRU怎么实现的?加载图片传入了 UIImageView 下载完图片后怎么拿到这个对象,因为是异步的
(1)SDWebImage 加载图片的流程
- 入口:
1
[imageView sd_setImageWithURL:url placeholderImage:nil options:0 completed:nil];
传入 UIImageView 和 URL 后,会走到 SDWebImageManager 的 loadImageWithURL。
- 检查缓存:
• 先查 内存缓存(SDImageCache.memoryCache,底层是 NSCache + LRU)
• 内存没有,再查 磁盘缓存(通过 key -> 文件路径 / SQLite 索引) - 下载图片(如果缓存都没有):
• 调用 SDWebImageDownloader,内部基于 NSURLSession 创建下载任务,异步下载数据。
• 支持下载队列、并发控制、请求去重、进度回调。 - 解码 & 缓存:
• 图片数据下载完成后,会进行 解码(decode),避免主线程卡顿。
• 然后写入 内存缓存 + 磁盘缓存。 - 回调 UI:
• 下载完成后,主线程回调,把 UIImage 设置到传入的 UIImageView.image 上。
(2)缓存机制
SDWebImage 的缓存分两级:
- 内存缓存
使用 NSCache 封装(带自动清理机制,内存紧张时会自动回收)。
内部实现了 LRU (Least Recently Used) 算法:
- 每次访问一个图片,会把它放到链表头。
- 淘汰时从链表尾部移除最久未使用的对象。
- 在 SD 里,具体用的是 SDMemoryCache,基于 YYMemoryCache 的思想,维护了一个 **双向链表 + 哈希表** 结构。
- 磁盘缓存
旧版本用 NSKeyedArchiver 直接存二进制文件。
新版本用 sqlite + 文件混合存储:
- 小文件直接存 sqlite。
- 大文件写到磁盘,sqlite 里保存路径索引。
- 有过期策略、容量控制(例如超过 1 周 / 超过 500MB 自动清理)。
(3)LRU 实现思路(简化版)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30// LRU 的核心:哈希表 + 双向链表
class LRUCache {
NSMutableDictionary *dict; // key -> node
Node *head, *tail; // 双向链表
- (id)get:(NSString *)key {
Node *node = dict[key];
if (node) {
[self moveToHead:node]; // 最近使用过,移动到头部
return node.value;
}
return nil;
}
- (void)put:(NSString *)key value:(id)value {
Node *node = dict[key];
if (node) {
node.value = value;
[self moveToHead:node];
} else {
node = [[Node alloc] initWithKey:key value:value];
dict[key] = node;
[self addToHead:node];
if (dict.count > capacity) {
Node *removed = [self removeTail]; // 淘汰最久没用的
[dict removeObjectForKey:removed.key];
}
}
}
}
(4)UIImageView 与异步回调
UIImageView 是通过 AssociatedObject 绑定 URL 的,所以异步回调时 SDWebImage 能知道要给哪个 imageView 设置图。
11.OOM 怎么避免?
避免 OOM 的核心就是 减少单次内存峰值 + 控制整体内存占用 + 及时释放资源。实际项目中通常要针对业务做专项优化,比如:
• 大图 -> 缩放解码
• 视频 -> 硬解码,流式处理
• 数据 -> 分页加载,NSCache 管理
• 缓存 -> 收到内存警告立即清理
12.MVCS 与 SectionProvider 怎么实现的