我认为这是一个非常好的问题,但遗憾的是,大多数答案都没有解决真正的问题,而是绕开了这个问题,只是说不要使用 swizzling。
使用方法咝咝作响就像在厨房里使用锋利的刀。有些人害怕锋利的刀,因为他们认为他们会严重割伤自己,但事实是sharp knives are safer。
方法调配可用于编写更好、更高效、更易于维护的代码。它也可能被滥用并导致可怕的错误。
背景
与所有设计模式一样,如果我们完全了解该模式的后果,我们就能够就是否使用它做出更明智的决定。单例是一个很有争议的例子,并且有充分的理由——它们真的很难正确实现。不过,许多人仍然选择使用单例。关于调酒也可以这样说。一旦你充分了解了好与坏,你应该形成自己的观点。
讨论
以下是方法调配的一些陷阱:
- 方法混合不是原子的
- 更改未拥有代码的行为
- 可能的命名冲突
- Swizzling 改变了方法的参数
- 调酒的顺序很重要
- 难以理解(看起来是递归的)
- 难以调试
这些观点都是正确的,在解决它们时,我们可以提高我们对方法混合的理解以及用于实现结果的方法。我会一次接一个。
方法调配不是原子的
我还没有看到可以安全地同时使用的方法混合实现1。这实际上在 95% 的情况下都不是问题,您希望使用方法调配。通常,您只是想替换一个方法的实现,并且希望该实现在程序的整个生命周期中都使用。这意味着你应该在+(void)load 中调配你的方法。 load 类方法在应用程序启动时串行执行。如果您在这里进行调配,您将不会遇到任何并发问题。但是,如果您要在 +(void)initialize 中调配,您的调配实现中可能会出现竞争条件,并且运行时可能会处于奇怪的状态。
改变非拥有代码的行为
这是 swizzling 的一个问题,但它是重点。目标是能够更改该代码。人们指出这很重要的原因是因为您不仅要更改要更改的 NSButton 的一个实例,还要更改应用程序中的所有 NSButton 实例。出于这个原因,你在调酒时应该小心,但你不需要完全避免它。
这样想……如果你在一个类中重写了一个方法,而你没有调用超类的方法,你可能会导致问题出现。在大多数情况下,超类期望调用该方法(除非另有说明)。如果你将同样的想法应用到 swizzling 上,你已经涵盖了大多数问题。始终调用原始实现。如果你不这样做,你可能改变太多而不安全。
可能的命名冲突
命名冲突是整个 Cocoa 中的一个问题。我们经常在类别中添加类名和方法名的前缀。不幸的是,命名冲突是我们语言中的瘟疫。但是,在 swizzling 的情况下,它们不必如此。我们只需要稍微改变一下我们对方法混杂的看法。大多数 swizzling 是这样完成的:
@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: 被定义在其他地方会发生什么?这个问题并不是 swizzling 所独有的,但无论如何我们都可以解决它。该解决方法还具有解决其他陷阱的额外好处。下面是我们的做法:
@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(因为它使用函数指针),但它避免了任何命名冲突。原则上,它的作用与标准调酒完全相同。对于一直使用 swizzling 的人来说,这可能有点改变,因为它已经定义了一段时间,但最后,我认为它更好。 swizzling 方法是这样定义的:
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:。最初的实现是用一个它从未预料到会收到的参数调用的。这不好。
有一个简单的解决方案 - 使用上面定义的替代混合技术。参数将保持不变!
调酒的顺序很重要
方法混合的顺序很重要。假设 setFrame: 仅在 NSView 上定义,想象一下这样的顺序:
[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 上的方法被混用时会发生什么?好吧,大多数 swizzling 将确保它不会替换所有视图的 setFrame: 的实现,因此它会拉起实例方法。这将使用现有实现在NSButton 类中重新定义setFrame:,以便交换实现不会影响所有视图。现有的实现是在NSView 上定义的。在NSControl 上调配时也会发生同样的事情(再次使用NSView 实现)。
当你在一个按钮上调用setFrame: 时,它会因此调用你的swizzled 方法,然后直接跳转到最初在NSView 上定义的setFrame: 方法。不会调用 NSControl 和 NSView swizzled 的实现。
但是如果订单是:
[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 方法保证超类加载方法将在任何子类之前被调用。我们会得到完全正确的订单!
难以理解(看起来是递归的)
查看传统定义的 swizzled 方法,我认为很难判断发生了什么。但是看看我们在上面进行的调动的另一种方式,它很容易理解。这个已经解决了!
难以调试
调试过程中的一个困惑是看到一个奇怪的回溯,其中混杂的名称混合在一起,一切都在你的脑海中变得混乱。同样,替代实现解决了这个问题。您将在回溯中看到明确命名的函数。尽管如此,调酒可能很难调试,因为很难记住调酒有什么影响。好好记录你的代码(即使你认为你是唯一会看到它的人)。遵循良好的做法,你会没事的。调试起来并不比多线程代码难。
结论
如果使用得当,方法调配是安全的。您可以采取的一个简单的安全措施是只在load 中调酒。就像编程中的许多事情一样,它可能很危险,但了解其后果将使您能够正确使用它。
1 使用上面定义的 swizzling 方法,如果您要使用蹦床,您可以使事情线程安全。你需要两个蹦床。在方法开始时,您必须将函数指针store 分配给一个旋转的函数,直到store 指向的地址发生变化。这将避免在您能够设置 store 函数指针之前调用 swizzled 方法的任何竞争条件。然后,您需要在类中尚未定义实现的情况下使用蹦床,并进行蹦床查找并正确调用超类方法。定义方法使其动态查找超级实现将确保调动调用的顺序无关紧要。