Category 的一些事

作者: Tsui_YuenHong | 来源:发表于2016-09-11 23:42 被阅读848次

    新增实践部分:偏方 Hook 进某些方法来添加功能

    Category - 简介

    Category(类别)是 Objective-C 2.0 添加的新特性(十年前的新特性 😆)。其作用可以扩展已有的类, 而不必通过子类化已有类,甚至也不必知道已有类的源码,还有就是分散代码,使已有类的体积大大减少,也利于分工合作。

    苹果开源项目中,我们可以下载相关的源码来查看 category 的资料。

    在 AFNetworking 和 SDWebImage 中也大量用到 category 来扩展已有类和分散代码。

    关于 category 的定义可以在 objc-runtime-new.h 中找到。由其定义可以看出 category 可以正常实现功能有:添加实例方法、类方法、协议、实例属性。( 在后面的实践中,发现类属性也是可以添加的 )

    struct category_t {
        const char *name;
        classref_t cls;
        struct method_list_t *instanceMethods;
        struct method_list_t *classMethods;
        struct protocol_list_t *protocols;
        struct property_list_t *instanceProperties;
    
        method_list_t *methodsForMeta(bool isMeta) {
            if (isMeta) return classMethods;
            else return instanceMethods;
        }
    
        property_list_t *propertiesForMeta(bool isMeta) {
            if (isMeta) return nil; // classProperties;
            else return instanceProperties;
        }
    };
    

    随便说一句,本文并不主要注重 category 的实现细节和工作原理。关于细节的方面可以看相关文章 深入理解Objective-C:Category结合 category 工作原理分析 OC2.0 中的 runtime


    Category - 能做什么

    首先,我们先来创建一个 Person 类以及 Person 类的 category,可以看得出 category 的文件名就是 已有类名+自定义名

    // Person.h
    @interface Person : NSObject
    
    @property (nonatomic, copy) NSString *name;
    
    + (void)run;
    - (void)talk;
    
    @end
    
    // Person.m
    @implementation Person
    
    // 原实例方法
    - (void)talk{
        NSLog(@"\n我是原实例方法\n我是%@",self.name);
    }
    
    // 原类方法
    + (void)run{
        NSLog(@"\n我是原类方法\n我是跑得很快的的香港记者");
    }
    
    @end
    
    // Person+OtherSkills.h
    @interface Person (OtherSkills){
        //⚠️ instance variables may not be placed in categories
        //int i;
        //NSString *str;
    }
    
    // 添加实例属性
    @property (nonatomic, copy) NSString *otherName;
    // 添加类属性
    @property (class, nonatomic, copy) NSString *clsStr;
    
    // 重写已有类方法
    + (void)run;
    - (void)talk;
    
    // 为已有类添加方法
    - (void)logInstProp;
    + (void)logClsProp;
    
    // Person+OtherSkills.m
    static NSString *_clsStr = nil;
    static NSString *_otherName = nil;
    
    @implementation Person (OtherSkills)
    
    @dynamic otherName;
    
    // 重写类方法
    + (void)run{
        // 警告⚠️ Category is implementing a method which will also be implemented by its primary class
        NSLog(@"\n我是重写方法\n我是跑得很快的的香港记者");
    }
    
    // 重写实例方法
    - (void)talk{
        // 警告⚠️ Category is implementing a method which will also be implemented by its primary class
        NSLog(@"\n我是重写方法\n我是会谈笑风生的%@",self.otherName);
    }
    
    // 输出实例属性
    - (void)logInstProp{
        NSLog(@"\n输出实例属性\n我是会谈笑风生的%@",self.otherName);
    }
    
    // 输出类属性
    + (void)logClsProp{
        NSLog(@"\n输出类属性\n我是会谈笑风生的%@",self.clsStr);
    }
    
    + (NSString *)clsStr{
        return _clsStr;
    }
    
    + (void)setClsStr:(NSString *)clsStr{
        _clsStr = clsStr;
    }
    
    - (NSString *)otherName{
        return _otherName;
    }
    
    - (void)setOtherName:(NSString *)otherName{
        _otherName = otherName;
    }
    

    创建完代码之后,下面我们来看看 category 到底能干什么。

    顺便一提,我是在网上看到很多文章说 category 不能添加属性,这是说法是不对的,如 Person+OtherSkills.h 中就添加了一个 otherName 的属性。正确的说法应该是 category 不能添加实例变量,否则编译器会报错 instance variables may not be placed in categories。正常情况下,因为 category 不能添加实例变量,也会导致属性的 setter & getter 方法不能正常工作。( 当然,可以利用 Runtimecategory 动态关联属性,最后会介绍两种使 category 属性正常工作的方法)

    category 可以为已有类添加实例属性。

    Person+OtherSkills.h 中就添加了一个 otherName 的属性。可以出来能正常工作。

    // 运行代码
    Person *p1 = [[Person alloc] init];
    
    // 实例属性
    p1.otherName = @"小花";
    [p1 logInstProp];
    
    p1.otherName = @"小明";
    [p1 logInstProp];
    
    // 输出结果
    2016-09-11 09:45:09.935 category[37281:1509791]
    输出实例属性
    我是会谈笑风生的小花
    2016-09-11 09:45:09.936 category[37281:1509791]
    输出实例属性
    我是会谈笑风生的小明
    

    category 可以为已有类添加类属性。

    虽然,category_t 中是没有定义 clssProperties,但是根据实际操作却显示 category 的确可以为已有类添加类属性并且成功执行。我个人觉得是部分源码没有更新或者隐藏了😁,如果有知道原因的同学可以说一下

    // 运行代码
    Person.clsStr = @"小东";
    [Person logClsProp];
    
    // 输出结果
    2016-09-11 09:45:09.936 category[37281:1509791]
    输出类属性
    我是会谈笑风生的小东
    

    category 可以为已有类添加实例方法和类方法。

    在上面的两个例子中已经体现了 category 可以为已有类添加实例方法和类方法。这里将讨论加入 category 重写了已有类的方法会怎么样,在创建的代码中我们已经重写了 runtalk 方法,那这时我们来调用看看。

    // 运行代码
    // 调用类方法
    [Person run];
    // 调用实例方法    
    Person *p1 = [[Person alloc] init];
    [p1 talk];
    
    // 输出结果
    2016-09-11 11:22:05.817 category[37733:1562534]
    我是重写方法
    我是跑得很快的的香港记者
    2016-09-11 11:22:05.817 category[37733:1562534]
    我是重写方法
    我是会谈笑风生的(null)
    

    可以看得出来,这时候无论是已有类中的类方法和实例方法都可以被 category 替换到其中的重写方法,即使我现在是没有导入 Person+OtherSkills.h 。这就带来一个很严重的问题,如果在 category 中不小心重写了已有类的方法将导致原方法无法正常执行。所以使用 category 添加方法时候请注意是否和已有类重名了,正如 《 Effective Objective-C 2.0 》 中的第 25 条所建议的:

    在给第三方类添加 category 时添加方法时记得加上你的专有前缀

    然而,因为 category 重写方法是并不是替换掉原方法,而是往已有类中继续添加方法,所以还是有机会去调用到原方法。这里利用 class_copyMethodList 获取 Person 类的全部类方法和实例方法。

    // 获取 Person 的方法列表
    unsigned int personMCount;
    // 获取实例方法
    //Method *personMList = class_copyMethodList([Person class], &personMCount);
    // 获取类方法
    Method *personMList = class_copyMethodList(object_getClass([Person class]), &personMCount);
    NSMutableArray *mArr = [NSMutableArray array];
    
    // 这里是倒序获取,所以 mArr 第一个方法对应的是 Person 类中最后一个方法
    for (int i = personMCount - 1; i >= 0; i--) {
    
       SEL sel = NULL;
       IMP imp = NULL;
    
       Method method = personMList[i];
       NSString *methodName = [NSString stringWithCString:sel_getName(method_getName(method))
                                                 encoding:NSUTF8StringEncoding];
       [mArr addObject:methodName];
    
       if ([@"run" isEqualToString:methodName]) {
           imp = method_getImplementation(method);
           sel = method_getName(method);
           ((void (*)(id, SEL))imp)(p1, sel); // 这里的 sel 有什么用呢 ?!
           //break;
       }
    }
    
    free(personMList);
    

    其中输出的类方法和实例方法分别如下,显示原方法的确可以被调用。
    不过我这里有个疑问,使用 imp 时第二个参数 sel 到底有什么用呢?

    2016-09-11 11:52:44.795 category[37893:1582677]
    我是原类方法
    我是跑得很快的的香港记者
    2016-09-11 11:52:44.796 category[37893:1582677]
    我是重写方法
    我是跑得很快的的香港记者
    2016-09-11 11:52:44.796 category[37893:1582677] (
        run, // 原方法
        run, // 重写方法
        "setClsStr:",
        logClsProp,
        clsStr
    )
    
    2016-09-11 11:54:14.545 category[37927:1584029]
    我是原实例方法
    我是(null)
    2016-09-11 11:54:14.545 category[37927:1584029]
    我是重写方法
    我是会谈笑风生的(null)
    2016-09-11 11:54:14.545 category[37927:1584029] (
        "setName:",
        name,
        ".cxx_destruct",
        "setOtherName:",
        logInstProp,
        tanxiaofengsheng,
        otherName,
        talk, //原方法
        talk  //重写方法
    

    category 可以为已有类添加协议。

    这里先添加一个新的 category,负责处理他谈笑风生的行为,和写个协议让他上电视。

    // Person+Delegate.h
    #import "Person.h"
    
    // 添加协议
    @protocol PersonDelegate <NSObject>
    
    - (void)showInTV;
    
    @end
    
    @interface Person (Delegate)
    
    // 添加 delegate
    @property (nonatomic, weak) id<PersonDelegate> delegate;
    
    - (void)tanxiaofengsheng;
    
    @end
    
    // Person+Delegate.m
    #import "Person+Delegate.h"
    #import <objc/runtime.h>
    
    @implementation Person (Delegate)
    
    - (id<PersonDelegate>)delegate{
        return objc_getAssociatedObject(self, @selector(delegate));
    }
    
    - (void)setDelegate:(id<PersonDelegate>)delegate{
        objc_setAssociatedObject(self, @selector(delegate), delegate, OBJC_ASSOCIATION_ASSIGN);
    }
    
    - (void)tanxiaofengsheng{
        for (int i = 0 ; i < 10; i ++) {
            NSLog(@"谈笑风生...");
        }
    
        // 谈笑风生完就要上电视了
        if ([self.delegate respondsToSelector:@selector(showInTV)]) {
            [self.delegate showInTV];
        }
    }
    
    @end
    

    在相应的代理里面添加 showInTV 的方法

    // 运行代码
    Person *p1 = [[Person alloc] init];
    p1.delegate = self;
    
    // 开始谈笑风生了
    [p1 tanxiaofengsheng];
    
    // ShowInTV 方法的实现
    - (void)showInTV{
        UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(100, 100, 150, 150)];
        imageView.image = [UIImage imageNamed:@"naive.jpg"];
        [self.view addSubview:imageView];
    }
    

    这样就利用 category 为已有类添加了协议。

    关于 category 的基本应用就介绍到这里了。下面就来分享一下 category 的实践中的使用。


    Category - 实践

    偏方:Hook 进某些方法来添加功能

    一般来说,为原方法添加功能都是利用 RuntimeMethod Swizzling。不过这里也有个奇淫技巧来实现同样的功能,例如我要在所有 VC- (void)viewDidLoad 里面打印一个句话,就可以用 category 重写已有类的方法,因为 category 重写方法不是通过替换原方法来实现的,而是在原方法列表又增添一个新的同名方法,这就创造了机会给我们重新调用原方法了。

    // 待 Hook 类
    // ViewController.m 
    // 待替换方法 无参
    - (void)viewDidLoad {
        [super viewDidLoad];
        [self testForHook:@"Hello World"];
        NSLog(@"执行原方法");
    }
    
    // 待替换方法 有参
    - (void)testForHook:(NSString *)str1{
        NSLog(@"%@",str1);
    }
    
    // category 实现方法
    // ViewController+HookOriginMethod.m 
    // category 重写原方法
    - (void)viewDidLoad {
        NSLog(@"HOOK SUCCESS! \n--%@-- DidLoad !",[self class]);
        IMP imp = [self getOriginMethod:@"viewDidLoad"];
        ((void (*)(id, SEL))imp)(self, @selector(viewDidLoad));
    }
    
    // category 重写原方法
    - (void)testForHook:(NSString *)str1{
        NSLog(@"HOOK SUCCESS \n--%s-- 执行",_cmd);
        IMP imp = [self getOriginMethod:@"testForHook:"];
        ((void (*)(id, SEL, ...))imp)(self, @selector(testForHook:), str1);
    }
    
    // 获取原方法的 IMP
    - (IMP)getOriginMethod:(NSString *)originMethod{
        // 获取 Person 的方法列表
        unsigned int methodCount;
        // 获取实例方法
        Method *VCMethodList = class_copyMethodList([self class], &methodCount);
    
        IMP imp = NULL;
        
        // 这里是倒序获取,所以 mArr 第一个方法对应的是 Person 类中最后一个方法
        for (int i = methodCount - 1; i >= 0; i--) {
    
            Method method = VCMethodList[i];
            NSString *methodName = [NSString stringWithCString:sel_getName(method_getName(method))
                                                      encoding:NSUTF8StringEncoding];
    
            if ([originMethod isEqualToString:methodName]) {
                imp = method_getImplementation(method);
                break;
            }
        }
        
        free(VCMethodList);
        return imp;
    }
    
    // 执行代码
    // ViewController.m
    - (void)viewDidLoad {
        [super viewDidLoad];
        [self testForHook:@"Hello World"];
        NSLog(@"执行原方法");
    }
    
    // 输出结果
    2016-09-12 23:00:15.887 category[63655:2375379] HOOK SUCCESS! 
    --ViewController-- DidLoad !
    2016-09-12 23:00:15.888 category[63655:2375379] HOOK SUCCESS 
    --testForHook:-- 执行
    2016-09-12 23:00:15.889 category[63655:2375379] Hello World
    2016-09-12 23:00:15.889 category[63655:2375379] 执行原方法
    

    查看输出结果,可以看得出来我们的 Hook 掉 viewDidLoad 来实现打印成功了。


    UIButton 实现点击事件可以“传参”。

    一般创建UIButton的时候都会使用 addTarget ...这个方法来为button添加点击事件,不过这个方法有个不好的地方就是无法传自己想要的参数。例如下面代码中声明了str,我的意图是点击button就使控制台或者屏幕显示str的内容。如果按照这样来写的我想到的解决办法就是将str设置为属性或者成员变量,不过这样都是比较麻烦而且不直观的(代码分散)。

    NSString *str = @"hi";
    UIButton *button = [[UIButton alloc] initWithFrame:CGRectMake(100, 250, 150, 100)];
    button.backgroundColor = [UIColor redColor];
    [button addTarget:self action:@selector(click:) forControlEvents:UIControlEventTouchDown];
    [self.view addSubview:button];
    
    // 点击事件
    - (void)click:(UIButton *)button{
        ...    
    }
    

    我想到较好的解决办法应该在创建button,就为它设置具体的点击响应事件。实现方法就是为 UIButton 添加 block 属性或者添加可传入 block 的方法。具体代码如下:

    // UIButton+Category.h
    #import <UIKit/UIKit.h>
    
    typedef void(^ActionHandlerBlock)(void);
    
    @interface UIButton (Category)
    
    // 点击响应的 block
    @property (nonatomic, copy) ActionHandlerBlock actionHandlerBlock;
    
    // 设置 UIButton 的点击事件
    - (void)kk_addActionHandler: (ActionHandlerBlock )actionHandlerBlock ForControlEvents:(UIControlEvents )controlEvents;
    
    @end
    
    // UIButton+Category.m
    #import "UIButton+Category.h"
    #import <objc/runtime.h>
    
    static const void *kk_actionHandlerBlock = &kk_actionHandlerBlock;
    
    @implementation UIButton (Category)
    
    - (void)kk_addActionHandler:(ActionHandlerBlock)actionHandler ForControlEvents:(UIControlEvents)controlEvents{
    
        // 关联 actionHandler
        objc_setAssociatedObject(self, kk_actionHandlerBlock, actionHandler, OBJC_ASSOCIATION_COPY_NONATOMIC);
    
        // 设置点击事件
        [self addTarget:self action:@selector(handleAction) forControlEvents:controlEvents];
    }
    
    // 处理点击事件
    - (void)handleAction{
    
        ActionHandlerBlock actionHandlerBlock = objc_getAssociatedObject(self, kk_actionHandlerBlock);
    
        if (actionHandlerBlock) {
            actionHandlerBlock();
        }
    }
    
    - (ActionHandlerBlock)actionHandlerBlock{
        return objc_getAssociatedObject(self, @selector(actionHandlerBlock));
    }
    
    - (void)setActionHandlerBlock:(ActionHandlerBlock)actionHandlerBlock{
        objc_setAssociatedObject(self, @selector(actionHandlerBlock), actionHandlerBlock, OBJC_ASSOCIATION_COPY_NONATOMIC);
    }
    
    @end
    

    那现在我们来看看调用的结果,例如我现在想要的点击事件是 button 颜色随机变换。

    
    UIButton *button = [[UIButton alloc] initWithFrame:CGRectMake(100, 250, 150, 100)];
    button.backgroundColor = [UIColor redColor];
    [self.view addSubview:button];
    
    // 1. 通过实例方法传入 block 来修改  
    UIButton *button2 = [[UIButton alloc] initWithFrame:CGRectMake(100, 400, 150, 100)];
    button2.backgroundColor = [UIColor redColor];
    [button2 kk_addActionHandler:^{
       button.backgroundColor = [UIColor colorWithRed:arc4random_uniform(256) / 255.0 green:arc4random_uniform(256) / 255.0 blue:arc4random_uniform(256) / 255.0 alpha:1.0];
    } ForControlEvents:UIControlEventTouchDown];
    [self.view addSubview:button2];
    
    // 2. 通过修改 block 属性来修改
    UIButton *button3 = [[UIButton alloc] initWithFrame:CGRectMake(100, 550, 150, 100)];
    button3.backgroundColor = [UIColor redColor];
    button3.actionHandlerBlock = ^{
       button.backgroundColor = [UIColor colorWithRed:arc4random_uniform(256) / 255.0 green:arc4random_uniform(256) / 255.0 blue:arc4random_uniform(256) / 255.0 alpha:1.0];
    };
    [button3 addTarget:self action:@selector(click:) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:button3];
    
    
    // 响应事件
    - (void)click:(UIButton *)button{
        if (button.actionHandlerBlock) {
            button.actionHandlerBlock();
        }
    }
    
    实现效果图

    显然,方法1和方法2在这个例子中实现的效果是相同的。不过,在不同场合这两个方法适用的范围也不同。

    1. 直接调用实例方法传入 block 会使代码更加简洁和集中,但不适合 block 需要传值的情景。
    2. 相反,设置 block 属性要在 @selector() 中的方法中调用 block,比较麻烦,不过在需要的情况下可以传入合适的参数。

    p.s. 以后会继续补充实践部分。

    最后说一下,两种使 category 属性正常工作的方法:

    1. 因为 category 不能创建实例变量,那就直接使用静态变量,如最开始为 ohterNameclsStr 属性设置 setter & getter的做法。

    2. 使用objc_setAssociatedObject,其中 key 的选择有以下几种,个人比较喜欢第四种。

      • static char *key1; // SDWebImage & AFNetworking 中的做法,比较简单,而且 &key1 肯定唯一。key 取 &key1
      • static const char * const key2 = "key2"; // 网上看到的做法,指针不可变,指向内容不可变,但是这种情况必须在赋值确保 key2 指向内容的值是唯一。key 取 key2。
      • static const void *key3 = &key3; // 最取巧的方法,指向自己是为了不创建额外空间,而 const 修饰可以确保无法修改 key3 指向的内容。key 取 key3。
      • key 取 @selector(属性名),最方便,输入有提示,只要你确保属性名添加上合适的前缀就不会出问题。

    相关文章

      网友评论

        本文标题:Category 的一些事

        本文链接:https://www.haomeiwen.com/subject/fgfpettx.html