没有 Instruments 怎么在 Windows 上查 iOS 卡顿和内存
开发中发现 App 启动慢、列表滑动卡、内存越用越多,或者玩一会儿手机就发烫,在 Mac 上会打开 Instruments。这篇讲在 Windows 上怎么用 kxdevice 对 iPhone 真机采样,定位到问题代码并改掉。你需要一台 iPhone 和数据线,工程已经能用 KXBuild 编译,KXBuild 的安装见安装页。
先装 Release 包
Debug 包没有优化,还带着断言和调试信息,测出来的数据和用户手里的不同,在 Debug 下慢的地方到了 Release 可能不慢。测性能前先用 Release 配置构建并装到手机上。
1kxbuild build .\MyApp.xcodeproj -c Release --install
Release 包默认剥离符号,调用栈里只有地址。需要看函数名时加上 -D STRIP_INSTALLED_PRODUCT=NO 再构建一次。
1kxbuild build .\MyApp.xcodeproj -c Release --install -D STRIP_INSTALLED_PRODUCT=NO
测试时尽量固定条件,前后两次的数据才能比较。手机不要开低电量模式,发烫时先放凉再测,同一个操作重复几次看趋势,不要只看一次。手头有旧机型的,优先在旧机型上测,用户里最慢的那部分手机最先暴露问题。
启动 App 并拿到 PID
多数采样命令按进程号过滤。用 kxdevice 启动 App 会直接打印 PID,App 已经在运行时用 apps ps 查。
1kxdevice apps launch com.example.myapp
2kxdevice apps ps --apps
后面的采样命令默认一直输出,按 Ctrl-C 停止,加 -n 限定次数。接了多台手机时加 --udid 指定设备。
不想记命令时执行 kxdevice web,浏览器打开 http://localhost:8780,左侧「Instruments」页可以选中你的 App 进程,同时看 CPU、内存和帧率曲线,下面的进程列表按 CPU 占用排序。
启动慢
先确认慢在哪一段。在代码里记下 App 初始化的时间点,到第一个页面显示时打一条日志。
1import os
2
3enum LaunchTimer {
4 static let start = CFAbsoluteTimeGetCurrent()
5 static let log = Logger(subsystem: "com.example.myapp", category: "launch")
6}
7
8// application(_:didFinishLaunchingWithOptions:) 或 App 的 init 第一行
9_ = LaunchTimer.start
10
11// 第一个页面的 viewDidAppear 或 onAppear
12LaunchTimer.log.notice("首屏耗时 \(CFAbsoluteTimeGetCurrent() - LaunchTimer.start, format: .fixed(precision: 3)) 秒")
装好 Release 包后,先结束 App,再在一个终端里看日志,另一个终端里启动,测的就是冷启动。
1kxdevice apps kill com.example.myapp
2kxdevice ostrace --subsystem com.example.myapp
1kxdevice apps launch com.example.myapp
首屏耗时明显偏长时,在 didFinishLaunching 和第一个页面的初始化代码里再加几条日志,二分找到耗时的那一段。启动过程中同时运行 kxdevice instruments stackshot,能看到主线程停在哪些函数上。
常见原因和改法如下。
- 启动时同步初始化太多 SDK。统计、推送、广告这类不影响首屏的,放到第一个页面显示之后再初始化,或者放到后台队列。
- 主线程读大文件、做数据库迁移、解析大 JSON。移到
DispatchQueue.global()或 Swift 的Task.detached中执行,完成后回主线程更新界面。 - 首屏等网络请求返回才显示。先显示缓存或占位界面,请求回来再刷新。
滑动掉帧
用 fluidity 监控掉帧事件,然后在手机上来回滑动出问题的列表。
1kxdevice instruments fluidity --pid 1234
每次掉帧都会输出一条事件。停止滑动后没有新输出、一滑就连续出现,说明问题出在列表单元格的创建和显示上。再用 stackshot 查看掉帧那段时间主线程停在哪个函数,这时要用上一节带符号的 Release 包。
1kxdevice instruments stackshot -n 20
常见原因和改法如下。
- 主线程解码大图。
UIImage(named:)、UIImage(contentsOfFile:)拿到的图片在第一次显示时才解码,几 MB 的照片放进小图标位置,每次都会卡一下。按显示尺寸在后台生成缩略图,iOS 15 起可以用preparingThumbnail(of:)或byPreparingForDisplay(),更早的系统用 ImageIO 的CGImageSourceCreateThumbnailAtIndex。 - cellForRowAt 里做同步工作。读文件、查数据库、创建
DateFormatter都不要放在这里。数据提前准备好,DateFormatter建一个复用。 - 阴影和圆角引起离屏渲染。加阴影时设置
layer.shadowPath,大量带圆角的图片提前裁好再显示,不要每个单元格都masksToBounds。 - SwiftUI 长列表。用
LazyVStack或List,不要在body里做计算或创建格式化器,body会被频繁调用。
内存持续上涨
内存问题多数是页面关掉后对象没有释放。先找到一个能复现的操作,例如反复进入和退出同一个页面,然后记录这段时间的内存分配和回溯。
1kxdevice instruments allocations --pid 1234 --backtrace -n 20
记录期间进出页面五六次。只增不减的分配就是嫌疑对象,回溯指向创建它的代码和类。配合 kxdevice web 中的内存曲线,每进出一次涨一截、退出后不回落,基本就是这个页面泄漏了。
常见原因和改法如下。
- 闭包循环引用。对象持有闭包,闭包里又用了
self。在闭包开头写[weak self],Objective-C 用__weak typeof(self) weakSelf = self。 - Timer 和 CADisplayLink 强引用 target。
Timer.scheduledTimer(timeInterval:target:selector:...)和CADisplayLink会一直持有 target,页面消失时调用invalidate()。Swift 改用 block 形式的 Timer,并在 block 里弱引用self。 - delegate 声明成强引用。自己写的 delegate 属性要声明为
weak var delegate,Objective-C 用weak。 - 通知观察者没有移除。
addObserver(forName:object:queue:using:)返回的对象要保存下来,在页面销毁时移除,block 里同样弱引用self。 - 缓存没有上限。图片、数据缓存用
NSCache,系统内存紧张时会自动清理,字典做的缓存只会越来越大。
改完之后确认对象确实释放了。在 Debug 配置下给页面加一个 deinit,退出页面时应当打印出来。
1deinit {
2 print("DetailViewController deinit")
3}
Objective-C 在 dealloc 里打印。打印显示在 VS Code「输出」面板的 KXBuild Console 通道,退出页面后没有打印,说明还有别的对象持有它。
CPU 占用高、手机发烫
先看整机 CPU,每秒输出一次。
1kxdevice instruments sysmon -i 1
每行开头的 CPU: 35.2% 是整机总负载。让 App 停在一个静止的页面上,什么都不操作,数值还一直很高,说明后台有代码在持续运行。kxdevice web 的进程列表能看到各进程各自的 CPU 占用,确认是不是你的 App。
再对 App 进程采样一次能耗,输出一组以 energy. 开头的读数,看哪一项最高。
1kxdevice instruments energy 1234
要找具体是哪个函数在占 CPU,用 stackshot 采样调用栈。
常见原因和改法如下。
- 定时器间隔太短或没停。轮询用的 Timer 在页面不可见时停掉,能用推送、通知或 KVO 代替轮询的就不要轮询。
- 动画在看不见时仍在运行。页面消失时停掉
CADisplayLink、Lottie 这类循环动画,App 进入后台时同样要停。 - 定位精度过高。不需要导航级精度时,把
desiredAccuracy调低,不用时调用stopUpdatingLocation()。 - 反复触发布局。在
layoutSubviews或viewDidLayoutSubviews里修改会影响布局的属性,会引起一轮又一轮重新布局。
用 VS Code 断点缩小范围
采样告诉你大概是哪类问题、在哪个页面,具体是哪一行,用调试器找更快。把「构建配置」节点切回 Debug,按 F5 启动。Debug 下的耗时和 Release 不同,这一步只用来找代码位置,改完再用 Release 包复测。
- 卡住时按暂停。界面卡住的那一刻点调试工具栏的「暂停」,在「调用栈」面板看主线程,停在哪个函数就是卡在哪里。调试控制台输入
bt all可以打印所有线程的调用栈。 - 怀疑某个函数被调用太多次。在函数第一行加日志点,打印一段消息,看滑动一次打印多少条。
- 只想在某种情况下停下。用条件断点,例如
images.count > 500,内存涨到一定程度时停下来看是谁在往里加。
断点的完整用法见在 Windows 上真机调试 iPhone App。
改完代码后切回 Release,用同样的操作和同样的命令再测一次,和改之前的输出对比。掉帧事件变少、内存进出页面后能回落、静止时 CPU 降下来,才算改好了。
与 Xcode Instruments 的对应关系
用过 Instruments 的可以按模板找到对应命令,命令前面都加 kxdevice。
| Instruments 模板 | 命令 |
|---|---|
| Activity Monitor | instruments sysmon -i 1 |
| Time Profiler | instruments stackshot |
| Allocations / Leaks | instruments allocations --pid <pid> --backtrace |
| Core Animation / Animation Hitches | instruments fluidity --pid <pid> |
| Energy Log | instruments energy <pid> |
| os_signpost / Logging | instruments activitytrace --pid <pid> |
| System Trace | instruments coreprofile |
各命令的参数见性能分析和 kxdevice 命令参考。