UITableView作为iOS中一个重要的基础组件,几乎是无bug的。但在开发聊天室过程中,此控件出现了一个匪夷所思的问题。由于中间又夹杂着大量功能开发,因此这个用户反馈的问题前后困扰了我3个月之久。下面我们就来回顾问题的定位过程和解决方案。
问题阶段1
偶尔有用户反馈,聊天室卡顿。
思考过程
首先,用户提供的卡顿体验描述非常粗糙。
其次,从技术角度来说,卡顿无非就是数据计算的时间较长,占用了主线程的处理器资源,影响了UI响应的及时性。可以从数据获取、处理、展示这个流程来尝试定位。
定位过程
- 数据获取。数据获取此处有两个渠道,一个是环信的聊天室消息实时推送,另一个是后台服务器接口给的历史消息。大致工作流程为:用户点击大厅tab,如果不在聊天室中则立即加入聊天室,并向后台接口拉取最新的历史消息若干条。聊天室加入成功后,环信会推送10条最新的聊天室消息。
- 数据处理。通过上述两个异步操作,获取到了初始数据,在主线程完成合并去重。之后的新消息由环信推送附加在列表末尾,而要查看更多历史消息则调用接口获取插入到列表顶部。
- 数据展示。调用UITableView的reloadData方法。
阶段1结论
数据获取这一过程没有过多占用主线程资源的可能。所有HTTP(S)网络请求都是在子线程同步队列中进行,完成后在主线程回调给上层;环信消息发送可以选择子线程中异步,那么猜测其收消息应当也是子线程中接收完成后再在主线程调用自己的代理方法。
而数据处理过程是可能存在卡顿的。第一版开发的过程中,为了加快开发速度,所有cell高度的计算,都是完整地用数据构造好cell之后直接获取其高度的,因此在一个cell展示到界面的过程中,需要完成2次构造过程。
而事实确实如此,每次有新消息推过来时,如果当前正在滚动列表并且由于惯性并未停止,则刷新时有短暂的卡顿。可在深入与用户沟通后,问题并不是新消息推过来的构造过程卡顿,而是每次滑动列表时的响应延迟。
问题阶段2
在用户反复的反馈中,我们意识到了问题的严重性。团队中的其他成员,但凡中度使用者,也都会碰到卡顿的问题。针对这个问题,团队讨论决定专门花费1天人力定位问题。
分析过程
定位偶现问题需要花费大量时间,因此方向找对很重要。特定页面滑动列表响应延迟,基本可以排除数据原因,猜测的可能性有以下几种:
- UIScrollView中的delaysContentTouches属性导致;
- UITableView上是否有其他覆盖view;
- Cell中用到的更好支持文本链接的第三方UITextView子类控件是否改变了UITableView的行为。
定位过程
首先,针对delaysContentTouches属性的用途,要深入理解。官方文档上提到:
定义:A Boolean value that determines whether the scroll view delays the handling of touch-down gestures. 可能出现的情况:If the value of this property is true, the scroll view delays handling the touch-down gesture until it can determine if scrolling is the intent. If the value is false , the scroll view immediately calls touchesShouldBegin(_:with:in:). The default value is true.
大致意思是,这个属性决定了ScrollView是否会延迟响应。用户触发touch down事件后,如果该属性为true,ScrollView会先询问代理方法touchesShouldBegin(_:with:in:)是否可以开始处理touch事件;否则,无论touchesShouldBegin(_:with:in:)返回值是什么,ScrollView都会立即响应用户操作。
理解完毕,上述所有属性设置之后,都是一锤子买卖:要么立即能响应,要么立即不能响应,不可能出现延迟响应的情况。因此猜测不成立。
对于第二种可能,借助Xcode的Debug View Hierarchy工具,查看到视图上并没有覆盖View。
而第三种猜测,由于滑动延迟本身不必现,因此将CCHLinkTextView替换为UITextView后,无法立即看到效果。团队内经过初步讨论,决定跟随新功能发一个版本专门用来验证。
问题阶段3
经过上述各种推断和优化后,新版本仍然有用户反馈使用一段时间后滑动延迟,杀进程重启app就好了。
思考
一位安卓开发同事在和产品经理争论某bug是否值得修复时,说:“你提的bug不能必现,我改了你也不知道我改好了啊。你们得先找到必现的步骤,再来找我。”
看似不经意间的对话,却引出了一个新的方向——改bug必先复现。有经验的程序员都知道,必现的bug最好改。而不好改的,是那些偶现的问题。尽管某些问题是偶现,但能出现就一定需要满足一些触发条件。找到这个触发条件,成为解决问题的一个方向。
定位过程
用户反馈中,有一个共性:用久了就滑动延迟。
“用久了”是一个切入点。在重度使用半小时后,我自己也碰到了这个问题。并且随着使用时间越久,症状越明显。此时,忽然想到有同事反馈说从后台切出来有时就会卡顿,我尝试将app反复前后台切换,有惊人发现。反复切换200次之后,必现滑动延迟!
前后台切换app,会不断的拉取历史消息,退出、加入聊天室,刷新列表。经过一番代码屏蔽测试,查到了基类ViewController如下代码:
- (void)setTapToEndEditingEnabled:(BOOL)tapToEndEditingEnabled {
_tapToEndEditingEnabled = tapToEndEditingEnabled;
if (tapToEndEditingEnabled) {
if (_endEditingTapGesture == nil) { //TapGesture为nil时重新构造
_endEditingTapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapToEndEditing:)];
_endEditingTapGesture.delegate = self;
[self.view addGestureRecognizer:_endEditingTapGesture]; //每次构造TapGesture都加到ViewController的view上
}
} else {
[_endEditingTapGesture removeTarget:self action:@selector(tapToEndEditing:)]; //移除了TapGesture响应链
_endEditingTapGesture = nil; //置TapGesture为nil
}
}
刷新时,有一段switch-case:
//默认设置点击背景可dismiss掉键盘
tapToEndEditingEnabled = true
switch 是否输入状态 {
case 文本输入状态:
...
break
case 语音输入状态:
...
break
default: //没有输入状态,需要禁用点击背景去掉键盘
tapToEndEditingEnabled = false
break
}
这两段代码结合起来看,等同于每次刷新都在ViewController的view上添加了一个TapGesture,并且之后在switch-case中检测到没有输入状态,又触发基类set方法把tapGesture置为了nil,但并没有将tapGesture从view上移除掉。反复刷新反复添加tapGesture,tapGesture越来越多,多到几百层覆盖在view上时,就会逐渐导致滑动响应延迟。
解决方案
问题终于找到了,遂解决之。修改基类方法如下:
- (void)setTapToEndEditingEnabled:(BOOL)tapToEndEditingEnabled {
_tapToEndEditingEnabled = tapToEndEditingEnabled;
//要启用,且手势为nil,才构造
if (tapToEndEditingEnabled && _endEditingTapGesture == nil) {
_endEditingTapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapToEndEditing:)];
_endEditingTapGesture.delegate = self;
[self.view addGestureRecognizer:_endEditingTapGesture];
}
_endEditingTapGesture.enabled = tapToEndEditingEnabled;
}
总结
从此次解决偶现bug中可以发现,但凡遇见不能重现的问题,作为开发人员一定要从代码层面思考如何重现。一个问题不可能平白无故产生,也不会无缘无故自我修复,找到问题所在,先复现,再调整优化。之后经过相同条件测试,不再复现,才算改好。纯粹靠臆测改bug,只会增加bug打回次数,在大公司更影响个人绩效考核。
所以说,做事方向正确,比努力更重要。