SwiftprogramowanieProgramista iOS

Pod jakimi konkretnymi warunkami zliczania referencji przy przypisywaniu zamknięcia do właściwości instancji powstaje cykl zatrzymywania, a jak listy przechwytywania zmieniają semantykę ARC, aby rozwiązać ten problem?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Historia pytania

Zanim Swift wprowadził Automatyczne Zliczanie Referencji (ARC), programiści zarządzali pamięcią ręcznie, korzystając z wywołań retain, release i autorelease, co prowadziło do częstych wycieków lub wiszących wskaźników. ARC w Swifcie automatyzuje to w czasie kompilacji, wstawiając wywołania retain/release, ale wprowadza subtelną złożoność z zamknięciami, które są typami referencyjnymi, które przechwytują okoliczne zmienne. Stworzyło to nową klasę problemów związanych z pamięcią specyficznych dla Swifta, gdzie dwa typy referencyjne mogą tworzyć nieusuwalne zależności cykliczne, co wymaga zastosowania składni listy przechwytywania, aby zapewnić wyraźną kontrolę nad tymi semantykami przechwytywania.

Problem

Gdy instancja klasy przechowuje zamknięcie jako właściwość i to zamknięcie odwołuje się do self lub innych właściwości instancji, ARC zwiększa liczbę referencji instancji, aby utrzymać ją przy życiu na czas trwania zamknięcia. Ponieważ zamknięcie jest samo odwoływane do instancji, powstaje cykl zatrzymywania: instancja trzyma zamknięcie mocno, a zamknięcie trzyma instancję mocno. Żaden z liczników referencji nie osiąga zera, co uniemożliwia wykonanie deinit i powoduje wyciek pamięci przez całe życie aplikacji.

Rozwiązanie

Swift udostępnia listy przechwytywania — wyrażenia rozdzielane przecinkami w nawiasach kwadratowych, które poprzedzają listę parametrów zamknięcia — aby zmodyfikować domyślne zachowanie przechwytywania. Określenie [weak self] tworzy słabą referencję (opcjonalną, staje się nil po zwolnieniu), podczas gdy [unowned self] tworzy referencję nieposiadającą (zakłada istnienie, powoduje awarię po dostępie po zwolnieniu). Dla wartości, [x = x] przechwytuje bieżącą wartość zamiast referencji. To wyraźnie łamie silny cykl referencji, umożliwiając ARC zwolnienie instancji, gdy zewnętrzne referencje są usuwane.

Przykład kodu:

class DataManager { var completionHandler: ((Data) -> Void)? var data: Data = Data() func fetchData() { // Cykl zatrzymywania: self trzyma zamknięcie, zamknięcie trzyma self completionHandler = { newData in self.data = newData // Silne przechwycenie self } } func fetchDataFixed() { // Rozwiązanie: słabe przechwycenie completionHandler = { [weak self] newData in guard let self = self else { return } self.data = newData } } deinit { print("DataManager zwolniony") } }

Sytuacja z życia

W produkcyjnej aplikacji iOS zaimplementowaliśmy ProfileViewController, który polegał na klasie UserService, aby asynchronicznie pobierać dane profilu. Usługa udostępniała interfejs API przy użyciu zamknięciowych handlerów zwrotnych przechowywanych jako właściwości, aby wspierać odwoływalne żądania. Zauważyliśmy, że nawigacja z powrotem z ekranu profilu nigdy nie wywoływała deinit ViewController'a, a Instruments zgłaszało trwały obiekt wykresu pamięci utrzymujący hierarchię widoków.

Rozważaliśmy kilka podejść architektonicznych, aby rozwiązać ten wyciek.

Próbowaliśmy wyraźnie ustawić handler zwrotny na nil w viewWillDisappear. Choć technicznie łamało to cykl, gdy użytkownik nawigował z powrotem, okazało się to niewiarygodne w przypadku nagłych zakończeń lub niespodziewanych przejść stanów. Ukazało również wyciek, jeśli zamknięcie nigdy nie było wywoływane, a kontroler widoku został zwolniony przez system pod naciskiem pamięci przed zdarzeniem zniknięcia. To podejście wymagało nadmiernego programowania obronnego i naruszało zasadę pojedynczej odpowiedzialności, zmuszając kontroler widoku do zarządzania wewnętrznym stanem usługi.

Oceniśmy użycie [unowned self] w zamknięciu, aby uniknąć kosztów związanych z opcjonalnym rozpakowaniem. Oferowało to czystość składniową i korzyści z abstrakcji bez kosztów. Jednak podczas testowania odkryliśmy warunki wyścigu, gdy szybka nawigacja mogła zwolnić ViewController podczas gdy żądanie sieciowe wciąż trwało, co prowadziło do awarii, gdy callback próbował uzyskać dostęp do zwolnionej instancji. Ryzyko nieokreślonego zachowania w produkcji przewyższało korzyści wydajnościowe.

Zastosowaliśmy podejście słabego przechwytywania razem z kontrolą guard let self = self else { return } w punkcie wejścia zamknięcia. To bezpiecznie obsługiwało wszystkie scenariusze cyklu życia: jeśli kontroler widoku został zwolniony przed wywołaniem callbacka, słaba referencja stała się nil, strzeżenie nie powiodło się milcząco, a ARC posprzątało zamknięcie później. Chociaż wymagało to nieco więcej kodu zapasowego i wprowadzało niewielki koszt obsługi opcjonalnej, gwarantowało bezpieczeństwo pamięci i działanie bezawaryjne.

Przyjęliśmy podejście słabego przechwytywania powszechnie w całej bazie kodu. Po refaktoryzacji integracji UserService, aby używała [weak self], debugowanie wykresów pamięci potwierdziło, że instancje ProfileViewController zwalniają się natychmiast po zamknięciu. Debugger wykresu pamięci Xcode nie pokazał już żadnych pozostałych silnych referencji z zamknięcia, a detekcja wycieków w Instruments zgłosiła zerowe wycieki w tej funkcji. Ten wzorzec stał się naszym standardem dla wszystkich zamknięciowych asynchronicznych interfejsów API.

Co często umyka kandydatom

Jak różni się przechwytywanie instancji struktury w zamknięciu od przechwytywania instancji klasy i dlaczego struktury nie mogą tworzyć cykli zatrzymywania?

Wielu kandydatów błędnie zakłada, że przechwytywanie self w zamknięciu zawsze wiąże się z ryzykiem cykli zatrzymywania niezależnie od kontekstu. Struktury są typami wartości w Swifcie, co oznacza, że są kopiowane, a nie referencjonowane. Gdy struktura jest przechwytywana przez zamknięcie, ARC kopiuje wartość struktury do listy przechwytywania zamknięcia (lub przechwytuje referencję do niezmiennej kopii w zależności od optymalizacji), ale kluczowe jest to, że struktura nie ma liczby referencji. Ponieważ zamknięcie przechowuje wartość, a nie wskaźnik do obiektu przydzielonego na stercie, nie ma możliwości okrągłej referencji między zamknięciem a oryginalną instancją struktury.

Niebezpieczeństwo istnieje wyłącznie wtedy, gdy self odnosi się do klasy (typ referencyjny), gdzie zamknięcie przechowuje wskaźnik do obiektu na stercie, zwiększając jego liczbę referencji. Zrozumienie tej różnicy jest kluczowe przy podejmowaniu decyzji o zastosowaniu modyfikatorów listy przechwytywania podczas pracy z widokami struktury SwiftUI w porównaniu do kontrolerów widoku UIKit.

Jaka jest precyzyjna różnica między [weak self] a [unowned self] w odniesieniu do założeń dotyczących czasu życia obiektu i kiedy [unowned self] powoduje awarię?

Kandydaci często traktują je zamiennie. [weak self] przekształca przechwycenie na opcjonalny WeakReference, który ARC automatycznie ustawia na nil, gdy obiekt ulega zwolnieniu. Uzyskanie do niego dostępu wymaga opcjonalnego wiązania i jest bezpieczne, nawet jeśli obiekt umiera. [unowned self] tworzy referencję nieposiadającą, która zakłada, że obiekt będzie istniał przez cały czas życia zamknięcia; zachowuje się jak opcjonalnie automatycznie rozpakowana, która nigdy nie jest ustawiona na nil.

Jeśli zamknięcie przetrwa obiekt (np. przechowywany handler zwrotny wywołany po usunięciu kontrolera widoku), uzyskanie dostępu do self dereferencjonuje wiszący wskaźnik, powodując awarię EXC_BAD_ACCESS. Używaj [unowned self] tylko wtedy, gdy zamknięcie i obiekt mają identyczne czasy życia, takie jak zamknięcia nie uciekające lub konkretne wzorce delegatów, w których zamknięcie nie może przetrwać właściciela.

Jak listy przechwytywania współdziałają z zmiennymi zadeklarowanymi poza zakresem zamknięcia i czy [x] tworzy kopię, czy referencję dla typów wartości?

Powszechnym błędnym przekonaniem jest to, że listy przechwytywania wpływają wyłącznie na self. Kiedy piszesz { [x] in ... }, wyraźnie przechwytujesz bieżącą wartość x w momencie tworzenia zamknięcia, skutkując efektywnym utworzeniem kopii cienia, która jest niezmienna w obrębie zamknięcia. Bez listy przechwytywania zamknięcie przechwytuje referencję do oryginalnej zmiennej, umożliwiając jej widzenie modyfikacji dokonanych po utworzeniu zamknięcia i potencjalnie uczestniczenie w logice cyklicznej, jeśli x jest typem referencyjnym.

Dla typów wartości, takich jak Int lub String, [x] przechwytuje kopię, co uniemożliwia zamknięciu obserwowanie zewnętrznych zmian w x i zapewnia, że zachowanie zamknięcia jest deterministyczne na podstawie stanu w momencie przechwytywania.