本文系统梳理 iOS 性能优化的各个方面,包含原理、底层机制、八股文要点与优化手段。
目录
- [启动性能(Launch Time)](#启动性能(Launch Time) "#1-%E5%90%AF%E5%8A%A8%E6%80%A7%E8%83%BDlaunch-time")
- [渲染与流畅度(UI / FPS)](#渲染与流畅度(UI / FPS) "#2-%E6%B8%B2%E6%9F%93%E4%B8%8E%E6%B5%81%E7%95%85%E5%BA%A6ui--fps")
- 内存(Memory)
- [CPU 与耗电(Power)](#CPU 与耗电(Power) "#4-cpu-%E4%B8%8E%E8%80%97%E7%94%B5power")
- 网络(Network)
- [I/O 与存储(Disk)](#I/O 与存储(Disk) "#6-io-%E4%B8%8E%E5%AD%98%E5%82%A8disk")
- [包体积(App Size)](#包体积(App Size) "#7-%E5%8C%85%E4%BD%93%E7%A7%AFapp-size")
- 稳定性(Stability)
- 监控与工具
1. 启动性能(Launch Time)
启动时间是用户对 App 的第一印象,Apple 建议冷启动控制在 400ms 以内,超过 20s 会被系统 watchdog 杀死。
1.1 启动类型
- 冷启动(Cold Launch):进程不在内存,需要从磁盘加载可执行文件、动态库,重新创建进程。最耗时,是优化的核心。
- 热启动(Warm Launch):进程被杀但部分内容仍在内存缓存(page cache),加载更快。
- 回到前台(Resume):进程还在后台存活,几乎瞬时,不属于真正的启动。
1.2 启动阶段划分(以 main 为界)
整个启动可以分为 pre-main(main 函数之前) 和 main 之后 两大阶段。
1.2.0 前置知识:Mach-O、虚拟内存与 page fault
要真正理解启动,必须先理解三个底层概念,后面所有的优化都建立在它们之上。
(1) Mach-O 文件结构
Mach-O(Mach Object)是 iOS/macOS 可执行文件、动态库、Bundle 的格式,分三部分:
- Header(头部):标识 CPU 架构(arm64)、文件类型(可执行/dylib)、Load Commands 数量。
- Load Commands(加载命令) :是一张"说明书",告诉内核和 dyld 怎么加载这个文件。关键命令:
LC_SEGMENT_64:定义段(Segment)如何映射到虚拟内存。LC_LOAD_DYLIB:依赖哪些动态库(每条对应一个 dylib,越多加载越慢)。LC_LOAD_DYLINKER:指定用哪个 dyld。LC_MAIN:程序入口(main 的偏移)。LC_SYMTAB/LC_DYSYMTAB:符号表位置。
- Data(数据区) :由若干 Segment 组成,每个 Segment 又含若干 Section :
__TEXT段:代码(__text)、只读常量(__cstring)。只读可执行,可被多进程共享、可从磁盘按需重载(被回收后再次缺页可直接重新映射,不占脏内存)。__DATA段:可读写数据,全局变量、__objc_classrefs(类引用)、__la_symbol_ptr(懒绑定符号指针)、__nl_symbol_ptr(非懒绑定)。Rebase/Binding 主要修改这里,修改后变成脏页(dirty page)。__LINKEDIT段:给 dyld 用的元数据(符号表、rebase/binding 信息、代码签名)。
(2) 虚拟内存与 mmap
- 进程访问的是 虚拟地址 ,由 MMU(内存管理单元)通过页表映射到 物理地址 。页大小 iOS 上为 16KB。
- dyld 加载镜像并不是把整个文件读进物理内存,而是用 mmap 把文件"映射"到虚拟地址空间,建立映射但不实际读取。这一步很快。
- 真正访问某页代码/数据时,该页尚未在物理内存 → 触发 page fault(缺页中断) → 内核从磁盘把这一页读入物理内存 → 重新执行指令。
- 干净页(clean page) :内容与磁盘一致(如
__TEXT),内存紧张时可直接丢弃,下次缺页重新加载。 - 脏页(dirty page) :被写过(如 Rebase 后的
__DATA),不能简单丢弃,内存紧张时需写到别处(iOS 用 compressed memory 压缩,而非 swap)。脏页是真正消耗 App 内存配额的部分。
启动慢的物理本质:大量 page fault(I/O 等待) + 大量脏页生成(Rebase/Binding 写内存)。所有启动优化都是在减少这两件事。
(3) ASLR(地址空间布局随机化)
- 为安全,每次启动镜像被加载到 随机基址 。实际基址 = 链接时预期基址 + 一个随机 slide(偏移量)。
- 后果:二进制里所有"写死的指针"都不对了,必须运行时修正 → 这就是 Rebase 的根本原因。
1.2.1 pre-main 阶段(系统加载,dyld 主导)
dyld(dynamic link editor,动态链接器)负责把可执行文件和依赖的动态库加载进内存并完成链接。
dyld 版本演进(重要八股)
- dyld 2(iOS 13 前):纯运行时(just-in-time)链接。每次启动都要重新解析依赖、查符号、做 rebase/binding,耗时全在启动时发生。
- dyld 3(iOS 13+) :引入 启动闭包(launch closure) 。把"解析 Mach-O 头、依赖关系、符号查找结果"等可缓存的工作提前到 App 安装/首次启动时 计算好,写成闭包缓存。后续启动直接读闭包,跳过大量解析,显著加速。系统库的闭包内建在 dyld shared cache 中。
- dyld 4(iOS 15+) :进一步合并优化(PrebuiltLoader、引入
dyld_shared_cache更深度的预链接),但思想一致:能提前算的就提前算、能缓存的就缓存。
详细流程
- 加载 dyld 本身 :内核
execve后把 App 可执行文件 mmap 进内存,读取LC_LOAD_DYLINKER找到 dyld(/usr/lib/dyld),把控制权交给 dyld 的入口。 - 加载动态库(Load dylibs)
- dyld 读取主二进制的
LC_LOAD_DYLIB,递归 解析所有依赖的dylib。一个大型 App 可能依赖数百个库。 - 系统库(UIKit、Foundation...)已在 dyld shared cache(一个把所有系统库预链接合并的大缓存文件)中,所有进程共享,加载几乎零成本。
- 自定义动态库 每个都要:打开文件、读 header、mmap、递归解析其依赖、验证代码签名(首次访问每页都要校验签名哈希,触发缺页)。Apple 官方建议自定义动态库 不超过 6 个。
- 优化点 :减少自定义动态库数量,合并多个 framework 为一个 ,能用静态库就用静态库(静态库在 编译/链接期 就被打进主二进制,零运行时加载成本,且会被 dead-strip 裁剪无用代码)。
- dyld 读取主二进制的
- Rebase(地址重定位)
- 因 ASLR,镜像内部所有指向 自身 的指针(虚函数表、Class 内部指针、字符串指针等)都要加上 slide。
- dyld 读取
__LINKEDIT里的 rebase 信息(一串操作码 opcode 表示"在哪些偏移处 +slide"),逐个修正__DATA中的指针。 - 修正会 写
__DATA,把原本干净的页变成 脏页,并触发缺页读入 → I/O 密集 + 产生脏内存。 - 优化点:减少指针数量 → 减少 ObjC 类、方法、分类、C++ 虚函数、全局指针。
- Binding(符号绑定)
- 处理指向 外部库 的符号(如
_objc_msgSend、printf、_OBJC_CLASS_$_NSObject)。 - dyld 按符号名字符串去依赖库的符号表里查找真实地址,写回到
__DATA的__la_symbol_ptr/__got中。 - 懒绑定(lazy binding) :函数符号默认首次调用时才绑定(通过
dyld_stub_binder桩),分摊成本;非懒绑定(数据符号、ObjC class)启动时全绑。 - 是 CPU 密集(大量字符串比较查表)。
- 优化点:减少外部符号引用、减少 ObjC 类引用。
- 处理指向 外部库 的符号(如
- ObjC Setup(运行时初始化)
libobjc的_objc_init注册回调,dyld 加载完镜像后通知 runtime:- 把所有 ObjC 类注册到全局类表(
gdb_objc_realized_classes哈希表)。 - 读取
__objc_classlist、__objc_catlist,把 分类(Category) 的方法、属性、协议 插入 到宿主类的方法列表(attachCategories)。 - 确保所有 selector 全局唯一(注册到 selector 表)。
- 处理
+load方法所属类的 non-lazy class 实现(realize)。
- 把所有 ObjC 类注册到全局类表(
- 优化点:减少类、分类、selector 数量;很多工作其实和 Rebase/Binding 重叠,所以"减元数据"一举多得。
- Initializers(初始化器)
- dyld 按依赖顺序调用各镜像的初始化器:
- C++ 静态构造函数(
__attribute__((constructor))标记的函数、全局 C++ 对象的构造)。 - ObjC 的
+load方法:每个实现了+load的类和分类,在此被同步、串行调用 ,且发生在 main 之前。+load里若有耗时操作(注册、读文件、网络)会直接拖慢启动。 - 初始化带初值的全局/静态变量。
- C++ 静态构造函数(
+loadvs+initialize的本质区别(高频八股) :+load:App 启动时、main 之前,只要类被加载就 一定调用一次 ,无论是否用到,不能懒加载,且调用时机早、不安全(其他类可能还没初始化)。+initialize:在类 第一次收到消息 (被使用)时才调用,是 懒加载 的;通过objc_msgSend触发,由 runtime 保证线程安全和"父类先于子类"。若类从未被使用则永不调用。
- 优化点 :把
+load逻辑迁移到+initialize或首次使用时;删除空+load;避免在初始化器里做 I/O / 网络 / 锁等待。
- dyld 按依赖顺序调用各镜像的初始化器:
八股核心 :pre-main 耗时主要来自 dylib 加载、Rebase/Binding、ObjC 注册、Initializer 。物理本质是 缺页 I/O + 脏页生成 + 字符串查表 。三大优化方向:减少动态库、减少 ObjC 元数据、推迟/删除初始化逻辑。
1.2.2 main 之后阶段
main()→UIApplicationMain():- 创建
UIApplication单例和AppDelegate。 - 创建主线程 RunLoop 并启动(
CFRunLoopRun),让 App 进入事件循环、不退出。 UIApplicationMain内部会读取Info.plist找到 main storyboard 或交给SceneDelegate。
- 创建
application:didFinishLaunchingWithOptions::很多 App 在这里塞入大量 SDK 初始化(埋点、推送、崩溃、支付、A/B、热修...),是 可控的最大优化空间。- 首屏 ViewController:
loadView(创建根 view)→viewDidLoad(搭建子视图、请求数据)→viewWillAppear→ 首次 Auto Layout 布局 → 首次 渲染。 - 首帧渲染完成 (用户可见可交互),通常以
viewDidAppear或第一次CADisplayLink/RunLoop 进入休眠作为"启动结束"埋点。
main 后优化的核心思路
- 任务分级 :把
didFinishLaunching的初始化分为四类:- 首屏强依赖(必须同步、阻塞首帧):尽量少。
- 首屏后可做(首帧渲染完成再异步执行)。
- 空闲时做 (监听 RunLoop
kCFRunLoopBeforeWaiting,主线程空闲再执行)。 - 用到再做(懒加载,第一次调用时初始化)。
- 异步化 :非 UI、非首屏依赖的任务
dispatch_async到子线程。注意线程安全和并发数控制。 - 避免主线程 I/O:首屏不要同步读大文件 / 查数据库 / 解归档大对象。
- 首屏数据缓存:用上次的缓存先渲染(秒开),后台再刷新真实数据。
- 延迟 / 预渲染:用骨架屏、占位图减少"白屏"感知时间(感知性能 ≠ 真实耗时,但同样重要)。
1.3 启动优化手段汇总
| 阶段 | 优化手段 |
|---|---|
| dylib 加载 | 减少动态库、合并 framework、动态库转静态库 |
| Rebase/Binding | 减少 ObjC 类/方法/分类、减少 C++ 全局变量 |
| ObjC Setup | 删除无用类、合并分类 |
| Initializer | +load 改 +initialize、移除 __attribute__((constructor)) 耗时逻辑 |
| didFinishLaunching | SDK 懒加载/分级初始化、按需延迟、异步化非关键任务 |
| 首屏 | 首屏数据预加载/缓存、减少首屏层级、用骨架屏、避免主线程 I/O |
1.3.1 二进制重排(Binary Reordering / Clang Order File)
- 原理 :链接器默认按 编译单元(.o 文件)顺序 排列函数。启动期间用到的函数分散在二进制各处,按页(16KB)加载时,每碰到一个新页就触发一次 page fault。每次缺页要:陷入内核 → 磁盘读这一页 → 验证代码签名(CoreCrypto 算哈希)→ 映射回用户态。一次缺页约 0.x ~ 几 ms,启动期成千上万次缺页累计可观。
- 核心思想 :把 启动期间会调用到的所有函数集中排到二进制最前面、紧挨着,让它们落在尽量少的连续页里,从而把"几千次缺页"压缩成"几十次缺页"。
- 怎么拿到启动调用顺序 :
- 用 Clang 插桩
-fsanitize-coverage=func,trace-pc-guard,编译器在 每个函数入口 插入一个 hook 回调。 - 启动时这些 hook 按真实执行顺序被触发,记录下符号名(去重、保序)。
- 输出为
order_file(一行一个符号),在 Xcode 的Order File构建设置里指定,链接器据此排列函数。
- 用 Clang 插桩
- 验证 :用
DYLD_PRINT_STATISTICS或 System Trace 看缺页次数下降、pre-main / 首帧时间下降。 - 抖音(公开分享过 "启动优化之 Clang 插桩")、微信都用过,pre-main 可降 10%~30%。
进阶:除了函数重排,还有
__DATA段顺序优化 、冷热代码分离(把启动不用的代码移到后面)等思路,原理都是"减少启动期触及的页数"。
1.3.2 启动任务治理
- 把
didFinishLaunching的任务分级:首屏必需 / 首屏后 / 空闲时 / 用完再加载。 - 用任务调度框架(如有向无环图 DAG)管理依赖,异步并发执行非主线程任务。
dispatch_async到子线程、利用dispatch_once、RunLoop空闲(kCFRunLoopBeforeWaiting)时机执行低优先级任务。
1.3.3 测量方法
- DYLD_PRINT_STATISTICS=1 (Xcode → Scheme → Run → Environment Variables):打印 pre-main 各阶段耗时,输出形如:
total pre-main time总时间dylib loading time动态库加载rebase/binding timeObjC setup timeinitializer time(细看哪个库的 initializer 最慢)DYLD_PRINT_STATISTICS_DETAILS=1给更细粒度。
- Instruments → App Launch 模板:时间线看每个阶段、每个 initializer、首帧。
- 手动埋点(main 后) :在
main()第一行记t0(或用__attribute__((constructor))抢更早),首帧后(viewDidAppear或 RunLoop 首次 BeforeWaiting)记t1,t1-t0即 main 后耗时。pre-main 用DYLD数据或进程创建时间(sysctl取kp_proc.p_starttime)反推。 - MetricKit
MXAppLaunchMetric:线上采集真实用户启动时间分布(含histogrammedTimeToFirstDraw),适合大盘。 - 关键区分 :
DYLD_PRINT_STATISTICS只覆盖 pre-main;首帧时间要自己埋点;不要只盯 pre-main 而忽略didFinishLaunching里塞的 SDK(往往那才是大头)。
2. 渲染与流畅度(UI / FPS)
目标:稳定 60 FPS(普通屏)或 120 FPS(ProMotion)。一帧的预算分别是 16.67ms / 8.33ms。超过预算就会掉帧(卡顿)。
2.1 屏幕显示原理
2.1.1 帧缓冲、垂直同步与撕裂
显示器扫描原理
- 显示器(LCD/OLED)由电子/驱动逐行刷新像素,从 帧缓冲区(Frame Buffer) 读取像素数据。
- 刷新率(Refresh Rate):屏幕每秒刷新次数,60Hz = 每秒读取帧缓冲 60 次。
- VSync(垂直同步信号 Vertical Sync):屏幕扫描完一帧、回到左上角准备下一帧时发出的脉冲。
- HSync(水平同步):每扫描完一行发出,了解即可。
画面撕裂(Tearing)与双/三缓冲
- 单缓冲问题 :若 GPU 边写帧缓冲、屏幕边读同一块,会读到"半新半旧"的画面 → 撕裂。
- 双缓冲(Double Buffering) :GPU 画到 后缓冲(back buffer) ,画完在 VSync 时与 前缓冲(front buffer) 交换(swap),屏幕只读前缓冲。避免撕裂。
- 双缓冲的代价 :若 GPU 在 VSync 前没画完,这一帧只能继续显示前缓冲旧内容(掉帧),且 GPU 必须等交换后才能继续画下一帧 → 浪费。
- 三缓冲(Triple Buffering):再加一块缓冲,GPU 不必干等交换,可继续画下一帧,提升吞吐、减少卡顿,代价是多一块缓冲内存 + 略增延迟。iOS 实际采用类似机制。
掉帧(Jank)的精确定义
- 屏幕在每个 VSync 周期都要拿到一帧新内容。若到 VSync 时 CPU+GPU 还没把下一帧准备好放进可显示的缓冲 ,屏幕只能 重复显示上一帧,即掉帧。
- 一次掉帧用户可能无感,连续掉帧(如滑动时持续凑不齐)就是明显卡顿。
- 60Hz 下每帧预算 16.67ms,是 CPU 提交 + GPU 渲染 两者之和 必须挤进的窗口。
ProMotion 自适应刷新
- ProMotion 屏支持 可变刷新率(10~120Hz)。静止时降到低频省电,滑动/动画时升到 120Hz(每帧预算缩到 8.33ms,对性能要求更高)。
- 用
CADisplayLink.preferredFrameRateRange声明期望帧率。
2.1.2 CPU 与 GPU 的分工
一帧的生成是 CPU 和 GPU 协作的流水线:
- CPU 负责 :
- 布局(Layout):
layoutSubviews、Auto Layout 约束计算、frame 计算。 - 显示(Display):
drawRect:的 Core Graphics 绘制(CPU 软件绘制)。 - 准备(Prepare):图片解码(decode)、图片格式转换。
- 提交(Commit):打包图层树(layer tree)提交给 Render Server。
- 布局(Layout):
- GPU 负责 :
- 顶点变换、纹理合成、混合(Blending)、渲染到帧缓冲。
八股核心 :卡顿要么是 CPU 太忙 (布局/解码/计算阻塞主线程),要么是 GPU 太忙(离屏渲染、过度混合、纹理过大)。
2.2 Core Animation 渲染流水线
UIView 与 CALayer 的关系(基础)
- 每个
UIView内部都有一个CALayer(view.layer),视图负责事件响应,图层负责显示。 - 真正参与渲染的是 图层树(layer tree),UIView 只是对 CALayer 的封装 + 触摸事件处理。
- CALayer 持有一个
contents(通常是一块 bitmap,即 backing store)。改 frame、背景色、圆角等其实是改 CALayer 属性。 - 三棵树:模型树(model layer,你设置的目标值)→ 呈现树(presentation layer,动画当前帧的值)→ 渲染树(render tree,Render Server 内部)。
渲染流水线 6 步(跨进程协作)
参与方:App 进程 + Render Server(独立进程,旧称 backboardd,承载 Core Animation 合成) + GPU。
- Handle Events:处理触摸、定时器、网络回调等,可能修改视图(改 frame、加子视图)。被修改的图层被标记为"需要更新"。
- Commit Transaction (App 进程内,由 RunLoop 在本次循环末尾自动提交,4 子步骤):
- Layout(布局) :调用
layoutSubviews、updateConstraints,计算各图层 frame。Auto Layout 在此求解。CPU 操作。 - Display(绘制) :若实现了
drawRect:或用了CoreText/CoreGraphics,在这里把内容 用 CPU 软件绘制成 bitmap (位图),存入 CALayer 的 backing store。没实现drawRect:的普通视图跳过此步(系统直接交给 GPU 合成)。CPU 操作,且占内存。 - Prepare(准备) :图片 解码 (JPEG/PNG → bitmap)、格式转换(GPU 不支持的格式转成支持的)。CPU 操作。
- Commit(提交) :把更新后的图层树打包,通过 IPC(进程间通信) 发送给 Render Server。图层越多、层级越深,打包和传输越慢。
- Layout(布局) :调用
- Render Server 解码与生成指令 :收到图层树,反序列化,根据图层属性(位置、变换、透明度、圆角、阴影)调用 Metal(旧 OpenGL ES) 生成 GPU 绘制指令(draw call)。
- GPU 渲染:执行顶点着色(变换坐标)→ 光栅化(图元转像素)→ 片段着色(算每个像素颜色、采样纹理)→ 混合(Blending)→ 写入帧缓冲(back buffer)。
- Display:下一次 VSync 时交换缓冲,显示到屏幕。
关键 :第 2 步整个发生在 App 主线程 ,是我们能优化的部分。它必须在一帧内完成;一旦 Layout/Display/Prepare 太重(复杂约束、
drawRect:重绘、大图主线程解码),主线程超时,commit 赶不上 VSync → 掉帧。第 3、4 步在 Render Server/GPU,受离屏渲染、过度混合、纹理大小影响。
2.3 离屏渲染(Offscreen Rendering)
2.3.1 原理:为什么会"离屏"
先理解 iOS GPU 的 Tile-Based Deferred Rendering(TBDR)
- 移动 GPU(Apple/PowerVR)用 基于瓦片的延迟渲染 :把屏幕切成很多小 tile(瓦片) ,每个 tile 的渲染在 GPU 内部 超高速的片上内存(on-chip tile memory) 中完成,最后一次性写回显存(帧缓冲)。
- 好处:减少对慢速显存的读写,省带宽省电。
- 关键前提:每个像素的最终颜色能在"当前这一遍"内算完。
离屏渲染的本质
- On-Screen(当前屏渲染):GPU 在自己的工作流水线 + 帧缓冲里一次算完。
- 离屏渲染(Off-Screen) :当某些效果 必须先把多个图层的结果合成到一块临时缓冲,再基于这个结果做后续处理 时(比如要先合成出完整内容、再统一裁剪圆角/加遮罩),GPU 无法在一遍内完成,于是:
- 额外开辟一块 离屏缓冲区(offscreen buffer),在显存里,有内存开销。
- 切换渲染目标(render target)到这块缓冲------上下文切换会打断 tile 流水线、刷新片上内存,代价高。
- 渲染完再切回主帧缓冲、把结果贴上去。
- 多次上下文切换 + 额外显存读写 = GPU 负担陡增,滑动时尤其容易掉帧。
2.3.2 触发离屏渲染的场景(逐个解释)
cornerRadius+masksToBounds/clipsToBounds同时设置 :圆角裁剪需要先合成出图层及其子内容,再按圆角形状裁剪 → 离屏。(注意:只设cornerRadius不设masksToBounds,且只有背景色没有 contents/子层时,新系统已优化为不离屏。)layer.mask(遮罩):要先渲染被遮罩内容和遮罩,再做 alpha 相乘 → 离屏。layer.shadow*(阴影)未设shadowPath:GPU 需先渲染图层内容算出非透明区域形状,才能生成阴影 → 离屏。layer.allowsGroupOpacity=YES+ 图层透明度 < 1:组透明要先把子树合成再整体调透明 → 离屏。shouldRasterize=YES(光栅化) :主动离屏------把图层渲染一次后缓存为 bitmap,之后直接复用。内容静止时反而省;但内容频繁变化会不停重建缓存,反而更糟。UIVisualEffectView/UIBlurEffect(毛玻璃):需采样背景做模糊 → 离屏。- 抗锯齿、
edgeAntialiasingMask等也可能触发。
2.3.3 优化
- 圆角 :
- 纯色/图片圆角:用 已经画好圆角的图片 ,或
UIBezierPath+CoreGraphics在子线程把图片预先切成圆角 bitmap。 - 用
CAShapeLayer+ 圆角UIBezierPath作为 mask(仍可能离屏,但对纯色更可控)。 - 简单情况只设
cornerRadius不设裁剪,依赖系统新优化。
- 纯色/图片圆角:用 已经画好圆角的图片 ,或
- 阴影 :务必显式设置
layer.shadowPath(给出明确路径,GPU 不必再去算形状),可避免离屏,是性价比最高的优化之一。 - 光栅化 :仅对 内容基本不变 的复杂静态图层用
shouldRasterize;注意缓存 100ms 未使用会被回收,且缓存超过屏幕 2.5 倍像素也会失效。 - 用 Instruments → Core Animation → Color Offscreen-Rendered Yellow 检测:黄色区域即离屏,逐个消除。
2.4 其他渲染问题
2.4.1 图层混合(Blending)
- 原理 :当上层图层 半透明(alpha<1 或含透明像素) 时,该像素最终颜色 = 上层与下层按公式混合:
R = S·αs + D·(1−αs)(S 源色、D 目标色、αs 源透明度)。GPU 必须 读取下层颜色(额外显存读)再计算,逐像素做,增加 GPU 片段着色负担。 - 若图层 完全不透明(opaque) ,GPU 知道下层被完全遮挡,可 跳过下层,无需读取和混合(配合 TBDR 还能做 Hidden Surface Removal 提前剔除)。
- 优化 :
view.opaque = YES且backgroundColor设为 不透明纯色 (别留默认clearColor)。- 避免大量半透明层叠加;文字标签给不透明背景色。
- Instruments → Color Blended Layers:红色 = 发生混合,绿色 = 不混合。红色越少越好。
2.4.2 过度绘制(Overdraw)
- 原理 :同一块屏幕像素被多个图层 重复绘制(着色) 多次。比如背景 + 卡片 + 图标 + 蒙层叠了 5 层,每层都对该像素着色一次,GPU 像素填充率(fill rate)被浪费。
- 优化 :
- 减少视图层级和重叠。
- 隐藏 / 移除被完全遮挡的视图 (
hidden=YES或不加入层级)。 - 避免"一张大背景图 + 一堆覆盖其上的不透明子视图"这种全屏多层。
- Color Overdraw(Simulator/Instruments):颜色越深(越红)表示重绘次数越多。
2.4.3 图片解码(Image Decoding)
- 为什么要解码 :磁盘/网络上的图是 压缩格式 (JPEG 有损、PNG 无损),GPU 只能用 未压缩的位图(bitmap,每像素 RGBA 4 字节) 。显示前必须 解码(decompress):JPEG 还要做反 DCT 变换,CPU 密集。
- 默认时机的坑 :
UIImage(named:)/imageWithContentsOfFile:只是"懒加载",真正解码发生在第一次要显示时(CALayer 准备 contents 阶段),且在主线程同步进行 → 大图/多图同时显示就卡顿。 - 预解码(force decode)原理 :在 子线程 用
CGBitmapContextCreate创建一个上下文,把图CGContextDrawImage画进去,强制触发解码,得到已解码 bitmap,再回主线程赋给imageView。这样主线程不再承担解码。SDWebImage、Kingfisher、YYImage 都这么做。 - 内存换算 :一张 bitmap 占用 = 宽 × 高 × 4 字节,与压缩后文件大小 无关 。一张 4000×3000 的图解码后约 48MB,远超其几 MB 的 JPEG 体积------这也是图片导致 OOM 的主因。
- 降采样(Downsampling) :用
ImageIO(CGImageSourceCreateThumbnailAtIndex+kCGImageSourceThumbnailMaxPixelSize)按 实际显示尺寸 解码,避免把超大图全解进内存。比先解码再缩放更省内存。
2.4.4 Auto Layout 性能
- 原理 :Auto Layout 把约束转成一组 线性方程/不等式 ,用 Cassowary 增量约束求解算法 求解每个视图的 frame。
- 为什么慢 :约束之间相互依赖,求解复杂度随约束数量 超线性(接近指数)增长。嵌套深、约束多的视图树,每次 layout 都重算,滚动时反复触发就卡。
- 优化 :
- 复杂、需要极致性能的列表 cell 用 手动 frame 布局 (直接算 frame,O(n))或 Texture/ASDK(异步布局 + 绘制,绕开主线程 Auto Layout)。
- 减少约束数量、降低层级嵌套。
- 避免频繁
setNeedsLayout/ 反复增删约束;批量修改。 - 用
systemLayoutSizeFitting算高度也很贵,列表高度尽量缓存或预排版。
2.5 列表(UITableView / UICollectionView)卡顿优化
- Cell 复用(核心) :
dequeueReusableCellWithIdentifier:从复用池取离屏 cell,避免反复创建/销毁视图对象。滑出屏幕的 cell 进复用池,滑入的复用它(触发prepareForReuse重置状态)。 - 高度缓存 :
heightForRowAt在滚动时被频繁调用,若每次都 Auto Layout 算高度会卡。预先计算并按 indexPath/数据 id 缓存高度;用UITableView.automaticDimension也要配合estimatedRowHeight减少抖动。 - 异步绘制 :文本排版(CoreText 计算字形位置)、图片解码、富文本布局放 子线程,算好结果(bitmap/排版结果)再回主线程贴上。
- 减少层级与离屏渲染:cell 内子视图扁平化,避免圆角裁剪/阴影未设 path/混合。
- 按需加载 :高速滚动时(
scrollViewDidScroll中判断速度)只占位不加载图片,停止时(scrollViewDidEndDecelerating/DidEndDragging)再加载可见 cell 图片。 - 预排版(预计算) :把每个 cell 的所有子视图 frame、文本尺寸在 数据进入数据源时一次性算好 存进 model(VVeboTableViewDemo、ASDK/Texture 思路),
cellForRow里直接赋值,主线程几乎零计算。 - 数据源预处理 :JSON 解析、富文本生成、日期格式化等放子线程,别在
cellForRow里现算。
2.6 卡顿监控原理
为什么能监控:主线程都在 RunLoop 里
主线程所有任务(事件处理、布局、绘制、定时器、dispatch_async 到主队列的 block)都由 主线程 RunLoop 调度执行。RunLoop 一轮循环会在不同状态间切换,并通过 Observer 回调通知状态。
RunLoop 状态(CFRunLoopActivity):kCFRunLoopEntry → BeforeTimers → BeforeSources → BeforeWaiting(即将休眠)→ AfterWaiting(被唤醒)→ Exit。
三种监控方案
- RunLoop 状态监控(主流,微信 Matrix 思路) :
- 注册一个 RunLoop Observer 监听状态。
- 开一个 子线程 + 信号量 做"看门狗":每次主线程进入
BeforeSources或AfterWaiting(开始处理任务)时刷新状态,子线程dispatch_semaphore_wait设超时(如 50ms)。 - 若超时仍停留在"处理任务"状态没切换到
BeforeWaiting(休眠),说明主线程一个任务执行 > 50ms = 卡顿 → 子线程 dump 主线程调用栈 上报。
- 子线程 ping:子线程定时(如每秒)向主队列派一个标记任务并启动计时,若主线程在阈值内没执行该任务(标记没翻转),判定卡顿,dump 堆栈。
- CADisplayLink 测 FPS :
CADisplayLink每帧回调一次,统计单位时间回调次数估算 FPS(注意:FPS 只反映"掉没掉帧",不能直接定位卡在哪个函数,需配合堆栈)。
堆栈抓取
- 卡顿发生在主线程,监控逻辑在子线程,需用
task_threads拿到主线程thread_t,再thread_get_state取寄存器,回溯栈帧(__builtin_frame_address/backtrace),结合 dSYM 符号化得到卡顿调用栈。
3. 内存(Memory)
3.1 内存管理机制:MRC、ARC 与引用计数
3.1.1 引用计数(Reference Counting)
- 每个对象有一个 引用计数(retainCount) ,
retain+1,release-1,减到 0 时调用dealloc释放内存(free)。 - MRC(手动引用计数) :手写
retain/release/autorelease,遵循"谁创建谁释放"。 - ARC(自动引用计数) :编译期由 LLVM(前端) 在合适位置 自动插入
retain/release/autorelease调用,运行时由 ObjC Runtime 配合管理。 - ARC ≠ GC(垃圾回收) :GC 需运行时周期性扫描堆、标记清除,有 STW 停顿;ARC 是 编译期确定 + 运行时计数 ,无扫描、无停顿,确定性释放。代价是 循环引用需手动打破(GC 能自动回收环,ARC 不能)。
- ARC 的运行时优化 :
objc_retainAutoreleasedReturnValue/objc_autoreleaseReturnValue配合,在"方法返回 autorelease 对象、调用方立即 retain"这一常见模式下,跳过加入 autoreleasepool,直接转交所有权(Tail Call 优化),减少开销。
3.1.2 引用计数存储位置:isa 与 SideTable
isa 指针的演进
- 早期
isa是纯粹的类指针(Class isa)。 - 现代 64 位用 nonpointer isa :
isa是一个 union(isa_t,8 字节 64 位) ,只用部分位存类地址(shiftcls),其余位塞了很多信息(位域):nonpointer:1 位,标记是否为优化过的 isa。has_assoc:是否有关联对象。has_cxx_dtor:是否有 C++/ObjC 析构逻辑(影响释放路径)。shiftcls:类指针(占 33 位左右,因内存对齐低位为 0 可省)。magic、weakly_referenced(是否被 weak 引用过)、deallocating(是否正在释放)。extra_rc:额外引用计数 ------引用计数 优先存这里,直接位运算,极快。has_sidetable_rc:标记引用计数是否已溢出到 SideTable。
- 流程 :retain 时先加
extra_rc;extra_rc满了(溢出),把一半挪到 SideTable 并置has_sidetable_rc=1,之后大计数走 SideTable。
SideTable
- 全局有一组 SideTable (数量固定,按对象地址哈希分散,减少锁竞争),每个含三部分:
spinlock_t slock:保护本表的锁(现为os_unfair_lock)。RefcountMap refcnts:引用计数表(溢出部分的计数)。weak_table_t weak_table:弱引用表。
- 为什么分多张表:若全局一张表,所有对象的 retain/release 都抢一把锁,并发性能差。分桶 + 按地址哈希降低锁粒度。
3.1.3 弱引用(weak)原理
weak指针 不增加引用计数 ,且在对象释放时 自动置nil,从根本上避免"悬垂指针/野指针"访问已释放内存。- 底层实现(weak_table_t) :
- 是个哈希表,key = 对象地址 ,value = weak_entry_t (记录所有指向该对象的 weak 指针变量的 地址 ,即
&weakPtr列表)。 __weak id p = obj;编译为objc_initWeak→storeWeak:以 obj 地址为 key,把&p注册进 weak_table;同时把 obj 的 isaweakly_referenced置 1。
- 是个哈希表,key = 对象地址 ,value = weak_entry_t (记录所有指向该对象的 weak 指针变量的 地址 ,即
- 自动置 nil 的时机 :对象
dealloc→objc_clear_deallocating→ 在 weak_table 里以对象地址查到 weak_entry → 遍历所有登记的 weak 指针地址,逐个写入 nil → 从表中删除该 entry。 - 性能开销 :weak 的读写都要 加锁查 weak_table ,比
strong(多数时候只动 isa 的 extra_rc)/assign(纯指针赋值)更慢。高频访问的属性慎用 weak。 unsafe_unretained/assign对象指针:不计数、也不自动置 nil,对象释放后变野指针,危险。
3.1.4 Tagged Pointer(小对象优化)
- 对于
NSNumber、NSDate、短NSString等 小值对象 ,64 位下值可能直接 编码进指针本身(指针的部分位存类型标记 + 数据),不在堆上分配对象。 - 好处:无需 malloc/free、无引用计数、读取极快,省内存。
- 识别:指针最高/最低位被设为标记位(受平台与是否开启混淆影响)。
- 注意:判断对象是否相等、自定义 isa 操作时要小心 Tagged Pointer 没有真实 isa 内存。
3.2 内存布局(虚拟地址空间分区)
进程的虚拟地址空间从低到高大致:
- 代码区(
__TEXT) :可执行机器指令,只读 + 可执行,多进程可共享,干净页。 - 常量区 :字符串字面量、
const全局只读数据。 - 全局/静态区 :
__DATA:已初始化的全局/静态变量。__BSS:未初始化(或初值为 0)的全局/静态变量,运行时清零,不占文件体积。
- 堆区(Heap) :
alloc/malloc/new动态分配的对象,由低地址向高地址增长,空间大(GB 级,受设备限制)。由程序员/ARC 管理,是内存泄漏发生地。 - 栈区(Stack) :函数调用帧、局部变量、参数、返回地址。由高地址向低地址增长 ,自动分配释放(函数返回即弹栈)。空间小:主线程约 1MB,子线程默认 512KB 。递归过深/超大局部数组会 栈溢出(SIGSEGV / 栈爆)。
- 内核区:用户态不可直接访问。
补充:iOS 用 malloc 的分级分配器 (nano/tiny/small/large zone),小对象走专门的小内存区,减少碎片。
calloc/malloc大块时直接 mmap 匿名内存。
3.3 autorelease 与 @autoreleasepool
3.3.1 原理与数据结构
autorelease把对象加入 当前自动释放池 ,延迟到池 drain(释放) 时再统一release。用于"我创建了对象要返回给你,但不想自己管释放时机"的场景。- AutoreleasePoolPage 结构 :
- 自动释放池 没有单独的类 ,本质是以线程为单位的一个 双向链表 ,节点是
AutoreleasePoolPage,每页大小 4096 字节(4KB)。 - 每页除了
parent/child(链表指针)、thread(所属线程)等成员外,剩余空间像 栈 一样从低到高存放 autorelease 对象的指针。 - 一页满了就新建一页,用
child/parent串起来。
- 自动释放池 没有单独的类 ,本质是以线程为单位的一个 双向链表 ,节点是
- 哨兵(POOL_BOUNDARY / 边界对象) :
@autoreleasepool {}编译成:开头objc_autoreleasePoolPush(),结尾objc_autoreleasePoolPop(atautoreleasepoolobj)。push:在栈顶压入一个 哨兵(nil 边界),返回哨兵地址。表示"一个新池开始"。- 每次
autorelease:把对象指针压栈。 pop(哨兵):从栈顶一路release所有对象,直到遇到该哨兵为止,并回收页。
- 嵌套的
@autoreleasepool就是多个哨兵分段。
3.3.2 与 RunLoop 的关系
- 主线程 RunLoop 启动时注册了 两个 Observer :
- 优先级最高的 Observer 监听
kCFRunLoopEntry:调用push创建池。 - 优先级最低的 Observer 监听
kCFRunLoopBeforeWaiting(即将休眠)和kCFRunLoopExit:休眠前先pop(释放上一轮的 autorelease 对象)再push(为下一轮建新池);退出时pop。
- 优先级最高的 Observer 监听
- 所以:一次 RunLoop 循环里产生的 autorelease 对象,会在这次循环结束(主线程即将休眠)时统一释放 。这就是"为什么平时不写
@autoreleasepool也不会一直泄漏"的原因。
3.3.3 优化
- 循环里产生大量临时对象 时(如 for 循环里反复创建
NSString/图片),它们都进同一个 RunLoop 的池,要等循环结束才释放,内存峰值飙高甚至 OOM。 - 解决:循环体内手动包
@autoreleasepool { ... },每轮迭代结束立即释放临时对象,压平内存峰值。 - 子线程默认 没有自动的 autoreleasepool (除非起了 RunLoop),手动管理时建议自己加
@autoreleasepool。
3.4 内存泄漏与循环引用
3.4.0 循环引用的本质
- 引用计数法的天生缺陷:A 强引用 B,B 强引用 A,两者计数永远 ≥1,谁都到不了 0,永远不会 dealloc → 泄漏。环越大越隐蔽(A→B→C→A)。
3.4.1 常见循环引用
- block :实例持有 block 属性(
copy),block 内部又 强捕获 self → self↔block 成环。- 解法:
__weak typeof(self) weakSelf = self;在 block 内用 weakSelf;若 block 内需多次用且怕中途释放,再__strong typeof(weakSelf) strongSelf = weakSelf;(strong-weak dance:弱引用避免环,进入 block 后临时强持防止执行到一半被释放)。 - 注意:
dispatch_async、UIView animateWithDuration、非 self 持有的 block(如系统的)不会成环,无需 weak。
- 解法:
- block 捕获原理 :block 是个结构体,捕获的对象变量会被 复制进 block 的结构体并 retain (ARC 下
Block_copy时);block 销毁时 release。所以 self 被 block retain,若 self 又持有该 block 即成环。 - delegate :委托方持有代理(
@property (weak) id<XXDelegate> delegate;必须 weak),否则 A 持有 B 作 delegate、B 又是 A 的子视图/被 A 持有 → 成环。 - NSTimer :
scheduledTimerWithTimeInterval:target:...中 timer 强引用 target(self) ,而 timer 被 RunLoop 强引用 ,于是 RunLoop→timer→self,self 即使被释放也因 timer 存活而无法释放(且dealloc不会调,invalidate没机会执行)。- 解法:① iOS 10+ 用 block 版
timerWithTimeInterval:repeats:block:+[weak self];② NSProxy 中间代理 :自定义继承NSProxy的 weak proxy,把消息转发给真实 target,timer 强引用的是 proxy,proxy 弱引用 self;③CADisplayLink同理(也强引用 target)。
- 解法:① iOS 10+ 用 block 版
- 闭包捕获(Swift) :闭包默认 强捕获 引用类型。用捕获列表
[weak self](self 可能为 nil,用可选绑定)或[unowned self](确定生命周期内 self 不会先释放,否则崩溃)。
3.4.2 检测工具
- Xcode Memory Graph Debugger:可视化对象引用关系,发现环。
- Instruments Leaks:检测无法访问的泄漏内存。
- Instruments Allocations:内存分配统计、abandoned memory。
- MLeaksFinder(腾讯):基于 ViewController/View 释放后弱引用断言检测泄漏。
- FBRetainCycleDetector(Facebook):运行时遍历对象引用图找环。
3.5 内存警告与 OOM
3.5.1 内存上限(Jetsam / 内存压力机制)
- iOS 没有磁盘交换区(swap) 。物理内存紧张时改用 内存压缩(compressed memory) :把不活跃的脏页压缩留在内存里;仍不够则由 Jetsam(jetsam,内核的内存压力守护) 按 优先级(前台 App 优先级高、后台低) 直接 杀进程 回收内存。
- 前台 App 自身内存 超过单进程上限 (每设备不同,老设备更小)也会被直接杀 → OOM(FOOM,Foreground OOM)。
- OOM 与普通崩溃的区别 :OOM 是被内核
kill,不是信号/异常 ,进程没有机会执行 crash handler,不会生成常规 crash 堆栈,所以传统 crash SDK 抓不到,需要专门检测。 - 内存计算口径:关注 footprint(脏页 + 压缩内存 + IOKit 等),不是简单的"分配字节数"。
3.5.2 处理
- 收到内存警告:
didReceiveMemoryWarning/UIApplicationDidReceiveMemoryWarningNotification→ 释放 可重建的缓存(图片缓存、预渲染结果)、释放不可见页的资源。 - 主动监控水位:用
task_info(TASK_VM_INFO)取phys_footprint,或 iOS 13+ 的os_proc_available_memory()取剩余可用额度,接近上限时主动降级(降图片质量、清缓存、限制并发)。
3.5.3 OOM 检测
- 排除法(微信 OOMDetector / Facebook 思路) :App 下次启动时判断上次退出原因。若上次 不是 :正常退出、主动 crash(有 crash 日志)、被升级/重启、后台被杀、看门狗超时......那大概率就是 前台 OOM。
- 配合记录退出前的内存快照、大对象分配栈,定位是谁吃的内存。
- MetricKit
MXAppExitMetric(iOS 14+)能区分各种退出原因,含因内存资源限制退出的计数,官方、低成本。
3.6 内存优化要点
- 图片:按显示尺寸 downsample,使用合适缓存策略(内存+磁盘双层),及时清理。
- 复用:Cell、对象池。
- 懒加载:用到再创建。
- 大图/大数据:用
mmap内存映射、分块加载。 - 避免
imageNamed:缓存所有图片(适合频繁使用的小图,大图用imageWithContentsOfFile:)。
4. CPU 与耗电(Power)
4.1 耗电来源
电量消耗主要来自:CPU/GPU 计算、屏幕、网络(蜂窝/WiFi 射频)、GPS 定位、传感器、蓝牙。其中射频(Radio)和 GPS 是耗电大户。
4.2 CPU 优化
- 降低主线程负载:耗时计算、解析、加解密、压缩放子线程(GCD / NSOperation)。
- 算法复杂度:避免 O(n²) 操作、减少重复计算、加缓存。
- 避免忙等与高频定时器:高频 timer 让 CPU 无法休眠,增加功耗。
- 合并任务:减少线程频繁唤醒,让 CPU 有机会进入低功耗状态。
4.3 多线程与 GCD 底层
线程、队列、池的关系
- 线程(thread) 是 CPU 调度的基本单位,由内核调度到 CPU 核心上执行。并行(parallel) = 多核同时跑多个线程;并发(concurrency) = 单核上线程快速切换造成的"同时"错觉。
- GCD(Grand Central Dispatch / libdispatch) 帮你管理一个 全局线程池 ,你只管把任务(block)丢进 队列(dispatch_queue) ,由 GCD 决定用哪个线程执行,避免手动
NSThread管理。 - 队列类型 :
- 串行队列(serial) :FIFO,一次只取一个任务执行完再下一个(同一时刻只占一个线程)。主队列
dispatch_get_main_queue是特殊串行队列,绑定主线程。 - 并发队列(concurrent) :可同时取多个任务分发到多个线程并行。
dispatch_get_global_queue是全局并发队列。
- 串行队列(serial) :FIFO,一次只取一个任务执行完再下一个(同一时刻只占一个线程)。主队列
- 同步 vs 异步 :
dispatch_sync:阻塞当前线程 直到任务完成,不开新线程(在当前线程执行)。易死锁 :在串行队列里向同一队列sync会自己等自己(如主线程向主队列 sync)。dispatch_async:不阻塞 ,任务交给队列,GCD 视情况 从池里取或新建线程 执行。
- 底层调度 :libdispatch 维护工作线程池,配合内核的 workqueue 机制按需创建/复用线程;任务多时增线程,空闲时回收。
线程过多的危害(线程爆炸)
- 每个线程占 栈内存(子线程 512KB) + 内核线程结构。
- 上下文切换 成本:保存/恢复寄存器、刷 TLB/缓存,切换太频繁 CPU 都在"切换"而非"干活"。
- 大量
dispatch_async里若都执行 阻塞操作(同步网络、sleep、锁等待) ,GCD 为了让队列继续推进会 不断新建线程,导致线程暴涨甚至卡死/崩溃。 - 避免 :用 并发数受控 的方式------
NSOperationQueue.maxConcurrentOperationCount、dispatch_semaphore限流;阻塞操作别一股脑 async。
QoS(Quality of Service 服务质量)
给任务/队列标优先级,让系统按重要性调度并平衡性能与功耗:
userInteractive:与用户交互、动画相关,最高优先级,要立即完成。userInitiated:用户发起、等待结果(点开页面加载)。utility:耗时任务,有进度(下载)。background:用户无感的后台任务(同步、备份),系统会安排到省电时段、低优先级核心。- 合理标 QoS 能让系统把后台任务调度到 能效核心(小核),省电。
4.4 锁与线程安全
为什么需要锁
多个线程并发读写同一共享资源(如可变数组、计数器)会产生 数据竞争(data race) ,结果不可预测甚至崩溃。锁保证 临界区 同一时刻只有一个线程进入(互斥)。
锁的两大类机制
- 自旋锁(spin lock) :拿不到锁就 忙等(循环空转),不让出 CPU。适合临界区极短的场景(切换线程的代价 > 等待代价)。缺点是空转耗 CPU。
- 互斥锁(mutex) :拿不到锁就 休眠,让出 CPU,锁释放时被唤醒。适合临界区较长。缺点是有线程切换开销。
常见锁性能(大致从快到慢)
os_unfair_lock > dispatch_semaphore > pthread_mutex > NSLock / NSCondition > NSRecursiveLock > @synchronized。
OSSpinLock(已废弃,不安全) :典型自旋锁,存在 优先级反转(priority inversion) :低优先级线程持锁,高优先级线程自旋等待并占满 CPU,导致低优先级线程 得不到 CPU 时间片去释放锁 ,形成活锁。被os_unfair_lock取代(它会让等待线程休眠并把持锁线程优先级提升,规避反转)。os_unfair_lock:现代首选互斥锁,开销小。dispatch_semaphore:信号量,wait(-1,为负则阻塞)/signal(+1)。可做锁(初值 1)也可做并发数限流(初值 N)。底层value≥0时纯原子操作,极快。pthread_mutex:POSIX 互斥锁,可配置普通/递归/错误检查类型。@synchronized(obj):最易用但最慢------内部用 obj 地址在 全局哈希表 查/建一把递归锁,还包了 异常处理(@try/@finally),开销大。但能防重入。NSRecursiveLock:递归锁,同一线程可重复加锁不死锁(计数)。- 读写锁 :读多写少时用
pthread_rwlock(多读单写)或 GCD 栅栏dispatch_barrier_async(并发队列里读用 async、写用 barrier,写时独占、读时并发)。
死锁四条件 & 避免
- 互斥、持有并等待、不可剥夺、循环等待 四者同时成立才死锁。
- 避免:统一加锁顺序、用超时、减少嵌套锁、主线程别向主队列 sync。
4.4b RunLoop 深入(贯穿启动、流畅度、内存、监控)
RunLoop 在前面多处出现,单独讲清楚它的底层。
是什么
- RunLoop 是一个 "事件循环" :让线程在 有事做时干活、没事做时休眠(不空转、不退出) 。本质是一个
do-while,但休眠时是 内核态阻塞(mach_msg),不占 CPU。 - 主线程默认 开启 RunLoop(所以 App 不会跑完 main 就退出);子线程默认 没有 ,需手动
[[NSRunLoop currentRunLoop] run]。 - 对应
CFRunLoop(Core Foundation 层),NSRunLoop是其封装。
核心结构
- 一个 RunLoop 含多个 Mode(模式) ,同一时刻只跑一个 Mode。常见:
kCFRunLoopDefaultMode(默认)、UITrackingRunLoopMode(滑动时)、kCFRunLoopCommonModes(占位集合,被标为 common 的 mode 会同步注册)。 - 为什么滑动时定时器停了 :默认把 timer 加在 DefaultMode,滑动时 RunLoop 切到 TrackingMode,timer 不在该 mode 就不触发。解决:加到
commonModes。 - 每个 Mode 含三类事件源:
- Source0 :App 内部事件(触摸、
performSelector:onThread:),需手动唤醒。 - Source1:基于 mach port 的内核事件(系统事件、CADisplayLink),能主动唤醒 RunLoop。
- Timer:定时器(NSTimer/CADisplayLink 本质)。
- Observer:观察状态切换(前面 autoreleasepool、卡顿监控都用它)。
- Source0 :App 内部事件(触摸、
一轮循环流程(简化)
- 通知 Observer 进入循环。
- 处理 Timer、Source0。
- 若有 Source1 待处理,跳去处理。
- 通知 Observer 即将休眠(
BeforeWaiting)→ 调用mach_msg进入内核休眠,等待端口消息唤醒。 - 被唤醒(Source1/Timer/手动),通知
AfterWaiting,处理对应事件。 - 回到第 2 步循环,直到超时/被停止。
应用
- autoreleasepool:随循环 push/pop(见 3.3.2)。
- 卡顿监控:监听状态停留时长(见 2.6)。
- 滑动流畅 :把图片加载等任务 注册到 DefaultMode + 空闲时执行,滑动(TrackingMode)时不打扰。
- AsyncDisplayKit/性能优化 :利用
BeforeWaiting空闲时机执行低优先级绘制任务。 - 线程保活:给子线程加 RunLoop + Source 让它不退出,复用线程(如 AFNetworking 的常驻线程)。
4.5 定位与后台
- 定位精度按需选择,
kCLLocationAccuracyBest最耗电;用 significant location change 或区域监听替代高频定位。 - 后台任务用
BGTaskScheduler,让系统在合适时机(充电/WiFi)批量执行,避免频繁唤醒。 - 网络合并:批量、合并请求,利用射频唤醒窗口(射频开启后有 tail time 持续耗电)。
4.6 监控
- Instruments Energy Log 、Time Profiler (CPU 热点)、System Trace。
- MetricKit
MXCPUMetric、MXCellularConditionMetric。 - Xcode Energy Gauge。
5. 网络(Network)
5.1 网络协议栈基础
一次 HTTPS 请求的完整时间线(八股必背)
DNS 解析 → TCP 三次握手 → TLS 握手 → 发送请求 → 服务器处理 → 接收首字节(TTFB) → 接收完整响应。优化就是缩短/省略其中每一段。
TCP 三次握手 / 四次挥手
- 三次握手 (建立连接,1 RTT 才能开始发数据):
- 客户端发
SYN(seq=x)。 - 服务端回
SYN+ACK(seq=y, ack=x+1)。 - 客户端发
ACK(ack=y+1)。之后才能传数据。
- 客户端发
- 为什么三次 :确认双方 收发能力都正常,防止历史失效连接请求突然到达造成误建连。
- 四次挥手 (关闭):FIN→ACK→FIN→ACK,因为 TCP 全双工,两个方向要各自关闭;
TIME_WAIT等待 2MSL 确保对端收到最后 ACK。 - 拥塞控制 :慢启动(cwnd 指数增长)、拥塞避免、快重传/快恢复。首次连接窗口小,所以大文件冷连接初期慢------这也是连接复用重要的原因。
TLS 握手
- 作用:协商加密套件、交换密钥、验证证书,保证机密性 + 完整性 + 身份认证。
- TLS 1.2 :约 2 RTT(ClientHello/ServerHello + 证书 + 密钥交换)。
- TLS 1.3 :简化到 1 RTT ;会话复用(session resumption / PSK) 可达 0-RTT(首个数据随握手一起发,有重放风险需防护)。
- 优化:升级 TLS 1.3、开启 会话复用(Session ID / Session Ticket)、证书链精简、OCSP Stapling 避免额外验证请求。
HTTP 版本演进
- HTTP/1.0:每请求一个 TCP 连接,用完即关,开销大。
- HTTP/1.1 :
- Keep-Alive 持久连接,复用 TCP。
- 队头阻塞(Head-of-Line Blocking) :同一连接上请求必须 按序 收响应,前一个慢则后面全堵。浏览器只好开多条连接(一般 6 条)绕过。
- 管线化(pipelining)实践中基本没用。
- HTTP/2 :
- 二进制分帧(frame):把消息拆成帧(HEADERS 帧、DATA 帧),带 Stream ID。
- 多路复用(multiplexing) :一条 TCP 连接 上并发多个 stream,帧交错传输,互不阻塞 → 解决了 HTTP 层 队头阻塞。
- 头部压缩(HPACK):用静态表 + 动态表 + 霍夫曼编码压缩重复的 header(cookie、UA)。
- Server Push (已基本废弃)、流优先级。
- 遗留问题 :底层仍是 TCP,TCP 层丢包 会阻塞该连接上所有 stream(TCP 队头阻塞)。
- HTTP/3 / QUIC :
- 基于 UDP 自建可靠传输(QUIC),把可靠性、拥塞控制、TLS 1.3 都做进用户态。
- 彻底解决队头阻塞:每个 stream 独立,丢包只影响该 stream。
- 0-RTT 建连 (含加密)、连接迁移(用 Connection ID 标识连接,Wi-Fi↔蜂窝切换 IP 变了也不断连)。
- 用户态实现,可快速迭代,不依赖系统内核 TCP 栈。
5.2 优化手段
5.2.1 连接优化
- 连接复用 :Keep-Alive、HTTP/2 多路复用,复用已建立的 TCP+TLS,省掉握手的 2~3 个 RTT 和慢启动初期低吞吐。
URLSession默认会复用同一 host 的连接。 - DNS 优化 :
- LocalDNS 的问题 :运营商 DNS 可能 解析慢、被劫持、调度不准(返回非最优 IP/缓存过期 IP)。
- HTTPDNS :绕过 LocalDNS,直接用 HTTP/HTTPS 请求自家 DNS 服务 拿到准确 IP(按地域/运营商精准调度),返回的 IP 直接用于建连(IP 直连),避免劫持、降低解析时延。
- DNS 预解析:App 启动/进入页面前提前解析常用域名,缓存结果。
- 注意 IP 直连后 TLS 校验的 SNI / Host 要正确设置。
- TLS 优化:升级 TLS 1.3、开启会话复用(0-RTT/1-RTT)、精简证书链、OCSP Stapling。
- 预连接(pre-connect):可预测的下一步请求,提前把 TCP+TLS 建好(如启动后预热 API 域名连接),点击时直接发数据。
5.2.2 数据优化
- 压缩:gzip / Brotli 压缩响应体。
- 数据格式:Protobuf / FlatBuffers 替代 JSON,体积更小、解析更快。
- 图片:WebP / HEIF、按需尺寸、CDN 裁剪。
- 增量更新:只传 diff。
5.2.3 缓存策略
- HTTP 缓存语义(精确) :
- 强缓存 :响应头
Cache-Control: max-age=3600(相对时间,优先)或Expires(绝对时间)。在有效期内 直接用本地缓存,不发请求。 - 协商缓存 (强缓存过期后):
ETag(资源指纹)↔ 请求头If-None-Match;Last-Modified(最后修改时间)↔ 请求头If-Modified-Since。- 服务端比对,未变返回 304 Not Modified(无 body),省下载;变了返回 200 + 新内容。
Cache-Control指令:no-cache(可缓存但每次需协商)、no-store(完全不缓存)、private/public、stale-while-revalidate(先用旧的再后台刷新)。- iOS 用
URLCache(内存+磁盘)自动处理 HTTP 缓存,可自定义cachePolicy。
- 强缓存 :响应头
- 业务缓存 :API 数据本地落库(SQLite/文件)+ 过期策略;先返缓存秒开,再异步拉新刷新 UI(SWR 模式),弱网/无网时降级到缓存。
5.2.4 弱网与稳定性
- 超时设置、重试(指数退避)、请求合并、请求优先级。
- 弱网检测降级、断点续传、预加载。
- 离线队列:无网时缓存请求,有网时重发。
5.3 监控
- 拦截
NSURLProtocol或 hook URLSession 统计请求耗时、成功率、各阶段(DNS/连接/TLS/首包/总)耗时。 URLSessionTaskMetrics提供精细的分阶段时延数据。
6. I/O 与存储(Disk)
6.1 I/O 原理
速度层级
- 寄存器 < L1/L2/L3 缓存 < 内存(纳秒级)<< 闪存/SSD(微秒~毫秒级)。磁盘 I/O 比内存慢几个数量级,是常见瓶颈。
- iOS 设备用 NAND 闪存 ,随机读不错但写有 写放大、垃圾回收,频繁小写比批量写差。
普通读写(read/write 系统调用)
read()/write()是 系统调用 ,要 用户态↔内核态切换 ,数据要在 内核页缓存(page cache)↔ 用户缓冲区 之间 拷贝一次。- 写入默认是 异步落盘 :先写 page cache(标记 dirty),内核稍后批量刷盘。所以
write返回 ≠ 已经在磁盘上。 fsync():强制把该文件的脏页 立即刷到物理磁盘并等待完成 ,保证持久化(防丢数据),但 很慢(等真正的磁盘写)。数据库提交、关键数据才需要。
mmap(内存映射,重点)
mmap把文件 直接映射进进程虚拟地址空间,访问文件像访问内存数组一样(指针读写)。- 原理 :建立虚拟页 ↔ 文件页的映射,不立即读盘 ;访问某页时触发 缺页中断,内核按页加载(按需、懒加载)。
- 优势 :
- 少一次拷贝:普通 read 是 "磁盘→内核 page cache→用户缓冲区",mmap 是 "磁盘→page cache(直接映射给用户)",省去内核↔用户的那次 memcpy(零拷贝思想)。
- 按页懒加载 ,适合 大文件随机访问,不必一次性读入内存。
- 写入修改的是映射内存,由内核异步回写文件,进程崩溃也不会丢(数据已在内核 page cache/文件),这正是 MMKV 高性能 + 防丢的基础。
- 代价 :小文件用 mmap 不划算(映射本身有开销);写时机不完全可控(需
msync强制同步)。
6.1b MMKV / SQLite 底层(深入)
MMKV(微信开源 KV 存储)为什么快
- 痛点 :
NSUserDefaults每次写都可能 全量序列化整个 plist 落盘,高频写性能差,且非线程安全。 - MMKV 方案 :
- 用 mmap 把存储文件映射到内存,写入即写内存,由内核异步回写,避免每次 write/fsync 系统调用。
- 用 Protobuf 序列化 KV,紧凑高效。
- 增量追加(append) :更新某 key 不重写整个文件,而是把新值 追加 到末尾(读时后值覆盖前值);文件增长到阈值再 回写整理(defrag)。
- 结果:写性能比 NSUserDefaults 高一两个数量级,进程被杀也不丢已写数据。
SQLite 的事务与 WAL
- 事务(transaction) :一组操作要么全成功要么全回滚(ACID)。每次
INSERT默认是一个 隐式事务 ,都要 fsync 一次 → 逐条插入极慢 。把 N 条放进 一个显式事务 (BEGIN ... COMMIT),只在 COMMIT 时 fsync 一次,速度提升几十上百倍。 - journal 模式 :
- rollback journal(默认):改数据前先把旧页写到 journal 文件用于回滚,写主库时要锁全库,读写互斥。
- WAL(Write-Ahead Logging,推荐) :改动 先追加写到 WAL 日志文件 ,读仍读主库 + 已提交的 WAL,读写可并发(读不阻塞写、写不阻塞读),后续 checkpoint 时把 WAL 合并回主库。显著提升并发与写性能。
- 索引(B-Tree) :为
WHERE/ORDER BY字段建索引,把全表扫描 O(n) 降为 O(log n)。代价:占空间、拖慢写入(要维护索引树)。避免对低区分度字段(如布尔)建索引。 - PRAGMA 调优 :
synchronous=NORMAL(WAL 下安全且更快)、cache_size、mmap_size。
6.2 数据存储方案对比
| 方案 | 适用 | 特点 |
|---|---|---|
NSUserDefaults |
少量配置 | 基于 plist,全量读写,不适合大数据/高频 |
| Plist / 归档(NSKeyedArchiver) | 小型结构数据 | 全量序列化 |
| SQLite | 结构化大数据 | 支持事务、索引、增量更新 |
| Core Data | ORM | 苹果对象图管理,封装 SQLite,有学习与性能成本 |
| Realm | ORM | 第三方,性能好,零拷贝 |
| WCDB(微信) | SQLite 封装 | ORM + 加密 + 高性能 + 多线程 |
| MMKV(微信) | KV 高频写 | 基于 mmap + protobuf,替代 NSUserDefaults,极快 |
| File / mmap | 大文件 | 自管理 |
6.3 优化要点
- I/O 放子线程,避免主线程同步读写。
- 批量与事务:SQLite 多条写入放一个事务,避免每条都 fsync。
- 索引:为查询字段建索引,避免全表扫描;但索引会增大写入成本与体积。
- MMKV 替代 NSUserDefaults 处理高频小数据。
- 缓存清理策略:LRU、容量上限、定期清理,避免沙盒膨胀。
- 冷热分离:常用数据小文件,不常用数据归档压缩。
7. 包体积(App Size)
7.1 包体积构成
.ipa 解压后主要是 Payload/App.app,里面:
- 可执行文件(Mach-O) :各 Segment------
__TEXT(代码、常量,可加密 FairPlay)、__DATA(全局数据、ObjC 元数据:__objc_classlist/__objc_selrefs/__objc_methname等,ObjC 用得越多越大)、__LINKEDIT(符号表、签名)。 - 资源 :图片(编译进
Assets.car,由 actool 打包)、音视频、字体、.lproj本地化、json/plist、storyboard 编译后的.storyboardc。 - Frameworks :动态库
.framework(自带 + 第三方 SDK),每个又是一个 Mach-O。 - App Thinning(下载瘦身) :
- Slicing(切片) :App Store 按设备 架构(arm64) 和 屏幕分辨率(@2x/@3x) 生成 variant,用户只下载匹配自己设备的部分。
- On-Demand Resources(ODR) :非首次必需的资源(关卡、教程)标 tag,由 App Store 托管,按需下载,不计入初始包。
- Bitcode:已废弃(Xcode 14+ 移除),不用再纠结。
- 区分两个体积 :下载大小(App Store 显示,瘦身+压缩后) vs 安装大小(设备占用)。优化目标通常是前者(影响下载转化,且有蜂窝下载 200MB 等限制历史)。
7.2 优化手段
7.2.1 可执行文件
- Dead Code Stripping(
-dead_strip) :链接器从入口出发做 可达性分析 ,删掉没被引用到的 C/C++/Swift 函数与数据。- 关键坑 :ObjC 的方法/类不会被 dead strip !因为 ObjC 是 运行时动态消息派发 (
objc_msgSend靠字符串 selector 查找),链接器无法静态判断某方法是否被调用,只能全保留。所以无用 OC 代码要靠 工具检测 + 手动删。
- 关键坑 :ObjC 的方法/类不会被 dead strip !因为 ObjC 是 运行时动态消息派发 (
- 去符号 :Release 下
Strip Linked Product = YES、Deployment Postprocessing = YES、Strip Style = All Symbols;dSYM 单独生成保留(用于线上崩溃符号化),从二进制里剥离符号表减小体积。 - 减少 ObjC 元数据:合并/删除无用类、删空分类、减少 selector/字符串。
- 编译优化 :
Optimization Level用-Os(体积优先) ;开启 LTO(Link Time Optimization 链接时优化) 跨文件内联与去冗余;Swift 开-Osize并开启 whole-module-optimization。 - 检测无用代码 :
- OC 无用类:导出
__objc_classlist(所有定义的类)与__objc_classrefs(被引用的类),差集 ≈ 没被引用的类(如 fui / AppCode 的检测)。注意反射、storyboard、字符串动态调用的类会误报。 - LinkMap 分析各符号体积,揪出大模块。
- OC 无用类:导出
7.2.2 资源
- 图片压缩(TinyPNG)、改用 WebP/HEIF、移除多余 @1x/重复资源。
- 大资源放服务端按需下载(ODR / 自建 CDN)。
- 音视频压缩、字体子集化。
- 去重:相同资源去重,检测大文件。
7.2.3 依赖
- 评估第三方 SDK 体积收益,删除冗余依赖。
- 静态库合理使用(静态库会被 dead strip,动态库不会全部裁剪)。
7.3 分析工具
- App Thinning Size Report:Archive → Distribute 时勾选生成,看各设备 variant 的下载/安装大小。
- LinkMap (
Write Link Map File = YES):链接器输出的文本,列出 每个 .o、每个符号(函数/数据)占多少字节 ,按段分类。写脚本聚合即可看出 哪个库/哪个类最占代码体积。 otool -l:查看 Mach-O 的 Load Commands 和各 Segment/Section。size -x -l:查看各段大小。nm:查看符号表;strings:看二进制里的字符串常量(有时能发现冗余)。- 解压 .ipa 直接看资源大文件(图片、视频、模型)。
8. 稳定性(Stability)
性能与稳定性密不可分,崩溃和卡死是最严重的"性能问题"。
8.1 崩溃类型
8.1.1 Mach 异常 / Unix 信号
两层异常机制(重要原理)
- Mach 异常(内核层,最底层) :CPU 触发硬件异常(如访问非法地址)时,内核 先以 Mach 异常的形式 投递到出错线程/任务注册的 异常端口(exception port) 。这是 最先、最底层 的捕获点。
- Unix 信号(BSD 层) :若 Mach 异常没被处理,内核的 BSD 层会把它 转换成对应的 Unix 信号 投递给进程,触发
signalhandler。 - 常见信号与含义:
SIGSEGV:访问无效/越权内存(野指针、访问已释放对象)。SIGABRT:调用abort(),多由 未捕获的 NSException、断言失败、C++ 未捕获异常 引发。SIGBUS:内存对齐错误 / 访问映射区无效地址。SIGILL:执行非法指令(常见于跳到坏地址、栈被破坏)。SIGFPE:算术异常(除零)。SIGTRAP:断点/陷阱指令(Swift 运行时检查、__builtin_trap)。
- 捕获方式与取舍 :
- 注册 Mach 异常端口:能拿到更底层信息,但实现复杂,且某些场景(如 Watchdog)拿不到。
- 注册 signal handler (
signal/sigaction):简单,但 handler 运行在 受限的异步信号上下文,能调用的函数有限(async-signal-safe),且对某些 Mach 异常会与端口冲突。 - 业界(KSCrash、PLCrashReporter)通常 两者结合 / 优先 Mach 异常,并小心避免二次崩溃。
8.1.2 OC 异常(NSException)
unrecognized selector sent to instance(找不到方法)、数组越界、字典插 nil、KVO 未移除、@throw等抛NSException。- 流程:未被
@try/@catch捕获 → 触发NSUncaughtExceptionHandler→ 调用abort()→ 转SIGABRT崩溃。 - 捕获:
NSSetUncaughtExceptionHandler(handler)注册全局处理器,在崩溃前拿到NSException(含 name、reason、callStackSymbols调用栈)上报。 - 消息转发(防 unrecognized selector 崩溃的原理) :
objc_msgSend找不到方法时会走三级转发:+resolveInstanceMethod:(动态添加方法)。-forwardingTargetForSelector:(转给备援接收者)。-methodSignatureForSelector:+-forwardInvocation:(完整转发)。 全失败才抛 unrecognized selector。兜底框架在此插入"吞掉"逻辑(慎用,会掩盖 bug)。
8.1.3 其他(系统杀进程,非常规信号)
这几类 抓不到常规信号,需专门检测:
- OOM(内存):被 Jetsam 杀,无 crash 堆栈,用排除法检测(见 3.5.3)。
- Watchdog(卡死,0x8badf00d = "ate bad food") :主线程被某任务长时间阻塞,超过系统阈值未响应被强杀。常见阈值:启动 ~20s、resume/suspend ~10s、处理事件 ~10s。本质是主线程卡死,用卡顿监控(2.6)提前发现。
- 后台超时(0xdead10cc = "deadlock"):App 进入后台后仍持有系统资源(如文件锁、SQLite 数据库锁、assertion)超时被杀。注意后台收尾、释放资源。
- 0xc00010ff = "cool off":设备过热被降频/杀进程。
- 0xbaaaaaad:用户主动触发的整机状态快照(非崩溃)。
8.1.4 Swift 特有崩溃
- 强制解包 nil(
Optional为 nil 时!)、数组越界、as!转换失败、整数溢出、fatalError/precondition→ 通常以 SIGTRAP /__builtin_trap形式崩溃,崩溃信息里常见Fatal error: Unexpectedly found nil...。
8.2 崩溃收集与符号化
收集什么
- 线程调用栈 (所有线程的栈帧地址,崩溃线程重点)、信号/异常类型、寄存器、二进制镜像列表(含每个镜像的加载地址 + UUID)、设备/系统/内存/电量等现场环境。
符号化(Symbolication)原理 ------ 为什么需要 ASLR slide
- 崩溃日志里记录的是 运行时内存地址 (如
0x1023a4f1c),人看不懂,要还原成函数名 + 文件:行号。 - 因为 ASLR ,每次运行镜像的加载基址不同:
运行时地址 = 镜像加载基址(load address) + 函数在二进制中的偏移。 - 符号化步骤:
- 从崩溃日志拿到 崩溃地址 和该镜像的 load address。
偏移 = 崩溃地址 − load address(去掉 ASLR slide,还原到"链接时的静态地址")。- 用 dSYM(DWARF 调试信息,记录"静态地址 → 函数名/行号"的映射)查出符号。
- 工具:
atos -o App.app.dSYM/.../DWARF/App -l <load address> <崩溃地址>,或symbolicatecrash、dwarfdump。
- UUID 必须匹配 :dSYM 与二进制有相同 UUID(build 唯一标识) ,对不上就符号化失败。所以要 保存每个发版的 dSYM。
- 工具平台:Bugly、Firebase Crashlytics、Sentry、KSCrash、PLCrashReporter、Xcode Organizer、MetricKit
MXCrashDiagnostic(系统级、含调用栈)。
8.3 防护与监控
- 野指针(释放后访问) :
- 调试:开启 Zombie Objects(对象释放后变成僵尸对象,再发消息会报具体类,便于定位)。
- 线上:可对释放对象的内存做填充/标记(如把 isa 改写)检测访问。
- 用 Address Sanitizer(ASan) 在测试期捕获越界/释放后使用。
- 主线程卡死监控:见 2.6(RunLoop Observer + 子线程看门狗)。
- Crash 兜底(慎用) :
- unrecognized selector → 用
forwardInvocation:吞掉。 - 容器类(NSArray/NSDictionary/NSString)method swizzling 加边界/nil 保护。
- KVO、通知自动移除。
- 原则 :兜底是"别让线上炸",但会 掩盖真实 bug ,必须 同时上报 被兜底的异常,线下修复根因,不能只兜不修。
- unrecognized selector → 用
9. 监控与工具
9.1 Instruments 模板
| 模板 | 用途 |
|---|---|
| Time Profiler | CPU 热点、函数耗时采样 |
| Allocations | 内存分配、abandoned memory |
| Leaks | 内存泄漏 |
| Core Animation | FPS、离屏渲染、混合 |
| Energy Log | 耗电分析 |
| System Trace | 系统调用、线程调度、page fault |
| App Launch | 启动耗时分阶段 |
| Network | 网络请求 |
| File Activity | 磁盘 I/O |
9.2 Xcode 内置
- Debug Gauges:实时 CPU / 内存 / 磁盘 / 网络 / 能耗。
- Memory Graph Debugger:引用关系可视化。
- View Debugger:视图层级与离屏分析。
- Malloc Scribble / Guard Malloc / Zombie / Address Sanitizer / Thread Sanitizer:内存与线程问题检测。
9.3 MetricKit(线上)
- 系统级聚合上报:启动时间、卡顿(hang)、内存、磁盘、电量、崩溃诊断。
- 低成本、官方、无侵入,适合大盘趋势监控。
9.4 开源/第三方 APM
- 微信 Matrix(卡顿、内存、IO、OOM)。
- KSCrash / PLCrashReporter(崩溃)。
- FBRetainCycleDetector / MLeaksFinder(泄漏)。
- 商业:Bugly、Firebase、Sentry、友盟、火山引擎 APM。
9.5 性能优化方法论
- 量化:先有指标和监控(不要凭感觉优化)。
- 定位:用工具找瓶颈(二八法则,抓最大头)。
- 优化:针对性改,避免过度优化。
- 验证:A/B 对比,确认收益且无回退。
- 守护:建立线上监控 + CI 性能卡口,防止劣化。
速记总结(八股口诀)
- 启动 :dylib 少、ObjC 少、
+load改+initialize、SDK 懒加载、二进制重排减 page fault。 - 流畅:主线程别阻塞、避免离屏渲染、减少混合与过度绘制、图片预解码、高度缓存、异步排版。
- 内存:引用计数 + weak 表原理、循环引用(block/delegate/timer)、autoreleasepool 配合 RunLoop、OOM 被 Jetsam 杀。
- 耗电:CPU 子线程化、控制线程数与定时器、定位按需、网络合并。
- 网络:连接复用 + HTTPDNS + HTTP/2/3 + 压缩 + Protobuf + 缓存 + 弱网降级。
- 存储:I/O 子线程、SQLite 事务+索引、MMKV 替代 NSUserDefaults、mmap 大文件。
- 包体积:dead strip、资源压缩、按需下载、删冗余 SDK。
- 稳定:Mach 异常/信号/NSException/OOM/Watchdog,dSYM 符号化。