Pull to refresh

До чего доводит идея (Objective-C) — target-action на блоках и много рантайма

Reading time14 min
Views9.2K
Пришла мне как-то в голову идея, а можно ли взять блок и отдать для target-action?
Есть готовые решения, как к примеру BlocksKit и другие библиотеки, однако их решение заключается в сохранении блока, установкой таргета и вызова блока из указанного селектора.

Зачем тогда нужна эта статья?

Я захотел создать способ генерации селектора, по которому будет вызван блок. Что здесь сложного, скажете вы? imp_implementationWithBlock + class_addMethod и дело закрыто. Но при этом подходе есть одно серьезное требование, это первый аргумент блока — владелец метода.

Как обойти это требование и сделать такое?
    [button addTarget:self action:[self ax_lambda:^(UIButton *sender, UIEvent *event){
        NSLog(@"click on button %@, event = %@", sender, event);
    }] forControlEvents:UIControlEventTouchUpInside];

    [button addTarget:self action:[self ax_lambda:^{
        NSLog(@"click");
    }] forControlEvents:UIControlEventTouchUpInside];

Или даже вот так
    __block NSInteger sum = 0;
    [self performSelector:[self ax_lambda:^(NSNumber *argA, NSNumber *argB) {
        sum = [argA integerValue] + [argB integerValue];
    }] withObject:@(2) withObject:@(3)];
    //sum — 5

    SEL selSum = [self ax_lambda:^NSInteger(NSInteger argA, NSInteger argB){
        return argA + argB;
    }];
    NSInteger(*funcSum)(id, SEL, NSInteger, NSInteger) = (NSInteger(*)(id, SEL, NSInteger, NSInteger))objc_msgSend;
    NSInteger sum2 = funcSum(self, selSum, 2, 3);
    //sum2 — 5

Реализация оказалась настолько интересной, что я решил написать об этом.

По сути, основная задача это избавиться от первого аргумента self в вызове блока. Это корневая проблема всего решения (жаль, что не единственная).
Ранее я уже немного писал о блоках, и отметил, что блок — это объект, а значит вызов будет происходить через NSInvocation.
Если получить момент вызова блока и в NSInvocation убрать аргумент self (сдвинув аргументы), то тогда я получу желаемый результат.

Дальше надо будет разбираться по ходу дела.


AXProxyBlock


Вопрос, как вклиниться в момент вызова блока? Как вообще получить момент вызова блока?
Очень часто я пишу эту фразу, но блок — это объект. Объект в objc в конечном виде это структура. Раз id — это указатель на структуру, позволено и обратное (__bridge, привет).
Получается, можно создать фейковый блок. Ну или прокси для блока.

Интерфейс моего класса получился следующий:
typedef void(^AXProxyBlockInterpose)(NSInvocation *invocation);


@interface AXProxyBlock : NSProxy

+ (instancetype)initWithBlock:(id)block;

- (void)setBeforeInvoke:(AXProxyBlockInterpose)beforeInvoke;

- (NSString *)blockSignatureStringCTypes;

@end



Как можно догадаться, setBeforeInvoke принимает блок, в котором можно делать «магические» преобразования аргументов блока.
blockSignatureStringCTypes возвращает сигнатуру проксируемого блока. Зачем он в заголовочном файле? Об этом позднее.

ссылка на страницу документации о том, что сейчас начнется

Для начала по документации создадим структуры блока и перечисление с нашими наименованиями.
typedef struct AXBlockStruct_1 {
    unsigned long int reserved;
    unsigned long int size;
    
    void (*copy_helper)(void *dst, void *src);
    void (*dispose_helper)(void *src);
    
    const char *signature;
} AXBlockStruct_1;

typedef struct AXBlockStruct {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(void *, ...);
    struct AXBlockStruct_1 *descriptor;
} AXBlockStruct;


typedef NS_ENUM(NSUInteger, AXBlockFlag) {
    AXBlockFlag_HasCopyDispose = (1 << 25),
    AXBlockFlag_HasCtor = (1 << 26),
    AXBlockFlag_IsGlobal = (1 << 28),
    AXBlockFlag_HasStret = (1 << 29),
    AXBlockFlag_HasSignature = (1 << 30)
};


А теперь займемся нашим классом.
Нужно сделать соответствующее строение:
@interface AXProxyBlock ()  {

//  isa поле уже имеется в реализации NSProxy, а остальные поля добавим
    int _flags;
    int _reserved;
    IMP _invoke;
    AXBlockStruct_1 *_descriptor;

//  готово, а теперь те поля, которые нужны для класса    
    AXProxyBlockInterpose _beforeInvoke;
    id _block;
    NSMethodSignature *_blockMethodSignature;
    IMP _impBlockInvoke;
}

@end


Теперь нужно чтобы на момент вызова класс имитировал принимаемый блок:
Соотвествие значений полей
- (instancetype)initWithBlock:(id)block {
    if (self != nil) {
        AXBlockStruct *blockRef = (__bridge AXBlockStruct *)block;
        _flags = blockRef->flags;
        _reserved = blockRef->reserved;
        _descriptor = calloc(1, sizeof(AXBlockStruct_1));
        _descriptor->size = class_getInstanceSize([self class]);
        
        BOOL flag_stret = _flags & AXBlockFlag_HasStret;
        _invoke = (flag_stret ? (IMP)_objc_msgForward_stret : (IMP)_objc_msgForward);
...


Описание назначения этих полей можно прочитать все на той же странице документации clang. Теперь поля соответствуют блоку на момент вызова.

Но у меня есть 2 очень важных ivar, которые я не стал включать под спойлер выше, поскольку они относятся уже к вызову блока и на них я хочу остановится более подробно.

        _impBlockInvoke = (IMP)blockRef->invoke;
        _blockMethodSignature = [self blockMethodSignature];


_impBlockInvoke — это функция вызова блока, имплементация. Это обычный указатель на функцию и вызвать можно руками.
_blockMethodSignature это метод-сигнатура блока. Что это такое будет рассмотрено очень подробно далее.

Как получить NSMethodSignature для блока
- (NSMethodSignature *)blockMethodSignature {
    const char *signature = [[self blockSignatureStringCTypes] UTF8String];
    return [NSMethodSignature signatureWithObjCTypes:signature];
}

- (NSString *)blockSignatureStringCTypes {
    AXBlockStruct *blockRef = (__bridge AXBlockStruct *)_block;
    
    const int flags = blockRef->flags;
    
    void *signatureLocation = blockRef->descriptor;
    signatureLocation += sizeof(unsigned long int);
    signatureLocation += sizeof(unsigned long int);
    
    if (flags & AXBlockFlag_HasCopyDispose) {
        signatureLocation += sizeof(void(*)(void *dst, void *src));
        signatureLocation += sizeof(void (*)(void *src));
    }
    
    const char *signature = (*(const char **)signatureLocation);
    return [NSString stringWithUTF8String:signature];
}



Мы берем наш блок, получаем из него descriptor, потом смещаемся на нужную величину для получения сигнатуры блока (const char *) и через нее создаем NSMethodSignature. NSMethodSignature определяет кол-во и типы аргументов, возвращаемое значение и тп.
Выглядит не сложно, но манипуляции с флагом смущают: в зависимости от типа блока, его сигнатура может располагаться по-разному. К примеру, у глобального блока не нужно смещаться за функции копирования и разрушения.

Метода на вызов блока у моего класса нет, значит вызван будет forwardInvocation, а перед ним необходимо узнать какого типа будет сформирован NSInvocation, поэтому происходит вызов methodSignatureForSelector, в котором мы отдаем наш _blockMethodSignature.

forwardInvocation
- (void)forwardInvocation:(NSInvocation *)anInvocation {
    [anInvocation setTarget:_block];
    if (_beforeInvoke) {
        _beforeInvoke(anInvocation);
    }
    IMP imp = _impBlockInvoke;
    [anInvocation invokeUsingIMP:imp];
}

Код здесь должен быть очень понятен (установили новую цель на вызов, вызвали блок before если существует), но где вызов [anInvocation invoke]?!
Это черная магия. Метод invokeUsingIMP это private API, которое можно найти здесь, как и еще много чего


Собираем пазл перед продолжением


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

Поговорим о методе, который вызывался в самом начале статьи — ax_lambda. Это всего лишь категория для NSObject, она является оберткой для вызова основной функции, которая выглядит следующим образом:
SEL ax_lambda(id obj, id block, NSMutableArray *lambdas);

Думаю теперь становится понятнее, для чего написана обертка. И если первый и второй аргумент не вызывает вопросов, то 3й заставляет задуматься. Сперва я расскажу о необходимости третьего аргумента, а потом уже приведу под спойлеры код категории.

SEL ax_lambda(id obj, id block, NSMutableArray *lambdas) {
    SEL selector = ax_generateFreeSelector(obj);
    
    AXProxyBlockWithSelf *proxyBlock = [AXProxyBlockWithSelf initWithBlock:block];
    [proxyBlock setBeforeInvoke:^(NSInvocation *invocation){
        ax_offsetArgInInvocation(invocation);
    }];
    [lambdas addObject:proxyBlock];
    
    IMP imp = imp_implementationWithBlock(proxyBlock);
    NSString *signatureString = [proxyBlock blockSignatureStringCTypes];
    class_addMethod([obj class], selector, imp, [signatureString UTF8String]);
    
    return selector;
}

Это и есть основная функция, тот самый собранный пазл. Класс AXProxyBlockWithSelf будет рассмотрен далее, пока только отмечу, что это потомок класса AXProxyBlock как наверняка догадались.
Чтобы сделать блок методом необходим селектор, имплементация и строковая сигнатура. Имплементация будет получена с проксиблока, строковую сигнатуру отдаст тоже прокси (в AXProxyBlock это сигнатура проксируемого блока, но в AXProxyBlockWithSelf она отличается и это будет рассмотрено далее), ну а селектор сгенерировать не сложно. Так зачем же 3й параметр?

При вызове imp_implementationWithBlock будет вызвано копирование блока (Block_copy). Поле copy_helper в блоке указатель на функцию копирования блока. Однако прокси блока не имеет такой возможности. Даже если я создам функцию копирования вида void (*)(void *dst, void *src), я не смогу получить желаемый результат. В src придет объект, в который нужно копировать и это будет не экземпляр моего класса. Поэтому вызов imp_implementationWithBlock не увеличит счетчик ссылок для объекта proxyBlock (и proxyBlock будет уничтожен после завершения функции). Чтобы этого не допустить, я использую коллекцию, которая увеличит внутренний счетчик ссылок. Получается срок жизни блока зависит от срока жизни коллекции хранящей его. В случае с категорией срок жизни блока ограничен сроком жизни владельца.

AXLambda.h
SEL ax_lambda(id obj, id block, NSMutableArray *lambdas);

@interface NSObject (AX_Lambda)

- (SEL)ax_lambda:(id)block;

@end


AXLambda.m
static char kAX_NSObjectAssociatedObjectKey;


@interface NSObject (_AX_Lambda)

@property (copy, nonatomic) NSMutableArray *ax_lambdas;

@end


@implementation NSObject (_AX_Lambda)

@dynamic ax_lambdas;

- (void)setAx_lambdas:(NSMutableArray *)lambdas {
    objc_setAssociatedObject(self, &kAX_NSObjectAssociatedObjectKey, lambdas, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSMutableArray *)ax_lambdas {
    NSMutableArray *marrey = objc_getAssociatedObject(self, &kAX_NSObjectAssociatedObjectKey);
    if (marrey == nil) {
        self.ax_lambdas = [NSMutableArray array];
    }
    return objc_getAssociatedObject(self, &kAX_NSObjectAssociatedObjectKey);
}

@end

@implementation NSObject (AX_Lambda)

- (SEL)ax_lambda:(id)block {
    return ax_lambda(self, block, self.ax_lambdas);
}

@end



Ну и функции, используемые в SEL ax_lambda(id obj, id block, NSMutableArray *lambdas);
SEL ax_generateFreeSelector(id obj)
SEL ax_generateFreeSelector(id obj) {
    SEL selector;
    NSMutableString *mstring = [NSMutableString string];
    do {
        [mstring setString:@"ax_rundom_selector"];
        u_int32_t rand = arc4random_uniform(UINT32_MAX);
        [mstring appendFormat:@"%zd", rand];
        selector = NSSelectorFromString(mstring);
    } while ([obj respondsToSelector:selector]);
    return selector;
}


void ax_offsetArgInInvocation(NSInvocation *invocation)
void ax_offsetArgInInvocation(NSInvocation *invocation) {
    void *foo = malloc(sizeof(void*));
    NSInteger arguments = [[invocation methodSignature] numberOfArguments];
    for (NSInteger i = 1; i < arguments-1; i++) { //i = 0 is self
        [invocation getArgument:foo atIndex:i+1];
        [invocation setArgument:foo atIndex:i];
    }
    
    free(foo);
}




Разбираемся с NSMethodSignature на примере комбинирования stringWithFormat и NSArray


Перед тем, как приступить к следующей части, необходимо базовое понимание работы NSInvocation и NSMethodSignature. Я думал выделить это в отдельную статью, но пришел к выводу, что если очень не углубляться в материал, то статья получится пусть интересной и простой (в разборе конкретного примера), но очень не большой. Поэтому я решил написать об этом прямо здесь.

Мне нужен был метод, позволяющий генерировать строку из формата и массива аргументов, к примеру вот так:
    NSString *format = @"%@, foo:%@, hello%@";
    NSArray *input = @[@(12), @(13), @" world"];
    NSString *result = [NSString ax_stringWithFormat:format array:input];
    //result — @"12, foo:13, hello world"


К сожалению, методы, которые я находил на SO не работали (первый, второй). Возможно я не правильно пытался их использовать (у кого получилось — отпишите, пожалуйста) на ARC, но поскольку мне нужен был рабочий вариант, я написал свою реализацию.

Не делая никаких обходов с указателями или преобразованиями, решение основывается полностью на принципе работы методов.

Конечный вид метода выглядит так:
+ (instancetype)ax_stringWithFormat:(NSString *)format array:(NSArray *)arguments; 


Стандартный метод для создания строки по формату и параметрам выглядит следующим образом
- (instancetype)initWithFormat:(NSString *)format arguments:(va_list)argList NS_FORMAT_FUNCTION(1,0);

Но для использования (и самая проблема) нужно создать va_list (что это и как использовать).
Следующий метод отлично подходит
+ (instancetype)ax_string:(NSString *)format, ...
+ (instancetype)ax_string:(NSString *)format, ... {
    va_list list;
    va_start(list, format);
    NSString *str = [[NSString alloc] initWithFormat:format arguments:list];
    va_end(list);
    return str;
}


Теперь проблема как его вызвать с аргументами из NSArray.

NSInvocation — это объект используемый для хранения и пересылки сообщения между объектами и/или между приложениями.
Однако при создании NSInvocation нужно иметь NSMethodSignature.
NSMethodSignature позволяет определить сколько аргументов принимает метод, типы аргументов, смещения, тип возвращаемого значения. По этом очень логичным смотрится замечание из документации
NSInvocation does not support invocations of methods with either variable numbers of arguments or union arguments.

Ведь не известно сколько аргументов и какого типа будет передано в функцию/метод с переменным кол-вом аргументов.

А если все же известно? Если я сам знаю эту информацию перед вызовом? Тогда я могу сказать, что в данном случае метод будет принимать к примеру 4 аргумента и тк функция принимает переменное кол-во аргументов, это сработает.
NSMethodSignature можно создать через генерируемую сигнатуру, если самому указать всю информацию выше. NSArray содержит только указатели и смещения всех параметров только на величину указателя, поэтому все довольно просто. Как я уже писал, в методе можно использовать self и _cmd потому что они в неявном виде передаются в метод.
+ (NSMethodSignature *)ax_generateSignatureForArguments:(NSArray *)arguments
+ (NSMethodSignature *)ax_generateSignatureForArguments:(NSArray *)arguments {
    NSInteger count = [arguments count];
    NSInteger sizeptr = sizeof(void *);
    NSInteger sumArgInvoke = count + 3; // self + _cmd + не забыть про то что в метод еще и формат будет передаваться
    NSInteger offsetReturnType = sumArgInvoke * sizeptr;
    
    NSMutableString *mstring = [[NSMutableString alloc] init];
    [mstring appendFormat:@"@%zd@0:%zd", offsetReturnType, sizeptr];
    for (NSInteger i = 2; i < sumArgInvoke; i++) {
        [mstring appendFormat:@"@%zd", sizeptr * i];
    }
    return [NSMethodSignature signatureWithObjCTypes:[mstring UTF8String]];
}


Стоит немного рассказать о том, что здесь происходит. Для начала надо посмотреть здесь типы кодирования.
А теперь по порядку, я очень надеюсь, что вы посмотрели в таблицу.

На первом месте сигнатуры будет возвращаемый тип и его смещение (возвращаемый тип находится после всех аргументов, поэтому у него будет максимальное смещение, но пишется на первом). Предположим sizeof(void*) будет 8 и массив из 3х аргументов. Но включая self + _cmd + формат который будет передан и того получаем 6 аргументов. 6х8 = 48
@48
Затем следует self и _cmd. self на первом месте в аргументах, по этому
@48@0:8
Затем формат
@48@0:8@16
и аргументы
@48@0:8@16@24@32@40

Теперь, имея сигнатуру можно использовать NSInvocation
+ (instancetype)ax_stringWithFormat:(NSString *)format array:(NSArray *)arrayArguments
+ (instancetype)ax_stringWithFormat:(NSString *)format array:(NSArray *)arrayArguments {
    NSMethodSignature *methodSignature = [self ax_generateSignatureForArguments:arrayArguments];
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature];
    [invocation setTarget:self];
    [invocation setSelector:@selector(ax_string:)];
    
    [invocation setArgument:&format atIndex:2];
    for (NSInteger i = 0; i < [arrayArguments count]; i++) {
        id obj = arrayArguments[i];
        [invocation setArgument:(&obj) atIndex:i+3];
    }
    
    [invocation invoke];
    
    __autoreleasing NSString *string;
    [invocation getReturnValue:&string];
    
    return string;
}



И теперь, если немного изменить метод выше, можно избавиться от метода + (instancetype)ax_string:(NSString *)format,…
Полный код под спойлером
+ (instancetype)ax_stringWithFormat:(NSString *)format array:(NSArray *)arrayArguments {
    NSMethodSignature *methodSignature = [self ax_generateSignatureForArguments:arrayArguments];
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature];

    [invocation setTarget:self];
    [invocation setSelector:@selector(stringWithFormat:)];
    
    [invocation setArgument:&format atIndex:2];
    for (NSInteger i = 0; i < [arrayArguments count]; i++) {
        id obj = arrayArguments[i];
        [invocation setArgument:(&obj) atIndex:i+3];
    }
    
    [invocation invoke];
    
    __autoreleasing NSString *string;
    [invocation getReturnValue:&string];
    
    return string;
}

//https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html
+ (NSMethodSignature *)ax_generateSignatureForArguments:(NSArray *)arguments {
    NSInteger count = [arguments count];
    NSInteger sizeptr = sizeof(void *);
    NSInteger sumArgInvoke = count + 3; //self + _cmd + (NSString *)format
    NSInteger offsetReturnType = sumArgInvoke * sizeptr;
    
    NSMutableString *mstring = [[NSMutableString alloc] init];
    [mstring appendFormat:@"@%zd@0:%zd", offsetReturnType, sizeptr];
    for (NSInteger i = 2; i < sumArgInvoke; i++) {
        [mstring appendFormat:@"@%zd", sizeptr * i];
    }
    return [NSMethodSignature signatureWithObjCTypes:[mstring UTF8String]];
}



Решение второй половины задачи — как добавить еще 1 аргумент в блок незамеченно?



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

Блок, принимаемый в imp_implementationWithBlock, должен принимать первым аргументов владельца. Получается, что сигнатура входного блока для функции ax_lambda отличается от положенной сигнатуры и в NSInvocation аргументы будут переданы совершенно не верно.

Класс AXProxyBlockWithSelf переделывает сигнатуру проксируемого блока, добавляя в нее дополнительно первый аргумент. Таким образом, вызов проксиблока будет совершен с правильными аргументами, а первый аргумент уже сместим перед вызовом самого блока.
Нужно переписать метод — (NSString *)blockSignatureStringCTypes

- (NSString *)blockSignatureStringCTypes
- (NSString *)blockSignatureStringCTypes {
    NSString *signature = [super blockSignatureStringCTypes];
    NSString *unformatObject = [signature ax_unformatDec];
    NSString *formatNewSignature = [self addSelfToFormat:unformatObject];
    
    NSArray *byteSignature = [signature ax_numbers];
    NSArray *byteNewSignature = [self changeByteSignature:byteSignature];
    
    return [NSString ax_stringWithFormat:formatNewSignature array:byteNewSignature];
}


Итак, имеется сигнатура блока, с типами аргументов и смещением, возвращаемый тип и тп
Нужно вставить дополнительный аргумент в сигнатуру и сместить аргументы.
Получаем первоначальный вид формат строки сигнатуры
- (NSString *)ax_unformatDec {
    NSCharacterSet *characterSet = [NSCharacterSet decimalDigitCharacterSet];
    NSPredicate *predicate = [NSPredicate predicateWithFormat:@"length > 0"];
    NSArray *separated = [[self componentsSeparatedByCharactersInSet:characterSet] filteredArrayUsingPredicate:predicate];
    NSString *format = [separated componentsJoinedByString:@"%@"];
    if ([[self lastSubstring] isEqualToString:[format lastSubstring]] ) {
        return format;
    } else {
        return [format stringByAppendingString:@"%@"];
    }
}

- (NSString *)lastSubstring {
    NSInteger lastIndex = [self length] - 1;
    return [self substringFromIndex:lastIndex];
}


Далее надо посмотреть здесь типы кодирования.
Добавляем аргумент \"владелец\" на первое место
- (NSString *)addSelfToFormat:(NSString *)format {
    NSMutableArray *marray = [[format componentsSeparatedByString:@"?"] mutableCopy];
    [marray insertObject:@"?%@@" atIndex:1];
    return [marray componentsJoinedByString:@""];
}


Получаем NSArray смещений аргументов для вызова
- (NSArray *)ax_numbers {
    NSString *pattern = @"\\d+";
    NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern options:NSRegularExpressionCaseInsensitive error:nil];
    NSRange fullRange = NSMakeRange(0, [self length]);
    NSArray *matches = [regex matchesInString:self options:NSMatchingReportProgress range:fullRange];
    
    NSMutableArray *numbers = [NSMutableArray array];
    for (NSTextCheckingResult *checkingResult in matches) {
        NSRange range = [checkingResult range];
        NSString *numberStr = [self substringWithRange:range];
        NSNumber *number = @([numberStr integerValue]);
        [numbers addObject:number];
    }
    
    return numbers;
}


Изменяем смещения аргументов на новое значение с учетом добавленного аргумента
- (NSArray *)changeByteSignature:(NSArray *)byteSignature {
    NSInteger value = sizeof(void *);
    NSMutableArray *marray = [NSMutableArray array];
    for (NSNumber *number in byteSignature) {
        NSInteger offset = [number integerValue] + value;
        [marray addObject:@(offset)];
    }
    [marray insertObject:@0 atIndex:1];
    return marray;
}



Ну и в конце создаем новую сигнатуру, используя новую формат-строку и NSArray с новым смещением. Таким образом, при вызове имплементации будет передан согласно документации владелец как первый аргумент, смещен благодаря перехвату и вызван оригинальный блок.


Полный код здесь. Это был всего лишь эксперимент, у меня не было желания написать этот код для использования в проектах. Но я рад, что смог завершить это дело успешно. Так же я рад, что возможно смог кому-то помочь выложив решение генерации строки с использованием NSArray на SO.
Надеюсь, у меня получилось донести материал в понятной форме и разбить на блоки.
Tags:
Hubs:
+5
Comments7

Articles