Недавно я столкнулся с забавной ошибкой, связанной с глубокими ссылками.
Иногда при нажатии на push-уведомление некоторые пользователи сообщали, что целевой экран появляется дважды — приложение открывалось, переходило на нужный экран, но переход между экранами происходил дважды.
Я начал расследование, не подозревая, насколько глубокой окажется эта кроличья нора.
Как работают глубокие ссылки
Прежде чем приступать к отладке, необходимо убедиться, что мы понимаем систему, с которой работаем, поэтому давайте разберемся, что такое глубокая ссылка.
Мое приложение имело довольно распространенную архитектуру на основе координаторов: AppCoordinator на верхнем уровне, с дочерними координаторами для каждой вкладки. Они управляли навигацией по всему приложению. Если вы не знакомы с этой архитектурой, обратитесь к статье SwiftUI Apps at Scale за более подробной информацией.
Глубокие ссылки позволяют пользователям напрямую переходить к определенному экрану в вашем приложении с помощью гиперссылки или push-уведомления. Они являются важным инструментом для вовлечения пользователей в приложение.
Мы создали глубокие ссылки с помощью DeepLinkHandler
на верхнем уровне, передавая его интерфейс в каждый координатор приложения.
public protocol DeepLinkHandler { func open(url: URL) func publisher(for link: DeepLink) -> AnyPublisher<Void, Never> }
Наш верхнеуровневый SceneDelegate
реагировал на ссылки через обратный вызов делегата scene(openURLContexts:)
, передавая URL нашему обработчику глубоких ссылок.
Внутри обработчик глубоких ссылок использовал регулярное выражение для преобразования URL в кейс перечисления DeepLink, отправляя сигнал соответствующему издателю Combine.
Дочерние координаторы были настроены на прослушивание определенных издателей и запускали навигацию, когда получали сигнал от DeepLinkHandler
:
// MyDataCoordinator.swift func listenToDeepLinks() { deepLinkHandler .publisher(for: .myDataDeepLink) .sink { [weak self] in self?.navigate(to: .myDataScreen) } .store(in: &cancellables) }
Это все работало довольно хорошо до сих пор.
Поиск повторения
После того как мы поняли предполагаемое поведение системы, наиболее важным фактором в исправлении ошибок становится воспроизведение — шаги, необходимые для надежного воспроизведения проблемы.
Не буду утомлять вас своим (позорно долгим) расследованием, но в итоге я достиг золотого стандарта воспроизведения — шагов, позволяющих воспроизвести ошибку в 100% случаев.
Когда я выходил (logged out) и снова входил в приложение, то глубокие ссылки всегда вызывали эту двойную навигационную анимацию от пушей. Если я выходил из приложения и снова перезапускал его, начиная новый сеанс работы с приложением, то глубокие ссылки работали как обычно.
Эта критическая подсказка показала мне, где искать дальше.
Множество print
Признаюсь, я бумер, когда дело доходит до отладки.
Xcode предоставляет нам множество навороченных инструментов отладки, от точек останова в исполняемом коде до стека вызовов в отладчике LLDB.
Но мне нравятся старые добрые операторы печати. Нравится когда их много.
Давайте разбросаем их по всему стеку вызовов. Следуя нашим шагам по исправлению ошибок — выходу из системы и возвращению в нее — мы можем теперь проанализировать вывод консоли при нажатии на глубокую ссылку.
// SceneDelegate.swift func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { guard let urlContext = URLContexts.first else { return } let url = urlContext.url print(url) deepLinkHandler.open(url: url) }
Print в SceneDelegate при открытии глубокой ссылки ведет себя как и ожидалось, срабатывая только один раз. Таким образом, мы знаем, что ничего странного не происходит с тем, как система работает с URL.
// DeepLinkHandler.swift public func open(url: URL) { guard let link = DeepLink.link(from: url) else { return } print(link) _publisher(for: link).send(()) }
Наш обработчик глубоких ссылок, похоже, тоже ведет себя нормально: печатает ссылку один раз и отправляет сигнал издателю Combine.
// MyDataCoordinator.swift func listenToDeepLinks() { deepLinkHandler .publisher(for: .myDataDeepLink) .sink { [weak self] in print(self) self?.navigate(to: .myDataScreen) } .store(in: &cancellables) }
Координатор, который обрабатывает глубокую ссылку, ведет себя странно.
Он печатает дважды.
Это может быть нашим «дымящимся пистолетом» — главной уликой. Этот слушатель глубокой ссылки срабатывает дважды, дважды вызывая метод navigate(to:)
, что приводит к ошибке двойной навигации, о которой сообщают наши пользователи!
Утечка памяти
У меня есть несколько отточенных инстинктов, которые я приобрел за свою блестящую карьеру, занимаясь написанием нелепого кода. Такие инстинкты, которые иногда заставляют меня выглядеть как человек дождя.
- Если кто-то видит необъяснимую ошибку 403 HTTP, это CORS.
- Если не работает table view, значит вы забыли установить делегат.
- А если что-то происходит дважды, это утечка памяти.
Я модернизировал свой оператор печати, чтобы записать в логи адрес кучи памяти экземпляра координатора, чтобы получить больше информации.
print(Unmanaged.passUnretained(self).toOpaque())
Снова выполняем все шаги, переходим по глубокой ссылке и наблюдаем, что она срабатывает дважды. Моя догадка оказалась верной:
Адреса кучи памяти, выведенные отладчиком, были следующими:
0x00006000000108d0
0x000060000002b7b0
Это означает, что сама глубокая ссылка была обработана один раз, но несколькими экземплярами координатора. Именно поэтому мы наблюдали двойную навигацию.
Мы можем проверить это, заглянув в навигатор Xcode Debug, снова воспроизведя ошибку и выбрав «View Memory Graph Hierarchy». Здесь мы ищем MyDataCoordinator и видим оба экземпляра.
Что такое утечка памяти?
Прежде чем продолжить, давайте убедимся, что мы понимаем друг друга.
Все объекты, используемые в работающей программе, хранятся в оперативной памяти компьютера (да, я знаю, что это небольшое упрощение).
Чтобы не занимать все ресурсы компьютерной системы, языки программирования разработаны таким образом, чтобы «отпускать» память, которая больше не нужна программе. В зависимости от того, как устроен язык, это управление памятью может быть:
- Ручное управление памятью, как в C/C++, где разработчики используют malloc и release, чтобы самостоятельно выделять и освобождать память.
- В таких языках, как Kotlin или Java, есть «сборщик мусора», который периодически обходит системную память и удаляет неиспользуемые объекты.
- Rust использует безопасную для компилятора систему владения, чтобы определить, когда нужно освободить память для объекта.
В Swift память обрабатывается с помощью подсчета ссылок:
- Сильные ссылки — то есть указатель на адрес класса в куче памяти — увеличивают refCount на 1.
- Слабые ссылки указывают на память, но не увеличивают refCount.
- Когда refCount объекта HeapObject достигает нуля, он немедленно деаллоцируется.
В Swift утечка памяти может произойти, когда разработчики допускают ошибку в управлении этими ссылками. Они могут случайно установить сильную ссылку — возможно, в замыкании, которое захватывает self — которая никогда не деаллоцируется, сохраняя объект, на который ссылаются, в памяти навсегда.
Если у протекающего объекта есть собственные сильные ссылки, он также будет поддерживать жизнь всех объектов, на которые он сильно ссылается, каскадным образом.
Для дальнейшего углубления в то, как работает память под капотом в Swift, ознакомьтесь с COW2LLVM: The isKnownUniquelyReferenced Deep-Dive или The Case Against unowned self.
Большая, большая утечка памяти
Теперь у нас есть ясность и довольно хорошее понимание поведения этой системы:
- Когда вы делаете логаут и снова входите, при переходе от экранов регистрации и аутентификации к основному приложению создаются новые экземпляры координаторов главной вкладки.
- В нашем приложении была утечка памяти, из-за которой наш координатор
MyDataCoordinator
оставался живым на протяжении всей сессии приложения. - Поэтому слушатель глубоких ссылок из старого координатора был все еще активен, что приводило к тому, что навигация по глубоким ссылкам срабатывала дважды.
Это наводило на мысль, что масштаб проблемы гораздо больше, чем я ожидал от относительно незначительной на первый взгляд проблемы с навигацией.
Координатор MyDataCoordinator
, будучи координатором верхнего уровня на вкладке, владел несколькими дочерними координаторами, которые содержали подфункции.
Таким образом, я сохранял иерархию навигации для всех этих функций в памяти каждый раз, когда пользователь выходил из системы и снова входил. Сюда также входили координаторы, фабричные классы, сервисы и многие модели представлений @ObservedObject
, кэшируемые в памяти.
Главная часть
Теперь мы понимаем суть проблемы, нам легко проверить, что мы ее решили.
Вместо того чтобы проходить через всю процедуру запуска глубокой ссылки, нам просто нужно убедиться, что наш MyDataCoordinator
деаллоцируется, когда мы выходим из системы.
При отладке утечек памяти deinit
— ваш лучший друг. Я добавил их ко всем трем координаторам главной вкладки.
deinit { print("deinit (self)") }
Это вызывалось для 2 координаторов вкладок при выходе из системы, но не для MyDataCoordinator
(который содержал проблемную глубокую ссылку). Это означало, что все сущности и дочерние координаторы, на которые он сильно ссылался, также оставались живы.
Теперь мы знаем главное: когда deinit
сработает при логауте из системы, мы победим.
Найти и уничтожить
Возвращаясь к отладчику графа памяти, мы ищем «дымящийся пистолет»: сильную ссылку, которой там быть не должно.
Мы можем просмотреть граф памяти для каждого экземпляра, и при сравнении становится ясно, кто из них «хороший» — эта версия MyDataCoordinator
прекрасно размещается в графе памяти с нашей основной навигационной инфраструктурой, такой как SceneDelegate
и AppCoordinator
.
Однако, похоже, здесь также есть циклическая ссылка из модели представления, чего мы не ожидаем.
Утечка довольно очевидна: единственная ссылка поддерживает жизнь нашего координатора, наряду с ульем низкоуровневых сущностей памяти.
Поскольку сильная ссылка не имеет имени, это говорит о наличии анонимной функции (т. е. замыкания), а не именованного класса с сильной ссылкой.
Желая показаться очень умным, я открыл Instruments для поиска утечек и аллокаций в моем координаторе. К сожалению, вывод не дал нам никакой новой информации.
Мы зашли так далеко, как только могли, используя инструментарий Xcode. Нам нужна новая тактика: методичное чтение нашей кодовой базы в поисках нарушающих ее замыканий.
Разделяй и властвуй
Когда источник ошибки не очевиден, хорошим подходом будет минимизация площади поверхности ошибки.
Во времени это можно сделать с помощью такой команды, как git bisect
. В пространстве это можно сделать с помощью более тупого инструмента: закомментировать много кода.
Для начала я могу закомментировать как можно больше кода, сохранив возможность создания приложения, включая все дочерние координаторы, логику навигации и приватные методы. Затем я могу проверить, удаляется ли координатор MyDataCoordinator
при выходе из приложения.
Так и есть!
Я повторял подход систематически, изолируя каждый метод и дочерний координатор по очереди, повторяя шаги по исправлению ошибок для каждой сборки.
Пока не обнаружил виновника.
Виновник
Я выделил виновный блок кода.
12 строк, от которых зависело, будет ли утекать весь наш навигационный стек или наше приложение будет вести себя как положено.
Этот блок также был частью нашей логики глубокой перелинковки, однако он был более сложным, чем простая навигация по экрану.
Для предотвращения мошенничества пользователи могли получить доступ к этой функции только в том случае, если их аккаунт был достаточно старым. Мы отправляли ссылки через нашу CRM, как только они проходили квалификацию. Поэтому, прежде чем выполнить навигацию, мы выполняем асинхронную проверку с помощью нашего API на предмет соответствия учетной записи пользователя требованиям. Если это удается, мы выполняем навигацию.
Вот код — посмотрите, сможете ли вы обнаружить проблему!
private func setupSpecialOffersDeepLink() { deepLinkHandler .publisher(for: .specialOffersDeepLink) .sink(receiveValue: { Task { [weak self] in if (await self?.offersService.userIsEligible() == true) { self?.navigate(to: .specialOffersScreen) } } }) .store(in: &cancellables) }
Честно говоря, это в какой-то степени очевидно, в какой-то — нет. Вы, возможно, будете пинать себя, когда я все объясню.
Устранение утечки
Внешне код выглядит так, будто он делает все возможное, чтобы избежать цикла удержания (retain cycle). Он слабо перехватывает self перед использованием. Он хранит издателя комбайна в cancellable наборе.
Но есть одна загвоздка.
Она становится понятной только после того, как вы действительно поймете, что делает замыкание в Swift.
Когда мы слабо захватываем self с помощью Task { [weak self] in
, мы действительно создаем слабую ссылку на self.
Однако сам захват происходит уже в замыкании, которое передается в Combine .sink(receiveValue: {
.
Замыкание — это блок кода, хранящийся в куче, отдельно от класса, в котором он был создан. Поэтому self
, используемое в [weak self]
в Task, является неявной сильной ссылкой на self
, а не на исходную сущность.
Нам нужно поместить [weak self]
в замыкание .sink
вместо инициализатора Task:
private func setupSpecialOffersDeepLink() { deepLinkHandler .publisher(for: .specialOffersDeepLink) .sink(receiveValue: { [weak self] in Task { if (await self?.offersService.userIsEligible() == true) { self?.navigate(to: .specialOffersScreen) } } }) .store(in: &cancellables) }
Обновив код и выполнив все шаги по воспроизведению, мы получили вызов deinit
при выходе из системы! Утечка памяти устранена.
Взглянув на отладчик графа памяти, мы видим единственный экземпляр MyDataCoordinator
, как мы и ожидали, без каких-либо нежелательных ссылок.
Заключение
Итак, у меня произошла утечка памяти. Большая проблема.
Утечка, вызванная неправильным использованием [weak self]
. 🤷♂️.
Но этот баг показался мне достойным того, чтобы о нем написать. Симптом, о котором сообщали наши пользователи, заключался в незначительной на первый взгляд «странной анимации навигации». Нажатие на глубокую ссылку иногда приводило к тому, что анимация нажатия происходила дважды.
Я не мог предположить, что при повторном входе пользователя в систему возникнет такая серьезная проблема, как «половина функций нашего приложения дублируется в памяти». И что у нее есть такое простое решение, как перемещение захвата [weak self]
на одну строку вверх.
Проблема вложенного замыкания заключалась в том, что внешне код был вполне приемлемым, с очень тонким retain cycle. В проверке кода и слиянии ошибка оказалась совсем неочевидной.
В этой истории есть мораль. Когда вы в последний раз проверяли отладчик графа памяти Xcode в своем приложении? Возможно, у вас в бэклоге сидит неприметная ошибка P4, а под поверхностью скрывается драматическая утечка памяти.