前言
最近遇到一起由objc_setAssociatedObject
和objc_getAssociatedObject
引發(fā)的線上Crash事故竿拆,在痛心疾首的同時(shí)也覺得很有意思旦万,特此分享楣铁。
正文
問題背景
項(xiàng)目中已經(jīng)存在某個(gè)Catagory,會(huì)往一個(gè)第三方庫的類中掛載一個(gè)屬性以清,用下面代碼的TestCatagory中ssShowTime屬性來表示雾狈。
@interface ViewController(TestCategory)
@property (nonatomic, assign) long ssShowTime;
@end
具體的實(shí)現(xiàn)是用objc_setAssociatedObject
和objc_getAssociatedObject
方法岛宦。
@implementation ViewController (TestCategory)
- (void)setSsShowTime:(long)ssShowTime {
NSNumber *number = @(ssShowTime);
objc_setAssociatedObject(self, @selector(ssShowTime), number, OBJC_ASSOCIATION_ASSIGN);
}
- (long)ssShowTime {
NSNumber *number = objc_getAssociatedObject(self, @selector(ssShowTime));
return [number longValue];
}
@end
該方法已經(jīng)跑了好幾個(gè)版本阱佛,沒有出現(xiàn)過任何問題帖汞。
后面在此基礎(chǔ)上又新增一個(gè)掛載屬性,我們用ssLocalDesc來表示凑术。
@property (nonatomic, strong) NSString *ssLocalDesc;
- (void)setSsLocalDesc:(NSString *)ssLocalDesc {
objc_setAssociatedObject(self, @selector(ssLocalDesc), ssLocalDesc, OBJC_ASSOCIATION_ASSIGN);
}
- (NSString *)ssLocalDesc {
NSString *ret = objc_getAssociatedObject(self, @selector(ssLocalDesc));
return ret;
}
ssLocalDesc屬性會(huì)用來存一些描述翩蘸,比如說用常量,又或者拼接起來的字符串淮逊,如下:
self.ssLocalDesc = @"123";
// 或者
int index = 1;
self.ssLocalDesc = [NSString stringWithFormat:@"Tag_%d", index];
一切都正常催首,直到下面這段代碼出現(xiàn):
self.ssLocalDesc = [NSString stringWithFormat:@"Tag_%d", (int)time(NULL)];
這個(gè)賦值語句執(zhí)行完之后,再訪問self.ssLocalDesc
屬性就會(huì)產(chǎn)生Crash泄鹏!
問題回溯
當(dāng)問題出現(xiàn)之后郎任,我們來看看是犯了哪些錯(cuò)誤,才會(huì)導(dǎo)致問題的出現(xiàn):
ssShowTime 屬性雖然是long备籽,但是內(nèi)部實(shí)現(xiàn)的時(shí)候還是通過NSNumber類來實(shí)現(xiàn)舶治,所以這里不應(yīng)該使用OBJC_ASSOCIATION_ASSIGN;
typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
OBJC_ASSOCIATION_ASSIGN = 0, /**< Specifies a weak reference to the associated object. */
OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, /**< Specifies a strong reference to the associated object.
* The association is not made atomically. */
OBJC_ASSOCIATION_COPY_NONATOMIC = 3, /**< Specifies that the associated object is copied.
* The association is not made atomically. */
OBJC_ASSOCIATION_RETAIN = 01401, /**< Specifies a strong reference to the associated object.
* The association is made atomically. */
OBJC_ASSOCIATION_COPY = 01403 /**< Specifies that the associated object is copied.
* The association is made atomically. */
};
這里更合適的做法是使用OBJC_ASSOCIATION_RETAIN或者OBJC_ASSOCIATION_RETAIN_NONATOMIC胶台。
ssLocalDesc屬性是字符串歼疮,字符串通常使用strong或者copy杂抽,那么這里使用OBJC_ASSOCIATION_ASSIGN本身就是錯(cuò)誤的诈唬。
OBJC_ASSOCIATION_ASSIGN通常是為了避免循環(huán)引用而添加,不會(huì)對引用計(jì)數(shù)產(chǎn)生變化缩麸。
問題延伸
當(dāng)解決完這個(gè)問題之后铸磅,我們發(fā)現(xiàn)crash出現(xiàn)之前,有幾個(gè)延伸問題:
問題1:為什么ssShowTime這個(gè)屬性在運(yùn)行過程中不會(huì)Crash杭朱?
我們知道Crash是由于OBJC_ASSOCIATION_ASSIGN不會(huì)引用計(jì)數(shù)加1阅仔,導(dǎo)致對象被釋放出現(xiàn)野指針的情況。那么我們在number對象掛載之前弧械,看下對象的引用計(jì)數(shù)八酒。
- (void)setSsShowTime:(long)ssShowTime {
NSNumber *number = @(ssShowTime);
objc_setAssociatedObject(self, @selector(ssShowTime), number, OBJC_ASSOCIATION_ASSIGN);
}
結(jié)果非常意外,引用計(jì)數(shù)的值非常大刃唐。
(lldb) p CFGetRetainCount(number)
(CFIndex) $0 = 9223372036854775807
如果排除掉引用計(jì)數(shù)出錯(cuò)的可能羞迷,我們可以理解為什么number對象不會(huì)被釋放。
問題2:為什么ssLocalDesc這個(gè)屬性在測試不會(huì)Crash画饥,而在線上運(yùn)行會(huì)出現(xiàn)Crash衔瓮?
針對ssLocalDesc屬性,我構(gòu)造了三種情況:
- 情況1抖甘,普通常量字符串热鞍;
self.ssLocalDesc = @"123";
結(jié)果如下圖,引用計(jì)數(shù)也很大;字符串類型為常量字符串薇宠, 隨著App運(yùn)行就創(chuàng)建偷办,退出時(shí)才銷毀。
- 情況2澄港,測試時(shí)較短的字符串爽篷;
int index = 1;
self.ssLocalDesc = [NSString stringWithFormat:@"Tag_%d", index];
結(jié)果如下圖,引用計(jì)數(shù)仍很大慢睡;字符串類型為TaggedPointerString逐工,這是標(biāo)簽指針類型的字符串,把指針當(dāng)做字符串對象來使用漂辐;
- 情況3泪喊,上線后較長的字符串;
self.ssLocalDesc = [NSString stringWithFormat:@"Tag_%d", (int)time(NULL)];
結(jié)果如下圖髓涯,引用計(jì)數(shù)為正常袒啼;字符串類型是普通字符串,這是我們最常見的字符串類型纬纪。這個(gè)類型的字符串蚓再,在下面訪問ssLocalDesc屬性時(shí)會(huì)發(fā)生Crash。
再回到問題1包各,我們知道NSNumber也使用類似的標(biāo)簽指針(Tagged Pointer)摘仅。當(dāng)數(shù)字較小的時(shí)候,NSNumber就不是真正的對象问畅,而是一個(gè)標(biāo)簽指針娃属,并不會(huì)像對象一樣走銷毀釋放的流程。
驗(yàn)證方法:使用一個(gè)較大的數(shù)字來初始化护姆。比如說設(shè)置ssShowTime為NSIntegerMax矾端,此時(shí)引用計(jì)數(shù)恢復(fù)正常范圍。
相關(guān)知識——Tagged Pointer
Tagged pointer:是用于提高性能并減少內(nèi)存使用的技術(shù)卵皂。原理是利用內(nèi)存存儲(chǔ)中的內(nèi)存對齊秩铆,對象的地址通常是指針大小的倍數(shù)。iOS的設(shè)備中大部分都是64位的機(jī)器灯变,所以指針通常是以64 位整型存儲(chǔ)殴玛。
由于內(nèi)存對齊,指針中會(huì)有一些位總會(huì)為零柒凉。為了高效利用這些空間族阅,iOS把對象指針的最低有效位為1時(shí),認(rèn)為該指針是 tagged pointer(標(biāo)簽指針)膝捞。tagged pointer最低位中的前3位不再被當(dāng)作isa指針的地址坦刀,而是表示一個(gè)特殊的tagged class表的索引值愧沟;這個(gè)索引值用來查找tagged pointer所對應(yīng)的類,剩余的60位則會(huì)被直接使用鲤遥。
總結(jié)
標(biāo)簽指針的具體概念沐寺,在附錄兩篇文章已經(jīng)描述得很清晰,這里就不再贅述盖奈。
這個(gè)事故還有很多隱藏因素導(dǎo)致混坞,比如說測試環(huán)境與線上環(huán)境不一致,比如說上線流程沒有按照規(guī)范執(zhí)行钢坦,比如說代碼規(guī)范沒有遵守究孕,比如說review流程沒有發(fā)現(xiàn)問題等等,針對這么多因素爹凹,其中有兩步是很重要的:
1厨诸、保證測試環(huán)境和線上環(huán)境一致;
2禾酱、按照上線流程進(jìn)行規(guī)范操作微酬;
為了能在測試階段發(fā)現(xiàn)問題,還是把測試環(huán)境和線上環(huán)境調(diào)成完全一樣的好颤陶;
從技術(shù)的角度來分析颗管,只要工程設(shè)置完全一致,就可以實(shí)現(xiàn)客戶端的測試環(huán)境=線上環(huán)境滓走。