Всем привет! Меня зовут Фарид, я занимаюсь iOS-разработкой в компании Банки.ру.
Рано или поздно каждый проект сталкивается с проблемой утечек памяти: растёт её использование, в отдельных сценариях приложение ведёт себя странно или вовсе аварийно завершается. Начинается долгий и мучительный поиск причин утечки и отладка кода.
В нашем проекте ставка сделана на использование SwiftUI, что затрудняет решение задачи: из-за декларативности подхода и отсутствия явно выраженного жизненного цикла в UI, обнаружить причину утечки памяти сложнее.
В этой статье мы:
пройдёмся по основным подходам к поиску утечек;
попробуем найти способ сделать его обнаружение утечек менее болезненным;
выясним, можно ли каким-то образом застраховаться от утечек в будущем развитии проекта.
Имеющиеся инструменты
В Xcode есть хорошие инструменты для поиска утечек памяти. Бегло пройдёмся по ним и выделим их достоинства и недостатки.
Опытный подход
В процессе тестирования можно отслеживать используемую память и на основании изменений этого показателя выдвигать гипотезы о наличии утечки. Например, после входа на экран и немедленного выхода мы ожидаем, что использование памяти вырастет, а после вернётся к показателю, близкому к стартовому.
Если утечка есть, при многократном повторении этих манипуляций будем наблюдать такую «лесенку» на графике:
![](https://habrastorage.org/getpro/habr/upload_files/05b/15d/b5a/05b15db5ad19dedae5ddcb67fdfae249.png)
В отсутствии утечки картина другая:
![](https://habrastorage.org/getpro/habr/upload_files/022/b49/ed7/022b49ed7d83233a98375e49e9fce85b.png)
Плюс подхода в том, что мы можем примерно сказать, где у нас утечка.
Минусы:
приходится проверять каждый экран в отдельности;
никаких сигналов о том, что эту процедуру вообще нужно произвести, нет;
после длительного использования приложения мы видим лишь большой объем использования памяти. Информации о том, какой именно объект не освободился, у нас также нет.
Memory Graph
Больше информации может дать Xcode Memory Graph.
![](https://habrastorage.org/getpro/habr/upload_files/dcc/811/045/dcc8110459d2c41d5b55545ff0f782a5.png)
Здесь уже явно видно, какой именно объект не освободился.
Однако один из минусов предыдущего подхода сохраняется: утечку нужно специально искать.
Но есть и приятный бонус: в списке выделений памяти можно отфильтровать утечки и уже с помощью Memory Graph установить их причину.
![](https://habrastorage.org/getpro/habr/upload_files/863/926/957/863926957f1cb05181021a46a601606f.png)
То есть, пользуясь Memory Graph можно обнаружить утечку в процессе отладки и выявить её источники. Однако мы по-прежнему не получаем какого-то явного сигнала о наличии утечки: нам нужно останавливать выполнение приложения и проверять список выделений памяти.
Другие методы
Примерно аналогичный описанному выше функционал предоставляет инструмент Leaks в Instruments. На нём подробно останавливаться не будем.
Инструмент статического анализа в Xcode позволяет найти утечки, однако не работает с Swift-кодом.
Еще можно использовать symbolic breakpoints:
![](https://habrastorage.org/getpro/habr/upload_files/374/cee/918/374cee9187bda8914c9bf1782e593679.png)
В этом случае мы будем получать сообщения вида
--- -[UIViewController dealloc] @"<MemoryTest.LeakingViewController: 0x7f88acc69260>"
если объект освобождался. Если сообщения не видим – вероятно, этого не произошло и случилась утечка.
Но такой подход применим не везде и требует настройки точек остановки для вызова каждого конкретного dealloc или deinit.
Также можно добавить этап проверки утечки в unit-тесте:
addTeardownBlock { [weak viewController] in
XCTAssertNil(viewController, "Expected deallocation of \(viewController)")
}
Но и эта методика ограничена: она работает только в случаях утечки в конкретном сценарии, который проверяется этим тестом.
Подытожу рассмотрение имеющихся методик и выделю некоторые общие минусы подходов:
Мы должны либо заранее предполагать, что утечка имеется, либо отслеживать их постоянно в процессе отладки или тестирования приложения.
Во всех подходах участвует Xcode. Если утечка происходит при тестировании на реальном устройстве в каком-то отдельном сценарии и без Xcode, тестирование утечку не обнаружит.
В то же время нам бы хотелось, чтобы приложение в случае утечки аварийно завершилось, а QA-инженер смог создать отчёт с описанием сценария, который привёл к аварийному завершению. После чего, при воспроизведении этого сценария, разработчик смог бы выявить причины утечки, пользуясь ранее описанными инструментами.
Программный поиск утечек
Далее в статье создадим программные средства поиска утечек по времени выполнения приложения.
Рассмотрим на примере самого частого случая: когда освобождается какой-то экран ViewController, и при этом по какой-то причине в памяти остаётся ViewModel этого экрана. Попробуем вызвать аварийное завершение приложения для этого кейса, указав, какой объект не освободился, хотя мы этого ожидали.
Опишем такое поведение:
enum LeakDetection {
static func expectDeallocation(_ object: AnyObject, in timeInterval: TimeInterval = 1) {
DispatchQueue.main.asyncAfter(deadline: .now() + timeInterval) { [weak object] in
if let object {
fatalError("Expected deallocation of \(object)")
}
}
}
}
Теперь, если вызвать эту функцию в deinit нашего ViewController и передать ссылку на нашу ViewModel, мы получим краш, если через одну секунду ViewModel не будет освобождена.
Опробуем функцию, опишем такую «утекающую» модель:
final class LeakingViewModel: ObservableObject {
var leak: AnyObject? = nil
init() {
leak = self
}
}
И применим нашу функцию в контроллере:
final class LeakingViewController: UIViewController {
let viewModel = LeakingViewModel()
deinit {
LeakDetection.expectDeallocation(viewModel)
}
}
Запускаем и получаем ожидаемый краш:
![](https://habrastorage.org/getpro/habr/upload_files/aff/031/fef/aff031fef9f41d91d1ad83e732bc2c31.png)
Для варианта с использованием UIKit – вполне рабочая схема. Важная особенность состоит в том, что нам известен момент, в который мы ожидаем освобождение модели — при освобождении контроллера, то есть в deinit этого контроллера. Вызов LeakDetection.expectDeallocation(:) потребуется размещать в deinit всех наших контроллеров.
Однако, если мы используем SwiftUI, объекта-контроллера у нас нет. View – это структура, у которой deinit отсутствует. То есть, у нас просто нет функции, которая бы вызывалась при окончательном освобождении View и связанных с ней объектов. При этом, даже если в некоторых случаях мы можем привязаться к моменту, когда View точно будет удалена с экрана — например, если есть кнопка «Закрыть», — то вот когда View находится внутри стека навигации и удаляется с экрана кнопкой «Назад» или даже жестом смахивания, легко отследить это мы не сможем. Метод onDisappear также не подходит, поскольку будет срабатывать во множестве других случаев, не связанных с окончательным удалением View с экрана. Например, при показе какого-то модального экрана.
Мы можем добавить во View свойство-объект, при освобождении которого мы будем ожидать освобождение модели:
struct LeakingView: View {
@StateObject var viewModel = LeakingViewModel()
@State private var leakWatcher = LeakWatcher()
var body: some View {
Text("Hello world!")
.onAppear {
leakWatcher.expectDeallocation(viewModel)
}
}
}
final class LeakWatcher {
private struct WatchObject {
weak var object: AnyObject?
let timeInterval: TimeInterval
}
private var watches: [WatchObject] = []
func expectDeallocation(_ object: AnyObject, in timeInterval: TimeInterval = 1) {
watches.append(.init(object: object, timeInterval: timeInterval))
}
deinit {
for watch in watches {
if let object = watch.object {
LeakDetection.expectDeallocation(object, in: watch.timeInterval)
}
}
}
}
Схема также рабочая. Очевидный минус — слишком много бойлерплейт-кода во View. Нам бы хотелось как-то помечать свойства, объекты в которых должны освобождаться. Напрашивается какой-то property wrapper, чтобы наша View выглядела как-то так:
struct LeakingView: View {
@StateObject @Deallocating var viewModel = LeakingViewModel()
var body: some View {
Text("Hello world!")
}
}
При этом он должен соответствовать ObservableObject, чтобы View могла подписываться на его изменения, а также на изменения свойств этого объекта. Получается вот такой класс:
@propertyWrapper @dynamicMemberLookup
final class Deallocating<Value: AnyObject> {
var wrappedValue: Value
private let timeInterval: TimeInterval
init(wrappedValue: Value, timeInterval: TimeInterval = 1) {
self.wrappedValue = wrappedValue
self.timeInterval = timeInterval
}
subscript<Member>(dynamicMember keyPath: WritableKeyPath<Value, Member>) -> Member {
get {
wrappedValue[keyPath: keyPath]
}
set {
wrappedValue[keyPath: keyPath] = newValue
}
}
deinit {
LeakDetection.expectDeallocation(wrappedValue, in: timeInterval)
}
}
extension Deallocating: ObservableObject where Value: ObservableObject {
var objectWillChange: Value.ObjectWillChangePublisher {
wrappedValue.objectWillChange
}
}
Мы сохранили всю функциональность @StateObject и обеспечили отслеживание освобождения модели при освобождении View. Вернее, значений её свойств в графе атрибутов.
Характерно и то, что этот property wrapper применим к любым свойствам-объектам, также не являющихся ObservableObject.
Отмечу, что описанная методика прекрасно работает в паре с кодогенерацией. Мы можем добавить аннотацию @Deallocating в шаблон View и все новые экраны приложения будут защищены от утечек модели «из коробки».
Таким образом, мы создали средство отслеживания утечек памяти по времени выполнения. Если утечка происходит во время разработки или тестирования приложения, оно завершится аварийно. Мы получим сигнал о наличии утечки, последовательность действий в приложении, которая к ней приводит. У нас также будет возможность оперативно устранить утечку, пользуясь описанными выше инструментами.
Предупреждать проблемы в долгосрочной перспективе эффективнее, чем реагировать на них постфактум. Застраховав от утечек критические места приложения по описанной методике, мы не обнаружили каких-либо утечек памяти в моменте. При этом можем быть уверены, что эта проблема не проявится при дальнейшем развитии проекта и нам не придется тратить ресурсы тестирования и разработки.