让人恐惧的属性关键字

一半的 App 都会有一个个人这个模块来对个人信息,设置等功能进行管理,最近搞公司的项目,在项目结束的时候就想到了应该给我的这个界面加上 “headerimage scale” 这样的效果。然后项目已经结束,也并不想大刀阔斧的去修改原来的代码,想到之前在微博看到一个这样的库。时间还是比较紧急的,毕竟周末,然后就把代码下下来,简单的跑了一下,没有发现什么问题之后就直接在项目中用了。

由于公司一直缺乏很系统的兼容测试,只是对功能有比较严格的要求,所以接下来到上线这个过程都很愉快。 UI、产品都比较满意这样搞。

上线之后,就发生了一些悲剧的事情了….

UIScrollView + headerImageScale 实现原理

不得不说,这个category也是一个非常好的东西,帮助我们轻松的集成tableview上面的头部缩放图片,且无侵入性。

实现原理其实看起来也比较见到,使用到了method swizzling来拦截到tableview的头部试图,然后通过KVO来监听scrollview的偏移量。然后修改imageView的frame实现头部试图的缩放。

当然既然使用到了KVO很自然的就要想到在dealloc方法里面需要去移除observer我也很愉快的在源码里面找到了这个方法。这就是坑所在

1
2
3
4
5
6
7
8
9
- (void)dealloc
{
if (self.yz_isInitial) { // 初始化过,就表示有监听contentOffset属性,才需要移除
[self removeObserver:self forKeyPath:YZKeyPath(self, contentOffset)];
}
}

可能说的不好,但是大概实现思路,也跟我之前自己写的差不多,就是在原来的header位置上面放一个UIimageView,通过监听scrollview的偏移量来改变UIimageView的frame.

然后我们接着讲,由于公司缺乏系统的兼容性测试,在上线前使用模拟器跟UI对应调整了一下试图,以及针对小屏幕手机做了相应了屏幕适配之后。项目也很顺利的上线了,虽然从我点发布,到我在app store上面搜到这期间相差了6七个小时,感觉这个周期比往常慢了很多。

原以为提前了半个月完成了项目,然后经过了十分充分的测试,自己也针对极端网络条件下做了很多测试。这个版本应该不会收集到太多的线上crash。

然后很不幸的是我在线上日志收集日志中看到了很怪异的问题,线上crash一直都非常高。

这是我通过bugly收集到的相关日志, 仔细一看,所有crash都发生在iOS8上面。一共三条日志,全部发生在iOS8 上面。

这让我意识到这个问题一定是这次更新导致了严重的不兼容iOS8的问题。

DEBUG过程

可能也注意到了,上面有一条ipad的日志,虽然app并没有兼容apad,但是我也用家里的ipad试了一下,进入app,到登录界面然后就crash,在打了全局断点的情况下,crash到了maim.m这个文件里面,bad_access野指针。然后我想了半天也没有找到什么情况下能这么快的出现crash。然后我把目标放在了JPush 极光推送上面。经历了一段时间之后,我仔细检查了极光推送所有的地方,并且尝试将所有代码注释的方法,crash依旧。

后来我开始依赖于google,最终在唐巧巧大的博客里找到了解决方法。

在appdelegate中定义一个方法。

1
2
3
4
5
void uncaughtExceptionHandler(NSException *exception) {
NSLog(@"CRASH: %@", exception);
NSLog(@"Stack Trace: %@", [exception callStackSymbols]);
// Internal error reporting
}

在应用启动的时候,将这个方法作为异常的回调

1
2
3
4
5
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
NSSetUncaughtExceptionHandler(&uncaughtExceptionHandler);
// Normal launch stuff
}

然后终于能看到日志了。

在另外一次crash中我看到了

1
[UIScrollView _systemGestureStateChanged:]: message sent to deallocated instance

这样的句子,才明白,crash并不是JPush造成的。UIScrollView,此时我并没有想到什么东西,因为在这个使用到了ScrollView的场景实在不多,然后我通过修改代码,绕开使用了UIScrollViewWelcomeViewController之后发现登录界面正常,不绕开,无法进入登录界面。然后登录界面登录进去之后依然crash

因为我的登录界面

这个控件使用了Scrollview。

然后在登录成功最后,几乎无法使用,log全是差不多的情况,这个时候我才开始吧注意力放在相关的类别里面。

然后我又打开了作者的简书

在评论中看到这样一句

1.发现app中任何的uitableview被释放之后,再点击屏幕就会crash,报
[UITableView _systemGestureStateChanged:]: message sent to deallocated instance 0x17385200
2.后来发现任何的collectionview被释放之后,点击屏幕也会crash,报
[UICollectionView _systemGestureStateChanged:]: message sent to deallocated instance 0x17385200
3.把目光放在UIScrollView上,查找UIScrollView的分类,是否有问题
4.找到 UIScrollView+HeaderScaleImage.h 注释了其中一部分代码,并把所有的引用都注释掉,依然有这个问题。
5.开始将目光放在_systemGestureStateChanged上,还有僵尸对象上,用instrument调试,依然无解。
6.六个小时过去了。
7.睡了一觉,然后又把目光放在UIScrollView上,将UIScrollView+HeaderScaleImage.h 中的代码全部注释掉了。
8.正常。感觉世界都亮了。
9.一步一步注释,排除,最后发现这个里面重写了uiscrollview的dealloc方法,导致scrollview在释放的时候无法清除自己的观察者身份,导致系统发通知的时候仍然能发到它身上。ios 9 没问题,ios8上就会直接crash。
10.将最后那个dealloc中的代码另外立出来,在controller的dealloc中主动调用这个方法,整个app没几个页面用这个,所以也并没有增加太多的工作量。

照着上面的方法修改之后,果然,问题得到解决。

分析原因

如果还记得本文的标题,大概也能想到了,在iOS9以前的系统上delegate的属性关键字是assign,这一点不用查看什么文档,只需要在百度里面搜索一下 delegate assign 2014 就可以得到验证。

在那个时代的delegate都是使用的assign作为delegate的属性关键字。

我们再看看52个方法中对几个属性关键字的描述

  1. assign “设置方法” 只会执行针对“纯量关系”的简单负值操作。
  2. strong 此特质表明该属性定义了一种“拥有关系”,为这种属性设置新值时,设置方法会先保留新值,并释放旧值然后讲新值设置上去。
  3. weak 此特质表明该属性定义了一种“非拥有关系”,为这种属性设置新值时,设置方法既不保留新值,也不释放旧值,此特征同assign类似,然而在属性所指的对象遭到摧毁时,属性指也会清空(nil out)
  4. unsafe_unretained 此特质的语义和assign相同,但是它适用于“对象类型”,该特质表达一种“非拥有关系”,当目标对象遭到摧毁时,属性值不会自动清空。
  5. copy 此特质与strong类似,然而设置方法并不保留新值而是将其拷贝一份。

这时候我们看到无论是使用assign 还是weak 甚至是unsafe_unretained都不会造成循环引用的问题。

assign实际上是指针覆值,不对引用计数操作,使用之后如果没有置为nil,可能就会产生野指针;而weak一旦不进行使用后,会有一个置nil的操作。

猜测

这就说明在iOS8下虽然系统在代理中都使用的assign属性关键字,但是在dealloc方法中使用了如self.delegate = nil这样的代码,将对象置为nil.

然后我我们在UISCrollView的分类中重写了他的dealloc方法,导致这一过程消失。所以造成野指针。

解决方案

由于app已经上线,所以最初的思路还是使用hotfix来解决这个问题,但是经过一番努力之后,发现即使是使用了JSPatch也没有办法来解决这个问题,因为UIScrollview的子类涉及的太多了。我们也没有办法,通过patch的方法,还原系统的dealloc方法。所以我也只能先注释掉category中的dealloc方法,然后在使用了这个类别的我的界面中,手动的移除observer。

证明猜测

既然结论都是猜测出来了,我也简单的写一个小demo来验证一下刚才的猜测吧!

首先定义了一个Dog类

1
2
3
4
5
6
7
8
9
10
11
12
13
#import <Foundation/Foundation.h>
@class Cat;
@protocol DogDelegate <NSObject>
- (void)fuck;
@end
@interface Dog : NSObject
@property (nonatomic ,assign) Cat <DogDelegate> *delegate;
-(void)run;
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#import "Dog.h"
#import "Cat.h"
@implementation Dog
- (void)setDelegate:(Cat<DogDelegate> *)delegate {
_delegate = delegate;
[delegate addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil];
}
//- (void)dealloc {
// [self.delegate removeObserver:self forKeyPath:@"name"];
//}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
}
@end

然后定义个一个Cat类

1
2
3
4
5
#import <Foundation/Foundation.h>
@interface Cat : NSObject
@property (nonatomic ,strong) NSString *name;
@end
1
2
3
4
5
6
7
8
#import "Cat.h"
#import "Dog.h"
@interface Cat ()<DogDelegate>
@end
@implementation Cat
@end

最后调用一下

1
2
3
4
5
6
Dog *dog = [[Dog alloc] init];
Cat *cat = [[Cat alloc] init];
dog.delegate = cat;
cat.name = @"nacy";
dog = nil;
cat.name = @"lily";

果然如果注释掉Dog的dealloc方法,就会造成EXC_BAD_ACCESScrash。

结论

  1. 千万不要在 category 中覆盖掉原来类中的方法。不然不知道有多少坑等着你。
  2. 在 iOS8 下 delegate使用的是 assign 属性关键字,这是MRC时代的问题。
  3. 这个问题确实是由于覆盖了dealloc方法,导致原来dealloc中清理掉原来对象中的delegate相关代码无法执行。这时候再有observer就会造成野指针。
  4. 在使用各种属性关键字的时候,一定要想好。
  5. 不要以为新的东西出来了就忘掉了以前的东西吧。

最后

大神都是一步一个坑踩过来的。虽然这个问题导致原本很愉快的版本(线上没有crash)变成了现在这个样子,甚至还只能紧急发版来解决。但还是值得的。

CepheusSun wechat
订阅我的公众号,每次更新我都不一定会告诉你!
坚持原创技术分享,您的支持将鼓励我继续创作!
0%