Method Swizzling is often called Objective-C black magic, but in practice it is a precise engineering tool — when your product manager asks you to add tracking to 50 ViewControllers without touching any existing code. The product manager’s requirement: every page appearance needed to report data. Adding code to each Controller one by one would have been a maintenance nightmare. Later I discovered Method Swizzling and understood what “non-intrusive” really means — no changes to business code, no imports, and features just work.
Why Non-intrusive Insertion Matters
There are two main traditional ways to add features. One is through base class inheritance — for example, a BaseViewController that all pages inherit, with tracking code added uniformly. This approach is precise because only pages inheriting the base class get tracked — system controllers like UINavigationController and UIAlertController don’t get mixed in. But the limitation is obvious: you need to design the inheritance hierarchy upfront. For legacy projects or SDKs meant for other teams, it can be challenging.
The other approach uses Runtime for Method Swizzling, directly intercepting system methods. Its advantage is true zero-code intrusion — global coverage without modifying the existing project. The downside is the need for filtering because viewDidAppear: is called by every Controller — you must use blacklists or check the title property to filter out pages that shouldn’t be tracked.
The Underlying Principle of Method Swizzling
The core operation of method swizzling is method_exchangeImplementations, which swaps two methods’ IMPs (function pointers). In the Objective-C runtime, each class maintains a message dispatch table where each entry is a Method containing the mapping between SEL (method name) and IMP (implementation address). Method Swizzling modifies this table so a SEL points to a different IMP.
Here’s a standard method swizzling implementation:
void __gbh_tracer_swizzleMethod(Class class, SEL originalSelector, SEL swizzledSelector) {
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
BOOL didAddMethod = class_addMethod(class,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {
class_replaceMethod(class,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}
There are a few key points to this approach. Using class_addMethod first checks whether the original method exists. If not, it adds one — preventing the superclass’s implementation from being swapped. If it exists, it swaps directly. This is safer than direct method_exchangeImplementations, especially when the target class doesn’t implement the method, because it won’t affect the superclass or other subclasses.
Executing Swizzling in Category’s +load
Swizzling should be performed in the +load method, wrapped in dispatch_once. +load is called when the class is loaded into memory — early enough, without manual invocation. dispatch_once ensures the swap only executes once, preventing duplicate swaps from multiple threads triggering simultaneously.
Practical Example: Full Page-View Tracking
// UIViewController+Tracker.m
@implementation UIViewController (Tracker)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
__gbh_tracer_swizzleMethod([self class],
@selector(viewDidAppear:),
@selector(__gbh_tracer_viewDidAppear:));
});
}
- (void)__gbh_tracer_viewDidAppear:(BOOL)animated {
// This actually calls the original viewDidAppear: (since the methods are swapped)
[self __gbh_tracer_viewDidAppear:animated];
// Blacklist filtering: don't track system controllers
NSArray *filter = @[@"UINavigationController",
@"UITabBarController",
@"UIAlertController"];
NSString *className = NSStringFromClass(self.class);
if ([filter containsObject:className]) {
return;
}
// Only track pages with a title
if ([self.title isKindOfClass:[NSString class]] && self.title.length > 0) {
[Tracker uploadPageView:className title:self.title];
}
}
@end
With this approach, just adding a Category to the project — no changes to any business code — automatically triggers tracking when all UIViewController subclasses appear.
More Complex Scenario: Intercepting UIControl Events
Beyond page lifecycle, user behavior analysis also needs button click tracking. This can also be handled non-intrusively with Runtime — intercept UIControl‘s sendAction:to:forEvent: method and automatically log events when any control is tapped:
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Method original = class_getInstanceMethod([UIControl class],
@selector(sendAction:to:forEvent:));
Method swizzled = class_getInstanceMethod([UIControl class],
@selector(__tracker_sendAction:to:forEvent:));
method_exchangeImplementations(original, swizzled);
});
}
- (void)__tracker_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
// Call the original method first to ensure business logic executes
[self __tracker_sendAction:action to:target forEvent:event];
// Filter system events
if ([target isKindOfClass:[UIViewController class]]) {
NSString *eventName = [NSString stringWithFormat:@"click_%@_%@",
NSStringFromClass([target class]),
NSStringFromSelector(action)];
[Tracker uploadEvent:eventName];
}
}
Now all button click events are automatically captured — no manual tracking in each button’s click method.
More Applications of Non-intrusive AOP
Non-intrusive Runtime-based insertion is essentially AOP (Aspect-Oriented Programming) on the iOS platform. It leverages Objective-C’s Runtime and message forwarding mechanisms to insert custom logic before and after method calls without modifying business code.
Beyond tracking, this approach can also be used for logging, performance monitoring, and crash prevention. For example, injecting JavaScript into UIWebView delegate methods to capture frontend errors, or adding timing to network request methods. Some mature APM (Application Performance Monitoring) SDKs are also based on similar principles — non-intrusively integrating to automatically perform crash analysis, lag monitoring, and network analysis.
Important Considerations
Be careful when calling the swizzled method from within itself. Since methods are swapped, calling [self __tracker_sendAction:to:forEvent:] inside __tracker_sendAction:to:forEvent: actually executes the original method — no recursion.
Several principles must be followed: use dispatch_once to ensure it only executes once, execute it as early as possible in +load, and ensure method signatures match. Method swizzling modifies global state — even a small mistake can cause hard-to-debug issues. If both a Category and the main class implement the same method, the Category overrides the main class — consistent with the Runtime’s message forwarding logic.
Runtime-based AOP is a powerful capability in iOS development. Used well, it’s black magic. Used poorly, it’s a ticking time bomb. The key is knowing where non-intrusive insertion is needed, and writing it with careful consideration.
In my experience, the biggest pitfall with non-intrusive insertion is overuse. I once swizzled viewDidLoad across all ViewControllers to add a logging feature, and it worked perfectly in development. But in production, the logging created a noticeable frame drop on older devices because every controller’s initialization was now doing extra work. The lesson: just because you can intercept everything doesn’t mean you should. Always profile with Instruments before deploying swizzling-based features to production.
Another practical tip: when designing a swizzling-based system, always include a way to disable it at runtime. I typically use a plist flag or a user defaults key — if something goes wrong in production, you can turn off the entire tracking system without releasing a new build. This has saved me from at least two emergency hotfixes in the past year.
A Reproducible Project Scenario: Timing a Legacy Request Runner
I reproduced this pattern with a request runner that already returned the correct business response but provided no timing information. Editing the original method was undesirable because the runner represented third-party or legacy code. The demo installs one narrow exchange around performRequestNamed:, records elapsed time, and returns the original result unchanged.
This is “non-intrusive” only from the caller’s point of view. The process has still changed its global Objective-C method table. Treating that distinction honestly is important: another library can target the same selector, tests can run in a different load order, and expensive injected work can slow every call.
A Defensive Swizzle Installer
Method original = class_getInstanceMethod(
cls, @selector(performRequestNamed:));
Method replacement = class_getInstanceMethod(
cls, @selector(trace_performRequestNamed:));
if (!original || !replacement) return;
const char *originalTypes = method_getTypeEncoding(original);
const char *replacementTypes = method_getTypeEncoding(replacement);
if (!originalTypes || !replacementTypes ||
strcmp(originalTypes, replacementTypes) != 0) {
return;
}
method_exchangeImplementations(original, replacement);
The installer runs through dispatch_once, verifies that both methods exist, and refuses to exchange implementations when their type encodings differ. Apple documents method_exchangeImplementations as an atomic exchange of two method implementations, but atomic exchange does not make the surrounding design automatically conflict-free.
Preserving Behavior and Providing a Kill Switch
- (NSString *)trace_performRequestNamed:(NSString *)name {
if (!DemoTrackingEnabled()) {
return [self trace_performRequestNamed:name];
}
CFAbsoluteTime start = CFAbsoluteTimeGetCurrent();
NSString *result = [self trace_performRequestNamed:name];
CFAbsoluteTime elapsed = CFAbsoluteTimeGetCurrent() - start;
NSLog(@"[trace] request=%@ duration=%.1f ms",
name, elapsed * 1000.0);
return result;
}
The second call to trace_performRequestNamed: reaches the original implementation after the exchange. The environment flag bypasses instrumentation without trying to reverse global runtime state. This is safer than repeatedly swapping methods on and off while other threads may be executing them.
- Prefer composition, delegates, subclassing, or dependency injection when they can solve the problem.
- Keep the target class and selector narrow instead of intercepting every controller or control globally.
- Do not rely on Category method collision order as a supported override mechanism.
- Measure injected work with realistic hardware and production-like traffic.
Download and Run the Complete Project
The complete tested source is available in its dedicated repository: Objective-C Runtime Swizzle Tracing Demo on GitHub.
git clone https://github.com/2252408699/objc-runtime-swizzle-tracing-demo.git
cd objc-runtime-swizzle-tracing-demo
make run
# Run again with instrumentation bypassed
DEMO_DISABLE_TRACKING=1 make run
Requirements: macOS and Xcode or the Xcode Command Line Tools. The normal run prints a measured duration and the unchanged business result; the bypassed run prints the same result without the trace line.
