I first used method swizzling in a checkout feature that needed temporary timing logs around an existing service method. The tempting solution was to exchange implementations at application launch and forget about it. That worked in a test build, but it also made every call site depend on a global behavior change that was difficult to see during review.
I now treat Objective-C runtime changes like changes to shared infrastructure. I keep them narrow, use only classes the application owns, check every method lookup, make installation reversible, and test the behavior before and after the exchange. The runnable command-line project at the end follows those rules without touching UIKit or private framework classes.
The Project Scenario: Optional Reports and Checkout Logging
The sample contains two small features. A FeatureReporter receives an optional buildReport message that is not compiled as a normal Objective-C method. The class installs that implementation only when the runtime asks it to resolve the missing selector.
A separate CheckoutService submits an order. During a diagnostic session, we want to record events immediately before and after submission while preserving the original return value and side effects. That is a reasonable place to demonstrate an implementation exchange because the class, both methods, and the test harness are all under our control.
This is deliberately less dramatic than patching every view controller or intercepting collection access. In production, a small and explicit experiment is much safer than a clever global hook whose effect depends on undocumented framework details.
A Selector and an Implementation Are Different Things
A selector identifies an Objective-C message, while an implementation pointer identifies executable code that can handle it. A runtime method record connects that selector to an implementation and a type encoding. This separation is what makes dynamic addition and implementation exchange possible.
The implementation still follows Objective-C calling conventions. Its first two arguments are the receiving object and the selector. Additional method parameters follow them. Returning or accepting the wrong types is not a flexible shortcut; it creates undefined behavior. The function in the sample therefore matches a method that takes no explicit arguments and returns an object:
static NSString *RuntimeBuildReport(id self, SEL _cmd) {
(void)self;
(void)_cmd;
return @"report-created-on-demand";
}
The encoding @@: describes an object return value, the hidden object receiver, and the hidden selector argument. When adapting this pattern, I copy the encoding from a known matching method or verify it carefully instead of inventing a string from memory.
Dynamic Resolution Adds a Method on First Use
When an instance does not recognize a selector, the runtime gives its class an opportunity to resolve it. The sample handles exactly one known selector and delegates every other case to the superclass:
+ (BOOL)resolveInstanceMethod:(SEL)selector {
if (selector == NSSelectorFromString(@"buildReport")) {
return class_addMethod(
self,
selector,
(IMP)RuntimeBuildReport,
"@@:"
);
}
return [super resolveInstanceMethod:selector];
}
Returning the result of class_addMethod matters. A failed installation should not be reported as success. The program checks that the class does not respond before the first message, receives the expected report, and responds afterward.
I would not use this merely to save the memory occupied by ordinary methods. Its practical value is compatibility work, generated behavior, proxies, and systems where method availability is intentionally decided at runtime. If a normal method declaration solves the problem clearly, that is usually the better design.
Method Swizzling Changes Dispatch for Every Caller
method_exchangeImplementations swaps the implementations attached to two method records. After the exchange, sending submitOrder: reaches the logging wrapper. Inside that wrapper, sending demo_submitOrder: reaches the original checkout implementation because the mappings have already traded places.
- (NSString *)demo_submitOrder:(NSString *)orderID {
[events addObject:
[NSString stringWithFormat:@"will-submit:%@", orderID]];
NSString *receipt = [self demo_submitOrder:orderID];
[events addObject:
[NSString stringWithFormat:@"did-submit:%@", receipt]];
return receipt;
}
That call looks recursive when read without context, which is one reason swizzled code surprises maintainers. A prefix helps avoid selector collisions, but naming alone does not explain when installation happens or which scope it affects. I keep the exchange helper and the test beside the feature that needs it.
The important consequence is scope: changing a class method record affects all callers dispatching through that class. It is not limited to the object used during installation. A logging experiment can therefore alter unrelated screens, tests, extensions, or third-party code that uses the same class.
Validate Both Method Lookups Before Exchanging
The sample does not pass unchecked pointers into the exchange function. It first confirms that both lookups returned methods:
static BOOL ExchangeInstanceMethods(
Class cls,
SEL first,
SEL second
) {
Method firstMethod = class_getInstanceMethod(cls, first);
Method secondMethod = class_getInstanceMethod(cls, second);
if (firstMethod == NULL || secondMethod == NULL) {
return NO;
}
method_exchangeImplementations(firstMethod, secondMethod);
return YES;
}
A missing method can result from a renamed selector, a feature compiled out of one target, or an assumption about an inherited method. Failing installation cleanly is better than continuing with a partially configured process.
Inherited methods require extra care. A lookup can return a method owned by a superclass, so exchanging it may affect a wider hierarchy than intended. For application code, I prefer swizzling methods implemented directly by the target class or first adding a class-local entry with a verified implementation and encoding.
Installation Timing Must Be Explicit
Many examples install exchanges in +load. That guarantees early execution, but it also hides a global mutation before normal application startup and makes tests harder to isolate. Heavy work in that path can also make launch behavior more difficult to diagnose.
For my own classes, I prefer an explicit installer called from a known bootstrap point, guarded so the same exchange is not accidentally applied twice. Exchanging the same pair a second time restores the original mapping, which means a duplicate call can silently disable the intended wrapper.
The command-line project intentionally installs and then removes the wrapper. That makes the state transition visible and proves the original behavior can be restored. In a multithreaded application, installation should happen before concurrent calls begin or be protected by appropriate synchronization.
Why I Avoid Swizzling System Class Clusters
A common crash-prevention example replaces array subscripting behavior by targeting private concrete classes. I do not use that approach in the sample. Class-cluster internals can vary across operating-system versions and object shapes, and swallowing an out-of-range access can hide the bug that should be fixed at its source.
The same caution applies to globally intercepting lifecycle methods on framework classes. Analytics is easier to reason about when screens call a typed tracking API, inherit from an application-owned base class where appropriate, or use composition. Runtime interception should be a measured last option, not the default architecture.
If framework behavior must be observed, I verify the documented contract on every supported OS, keep a kill switch, avoid changing return values, and test cold launch, navigation, background transitions, and subclass behavior. A technique working on one simulator is not enough evidence for a production-wide patch.
How the Demo Proves the Behavior
The executable uses deterministic checks rather than asking the reader to trust log order. It confirms six behaviors: the optional selector starts absent, dynamic resolution returns the expected report, the selector becomes available, checkout works before exchange, the wrapper records events in order, and restoring the exchange removes the wrapper.
- Keep runtime changes inside application-owned classes.
- Match the function signature and type encoding exactly.
- Handle only selectors the resolver truly owns.
- Validate both method lookups before an exchange.
- Install once at a documented point.
- Test the original, modified, and restored behavior.
- Avoid private class names and silent crash suppression.
- Provide a way to disable risky diagnostics.
This checklist is less exciting than calling swizzling black magic, but it is what makes the technique reviewable. The real skill is not knowing that implementations can be exchanged. It is controlling the blast radius and showing that the original contract still holds.
Download and Run the Complete Project
The project is a Foundation command-line program for macOS. It requires Xcode Command Line Tools and uses no third-party dependencies, network calls, accounts, credentials, UIKit hooks, or private APIs.
git clone https://github.com/2252408699/objc-runtime-method-tools-demo.git
cd objc-runtime-method-tools-demo
make run
A successful run prints six PASS checks followed by All runtime method checks passed. You can run make clean afterward to remove the local build directory.
Complete source code: objc-runtime-method-tools-demo on GitHub
