Skip to content

[iOS][XCTest] XCDevice 真机内存快照(memgraph)采集:单轮 measure + 独立测试工程 #54

Description

@nzcv

[iOS][XCTest] XCDevice 真机内存快照(memgraph)采集:单轮 measure + 独立测试工程

场景:Unity 导出的 Xcode 工程(XPrj,App bundle id com.rm42.TrashDash),用 idevice 的 XCDevice.test()物理真机 上跑一个与 App 工程解耦的独立 XCUITest,执行「启动游戏 → 延时 10s → 开始采集 → 延时 30s → 结束采集 → 结束游戏」,采集内存指标并导出 .memgraph 内存图。

总链路

独立测试工程 UITestsTemplate (UI Testing Bundle, 无 host app)
   → xcodebuild test (XCDevice.test) 针对真机
   → 测试内用 XCUIApplication(bundleIdentifier:) 启动已安装的 App
   → XCTMemoryMetric 采集 + -enablePerformanceTestsDiagnostics YES 产出 pre/post memgraph
   → xcresulttool export attachments 导出 memgraphset

方案一(推荐):测试工程固定、与频繁重导出的 App 工程完全解耦。测试用例通过 bundle id 启动「已安装」的 App,Unity 怎么重导出都不影响测试工程。

1. 独立测试工程的必要配置

UITestsTemplate.xcodeproj 是一个 UI Testing Bundle(无 target application),可驱动任意已安装 App。要在真机上跑通需配置两点:

1.1 签名团队(否则测试 runner 无法在真机签名)

project.pbxproj 里给测试 target 设置:

DEVELOPMENT_TEAM = TB8YV4RNJT;   # 用 App 工程同一个 Team
CODE_SIGN_STYLE = Automatic;

1.2 被测 App 的 bundle id

测试用例通过环境变量 APP_BUNDLE_ID 决定启动哪个 App:

private let defaultBundleID = "com.example.app"
private var bundleID: String {
    ProcessInfo.processInfo.environment["APP_BUNDLE_ID"] ?? defaultBundleID
}
let app = XCUIApplication(bundleIdentifier: bundleID)

UITests.xcschemeTestAction 注入:

<TestAction
   ...
   shouldUseLaunchSchemeArgsEnv = "NO">   <!-- 关键:必须为 NO -->
   <EnvironmentVariables>
      <EnvironmentVariable key = "APP_BUNDLE_ID" value = "com.rm42.TrashDash" isEnabled = "YES"/>
   </EnvironmentVariables>
   <Testables> ... </Testables>
</TestAction>

⚠️ :shouldUseLaunchSchemeArgsEnv = "YES" 时,Test action 会继承 Run action 的(空)环境变量,忽略 TestAction 自己的 <EnvironmentVariables>,导致测试仍去启动默认的 com.example.app(设备上没装 → FBSApplicationLibrary returned nil)。改为 NO 即可。

2. 内存快照用例(单轮 + 可抓 memgraph)

import XCTest

final class GameplayUITests: XCTestCase {

    private let defaultBundleID = "com.example.app"
    private var bundleID: String {
        ProcessInfo.processInfo.environment["APP_BUNDLE_ID"] ?? defaultBundleID
    }

    private let warmupSeconds: UInt32 = 10
    private let captureSeconds: UInt32 = 30

    override func setUpWithError() throws { continueAfterFailure = false }

    // 结束游戏放在 tearDown,保证 post memgraph 抓取时 App 仍存活
    override func tearDownWithError() throws {
        XCUIApplication(bundleIdentifier: bundleID).terminate()   // 结束游戏
    }

    func testGameplayMemory() throws {
        let app = XCUIApplication(bundleIdentifier: bundleID)

        let options = XCTMeasureOptions()
        options.invocationOptions = [.manuallyStart, .manuallyStop]
        options.iterationCount = 1   // 只测一轮(block 跑 iterationCount+1 次,首次 warmup 被忽略)

        measure(metrics: [XCTMemoryMetric(application: app)], options: options) {
            app.launch()                                          // 启动游戏
            XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15))
            sleep(warmupSeconds)                                  // 延时 10s
            startMeasuring()                                      // 开始采集
            sleep(captureSeconds)                                 // 延时 30s
            stopMeasuring()                                       // 结束采集
        }
    }
}

两个关键点

  • 只测一轮:XCTMeasureOptions.iterationCount 控制测量次数;measure 实际跑 iterationCount+1 次(首次是被忽略的 warmup)。设 1 后指标 measurements 只有 1 个值;默认是 5。耗时从 ~5.4min 降到 ~2.5min。
  • memgraph 与「结束游戏」的顺序冲突:-enablePerformanceTestsDiagnostics YES额外追加一次迭代,在测量迭代开始/结束时分别抓 pre_* / post_* memgraph。post 图在迭代结束那一刻抓取,所以不能在 measure {} 块内提前 app.terminate(),否则 post 图抓不到存活进程。把「结束游戏」挪到 tearDownWithError()

3. 用 idevice 运行(开启 memgraph 诊断)

XCDevice.test() 支持 enable_performance_diagnostics,内部追加 -enablePerformanceTestsDiagnostics YES:

from idevice.device import XCDevice

xc = XCDevice("/path/to/XPrj")   # Unity 导出工程 / .xcodeproj 路径
result_bundle = xc.test(
    test_project="examples/UITestsTemplate/UITestsTemplate.xcodeproj",
    scheme="UITests",
    udid="00008101-00161DAE14B8001E",
    only_testing=["UITests/GameplayUITests/testGameplayMemory"],
    enable_performance_diagnostics=True,
)
print(result_bundle)   # .../build/TestResults.xcresult

等价的 xcodebuild 命令:

xcodebuild test \
  -project examples/UITestsTemplate/UITestsTemplate.xcodeproj \
  -scheme UITests \
  -destination 'platform=iOS,id=00008101-00161DAE14B8001E' \
  -only-testing:UITests/GameplayUITests/testGameplayMemory \
  -enablePerformanceTestsDiagnostics YES \
  -resultBundlePath ./TestResults.xcresult

设备 UDID 获取:xcrun devicectl device info details --device <coredevice-id>hardwareProperties.udid

4. 导出并查看 memgraph

xcrun xcresulttool export attachments \
  --path TestResults.xcresult \
  --output-path memgraph_output

导出的附件是一个 memgraphset 的 .tar.gz(文件名为 UUID .gz,真实名见 manifest.jsonsuggestedHumanReadableName),解包后得到两张图:

pre_TrashDash_<时间>.memgraph     # 测量迭代开始
post_TrashDash_<时间>.memgraph    # 测量迭代结束(相隔 ≈30s 采集窗口)
tar -xzf <uuid>.gz -C extracted

5. 分析

# pre/post 增长对比
heap -diffFrom pre_*.memgraph post_*.memgraph

# 泄漏 / 大对象 / 虚存摘要
leaks --fullContent post_*.memgraph
heap post_*.memgraph -sortBySize
vmmap --summary post_*.memgraph

也可 open *.memgraph 用 Xcode 可视化查看。

实测样例(iPhone 12 Pro / iOS 26.3.1,com.rm42.TrashDash):

pre post
Physical footprint 411.1 MB 413.0 MB
Physical footprint (peak) 443.1 MB 443.1 MB

XCTMemoryMetric 指标:峰值物理内存 ≈456 MB,泄漏计数 84。

注意事项 / 踩坑总结

  1. memgraph 仅真机可靠,模拟器不生成。
  2. 必须加 -enablePerformanceTestsDiagnostics YES,否则只有内存数值、没有 .memgraph
  3. shouldUseLaunchSchemeArgsEnv 必须为 NO,否则 scheme 里 TestAction 的环境变量(APP_BUNDLE_ID)被忽略。
  4. 测试 runner 需 DEVELOPMENT_TEAM 才能在真机签名运行。
  5. app.terminate() 不能放在 measure {} 块内(会破坏 post memgraph 抓取),放 tearDown
  6. XCDevice.test() 每次运行前会 rmtree 同名 TestResults.xcresult;要留存结果用 result_bundle_path= 指定不同路径,或及时复制 memgraphset
  7. iterationCount 最小有效值为 1(=1 warmup + 1 measured);设 0 无测量结果。

推荐组合

固定独立 UITest 工程(方案一)+ APP_BUNDLE_ID 环境变量切换被测 App + iterationCount=1 单轮 + enable_performance_diagnostics=True + xcresulttool export attachments 导出 → heap -diffFrom 分析增量。Unity 重导出 App 工程不影响测试工程,维护成本最低。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions