找回密码
 会员注册
查看: 14|回复: 0

iOS端循环引用检测实战

[复制链接]

2

主题

0

回帖

7

积分

新手上路

积分
7
发表于 2024-10-9 15:56:54 | 显示全部楼层 |阅读模式
iOS端循环引用检测实战 iOS端循环引用检测实战 段永旭、中平 贝壳产品技术 贝壳产品技术 “贝壳产品技术公众号”作为贝壳官方产品技术号,致力打造贝壳产品、技术干货分享平台,面向互联网/O2O开发/产品从业者,每周推送优质产品技术文章、技术沙龙活动及招聘信息等。欢迎大家关注我们。 242篇内容 2021年11月05日 18:28 1. 前言内存(Memory)是计算机中重要的组件之一,用于暂存CPU中的运算数据、与硬盘等外部存储交换数据。所有的程序都会在内存中运行,而内存的性能直接影响程序的运行效率。应用中最常见的内存问题有:内存泄漏(Memory Leak)、内存溢出(Out Of Memory)和野指针(Wild Pointer)等。本文重点介绍循环引用检测实战。2. 基础内存的生命周期大致可以概括为:分配内存 -> 使用内存 -> 释放内存。而内存泄露 (Memory Leak)是程序中动态分配的堆内存由于某种原因未释放或者无法释放。这会造成内存资源的浪费、程序运行变慢甚至系统崩溃等严重后果。iOS开发中,常见的内存泄漏有:C&C++分配的内存部分未释放、对象之间循环引用造成不能释放,典型例子如Block、NSTimer与self之间的相互引用。前者是没有引用的内存,内存活动图中是不可达的,后者是引用仍存在,但是不会再使用的内存。目前,常见的泄露检测工具有:Analyze静态检测、 Instrusment的Leak动态检测、Instrusment的Allocation、Memory Graph以及第三方工具MLeaksFinder、FBRetainCycleDetector。ARC引入之后,循环引用成为了内存泄漏产生的主要原因。本文将重点介绍循环引用的检测工具和典型实践。3. 原理3.1 概述业内比较通用的检测工具是MLeaksFinder。它是WeRead团队设计的一款开源的iOS内存泄漏检测框架,框架使用十分简单,只需要将文件引入,运行项目,如果存在内存泄漏,利用FBRetainCycleDetector检测出循环引用环,并弹窗展示,如下:3.2 发现泄露MLeaksFinder为NSObject类增加一个willDealloc方法,当我们认为某个对象应该释放的时候,建立一个弱指针,在2秒后,使用这个弱指针调用断言方法。如果这个对象被释放,nil向方法发消息不会有任何反应,否则会执行中断言方法,进而会执行其他操作获取内存泄漏相关信息并以弹窗的形式告知用户。MLeaksFinder从UIViewController入手,通常一个UIViewController被pop或者dismiss之后,该UIViewController的view和view的subview都会很快被释放,所以只需要在ViewController被pop之后一小段时间后,查看其view、subview是否还存在就可以判断是否存在内存泄漏。MLeaksFinder首先会对白名单进行一个过滤,然后,执行target-action的时候,目标对象不检测内存泄漏,最后等待一小段时间后,使用弱指针调用中断言方法,如果弱指针不为空,中断言方法生效,中断生效,提示内存泄漏,否则此处不存在内存泄漏。3.3 泄露误判MLeaksFinder的泄露检测存在一些False Positive,主要有两类:3.3.1 对象被设计为单例或者cache起来复用单例模式和cache起来的对象,在pop和dismiss之后是不会被释放的,根据MLeaksFinder原理我们可以推断,此处会提示内存泄漏。但是此处是开发者由于性能等原因不得不这样设计的,我们不能把它当做一个内存泄漏的点去处理它。这种情况我们多次点击,只会产生一次弹窗提示。遇到这种情况,我们可以判断此处是单例模式或者cache。3.3.2 释放不及时在异步执行代码中,可能会产生block内部代码没有及时释放的情况,在pop后也会提示内存泄漏,但紧接着也会提示Object Deallocated。对应解决办法:在相应的类中重写willDealloc方法,直接返回NO即可消除这种误判。3.4 发现引用环FBRetainCycleDetector以待检测对象为根结点,遍历节点的强引用对象,同时根据配置进行过滤,返回强引用对象并将其保存到候选集里面。最后执行找环操作,对候选集中的对象依次执行深度优先搜索(DFS),如果找到环,将环数据合并到结果集中,最后得到所有的环。循环引用的检测问题被简化为有向图的环检测问题,而其中关键的是:获取普通OC对象、Block、NSTimer等场景下的强引用对象。3.4.1 寻找普通OC对象的引用对象 普通OC对象通过ivar(成员变量)、集合类中的元素,比如 NSArray、NSDictionary 中的元素、associatedObject(关联对象)等方式引用(强引用、弱引用)其他对象。对于ivar(成员变量),可以利用class_copyIvarList拿到 ivar 列表,遍历所有的 ivar ,利用class_getIvarLayout识别出强引用的ivar集合。对于集合类中的元素,可以直接遍历其元素就能够获取所有其强引用的所有对象,需要注意对可以自定义对元素的引用类型的集合类处理。对于associatedObject(关联对象)等,可以hook objc_setAssociatedObject将运行时添加的强引用对象记录下来。3.4.2 寻找Block引用对象Block本质是封装函数调用以及函数调用上下文环境的OC对象,有三类:__NSGlobalBlock、__NSMallocBlock 和 __NSStackBlock,其底层实现是结构体。可以利用Block结构里的dispose_helper(析构)函数获得强引用对象。如果不存在析构函数,就意味着函数没有强引用对象,否则,可以取得析构函数dispose_helper,然后计算出Block所占的内存能存储多少个指针大小数据块,并建立两个相同大小的数组 obj 和 detectors 作为一个“假的block”,并在两个数组中存储BlockStrongRelationDetector对象,然后对数组执行目标block的析构函数dispose_helper,BlockStrongRelationDetector中重写release方法,执行release时会将对象中的_strong标记为 YES,真正的销毁是在 trueRelease 方法中完成的。利用dispose_helper找到强引用对象原理如下图:人工创建的一个假的block(上半部分),内存结构与Block类似,下半部分是一个真实的Block。其中绿色部分是Block本身的内存结构,黄色部分是Block捕获的外部变量,析构函数释放的时候会对5,6执行release, 7是基础数据类型并不会执行。所以当对假的Block执行Block的析构函数时, 也会对5,6部分执行release方法,此处detector对象的release方法已经被重写,所以当析构函数执行之后,obj中5,6的_strong参数变为YES,只需要遍历obj数组,就能获得block对象的强引用对象的位置。3.4.3 寻找Timer引用对象NSTimer对象的所有强引用对象,除了继承父类 NSObject的所有强引用对象之外,还包括 target 和 userInfo 对象。因此,利用CFRunLoopTimerGetContext获得NSTimer对应的CFRunLoopTimerContext结构体,其中,CFRunLoopTimerContext中的info字段可以强制转成_FBNSCFTimerInfoStruct, 通过他拿到target、userInfo信息,即强引用对象。3.5 典型场景实际项目中,循环引用中大部分是Block使用不当造成的,而常用解决Block循环引用的方式是weak-strong-dance,但是依旧存在weakify&strongify使用不当、Block嵌套处理不当造成的循环引用问题。3.5.1 RAC的@weakify&@strongify我们常用RAC的@weakify&@strongify,源码如下:#defineweakify(...)\rac_keywordify\metamacro_foreach_cxt(rac_weakify_,,__weak,__VA_ARGS__)#definestrongify(...)\rac_keywordify\_Pragma("clangdiagnosticpush")\_Pragma("clangdiagnosticignored\"-Wshadow\"")\metamacro_foreach(rac_strongify_,,__VA_ARGS__)\_Pragma("clangdiagnosticpop")//@#ifDEBUG#definerac_keywordifyautoreleasepool{}#else#definerac_keywordifytry{}@catch(...){}#endif#definerac_weakify_(INDEX,CONTEXT,VAR)\CONTEXT__typeof__(VAR)metamacro_concat(VAR,_weak_)=(VAR);//上述代码等价于__weak__typeof__(self)self_weak_=self;#definerac_strongify_(INDEX,VAR)\__strong__typeof__(VAR)VAR=metamacro_concat(VAR,_weak_);//上述代码等价于__strong__typeof__(self)self=self_weak_;debug和release环境下的rac_keywordify,分别对应自动释放池、try/catch。实现了"@"的效果。metamacro_foreach_cxt方法将weakify提供的参数进行了weak处理。在strongify定义中增加了三行_Pragma代码,意为忽略当一个局部变量或类型声明遮盖另一个变量的警告。其中metamacro_foreach方法主要对参数进行了strong处理。解决Block循环引用,@weakify和@strongify必须成对使用,这是因为:使用@weakify(self)之后,会定义一个 __weak 类型的 weak_self 变量,而@strongify(self) 定义了一个 __strong 类型的self指向了weak_self。如图一所示,当只使用@weakify时,Xcode会提示存在一个没有使用过的变量 weak_self ,此时如果不使用@strongify(self),block内部的self 则还是self,相当于@weakify没起作用;如果只使用@strongify(self),就会产生图二所示情况,警告没有定义变量 weak_self ,可见,@strongify是对weak_self 的一个强引用,使用@strongify(self)之后, block内部的self 就不再和外部的self相同。图一.单独使用@weakify图二. 单独使用@strongify在贝壳App循环引用检测实践中,遇到过类似问题,因@weakify和@strongify未成对使用造成内存泄漏。因此,在开发过程中必须保证@weakify和@strongify 成对出现。当然也有例外,如block块的嵌套使用场景。3.5.2 Block嵌套问题开发中,我们需要处理block嵌套的情况,否则依然会有循环引用。一般解决方法是:在外层block上使用@weakify和@strongify,内部的block使用@strongify。代码如下weakify(self)self.block1=^BOOL{@strongify(self)self.block2=^BOOL{@strongify(self)NSLog(@"hello%@",self);};};在block2中,如果不存在第5行代码,此时self持有block2,block2持有self,形成了一个循环引用,在调用self.block1()时就会产生内存泄漏。而代码中,strongify宏定义会去捕捉一个weak_self变量,而这个变量在block1外部已经定义了,所以可以直接单独在block2内部使用strongify。4. 实战4.1 定位问题实际开发中,遇到页面释放,但内存依然泄露的情况。一种是页面内的子元素因为循环引用问题而导致泄露;另一种是子元素因被单例持有而泄露。前者能很快定位到引用环,而后者却是"发现循环引用失败"。示意如下:4.1.1 子元素循环引用如图所示,父View强引用子View们,当父View被释放时,其持有的view1~view3都将被释放,但是view4由于其内部与block形成了循环引用(如图),产生了内存泄漏。4.1.2 子元素被单例持有如图所示,父View 中持有了三个子view,其中view3被单例对象Obj持有。而单例对象是在程序结束时才会释放。当父View释放后,view1,view2响应释放,而view3因为被单例持有,不能释放,进而造成了内存泄漏。上述两类情况都比较容易被忽视,这会导致页面退出后,内存不怎么下降。这是因为:子元素持有图片或大内存对象。在页面多次访问后,内存消耗加剧,甚至最终导致OOM(低端机易复现)。4.2 完善检测我们知道MLeaksFinder会对页面的子元素,如View及其View的SubViews进行泄露检测,因此子元素的循环引用问题,是很方便被检测的。但是如果是全局对象持有的话,一般不存在泄漏对象对全局对象的引用,存在的是全局对象对泄漏对象引用,并没有引用环,因此需要做原有检测进一步优化。采用的方案是:发现泄露对象,正常引用检测失效后,检查是否被全局对象持有。遍历主二进制的Mach-O文件中的__DATA segment __bss section的所有指针,利用对象符号化,找到所有全局OC对象。然后给泄漏对象添加上对全局对象的引用后,如果全局对象也引用了泄漏对象,那自然就出现循环引用了。检测结束后,将泄漏对象关联的全局对象移除之,恢复上下文。示意如下:如上图所示,黄色线是假设存在的,某一个全局变量对内存泄漏对象的强引用关系,蓝色线是人为加上的内存泄漏对象对全局变量对象的强引用。这样一来,就产生了一个环,objx和内存泄漏对象之间互相引用,此时再使用MLeaksFinder工具进行检测,如果存在环,那么假设成立,存在一个全局变量对象强引用了内存泄漏对象。4.3 线下防劣化内存泄露问题容易被忽视,常见于大家默认不开启泄露检测。一方面在于弹窗提醒干扰比较大、发现泄露的非自己所属业务,推动解决繁琐。另一方面是在于自信,不会犯内存泄露这样的低级错误。但是从全局来看,开发和测试过程中,使用内存泄露检测是非常必要的。综合研发“不被打扰” 和 “内存泄露应该及早发现”的需求,提供内存泄露静默提醒,默认不出弹窗,将问题通过机器人发送对应的研发群中。同时在后台记录本版本遇到的所有内存泄露及其解决状态。将内存泄露问题控制在线下,避免问题带入线上,保障App的稳定性。5. 总结本文介绍了iOS循环引用检测的原理和实战。经过阶段性治理,因循环引用导致的内存泄露问题基本解决,内存OOM率也降低了20%+,App的稳定性得到了提升。然而,治理循环引用并非一劳永逸,需要我们在解决和防劣化方面继续探索、实践。 预览时标签不可点 移动端37大前端69移动端 · 目录#移动端上一篇Flutter流畅度优化神器-开源组件keframe详解下一篇Flutter Navigator局部页面切换实践关闭更多小程序广告搜索「undefined」网络结果
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-31 03:48 , Processed in 0.507810 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表