1、引言
相信大家都遇到過(guò)一段特殊文本可以讓iOS設(shè)備所有app閃退的經(jīng)歷。前段時(shí)間大年初一,又出現(xiàn)某個(gè)印度語(yǔ)字符引起iOS11系統(tǒng)奔潰,所幸iOS版微信客戶端做了保護(hù)并沒(méi)有引起太大問(wèn)題(字符處理這類技術(shù)問(wèn)題,其實(shí)曾在Android版微信上導(dǎo)致過(guò)嚴(yán)重的用戶體驗(yàn)危機(jī)。
一般來(lái)說(shuō),特殊字符閃退是系統(tǒng)漏洞引起,只要更新系統(tǒng)就行。但大部分用戶不愿意更新系統(tǒng),而蘋(píng)果也不一定第一時(shí)間解決問(wèn)題。另外后臺(tái)可以攔截惡意文本傳遞,但對(duì)于本地已下發(fā)的消息,后臺(tái)沒(méi)有辦法讓它刪除。所以客戶端還是要做些保護(hù)預(yù)防特殊字符閃退。
2、微信的思路
由于無(wú)法事先知道字符串里包含特殊字符,所以只能先讓它排版/繪制,看看是否出現(xiàn)問(wèn)題。做法是,在排版/繪制字符串前,先設(shè)置標(biāo)記位,排版/繪制結(jié)束后,移除標(biāo)記位。
一旦發(fā)現(xiàn)標(biāo)記位存在,就意味著這字符串可能有問(wèn)題,下次就不顯示這個(gè)字符串:

這里有幾個(gè)問(wèn)題:
有可能在排版/繪制過(guò)程中,其它線程crash,導(dǎo)致標(biāo)記位不能正常移除。所以crash時(shí)要判斷crash線程是否為排版/繪制線程。
究竟crash多少次才能判斷這字符串是有問(wèn)題的:最早做法是crash一次就直接屏蔽,但很多用戶反饋,說(shuō)某些好友昵稱無(wú)法顯示。其實(shí)iOS繪制字符串時(shí)也會(huì)極少概率出現(xiàn)閃退,從而誤判。但crash兩次才屏蔽的話,如果用戶連續(xù)收到N條惡意消息,那么至少crash 2N次才徹底把所有有問(wèn)題消息屏蔽。
因此,第一次字符串crash先不屏蔽,后續(xù)連續(xù)字符串crash的話,直接屏蔽。這樣crash N+1次就能處理完了。
3、具體的iOS代碼實(shí)現(xiàn)
正如第2節(jié)的思路那樣。整個(gè)邏輯代碼大致如下。
MessageItemView.mm:
//CP是CrashProtected的簡(jiǎn)稱
@implementationMessageItemView
- (void)initContentLabel {
m_label = [[MMCPLabel alloc] init];
m_label.cpKey = [MMCPUtil generateKeyWithObject:self.messageModel];
if([MMCPUtil isUnsafeWithKey:m_label.cpKey]) {
// 檢測(cè)出messageModel消息內(nèi)容有問(wèn)題,屏蔽顯示
m_label.text = @"該內(nèi)容無(wú)法顯示";
} else{
m_label.text = self.messageModel.content;
}
}
@end
MMCPLabel.mm:
@implementationMMCPLabel
@synthesizecpKey = m_cpKey;
// 對(duì)常用的排版/繪制接口做檢查
- (void)layoutSublayersOfLayer:(CALayer *)layer {
CScopedCrashCounter crashCounter(m_cpKey);
[superlayoutSublayersOfLayer:layer];
}
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx {
CScopedCrashCounter crashCounter(m_cpKey);
[superdrawLayer:layer inContext:ctx];
}
- (CGSize)sizeThatFits:(CGSize)size {
CScopedCrashCounter crashCounter(m_cpKey);
return[supersizeThatFits:size];
}
@end
MMCPUtil.mm:
// 利用C++特性,在聲明C++類臨時(shí)變量時(shí),會(huì)自動(dòng)執(zhí)行構(gòu)造函數(shù),離開(kāi)作用域會(huì)執(zhí)行析構(gòu)函數(shù)
// 因此構(gòu)造函數(shù)做crashCount+1,析構(gòu)函數(shù)做crashCount-1
classCScopedCrashCounter {
public:
CScopedCrashCounter(NSString*cpKey) {
m_cpKey = cpKey;
[MMCPUtil increaseCrashCountWithKey:m_cpKey];
}
~CScopedCrashCounter() {
[MMCPUtil decreaseCrashCountWithKey:m_cpKey];
}
private:
NSString*m_cpKey;
};
@implementationMMCPUtil
@synthesizecrashKeyMemoryMappedKV = m_crashKeyMemoryMappedKV; // 被判定為惡意信息對(duì)應(yīng)的key
@synthesizecrashCountMemoryMappedKV = m_crashCountMemoryMappedKV; // 每個(gè)key crash次數(shù)
- (BOOL)isUnsafeWithKey:(NSString*)key {
return[m_crashKeyMemoryMappedKV getBoolForKey:key] == YES;
}
- (void)increaseCrashCountWithKey:(NSString*)key {
// 這里記錄key所在線程
...
int32_t count = [m_crashCountMemoryMappedKV getInt32ForKey:key];
[m_crashCountMemoryMappedKV setInt32:count + 1 forKey:key]
}
- (void)decreaseCrashCountWithKey:(NSString*)key {
int32_t count = [m_crashCountMemoryMappedKV getInt32ForKey:key];
[m_crashCountMemoryMappedKV setInt32:MAX(0, count - 1) forKey:key];
}
// crash回調(diào)函數(shù)
- (void)onSignalCrash:(siginfo_t *)info {
// 先找到跟crash線程相同的key
NSString*key = [selflastCPKey:info->si_pid];
if(key == nil) return;
if(m_isLastTimeCrashedBySpecialCharacter == NO) {
// 設(shè)置當(dāng)前是特殊字符引起的閃退,如果crash次數(shù)大于1,則屏蔽這字符串顯示
[selfsetLastTimeCrashedBySpecialCharacter:YES];
if([m_crashCountMemoryMappedKV getInt32ForKey:key] > 1) {
[m_crashKeyMemoryMappedKV setBool:YESforKey:key];
}
} else{
// 連續(xù)特殊字符閃退,直接屏蔽
[m_crashKeyMemoryMappedKV setBool:YESforKey:key];
}
}
@end
4、還需要從用戶體驗(yàn)上做更進(jìn)一步改進(jìn)
即使有了上面的N+1優(yōu)化,當(dāng)N很大時(shí),客戶端還是要crash很多次才能正常使用。之前有用戶亂掃二維碼被拉進(jìn)炸群,如果不發(fā)紅包,群主不停炸群;用戶頻繁crash,也無(wú)法退群。不少用戶會(huì)選擇卸載重裝客戶端。因此客戶端要加上安全模式的機(jī)制。
當(dāng)客戶端檢測(cè)出連續(xù)三次crash,下次啟動(dòng)會(huì)出現(xiàn)安全模式的界面,提示用戶如何處理:

對(duì)于頻繁閃退的群聊,主界面提供快捷入口方便用戶退群。另外對(duì)于可能誤判的字符串,界面也提供入口方便用戶恢復(fù)字符串顯示:

為了讓后臺(tái)第一時(shí)間發(fā)現(xiàn)新的特殊字符變種,客戶端檢測(cè)出特殊字符crash后,會(huì)把相關(guān)信息上報(bào)到后臺(tái)。通過(guò)客戶端上報(bào)、后臺(tái)攔截的閉環(huán),能大大降低特殊字符傳播范圍。這方案不僅用于特殊字符,還能用于其他惡意信息,如炸群消息、GIF、小視頻、鏈接等。
?