我听说过有人说方法混淆是一种危险的做法。甚至混淆这个名字本身就暗示了它有点欺骗性。
方法混淆是修改映射,使得调用选择器A实际上会调用实现B。其中一个使用场景是扩展闭源类的功能。
我们能否系统化地列出风险,以便任何决定是否使用混淆的人都可以明智地决定是否值得为他们所要做的事而冒这个风险。
例如:
- 命名冲突: 如果类后来扩展其功能以包括您添加的方法名称,它将会导致大量问题。通过合理命名混淆方法来减少风险。
我听说过有人说方法混淆是一种危险的做法。甚至混淆这个名字本身就暗示了它有点欺骗性。
方法混淆是修改映射,使得调用选择器A实际上会调用实现B。其中一个使用场景是扩展闭源类的功能。
我们能否系统化地列出风险,以便任何决定是否使用混淆的人都可以明智地决定是否值得为他们所要做的事而冒这个风险。
例如:
我认为这是一个非常好的问题,可惜大多数答案都绕开了真正的问题,只是说不要使用swizzling。
使用方法混合(swizzling)就像在厨房使用锋利的刀具一样。有些人害怕使用锋利的刀具,因为他们认为会严重伤到自己,但事实上 锋利的刀更安全。
方法混合可以用于编写更好、更高效、更易维护的代码。但也可能被滥用,导致可怕的错误。
与所有设计模式一样,如果我们充分了解了该模式的后果,我们就能更明智地决定是否使用它。单例是一个很好的例子,它是相当有争议的,原因是它们很难正确实现。尽管如此,仍有许多人选择使用单例。同样的情况也适用于方法混合。只有在你充分了解其优点和缺点后,你才应该形成自己的观点。
以下是方法混合的一些缺点:
这些观点都是有道理的,并且通过解决它们,我们可以提高对方法混合的理解以及用于实现结果的方法。我将逐一说明每个观点。
我还没有见过一个安全使用并发的方法混合实现1。但在95%的情况下,你只需要替换一个方法的实现,并希望该实现在程序的整个生命周期中都能被使用。这意味着你应该在+(void)load
中进行方法混合。 load
类方法在应用程序启动时串行执行。如果你在这里做方法混合,就不会遇到并发问题。但是,如果你在+(void)initialize
中混合,你可能会在混合实现和运行时之间出现竞争条件,从而导致运行时处于奇怪的状态。
这是一个关于方法替换的问题,但这也是方法替换的重点所在。目标就是能够改变那些代码。人们认为这很重要的原因在于,你不仅仅是为了一个想改变的NSButton
实例而改变了它,而是为了应用程序中所有的NSButton
实例都改变了。因此,在进行方法替换时应该谨慎,但并不需要完全避免。
可以这样理解...如果你覆盖一个类中的方法并且没有调用父类的方法,可能会导致问题出现。在大多数情况下,父类希望被调用(除非有文档说明)。如果您将相同的思想应用于方法替换,就能避免大部分问题。始终调用原始实现。如果不这样做,您可能会改变太多以至于不安全。
在Cocoa中,命名冲突一直是一个问题。我们经常在分类中添加类名称和方法名称前缀。不幸的是,在我们的语言中,命名冲突是一种瘟疫。但是,在涉及到方法替换时,它们不必成为问题。我们只需要稍微改变一下对方法替换的想法。大多数方法替换都是这样完成的:
@interface NSView : NSObject
- (void)setFrame:(NSRect)frame;
@end
@implementation NSView (MyViewAdditions)
- (void)my_setFrame:(NSRect)frame {
// do custom work
[self my_setFrame:frame];
}
+ (void)load {
[self swizzle:@selector(setFrame:) with:@selector(my_setFrame:)];
}
@end
这个方法运行良好,但是如果 my_setFrame:
在其他地方定义会发生什么?这个问题并不是独特于交换方法实现的,但是我们可以通过一些方式解决。此解决方法还有一个附加优势,可以解决其他潜在问题。这里是我们采取的替代方案:
@implementation NSView (MyViewAdditions)
static void MySetFrame(id self, SEL _cmd, NSRect frame);
static void (*SetFrameIMP)(id self, SEL _cmd, NSRect frame);
static void MySetFrame(id self, SEL _cmd, NSRect frame) {
// do custom work
SetFrameIMP(self, _cmd, frame);
}
+ (void)load {
[self swizzle:@selector(setFrame:) with:(IMP)MySetFrame store:(IMP *)&SetFrameIMP];
}
@end
虽然这看起来有点不像Objective-C(因为它使用了函数指针),但它避免了任何命名冲突。从原理上讲,它与标准交换操作做的是完全相同的事情。对于一直在使用旧定义的人来说,这可能是一个小小的改变,但最终我认为这是更好的方法。交换方法定义如下:
typedef IMP *IMPPointer;
BOOL class_swizzleMethodAndStore(Class class, SEL original, IMP replacement, IMPPointer store) {
IMP imp = NULL;
Method method = class_getInstanceMethod(class, original);
if (method) {
const char *type = method_getTypeEncoding(method);
imp = class_replaceMethod(class, original, replacement, type);
if (!imp) {
imp = method_getImplementation(method);
}
}
if (imp && store) { *store = imp; }
return (imp != NULL);
}
@implementation NSObject (FRRuntimeAdditions)
+ (BOOL)swizzle:(SEL)original with:(IMP)replacement store:(IMPPointer)store {
return class_swizzleMethodAndStore(self, original, replacement, store);
}
@end
在我看来,这是最重要的一点。这就是标准方法混淆不应该被执行的原因。您正在更改传递给原始方法实现的参数。这就是它发生的地方:
[self my_setFrame:frame];
这行代码的作用是:
objc_msgSend(self, @selector(my_setFrame:), frame);
使用运行时查找my_setFrame:
的实现。一旦找到了实现,它将使用相同的参数调用该实现。它找到的实现是setFrame:
的原始实现,因此它继续调用该实现,但是_cmd
参数不是像应该的那样setFrame:
,而是my_setFrame:
。原始实现被调用时带有它从未预期接收到的参数。这不好。
有一个简单的解决方案— 使用上面定义的备选交换技术。参数将保持不变!
方法交换的顺序很重要。假设只有NSView
上定义了setFrame:
,请想象以下事情发生的顺序:
[NSButton swizzle:@selector(setFrame:) with:@selector(my_buttonSetFrame:)];
[NSControl swizzle:@selector(setFrame:) with:@selector(my_controlSetFrame:)];
[NSView swizzle:@selector(setFrame:) with:@selector(my_viewSetFrame:)];
当NSButton
的方法被篡改时会发生什么呢?大多数篡改都将确保不替换所有视图的setFrame:
实现,因此它将使用现有实现来重新定义NSButton
类中的setFrame:
,以便交换实现不会影响所有视图。现有实现是在NSView
上定义的实现。当在NSControl
上进行篡改时(再次使用NSView
实现),同样的事情也会发生。当您在按钮上调用setFrame:
时,它将调用您的篡改方法,然后直接跳转到最初在NSView
上定义的setFrame:
方法。不会调用NSControl
和NSView
篡改实现。
但如果顺序是什么:
[NSView swizzle:@selector(setFrame:) with:@selector(my_viewSetFrame:)];
[NSControl swizzle:@selector(setFrame:) with:@selector(my_controlSetFrame:)];
[NSButton swizzle:@selector(setFrame:) with:@selector(my_buttonSetFrame:)];
由于视图交换是首先发生的,因此控制器交换将能够提起正确的方法。同样,由于控制器交换在按钮交换之前,按钮将提取控制器交换的setFrame:
实现。这有点令人困惑,但这是正确的顺序。我们如何确保这个顺序?
再次,只需使用load
来交换事物。如果你在load
中进行交换,并且你只对正在加载的类进行更改,那么你就会很安全。load方法保证超类的load方法将在任何子类之前被调用。我们将得到完全正确的顺序!
观察传统定义的交换方法,我认为很难理解发生了什么。但是,观察以上替代的交换方式,就很容易理解了。这个问题已经解决了!
调试时的困惑之一是看到一个奇怪的回溯,在其中交换名称混合在一起,让你的头脑一片混乱。同样,替代实现解决了这个问题。你将在回溯中看到明确命名的函数。尽管如此,交换仍然很难调试,因为很难记住交换所产生的影响。好好记录你的代码(即使你认为只有你自己会看到它)。遵循良好的实践,你就没问题了。这并不比多线程代码更难调试。
如果使用得当,则方法交换是安全的。一个简单的安全措施是只在load
中进行交换。像编程中的许多其他事物一样,它可能是危险的,但理解其后果将允许你正确地使用它。
1使用上述定义的交换方法,如果你使用跳板,你可以使事物变得线程安全。你需要两个跳板。在方法的开始处,你需要将函数指针store
分配给一个旋转直到store
指向的地址改变的函数。这将避免在设置store
函数指针之前调用交换方法的任何竞争条件。然后,在实现没有在类中已经被定义的情况下,你需要使用跳板,并且以适当方式查找并调用超类方法。定义方法,使其动态查找超级实现,将确保交换调用的顺序无关紧要。
首先,我将准确地定义我所说的方法交换:
方法交换比这更普遍,但这是我感兴趣的情况。
危险:
原始类的更改。我们不拥有要交换的类。如果该类发生更改,我们的交换可能会停止工作。
难以维护。不仅需要编写和维护交换方法,还需要编写和维护执行交换的代码
难以调试。跟踪交换的流程很困难,有些人甚至可能没有意识到进行了交换。如果由于原始类的更改引入了漏洞,它们将很难解决。
总之,您应该尽量减少方法交换,并考虑原始类的更改可能如何影响您的交换。此外,您应该清楚地注释和记录自己在做什么(或完全避免使用它)。
实际上,交换方法本身并不是真正危险的。问题在于,正如你所说,它经常用于修改框架类的行为。这意味着你假设了解那些私有类的工作方式,这是“危险”的。即使你的修改今天有效,也有可能苹果在未来更改类,导致你的修改失效。此外,如果许多不同的应用程序都这样做,那么苹果要更改框架而不破坏大量现有软件的难度就会增加。
如果使用得当,它可以导致优雅的代码,但通常只会导致混乱的代码。
我认为除非你知道它对于特定的设计任务提供了非常优雅的机会,否则应该禁止使用它,但你需要清楚地知道它为什么适用于该情况,以及为什么其他替代方案不能为该情况提供优雅的解决方案。
例如,方法交换的一个很好的应用是isa交换,这是ObjC实现键值观察的方式。
一个糟糕的例子可能是依赖方法交换作为扩展类的手段,这会导致极高的耦合。
尽管我用过这种技术,但我想指出:
我认为最大的危险在于无意中创造出许多不必要的副作用。这些副作用可能表现为“错误”,从而使您走上错误的解决方案之路。根据我的经验,危险在于难以阅读、混乱和令人沮丧的代码。就像某人在C++中过度使用函数指针一样。
您可能最终会得到看起来奇怪的代码,例如:
- (void)addSubview:(UIView *)view atIndex:(NSInteger)index {
//this looks like an infinite loop but we're swizzling so default will get called
[self addSubview:view atIndex:index];
源自一些与UI技巧相关的实际生产代码。
方法混淆在单元测试中非常有用。
它允许您编写模拟对象并使用该模拟对象代替真实对象。这样您的代码可以保持整洁,您的单元测试具有可预测的行为。例如,您想要测试一些使用CLLocationManager的代码。您的单元测试可以混淆startUpdatingLocation方法,以便将预定的一组位置提供给您的委托,并且您的代码无需更改。