
Здравствуйте, меня зовут Егор Короткий. Я работаю над Backend Driven UI — движком Fusion — в iOS-команде AliExpress. Сегодня я поделюсь опытом снижения крашей, связанных с нехваткой оперативной памяти на мобильных устройствах.
Основная идея и цели
Чтобы избежать перегрузки системы и крашей приложения, мы решили разработать инструмент для мониторинга памяти. Его задача — отслеживать потребление ресурсов на этапе тестирования и заранее предупреждать разработчиков о потенциальных перегрузках, чтобы проблемный код не попал к пользователям.
Цели:
Отслеживать текущее и пиковое использование памяти во время автоматизированных тестов.
Логировать данные для анализа и выявления узких мест.
Предотвращать создание ресурсоёмких экранов, информируя разработчиков о превышении лимитов.
Теперь перейдём к реализации.
Реализация
После того как мы сформировали идею и определили цели, возник ключевой вопрос: как именно измерять потребление памяти в тестируемом приложении и получать эти данные прямо из тестов?
При запуске UI-тестов на панели отладки Xcode доступна информация о потреблении ресурсов в реальном времени: метрики CPU, памяти, диска и сети для тестового таргета и самого приложения.

Однако эти данные недоступны из кода тестового таргета. Причина в том, что тестовый таргет (например, AERSmokeUITests
) и тестируемое приложение — это два отдельных процесса. Для анализа нам нужны данные тестируемого приложения, но прямых API для их извлечения через XCUIApplication
не существует.
Мы нашли решение: сначала извлекаем PID (идентификатор процесса) приложения, а затем используем его для профилирования памяти.
Получение PID приложения
Одним из первых вызовов стало получение PID нашего приложения во время выполнения тестов. Стандартные средства Apple не позволяют напрямую получить PID из XCUIApplication
, что осложняет мониторинг ресурсов.
Однако мы обнаружили, что свойство debugDescription
объекта XCUIApplication
содержит текстовую информацию, в том числе PID процесса.
Вот пример строки, которую мы получаем:
“Attributes: Application, 0x1059a9620, pid: 6092, label: ‘AliExpress’\nElement subtree:\n →Application, 0x1059a9620, pid: 11533, label: ‘AliExpress’\n Window (Main), 0x1059a9960, {{0.0, 0.0}, {402.0, 874.0}}\n Other, 0x105984d10, {{0.0, 0.0}, {402.0, 874.0}}\n …”
В этом тексте PID отображается как pid: 6092
. С помощью регулярного выражения мы можем извлечь PID из этой строки:
extension XCUIApplication {
func pid() -> String? {
let regex = /pid:\s*(?<pid>\d+)/
if let match = try? regex.firstMatch(in: debugDescription) {
return String(match.pid)
}
return nil
}
}
Профилирование памяти
После получения PID процесса нужно измерить потребление памяти нашим приложением. Получить такие данные из песочницы iOS-приложения средствами API невозможно, поэтому мы обратились к низкоуровневым функциям ядра операционной системы.
Мы исследовали возможности фреймворка Mach, который предоставляет интерфейсы для взаимодействия с ядром iOS, и обратили внимание на функцию task_info
. Она позволяет получать информацию о задаче (процессе) на уровне ОС.
С помощью функции task_info
с параметром TASK_VM_INFO
мы можем заполнить структуру task_vm_info_data_t
, содержащую подробные данные о виртуальной памяти процесса. Это именно то, что нам нужно!
Здесь мы столкнулись с проблемой. Документация по этим функциям скудна, и не всегда понятно, какие именно поля структуры стоит использовать для получения достоверной информации о потреблении памяти. После нескольких экспериментов и изучения исходного кода открытых проектов мы выяснили, что:
phys_footprint
показывает текущее физическое потребление памяти;ledger_phys_footprint_peak
отражает пиковое потребление памяти за весь жизненный цикл процесса.
С учётом этого мы приступили к реализации нашего профайлера памяти. Мы разработали класс MemoryUsageProfiler
, который инкапсулирует всю сложную работу с низкоуровневыми API и предоставляет простой интерфейс для получения текущего и пикового потребления памяти:
public final class MemoryUsageProfiler {
public init() {}
/// Retrieves the current memory usage for an application by its PID.
public func getCurrentMemoryUsage(for pid: String?) -> Double? {
guard let pid, let pidValue = pid_t(pid) else {
return nil
}
return getMemoryUsage(for: pidValue)?.current
}
/// Retrieves the peak memory usage for an application by its PID.
public func getPeakMemoryUsage(for pid: String?) -> Double? {
guard let pid, let pidValue = pid_t(pid) else {
return nil
}
return getMemoryUsage(for: pidValue)?.peak
}
private func getMemoryUsage(for pid: pid_t) -> (current: Double, peak: Double)? {
var task: mach_port_t = 0
let kern = task_for_pid(mach_task_self_, pid, &task)
guard kern == KERN_SUCCESS else {
return nil
}
return getMemoryUsage(for: task)
}
private func getMemoryUsage(for task: mach_port_t) -> (current: Double, peak: Double)? {
var vmInfo = task_vm_info_data_t()
var vmCount = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size) / 4
// Retrieve task information
let kern: kern_return_t = withUnsafeMutablePointer(to: &vmInfo) {
$0.withMemoryRebound(to: integer_t.self, capacity: 1) {
task_info(task, task_flavor_t(TASK_VM_INFO), $0, &vmCount)
}
}
guard kern == KERN_SUCCESS else {
return nil
}
// Extract current and peak memory usage
let currentMemoryUsage = vmInfo.phys_footprint.bytesToMegabytes()
let peakMemoryUsage = (UInt64(bitPattern: vmInfo.ledger_phys_footprint_peak)).bytesToMegabytes()
return (current: currentMemoryUsage, peak: peakMemoryUsage)
}
}
Значения из структуры task_vm_info_data_t
возвращаются в байтах, что не всегда удобно для восприятия. Поэтому мы решили конвертировать их в мегабайты с округлением до одного знака после запятой:
extension BinaryInteger {
/// Converts bytes to megabytes with rounding to one decimal place.
public func bytesToMegabytes() -> Double {
let megabytes = Double(self) / 1024.0 / 1024.0
return round(megabytes * 10) / 10
}
}
Логирование результатов
Чтобы эффективно анализировать и визуализировать данные о потреблении памяти, мы внедрили два способа логирования результатов:
Интеграция с GitLab CI. Данные сохраняются в формате CSV для последующего анализа.
Визуализация в Grafana. Данные отправляются в Victoria Metrics для мониторинга в реальном времени.
GitLab CI
При интеграции с GitLab CI мы решили хранить результаты тестов в виде CSV-файла. Это не совсем типично для iOS-разработки, так как чаще используют JSON или plist. Однако CSV отлично подходит для дальнейшего анализа данных, их импорта в другие системы и визуализации метрик. Мы хотели, чтобы после каждого запуска тестов данные о потреблении памяти (начальном, пиковом и конечном) были доступны в удобном табличном формате.
С интеграцией нам помогает функция MemoryUsageCSVFileLogger
, которая:
Создаёт CSV-файл с заголовками (при необходимости).
Добавляет новые строки с данными о потреблении памяти для каждого теста.
Сохраняет файл как артефакт в GitLab CI, чтобы мы могли анализировать изменения между сборками и коммитами.
Структура отчёта и базовые настройки
Сначала определяем структуру отчёта и имя файла:
enum MemoryUsageCSVFileLogger {
struct Report {
let commitHash: String
let testName: String
let startMemory: Double
let peakMemory: Double
let endMemory: Double
}
private static let csvFileName: String = "MemoryUsageLog.csv"
}
Каждый Report
— это одна строка в CSV, содержащая хеш коммита, имя теста и данные о памяти.
Создание файла при необходимости
Если CSV-файла ещё не существует, создаём его и добавляем заголовки:
private static func createFileIfNeeded(csvFilePath: String) {
let fileManager = FileManager.default
let fileURL = URL(fileURLWithPath: csvFilePath)
guard !fileManager.fileExists(atPath: csvFilePath) else {
return
}
let headers = ["CommitHash", "TestName", "StartMemory", "MaxMemory", "EndMemory"]
let headersString: String = headers.joined(separator: ",")
do {
try headersString.write(to: fileURL, atomically: true, encoding: .utf8)
} catch {
XCTFail("Failed to create CSV file: \(error)")
}
}
Здесь мы используем возможности FileManager
для проверки наличия файла и, если его нет, создаём его со строкой-заголовком.
Чтение CSV-файла
Чтобы добавлять новые строки в CSV-файл, нужно уметь считывать из него данные. Прочитаем файл построчно, разделяя строки запятыми:
private static func readCSV(filePath: String) -> [[String]]? {
do {
let content = try String(contentsOfFile: filePath, encoding: .utf8)
let lines = content.split(separator: "\n").map { $0.split(separator: ",").map { String($0) } }
return lines
} catch {
XCTFail("Error reading CSV file: \(error)")
return nil
}
}
Здесь мы превращаем содержимое файла в массив строк, а затем каждую строку — в массив значений, разделённых запятыми.
Запись данных в CSV
Чтобы добавить новые строки в CSV-файл, нам нужно взять уже существующие данные, добавить новую строку и перезаписать файл целиком:
private static func writeCSV(filePath: String, data: [[String]]) {
let csvString = data.map { $0.joined(separator: ",") }.joined(separator: "\n")
do {
try csvString.write(toFile: filePath, atomically: true, encoding: .utf8)
} catch {
XCTFail("Error writing CSV file: \(error)")
}
}
Здесь мы сначала собираем двумерный массив строк обратно в формат CSV (соединяя значения запятой и строки переносом), затем записываем всё в файл.
Добавление новой строки
Теперь, когда у нас есть функции для создания, чтения и записи файлов, мы можем реализовать логику вставки новой строки с результатами конкретного теста:
static func insertLineIntoCSV(filePath: String, report: Report) {
let newLine: [String] = report.toStringArray()
let csvFilePath: String = filePath + "/" + csvFileName
createFileIfNeeded(csvFilePath: csvFilePath)
guard var lines = readCSV(filePath: csvFilePath) else {
return
}
lines.append(newLine)
writeCSV(filePath: csvFilePath, data: lines)
}
Здесь мы:
Убеждаемся, что файл существует, или создаём его с заголовками.
Считываем текущие данные.
Добавляем новую строку.
Перезаписываем файл с обновлёнными данными.
Преобразование отчёта в массив строк
Последний штрих — метод, который конвертирует структуру Report
в массив строк, чтобы нам было проще добавлять данные в CSV:
extension MemoryUsageCSVFileLogger.Report {
fileprivate func toStringArray() -> [String] {
return [
commitHash,
testName,
String(startMemory),
String(peakMemory),
String(endMemory)
]
}
}
Пример итогового CSV-файла:
CommitHash |
TestName |
StartMemory |
MaxMemory |
EndMemory |
abc123 |
testMemoryUsageInHome |
150.0 |
200.5 |
155.0 |
Итог
Такой подход к работе с CSV показал, что Swift позволяет решать не только классические задачи мобильной разработки, но и сервисные, или инфраструктурные, задачи. Генерация и обновление CSV-файлов прямо из кода тестов на iOS позволяет более гибко анализировать данные, интегрироваться с CI-системами и другими инструментами DevOps.
Grafana
Визуализация — это финал всей работы по сбору и анализу метрик. Мы не просто хотим видеть числа — нам нужны наглядные графики, понятные дашборды и удобные инструменты для изучения динамики. Grafana выступает здесь в роли панорамного окна в мир метрик: позволяет мгновенно оценить состояние приложения, находить закономерности и оперативно реагировать на проблемы.
Чтобы Grafana могла получать наши данные, мы отправляем их в Victoria Metrics — хранилище метрик, совместимое с форматом Prometheus. На самом деле выбор конкретного хранилища не так важен, как стандартный формат метрик. Использование понятного языка взаимодействия с системами мониторинга позволяет легко интегрироваться с любыми существующими стеками, основанными на Prometheus и Grafana. Мы просто формируем метрики в нужном формате, отправляем их в хранилище, а Grafana визуализирует их.
Ниже мы привели пример кода, отправляющего данные о пиковом потреблении памяти в Victoria Metrics. Главное — мы используем стандартный протокол, понятный Grafana, поэтому получаем гибкое, масштабируемое решение для мониторинга памяти. Вы можете адаптировать этот код к своему стеку:
enum MemoryUsageVictoriaMetricsService {
static func logData(
commitHash: String,
testName: String,
peakMemory: String,
completion: @escaping (Result<Void, Error>) -> Void
) {
let payloadString = """
#TYPE \(Constants.metricName) \(Constants.metricType)
\(Constants.metricName){commitHash="\(commitHash)",testName="\(testName)",platform="ios"} \(peakMemory)
"""
guard let url = URL(string: Constants.victoriaMetricsURL) else {
completion(.failure(URLError(.badURL)))
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = payloadString.data(using: .utf8)
let task = URLSession.shared.dataTask(with: request) { _, response, error in
if let error = error {
completion(.failure(error))
} else {
completion(.success(()))
}
}
task.resume()
}
private enum Constants {
static let victoriaMetricsURL = "https://your-victoria-metrics-instance/api/v1/import/prometheus"
static let metricName = "memory_usage_peak"
static let metricType = "gauge"
}
}
Пример визуализации в Grafana:

Интеграция с тестами
После того как мы научились собирать, сохранять и визуализировать данные о потреблении памяти, осталось самое главное — органично вписать этот процесс в цикл разработки и тестирования. Для этого мы создали базовый тестовый класс MemoryUsageBaseTest
, который упрощает интеграцию мониторинга памяти с обычными тестами приложения. Теперь достаточно вызвать метод logMemoryUsage
, которым оперируют наследники MemoryUsageBaseTest
, передать имя теста и блок кода с действиями в приложении. Всё остальное — определение PID, профилирование памяти, логирование результатов — произойдёт «за кулисами».
Реализация базового класса:
class MemoryUsageBaseTest: XCTestCase {
private let memoryUsageProfiler = MemoryUsageProfiler()
private let memoryUsageLogger = MemoryUsageLogger()
func logMemoryUsage(testName: String, testedCode: (_ app: XCUIApplication) -> Void) {
guard let pid = app.pid() else {
return
}
let startMemoryUsage = currentMemoryUsage(pid: pid)
testedCode(app)
let endMemoryUsage = currentMemoryUsage(pid: pid)
let peakMemoryUsage = peakMemoryUsage(pid: pid)
memoryUsageLogger.log(
testName: testName,
testCase: self,
startMemoryUsage: startMemoryUsage,
endMemoryUsage: endMemoryUsage,
peakMemoryUsage: peakMemoryUsage
)
}
private func currentMemoryUsage(pid: String) -> Double {
guard let memoryUsage = memoryUsageProfiler.getCurrentMemoryUsage(for: pid) else {
XCTFail("Failed to collect current used memory")
return .zero
}
return memoryUsage
}
private func peakMemoryUsage(pid: String) -> Double {
guard let memoryUsage = memoryUsageProfiler.getPeakMemoryUsage(for: pid) else {
XCTFail("Failed to collect peak used memory")
return .zero
}
return memoryUsage
}
}
Пример теста:
final class MemoryUsageInHomeScreenTest: MemoryUsageBaseTest {
func testMemoryInHome() {
logMemoryUsage(testName: #function) { app in
// Screen navigation and interaction with elements
}
}
}
Таким образом, мы интегрировали процесс мониторинга памяти с процессом тестирования простым и прозрачным способом. Разработчики больше не тратят время на поиск утечек по всему коду — они видят конкретный коммит, изменения и их влияние на память.
Реальный кейс: глобальное увеличение потребления памяти
После внедрения системы мониторинга мы смогли не просто контролировать изменения на отдельных экранах, но и быстро оценивать влияние крупных внутренних перестроек на стабильность всего приложения. Именно так нам удалось оперативно обнаружить проблему, связанную с переходом на собственное решение для кэширования.
Изначально наше приложение использовало китайскую библиотеку кэширования тяжёлых ресурсов (изображений, шрифтов, мультимедиа), доставшуюся нам в наследство от глобального AliExpress. Она давно не обновлялась, её код был сложным и плохо документированным, а механизм кэширования — неэффективным по современным стандартам. Мы решили переписать кэширующую логику самостоятельно, рассчитывая получить более тонкий контроль над памятью и производительностью.
Новое решение было включено за фича-флагом — чтобы протестировать его влияние до релиза, не затрагивая реальных пользователей.
До включения фича-флага (использовалась старая библиотека) интеграционные тесты всего приложения показывали среднее пиковое потребление памяти на уровне 450 МБ.
Метрики до включения фича-флага:
Tests |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
Среднее |
testMemoryUsageInBuyProduct |
390.3 |
402.5 |
415.2 |
385.1 |
420.4 |
405.6 |
395.8 |
410.2 |
400.9 |
412.1 |
404.8 |
testMemoryUsageInHome |
425.0 |
435.7 |
420.3 |
430.1 |
415.6 |
440.5 |
422.0 |
418.9 |
435.2 |
428.3 |
424.1 |
testMemoryUsageInSearch |
450.1 |
460.2 |
455.3 |
448.9 |
462.7 |
470.0 |
445.8 |
452.3 |
460.5 |
457.1 |
456.3 |
После включения фича-флага (собственное решение для кэширования) пиковое потребление памяти в тех же тестах приблизилось к 900 МБ, фактически удвоилось.
Метрики после включения фича-флага:
Tests |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
Среднее |
testMemoryUsageInBuyProduct |
690.2 |
731.3 |
694.1 |
725.5 |
756.3 |
723.6 |
750.4 |
730.4 |
679.8 |
757.5 |
723.9 |
testMemoryUsageInHome |
849.7 |
833.5 |
700.3 |
794.0 |
871.6 |
828.4 |
857.2 |
953.5 |
716.1 |
822.9 |
822.4 |
testMemoryUsageInSearch |
903.4 |
865.0 |
905.6 |
814.8 |
908.2 |
848.0 |
906.9 |
953.5 |
894.1 |
822.8 |
892.9 |
Без системы мониторинга мы бы долго гадали, почему после активации фича-флага потребление памяти взлетело до небес. Но теперь всё было предельно ясно: резкий скачок совпал с включением нового кэширования. Изучив код, команда выяснила, что наше решение загружает и удерживает слишком много тяжёлых ресурсов, не освобождая их вовремя.
Мы оперативно отключили фичу-флаг, вернув приложение к прежним показателям и избежав проблем для реальных пользователей. Затем провели оптимизацию:
Добавили умный кэш-контроль, чтобы освободить неактуальные ресурсы.
Реализовали отложенную подгрузку (lazy loading) для самых тяжёлых данных.
Настроили механизмы снижения качества ресурсов при пиковых нагрузках.
После оптимизации повторный прогон тестов показал, что пиковое потребление стабилизировалось на уровне 460 МБ — лишь ненамного превысило исходные значения, но уже без перегрузок и рисков.
Таким образом, мониторинг памяти позволил нам своевременно обнаружить и исправить критическую проблему ещё до релиза, обеспечив стабильность и сохранив преимущества нашего нового решения. Это продемонстрировало ценность превентивного подхода к управлению ресурсами и подтвердило правильность внедрения системы мониторинга.
Результаты
Внедрение системы мониторинга потребления оперативной памяти принесло ощутимые результаты. Если раньше во время распродаж — периодов максимальной нагрузки на сервисы — стабильность приложения могла снизиться примерно до 99.082% crash-free, то после внедрения мониторинга и оптимизаций этот показатель удалось повысить до ~99.965%.


Это хороший результат, но мы не останавливаемся на достигнутом. Мы продолжим улучшать стабильность и производительность приложения.
Результаты
Внедрение системы мониторинга потребления оперативной памяти позволило нам:
Повысить стабильность приложения: снизилось количество крашей, связанных с нехваткой памяти.
Предотвратить появление новых проблем: разработчики получают актуальную информацию о потреблении ресурсов и могут своевременно оптимизировать код.
Улучшить качество продукта: постоянный мониторинг и анализ данных помогает повысить производительность и надёжность приложения.
Интеграция с GitLab CI и Grafana позволила автоматизировать процесс сбора данных, предоставила инструменты для их визуализации и анализа и стала частью нашего процесса разработки.
Благодарю команду разработчиков AliExpress Fusion за помощь в реализации решения. Вместе мы сгенерировали ценные идеи и нашли оптимальный подход к мониторингу и оптимизации памяти. Отдельное спасибо мобильной команде CI, которая обеспечила доступ к сборочным машинам и упростила процесс интеграции.
А был ли у вас опыт оптимизации производительности приложений? С какими проблемами вы сталкивались и какие инструменты или подходы использовали? Буду рад вашим комментариям, идеям и кейсам.