1 引言
相信大家都遇到过一段特殊文本可以让 iOS 设备所有 app 闪退的经历前段时间大年初一, 又出现某个印度语字符引起 iOS11 系统奔溃, 所幸 iOS 版微信客户端做了保护并没有引起太大问题 (字符处理这类技术问题, 其实曾在 Android 版微信上导致过严重的用户体验危机, 感兴趣的可以看看文章微信团队披露: 微信界面卡死超级 bug15 的来龙去脉)
一般来说, 特殊字符闪退是系统漏洞引起, 只要更新系统就行但大部分用户不愿意更新系统, 而苹果也不一定第一时间解决问题另外后台可以拦截恶意文本传递, 但对于本地已下发的消息, 后台没有办法让它删除所以客户端还是要做些保护预防特殊字符闪退
2 微信的思路
由于无法事先知道字符串里包含特殊字符, 所以只能先让它排版 / 绘制, 看看是否出现问题做法是, 在排版 / 绘制字符串前, 先设置标记位, 排版 / 绘制结束后, 移除标记位
一旦发现标记位存在, 就意味着这字符串可能有问题, 下次就不显示这个字符串:
这里有几个问题:
有可能在排版 / 绘制过程中, 其它线程 crash, 导致标记位不能正常移除所以 crash 时要判断 crash 线程是否为排版 / 绘制线程
究竟 crash 多少次才能判断这字符串是有问题的: 最早做法是 crash 一次就直接屏蔽, 但很多用户反馈, 说某些好友昵称无法显示其实 iOS 绘制字符串时也会极少概率出现闪退, 从而误判但 crash 两次才屏蔽的话, 如果用户连续收到 N 条恶意消息, 那么至少 crash 2N 次才彻底把所有有问题消息屏蔽
因此, 第一次字符串 crash 先不屏蔽, 后续连续字符串 crash 的话, 直接屏蔽这样 crash N+1 次就能处理完了
3 具体的 iOS 代码实现
正如第 2 节的思路那样整个逻辑代码大致如下
- MessageItemView.mm:
- //CP 是 CrashProtected 的简称
- @implementationMessageItemView
- - (void)initContentLabel {
- m_label = [[MMCPLabel alloc] init];
- m_label.cpKey = [MMCPUtil generateKeyWithObject:self.messageModel];
- if([MMCPUtil isUnsafeWithKey:m_label.cpKey]) {
- // 检测出 messageModel 消息内容有问题, 屏蔽显示
- m_label.text = @"该内容无法显示";
- } else{
- m_label.text = self.messageModel.content;
- }
- }
- @end
- MMCPLabel.mm:
- @implementationMMCPLabel
- @synthesizecpKey = m_cpKey;
- // 对常用的排版 / 绘制接口做检查
- - (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++ 类临时变量时, 会自动执行构造函数, 离开作用域会执行析构函数
- // 因此构造函数做 crashCount+1, 析构函数做 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; // 被判定为恶意信息对应的 key
- @synthesizecrashCountMemoryMappedKV = m_crashCountMemoryMappedKV; // 每个 key crash 次数
- - (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 回调函数
- - (void)onSignalCrash:(siginfo_t *)info {
- // 先找到跟 crash 线程相同的 key
- NSString*key = [selflastCPKey:info->si_pid];
- if(key == nil) return;
- if(m_isLastTimeCrashedBySpecialCharacter == NO) {
- // 设置当前是特殊字符引起的闪退, 如果 crash 次数大于 1, 则屏蔽这字符串显示
- [selfsetLastTimeCrashedBySpecialCharacter:YES];
- if([m_crashCountMemoryMappedKV getInt32ForKey:key]> 1) {
- [m_crashKeyMemoryMappedKV setBool:YESforKey:key];
- }
- } else{
- // 连续特殊字符闪退, 直接屏蔽
- [m_crashKeyMemoryMappedKV setBool:YESforKey:key];
- }
- }
- @end
4 还需要从用户体验上做更进一步改进
即使有了上面的 N+1 优化, 当 N 很大时, 客户端还是要 crash 很多次才能正常使用之前有用户乱扫二维码被拉进炸群, 如果不发红包, 群主不停炸群; 用户频繁 crash, 也无法退群不少用户会选择卸载重装客户端因此客户端要加上安全模式的机制
当客户端检测出连续三次 crash, 下次启动会出现安全模式的界面, 提示用户如何处理:
对于频繁闪退的群聊, 主界面提供快捷入口方便用户退群另外对于可能误判的字符串, 界面也提供入口方便用户恢复字符串显示:
为了让后台第一时间发现新的特殊字符变种, 客户端检测出特殊字符 crash 后, 会把相关信息上报到后台通过客户端上报后台拦截的闭环, 能大大降低特殊字符传播范围这方案不仅用于特殊字符, 还能用于其他恶意信息, 如炸群消息 GIF 小视频链接等
5 通用组件 MemoryMappedKV
由于需要埋点的地方太多了, 昵称消息内容头像等等, 为了不影响滑动性能, guoling 同学开发了一套基于 mmap 的高性能通用 key-value 存储组件, 敬请留意微信团队公众号的后续技术文章
来源: https://www.thinksaas.cn/group/topic/839018/