这周应用上线 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
时崩溃。更新版本要记得检查数据版本的一致性。
总结
-
Delegate
要用声明为weak
- 每一个
NSNotification
都记得加removeObserver
-
unarchiveObjectWithData:
加异常处理@try { } @catch ( NSException *ex ){}
- 版本更新时检查数据版本。
上线前要在每个支持的系统版本测试通过。如果在提交前有检查代码内存是否有泄漏,可能就会发现第一个 Bug 了,学习用工具 Instruments。经验不足更要小心谨慎,思考周全,测试全面,这种 Bug 一次就够了,一定要长点记性!