这个月我一直在盯一个让人头疼的版本灰度到一半线上冷启动中位数从 1.1 秒直接飙到 2.4 秒差评区齐刷刷出现“打开转圈”“点图标等半天”。单看提交记录又似乎什么都没动坏只是顺手加了两个启动期 SDK 初始化、多加了一个全局单例、升级了个网络库冷启动时间就翻倍了。移动端性能优化里启动速度和流畅度是最容易被看见、又最难定位的一类问题——它不报错、不崩溃只是每处慢那么一点点叠加起来用户就说“卡”。这篇文章我不会讲那些“少用一层布局”之类的废话而是把 Android 和 iOS 双端从冷启动链路到掉帧根因完整拆一遍我自己的排查过程、埋点口径和落地手段适合正在做性能优化、准备建性能基线的团队直接参考。1. 别急着改代码先把启动速度拆成可测量的数字1.1 冷、温、热的定义不统一后面所有优化都是账糊涂很多团队在讨论启动优化时说的根本不是同一件事。有人统计的是“点击图标到看到 Logo”有人统计的是“到首页接口返回”还有人拿开发机的热启动数据当基准。口径不统一优化就无从谈起。这里建议直接按系统语义定义三态冷启动进程从头创建系统加载 App 二进制、创建 Application/AppDelegate到首帧真正绘制出来。这是优化主战场也是用户感知最明显的场景。热启动进程还在后台存活只是 Activity/ViewController 被重建或回到前台。热启动通常几十到几百毫秒重点不在进程初始化而在恢复状态和重建界面。温启动进程被杀但系统还保留部分缓存介于两者之间约等于一次简化的冷启动。Android 端冷启动从 Zygote fork 新进程开始然后依次执行 Application.attachBaseContext、Application.onCreate、Activity 创建与 View 树构建、首帧绘制。iOS 端冷启动则要经历内核加载、dyld 动态链接、ObjC runtime 初始化、main() 执行、UIApplication 启动这个完整过程。两端实测数据只有口径一致才有可比性。我踩过的坑是拿“首页 onResume 回调”当启动完成点结果首页 Fragment 里有三四个异步回调图片加载完才真正可点。最后统一改成“第一帧可交互”——即View.dispatchDraw执行完且用户关键操作已可响应。这个口径才真正对应用户体感。1.2 时间锚点别用累加值要一帧到底埋点方案上我见过不少团队用System.currentTimeMillis()在每个阶段打点最后用“后一个点减前一个点”来做耗时叠加。这个做法会吃大亏currentTimeMillis()不是单调时钟系统校时、用户手动改时间都会让差值变成负数或者翻倍。Android 端推荐统一用SystemClock.uptimeMillis()作为阶段时间锚点它是单调时钟不受系统时间调整影响。同时注意Process.getElapsedCpuTime()返回的是 CPU 占用时间不包含线程挂起等待不能用来测量“墙钟时间”这点很容易被误导。iOS 端对应推荐CACurrentMediaTime()或mach_absolute_time()同样是单调时钟。pre-main 阶段更要单独处理我会在第 3 节展开。埋点时另外注意两个细节。一是首帧标记要在ViewTreeObserver.OnDrawListener或Choreographer回调里打点而不是放在onCreate结尾——onCreate 结束远不等于首帧绘制完成。二是可交互点如首页核心按钮可点击通常晚于首帧建议各记一个时间戳输出到性能平台时再计算差值不要把一个点当成终点。2. Android 启动链路优化主线程少干一点是一点2.1 冷启动链路里真正能做文章的是哪几段Android 冷启动的完整链路大致是Launcher 点击 → AMS 向 Zygote 请求 fork 新进程 → 进程内创建 ActivityThread → 调用 Application.attachBaseContext → Application.onCreate → 启动 MainActivity → View 层级测量布局绘制 → 首帧上屏。其中 Zygote fork 阶段应用侧无法干预能优化的是后半程。我一般先抓三份数据systrace或Perfetto抓冷启动全程看主线程在哪个阶段忙了多久Debug.startMethodTracing/simpleperf抓 Application 阶段的 CPU 调用栈自埋点看 Application.onCreate、MainActivity.onCreate、首帧交付三个时间点。这里最典型的问题是Application.onCreate 里塞了太多同步初始化。我见过一个项目在 Application 里同步创建了数据库、预加载用户配置、初始化 IM 长连接、提前反射加载 WebView 内核、甚至预热图片加载框架全部串行执行光这一段就干了 800 多毫秒。一个更隐蔽的坑是ContentProvider 初始化。每个通过 manifest 声明的 ContentProvider 都会在 Application.onCreate 之前被实例化并执行 onCreate()等于一个隐形的同步初始化节点。如果某个第三方 SDK 用 ContentProvider 做自动初始化它就会实实在在地消耗冷启动时间。建议用adb shell dumpsys activity provider或第三步指令查看当前 App 的 provider 列表把可延迟的 SDK 改为手动初始化。2.2 启动期任务四分类该异步的异步该砍的砍落到操作层面我习惯把启动期全部初始化任务列成一张表按依赖和时序要求分成四类分类特征处理方式必须主线程同步首页强依赖且线程切换有可见首帧时延保留但做代码级瘦身能多快就多快可延迟到空闲期非交互关键但后续会被用到监听IdleHandler在主线程空闲后再逐批执行可异步执行无 UI 依赖只需线程安全初始化放入ExecutorService或协程启动完成前只能等待结果可完全去除启动路径上根本不需要的旧逻辑下线绝不犹豫这里有个反直觉的经验异步化并不一定更快。如果异步任务启动后主线程立刻阻塞等待它的结果那和同步执行没区别还额外多了线程切换成本。正确做法是让依赖方变成“先读缓存异步更新”而不是“等异步结果回来再继续”。比如用户配置项可以先读本地缓存展示后台线程拉新配置后通过回调刷新图片库初始化首屏必要图片用启动期预解码其余走正常加载流程。懒加载也是收割大头。典型例子是应用一启动就创建全局 Toast 工具、开传感器监听、注册网络状态广播、创建数据库连接池。这些都可以改成“首次使用时再初始化”。以数据库为例Application 阶段只创建连接配置对象真正执行 open 放到第一条查询前能省掉 50-150 毫秒主线程时间。2.3 首屏 View 层优化异步准备视图而不是现场装配Application 阶段瘦完身下一刀切在 Activity 的视图构建上。一个常见现象是主界面用了一堆嵌套布局根布局又做了过度绘制导致测量和绘制阶段耗时明显。第一招先做“Layout 精简”使用Layout Inspector或systrace的视图构建段检查层级深度把无用的中间容器删掉。FrameLayout 与 ConstraintLayout 之间没有绝对优劣关键是层级不要超过 4-6 层。第二招是视图预创建。复杂列表页或主页面可以在 Application 空闲期用LayoutInflater预创建一份 ItemView 模板创建完成后放入对象池真正列表滚动时直接复用。这招对 RecyclerView 效果尤其明显首屏 Item 的 inflate 时间可以省掉大半。注意预创建应当在低优先级线程THREAD_PRIORITY_BACKGROUND里执行避免抢主线程时间片。第三招是主题闪屏。在等待数据加载的过程中不要白屏。定义一个启动主题设置windowBackground为品牌图片或纯色让系统在 App 真正绘制出首帧前先展示启动背景。这并不会缩短加载时间但用户感知是“秒开”且能避免启动时黑白屏闪烁的劣质感。很多厂商 App 都在用这一手搭配上边的内容瘦身体感提升非常明显。3. iOS 启动优化真正的战场在 pre-main3.1 pre-main 阶段到底在忙什么怎么量化iOS 冷启动的时间和 Android 有很大不同很大一块消耗发生在我们自己的代码一个字节都没有执行之前也就是main()之前的 dyld 阶段。这个阶段包含五个主要动作加载所有动态库系统库 应用自带动态库对 Mach-O 执行 rebase修正内部指针执行 bind绑定外部符号注册 ObjC 类、分类、协议执行所有静态初始化包括load方法和 C 静态对象构造。要量化这一段一条命令就够在 scheme 的环境变量里加DYLD_PRINT_STATISTICS1以及DYLD_PRINT_STATISTICS_DETAILS1冷启动后在控制台会打印出每个阶段的耗时明细。它连续展示了 total time、dyld 加载 mapper/mapping、rebase/bind、ObjC setup、initializer 等分段数据是 iOS pre-main 优化的第一手依据。我实际优化过的项目里最常见的耗时段是initializer初始化器± 动态库加载。有些工程为了追求模块化塞了十几个自研动态库dyld 加载成本成倍上涨还有些老项目每个类都写load方法在冷启动时全量执行。在 iPhone 8 这类老机型上前者的加载成本能达到几百毫秒。3.2 拆动态库、干系 load、盯 C 静态对象iOS 侧优化 pre-main 有四个可落地的方向减少动态库数量。把纯业务的自研动态库改回静态库合并进主二进制或至少合并成少数几个动态库。系统库一般不动但注意哪怕是空壳动态库每多一个也就多一份映射和解析开销。清剿load方法。load在 main() 之前调用且是同步全量执行。大部分可以改到initialize第一次消息派发时才执行或dispatch_once。有些老代码在load里做方法 Swizzling这类操作可以改到didFinishLaunching之后的第一帧前用dispatch_once包住。处理 C 静态对象。在__attribute__((constructor))中执行的逻辑同理能延就延能换 static local 对象首次访问初始化就换。二进制重排Launch Time Tuning。用 linker 的-order_file把启动路径上的函数集中排列减少虚拟内存页换页带来的命中消耗。这一招收益在冷启动前后都可观尤其对后缀老设备效果明显。这里掺杂一个经验不要为了“模块化”而盲目使用动态库。iOS 的动态库加载不是免费的它在启动期的成本显著高于静态库。动态库更适用于真正需要独立更新或共享的托管和非托管库业务模块间尽可能用静态库加组件化协议的方式组织。3.3 main() 之后首帧缓、同步 IO 都值得查pre-main 处理完转头检查application:didFinishLaunchingWithOptions:到首帧渲染这一段的耗时。这里的高危操作有几类同步读取本地文件用户配置、缓存目录遍历、数据库 open启动时就创建整个 Tab 树所有子页面 ViewController 一次性实例化首屏静图或大图在主线程解码GPU/CPU 首帧时间立刻飙升通知权限弹窗、隐私合规弹窗在启动首帧前弹出把启动流程人为滞后。针对这些我通常把 didFinishLaunching 里的任务按“首帧必须/首帧后可以”重新划一遍非必须任务统一排到首帧完成后的下一次 runloop 空闲再执行或者干脆用后台队列并行。首屏若依赖网络先展示缓存数据 骨架屏再异步刷新数据库和文件 IO 全部移到后台队列。另外传起一个细节iOS 的DispatchGroup/dispatch_async虽然不占主线程但如果几百个任务同时往全局队列丢反而会因为线程池争抢拖慢启动建议用串行队列或有上限的并发队列来控制。4. 流畅度优化掉帧只是表象卡顿要看那三处4.1 为什么 16.7ms 是生死线移动端流畅度的物理基础是 60Hz 屏幕刷新率也就是每 16.7ms 要出一帧。当然现在很多设备到了 90Hz、120Hz、144Hz对应预算更紧张分别是 11.1ms、8.3ms 和 6.9ms。一帧画面需要经过 CPU 处理布局与绘制指令、GPU 渲染合成、最后上屏。如果某帧在 CPU 或 GPU 阶段超了预算下屏幕刷新时这一帧就会缺——于是呈现为“掉帧”也就是我们常说的卡顿。理解卡顿不能只看掉帧本身。需要拆三处主线程UI 线程是不是干了太多活导致布局、绘制指令提交延后渲染线程是否过载GPU 提交是不是在等待纹理上传、Shader 编译CPU 是否存在竞争后台任务占了太多核心或者系统在 Java/Kotlin 层的 GC 频繁触发。我用过一个很朴素却有效的分类卡顿问题先用systrace或Perfetto抓 trace看卡顿时间戳附近的帧状态标记是 “主线程超时” 还是 “GPU waiting”。主线程超时查执行栈GPU waiting 查绘制指令和纹理内存。大部分情况下结论会集中在主线程因为 GPU 问题大多来自纹理过大、overdraw 和无谓的离屏渲染。4.2 主线程排查三板斧离线监控、在线监控、锁竞争先说离线监控。Android 用Perfetto或旧版systrace抓一次 10 秒滑动操作注意要在开发机上开 CPU 调度段和主线程段然后看 1s 区间内的线程状态。iOS 用 Instruments 的 Time Profiler或者os_signpost结合 Xcode 的线程性能工具。离线定位问题时我会特别关注三件事主线程是否长时间处于 Running 状态没切换是不是有频繁 GC 或者 malloc 大块内存导致系统抖动锁竞争是否严重lockHoldTime持续高位。在线监控方面Android 侧可以在Choreographer.FrameCallback里记录相邻帧间隔时间发现超过临界值比如 2 倍帧预算就打点同时抓主线程堆栈。iOS 侧可以用CADisplayLink做类似逻辑或对RunLoop的kCFRunLoopBeforeSources到kCFRunLoopAfterWaiting做观察统计卡顿期间主线程正在跑什么函数。更成熟的方案是参考 BlockCanary / Matrix-IO 这类现有 SDK 的思路自己按需裁剪。第三类是锁竞争。我发现很多慢并非来自耗时任务而是一把锁被后台线程长期持有主线程在等锁。排查手段在关键锁前后加临时打点用并发统计线程的 wait time。修复手段很简单——缩小锁粒度、改读锁或直接用无锁结构如ConcurrentLinkedQueue、os_unfair_lock的短临界区。还有一类隐蔽问题启动路径上的单例初始化如果被多个后台线程同时触发内部的同步块可能互相阻塞。统一用一个启动队列去初始化比各自加锁更可控。4.3 列表和动画最容易把卡顿“显形”的两个场景列表滚动是最容易暴露卡顿的界面。RecyclerView 侧我重点检查四点setHasFixedSize(true)有没有设置没设置会多走一次测量notifyDataSetChanged()是不是被滥用建议换成DiffUtil做局部刷新Item 布局层级是否过深item 里避免多层嵌套和weight属性图片加载过程中有没有频繁设置ImageView.setImageBitmap造成大量重绘。iOS 侧 UITableView / UICollectionView 同理重点检查 cell 复用逻辑避免在heightForRowAtIndexPath里做高开销计算每滚动一帧都要调用一次。cell 内容里的圆角、阴影也要小心cornerRadius masksToBounds会触发离屏渲染滚动场景建议预生成带圆角的图片或用UIImaging先画好。动画侧的坑更隐蔽。首先是避免在主线程持续回调中读文件/网络。比如在ValueAnimator或UIView动画的每一帧回调里去做格式化、写日志、更新多个布局约束哪怕单次只有几毫秒每帧叠加也会超过预算。其次是属性选择首选用transform、alpha、opacity这类可以由 GPU 高效处理的属性别拿layout_constraints或frame每一帧改去做动画后者会触发完整的布局计算。Android 用ViewPropertyAnimator或Camera渲染iOS 的UIViewPropertyAnimator天然也是走 Core Animation 提交只要别在 block 里写耗时逻辑就不会太差。最后说一句掉帧率比平均帧率更能反映卡顿感受。60 帧的平均帧率可能很漂亮但只要中间掉了几次 30ms 的帧用户就会体感明显“卡”。所以线上指标我更推荐监控jank%掉帧次数 ÷ 总帧数而不是平均值。5. 把优化成果固化下来基线、阈值和线上回归5.1 给“卡”也定个量化指标优化做得再好没有基线防回归下个版本就会偷偷长回去。团队内部至少要有一份按设备分级、按系统分级的性能基准表。我通常设这么几个核心指标指标建议口径阈值参考冷启动中位数P50同口径首帧可交互Android 1.5siOS 1.2s老机型可放宽冷启动 P95反映最差体验 2.5s卡顿率jank% 掉帧占比 0.5%主线程慢函数单次 200ms 的集中堆栈按次数逐步清零启动期同步 IO主线程启动路径上的 IO 次数0 次要注意不同系统版本、不同硬件等级的基准差异巨大。用同一台开发机跑一遍启动数据就建基线没什么现实价值。至少要按“高端机 / 中端机 / 低端机”各建一套再按冷启动口径分别输出 P50 和 P95。另外 Android 低端机的 CPU 频率波动大启动期的数据噪音也高建议至少连续跑 5 次取中位数而不是一次定位。5.2 建立 CI 回归和线上告警基线建好后接下来两个动作要让优化成果活下来。第一个动作是把启动性能测试放进 CI。Android 可以写一个 instrumentation 测试用例清空进程后启动 MainActivity再轮询等首帧标记把耗时与基线对比。iOS 可以用 XCUITest 启动 App在applicationDidFinishLaunching埋点上读取时间。每次合入请求都挂一条性能测试一旦超过阈值就直接阻断合入。这套流程真正在前端阻挡了“启动期加一段初始化代码”这类看似无害的合入。第二个动作是线上分位数告警。在性能平台配置 P95 突变告警例如“冷启动 P95 相比昨日同版本阈值增加 300ms”或“卡顿率提升 0.2%”时告警。我比较推荐按条件过滤后再告警只统计最近三天内有冷启动、且版本号匹配的用户剔除冷启动时被系统杀死的极端数据。等告警真正有效了再把同步 IO 次数、主线程单次超过 200ms 的堆栈等专项指标也逐步接上去。5.3 一个真实的优化收益案例供你对照最后放一组我最近一次优化的小样本数据方便对照参考。项目是 Android 端的一个工具类 App主流程首页占整体用户停留的 80%。优化动作包括Application 初始化瘦身、SQLite 懒加载、首屏 View 预创建、动态库延迟加载以及首页网络请求并行化。在低端测试机骁龙 662Android 10上冷启动中位数从 2.3 秒降到 1.4 秒卡顿率从 0.8% 降到 0.3%iOS 端同样一台 iPhone 11主要处理了 pre-main 的 load 方法清理和二进制重排冷启动中位数从 1.2 秒降到 0.9 秒。这个收益数据不算激进但你注意一个细节优化收益在低端机上比旗舰机上明显得多。性能优化这件事本质是解决低端机和中端机用户被默认忽略的体验。所以如果团队预算有限我会建议优先在低端设备上做调优和验收一次优化下来整体用户满意度变化远比旗舰机格局的变化大得多。这次优化过后我个人的一个强烈体会是启动速度和流畅度的问题绝大多数不是某个单独的大坑而是几十个小坑的累加。今天少一个 provider明天少一个 load后天把一块锁拆细单独看每一项都只快了十几毫秒但合在一起就是“打开 App 明显变快”的用户感知。性能优化没有银弹本质就是一遍一遍地看数据、删冗余、守基线然后 Repeat。