启动优化

iOS应用的启动时间是用户体验的关键指标之一。研究表明,应用性能直接影响用户留存:页面加载时间每增加1秒,用户流失率会显著上升。对于iOS应用,需要在20秒内完成启动,否则可能会被watchdog强制终止。 本系列文章从启动流程的各个阶段入手,系统性地介绍iOS启动优化的方法和实践。 启动监控是 APM 的重要子系,线上 P50/P90/P99 的采集、冷/温/热启动判定、以及与业务秒开指标的关联,请参考 APM 系列:指标体系、数据采集。 启动流程概述 App的启动流程分为两个主要阶段: 阶段 时间范围 主要工作 Pre-main 进程创建 → main()函数 加载可执行文件、加载动态库、Rebase/Bind、ObjC Runtime初始化、Swift Runtime元数据注册、+load方法、Initializers main main()函数 → 首帧渲染 UIApplicationMain、AppDelegate回调、首帧渲染 flowchart TD Start([App 冷启动]) --> PreMain subgraph PreMain["Pre-main 阶段"] direction TB A[加载可执行文件创建进程、mmap映射、签名验证] --> B[加载动态库递归加载依赖、包括 Swift Runtime] B --> C[Rebase & Bind指针修正、符号绑定] C --> D[ObjC Runtime 初始化类注册、Category 附加] D --> E[Swift Runtime 元数据注册类型元数据、协议遵循表] E --> F[调用 +load 方法] F --> G[执行 InitializersC++静态构造函数、__attribute__] end PreMain --> MainPhase subgraph MainPhase["main 阶段"] direction TB H[main函数] --> I[UIApplicationMain创建UIApplication、AppDelegate、启动RunLoop] I --> J[AppDelegate回调willFinish、didFinishLaunching] J --> K[首帧渲染创建Window、RootViewController、首屏UI] end MainPhase --> End([启动完成]) 文章导航 本系列包含以下文章,建议按顺序阅读: ...

May 2, 2026

启动优化-观测

准确测量启动时间是优化的前提。启动时间是用户体验的关键指标之一,研究表明,应用性能直接影响用户留存:页面加载时间每增加1秒,用户流失率会显著上升。对于iOS应用,需要在20秒内完成启动,否则可能会被watchdog强制终止。 本文介绍如何测量和监控iOS应用的启动时间。关于启动流程的详细介绍,请参考 App启动流程。 一、测量方式概览 iOS 提供了多种测量启动时间的方式,适用于不同场景: 方式 适用场景 粒度 是否需要代码 dyld 环境变量 开发调试 定性分析 否 Instruments App Launch 深度分析 函数级 否 代码埋点 开发调试 + 线上监控 自定义 是 MetricKit 线上监控 冷启动/热启动 少量代码 二、开发调试阶段 2.1 dyld 环境变量 通过设置 dyld 环境变量可以在控制台查看启动过程中的加载信息: Edit Scheme → Run → Arguments → Environment Variables 与启动测量相关的环境变量: 环境变量 作用 启动分析用途 DYLD_PRINT_LIBRARIES 打印每个加载的 mach-o 镜像 查看动态库加载顺序,确认是否加载了不必要的库 DYLD_PRINT_LOADERS 打印镜像的加载器类型(JustInTimeLoader / PrebuiltLoader) 分析是否使用了启动闭包优化 DYLD_PRINT_INITIALIZERS 打印每个 initializer 的执行 查看 +load、constructor、C++ 静态构造函数的执行顺序 DYLD_PRINT_BINDINGS 打印每次符号绑定 分析符号绑定情况(输出量很大) DYLD_PRINT_SEARCHING 打印库搜索路径 排查库加载问题 DYLD_PRINT_APIS 打印 dyld API 调用(如 dlopen) 检测运行时动态加载行为 DYLD_PRINT_TO_FILE 将日志输出到指定文件 避免控制台日志过多,方便后续分析 使用示例: ...

May 2, 2026

启动优化-减少动态库

动态库加载是Pre-main阶段的重要组成部分,每个动态库都需要加载、验证签名、进行符号绑定,这些操作会显著影响启动时间。 问题分析 当App启动时,dyld需要: 分析Mach-O文件的Load Commands,找出依赖的动态库 递归加载所有依赖的动态库 对每个动态库进行签名验证 执行Rebase和Bind操作 动态库数量越多,这些操作的耗时就越长。Apple建议将自定义动态库数量控制在6个以内。关于动态库加载的详细流程,可以参考Mach-O的链接、装载与库。 关于缓存机制: 系统动态库:已被放入 dyld shared cache 中,其加载和符号绑定操作已预先完成,不会影响 App 启动时间 App 动态库:dyld 3 引入的 Launch Closure 机制会缓存依赖分析、Rebase/Bind 信息等元数据。首次启动(或 App 更新后)会生成缓存,后续启动直接使用 但即使有 Launch Closure 缓存,Rebase/Bind 操作本身仍需执行(因为 ASLR slide 每次启动都不同),动态库数量越多,这些操作的耗时仍然越长。 App可执行文件 ├── UIKit.framework │ ├── Foundation.framework │ │ └── CoreFoundation.framework │ └── CoreGraphics.framework ├── 自定义Framework A │ └── 依赖库... └── 自定义Framework B └── 依赖库... 优化方案 1. 合并动态库 将功能相近的动态库合并为一个: ...

May 2, 2026

启动优化-load方法

+load方法是ObjC运行时在类被加载时自动调用的方法,所有+load方法在main函数之前同步执行,会阻塞启动。 问题分析 +load方法的特点: 特性 说明 调用时机 main函数之前,ObjC Runtime初始化时 调用顺序 父类 → 子类 → Category 线程 主线程,同步执行 影响 直接阻塞启动 // 问题代码:+load中执行耗时操作 @implementation HeavyModule + (void)load { // 耗时操作会阻塞启动 [self setupDatabase]; [self preloadResources]; [self registerServices]; } @end +load与+initialize的区别 特性 +load +initialize 调用时机 main之前 类首次使用时 调用次数 只调用一次 可能多次(子类触发) 是否阻塞启动 是 否 线程安全 是 是 调用顺序 父类→子类→Category 父类→子类 更详细的对比请参考:+load与+initialize的区别 优化方案 方案1:使用+initialize替代 将+load中的逻辑迁移到+initialize,延迟到类首次使用时执行: @implementation HeavyModule // 优化前 + (void)load { [self setupDatabase]; } // 优化后 + (void)initialize { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ // 延迟到类首次使用时执行 [self setupDatabase]; }); } @end 注意:+initialize可能被子类触发多次调用,需要使用dispatch_once保证只执行一次。 ...

May 2, 2026

启动优化-didFinishLaunching

didFinishLaunchingWithOptions是main阶段的核心入口,在这里初始化大量SDK和服务会阻塞启动。本文介绍如何优化这个阶段的耗时。 问题分析 典型的问题代码: func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // 所有初始化都在主线程同步执行 CrashReporter.setup() Analytics.setup() PushNotification.setup() NetworkManager.setup() DatabaseManager.setup() ImageCache.setup() AdSDK.setup() SocialSDK.setup() // ... 更多SDK return true } 这种写法的问题: 所有初始化都在主线程同步执行 无论是否需要,所有SDK都在启动时初始化 阻塞首帧渲染 优化方案 方案1:分级启动任务管理 将启动任务按优先级分类,只在启动时执行必要的任务: // 启动任务优先级 enum LaunchTaskPriority { case required // 必须在首帧前完成 case high // 首帧后立即执行 case normal // 首屏稳定后执行 case low // 空闲时执行 } // 启动任务管理器 class LaunchTaskManager { static let shared = LaunchTaskManager() private var tasks: [LaunchTaskPriority: [() -> Void]] = [:] func register(priority: LaunchTaskPriority, task: @escaping () -> Void) { if tasks[priority] == nil { tasks[priority] = [] } tasks[priority]?.append(task) } func executeRequiredTasks() { tasks[.required]?.forEach { $0() } } func executeHighPriorityTasks() { DispatchQueue.main.async { self.tasks[.high]?.forEach { $0() } } } func executeNormalTasks() { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.tasks[.normal]?.forEach { $0() } } } func executeLowPriorityTasks() { // 监听RunLoop空闲时执行 CFRunLoopPerformBlock(CFRunLoopGetMain(), kCFRunLoopDefaultMode) { self.tasks[.low]?.forEach { $0() } } } } 使用示例 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let manager = LaunchTaskManager.shared // 必须在首帧前完成 manager.register(priority: .required) { CrashReporter.setup() // 崩溃收集必须最先初始化 } // 首帧后立即执行 manager.register(priority: .high) { Analytics.setup() PushNotification.setup() } // 首屏稳定后执行 manager.register(priority: .normal) { AdSDK.setup() SocialSDK.setup() } // 空闲时执行 manager.register(priority: .low) { PreloadManager.preloadResources() } // 只执行必须的任务 manager.executeRequiredTasks() return true } 任务分级建议 优先级 适用场景 示例 required 崩溃收集、核心功能依赖 Crash SDK、必要的配置加载 high 用户可能很快用到 推送、统计、网络配置 normal 非首屏功能 广告SDK、社交分享 low 预加载、缓存 图片预加载、数据预热 方案2:并行初始化 将无依赖关系的初始化任务并行执行: ...

May 2, 2026

启动优化-Rebase与Bind

Rebase和Bind是Pre-main阶段的重要步骤,用于修正程序中的指针地址。ObjC类、方法、协议等元数据越多,需要修复的指针越多,启动时间越长。 基本概念 由于ASLR(Address Space Layout Randomization)技术,App每次启动时加载到内存的地址都是随机的,因此需要进行地址修正。关于Rebase和Bind的底层原理,可以参考Mach-O的链接、装载与库。 Rebase(重定位) 修正指向Mach-O内部的指针,将编译时的地址加上ASLR偏移量。 Bind(绑定) 修正指向Mach-O外部的指针,查找符号表,绑定到正确的外部符号地址。 flowchart LR subgraph compile["编译时 (Mach-O文件)"] A1["内部指针: 0x1000(指向内部数据)"] A2["外部符号: _objc_msgSend(待绑定)"] end subgraph runtime["运行时 (内存中)"] B1["内部指针: 0x1000 + slide(Rebase修正)"] B2["外部符号: 实际地址(Bind绑定)"] end A1 -->|"Rebase"| B1 A2 -->|"Bind"| B2 问题分析 以下因素会增加Rebase/Bind的工作量: 因素 影响 ObjC类数量 每个类都有元数据需要修正 Swift类数量 Swift类在Apple平台也会生成ObjC兼容的元数据,同样需要修正 ObjC方法数量 方法列表中的指针需要修正 Category数量 Category的方法列表需要绑定 C++虚函数 虚函数表中的指针需要绑定 全局变量 指向其他符号的全局变量需要绑定 关于缓存机制: 系统动态库:Rebase/Bind 操作已在 dyld shared cache 中预先完成,不会在 App 启动时执行 App 动态库:dyld 3 的 Launch Closure 会缓存 Rebase/Bind 的元数据信息(哪些指针需要修正、修正方式等),但 Rebase/Bind 操作本身仍需每次启动时执行,因为 ASLR slide 每次启动都不同 因此,减少 ObjC 类、Category、C++ 虚函数等仍然是有效的优化手段。 ...

May 2, 2026

启动优化-Initializers

Initializers是指在main函数之前执行的初始化代码,包括C++静态构造函数和__attribute__((constructor))标记的函数。这些代码会在Pre-main阶段同步执行,阻塞启动。 问题分析 以下代码会在main之前执行: 1. C++静态构造函数 __attribute__((constructor)) static void MyInitializer() { // 耗时初始化 initializeHeavyResource(); } 2. 全局静态变量的构造函数 // 全局静态变量 static std::vector<std::string> globalCache = loadFromDisk(); // 构造函数在main前调用 3. 非基本类型的全局变量 // 非POD类型的全局变量会触发构造函数 static std::string globalString = "Hello"; static MyClass globalObject; 优化方案 方案1:延迟初始化 将全局变量改为懒加载模式: // 优化前:全局变量在main前初始化 static std::vector<std::string> globalCache = loadFromDisk(); // 优化后(纯C++方案,适用于 .cpp 文件): #include <mutex> static std::vector<std::string>* getGlobalCache() { static std::vector<std::string>* cache = nullptr; static std::once_flag onceFlag; std::call_once(onceFlag, [&] { cache = new std::vector<std::string>(); *cache = loadFromDisk(); }); return cache; } // 优化后(ObjC++方案,适用于 .mm 文件): static std::vector<std::string>* getGlobalCache() { static std::vector<std::string>* cache = nullptr; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ cache = new std::vector<std::string>(); *cache = loadFromDisk(); }); return cache; } 方案2:使用Swift懒加载 Swift的懒加载特性可以避免在启动时初始化: ...

May 2, 2026

性能优化

本文篇幅较长,将从 编译阶段 -> 路由阶段 -> 渲染阶段 -> 细节优化 -> 状态管理 -> 海量数据源,长列表渲染 方向分别加以探讨。 一 不能输在起跑线上,优化babel配置,webpack配置为项 1 真实项目中痛点 当我们用create-react-app或者webpack构建react工程的时候,有没有想过一个问题,我们的配置能否让我们的项目更快的构建速度,更小的项目体积,更简洁清晰的项目结构。 随着我们的项目越做越大,项目依赖越来越多,项目结构越来越来复杂,项目体积就会越来越大,构建时间越来越长,久而久之就会成了一个又大又重的项目,所以说我们要学会适当的为项目‘减负’,让项目不能输在起跑线上。 2 一个老项目 拿我们之前接触过的一个react老项目为例。我们没有用dva,umi快速搭建react,而是用react老版本脚手架构建的,这对这种老的react项目,上述的问题都会存在,下面让我们一起来看看。 我们首先看一下项目结构。 再看看构建时间。 为了方便大家看构建时间,我简单写了一个webpack,plugin ConsolePlugin ,记录了webpack在一次compilation所用的时间。 const chalk = require('chalk') /* console 颜色 */ var slog = require('single-line-log'); /* 单行打印 console */ class ConsolePlugin { constructor(options){ this.options = options } apply(compiler){ /** * Monitor file change 记录当前改动文件 */ compiler.hooks.watchRun.tap('ConsolePlugin', (watching) => { const changeFiles = watching.watchFileSystem.watcher.mtimes for(let file in changeFiles){ console.log(chalk.green('当前改动文件:'+ file)) } }) /** * before a new compilation is created. 开始 compilation 编译 。 */ compiler.hooks.compile.tap('ConsolePlugin',()=>{ this.beginCompile() }) /** * Executed when the compilation has completed. 一次 compilation 完成。 */ compiler.hooks.done.tap('ConsolePlugin',()=>{ this.timer && clearInterval( this.timer ) const endTime = new Date().getTime() const time = (endTime - this.starTime) / 1000 console.log( chalk.yellow(' 编译完成') ) console.log( chalk.yellow('编译用时:' + time + '秒' ) ) }) } beginCompile(){ const lineSlog = slog.stdout let text = '开始编译:' /* 记录开始时间 */ this.starTime = new Date().getTime() this.timer = setInterval(()=>{ text += '█' lineSlog( chalk.green(text)) },50) } } 构建时间如下: ...

December 17, 2024

浏览器的存储机制

浏览器的存储机制涵盖多种技术,目的是满足 Web 应用在不同场景下的数据持久化、缓存和离线访问需求。它们各自具有不同的存储容量、生命周期、安全策略和访问方式,理解这些机制对于构建高效、安全的前端应用至关重要。 一、浏览器存储机制分类 1. Cookie 最早期的存储机制,主要用于会话管理和身份认证。 容量限制:单个约 4KB,总数有限制; 生命周期:可设置过期时间或为会话级; 访问方式:由浏览器自动在 HTTP 请求头中携带,也能通过 JS 访问(除非设置 HttpOnly); 安全性:可设置 HttpOnly、Secure、SameSite 防范安全风险。 2. Web Storage(LocalStorage 和 SessionStorage) HTML5 新增的简单键值对存储方案。 LocalStorage:永久存储,关闭浏览器数据仍保留,同源可访问; SessionStorage:页面会话存储,关闭标签页即清空; 容量:一般为 5~10MB; 访问方式:同步 API,容易使用但可能阻塞主线程。 3. IndexedDB 浏览器内置的底层结构化数据库,支持事务和索引,适合复杂和大容量数据存储。 容量:远大于 LocalStorage,通常以设备剩余空间为限; 访问方式:异步 API,支持复杂操作; 用途:离线应用、大数据缓存、文件存储等。 4. Cache Storage 由 Service Worker 管理的请求和响应缓存,用于离线和加速访问。 容量:较大,依赖设备和浏览器策略; 访问方式:异步 Promise API; 用途:缓存静态资源、接口响应,实现离线支持。 5. Service Worker 虽然不是存储机制本身,但作为浏览器后台代理进程,管理 Cache Storage,实现网络请求拦截和缓存策略,是现代离线应用的核心。 生命周期独立于页面,可在后台运行; 控制页面的网络请求,提供离线能力和资源预缓存; 可结合 Cache Storage 和 IndexedDB 进行数据管理。 二、存储机制的生命周期与访问作用域 Cookie:基于域和路径,可设置 HttpOnly、Secure 限制访问; LocalStorage/SessionStorage:基于同源策略,SessionStorage 进一步限定于标签页会话; IndexedDB 和 Cache Storage:基于同源,支持版本控制和升级; Service Worker:独立于页面,能控制同源下的所有相关页面。 三、容量限制与性能影响 Cookie 容量最小,且会随每次请求自动发送,影响网络性能; LocalStorage/SessionStorage 适合轻量存储,容量适中; IndexedDB 和 Cache Storage 容量大,适合海量数据和文件缓存; Service Worker 通过异步调度,避免阻塞主线程。 四、安全与隐私考虑 Cookie 可被服务器访问,设置 HttpOnly 避免脚本窃取; Web Storage、IndexedDB 只能由同源脚本访问,防止跨站数据泄漏; Service Worker 需 HTTPS 环境,避免被恶意注入; 用户隐私模式下,存储行为可能受限,数据不保证持久。 五、应用场景及选择建议 需要与服务器频繁交互、会话维持时用 Cookie; 存储简单配置、少量数据用 LocalStorage; 页面会话临时数据用 SessionStorage; 大规模结构化数据和离线存储用 IndexedDB; 静态资源及请求缓存用 Cache Storage,配合 Service Worker 提升离线体验和性能; Service Worker 负责管理缓存和拦截网络请求,实现 PWA 功能。 常见考点 面试考察点通常围绕存储类型、特点、使用场景、容量限制、安全性和生命周期展开: ...

December 17, 2024

浏览器的垃圾回收机制

浏览器的垃圾回收(Garbage Collection, GC)机制是前端性能优化和内存管理的重要基础。 一、垃圾回收的基本概念 目的:自动回收不再使用的内存,避免内存泄漏,保证浏览器性能稳定。 GC 触发:当浏览器检测到内存不足或特定条件时,启动垃圾回收过程。 二、主要垃圾回收算法 1. 标记清除(Mark-and-Sweep) 浏览器从**根对象(Global、执行上下文中的变量)**开始,标记所有可达对象。 没被标记的对象被认为不可达,即不再被使用,进行回收。 是现代 JS 引擎普遍采用的算法。 2. 引用计数(Reference Counting) 每个对象维护引用计数,引用增加时计数+1,引用消失时计数-1。 计数为 0 的对象立即回收。 缺陷:无法处理循环引用,现代引擎一般不单独使用。 三、垃圾回收的触发时机 内存分配达到一定阈值时自动触发。 主动调用相关接口(如 Chrome DevTools 手动触发)。 页面卸载时进行清理。 四、内存泄漏常见原因 全局变量未释放 全局变量一直被引用,无法回收。 闭包导致的变量无法释放 闭包作用域内变量被外部引用。 定时器未清除 setInterval、setTimeout 未正确清除,导致引用保留。 DOM 节点引用未释放 JS 中持有对已删除 DOM 的引用。 事件监听未移除 绑定事件后,未及时解绑,导致内存无法回收。 五、性能优化建议 避免不必要的全局变量。 使用完定时器及时清除。 解绑不再使用的事件监听。 谨慎使用闭包,避免无用变量持久存在。 小心操作 DOM,及时释放引用。 常见考点 面试中考察点主要包括 GC 的原理、算法、触发时机、内存泄漏原因及避免方法: 浏览器垃圾回收的原理和常见算法? 标记清除与引用计数的区别与优缺点? 什么是内存泄漏?常见的内存泄漏类型? 如何避免内存泄漏? JS 引擎如何判断对象是否可回收? 浏览器中 GC 触发的时机? 如何用 Chrome DevTools 监测内存泄漏? 事件监听和闭包如何导致内存泄漏?

December 17, 2024