QMUI / LookinServer

Free macOS app for iOS view debugging.
https://lookin.work
MIT License
2.66k stars 400 forks source link

UIView hook init 延迟释放可能引发逻辑问题 #94

Closed fabcz closed 2 years ago

fabcz commented 2 years ago

Demo.zip

背景


先上一段不规范的代码

@interface ViewController ()
@property (nonatomic, weak) CCView *cview;
@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    self.cview = [[CCView alloc] initWithFrame:CGRectMake(20, 60, 300, 500)];
//    LogCount(@"initWithFrameAfter", self.cview)
    self.cview.backgroundColor = UIColor.grayColor;
    [self.view addSubview:self.cview];
//    LogCount(@"addSubviewAfter", self.cview)
}

@end

@implementation CCView
- (void)dealloc
{
    NSLog(@"%@-dealloc", self.class);
}
@end

正常情况下 App 跑起来界面是看不到灰色控件的(property 修饰符是 weak),但在 Debug 环境下有 lookin 的加持,控件得以正常展示,而上线后没有 lookin 就凉了界面直接消失

问题原因


引发这个问题的原因是 hook 了 UIView 的 init 方法,交换的方法内部建了个临时变量引发了 TLS 优化导致引用计数 + 1,方法内部引用计数不平衡因此就延迟释放了

@implementation UIView (Hook)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Method oriMethod = class_getInstanceMethod(UIView.class, @selector(initWithFrame:));
        Method newMethod = class_getInstanceMethod(UIView.class, @selector(cc_initWithFrame:)); // 原用法
//        Method newMethod = class_getInstanceMethod(UIView.class, @selector(correct_initWithFrame:)); // 修复方案 一
//        Method newMethod = class_getInstanceMethod(UIView.class, @selector(initWithFrame_correct:)); // 修复方案 二
        method_exchangeImplementations(oriMethod, newMethod);
    });
}

- (instancetype)cc_initWithFrame:(CGRect)frame
{
    UIView *view = [self cc_initWithFrame:frame];
    view.layer.lks_hostView = view;
    return view;
}
@end

问题分析


先过一下 objc4 源码里几个函数的基本概念

// Try to accept an optimized return. // Returns the disposition of the returned object (+0 or +1). // An un-optimized return is +0. static ALWAYS_INLINE ReturnDisposition acceptOptimizedReturn() { ReturnDisposition disposition = getReturnDisposition(); setReturnDisposition(ReturnAtPlus0); // reset to the unoptimized state return disposition; }

- [`objc_autoreleaseReturnValue`](https://github.com/0xxd0/objc4/blob/b73f5d4700db192ffdc91b5ead36f3ddf8bfe174/objc4/runtime/NSObject.mm#L2136-L2143)、[`prepareOptimizedReturn`](https://github.com/0xxd0/objc4/blob/b73f5d4700db192ffdc91b5ead36f3ddf8bfe174/objc4/runtime/objc-object.h#L1405-L1419)
    - 若被优化,则利用 TLS 进行 cache,引用计数 + 1
    - 若未优化,加入 autoreleasepool 中,引用计数不变

// Prepare a value at +1 for return through a +0 autoreleasing convention. id objc_autoreleaseReturnValue(id obj) { if (prepareOptimizedReturn(ReturnAtPlus1)) return obj; return objc_autorelease(obj); }

// Try to prepare for optimized return with the given disposition (+0 or +1). // Returns true if the optimized path is successful. // Otherwise the return value must be retained and/or autoreleased as usual. static ALWAYS_INLINE bool prepareOptimizedReturn(ReturnDisposition disposition) { ASSERT(getReturnDisposition() == ReturnAtPlus0);

if (callerAcceptsOptimizedReturn(__builtin_return_address(0))) {
    if (disposition) setReturnDisposition(disposition);
    return true;
}

return false;

}

 - [`objc_storeStrong`](https://github.com/0xxd0/objc4/blob/b73f5d4700db192ffdc91b5ead36f3ddf8bfe174/objc4/runtime/NSObject.mm#L310-L320)
    - 销毁对象,对传入的 prev 执行 release,引用计数 - 1

void objc_storeStrong(id location, id obj) { id prev = location; if (obj == prev) { return; } objc_retain(obj); *location = obj; objc_release(prev); }

- [`objc_unsafeClaimAutoreleasedReturnValue`](https://github.com/0xxd0/objc4/blob/b73f5d4700db192ffdc91b5ead36f3ddf8bfe174/objc4/runtime/NSObject.mm#L2165-L2172)
    - 调用方不强持有返回的对象,引用计数不变

// Accept a value returned through a +0 autoreleasing convention for use at +0. id objc_unsafeClaimAutoreleasedReturnValue(id obj) { if (acceptOptimizedReturn() == ReturnAtPlus0) return obj; return objc_releaseAndReturn(obj); }

#### 原用法

汇编代码简化如下:临时保存的局部变量,加入 TLS 进行优化 lookin`-[UIView(Hook) cc_initWithFrame:]: objc_msgSend objc_retainAutoreleasedReturnValue // 被优化,引用计数不变 objc_retain // retain 引用计数 + 1 objc_storeStrong // release 引用计数 - 1 objc_autoreleaseReturnValue // cache 到 TLS,引用计数 + 1

最终:引用计数 + 1,方法内部不平衡
### 问题修复
---
#### 方案一

汇编代码简化如下:无临时变量持有返回值,不使用 TLS 进行优化 lookin`-[UIView(Hook) correct_initWithFrame:]: objc_msgSend objc_unsafeClaimAutoreleasedReturnValue // 引用计数不变

最终:引用计数不变,方法内部平衡
#### 方案二

汇编代码简化如下:方法以 init 开头 lookin`-[UIView(Hook) initWithFrame_correct:]: objc_msgSend objc_retain // 引用计数 + 1 objc_retain // 引用计数 + 1 objc_storeStrong // 引用计数 - 1 objc_storeStrong // 引用计数 - 1


 最终:引用计数不变,方法内部平衡
### 结论
- 处理 init 开头的方法时也需要以 [init 开头](https://clang.llvm.org/docs/AutomaticReferenceCounting.html#:~:text=The%20implicit%20self%20parameter%20of%20a%20method%20may%20be%20marked%20as%20consumed%20by%20adding%20__attribute__((ns_consumes_self))%20to%20the%20method%20declaration.%20Methods%20in%20the%20init%20family%20are%20treated%20as%20if%20they%20were%20implicitly%20marked%20with%20this%20attribute.),否则编译系统的优化会使得 xx_init 方法内部引用计数不平衡

[黑幕背后的 Autorelease](https://blog.sunnyxx.com/2014/10/15/behind-autorelease/)
[objc_autoreleaseReturnValue 和 objc_retainAutoreleasedReturnValue 函数对 ARC 优化](https://blog.csdn.net/wtl1804/article/details/117163336)
[iOS ARC 对 init 方法的处理](https://juejin.cn/post/6844903954724159496)
fabcz commented 2 years ago

76

hughkli commented 2 years ago

厉害了,我 merge 一下你的 commit,然后明天周末发个 1.0.6

sushushu commented 2 years ago

太强啦

zekunyan commented 2 years ago

偶然看到,厉害

zekunyan commented 2 years ago

我们内部的一些库都是全局MRC,也是为了防止类似这种问题

TheLittleBoy commented 2 years ago

- (instancetype)cc_initWithFrame:(CGRect)frame { UIView *view = [self cc_initWithFrame:frame]; view.layer.lks_hostView = view; return view; }

为什么把 view.layer.lks_hostView = view; 注释掉了

songxing10000 commented 2 years ago

膜拜大佬

zekunyan commented 2 years ago

@TheLittleBoy 应该是为了方便讲解Case,简化了反编译后的代码流程。正式的 Pull Request还是保留的

TheLittleBoy commented 2 years ago

@TheLittleBoy 应该是为了方便讲解Case,简化了反编译后的代码流程。正式的 Pull Request还是保留的

方案二 和 原方案 有什么区别吗?

zekunyan commented 2 years ago

@TheLittleBoy 仔细看,方案二是 initWithFrame_correct, init开头的,编译会有优化,就刚好 “匹配” 上了

fabcz commented 2 years ago

@TheLittleBoy 仔细看,方案二是 initWithFrame_correct, init开头的,编译会有优化,就刚好 “匹配” 上了

确实是刚好"匹配"上,解决问题的代码很简单就是方法重命名以 init 开头,算是 iOS 的冷门知识吧

fabcz commented 2 years ago

我们内部的一些库都是全局MRC,也是为了防止类似这种问题

掌握在自己手上确实靠谱,但同时这些库维护成本也高了,看团队如何取舍吧,不然时不时来个类似的问题查起来也痛苦

zekunyan commented 2 years ago

我们内部的一些库都是全局MRC,也是为了防止类似这种问题

掌握在自己手上确实靠谱,但同时这些库维护成本也高了,看团队如何取舍吧,不然时不时来个类似的问题查起来也痛苦

是的=。=,维护起来确实要小心,所以尽量控制在很小的范围内,最核心的部分

LarionLee commented 2 years ago

这个应该不是iOS16或者XCode14的新特性吧,为啥老版本的系统上没有问题,iOS16上大量出现呢

LarionLee commented 2 years ago

另外,是不是只要是return一个局部对象的时候都会有这样的问题啊,而不只是init方法

CoderXLL commented 1 year ago

今天刚看到,很棒👍🏻