Всем привет! Меня зовут Фарид, я занимаюсь iOS-разработкой в компании Банки.ру.

Рано или поздно каждый проект сталкивается с проблемой утечек памяти: растёт её использование, в отдельных сценариях приложение ведёт себя странно или вовсе аварийно завершается. Начинается долгий и мучительный поиск причин утечки и отладка кода. 

В нашем проекте ставка сделана на использование SwiftUI, что затрудняет решение задачи: из-за декларативности подхода и отсутствия явно выраженного жизненного цикла в UI, обнаружить причину утечки памяти сложнее. 

В этой статье мы: 

  • пройдёмся по основным подходам к поиску утечек; 

  • попробуем найти способ сделать его обнаружение утечек менее болезненным;

  • выясним, можно ли каким-то образом застраховаться от утечек в будущем развитии проекта.

Имеющиеся инструменты

В Xcode есть хорошие инструменты для поиска утечек памяти. Бегло пройдёмся по ним и выделим их достоинства и недостатки.

Опытный подход

В процессе тестирования можно отслеживать используемую память и на основании изменений этого показателя выдвигать гипотезы о наличии утечки. Например, после входа на экран и немедленного выхода мы ожидаем, что использование памяти вырастет, а после вернётся к показателю, близкому к стартовому. 

Если утечка есть, при многократном повторении этих манипуляций будем наблюдать такую «лесенку» на графике:

В отсутствии утечки картина другая:

Плюс подхода в том, что мы можем примерно сказать, где у нас утечка. 

Минусы: 

  • приходится проверять каждый экран в отдельности; 

  • никаких сигналов о том, что эту процедуру вообще нужно произвести, нет; 

  • после длительного использования приложения мы видим лишь большой объем использования памяти. Информации о том, какой именно объект не освободился, у нас также нет.

Memory Graph

Больше информации может дать Xcode Memory Graph

Здесь уже явно видно, какой именно объект не освободился.

Однако один из минусов предыдущего подхода сохраняется: утечку нужно специально искать. 

Но есть и приятный бонус: в списке выделений памяти можно отфильтровать утечки и уже с помощью Memory Graph установить их причину.

То есть, пользуясь Memory Graph можно обнаружить утечку в процессе отладки и выявить её источники. Однако мы по-прежнему не получаем какого-то явного сигнала о наличии утечки: нам нужно останавливать выполнение приложения и проверять список выделений памяти.

Другие методы

Примерно аналогичный описанному выше функционал предоставляет инструмент Leaks в Instruments. На нём подробно останавливаться не будем.

Инструмент статического анализа в Xcode позволяет найти утечки, однако не работает с Swift-кодом.

Еще можно использовать symbolic breakpoints:

В этом случае мы будем получать сообщения вида

--- -[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)
    }
}

Запускаем и получаем ожидаемый краш:

Для варианта с использованием 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 и все новые экраны приложения будут защищены от утечек модели «из коробки».

Таким образом, мы создали средство отслеживания утечек памяти по времени выполнения. Если утечка происходит во время разработки или тестирования приложения, оно завершится аварийно. Мы получим сигнал о наличии утечки, последовательность действий в приложении, которая к ней приводит. У нас также будет возможность оперативно устранить утечку, пользуясь описанными выше инструментами.

Предупреждать проблемы в долгосрочной перспективе эффективнее, чем реагировать на них постфактум. Застраховав от утечек критические места приложения по описанной методике, мы не обнаружили каких-либо утечек памяти в моменте. При этом можем быть уверены, что эта проблема не проявится при дальнейшем развитии проекта и нам не придется тратить ресурсы тестирования и разработки.

Комментарии (11)


  1. FreeNickname
    19.08.2024 11:37

    Спасибо за статью! Интересный подход.

    А Вы не замеряли, есть ли заметные накладные расходы по производительности при использовании этого подхода?

    Насколько часто Вы пользуетесь этими аннотациями? Только для ViewModel или для каких-то более мелких объектов тоже?

    Вы оставляете эти проверки в release-версии?

    Примерно аналогичный описанному выше функционал предоставляет инструмент Leaks в Instruments. На нём подробно останавливаться не будем.

    Но ведь не совсем? Instruments позволяет именно обнаружить утечки, они "подсвечиваются" по ходу работы приложения. Или применительно к SwiftUI этот инструмент работает не очень хорошо? Я в SwiftUI пока не пробовал им пользоваться.


    1. FrD1 Автор
      19.08.2024 11:37
      +2

      А Вы не замеряли, есть ли заметные накладные расходы по производительности при использовании этого подхода?

      Какие-то расходы есть, конечно, но они не сравнимы с последствиями утечки

      Насколько часто Вы пользуетесь этими аннотациями? Только для ViewModel или для каких-то более мелких объектов тоже?

      В основном – для моделей. Обычно, если "утекает" что-то мелкое внутри модели – сама модель тоже не освобождается. Однако, ограничений нет.

      Вы оставляете эти проверки в release-версии?

      Нет, в реальном коде проверки закрыты аннотациями #if DEBUG || ADHOC

      Но ведь не совсем? Instruments позволяет именно обнаружить утечки, они "подсвечиваются" по ходу работы приложения. Или применительно к SwiftUI этот инструмент работает не очень хорошо? Я в SwiftUI пока не пробовал им пользоваться.

      В самом Xcode примерно также, только нужно останавливать выполнение. Однако, не это главное. В описанном варианте утечку может обнаружить QA-инженер, тестирующий на реальном устройстве, а не только разработчик в Xcode/Instruments, когда специально этим озаботится. Разработчик может спокойно писать код, зная, что утечки будут найдены в процессе тестирования.


      1. FreeNickname
        19.08.2024 11:37

        Нет, в реальном коде проверки закрыты аннотациями #if DEBUG || ADHOC

        Тогда все вопросы к накладным расходам снимаются)


  1. house2008
    19.08.2024 11:37

    Deleted, перепутал блоки кода)


  1. varton86
    19.08.2024 11:37

    Вы могли просто добавить deinit с проверкой в класс LeakingViewModel и таким образом контролировать удаление объекта из памяти.


    1. FrD1 Автор
      19.08.2024 11:37
      +1

      Если объект LeakingViewModel "утечёт" – его deinit не будет вызван, соответственно, проверка не состоится.


      1. varton86
        19.08.2024 11:37

        Всё правильно, таким образом вы и узнаете, что есть утечка, потому что deinit не был вызван.


        1. FreeNickname
          19.08.2024 11:37

          Но в какой момент мы будем считать отсутствие вызова проверки в deinit утечкой?


          1. varton86
            19.08.2024 11:37

            В тот же момент, когда вы ожидаете вызов метода deinit в вашем примере у классов LeakWatcher и property wrapper.


            1. FreeNickname
              19.08.2024 11:37

              Мы с FrD1 разные люди, я просто тоже не совсем понял, что Вы имеете в виду) Вы не могли бы пояснить? Что бы Вы написали в deinit ViewModel-и / ViewController-а? И сделали ли бы Вы какие-нибудь ещё изменения?