Jak wykrywać wycieki pamięci w aplikacjach iOS z wykorzystaniem narzędzia Xcode Memory Graph?
Wyobraź sobie następującą sytuację: przeglądasz na swoim iPhone sklep App Store w poszukiwaniu oprogramowania do edycji zdjęć. W końcu trafiasz na nową aplikację, która na pierwszy rzut oka robi dokładnie to, czego od niej oczekujesz i w dodatku wygląda świetnie! Instalujesz ją, zaczynasz bawić się nakładając filtry i efekty na kolekcję zdjęć, ale czujesz, że coś jest nie tak…
Aplikacja działa coraz wolniej i wolniej, aż nagle Twoim oczom ukazuje się ekran główny iOS i znajomy widok Twojej tapety. Niestety okazuje się, że jest to zachowanie całkowicie powtarzalne, które znacząco utrudnia korzystanie z aplikacji. Zastanów się jakie mogą być reakcje użytkowników w takiej sytuacji? Najprawdopodobniej skończy się natychmiastowym usunięciem aplikacji, być może lawiną negatywnych ocen w App Store.
Dla projektu, który dopiero próbuje się wybić, opisany powyżej scenariusz może oznaczać koniec kariery aplikacji lub początek długiej drogi do odzyskania zaufania użytkowników. Właśnie dlatego dziś skupimy się na omówieniu problemu wycieków pamięci w aplikacjach iOS. Nauczymy się również jak wykorzystywać narzędzie Memory Graph Debugger, które może pomóc wykryć tego typu błędy.
Spis treści
Czym są wycieki pamięci oraz „retain cycle”?
Wyciek pamięci oznacza sytuację, w której raz zaalokowana jednostka pamięci nigdy nie zostaje zwolniona. Wykorzystywany w Swift mechanizm ARC (ang. Automatic Reference Counting – Automatyczne Zliczanie Referencji) samoistnie zwalnia pamięć, jeżeli na dany obiekt nie wskazuje odnośnik od żadnego innego obiektu. Działanie mechanizmu ARC może jednak zostać zakłócone, jeżeli dwa obiekty mają wobec siebie silną referencję. W wyniku tego, ich licznik referencji nigdy nie spadnie do zera, wobec czego obiekty te będą zajmowały pamięć do końca cyklu życia aplikacji. Sytuacja taka określana jest jako „retain cycle” i z braku dobrego polskiego odpowiednika, tak ją będę dla uproszczenia określał.
Jakie są skutki wycieków pamięci?
Wycieki pamięci stopniowo zwiększają ilość zajętej pamięci urządzenia, na którym działa aplikacja. Jeśli osiągną one znaczne poziomy, system iOS wyśle do aplikacji zbliżającej się do limitu dostępnej pamięci ostrzeżenie, które możemy obsłużyć poprzez funkcje applicationDidReceiveMemoryWarning(_:)
, didReceiveMemoryWarning()
oraz nasłuchiwanie zdarzeń didReceiveMemoryWarningNotification
publikowanych przez NotificationCenter
. Jeżeli nie uda nam się w ramach obsługi tych ostrzeżeń zwolnić wystarczającej ilości pamięci, może się to skończyć wymuszonym przez system zamknięciem aplikacji. Dotyczy to głównie znacznych wycieków pamięci, lub aplikacji które mogą akumulować wycieki przez długi czas.
Niestety nie oznacza to, że mniejsze wycieki pamięci nie sprawią problemów programistom. Zatrzymywane w pamięci obiekty mogą doprowadzić do błędnego działania logiki aplikacji, co może być wyjątkowo trudne do zdiagnozowania i naprawienia.
Przykładowa aplikacja
Dostępna jest na moim githubie. Projekt został utworzony z użyciem Xcode 13.2.1. Zanim przejdziesz do testowania aplikacji, zwróć uwagę na ustawienia dostępne w oknie target’u LeakyLeaks -> Edit Scheme -> Diagnostics (patrz obrazki). Upewnij się, że zaznaczona jest opcja „Malloc Stack Logging”, umożliwi ona prezentację większej ilości informacji w debuggerze. Po uruchomieniu aplikacji, zobaczysz na ekranie głównym symulatora widok z trzema przyciskami. Poklikaj trochę każdy z nich, a następnie przejdź do kolejnego akapitu, aby zapoznać się z działaniem narzędzia Xcode Memory Graph Debugger.
Xcode Memory Graph Debugger
Aby uruchomić Memory Graph, uruchom aplikację na symulatorze, a następnie w dolnej części okna kliknij przycisk z trzema kółkami tworzącymi graf (patrz obrazek).
Po chwili ujrzysz nowe okno z wizualizacją stanu pamięci oraz zależnościami pomiędzy obiektami przechowywanymi w pamięci. Widok podzielony jest na kilka części, a każda z nich zawiera inny zestaw cennych informacji, omówimy je zatem po kolei, zaczynając od listy obiektów znajdującej się w lewej części okna Xcode:
Na załączonym zrzucie ekranu widzimy fragment listy obiektów zaalokowanych w pamięci. Obok nazwy klasy, w nawiasie widoczna jest ilość kopii obiektu aktualnie przechowywanych w pamięci. Po rozwinięciu listy obiektów danej klasy widzimy poszczególne kopie, wraz z adresami pamięci pod którymi są przechowywane. Zwróć uwagę na fioletowe ikony z wykrzyknikami widoczne przy niektórych pozycjach. W ten sposób Xcode oznacza obiekty, w których automatycznie wykryto wyciek pamięci.
Dodatkowo na dolnym pasku umieszczona jest ikona, umożliwiająca wyświetlenie na liście obiektów wyłącznie instancji powodujących wycieki pamięci (zaznaczony czerwoną ramką). Możesz też zauważyć, że Xcode nie zawsze radzi sobie z automatycznym wykrywaniem problemów, nawet w banalnych przypadkach stworzonych wyłącznie na potrzeby demonstracji.
Centralną część ekranu zajmuje graf zależności pomiędzy obiektami przechowywanymi w pamięci. Przeanalizujmy najpierw prosty przykład, pokazujący jak na dłoni sytuację, w której dwa obiekty trzymają na sobie wzajemnie silną referencję, uniemożliwiając usunięcie ich z pamięci przez ARC:
Na grafie widzimy dwa sześciany (reprezentujące obiekty) połączone strzałkami. Jest to uproszczona sytuacja, z którą raczej rzadko spotkamy się w praktyce. Jak widzimy, obiekt klasy ClassA ma silną referencję do obiektu klasy ClassB, który z kolei ma silną referencję do obiektu klasy ClassA. W bardziej skomplikowanych przypadkach, możemy ujrzeć grafy o większej ilości węzłów oraz połączeń między nimi. Często jednak Xcode nie jest w stanie zaprezentować nam nawet tak prostego problemu w tak czytelny sposób i dla tego samego kodu możemy na przykład zobaczyć następujący diagram:
Jak widać, na tym obrazku problem nie jest tak oczywisty, choć graf obrazuje tę samą zależność między dwoma obiektami, co w poprzednim przypadku. Xcode nie zauważył jednak problemu i wskazał tu jedynie silną referencję od klasy ClassB trzymaną na instancji klasy ClassA. Zwróć uwagę, że na grafie występują dwa rodzaje strzałek: jaśniejsza i lekko pogrubiona symbolizuje silną referencję. Ciemniejsza strzałka oznacza referencję typu weak lub unowned (w zakładce z prawej strony określana jest jako „Type: conservative”).
Wciąż nie jest to jednak najgorszy przypadek, spójrzmy zatem co wydarzy się w przypadku przykładu drugiego, reprezentującego błąd w logice aplikacji. W tym przypadku UINavigationController
pokazuje naprzemiennie BuggyViewController
oraz MainViewController
, które są kolejno dodawane do stosu ekranów i nigdy z niego nie usuwane. Xcode Memory Graph wytworzył następujące dzieło:
Jak analizować wycieki pamięci przy użyciu Memory Graph
Wiemy już jak uruchomić i obsługiwać narzędzie Memory Graph. Jak zobaczyliśmy na powyższych przykładach znalezienie źródła wycieku pamięci nie musi być jednak prostym zadaniem. Opiszę więc przykładowe podejście, którego można użyć do zlokalizowania problemu:
1. Uruchom aplikację i otwórz „Debug Navigator” w lewej części okna Xcode (ikonka „puszki ze sprayem na robaki”). Kliknij pozycję „Memory” i obserwuj wskazania ilości zajętej pamięci.
2. Wybierz pewną funkcjonalność aplikacji, którą chcesz sprawdzić i wykonaj ją kilkukrotnie (np. jak w przykładowej aplikacji pokaż ekran modalnie a następnie go zamknij). Czy ilość zajętej pamięci ciągle narasta i nie spada do początkowego poziomu? To może oznaczać, że masz do czynienia z wyciekiem pamięci.
3. Uruchom Memory Graph. Zwróć uwagę na listę obiektów w lewej części okna. Sprawdź czy na liście są obiekty, które występują w kilku kopiach, choć można byłoby oczekiwać, że zawsze będzie istniała tylko jedna instancja obiektu. Jeżeli znajdziesz obiekt, który występuje w tylu kopiach, ile wykonasz powtórzeń testowej akcji, to udało Ci się zlokalizować klasę powodującą wyciek pamięci.
4. Wybierz z listy instancję obiektu powodującą wyciek pamięci i przeanalizuj diagram zależności między obiektami, zwracając szczególną uwagę na ścieżki składające się z silnych referencji (jaśniejsze strzałki). Może być konieczne rozwinięcie większej ilości szczegółów na grafie przez kliknięcie ikony w lewym górnym rogu elementów znajdujących się po lewej stronie grafu. W pierwszym przykładzie, analizując diagram prezentowanego ModalViewController
możemy znaleźć obiekty _NSMallocBlock_
reprezentujące closure, z silną referencją oznaczoną jako [capture].
Kliknij na jeden z tych obiektów i spójrz na szczegóły wyświetlane w prawej części okna Xcode. Znajdziesz tam adres obiektu w pamięci. Warto zanotować adresy obiektów będących początkiem ciągu silnych referencji. Zwróć też uwagę na informacje w sekcji „Backtrace” – informują Cię one o ścieżce w kodzie, która doprowadziła do utworzenia danego obiektu, co może być szczególnie pomocne w analizie. Dane te nie będą dostępne bez zaznaczenia „Malloc Stack Logging” w Build Scheme (patrz akapit 3.).
5. Jeżeli analiza diagramu nie dała oczywistego rozwiązania problemu, warto spojrzeć na tym etapie w kod aplikacji. Wyszukaj jakie inne klasy mogą być odpowiedzialne za tworzenie instancji klasy powodującej wycieki lub komunikują się z nią, zwłaszcza za pomocą funkcji wykorzystujących closure.
6. Po znalezieniu klasy, którą podejrzewamy o tworzenie cyklu silnych referencji z klasą powodującą wyciek pamięci warto wrócić do grafu pamięci, oraz sprawdzić czy na diagramie tej klasy występują obiekty o takich samych adresach, które zanotowaliśmy w punkcie 4. Oznacza to, że zlokalizowaliśmy źródło problemu.
Jak naprawiać wycieki pamięci
Podstawą rozwiązania problemu wycieku pamięci jest rozbicie cyklu wzajemnych silnych referencji. Musimy więc wymusić sytuację, w której jedna ze stron używa referencji typu weak lub unowned. W dużym skrócie- dokumentacja języka Swift sugeruje, aby używać referencji typu weak, kiedy wiemy, że dana klasa powinna mieć krótszy czas życia. Referencji typu unowned powinniśmy użyć, kiedy wiemy, że instancja danej klasy będzie miała taki sam lub dłuższy cykl życia.
Różnice między typami referencji stanowią materiał na kolejny artykuł, warto jednak wspomnieć, aby zwrócić uwagę na poprawność działania logiki aplikacji po ustawieniu jednej z referencji na weak lub unowned, w szczególności czy nie występują crashe aplikacji. W przykładowym projekcie, w plikach MainViewController.swift
oraz BuggyViewController.swift
zawarte są zakomentowane fragmenty kodu zawierające rozwiązania trzech różnych problemów z wyciekami pamięci. Przeanalizuj je, a następnie sprawdź czy Twój projekt nie ma problemów z pamięcią!
Zdjęcie główne artykułu pochodzi z unsplash.com.
Źródła: developer.apple.com, developer.apple.com, docs.swift.org.