Сбой управления памятью для UIViewController, встроенного через addChild

#ios #swift

#iOS #swift

Вопрос:

Я вижу очень странное поведение, когда UIKit пытается сохранить UIViewController, который был встроен addChild после того, как он уже был выпущен.

Чтобы воспроизвести проблему, я выделил проблему в примере проекта CardStackExample. У него есть класс StackViewController , который управляет дочерними контроллерами представления. Когда вы нажимаете Add Card кнопку, это добавляет UIViewController в качестве дочернего элемента. При достижении предела в 5 удаляются самые старые контроллеры, оставляя только самые новые 5. Это реализовано в StackViewController#enforceLimit . При нажатии кнопки 7 раз с включенными зомби вы можете увидеть сбой во внутренней процедуре UIKit _runAfterCACommitDeferredBlocks :

 2019-04-22 14:31:20.345319 0200 CardStackExample[84434:1400814] *** -[UIViewController retain]:
message sent to deallocated instance 0x7fbb99c1bc10
  
 Thread 1 Queue : com.apple.main-thread (serial)
#0  0x000000010cb7e378 in ___forwarding___ ()
#1  0x000000010cb80238 in __forwarding_prep_0___ ()
#2  0x000000010cbf867e in  [__NSArrayI __new::::] ()
#3  0x000000010cb677b0 in -[NSArray initWithArray:range:copyItems:] ()
#4  0x00000001107e6d5b in _runAfterCACommitDeferredBlocks ()
#5  0x00000001107d6199 in _cleanUpAfterCAFlushAndRunDeferredBlocks ()
#6  0x000000011080332b in _afterCACommitHandler ()
#7  0x000000010cae00f7 in __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ ()
#8  0x000000010cada5be in __CFRunLoopDoObservers ()
#9  0x000000010cadac31 in __CFRunLoopRun ()
#10 0x000000010cada302 in CFRunLoopRunSpecific ()
#11 0x0000000113dce2fe in GSEventRunModal ()
#12 0x00000001107dbba2 in UIApplicationMain ()
#13 0x000000010a0925bb in main at /Users/ralf/tmp/CardStackExample/CardStackExample/App/AppDelegate.swift:4
#14 0x000000010df81541 in start ()
  

Что очень странно, потому что в реализации не используется абсолютно ничего подозрительного, что могло бы привести к ошибке управления памятью, например, к неизвестным ссылкам. Особенно enforceLimit метод, который, по-видимому, вызывает это, довольно прост:

     func enforceLimit() {
        if children.count > self.cardLimit {

            let oldestControllers = self.children.dropLast(self.cardLimit)

            for controller in oldestControllers {
                debugPrint("removing controller", controller)
                controller.willMove(toParent: nil)
                controller.view.removeFromSuperview()
                controller.removeFromParent()
            }

        }
    }
  

Я совершенно сбит с толку этой проблемой. Это похоже на внутреннюю ошибку в UIKit или stdlib или в ARC, где UIViewController выпущен слишком рано. Но поскольку код довольно прост, я сомневаюсь в этом… Какие-либо подсказки? Я пропустил что-то очевидное здесь?

Я немного поиграл с этим примером, чтобы попытаться отследить основную причину. Я нашел следующие подсказки:

  • он работает на iOS 10, сбой на iOS 11/12
  • Это не сбой, когда я реализую enforceLimit метод по-другому:
     func enforceLimit() {
        while children.count > self.cardLimit {
            let controller = children[0]
            controller.willMove(toParent: nil)
            controller.view.removeFromSuperview()
            controller.removeFromParent()
        }
    }
  
  • Он начинает сбой только при значении cardLimit, равном 5, для cardLimit 3 или 4 он работает нормально.

Комментарии:

1. Происходит с Xcode 10.2 и 10.2.1.

Ответ №1:

Хммм …. не уверен, что это дуговая «ошибка» или «попался».

По — видимому , это связано со ссылкой на контроллер представления , хранящейся внутри oldestControllers .

Если я использую ваш второй метод, никаких проблем:

 func enforceLimit() {

    if self.children.count > self.cardLimit {

        while children.count > self.cardLimit {
            let controller = children[0]
            controller.willMove(toParent: nil)
            controller.view.removeFromSuperview()
            controller.removeFromParent()
        }

    }

}
  

Однако, если я просто добавлю вашу oldestControllers строку, ничего с ней не делая и не ссылаясь на нее каким-либо другим способом:

 func enforceLimit() {

    if self.children.count > self.cardLimit {

        let oldestControllers = self.children.dropLast(self.cardLimit)

        while children.count > self.cardLimit {
            let controller = children[0]
            controller.willMove(toParent: nil)
            controller.view.removeFromSuperview()
            controller.removeFromParent()
        }

    }

}
  

Я снова получаю исходный сбой.

Похоже, что «Метод 2» выполнит эту работу … если вы не хотите отправить отчет в Apple, чтобы выяснить, является ли это ошибкой.


Редактировать:

После еще немного чтения / тестирования…

 let oldestControllers = self.children.dropLast(self.cardLimit)
  

возвращает ArraySlice . Важно не то, что это не копия элементов массива:

Важно

Долговременное хранение экземпляров ArraySlice не рекомендуется. Фрагмент содержит ссылку на все хранилище большего массива, а не только на ту часть, которую он представляет, даже после окончания срока службы исходного массива. Поэтому длительное хранение фрагмента может продлить срок службы элементов, которые больше не доступны иным образом, что может показаться утечкой памяти и объекта.

Несмотря на то, что это не то, что я бы назвал «долгосрочным хранилищем», похоже, это применимо к тому, что здесь происходит.

Комментарии:

1. Фактическая реализация класса намного сложнее (с анимацией / множеством разных случаев, которые обрабатываются по-разному), и я вижу подобные сбои и в других случаях. Так что дело не столько в том, чтобы выполнить работу — я выделил один случай «wtf», чтобы выяснить и понять, что происходит.

2. @RalfEbert «Итак, дело не столько в выполнении работы» Итак, какой ответ вас удовлетворит?

3. Хм, прежде всего, я очень благодарен за ответ Донмага и за то, что он смог воспроизвести сбой и подсказку относительно dropFirst / ArraySlice. Хм, я думаю, я ищу ответ, который объясняет, почему он выходит из строя, и действительно ли это ошибка на стороне Apple, или если я пример нарушает какое-то правило, о котором я не знаю. Я хочу понять, что происходит 🙂

4. @RalfEbert Ну, если вы просто измените свой код на Array(self.children.dropLast(self.cardLimit)) , проблема исчезнет?

5. Что касается ArraySlice: спасибо за подсказку, я не знал об этом. Но все же, в худшем случае это может продлить срок службы контроллера и массива. Но я не понимаю, как это может привести к такому сбою, когда UIKit, похоже, содержит висячий указатель на контроллер представления…