Процесс управления памятью может стать фактором, весьма затрудняющим работу в приложениях iOS. В связи с этим нужно уделять пристальное внимание размещенным в памяти object instances и обеспечивать их корректное высвобождение.
Как известно, экземпляры высвобождаются из памяти посредством механизма автоматического подсчета ссылок (Automatic Reference Counting или ARC). В iOS существуют 2 типа таких ссылок: сильные и слабые.
Сильные ссылки — это тип ссылки по умолчанию. Рассмотрим простой пример объявления объекта. Допустим, у нас есть класс Person, и мы объявляем объект следующим образом:
var person = Person()
Здесь мы создаем сильную ссылку на Person. При каждом ее создании число ссылок на объект увеличивается на 1. Что это значит? И зачем нам это нужно? Дело в том, что ARC не будет удалять объект из памяти до тех пор, пока число ссылок на него не приравняется к 0.
Иначе себя ведут слабые ссылки, которые не увеличивают общее число ссылок на объект. Вашему вниманию — очередной пример:
var person = Person()
weak var anotherPerson: Person? = person
Здесь создается слабая ссылка на тот же самый объект person, так что теперь у нас есть сильная ссылка person и слабая — anotherPerson. Это значит, что при наличии 2 ссылок на один и тот же объект общее их число по-прежнему равняется 1, поскольку одна из них — слабая. Также важно знать, что слабые ссылки являются необязательными, поскольку доступ к ним можно получить даже после их удаления из памяти.
Поиск циклов retain и утечек памяти с помощью тестов
Цикл retain возникает, когда 2 объекта имеют сильную ссылку друг на друга. В результате этого они удерживают друг друга в памяти, поскольку число ссылок на них никогда не будет равно 0. Следовательно, наша задача — нарушить этот цикл, позволив ARC освободить область памяти, занятую такими объектами.
Предположим, у нас есть два объекта. Один из них имеет тип HTTPClient и при этом состоит во втором объекте UserProfileLoader.
class UserProfileLoader {
private let client: HTTPClient
private let url: URL
init(url: URL, client: HTTPClient) {
self.url = url
self.client = client
}
enum Error: Swift.Error {
case invalidProfileData
case connectivity
}
enum Result: Equatable {
case success(UserProfile)
case failure(Error)
}
func loadProfile(completion: @escaping (Result) -> Void) {
client.get(from: url) { data in
let result = self.map(data: data)
completion(result)
}
}
func map(data: HTTPClientResult) -> Result {
return .success(UserProfile(id: "an-user-id"))
}
}
enum HTTPClientResult {
case success(Data)
case failure(Error)
}
protocol HTTPClient {
func get(from url: URL,
completion: @escaping (HTTPClientResult) -> Void)
}
class HTTPClientSpy: HTTPClient {
var completion: ((HTTPClientResult) -> Void)? = nil
func get(from url: URL,
completion: @escaping (HTTPClientResult) -> Void) {
self.completion = completion
}
}
struct UserProfile: Equatable {
var id: String
}
В данном примере UserProfileLoader содержит метод loadProfile, вызывающий метод get в созданном client типа HTTPClient. Как видно, реализация get в HTTPClientSpy сохраняет замыкание в свойстве completion, что делает его доступным для тестов.
Представленная выше реализация, несомненно, содержит цикл retain, но вот для какой цели и в каком месте так сразу и не скажешь. Попробуем разобраться. UserProfileLoader содержит сильную ссылку на объект HTTPClient в свойстве client. С другой стороны, HTTPClient также имеет сильную ссылку на UserProfileLoader. “Где же она?” — спросите вы, поскольку может показаться, что никакого UserProfileLoader в HTTPClientSpy не существует. Однако он там точно есть. Обратим внимание на метод get. В свойстве completion он хранит полученное замыкание, которое содержит сильную ссылку на объект UserProfileLoader в вызове метода map. Как видно, мы добавили сильную ссылку с помощью явного использования self.
Как правило, ошибки подобного рода встречаются во многих базах кода. Порой они совсем не очевидны, так что никто от них не застрахован. Но мы можем сократить риск их появления с помощью автоматизированных тестов. Как же? Для этого необходимо убедиться, что тестируемая система (System Under Test или SUT) и ее составляющие высвобождаются должным образом.
Начнем с написания первого теста, который задаст нам правильный вектор. В этом примере экземпляр UserProfileLoader будет нашим SUT.
func test_load_withSuccesfulClientResponseDeliversSuccesLoadingResult() {
let url = URL(string: "https://a-url.com")!
let client = HTTPClientSpy()
let sut = UserProfileLoader(url: url, client: client)
}
Мы располагаем исходными данными, и теперь нам необходимо где-то сохранить результат загрузки для создания утверждений, поскольку, как вы помните, метод loadProfile завершится замыканием типа (Result) -> Void. Сохраним этот результат в тесте.
func test_load_withSuccesfulClientResponseDeliversSuccesLoadingResult() {
let url = URL(string: "https://a-url.com")!
let client = HTTPClientSpy()
let sut = UserProfileLoader(url: url, client: client)
var capturedResults = [UserProfileLoader.Result]()
sut.loadProfile { capturedResults.append($0) }
}
В вышеуказанном тесте замыкание загрузки никогда не будет выполнено. Причина в том, что loadProfile вызывает реализацию get. В экземпляре HTTPClientSpy метод get сохраняет полученное замыкание в свойстве. Для получения результата нужно вызвать в тесте этот сохраненный блок completion.
func test_load_withSuccesfulClientResponseDeliversSuccesLoadingResult() {
let url = URL(string: "https://a-url.com")!
let client = HTTPClientSpy()
let sut = UserProfileLoader(url: url, client: client)
var capturedResults = [UserProfileLoader.Result]()
sut.loadProfile { capturedResults.append($0) }
client.completion?(.success(Data()))
}
Вот теперь мы можем сделать утверждения с помощью массива capturedResults. Метод loadProfile будет всегда завершаться с успешным результатом, содержащим экземпляр UserProfile. Именно этого нам и нужно ожидать в тесте.
class MemoryLeaksTests: XCTestCase {
func test_load_withSuccesfulCallDeliversSuccesLoadingResult() {
let url = URL(string: "https://a-url.com")!
let client = HTTPClientSpy()
let sut = UserProfileLoader(url: url, client: client)
let expectedResult: UserProfileLoader.Result =
.success(UserProfile(id: "an-user-id"))
var capturedResults = [UserProfileLoader.Result]()
sut.loadProfile { capturedResults.append($0) }
client.completion?(.success(Data()))
XCTAssertEqual(capturedResults, [expectedResult])
}
}
Тест прошел! Гарантирует ли он корректное высвобождение SUT и его составляющих компонентов? Пока нет. Добавим еще утверждений для большей уверенности в высвобождении теста из памяти.
Класс MemoryLeaksTests расширяется из класса XCTestCase, предоставляющего метод addTearDownBlock. Этот метод получает блок типа () -> Void, который будет выполняться каждый раз по завершении тестовой функции. Мы могли бы добавить блок teardown сразу после создания SUT и его составляющих.
class MemoryLeaksTests: XCTestCase {
func test_load_withSuccesfulCallDeliversSuccesLoadingResult() {
let url = URL(string: "https://a-url.com")!
let client = HTTPClientSpy()
let sut = UserProfileLoader(url: url, client: client)
addTeardownBlock { [weak sut, weak client] in
XCTAssertNil(client, "Instance has not been deallocated")
XCTAssertNil(sut, "Instance has not been deallocated")
}
let expectedResult: UserProfileLoader.Result =
.success(UserProfile(id: "an-user-id"))
var capturedResults = [UserProfileLoader.Result]()
sut.loadProfile { capturedResults.append($0) }
client.completion?(.success(Data()))
XCTAssertEqual(capturedResults, [expectedResult])
}
}
Теперь блоки на своих местах. Обратите внимание, что в них мы добавили слабые ссылки на экземпляры sut и client, чтобы обойтись без дальнейшего увеличения их числа. Теперь зеленый тест уже не зеленый, потому что утверждения блоков teardown не срабатывают.
Для устранения цикла retain стоит воздержаться от явного использования self в методе loadProfile. И у нас есть несколько вариантов:
- Применить в замыкании слабую ссылку и добавить блок
guard letдля развертывания результата. - Превратить метод
mapв статический. - Переместить
mapв статический метод класса-преобразователя.
В следующем примере мы воспользовались 2 вариантом, чтобы нарушить цикл retain и успешно пройти тест.
func loadProfile(completion: @escaping (Result) -> Void) {
client.get(from: url) { data in
completion(UserProfileLoader.map(data: data))
}
}
static func map(data: HTTPClientResult) -> Result {
return .success(UserProfile(id: "an-user-id"))
}
Теперь тест проходит, но при этом выглядит немного неряшливо, поэтому стоит провести рефакторинг и немного его почистить. Допустим, нам понадобились дополнительные тестовые функции. Тогда мы бы создали SUT и его компоненты, а также добавили бы для каждого из них блоки tear down, что свидетельствовало бы о повторении.
Для рефакторинга этого теста создадим несколько фабричных методов. Первым станет makeSUT, который вернет SUT и его компоненты.
class MemoryLeaksTests: XCTestCase {
func test_load_withSuccesfulCallDeliversSuccesLoadingResult() {
let (sut, client) =
makeSUT(url: URL(string: "https://a-url.com")!)
let expectedResult: UserProfileLoader.Result =
.success(UserProfile(id: "an-user-id"))
var capturedResults = [UserProfileLoader.Result]()
sut.loadProfile { capturedResults.append($0) }
client.completion?(.success(Data()))
XCTAssertEqual(capturedResults, [expectedResult])
}
// MARK: - Helpers
func makeSUT(url: URL,
file: StaticString = #filePath,
line: UInt = #line) -> (sut: UserProfileLoader, client: HTTPClientSpy) {
let client = HTTPClientSpy()
let sut = UserProfileLoader(url: url, client: client)
addTeardownBlock { [weak sut, weak client] in
XCTAssertNil(client,
"Instance has not been deallocated",
file: file,
line: line)
XCTAssertNil(sut,
"Instance has not been deallocated",
file: file,
line: line)
}
return (sut, client)
}
}
Вот теперь тест стал более чистым и читаемым. К тому же у нас появилось еще одно дополнительное и большое преимущество. Присмотревшись к фабричному методу makeSUT, вы увидите, что он добавляет блоки tear down. Это значит, что по завершении каждый тест, который создает SUT, используя этот метод, будет автоматически проверять корректное высвобождение данного SUT и его объектов из памяти. Если SUT и его компоненты не будут удалены из памяти, то тест покажет ошибку при вызове makeSUT, поскольку мы передаем в XCTAssertNil параметры file и line.

Данный подход позволит уверенно проводить рефакторинг и послужит гарантией правильного управления памятью.
Читайте также:
- Как проще всего выполнять запросы GraphQL в iOS
- Анимируем скучные табличные представления в iOS-приложении
- Топ-10 самых популярных библиотек Android и iOS
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Luis Piura: Find potential memory leaks with automated tests





