背景
在最近的项目开发中遇到了一个很别扭的问题。项目本身是一个SDK,供业务方App集成使用。SDK内部有网络请求相关的逻辑,所以对外依赖AFNetworking(在SDK内部依赖一个第三方库本身不是一个合理设计,这次遇到的坑也是因为这个)。在SDK集成到业务方App的过程中发现,A、B两个业务方的App里虽然都集成了AFNetworking,但集成的版本有差异,导致部分API存在差异,例如 AFURLSessionManager 类里,下载相关的接口:
// 新版本:
- (NSURLSessionDownloadTask *)downloadTaskWithRequest:(NSURLRequest *)request
progress:(nullable void (^)(NSProgress *downloadProgress))downloadProgressBlock
destination:(nullable NSURL * (^)(NSURL *targetPath, NSURLResponse *response))destination
completionHandler:(nullable void (^)(NSURLResponse *response, NSURL * _Nullable filePath, NSError * _Nullable error))completionHandler;
// 旧版本
- (NSURLSessionDownloadTask *)downloadTaskWithRequest:(NSURLRequest *)request
progress:(NSProgress * __autoreleasing *)progress
destination:(NSURL * (^)(NSURL *targetPath, NSURLResponse *response))destination
completionHandler:(void (^)(NSURLResponse *response, NSURL *filePath, NSError *error))completionHandler;
这时需要SDK内部做一些适配,来适应不同版本AFN。但这里的难点在于 新、旧接口的方法名、参数名、返回值等的定义都是一致的,只有 progress 参数的类型不一致,而且没有其他可替代的接口。
一种解决方案是定义一个宏来标记依赖的AFN版本,在AFN头文件以及接口调用的地方用宏判断进行隔离。这种方法的缺点是SDK需要针对不同的业务App使用不同的编译项进行编译,将来维护起来会比较麻烦。
在这种场合下,NSInvocation就显得非常有用了。
解决方案
贴一段官方文档里对NSInvocation的定义:
An Objective-C message rendered as an object.
NSInvocation object contains all the elements of an Objective-C message: a target, a selector, arguments, and the return value. Each of these elements can be set directly, and the return value is set automatically when the NSInvocation object is dispatched.
简单来说,借助NSInvocation,我们可以向"Objective-C对象发送消息"这一操作保存到一个对象里,并执行这一操作。NSInvocation的几个主要的属性和接口:
@property (readonly) BOOL argumentsRetained;
@property (nullable, assign) id target;
@property SEL selector;
+ (NSInvocation *)invocationWithMethodSignature:(NSMethodSignature *)sig;
- (void)getReturnValue:(void *)retLoc;
- (void)setReturnValue:(void *)retLoc;
- (void)getArgument:(void *)argumentLocation atIndex:(NSInteger)idx;
- (void)setArgument:(void *)argumentLocation atIndex:(NSInteger)idx;
- (void)invoke;
- (void)invokeWithTarget:(id)target;
使用的过程也比较容易理解:
- 使用方法签名创建创建NSInvocation对象
- 设置target (发送消息的对象)
- 设置selector
- 设置参数
- 配置返回值的指针
- invoke 执行
- 从返回值指针里获取返回值
针对背景里介绍的问题,解决的代码如下:
NSURLSessionDownloadTask __unsafe_unretained *downloadTaskP;
NSProgress __unsafe_unretained *progressP;
NSProgress* __unsafe_unretained *progressAddress = &progressP;
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:
[[manager class] instanceMethodSignatureForSelector:@selector(downloadTaskWithRequest:progress:destination:completionHandler:)]];
[invocation setSelector:@selector(downloadTaskWithRequest:progress:destination:completionHandler:)];
[invocation setTarget:manager];
[invocation setArgument:&request atIndex:2];
if (afn_version_new) {
[invocation setArgument:&progressBlock atIndex:3];
} else {
[invocation setArgument:&progressAddress atIndex:3];
}
[invocation setArgument:&destinationBlock atIndex:4];
[invocation setArgument:&completionBlock atIndex:5];
[invocation invoke];
[invocation getReturnValue:&downloadTaskP];
NSURLSessionDownloadTask* downloadTask = downloadTaskP;
NSProgress* progress = progressP;
if (progress) {
// 处理NSProgress对象
}
[downloadTask resume];
其中,
- 参数的设置是从index = 2 开始的
- 设置参数时传递的是参数对象的地址 (NSInvocation对象设置参数的方法,接收的是 void *)
尤其需要注意的是,针对原方法参数本身类型就是 指针的地址以及返回值,需要考虑Objective-C的内存管理规则。
例如以上代码中,对返回值 downloadTask的处理,如果直接传递一个 NSURLSessionDownloadTask** 参数进去,最终程序运行的时候会crash,且crash的原因是 downloadTask 被多次dealloc。造成这种crash的原因是,NSInvocation在处理返回值时,仅会向我们传递的指针指向的地址里写入数据,不会做 retain/release等OC对象内存管理相关的操作,而我们直接用NSURLSessionDownloadTask* 接收返回值时,OC会认为接收到的对象已经被retain过了,导致downloadTask被重复dealloc,引起crash。
所以以上代码中,对接收返回值的指针标记了 __unsafe_unretained:
NSURLSessionDownloadTask __unsafe_unretained *downloadTaskP;
......
NSURLSessionDownloadTask* downloadTask = downloadTaskP;
对NSProgress ** 参数的处理也是一样的道理。
内存管理部分参考这个问答: https://stackoverflow.com/questions/22018272/nsinvocation-returns-value-but-makes-app-crash-with-exc-bad-access
网友评论