Objective-C, static libraries, categories, -ObjC, боль…

    Не всем повезло писать приложения полностью на Swift, да и еще под ios 8+ онли. Много легаси на Objective-C, много зависимостей идет через статик либы, ни cocoapods, ни carthage, всё ручками. Мы же крутые девелоперы, поэтому строго следуем DRY и все реюзабельные вкусшянки выносим либо в отдельные проекты, либо в статик библиотеки. Сейчас рассмотрим случай, когда мы сделали классную статичную библиотечку с не менее прикольным апи, и хотели бы поделиться с товарищами по цеху внутри компании — на вики ресурсе/гите выложить архивчик с либой, хедерами и, конечно же, ридмиком где описан весь апи и как им пользоваться.

    Для примера ради рассмотрим один класс + его категорию





    На скриншоте у нас структура проекта, где класс + класс категория, всё просто. Собираем обычным образом, пишем readme.md с описанием апи и архивируем библиотеку. Всё круто, залили на вики, пацанам твитнули в slack/skype/etc и пошли себе за очередным кофе. Только присели обратно со свежесваренным кофе и курсор мышки почти достиг закладки на хабр, как в чаты посыпались какие-то логи, и все требуют вашего немедленного ответа, так как проблема в свежезарелизенной либе. Вас бросило в пот, ведь у вас тестовое покрытие 146%, всё на сто раз перепроверено. В это же самое время в чате уже в личку снова пишут тот же самый лог ошибки:

    *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[Deadpool guns]: unrecognized selector sent to instance 0x7ffecbc12df0'
    


    После ознакомления с логом, причина ясна и до боли знакома когда часто работаешь со статик либами. Поняв проблему, вы уверенно вытираете пот со лба, открываете ранее отправленный readme.md и дописываете:

    Don't forget to add '-ObjC' flag to 'Other Linker Flags' in Build Settins of Xcode's scheme.

    После, обновили вики, снова всех оповестили и вроде всё успокоилось, месседжеры замолкли, кофе даже не успело остыть. «Ну сейчас точно никто меня не оставновит» — шепчет ваш внутренний голос и курсор мыши снова тянется к заветной закладке (ненене, только хабр!). От желанного тебя отделяет только клик по левой кнопке мыши, но тебя не покидает мысль: «Можно ли было избежать этой ошибки или как предотвратить ее в будущем?!». «Да к черту всё!» — воскликнул внутренний голос и курсор потянулся к Terminal.app.

    otool -tV -arch x86_64 libDeadpool.a
    


    выдает:

    Archive : libDeadpool.a
    libDeadpool.a(Deadpool+Guns.o):
    (__TEXT,__text) section
    -[Deadpool(Guns) guns]:
    0000000000000000	pushq	%rbp
    0000000000000001	movq	%rsp, %rbp
    0000000000000004	subq	$0x10, %rsp
    0000000000000008	leaq	0x71(%rip), %rax        ## Objc cfstring ref: @"sword 2"
    000000000000000f	movd	%rax, %xmm0
    0000000000000014	leaq	0x45(%rip), %rax        ## Objc cfstring ref: @"sword 1"
    000000000000001b	movd	%rax, %xmm1
    0000000000000020	punpcklqdq	%xmm0, %xmm1    ## xmm1 = xmm1[0],xmm0[0]
    0000000000000024	movdqa	%xmm1, -0x10(%rbp)
    0000000000000029	movq	0x70(%rip), %rdi        ## Objc class ref: NSArray
    0000000000000030	movq	0x91(%rip), %rsi        ## Objc selector ref: arrayWithObjects:count:
    0000000000000037	leaq	-0x10(%rbp), %rdx
    000000000000003b	movl	$0x2, %ecx
    0000000000000040	callq	*_objc_msgSend(%rip)
    0000000000000046	addq	$0x10, %rsp
    000000000000004a	popq	%rbp
    000000000000004b	retq
    libDeadpool.a(Deadpool.o):
    (__TEXT,__text) section
    -[Deadpool name]:
    0000000000000000	pushq	%rbp
    0000000000000001	movq	%rsp, %rbp
    0000000000000004	movq	0x1d(%rip), %rsi        ## Objc selector ref: class
    000000000000000b	callq	*_objc_msgSend(%rip)
    0000000000000011	movq	%rax, %rdi
    0000000000000014	popq	%rbp
    0000000000000015	jmp	_NSStringFromClass
    


    хм, в самой либе все методы на месте, теперь посмотрим исходники приложения:

    otool -tV -arch x86_64 DemoApp.app/DemoApp | grep Deadpool
    


    выдает:

    0000000100001ae8	movq	0x21f1(%rip), %rdi      ## Objc class ref: Deadpool
    0000000100001af6	movq	0x1513(%rip), %r12      ## Objc message: +[Deadpool new]
    -[Deadpool name]:
    


    WTF! Окей гугл, где же все таки метод из категории?

    гугл нам умело подсовывает ссылку на документацию эпла по этой как раз проблеме https://developer.apple.com/library/mac/qa/qa1490/_index.html, где беглый перевод говорит следующее:

    The Linker

    Когда си-программа скомпилирована, то каждый файл (.c) компилируется в так называемый «object file» (.o), который содержит имплементации функций и другую статичную информацию. После линкер собирает все эти файлы в один конечный файл — executable. И этот executable файл как раз и попадает внутрь нашей .app посредством Xcode.

    Но когда source файл (.c) использует что либо, например функцию, что определено в другом файле (другой .c файл), тогда «undefined symbol» записывается в .o файл для этого участка кода. И на этапе сборки линкеру достаточно информации чтобы по «undefined symbol» понять откуда нужно вытащить недостающую вещь чтобы собрать конечный executable. Это описание для сборки UNIX static library.


    Objective-C

    Из-за динамической природы языка этот процесс в Objective-C немного усложнен, так как поиск реализации метода происходит только по факту обращения к этому методу. Objective-C не определяет вспомогательных symbols для методов линкеру, а только определяет symbols для классов. Например, в классе/файле main.o есть код:

    [[FooClass alloc] initWithBar:nil]

    то есть, FooClass это отдельный класс, в отдельном FooClass.o файле, так вот main.o будет только содержать «undefined symbol» для самого FooClass, но никаких дополнительных symbols для метода -initWithBar: в этом классе.

    Так как категория это просто отдельный файл с методами, то у линкера нет совершенно никакой информации, что этот файл нужно слинковать, так как для методов не создаются вспомогательные линкеру «undefined symbol» штуки.


    Так, вроде разобрались, еще раз посмотрим на байт код либы:

    Archive : libDeadpool.a
    libDeadpool.a(Deadpool+Guns.o):
    (__TEXT,__text) section
    -[Deadpool(Guns) guns]:
    0000000000000000	pushq	%rbp
    0000000000000001	movq	%rsp, %rbp
    0000000000000004	subq	$0x10, %rsp
    0000000000000008	leaq	0x71(%rip), %rax        ## Objc cfstring ref: @"sword 2"
    000000000000000f	movd	%rax, %xmm0
    0000000000000014	leaq	0x45(%rip), %rax        ## Objc cfstring ref: @"sword 1"
    000000000000001b	movd	%rax, %xmm1
    0000000000000020	punpcklqdq	%xmm0, %xmm1    ## xmm1 = xmm1[0],xmm0[0]
    0000000000000024	movdqa	%xmm1, -0x10(%rbp)
    0000000000000029	movq	0x70(%rip), %rdi        ## Objc class ref: NSArray
    0000000000000030	movq	0x91(%rip), %rsi        ## Objc selector ref: arrayWithObjects:count:
    0000000000000037	leaq	-0x10(%rbp), %rdx
    000000000000003b	movl	$0x2, %ecx
    0000000000000040	callq	*_objc_msgSend(%rip)
    0000000000000046	addq	$0x10, %rsp
    000000000000004a	popq	%rbp
    000000000000004b	retq
    libDeadpool.a(Deadpool.o):
    (__TEXT,__text) section
    -[Deadpool name]:
    0000000000000000	pushq	%rbp
    0000000000000001	movq	%rsp, %rbp
    0000000000000004	movq	0x1d(%rip), %rsi        ## Objc selector ref: class
    000000000000000b	callq	*_objc_msgSend(%rip)
    0000000000000011	movq	%rax, %rdi
    0000000000000014	popq	%rbp
    0000000000000015	jmp	_NSStringFromClass
    


    Действительно, у нас скомпилировалось два файла Deadpool.o и Deadpool+Guns.o, так как второй файл это просто набор методов для первого, то линкер о нем ничего не знает и поэтому получаем эту ошибку только в рантайме.

    Сразу первое решение — перенести категорию в файл основного класса. Да, это будет работать :) но для нас это не совсем удобно, так как мы привыкли все категории держать в отдельных папочках для порядка.

    Другое решение. Те, кто использует нашу либу, должны указать -ObjC флаг в «Other Linker Flags», этот флаг говорит линкеру загрузить всё всё всё из статичной либы. Ну, нам подходит это решение тем, что на нашей стороне ничего править не нужно. Но если подумать, если разработчик подключит кучу либ и только из-за нашей ему приходится добавлять этот флаг, то он может получить нехилое прибавление в весе для своего приложения (я так предполагаю).

    А можно ли как то сказать линкеру, чтобы он собрал класс и его категории в один файл? Оказывается есть такое и название ему «Perform Single-Object Prelink» или «GENERATE_MASTER_OBJECT_FILE» в pbxproj файле. Правда происходит не просто объединение класса и его категории в единый файл, а все файлы проекта будут как единый «object file». Если это значение выставить в true, то мы должны получить поведение, которое хотим. Проверим.

    Выставляем:



    otool -tV -arch x86_64 libDeadpool.a
    


    получаем:

    Archive : libDeadpool.a
    libDeadpool.a(libDeadpool.a-x86_64-master.o):
    (__TEXT,__text) section
    -[Deadpool(Guns) guns]:
    0000000000000000	pushq	%rbp
    0000000000000001	movq	%rsp, %rbp
    0000000000000004	subq	$0x10, %rsp
    0000000000000008	leaq	0x149(%rip), %rax       ## Objc cfstring ref: @"sword 2"
    000000000000000f	movd	%rax, %xmm0
    0000000000000014	leaq	0x11d(%rip), %rax       ## Objc cfstring ref: @"sword 1"
    000000000000001b	movd	%rax, %xmm1
    0000000000000020	punpcklqdq	%xmm0, %xmm1    ## xmm1 = xmm1[0],xmm0[0]
    0000000000000024	movdqa	%xmm1, -0x10(%rbp)
    0000000000000029	movq	0x270(%rip), %rdi       ## Objc class ref: NSArray
    0000000000000030	movq	0x259(%rip), %rsi       ## Objc selector ref: arrayWithObjects:count:
    0000000000000037	leaq	-0x10(%rbp), %rdx
    000000000000003b	movl	$0x2, %ecx
    0000000000000040	callq	*_objc_msgSend(%rip)
    0000000000000046	addq	$0x10, %rsp
    000000000000004a	popq	%rbp
    000000000000004b	retq
    -[Deadpool name]:
    000000000000004c	pushq	%rbp
    000000000000004d	movq	%rsp, %rbp
    0000000000000050	movq	0x241(%rip), %rsi       ## Objc selector ref: class
    0000000000000057	callq	*_objc_msgSend(%rip)
    000000000000005d	movq	%rax, %rdi
    0000000000000060	popq	%rbp
    0000000000000061	jmp	_NSStringFromClass
    


    Что и хотели, сейчас всё в одном файле libDeadpool.a-x86_64-master.o. Убираем из приложения -ObjC и пересобираем с новой версией нашей библиотеки и смотрим:

    otool -tV -arch x86_64 DemoApp.app/DemoApp | grep Deadpool
    


    вывод:

    0000000100001a70	movq	0x22c9(%rip), %rdi      ## Objc class ref: Deadpool
    0000000100001a7e	movq	0x158b(%rip), %r12      ## Objc message: +[Deadpool new]
    -[Deadpool(Guns) guns]:
    -[Deadpool name]:
    


    Отлично. Сейчас можно обратно из readme.md удалять информацию о -ObjC флаге, смело открывать хабр и допивать, к сожалению, уже остывший кофе )

    пс.

    Проблема старая, давно ее решил, сейчас вот дошли руки написать и более подробно в этом разобраться )
    Не уверен в идеальности решения, но мне помогло с этой проблемой, может кому будет интересно.

    Полезные ссылки:

    https://developer.apple.com/library/mac/qa/qa1490/_index.html
    http://stackoverflow.com/questions/2567498/objective-c-categories-in-static-library
    • +10
    • 7,8k
    • 8
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 8
    • 0
      Спасибо за информацию. А не происходит ли заметного замедления времени сборки библиотеки из-за того, что собирается единых объектный файл?
      • 0
        Спасибо за хороший вопрос. Лично не замерял, но какой-то ощутимой разницы не замечал. Проект небольшой, буквально пару десятков классов.
      • 0
        А чем плохо сделать приватный pod?
        • 0
          Всё верно говорите, но, к сожалению, где я работал всё было немного сложнее. Скажем так, был кейс не отдавать разработчикам из другого отдела исходники проекта, при этом в нашем отделе проект разумеется разрабатывался/интегрировался через cocoapods. Также на CI сервере через скрипт насильно в каждое приложение подключался этот проект через наш cocoapods для сборки конечного ipa файла. Если бы я обернул проект в другую версию cocoapods, чтобы подключение происходило только статик либы и отдал её разработчикам из другого отдела, то тогда на CI сервере был бы конфликт подов (это решаемо конечно тоже), поэтому на сервере просто еще один скрипт пробегался по проекту, выключал статик либу и подключал её через cocoapods с сорцами необходимую/актуальную версию. Извините немного запутанно объясняю, но смысл изложенного думаю вам ясен. Но это единичный случай, конечно, все остальные проекты шарятся через cocoapods.
        • 0
          Это работает, если категория расширяет стандартный класс? Например, NSString.
          • 0
            Работает)
            • 0
              К сожалению не работает. Все так же требуется флаг -ObjC
              • 0
                Привет. Я предполагаю ваша библиотека состоит только из категорий, в этом случае линкеру не за что зацепиться, чтобы вашу либу вообще включить в compile stage приложения. Предлагаю вам workaround, создать пустой класс Dummy в библиотеке, и в коде приложения дернуть для него какой-то метод в любом месте один раз или просто создать инстанс, так мы заставим линкер затащить вашу библиотеку внутрь бинарника приложения. Не очень мне нравится это решение на самом деле, но это точно решит вашу проблему.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.