跳到主要内容
教程目录

没有 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 命令参考。