iOS 性能优化全面详解

本文系统梳理 iOS 性能优化的各个方面,包含原理、底层机制、八股文要点与优化手段。

目录

  1. [启动性能(Launch Time)](#启动性能(Launch Time) "#1-%E5%90%AF%E5%8A%A8%E6%80%A7%E8%83%BDlaunch-time")
  2. [渲染与流畅度(UI / FPS)](#渲染与流畅度(UI / FPS) "#2-%E6%B8%B2%E6%9F%93%E4%B8%8E%E6%B5%81%E7%95%85%E5%BA%A6ui--fps")
  3. 内存(Memory)
  4. [CPU 与耗电(Power)](#CPU 与耗电(Power) "#4-cpu-%E4%B8%8E%E8%80%97%E7%94%B5power")
  5. 网络(Network)
  6. [I/O 与存储(Disk)](#I/O 与存储(Disk) "#6-io-%E4%B8%8E%E5%AD%98%E5%82%A8disk")
  7. [包体积(App Size)](#包体积(App Size) "#7-%E5%8C%85%E4%BD%93%E7%A7%AFapp-size")
  8. 稳定性(Stability)
  9. 监控与工具

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 更深度的预链接),但思想一致:能提前算的就提前算、能缓存的就缓存。
详细流程
  1. 加载 dyld 本身 :内核 execve 后把 App 可执行文件 mmap 进内存,读取 LC_LOAD_DYLINKER 找到 dyld(/usr/lib/dyld),把控制权交给 dyld 的入口。
  2. 加载动态库(Load dylibs)
    • dyld 读取主二进制的 LC_LOAD_DYLIB递归 解析所有依赖的 dylib。一个大型 App 可能依赖数百个库。
    • 系统库(UIKit、Foundation...)已在 dyld shared cache(一个把所有系统库预链接合并的大缓存文件)中,所有进程共享,加载几乎零成本。
    • 自定义动态库 每个都要:打开文件、读 header、mmap、递归解析其依赖、验证代码签名(首次访问每页都要校验签名哈希,触发缺页)。Apple 官方建议自定义动态库 不超过 6 个
    • 优化点 :减少自定义动态库数量,合并多个 framework 为一个 ,能用静态库就用静态库(静态库在 编译/链接期 就被打进主二进制,零运行时加载成本,且会被 dead-strip 裁剪无用代码)。
  3. Rebase(地址重定位)
    • 因 ASLR,镜像内部所有指向 自身 的指针(虚函数表、Class 内部指针、字符串指针等)都要加上 slide。
    • dyld 读取 __LINKEDIT 里的 rebase 信息(一串操作码 opcode 表示"在哪些偏移处 +slide"),逐个修正 __DATA 中的指针。
    • 修正会 __DATA,把原本干净的页变成 脏页,并触发缺页读入 → I/O 密集 + 产生脏内存。
    • 优化点:减少指针数量 → 减少 ObjC 类、方法、分类、C++ 虚函数、全局指针。
  4. Binding(符号绑定)
    • 处理指向 外部库 的符号(如 _objc_msgSendprintf_OBJC_CLASS_$_NSObject)。
    • dyld 按符号名字符串去依赖库的符号表里查找真实地址,写回到 __DATA__la_symbol_ptr / __got 中。
    • 懒绑定(lazy binding) :函数符号默认首次调用时才绑定(通过 dyld_stub_binder 桩),分摊成本;非懒绑定(数据符号、ObjC class)启动时全绑。
    • CPU 密集(大量字符串比较查表)。
    • 优化点:减少外部符号引用、减少 ObjC 类引用。
  5. 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)。
    • 优化点:减少类、分类、selector 数量;很多工作其实和 Rebase/Binding 重叠,所以"减元数据"一举多得。
  6. Initializers(初始化器)
    • dyld 按依赖顺序调用各镜像的初始化器:
      • C++ 静态构造函数(__attribute__((constructor)) 标记的函数、全局 C++ 对象的构造)。
      • ObjC 的 +load 方法:每个实现了 +load 的类和分类,在此被同步、串行调用 ,且发生在 main 之前。+load 里若有耗时操作(注册、读文件、网络)会直接拖慢启动。
      • 初始化带初值的全局/静态变量。
    • +load vs +initialize 的本质区别(高频八股)
      • +load:App 启动时、main 之前,只要类被加载就 一定调用一次 ,无论是否用到,不能懒加载,且调用时机早、不安全(其他类可能还没初始化)。
      • +initialize:在类 第一次收到消息 (被使用)时才调用,是 懒加载 的;通过 objc_msgSend 触发,由 runtime 保证线程安全和"父类先于子类"。若类从未被使用则永不调用。
    • 优化点 :把 +load 逻辑迁移到 +initialize 或首次使用时;删除空 +load;避免在初始化器里做 I/O / 网络 / 锁等待。

八股核心 :pre-main 耗时主要来自 dylib 加载、Rebase/Binding、ObjC 注册、Initializer 。物理本质是 缺页 I/O + 脏页生成 + 字符串查表 。三大优化方向:减少动态库、减少 ObjC 元数据、推迟/删除初始化逻辑

1.2.2 main 之后阶段

  1. main()UIApplicationMain()
    • 创建 UIApplication 单例和 AppDelegate
    • 创建主线程 RunLoop 并启动(CFRunLoopRun),让 App 进入事件循环、不退出。
    • UIApplicationMain 内部会读取 Info.plist 找到 main storyboard 或交给 SceneDelegate
  2. application:didFinishLaunchingWithOptions::很多 App 在这里塞入大量 SDK 初始化(埋点、推送、崩溃、支付、A/B、热修...),是 可控的最大优化空间
  3. 首屏 ViewController:loadView(创建根 view)→ viewDidLoad(搭建子视图、请求数据)→ viewWillAppear → 首次 Auto Layout 布局 → 首次 渲染
  4. 首帧渲染完成 (用户可见可交互),通常以 viewDidAppear 或第一次 CADisplayLink/RunLoop 进入休眠作为"启动结束"埋点。
main 后优化的核心思路
  • 任务分级 :把 didFinishLaunching 的初始化分为四类:
    1. 首屏强依赖(必须同步、阻塞首帧):尽量少。
    2. 首屏后可做(首帧渲染完成再异步执行)。
    3. 空闲时做 (监听 RunLoop kCFRunLoopBeforeWaiting,主线程空闲再执行)。
    4. 用到再做(懒加载,第一次调用时初始化)。
  • 异步化 :非 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,启动期成千上万次缺页累计可观。
  • 核心思想 :把 启动期间会调用到的所有函数集中排到二进制最前面、紧挨着,让它们落在尽量少的连续页里,从而把"几千次缺页"压缩成"几十次缺页"。
  • 怎么拿到启动调用顺序
    1. 用 Clang 插桩 -fsanitize-coverage=func,trace-pc-guard,编译器在 每个函数入口 插入一个 hook 回调。
    2. 启动时这些 hook 按真实执行顺序被触发,记录下符号名(去重、保序)。
    3. 输出为 order_file (一行一个符号),在 Xcode 的 Order File 构建设置里指定,链接器据此排列函数。
  • 验证 :用 DYLD_PRINT_STATISTICS 或 System Trace 看缺页次数下降、pre-main / 首帧时间下降。
  • 抖音(公开分享过 "启动优化之 Clang 插桩")、微信都用过,pre-main 可降 10%~30%。

进阶:除了函数重排,还有 __DATA 段顺序优化冷热代码分离(把启动不用的代码移到后面)等思路,原理都是"减少启动期触及的页数"。

1.3.2 启动任务治理

  • didFinishLaunching 的任务分级:首屏必需 / 首屏后 / 空闲时 / 用完再加载
  • 用任务调度框架(如有向无环图 DAG)管理依赖,异步并发执行非主线程任务。
  • dispatch_async 到子线程、利用 dispatch_onceRunLoop 空闲(kCFRunLoopBeforeWaiting)时机执行低优先级任务。

1.3.3 测量方法

  • DYLD_PRINT_STATISTICS=1 (Xcode → Scheme → Run → Environment Variables):打印 pre-main 各阶段耗时,输出形如:
    • total pre-main time 总时间
    • dylib loading time 动态库加载
    • rebase/binding time
    • ObjC setup time
    • initializer time(细看哪个库的 initializer 最慢)
    • DYLD_PRINT_STATISTICS_DETAILS=1 给更细粒度。
  • Instruments → App Launch 模板:时间线看每个阶段、每个 initializer、首帧。
  • 手动埋点(main 后) :在 main() 第一行记 t0(或用 __attribute__((constructor)) 抢更早),首帧后(viewDidAppear 或 RunLoop 首次 BeforeWaiting)记 t1t1-t0 即 main 后耗时。pre-main 用 DYLD 数据或进程创建时间(sysctlkp_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。
  • GPU 负责
    • 顶点变换、纹理合成、混合(Blending)、渲染到帧缓冲。

八股核心 :卡顿要么是 CPU 太忙 (布局/解码/计算阻塞主线程),要么是 GPU 太忙(离屏渲染、过度混合、纹理过大)。

2.2 Core Animation 渲染流水线

UIView 与 CALayer 的关系(基础)
  • 每个 UIView 内部都有一个 CALayerview.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

  1. Handle Events:处理触摸、定时器、网络回调等,可能修改视图(改 frame、加子视图)。被修改的图层被标记为"需要更新"。
  2. Commit Transaction (App 进程内,由 RunLoop 在本次循环末尾自动提交,4 子步骤):
    • Layout(布局) :调用 layoutSubviewsupdateConstraints,计算各图层 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。图层越多、层级越深,打包和传输越慢。
  3. Render Server 解码与生成指令 :收到图层树,反序列化,根据图层属性(位置、变换、透明度、圆角、阴影)调用 Metal(旧 OpenGL ES) 生成 GPU 绘制指令(draw call)。
  4. GPU 渲染:执行顶点着色(变换坐标)→ 光栅化(图元转像素)→ 片段着色(算每个像素颜色、采样纹理)→ 混合(Blending)→ 写入帧缓冲(back buffer)。
  5. 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 无法在一遍内完成,于是:
    1. 额外开辟一块 离屏缓冲区(offscreen buffer),在显存里,有内存开销。
    2. 切换渲染目标(render target)到这块缓冲------上下文切换会打断 tile 流水线、刷新片上内存,代价高。
    3. 渲染完再切回主帧缓冲、把结果贴上去。
  • 多次上下文切换 + 额外显存读写 = 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 = YESbackgroundColor 设为 不透明纯色 (别留默认 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) :用 ImageIOCGImageSourceCreateThumbnailAtIndex + 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):kCFRunLoopEntryBeforeTimersBeforeSourcesBeforeWaiting(即将休眠)→ AfterWaiting(被唤醒)→ Exit

三种监控方案
  1. RunLoop 状态监控(主流,微信 Matrix 思路)
    • 注册一个 RunLoop Observer 监听状态。
    • 开一个 子线程 + 信号量 做"看门狗":每次主线程进入 BeforeSourcesAfterWaiting(开始处理任务)时刷新状态,子线程 dispatch_semaphore_wait 设超时(如 50ms)。
    • 若超时仍停留在"处理任务"状态没切换到 BeforeWaiting(休眠),说明主线程一个任务执行 > 50ms = 卡顿 → 子线程 dump 主线程调用栈 上报。
  2. 子线程 ping:子线程定时(如每秒)向主队列派一个标记任务并启动计时,若主线程在阈值内没执行该任务(标记没翻转),判定卡顿,dump 堆栈。
  3. CADisplayLink 测 FPSCADisplayLink 每帧回调一次,统计单位时间回调次数估算 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 isaisa 是一个 union(isa_t,8 字节 64 位) ,只用部分位存类地址(shiftcls),其余位塞了很多信息(位域):
    • nonpointer:1 位,标记是否为优化过的 isa。
    • has_assoc:是否有关联对象。
    • has_cxx_dtor:是否有 C++/ObjC 析构逻辑(影响释放路径)。
    • shiftcls:类指针(占 33 位左右,因内存对齐低位为 0 可省)。
    • magicweakly_referenced(是否被 weak 引用过)、deallocating(是否正在释放)。
    • extra_rc额外引用计数 ------引用计数 优先存这里,直接位运算,极快。
    • has_sidetable_rc:标记引用计数是否已溢出到 SideTable。
  • 流程 :retain 时先加 extra_rcextra_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_initWeakstoreWeak:以 obj 地址为 key,把 &p 注册进 weak_table;同时把 obj 的 isa weakly_referenced 置 1。
  • 自动置 nil 的时机 :对象 deallocobjc_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(小对象优化)

  • 对于 NSNumberNSDate、短 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
  • 所以:一次 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_asyncUIView 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 持有 → 成环。
  • NSTimerscheduledTimerWithTimeInterval: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)。
  • 闭包捕获(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 是全局并发队列。
  • 同步 vs 异步
    • dispatch_sync阻塞当前线程 直到任务完成,不开新线程(在当前线程执行)。易死锁 :在串行队列里向同一队列 sync 会自己等自己(如主线程向主队列 sync)。
    • dispatch_async不阻塞 ,任务交给队列,GCD 视情况 从池里取或新建线程 执行。
  • 底层调度 :libdispatch 维护工作线程池,配合内核的 workqueue 机制按需创建/复用线程;任务多时增线程,空闲时回收。
线程过多的危害(线程爆炸)
  • 每个线程占 栈内存(子线程 512KB) + 内核线程结构。
  • 上下文切换 成本:保存/恢复寄存器、刷 TLB/缓存,切换太频繁 CPU 都在"切换"而非"干活"。
  • 大量 dispatch_async 里若都执行 阻塞操作(同步网络、sleep、锁等待) ,GCD 为了让队列继续推进会 不断新建线程,导致线程暴涨甚至卡死/崩溃。
  • 避免 :用 并发数受控 的方式------NSOperationQueue.maxConcurrentOperationCountdispatch_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、卡顿监控都用它)。
一轮循环流程(简化)
  1. 通知 Observer 进入循环。
  2. 处理 Timer、Source0。
  3. 若有 Source1 待处理,跳去处理。
  4. 通知 Observer 即将休眠(BeforeWaiting)→ 调用 mach_msg 进入内核休眠,等待端口消息唤醒。
  5. 被唤醒(Source1/Timer/手动),通知 AfterWaiting,处理对应事件。
  6. 回到第 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 LogTime Profiler (CPU 热点)、System Trace
  • MetricKit MXCPUMetricMXCellularConditionMetric
  • Xcode Energy Gauge。

5. 网络(Network)

5.1 网络协议栈基础

一次 HTTPS 请求的完整时间线(八股必背)

DNS 解析 → TCP 三次握手 → TLS 握手 → 发送请求 → 服务器处理 → 接收首字节(TTFB) → 接收完整响应。优化就是缩短/省略其中每一段。

TCP 三次握手 / 四次挥手
  • 三次握手 (建立连接,1 RTT 才能开始发数据):
    1. 客户端发 SYN(seq=x)。
    2. 服务端回 SYN+ACK(seq=y, ack=x+1)。
    3. 客户端发 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/publicstale-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_sizemmap_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 代码要靠 工具检测 + 手动删
  • 去符号 :Release 下 Strip Linked Product = YESDeployment Postprocessing = YESStrip Style = All SymbolsdSYM 单独生成保留(用于线上崩溃符号化),从二进制里剥离符号表减小体积。
  • 减少 ObjC 元数据:合并/删除无用类、删空分类、减少 selector/字符串。
  • 编译优化Optimization Level-Os(体积优先) ;开启 LTO(Link Time Optimization 链接时优化) 跨文件内联与去冗余;Swift 开 -Osize 并开启 whole-module-optimization。
  • 检测无用代码
    • OC 无用类:导出 __objc_classlist(所有定义的类)与 __objc_classrefs(被引用的类),差集 ≈ 没被引用的类(如 fui / AppCode 的检测)。注意反射、storyboard、字符串动态调用的类会误报。
    • LinkMap 分析各符号体积,揪出大模块。

7.2.2 资源

  • 图片压缩(TinyPNG)、改用 WebP/HEIF、移除多余 @1x/重复资源。
  • 大资源放服务端按需下载(ODR / 自建 CDN)。
  • 音视频压缩、字体子集化。
  • 去重:相同资源去重,检测大文件。

7.2.3 依赖

  • 评估第三方 SDK 体积收益,删除冗余依赖。
  • 静态库合理使用(静态库会被 dead strip,动态库不会全部裁剪)。

7.3 分析工具

  • App Thinning Size Report:Archive → Distribute 时勾选生成,看各设备 variant 的下载/安装大小。
  • LinkMapWrite 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 信号 投递给进程,触发 signal handler。
  • 常见信号与含义:
    • SIGSEGV:访问无效/越权内存(野指针、访问已释放对象)。
    • SIGABRT:调用 abort(),多由 未捕获的 NSException、断言失败、C++ 未捕获异常 引发。
    • SIGBUS:内存对齐错误 / 访问映射区无效地址。
    • SIGILL:执行非法指令(常见于跳到坏地址、栈被破坏)。
    • SIGFPE:算术异常(除零)。
    • SIGTRAP:断点/陷阱指令(Swift 运行时检查、__builtin_trap)。
  • 捕获方式与取舍
    • 注册 Mach 异常端口:能拿到更底层信息,但实现复杂,且某些场景(如 Watchdog)拿不到。
    • 注册 signal handlersignal/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 找不到方法时会走三级转发:
    1. +resolveInstanceMethod:(动态添加方法)。
    2. -forwardingTargetForSelector:(转给备援接收者)。
    3. -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) + 函数在二进制中的偏移
  • 符号化步骤:
    1. 从崩溃日志拿到 崩溃地址 和该镜像的 load address
    2. 偏移 = 崩溃地址 − load address(去掉 ASLR slide,还原到"链接时的静态地址")。
    3. dSYM(DWARF 调试信息,记录"静态地址 → 函数名/行号"的映射)查出符号。
    4. 工具:atos -o App.app.dSYM/.../DWARF/App -l <load address> <崩溃地址>,或 symbolicatecrashdwarfdump
  • 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 ,必须 同时上报 被兜底的异常,线下修复根因,不能只兜不修。

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 性能优化方法论

  1. 量化:先有指标和监控(不要凭感觉优化)。
  2. 定位:用工具找瓶颈(二八法则,抓最大头)。
  3. 优化:针对性改,避免过度优化。
  4. 验证:A/B 对比,确认收益且无回退。
  5. 守护:建立线上监控 + 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 符号化。
相关推荐
lichenyang4531 小时前
HAP / HAR / HSP 到底啥区别?顺带把「导入」那点疑惑讲清楚
前端
基德爆肝c语言1 小时前
MySQL表的操作
前端·数据库·mysql
秃头网友小李1 小时前
前端难点:Element Plus 样式覆盖 —— :deep()、CSS 变量与滚动状态类名
前端·vue.js
the_answer1 小时前
XSS 与 CSRF 深度解析
前端
打呵欠的猫1 小时前
AI 生成的代码你敢直接上线吗?我总结出 3 条铁律
前端·ai编程
极速蜗牛1 小时前
我在 Taro 小程序项目里实践的 API First + AI 编程方式
前端·人工智能·后端
锋行天下2 小时前
数据库安全并发控制详解:乐观锁 vs 悲观锁 vs 原子操作
前端·数据库·后端
饼饼饼3 小时前
React19 新手指南:JSX 没那么难,用好这几条规则就够了
前端·javascript·react.js
想吃火锅10053 小时前
【前端手撕】new
前端