一次 category 的误用引发的 crash

2,650 阅读5分钟

原文地址

在最近的一次开发中,不小心在自定义的 UIViewController 的 category 中重写了 dealloc 方法,导致项目中莫名出现了许多野指针的 crash,虽然重写 dealloc 方法会引发一些不确定的行为,但是为什么会引发 crash 呢?带着疑问又重新温习了下 category 的源码。

Category 的底层实现

可以在 objc4 源码的 objc-runtime-new.m 文件中看到它的实现,如下:

typedef struct category_t *Category;

struct category_t {
    const char *name; // Category 的名字
    classref_t cls; // 要扩展的类
    struct method_list_t *instanceMethods; // category的实例方法列表
    struct method_list_t *classMethods; // category类方法列表
    struct protocol_list_t *protocols; // category的协议列表
    struct property_list_t *instanceProperties; // category的属性列表

    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 其实就是一个 category_t 类型的结构体,它维护要扩展类和分类的相关信息。

扩展:从源码中可以看出,分类的结构体中并没有成员变量的存储方式,这就解释了为什么在分类中无法添加
成员变量了。
此外还需要注意的是,虽然分类中会提供了属性列表的的存储方式,但它并不会帮我们自动生成成员变量,它
只会生成setter getter方法的声明,具体还需要我们自己去实现。

category 是如何加载的

OC 的 runtime 是通过 dyld 动态加载的,而 _objc_init() 方法是 runtime 被加载后第一个执行的方法。我们从_objc_init()开始来追溯 category 的加载过程。

首先看下 _objc_init() 的实现,如下:

void _objc_init(void)
{
    static bool initialized = false;
    if (initialized) return;
    initialized = true;
    
    // fixme defer initialization until an objc-using image is found?
    environ_init();
    tls_init();
    static_init();
    lock_init();
    exception_init();
        
    // Register for unmap first, in case some +load unmaps something
    _dyld_register_func_for_remove_image(&unmap_image);
    dyld_register_image_state_change_handler(dyld_image_state_bound,
                                             1/*batch*/, &map_2_images);
    dyld_register_image_state_change_handler(dyld_image_state_dependents_initialized, 0/*not batch*/, &load_images);
}

我们看下 dyld_register_image_state_change_handler(dyld_image_state_bound, 1/*batch*/, &map_2_images),这里注册了一个回调,当 dyld_image 状态为 dyld_image_state_bound 时,触发map_2_images用来将 image Map到内存 ,其实现如下:

const char *
map_2_images(enum dyld_image_states state, uint32_t infoCount,
             const struct dyld_image_info infoList[])
{
    rwlock_writer_t lock(runtimeLock);
    return map_images_nolock(state, infoCount, infoList);
}

这里会调用 map_images_nolock 方法,map_images_nolock 的源码很多,我们就不在这里列出来了,感兴趣的同学可以自己去查阅源码。在 map_images_nolock 的实现中,我们会发现一个重要函数_read_images,它用来初始化 Map 后的 image。

继续查看 _read_images 的源码,在 Discover categories 的代码段中,会调用一个关键函数 remethodizeClass,其实现如下:

static void remethodizeClass(Class cls)
{
    category_list *cats;
    bool isMeta;

    runtimeLock.assertWriting();

    isMeta = cls->isMetaClass();

    // Re-methodizing: check for more categories
    if ((cats = unattachedCategoriesForClass(cls, false/*not realizing*/))) {
        if (PrintConnecting) {
            _objc_inform("CLASS: attaching categories to class '%s' %s", 
                         cls->nameForLogging(), isMeta ? "(meta)" : "");
        }
        
        attachCategories(cls, cats, true /*flush caches*/);        
        free(cats);
    }
}

这里会调用 attachCategories 函数将类别中的方法、属性、协议附加到类上,源码如下:

static void 
attachCategories(Class cls, category_list *cats, bool flush_caches)
{
    if (!cats) return;
    if (PrintReplacedMethods) printReplacements(cls, cats);

    bool isMeta = cls->isMetaClass();

    // fixme rearrange to remove these intermediate allocations
    method_list_t **mlists = (method_list_t **)
        malloc(cats->count * sizeof(*mlists));
    property_list_t **proplists = (property_list_t **)
        malloc(cats->count * sizeof(*proplists));
    protocol_list_t **protolists = (protocol_list_t **)
        malloc(cats->count * sizeof(*protolists));

    // Count backwards through cats to get newest categories first
    int mcount = 0;
    int propcount = 0;
    int protocount = 0;
    int i = cats->count;
    bool fromBundle = NO;
    while (i--) {
        auto& entry = cats->list[i];

        method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
        if (mlist) {
            mlists[mcount++] = mlist;
            fromBundle |= entry.hi->isBundle();
        }

        property_list_t *proplist = entry.cat->propertiesForMeta(isMeta);
        if (proplist) {
            proplists[propcount++] = proplist;
        }

        protocol_list_t *protolist = entry.cat->protocols;
        if (protolist) {
            protolists[protocount++] = protolist;
        }
    }

    auto rw = cls->data();

    prepareMethodLists(cls, mlists, mcount, NO, fromBundle);
    rw->methods.attachLists(mlists, mcount);
    free(mlists);
    if (flush_caches  &&  mcount > 0) flushCaches(cls);

    rw->properties.attachLists(proplists, propcount);
    free(proplists);

    rw->protocols.attachLists(protolists, protocount);
    free(protolists);
}

attachCategories 函数中,首先会进行一些内存分配的工作,然后获取分类的方法、属性和协议,并放到指定的数组中,最后调用 attachLists 方法将分类和原类中的方法、属性、和协议进行了合并。看下 attachLists 函数的实现:

void attachLists(List* const * addedLists, uint32_t addedCount) {
        if (addedCount == 0) return;

        if (hasArray()) {
            // many lists -> many lists
            uint32_t oldCount = array()->count;
            uint32_t newCount = oldCount + addedCount;
            setArray((array_t *)realloc(array(), array_t::byteSize(newCount)));
            array()->count = newCount;
            memmove(array()->lists + addedCount, array()->lists, 
                    oldCount * sizeof(array()->lists[0]));
            memcpy(array()->lists, addedLists, 
                   addedCount * sizeof(array()->lists[0]));
        }
        else if (!list  &&  addedCount == 1) {
            // 0 lists -> 1 list
            list = addedLists[0];
        } 
        else {
            // 1 list -> many lists
            List* oldList = list;
            uint32_t oldCount = oldList ? 1 : 0;
            uint32_t newCount = oldCount + addedCount;
            setArray((array_t *)malloc(array_t::byteSize(newCount)));
            array()->count = newCount;
            if (oldList) array()->lists[addedCount] = oldList;
            memcpy(array()->lists, addedLists, 
                   addedCount * sizeof(array()->lists[0]));
        }
    }

这个函数中,通过 memmove 和 memcpy 的操作,将分类的方法、属性、协议列表放入了类对象中原本存储的方法、属性、协议列表的前面

为什么会出现 crash

我们通过一个例子来复现下文章开头提到的 crash , 代码如下:

// 在 UIViewController+Test.m 的类别中重写 dealloc 方法
- (void)dealloc {

}

// TestAViewController.m
@interface TestAViewController ()

@property (nonatomic, strong) TestBViewController *bViewController;

@end

@implementation TestAViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    self.view.backgroundColor = UIColor.whiteColor;
    _bViewController = [TestBViewController new];
    __weak typeof (self) weakSelf = self;
    _bViewController.willDismiss = ^{
        __strong typeof(weakSelf) strongSelf = weakSelf;
        [strongSelf.navigationController popViewControllerAnimated:YES];
    };
    
    [self setDefinesPresentationContext:YES];
    [self.bViewController setModalPresentationStyle:UIModalPresentationCurrentContext];
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    if (!self.isFirst) {
        [self presentViewController:self.bViewController animated:YES completion:nil];
    }

    self.isFirst = YES;
};

// TestBViewController.m 中有个关闭按钮 点击执行下面的方法
- (void)onDismiss {
    if (self.willDismiss) {
        self.willDismiss();
    }
    
    
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
        if (self.presentingViewController) {
            [self dismissViewControllerAnimated:YES completion:^{                            }];
        }
    });
}

运行程序,点击 TestBViewController 中的关闭按钮,出现如下 crash:

*** -[TestAViewController retain]: message sent to deallocated instance 0x7fba53d161d0

从这条日志可以看出,是因为访问了已经释放的对象地址,导致的 crash.

分析:因为在类别中重写了 dealloc 方法,会导致 UIViewController 本身的 dealloc 方法不会执行,这样就不会释放 dealloc 中的成员变量,指向这些成员变量的指针会变成野指针,如果通过野指针不小心访问了已经释放的 UIViewController 对象的地址,就会出现上面的 crash.

总结

虽然 category 为我们提供了许多便利,但是我们在使用时也有多加小心,以免掉入陷阱,下面用苹果官网的一段话作为结束:

Avoid Category Method Name Clashes
Because the methods declared in a category are added to an existing class, 
you need to be very careful about method names.

If the name of a method declared in a category is the same as a method in the 
original class, or a method in another category on the same class (or even a 
superclass), the behavior is undefined as to which method implementation is used 
at runtime. This is less likely to be an issue if you’re using categories with 
your own classes, but can cause problems when using categories to add methods to 
standard Cocoa or Cocoa Touch classes.

----扫一扫关注公众号,get更多技术好文-----