Desenvolvimento

4 jul, 2016

Introdução sobre Runtime em Objective-C

Publicidade

Este artigo foi originalmente publicado no Medium pessoal da autora. Confira aqui.

Alguns leitores estão familiarizados com bibliotecas como Specta, OCMockReactiveCocoa. Em um primeiro momento pode parecer mágica, mas essas ferramentas na verdade exploram ao máximo o fato de o Objective-C ser uma linguagem dinâmica, isto é, o código em questão toma decisões em tempo de execução (“runtime”) sempre que possível.

runtime

Essa característica oferece uma flexibilidade que pode ser aproveitada de diversas maneiras. Uma das mais conhecidas é consultar informações sobre um dado objeto por meio de métodos como isKindOfClass:, respondsToSelector:conformsToProtocol:, e assim por diante. Pode-se também adicionar novos métodos, chamá-los e até mudar a sua implementação em runtime. Isso é comumente utilizado no OCMock quando, por exemplo, um objeto “mockado” não depende mais de um serviço para obter a resposta de uma chamada, mas retorna um JSON que foi previamente setado.

Embora o runtime funcione na maior parte do tempo por debaixo dos panos, é interessante estudar mais sobre o assunto não só para entender melhor como a estrutura da linguagem funciona, mas também para entender a razão pela qual alguns passos são realizados.

Vamos mostrar aqui uma visão geral sobre o Runtime. Para mais detalhes, recomendo a leitura do “Objective-C Runtime Programming Guide”.

Algumas definições

Para saber como o runtime funciona, é importante entender algumas definições. Selector, método e implementação podem, em um primeiro momento, parecer que são a mesma coisa, mas na verdade são diferentes etapas de um processo feito em tempo de execução. Os termos mais importantes serão descritos abaixo. Não se esqueça de importar a biblioteca <objc/runtime.h> caso queira explorar os parâmetros a seguir.

Selector

Um selector (typedef struct objc_selector *SEL) nada mais é do que o nome de um método, como viewDidAppear:, setObject:forKey: etc. Note que o “:” faz parte do selector e serve para identificar quando é preciso passar parâmetros para um método. Para trabalhar diretamente com um selector, basta fazer, por exemplo:

SEL selector = @selector(viewDidAppear:)
//ou
SEL aSelector = NSSelectorFromString(@"viewDidAppear:")

Method

Um método (typedef struct objc_method *Method) é a combinação de um selector e sua implementação. Para acessar um método de uma instância ou classe, basta fazer:

// Instance Method
Class class = [self class];
Method method = class_getInstanceMethod(class, selector);

// Class Method
Class class = object_getClass(self);
Method method = class_getClassMethod(class, selector);

O método class_getInstanceMethod(class, selector) retorna o método de instância que corresponde à implementação de um selector em uma dada classe, ou NULL, caso, por exemplo, a classe ou a classe pai não tiver o método de instância para um selector específico.

Implementation

Uma implementação (id (*IMP)(id, SEL, …)) é basicamente o que está escrito dentro do bloco de um código. Um objeto do tipo IMP é um tipo de dado que aponta para o início da função que implementa o método. O primeiro argumento (id) aponta para a memória de uma dada instância de uma classe (ou, no caso de um método de classe, um ponteiro para uma metaclasse), também chamado de “receiver” (aquele que recebe o método), o segundo é o nome do método (SEL) e os restantes são os parâmetros que um método requer. A implementação pode ser adquirida da seguinte forma:

IMP implementation = method_getImplementation(method);

Message

Enviar uma mensagem é invocar um selector junto com os parâmetros que serão enviados para um “receiver”. Por exemplo, ao fazer:

[button setTitle:@"title" forState:UIControlStateNormal];

o compilador chama a seguinte função:

objc_msgSend(button, @selector(setTitle:forState:), @"title", UIControlStateNormal);

e, assim, a mensagem enviada para o “receiver” button é o selector “setTitle:forState:” mais os argumentos “title” e “UIControlStateNormal”. É possível guardar uma mensagem em um objeto do tipo NSInvocation para invocá-la posteriormente.

Method Signature

A assinatura de um método (NSMethodSignature) representa os tipos de dados que são aceitos e retornados por um método. Pode ser obtido por:

NSMethodSignature *signature = [receiver methodSignatureForSelector:selector];

no qual o receiver é o objeto que implementa o método, e o selector é o nome do método, como já foi discutido anteriormente.

Invocation

Um objeto do tipo NSInvocation é usado para guardar e enviar mensagens para um dado objeto. Ele contém todos os elementos necessários para enviar uma mensagem: um “receiver”, um selector, parâmetros de envio e o valor que será retornado. Um exemplo de como implementar um objeto desse tipo é:

void invokeSelector(id receiver, SEL selector, NSArray *arrayArguments) {
    
    if (receiver != nil && [receiver respondsToSelector:selector]) {
        
        NSMethodSignature *signature = [receiver methodSignatureForSelector:selector];
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
        [invocation setTarget:receiver];
        [invocation setSelector:selector];
        
        for(int i = 0; i < [signature numberOfArguments] - 2; i++) {
            id arg = [arrayArguments objectAtIndex:i];
            [invocation setArgument:&arg atIndex:i+2]; // The first two arguments are the hidden arguments self and _cmd
        }
        
        [invocation invoke]; // Invoke the selector
    }
}

- (void)invocationExample {

    NSMutableDictionary *dictionary = [NSMutableDictionary new];
    invokeSelector(dictionary, NSSelectorFromString(@"setObject:forKey:"), @[@"keyString", @"valueString"]);
}

Note que os argumentos são setados a partir do índice 2. Isso porque os índices 0 e 1 são reservados para os argumentos self e _cmd, respectivamente, e devem ser setados explicitamente como mostrado acima, onde self é igual ao receiver e _cmd é o selector.

Se o você estiver familiarizado com testes unitários e usou o OCMock, já deve ter se deparado com a seguinte situação:

[[[managerMock expect] andDo:^(NSInvocation *invocation) {

        void (^successBlock)(NSString *aString) = nil;
        [invocation getArgument:&successBlock atIndex:2];
        
        successBlock(@"42");
        
}] successBlock:[OCMArg any] errorBlock:[OCMArg any]];
No caso, o objeto manager terá o retorno do bloco de resposta “mockado” toda vez que o método successBlock:errorBlock: for chamado. Note que o primeiro argumento é referenciado com o índice 2, o segundo a 3 e assim por diante (caso houvesse), devido ao fato que foi discutido acima.

Juntando o quebra-cabeça

Com essas definições em mente, fica mais fácil entender como o processo em runtime funciona e como esses conceitos estão relacionados.

No Objective-C, a estrutura de uma classe possui:

  • Um ponteiro para a classe pai (ou superclass)
  • Uma “dispatch table”, na qual cada entrada associa um selector a uma implementação.

Os objetos instanciados, por sua vez, possuem um ponteiro para a estrutura de classe, chamado isa, que dá ao objeto acesso à sua classe e, por meio da classe, a todas as classes que herda. Ao enviar uma mensagem a um objeto, a função objc_msgSend(receiver, selector, arg1, arg 2, etc…) segue o isa que aponta para a estrutura de classe e tenta encontrar o selector na “dispatch table”. Caso não encontre, a função segue o ponteiro que aponta para a superclass e tenta encontrar o selector na “dispatch table” dela. Falhas sucessivas fazem com que objc_msgSend vá subindo na hierarquia de classes até chegar na classe NSObject. Uma vez localizado o selector, o objc_msgSend chama o método correspondente e repassa os parâmetros, caso contrário, ocorre uma exceção. Dessa forma, as implementações são escolhidas em tempo de execução.

img-1

imagem: https://developer.apple.com

Para acelerar o processo, o sistema possui um cache para cada classe, que associa os selectors às implementações assim que vão sendo usadas. Quando uma mensagem for enviada, a função objc_msgSend checa primeiro esse cache antes de verificar a “dispatch table”. Assim, quanto mais tempo o programa for executado, mais rapidamente as mensagens serão enviadas.

Método Swizzling

Uma das formas de se aplicar esses conceitos sobre runtime é por meio do método Swizzling, que consiste em modificar a “dispatch table”, trocando as implementações de dois métodos entre si.

Imagine que você quer, por exemplo, adicionar um log toda vez que uma tela aparece:

- (void)viewDidAppear:(BOOL)animated {
    
    [super viewDidAppear:animated];
    
    [self writeLogWhenTheViewAppeared];
}

- (void)writeLogWhenTheViewAppeared {
    
    NSLog(@"The view of viewController %@ appeared", self);
}

Agora imagine repetir esse processo para uma UIViewController, UITableViewController, UINavigationViewController etc. A técnica mostrada a seguir visa a evitar esse tipo de repetição.

Resumidamente, implementamos o método extensionViewDidAppear: (que contém a implementação do log) e será trocada a implementação dele com a do viewDidAppear:. Assim, quando a mensagem for enviada para a viewController com o selector viewDidAppear:, será na verdade executada a implementação de extensionViewDidAppear:. Além do log, o método extensionViewDidAppear: irá chamar [self extensionViewDidAppear:]. Como os selectors estão trocados, a implementação de viewDidAppear: será executada e o processo poderá continuar normalmente. O código a seguir pode ser encontrado no GitHub.

O primeiro passo é criar uma categoria e importar a biblioteca <objc/runtime.h>:

#import "UIViewController+Swizzling.h"

#import <objc/runtime.h>

@implementation UIViewController (Swizzling)

O método a seguir aplicará a técnica Swizzling propriamente dita:

#pragma mark - Swizzling viewDidAppear:

+ (void)swizzlingViewDidAppear {
}

Dentro desse método será escrito:

Class class = [self class];
    
SEL selectorOriginal = @selector(viewDidAppear:);
SEL selectorSwizz = @selector(extensionViewDidAppear:);
    
Method methodOriginal = class_getInstanceMethod(class, selectorOriginal);
Method methodSwizz = class_getInstanceMethod(class, selectorSwizz);

 /*
     Class class = object_getClass(self);
     
     SEL selectorOriginal = @selector(...);
     SEL selectorSwizz = @selector(...);
     
     Method methodOriginal = class_getClassMethod(class, selectorOriginal);
     Method methodSwizz = class_getClassMethod(class, selectorSwizz);
*/

Primeiro, resgatamos a classe do objeto no qual o método viewDidAppear: é implementado. Depois, guardamos os selectors e métodos de viewDidAppear: eextensionViewDidAppear:. As linhas comentadas acima mostram como fazer esse mesmo processo para métodos de classe. Feito isso, pegamos a implementação dos respectivos métodos:

MP implementationOriginal = method_getImplementation(methodOriginal);
IMP implementationSwizz = method_getImplementation(methodSwizz);

Agora, tentamos adicionar à classe UIViewController (class) um método com o nome de viewDidAppear: (selectorOriginal), mas com a implementação de extensionViewDidAppear:. O último parâmetro trata-se de um array de caracteres que correspondem aos tipos dos argumentos que serão passados para o método.

BOOL didAddMethod = class_addMethod(class,
                                    selectorOriginal,
                                    implementationSwizz,
                                    method_getTypeEncoding(methodSwizz));

Em caso de sucesso, o próximo passo será fazer com que a classe em questão adicione um método com o nome de extensionViewDidAppear:, mas com a implementação do método original. Caso contrário, significa que a classe já contém uma implementação com o dado nome. Nesse caso, é necessário apenas trocar suas implementações:

if (didAddMethod) {
        class_replaceMethod(class,
                            selectorSwizz,
                            implementationOriginal,
                            method_getTypeEncoding(methodOriginal));
        
} else {
        method_exchangeImplementations(methodOriginal, methodSwizz);
}

Agora, é preciso escrever o método extensionViewDidAppear: propriamente dito:

#pragma mark - viewDidAppear: extension

- (void)extensionViewDidAppear:(BOOL)animated {
    
    NSLog(@"The view of viewController %@ appeared", self);
    
    [self extensionViewDidAppear:animated];
}

Lembre-se de que após terminar a implementação é preciso chamar o método original. Isso será feito ao escrever [self extensionViewDidAppear:].

Para finalizar, é necessário chamar o método swizzlingViewDidAppear no método load, que por sua vez é chamado assim que a classe for inicializada:

#pragma mark - Load

+ (void)load {
    
    static dispatch_once_t onceToken;
    dispatch_once (&onceToken, ^{
        
        [self swizzlingViewDidAppear];
    });
}

Como o método Swizzling muda o estado global da classe, é preciso garantir que o código seja executado apenas uma vez, e por isso utilizamos dispatch_once.

Cuidados a serem tomados

Embora os conceitos acima ofereçam ferramentas poderosas, deve-se tomar o máximo de cautela antes de aplicá-los. Além de não ser necessário usá-los explicitamente a maior parte do tempo, não entender a fundo como o processo funciona, ou caso haja alguma modificação interna da linguagem, pode fazer com que o aplicativo quebre quando menos se espera. No caso do método Swizzling, há o problema de haver conflito com métodos com mesmo nome, além de tornar o código mais difícil de debugar. A dica final é: pense em todas as possibilidades antes de utilizar os exemplos citados acima.

Ficou alguma dúvida ou tem algum comentário a fazer? Aproveite os campos abaixo!