使用 Delegate 和 NSNotification 需要注意的几个坑 ——记 iOS 两次线上严重 Bug

这周应用上线 App Store 之后崩溃数字花花的涨,惨不忍睹。App 新增加 IM 功能,用的第三方环信的 IM SDK。最终发现的两个严重 Bug 都是在改动环信的 UI 代码时引入的,改动第三方代码最容易引发 bug。由于 Bug 只出现在 iOS 8 系统,而开发和测试都使用 iOS 9,所以问题一直没被发现。

Delegate

第一个 bug 在打开聊天窗口时偶尔会触发,问题出现在[EaseMessageViewController didBecomeActive]方法中的UITableView reloadData这一行代码上,在 iOS 9 没有崩溃问题。拿了 iOS 8 系统的机子,打断点,不是每次打开聊天窗口都会崩溃,得反复点开关闭窗口,几次之后就崩溃了,崩溃时 UITableView 竟然为空,对象被释放了!为什么在 didBecomeActive 时释放对象了,聊天窗口还在,为什么 UITableView 被释放了?而其实窗口对象也被释放了,所有的属性华丽丽地都变成 nil。

#pragma mark - notification
- (void)didBecomeActive
{
    self.dataArray = [[self formatMessages:self.messsagesSource] mutableCopy];
    [self.tableView reloadData];
    
    //回到前台时
    if (self.isViewDidAppear)
    {
        NSMutableArray *unreadMessages = [NSMutableArray array];
        for (EMMessage *message in self.messsagesSource)
        {
            if ([self _shouldSendHasReadAckForMessage:message read:NO])
            {
                [unreadMessages addObject:message];
            }
        }
        if ([unreadMessages count])
        {
            [self _sendHasReadResponseForMessages:unreadMessages isRead:YES];
        }
        
        [_conversation markAllMessagesAsRead:YES];
    }
}

于是在dealloc里打日志,在退出窗口的时候,dealloc并没有被调用,也就是EaseMessageViewController并没有被释放。而每次didBecomeActive的时候,就释放了。什么鬼?到底是什么把EaseMessageViewController纠缠住了,在窗口退出时对象没被释放?

如果把[UITableView reloadData]注释掉,对象就不会被释放。也就是说 bug 出现这这,重新加载UITableView时清空了什么?问题大概出现在 dataSource 里,cellForRowAtIndexPath这里面应该有对象没被释放。代码如下:

- (UITableViewCell *)messageViewController:(UITableView *)tableView cellForMessageModel:(id)model
{
    if (model.bodyType == eMessageBodyType_Text) {
        if (model.xMessageType == MessageBodyType_ImageText) {
            NSString *cellIdentifier = @"MessageBodyType_ImageText";
            ServiceTableViewCell *cell = (ServiceTableViewCell *)[tableView dequeueReusableCellWithIdentifier:cellIdentifier];
            if (!cell) {
                cell = [[ServiceTableViewCell alloc] init];
                cell.delegate = self;
            }
            cell.model = model;
            
            return cell;
        } 

        NSString *CellIdentifier = [EaseBaseMessageCell cellIdentifierWithModel:model];
        //发送cell
        EaseBaseMessageCell *sendCell = (EaseBaseMessageCell *)[tableView dequeueReusableCellWithIdentifier:CellIdentifier];
        
        // Configure the cell...
        if (sendCell == nil) {
            sendCell = [[EaseBaseMessageCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier model:model];
            sendCell.selectionStyle = UITableViewCellSelectionStyleNone;
        }
        sendCell.model = model;
//        DLog(@"%@",model.text);
        return sendCell;
    }
    return nil;
}

ServiceTableViewCell是我添加的,先把自己改动的东西注释掉,只留EaseBaseMessageCell,然后一切正常了,退出窗口时dealloc会被吊用。原来都是ServiceTableViewCell惹的祸,为什么它没被释放,delegate?EaseBaseMessageCell 也有指定 delegate,但它正常.

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
 
    if (_delegate && [_delegate respondsToSelector:@selector(messageViewController:cellForMessageModel:)]) {
        UITableViewCell *cell = [_delegate messageViewController:tableView cellForMessageModel:model];
        if (cell) {
            if ([cell isKindOfClass:[EaseMessageCell class]]) {
                EaseMessageCell *emcell= (EaseMessageCell*)cell;
                if (emcell.delegate == nil) {
                    emcell.delegate = self;
                }
            }
            return cell;
        }
    }
}

对比两个 Cell 的 delegate 声明:

@property (strong, nonatomic) id delegate;
@property (weak, nonatomic) id delegate;

Bug 终于浮出水面,delegate 被声明为 strong,导致强引用,ServiceTableViewCell 和 UITableView 循环引用,导致窗口退出时,UITableView 无法被释放。而在 didBecomeActive中调用 [self.tableView reloadData];,释放了 cell,UITableView 也随之释放,EaseMessageViewController 对象也就被释放了。

但不理解的是,为什么这个问题只在 iOS 8 出现?释放的对象是之前打开并退出的窗口,当前窗口是一个新的对象,为什么当前对象被释放了?这是一个问题。

NSNotification

第二个 Bug 是出现大量类似, [UITableViewCellContentView chatKeyboardWillChangeFrame:] unrecognized selector 的崩溃信息,每次被调用的 UI 类还都不一样,有UILabel __NSCFString UIWebSelectionAssistant _CUIThemePixelRendition…… 问题很怪异,为什么这么多的对象都会调用chatKeyboardWillChangeFrame:这个方法?

点击每个 bug 查看 issue 详情,都有这句-[NSNotificationCenter postNotificationName:object:userInfo:]。起初怀疑chatKeyboardWillChangeFrame方法有问题。搜索代码,只有聊天窗口的自定义的 ToolBar 文件里有这个方法的实现,这个方法只有在键盘发送变化发起通知的时候才会被调用。只在这个文件里才会发送通知去调用这个方法,为什么 UITableViewCell 和 UILabel 也会去调用这个方法?对比环信的自定义 ToolBar 代码文件,才发现通知没有被 remove。不知当初出于什么原因把dealloc代码给删了,随之删除的还有removeObserver。只有用户退出聊天窗口再进来,由于通知观察者没被删除,导致每次键盘变化,就有超过 1 个通知都会被发送,系统可能随意指给了一个其他的对象,没有实现这个方法的对象,然后就 Crash 了,因为unrecognized selector

解决方法,在dealloc中添加removeObserver

- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillChangeFrameNotification object:nil];
    
}

NSKeyedUnarchiver 也有一个坑

以下代码在 iOS 8 可能会崩溃。

 NSString *abslutePath = [NSString stringWithFormat:@"%@/%@.plist", [self pathInCacheDirectory:kPathResponseCache], [requestPath md5]];
    NSData *data = [NSData dataWithContentsOfFile:abslutePath];
    NSDictionary *jsonObject = [NSKeyedUnarchiver unarchiveObjectWithData:data];
    return jsonObject;// [NSMutableDictionary dictionaryWithContentsOfFile:abslutePath];

崩溃信息:

NSInvalidArgumentException(SIGABRT)
*** -[NSKeyedUnarchiver initForReadingWithData:]: incomprehensible archive version (-1)

查看苹果的开发文档

This method raises an NSInvalidArgumentException if the file at path does not contain a valid archive.

如果 data 存储不使用[NSKeyedArchiver archivedDataWithRootObject:data],那么调用NSKeyedUnarchiver就会触发这个异常导致程序崩溃。这个异常在 iOS 9 并不会触发,只会返回 nil。解决方法:

 NSData *data = [NSData dataWithContentsOfFile:abslutePath];
    NSDictionary *jsonObject;// = [NSKeyedUnarchiver unarchiveObjectWithData:data];
    @try {
        jsonObject = [NSKeyedUnarchiver unarchiveObjectWithData:data];
    } @catch ( NSException *ex ) {
        //do whatever you need to in case of a crash
        [[NSFileManager defaultManager] removeItemAtPath:abslutePath error:nil];
    }

在代码中出现这个问题是由于缓存的问题,最开始时并没有使用archivedDataWithRootObject:,版本更新时没有先去清理老版本的缓存,导致调用NSKeyedUnarchiver时崩溃。更新版本要记得检查数据版本的一致性。

总结

  1. Delegate要用声明为weak
  2. 每一个NSNotification都记得加removeObserver
  3. unarchiveObjectWithData: 加异常处理@try { } @catch ( NSException *ex ){}
  4. 版本更新时检查数据版本。

上线前要在每个支持的系统版本测试通过。如果在提交前有检查代码内存是否有泄漏,可能就会发现第一个 Bug 了,学习用工具 Instruments。经验不足更要小心谨慎,思考周全,测试全面,这种 Bug 一次就够了,一定要长点记性!

你可能感兴趣的:(使用 Delegate 和 NSNotification 需要注意的几个坑 ——记 iOS 两次线上严重 Bug)