Убийственная связка из NSCache и UINib
Хочу поделиться еще одним крешем, с которым разбирался пару месяцев назад. Сейчас, по прошествии времени, крешрепорты такого типа больше не наблюдаются в HockeyApp, а раньше были одними из самых популярных. Собственно, проблема наблюдалась уже довольно давно, но тогда наше приложение еще использовало TestFlight и информации для анализа не доставало. Креш характеризовался примерно таким стеком: Thread 0 Crashed: 0 libobjc.A.dylib 0×39abcf42 objc_msgSend + 2 1 CoreFoundation 0×2bfe0c61 __CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ + 10 2 CoreFoundation 0×2bf3c6d5 _CFXNotificationPost + 1782 3 Foundation 0×2cc6e129 -[NSNotificationCenter postNotificationName: object: userInfo:] + 70 4 Foundation 0×2cc72c8f -[NSNotificationCenter postNotificationName: object:] + 28 5 UIKit 0×2f750883 -[UIApplication _performMemoryWarning] + 132 6 libdispatch.dylib 0×3a0107a7 _dispatch_client_callout + 20 7 libdispatch.dylib 0×3a021253 _dispatch_source_latch_and_call + 624 8 libdispatch.dylib 0×3a0122ed _dispatch_source_invoke + 210 9 libdispatch.dylib 0×3a013e1f _dispatch_main_queue_callback_4CF + 328 10 CoreFoundation 0×2bfee3b1 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 6 11 CoreFoundation 0×2bfecab1 __CFRunLoopRun + 1510 12 CoreFoundation 0×2bf3a3c1 CFRunLoopRunSpecific + 474 13 CoreFoundation 0×2bf3a1d3 CFRunLoopRunInMode + 104 14 GraphicsServices 0×332cf0a9 GSEventRunModal + 134 15 UIKit 0×2f5487b1 UIApplicationMain + 1438 16 xxx 0×0015bb81 main (main.m:18) 17 libdyld.dylib 0×3a030aaf start + 0 По вызову -[UIApplication _performMemoryWarning] понятно, что проблема произошла при обработке memory warning. По всей видимости, какой-то объект подписался на UIApplicationDidReceiveMemoryWarningNotification и забыл отписаться перед своим уничтожением. Но проверка по коду проекта не выявила подозрительных ситуаций — все, кто использовал эту нотификацию, либо были синглтонами, либо более-менее корректно отписывались. На тот момент дело этим и ограничилось, идей для фикса пока что не было.Затем, когда Apple купила TestFlight, мы перешли на HockeyApp. Они используют крутую крешрепортилку (PLCrashReporter), и в целом работа с крешами там обстояла гораздо лучше (можно еще и свои логи/инфу аттачить при посылке репорта с девайса). Но, возвращаясь к проблеме, в добавок к стеку, приведенному выше, появились еще и такие строчки:
Application Specific Information: objc_msgSend () selector name: setArchiveData: Теперь мы знаем, какой селектор посылался умершему объекту. В нашем коде таких методов/свойств не присутствовало, что подтверждало прежний анализ. Соответственно, встает задача найти класс, у которого есть такой селектор. В этом помогают функции obj-c рантайма objc_getClassList (выдает список зарегистрированных классов) и class_copyMethodList (позволяет получить методы экземпляров и самого класса). Пройдясь по всем классам и проверив все их селекторы, я получил единственный вариант — UINibStorage. Это приватный класс, и с помощью свизлинга его методов видим, что он создается и держится UINib’ами. Дальше, опять же с помощью свизлинга и дизассемблирования, выясняем, что UINib подписывается на UIApplicationDidReceiveMemoryWarningNotification, и при ее получении очищает содержимое своего UINibStorage (в т.ч. вызывает setArchiveData) — этот вызов и падает в крешлоге. Отписка от нотификации происходит в деаллоке UINib. Как же получилось, что UINib умер, но при этом получил нотификацию? Проблема, по всей видимости, возникла из-за того, что мы использовали NSCache для кеширования нибов. При нехватке памяти NSCache очищает свое содержимое в фоновом потоке, т.е. по сути асинхронно с memory warning в главном потоке. Т.о. в фоновом потоке вызывается -[UINib dealloc], в котором тот отписывается от нотификаций, а в главном идет их обработка. Это неправильный и опасный подход к использованию NSNotificationCenter. Вообще говоря, за время работы на проекте нам доводилось фиксить немало багов, связанных с асинхронностью, т.к. там выполняется много асинхронных операций. Одна из частых ошибок, с которой доводилось сталкиваться — отмена или отписывание от чего-либо в деаллоке. Это слишком поздний момент, т.к. объект уже фактически умирает, и если асинхронная операция в этот же момент пытается с ним работать, то это плохо кончится. К сожалению, суровая реальность такова, что не всегда есть хорошее место, где можно было бы отписываться. В случае с UINib понятно, что такого удобного места нет, поэтому сложно упрекнуть за это (скорее тогда стоит упрекать инфраструктуру или NSNotificationCenter).
В качестве решения проблемы я написал тривиальный кеш для хранения нибов. Вообще же, это не первый креш с NSCache. Раньше мне уже доводилось фиксить креш связанный с хранением NSCache в NSCache — так делать тоже не стоит. Но и NSCache я тоже не могу назвать явно виноватым, т.к. он не должен думать, что нельзя послать release объекту в любом фоновом потоке из-за того, что этот release может быть последним, а dealloc делает больше, чем ничего. Пожалуй, эта ситуация из тех, когда понятные и простительные решения дают негативный результат.