Процесс управления памятью может стать фактором, весьма затрудняющим работу в приложениях 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