刚好端午节了,不想出去旅游看世界了,也不想把玩游戏了,也不想把妹(ps 其实是我没有妹子可玩耍😊,不要告诉别人,你知我知天知就好了🤦♂️),刚好有时间静下心来写下在新公司的项目开发中遇到的问题和一些解决问题的心得,总结下来,也算是对那些被bug折磨的郁闷的日子的交代,同时也希望,遇到同样问题的你,因为看到这篇文章而少走弯路,因为看到这篇文章而心花怒放,如此,我也倍感荣幸能帮到你!
话不多说,下面我先简略简述下bug的背景和症状:
页面如下:(原谅我对图片做了必要的涂鸦😄)
一个tableView列表页,是一个群的成员列表页面,UI元素很简单,包含头像、群角色、拼接的昵称,原来业务代码实现是一次性加载所有需要的数据源,数据稍微多时,页面卡顿,甚至会闪退,或者导致其他页面不可测的卡顿、闪退,数据源大时,这个查看群组成员的功能基本报废不能使用,并且易导致app卡顿、闪退。
你一定会疑问,这不是及其简单的页面嘛,没错,你很聪明,这的确是极其简单的页面效果;但是你看下群的成员是多少,没错是将近10万人的群,10万,也就意味着大量的数据源;可能你心里还在想,10万人又咋了,我1000万的数据源还处理过呢,分布加载不就解决问题了嘛;没错,我完全相信你处理过1000万的数据源,我也绝对相信你用分页加载来控制用户的行为来避免设备的cpu瞬时峰值过大;但是,兄弟,不要慌,如你所以为的那样,如果这个功能是我从0到1开发,我也会如你所想的那样分页加载处理,可问题是,这个项目是12年就运营的,目前代码已经小百万行了,单单这个页面控制器代码也堆了足足1.4万行,这里面牵涉了UI最终展示前的大量业务逻辑(比如踢人、新进群、群成员头像/昵称更换、群成员角色变换等大概十来种业务)和相应业务操作对应的缓存处理逻辑,看到1.4万行代码外加十来种完全不知道的业务,兄弟,不用告诉我,我也知道你会一头雾水(ps 技术溜的上云霄的小伙伴就自动屏蔽了 );兄弟,也不要嗔怪为什么要在一个控制器里写那么多代码,接受当前的代码事实是你应该也是唯一可做的事,至于你看着不爽,决定要盘古开天劈地来重构代码,这是你接受事实后完全搞清楚这一块功能后并且和相关开发端的同事评估效益比后才能想的事。眼下,这边领导给你说这问题很严重,要立即修复;那边产品经理过来说,这个问题很严重,市场已经严重抱怨甚至愤怒了,老板已经严重关切这事了,你要立刻马上现在解决掉!所以,当前,你能做的就是梳理原来的业务代码,找到造成卡顿、闪退的关键因素,先临时性修复这个问题,后期可以再根据梳理结果来评估原来的代码是否需要完全重构!这是你最快也是应该唯一能最快解决这问题的态度,尽管很难,依然要平静的呼吸和说话,然后就埋头读代码找坑点!
暴露了,暴露了我曾当过个把月iOS讲师的事实😂,讲个bug解决点,讲了一通在着手读代码找坑点前的心理活动和独白,加的戏份太多了,下面直接贴出来简略的业务代码:(代码有点长,前方高能,请做好提前预警😜)
// 读取本地缓存数据源
- (void)getCacheContent{
// 当前线程为主线程
// 一顿业务操作略过细节
// 从本地缓存读取数据 获得群组成员列表信息数组resultArray
NSDictionary* responseObject = [NSDictionary dictionaryWithContentsOfFile:kCachefilePath]; // kCachefilePath 为本地缓存文件地址
NSMutableArray *resultArray = [NSMutableArray arrayWithArray:[responseObject objectForKey:@"info"]];
// usersInfoDic是一个存放FriendInfoStructure成员个体对象的字典 tableview的数据源
if (!usersInfoDic) {
usersInfoDic = [NSMutableDictionary dictionary];
}
// tips 1 下面这一顿操作 是遍历本地群组成员列表并对照本地数据库做查询对比处理 生成群组成员个人对象FriendInfoStructure 会创建大量对象
for (NSDictionary* dic in resultArray) {
//autoreleasepool 自动释放池 能够让对象及时的销毁 避免内存峰值过高而闪退 这是我规避内存峰值添加的
//@autoreleasepool {
NSInteger userID = [[dic objectForKey:@"uid"] intValue];
NSString* userIdString = [dic objectForKey:@"uid"];
// 创建对象
FriendInfoStructure* infoStructure = [FriendInfoStructure new];
infoStructure = [usersInfoDic objectForKey:userIdString];
if (!infoStructure) {
// 这一步是查询本地数据库 比对并做必要的一些处理
infoStructure =
[self getCacheInfoFromFMDB:userID];
[usersInfoDic setObject:infoStructure forKey:userIdString];
} else if (infoVersion != infoStructure.groupInfoVersion) {
infoStructure.groupInfoVersion = infoVersion;
} }
}
}
[tableView reloadData];
// 从网络上获取最新的群组成员列表信息
// 网络数据请求放在这的目的就是 加载本地缓存的同时进行网络最新数组加载 用户先看到缓存数据 当网络最新数据加载下来时再刷新 展示最新数据
[self getTableData];
}
// 网络数据源处理和本地数据源比对、更换处理
- (void)getTableData{
//当前线程为子线程
//一顿网络请求的操作 返回字典类型的数据responseObject
NSDictionary *responseObject;
// 将网络上下载的最新的群组成员信息存储在本地缓存数据中 (注意,这个过程包含了替换原来的本地缓存数据)
[responseObject writeToFile:kCachefilePath atomically:YES];
// 解析到网路上获取到的最新的群组成员信息列表resultArray
resultArray = [NSMutableArray arrayWithArray:[responseObject objectForKey:@"info"]];
if (!usersInfoDic) {
usersInfoDic = [NSMutableDictionary dictionary];
}
std::vector<int> uid_vector; // 注意 这是c++代码 正如中国外交部的声明一样 字越少 信息量越大 因为我们项目架构是 底层(数据层是c++代码) + 中间层(c++代码和oc代码组成,目的是建立抽象层,使底层能兼容PC、Andriod、Apple三端) + 高层(UI层),因此有c++代码 这是一个巨坑 后面我会解释为什么是一个巨坑
// tips 11下面的也是遍历网络上获得的数据 并和本地缓存数据库比对 会大量创建群组成员个人信息FriendInfoStructure对象
for (int a = 0; a < (int)[resultArray count];
a++) {
//autoreleasepool 自动释放池 能够让对象及时的销毁 避免内存峰值过高而闪退 这是我规避内存峰值添加的
//@autoreleasepool {
NSDictionary* dic =
[resultArray objectAtIndex:a];
NSInteger userID = [[dic objectForKey:@"uid"] intValue];
NSString* userIdString = [dic objectForKey:@"uid"];
// 创建对象
FriendInfoStructure* infoStructure = [FriendInfoStructure new];
infoStructure = [usersInfoDic objectForKey:userIdString];
if (!infoStructure) {
// 和本地缓存数据库比对
infoStructure =
[self getCacheInfoFromFMDB:userID];
[usersInfoDic setObject:infoStructure forKey:userIdString];
//if (self.needRequest) {
self.needRequest = NO;
uid_vector.push_back(userID); // 这是上面c++ 代码对应的业务 也是字越少 信息量越大
// }
} else if (infoVersion != infoStructure.groupInfoVersion) {
infoStructure.groupInfoVersion = infoVersion;
}
}
// 当所有网络数据比对完 刷新UI
[[NSOperationQueue mainQueue]
addOperationWithBlock:^{
// 这两行代码及其关键 通过中间层触发c++底层查询群组成员信息的条件 这是一个很不容易发现的坑 当初定位到这两行代码花费了我五六个小时的时间
[[[[AppDelegate appDelegate]
sharedProtoEngine] sharedGroupEngine]
getGroupUserCardVecForGId:self.groupId
forUserIdVec:uid_vector];
// 这个就是常规操作 刷新UI
[weakTableView reloadData];
}];
}
下面是我排查的思路:首先入手UI层,然后分析数据层
1、从UI层排查
是个列表tableView,首先找到对应的数据源代理,然后找对应的tableViewCell,看下cell内部的UI控件绘制是否存在过度绘制、重复绘制、耗时绘制情况,发现UI层都是常规操作,造成卡顿、闪退的不可能,当然如果是一直存在刷新的话,可能会因为cpu超负荷了而卡顿,但是这种极端情况几乎不可能发生,即便发生了,cell是被动绘制,一定是其他地方触发了一直刷新,原因在造成不停刷新的地方。UI的原因暂时没有可能性
2、数据层排查
最开始,排查数据层我没有采用额外的手段,就是在xcode的对应的群组成员信息列表控制器过目下所有的方法,找到与数据源处理相关的方法,重点关注数据源的set方法入口处和get方法出口处,然后,一眼就看到上面读取本地数据源的方法- (void)getCacheContent表tips 1处存在for循环,然后在for循环里又执行了创建对象的操作,职业直觉,很显然,当数据源很多时,这里大量创建对象,并且是主线程中执行的,必然的,数据源多时,for循环会耗费很长时间,自然出现卡顿,同时,由于短时间内创建了大量对象,导致设备运行内存急剧上升,当时我用6p测试,当运行内存达到650M左右时就闪退了,同时cpu甚至200%的超负荷运行,不卡顿不闪退才怪!
同理,在- (void)getTableData tips 11处 网络数据请求完也存在这样的for循环,更为惊人的是,这个for循环也放在主线程了,如此,这样大量消耗内存和cpu的操作,必然导致卡顿和闪退了,在某些数量的数据源区间里,app不会闪退,但是造成卡顿,cpu在设计时也有自己的保护机制,当其持续处于超负荷工作一段时间后,即便再进入超低负荷(如10%)运行也会有一定时间的卡顿,直到cpu的保护机制阀值达到了,卡顿才会解除,因此当从群成员列表页面退出后再进入其他页面,app其他页面不明原因的卡顿也就不足为奇了!
因此,为了规避内存峰值过高而闪退,我在for循环里使用了autoreleasepool 自动释放池 ,让每个临时创建的对象及时的销毁,保证app不会内存峰值过高;
到这问题解决完了嘛,并没有,远着呢!如果你仔细看了,你会发现,在for循环里存在从本地缓存数据库中查找比对字段的操作,没错,存在这种操作的,并且是大量的查询本地数据库,试想一个存有10万条数据的数据库去查找,是多么耗时,cpu会长时间超负荷工作的,卡顿是必然的,因此,必须限制数据源加载的方式,保证一定时间内不能有太多这种操作,因此,我改变了原来的数据交互方式,采用分页加载的方式,小手机每次加载50条数据,大手机每次加载100条数据,这样就不会因为本地数据库查询而导致cpu超负荷工作了!
到这,采用自动释放池技术和分页加载的交互方式,从理论上,内存过高和cpu满负荷工作的因素都消除了,貌似可以6的飞起的运行app了。哼着小曲、满心欢喜的comman + r 运行,感觉自己已经溜的飞起,从1.4万行代码和十多种业务交叉中竟找到了核心卡点,溜的不行不行的......结果,下一秒中,定睛一看,怀疑了人生,又卡了、又闪退了,只是卡和闪退的时间点滞后了,不该呀、不会呀,都这么处理了怎么还不行呢,抓狂中抓狂中😫😫😫......
抓狂过后,清醒了,不是卡顿、闪退滞后了嘛,说明数据源到UI刷新间还有坑点没找到,说明整个卡顿、闪退背后,除了for循环和数据库查询导致外,还有其他原因!读代码中打断点调试排查中......就这样三四小时过去了,还是没定位到关键问题代码处,这种通过读代码和打断点调试的方式不行了,效率太低,也不容易找到问题点了。好,我就打开了xcode自带的调试工具instruments,然后然后在xcode --> Product --> Profile,打开instruments,选择Time profile,运行app,发现tableView的reloadData,很长时间一直在执行,主线程一直满负荷运行,没错,一定是哪个地方一直在触发reloadData,在刷新相关处,通过打断点、注释代码,终于找到关键代码,还记得前面我代码里写的c++代码嘛,没错,就是 std::vector<int> uid_vector 和
[[[[AppDelegate appDelegate]
sharedProtoEngine] sharedGroupEngine]
getGroupUserCardVecForGId:self.groupId
forUserIdVec:uid_vector];
后者是中间层的代码,会调用底层(c++)获取群组成员信息的api,然后,底层以每次10条数据返回客户端,客户端注册了一个与之对应的观察者,这样底层每10条数据通知一下,列表就刷新一下,直至该群组成员信息下发完毕才停止下发数据,列表才停止刷新;后面的卡顿的原因就是这,底层数据回调的原因造成,然后就改变uid_vector.push_back(userID)的使用范围(ps 改变奇执行范围,上述代码中只是一部分),至此,相信你也理解我在上面代码中引用人们评价中国外交部的名言了:字越少 信息量越大 ,正是c++和oc语言的面向对象的特性,你只能看到对象最终的使用,具体细节你不知道,当你知道你作用时,一切都好用,像我刚遇到的那样,刚入手项目,那一部分c++ 代码业务逻辑都没人知道,自己入坑了也浑然不知,直到疼了才发现是入坑了,这也是没办法的办法,尤其对一些比较老且业务复杂的项目,中间都不知换了多少波人了,很多代码都没人知道了,如此,这样只能自己先进坑,疼了再修复出坑了,没办法,古老的项目就是这样的,所以,老项目的问题修复及其的漫长且艰难郁闷。
以上就是前段时间遇到的一个比价典型的复合型bug,同一个问题现象,但是导致其出现的原因是一层又一层的,就这个卡顿、闪退bug,听产品经理和技术老大说,已经困绕了他们几年了,只从用户量过百万后就一直存在,但是一直没人能耐心处理掉.....哦,好吧,暴露了,我承认我太老实了😂,他们竟然把我忽悠进坑给他们填坑了,怪不的他们都把这个问题都立项了,修复完后,我才恍然大悟,原来坑这么大,他们让我成功入坑,还好我修复了成功出坑了🤦♂️
如果你能有耐心读到这,并且还能保持着共鸣,我相信你在处理一些问题时,会有一个比较清晰的思路和心理预期;再次感谢你耐心读完这篇文章,写的有点不像技术文章了😄,倒像一篇bug产生到毁灭的回忆录了😂