ASIHTTPRequest iOS7下内存泄漏问题解决记录

Leak of ASIHTTPRequest on iOS7


这是2013年下半年解决iOS7下ASIHTTPRequest内存泄露时所做的记录,现在搬运过来了。

现在这个修复方法已经被merge到ASIHTTPRequest的主分支上,经过测试可以通过apple的审核,大家可以直接从主分支fork并使用了。

发现问题

iOS7发布后,我们对产品进行了iOS7的适配。适配完成之后的某天,我使用Leaks对产品的新版本进行内存泄漏检测时发现ASIHTTPRequest存在内存泄漏问题,当时使用的设备是iTouch5,系统为iOS7.0.2。

Leaks检测结果

Leaks

(ps:使用的是ASIHTTPRequest iPhoneSample的检测图,结果是一样的)

发现之初,我以为是某处ASIHTTPRequest使用不当导致的泄漏,于是把leaks中的堆栈全部都检查了一边,但没有发现任何产品工程中的代码(其中一处泄漏的堆栈如图)。

Leaks中StackTrace结果

StackTrace

由于在iOS7发布之前的所有版本中并未看到类似的内存泄漏,所以我就开始怀疑是ASIHTTPRequest在iOS7才产生的。于是我在iOS5和iOS6的设备上进行了Leak Profile,结果没有发现任何泄漏。

对于这样的结果我仍然不是很确信,因为项目的需要我们对ASIHTTPRequest进行了一定的定制,修改了其中一部分代码。为了确定问题确实是出在ASIHTTPRequest上,我去github上翻出了ASIHTTPRequest的repo,pull了最新的代码,用Leaks在iOS7系统上进行了profile。在profile过程中我对iPhone Sample中的每个Tab以此进行了测试,结果在SynchronousQueue上并没有发现内存泄漏,在Upload上发现了和之前一样泄漏。随后在iOS5和iOS6上也进行了一样的测试,结果依然是没有任何泄漏。

自此确定了这是ASIHTTPRequest在iOS7下特有的内存泄漏,并且只会出现在有POST body的情况下。


寻找解答

发现问题之后,我仔细查看了Leaks Profile的结果,发现泄漏集中在ASIInputStream上,应该是在使用的过程中release方法在某种情况下没有被调用到。于是我重写了ASIIputStream的release方法并在其中断点,分别在iOS7和iOS6进行调试后发现iOS7比iOS6少了一次Release的调用,堆栈如图所示。

缺少的Release的断点Stack Trace结果

Release

从堆栈来看似乎是 CoreReadStreamFromCFReadStream 这个类的析构函数在iOS7下没有被调用到。当时就感觉没救了,这是私有类,想要强行触发析构似乎是不可能的,能做的就是尽量减少其中的泄漏。于是我们做了如下修改:

1
2
3
4
5
6
- (void)close
{
    [stream close];
    [stream release];
    stream = nil;
}

修改了ASIInputStreamclose方法,在close完成后把其中的NSInputStream对象release掉,以减少内存泄漏,同时保证ASIHTTPRequest不被重复使用(因为其中的NSInputStream已经被release了无法再使用)。

这样一来泄漏有了一定的减少,如图。

修改后的Leaks检测结果

Leakss

但这样并不能真正解决问题,泄漏依然存在,但我一时也想不到很好的办法,于是只能给repo发issue期望能够得到原作者的回复(虽然我知道希望不大,这哥们很久没管这事了- -)。 这是我发的issue:https://github.com/pokeb/asi-http-request/issues/378


解决问题

发issue大约一周后的某一天收到github的邮件,说有人回复我的issue了,进去一看有一个好心人这样解答道:

@mjohnson12

The leak is because ASIInputStream is being cast to a CFReadStreamRef but ASIInputStream does not derive from NSInputStream it just wraps it.

My Solution is to get rid of ASIInputStream and create a NSInputStream instead in the ASIHTTPRequest startRequest: method.

It breaks using the metrics that ASIInputStream records but I wasn't using them.

I'm using a fairly old version of ASIHTTPRequest v.1.6.2 so your milage may vary.

于是我按照他的做法,把ASIHTTPRequest里的ASIInputStream全部替换成了NSInputStream,再用Leaks Profile的时候泄漏果真消失了。也正如这位仁兄所说的,ASIInputStream只是把自己伪装成一个NSInputStream,并且实现了一些NSInputStream的接口,实际都是由其中包含的NSInputStream实例完成的。ASIInputStream这个类的功能主要是用来做流量限制,如果不需要这个功能的话,直接把ASIInputStream替换成NSInputStream即可解决问题。

那么如果我把ASIInputStream继承自NSInputStream的话是不是就能既保留流量限制功能又解决泄漏问题了呢?于是我开始尝试继承NSInputStream,其中碰到了一些困难。NSInputStream的init方法都是写在一个Category里的,无法被继承- -!。

1
2
3
4
5
6
7
8
9
@interface NSInputStream (NSInputStreamExtensions)
- (id)initWithData:(NSData *)data;
- (id)initWithFileAtPath:(NSString *)path;
- (id)initWithURL:(NSURL *)url NS_AVAILABLE(10_6, 4_0);

+ (id)inputStreamWithData:(NSData *)data;
+ (id)inputStreamWithFileAtPath:(NSString *)path;
+ (id)inputStreamWithURL:(NSURL *)url NS_AVAILABLE(10_6, 4_0);
@end

经过一番google我在git上发现了一个repo:https://github.com/bjhomer/HSCountingInputStream

这个repo中实现了对于NSInputStream的继承。仔细阅读完成后发现其实所谓的继承也只不过时在类的@interface中声明了一下继承自NSInputStream而已,实际的工作还是由类中的一个NSInputStream实例完成的,但相比于ASIInputStream这个repo里的实现多了几个方法:

1
2
3
4
5
6
7
8
9
//私有的CFRunLoopRef schedule、unschedule方法
- (void)_scheduleInCFRunLoop:(CFRunLoopRef)aRunLoop forMode:(CFStringRef)aMode;
- (BOOL)_setCFClientFlags:(CFOptionFlags)inFlags
                 callback:(CFReadStreamClientCallBack)inCallback
                  context:(CFStreamClientContext *)inContext;
- (void)_unscheduleFromCFRunLoop:(CFRunLoopRef)aRunLoop forMode:(CFStringRef)aMode;

//NSInputStream的代理方法
- (void)stream:(NSStream *)aStream handleEvent:(NSStreamEvent)eventCode;

这些方法的作用大家可以参考链接:http://blog.octiplex.com/2011/06/how-to-implement-a-corefoundation-toll-free-bridged-nsinputstream-subclass/

简单的说就是NSInputStream需要同时支持NSRunLoopCFRunLoopRef的schedule和unschedule方法,那几个私有方法就是负责CFRunLoopRef的schedule,NSInputStream的代理方法则是负责事件的传递。这使我联想到了之前提到的没有被调用的release方法,iOS6下它的堆栈中正好有CFRunLoopRef的一些方法。莫非这就是ASIInputStream内存泄漏的原因所在?

我当时的猜测是,iOS7下的内存泄漏是ASIInputStream伪装的不够像而导致的,之所以这么认为因为虽然有泄漏但ASIInputStream的功能依然存在,HTTP请求并未因此失效,这说明ASIInputStream还是被bridge成了CFReadStream,但只是因为少了和NSInputStream一样的unschedule方法导致其没有被正常unschedule。如果我把ASIInputStream的unschedule方法补上是否就可以解决问题?

我把上述的几个方法在ASIInputStream中实现了以后再进行Leaks Profile,内存泄漏果然如预期的那样消失了!在ASIInputStreamrelease方法中打断点后发现也能够正常调用了。问题到这里算是解决了。接下来要解决这些私有方法调用可能会碰到的审核不通过问题,正好上面那篇文章里提供了思路,用runtime把三个私有方法重定向到自定义的方法上(方法名只是把”_“去掉了)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+ (BOOL) resolveInstanceMethod:(SEL) selector
{
    NSString * name = NSStringFromSelector(selector);

    if ( [name hasPrefix:@"_"] )
    {
        name = [name substringFromIndex:1];
        SEL aSelector = NSSelectorFromString(name);
        Method method = class_getInstanceMethod(self, aSelector);

        if ( method )
        {
            class_addMethod(self,
                            selector,
                            method_getImplementation(method),
                            method_getTypeEncoding(method));
            return YES;
        }
    }
    return [super resolveInstanceMethod:selector];
}

至此大功告成,问题顺利解决。但这个问题之所以会出现的真正缘由我还是没有弄清楚,Apple在iOS7下究竟做了什么,哪位大神如果知道的话还请告知。。感激不尽 ~_~。

附上修改过后的ASIInputStream代码


后记

问题没解决的时候其他同事建议我更换成AFNetworking等等其他开源库,因为这些库更新快文档全,ASIHTTPRequest接口复杂、代码繁多而且如今已年久失修无人维护了。确实如此,但由于一些项目上的原因我们无法更换,况且ASIHTTRequest在效率上略好,并且拥有其他基于NSURLConnection的库不具备一些功能。在解决问题的过程中也让我对NSInputStream的工作机制有了更深的理解,可谓一石二鸟。由于鄙人能力有限,其中一些地方可能说的有错误或者有纰漏的话还请大神们指正:)



原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

Comments