Skip to content

Commit 1d1fade

Browse files
hryhoriiK97meta-codesync[bot]
authored andcommitted
Look up each TurboModule method's signature once on iOS (#59394)
Summary: On iOS, every call from JavaScript to an Objective-C TurboModule method goes through `ObjCTurboModule::createMethodInvocation` (`ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm`), which asks the module for the method's signature with `methodSignatureForSelector:`. For methods declared in a protocol, the runtime looks the selector up in the module's protocol list (`class_copyProtocolList`, `protocol_getMethodDescription`) and builds a new `NSMethodSignature` each time. In a Time Profiler trace of a loop of sync calls on an iPhone 15 Pro, this lookup was about 25% of `invokeObjCMethod`. A method's signature doesn't change, so this change looks it up on the method's first call and keeps it. Each `ObjCTurboModule` now has one `MethodInfo` struct per method, keyed by the method's JS name, which holds the signature together with the metadata that was already kept per method: the argument types from `RCT_EXPORT_METHOD` and the `RCTCxxConvert` selectors set by the generated code. This replaces `methodArgConversionSelectors_` and `methodArgumentTypeNames_`, two dictionaries keyed by an `NSString` method name. The map is looked up with a `std::string_view`, so no string is allocated on a call. The cache is per instance and still calls `methodSignatureForSelector:` on the module instance, so it keeps the behavior from #51366: a mock module is a different instance and gets its own cache. A mutex guards the map. The members are private, so there is no API change. Memory: one entry per method that is actually called. Running RNTester's TurboModule, Linking, AppState, Clipboard, Vibration, Share and Alert examples filled 40 entries across 12 modules. An `NSMethodSignature` for a method with 4–6 arguments takes about 620 bytes, so this is about 25 KB. The signatures were already created on every call and released right after. ## Changelog: [IOS] [CHANGED] - Look up each TurboModule method's signature once instead of on every call Pull Request resolved: #59394 Test Plan: - `clang-format --dry-run --Werror` (the repository's pinned version) on the changed files passes. - I could not run `RNTesterUnitTests` locally: with Xcode 26.3 the test build fails to link `RNTesterIntegrationTests` (`__swift_FORCE_LOAD_$_swiftCompatibility56` not found), which is unrelated to this change. `RCTTurboModuleArrayBufferTests` calls `invokeObjCMethod` directly, so I'm relying on CI for it; the same paths are covered by the TurboModule example below. - RNTester built with `xcodebuild` in Release and run on a physical iPhone 15 Pro (iOS 26.6.1). In the TurboModule example, "Run function call tests" returns the expected value for all 18 methods, including callbacks, a resolved and a rejected promise, and ArrayBuffers. The Alert example, whose `alertWithArgs` argument goes through an `RCTCxxConvert` selector, shows its alerts as before. - **Measurement.** RNTester Playground calling `NativeSampleTurboModule` methods in a loop (20,000 calls each for the sync methods, 5,000 for `voidFunc` and `getValueWithCallback`), measured with `performance.now()`. Two Release builds, before and after this change, installed in the order before, after, after, before, 4 launches each. The presses were driven by [Argent](https://github.com/software-mansion/argent) on the physical iPhone. Mean time per call: | Call | before | this PR | change | |---|--:|--:|--:| | `getNumber` | 2.85 µs | 2.25 µs | −21% | | `getBool` | 1.46 µs | 1.19 µs | −18% | | `getString` | 1.56 µs | 1.31 µs | −16% | | `getArray` (3 items) | 1.93 µs | 1.65 µs | −14% | | `getObject` (small) | 2.75 µs | 2.53 µs | −8% | | `getObject` (20 keys) | 11.08 µs | 10.81 µs | −2% | | `getValue` | 2.97 µs | 2.81 µs | −6% | | `voidFunc` (async) | 1.04 µs | 0.60 µs | −42% | | `getValueWithCallback` | 1.51 µs | 0.98 µs | −35% | For every row except `getNumber`, the ranges of the two arms don't overlap. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: andrewdacenko Differential Revision: D127179102 Pulled By: javache fbshipit-source-id: beb48cb385af0719f2d37040c1fbcd7dbfc0fd4c
1 parent ca95277 commit 1d1fade

2 files changed

Lines changed: 80 additions & 51 deletions

File tree

‎packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.h‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
#import <React/RCTModuleMethod.h>
1717
#import <functional>
1818
#import <memory>
19+
#import <mutex>
1920
#import <string>
21+
#import <string_view>
2022
#import <unordered_map>
23+
#import <vector>
2124

2225
#define RCT_IS_TURBO_MODULE_CLASS(klass) \
2326
((RCTTurboModuleEnabled() && [(klass) conformsToProtocol:@protocol(RCTTurboModule)]))
@@ -132,14 +135,27 @@ class JSI_EXPORT ObjCTurboModule : public TurboModule {
132135
// Does the NativeModule dispatch async methods to the JS thread?
133136
const bool isSyncModule_;
134137

135-
/**
136-
* TODO(ramanpreet):
137-
* Investigate an optimization that'll let us get rid of this NSMutableDictionary.
138-
* Perhaps, have the code-generated TurboModule subclass implement
139-
* getMethodArgConversionSelector below.
140-
*/
141-
NSMutableDictionary<NSString *, NSMutableArray *> *methodArgConversionSelectors_;
142-
NSDictionary<NSString *, NSArray<NSString *> *> *methodArgumentTypeNames_;
138+
struct MethodInfo {
139+
// Looked up on the method's first call, since it doesn't change.
140+
NSMethodSignature *signature;
141+
// Argument types declared with RCT_EXPORT_METHOD, used to convert arguments with RCTConvert.
142+
NSArray<NSString *> *argumentTypeNames;
143+
// RCTCxxConvert selectors that convert arguments to C++ structs, set by the generated code.
144+
std::vector<SEL> argConversionSelectors;
145+
};
146+
struct MethodNameHash {
147+
using is_transparent = void;
148+
size_t operator()(std::string_view methodName) const
149+
{
150+
return std::hash<std::string_view>{}(methodName);
151+
}
152+
};
153+
// Keyed by the method's JS name; looked up without allocating a std::string.
154+
std::unordered_map<std::string, MethodInfo, MethodNameHash, std::equal_to<>> methodInfos_;
155+
bool argumentTypeNamesLoaded_{false};
156+
std::mutex methodInfosMutex_;
157+
MethodInfo &findOrCreateMethodInfo(std::string_view methodName);
158+
NSMethodSignature *getMethodSignature(const char *methodName, SEL selector);
143159

144160
bool isMethodSync(TurboModuleMethodValueKind returnType);
145161
bool mustCopyJSHeapArrayBufferBytes(TurboModuleMethodValueKind returnType);

‎packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm‎

Lines changed: 56 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -680,9 +680,8 @@ TraceSection s(
680680
*/
681681
NSString *ObjCTurboModule::getArgumentTypeName(jsi::Runtime &runtime, NSString *methodName, int argIndex)
682682
{
683-
if (methodArgumentTypeNames_ == nullptr) {
684-
NSMutableDictionary<NSString *, NSArray<NSString *> *> *methodArgumentTypeNames = [NSMutableDictionary new];
685-
683+
std::lock_guard<std::mutex> lock(methodInfosMutex_);
684+
if (!argumentTypeNamesLoaded_) {
686685
unsigned int numberOfMethods;
687686
Class cls = [instance_ class];
688687
Method *methods = class_copyMethodList(object_getClass(cls), &numberOfMethods);
@@ -707,19 +706,21 @@ TraceSection s(
707706
[argumentTypes addObject:arguments[j].type];
708707
}
709708

710-
NSString *normalizedOtherMethodName = [otherMethodName componentsSeparatedByString:@":"][0];
711-
methodArgumentTypeNames[normalizedOtherMethodName] = argumentTypes;
709+
std::string_view otherMethodNameView = otherMethodName.UTF8String;
710+
std::string_view normalizedOtherMethodName = otherMethodNameView.substr(0, otherMethodNameView.find(':'));
711+
findOrCreateMethodInfo(normalizedOtherMethodName).argumentTypeNames = argumentTypes;
712712
}
713713

714714
free(methods);
715715
}
716716

717-
methodArgumentTypeNames_ = methodArgumentTypeNames;
717+
argumentTypeNamesLoaded_ = true;
718718
}
719719

720-
if (methodArgumentTypeNames_[methodName] != nullptr) {
721-
assert([methodArgumentTypeNames_[methodName] count] > argIndex);
722-
return methodArgumentTypeNames_[methodName][argIndex];
720+
auto it = methodInfos_.find(std::string_view(methodName.UTF8String));
721+
if (it != methodInfos_.end() && it->second.argumentTypeNames != nil) {
722+
assert([it->second.argumentTypeNames count] > argIndex);
723+
return it->second.argumentTypeNames[argIndex];
723724
}
724725

725726
return nil;
@@ -818,18 +819,19 @@ TraceSection s(
818819
/**
819820
* Convert objects using RCTCxxConvert to structs.
820821
*/
821-
if ([objCArg isKindOfClass:[NSDictionary class]] && hasMethodArgConversionSelector(methodNameNSString, i)) {
822+
if ([objCArg isKindOfClass:[NSDictionary class]]) {
822823
SEL methodArgConversionSelector = getMethodArgConversionSelector(methodNameNSString, i);
823-
824-
// Message dispatch logic from old infra (link:
825-
// https://github.com/facebook/react-native/commit/6783694158057662fd7b11fc123c339b2b21bfe6#diff-263fc157dfce55895cdc16495b55d190R350)
826-
RCTManagedPointer *(*convert)(id, SEL, id) = (__typeof__(convert))objc_msgSend;
827-
RCTManagedPointer *box = convert([RCTCxxConvert class], methodArgConversionSelector, objCArg);
828-
829-
void *pointer = box.voidPointer;
830-
[inv setArgument:&pointer atIndex:i + 2];
831-
[retainedObjectsForInvocation addObject:box];
832-
return;
824+
if (methodArgConversionSelector != nullptr) {
825+
// Message dispatch logic from old infra (link:
826+
// https://github.com/facebook/react-native/commit/6783694158057662fd7b11fc123c339b2b21bfe6#diff-263fc157dfce55895cdc16495b55d190R350)
827+
RCTManagedPointer *(*convert)(id, SEL, id) = (__typeof__(convert))objc_msgSend;
828+
RCTManagedPointer *box = convert([RCTCxxConvert class], methodArgConversionSelector, objCArg);
829+
830+
void *pointer = box.voidPointer;
831+
[inv setArgument:&pointer atIndex:i + 2];
832+
[retainedObjectsForInvocation addObject:box];
833+
return;
834+
}
833835
}
834836
}
835837

@@ -842,6 +844,26 @@ TraceSection s(
842844
}
843845
}
844846

847+
ObjCTurboModule::MethodInfo &ObjCTurboModule::findOrCreateMethodInfo(std::string_view methodName)
848+
{
849+
auto it = methodInfos_.find(methodName);
850+
if (it == methodInfos_.end()) {
851+
it = methodInfos_.emplace(std::string(methodName), MethodInfo{}).first;
852+
}
853+
return it->second;
854+
}
855+
856+
NSMethodSignature *ObjCTurboModule::getMethodSignature(const char *methodName, SEL selector)
857+
{
858+
std::lock_guard<std::mutex> lock(methodInfosMutex_);
859+
MethodInfo &methodInfo = findOrCreateMethodInfo(methodName);
860+
if (methodInfo.signature == nil) {
861+
const NSObject<RCTBridgeModule> *module = instance_;
862+
methodInfo.signature = [module methodSignatureForSelector:selector];
863+
}
864+
return methodInfo.signature;
865+
}
866+
845867
NSInvocation *ObjCTurboModule::createMethodInvocation(
846868
jsi::Runtime &runtime,
847869
bool isSync,
@@ -859,8 +881,7 @@ TraceSection s(
859881
TurboModulePerfLogger::asyncMethodCallArgConversionStart(moduleName, methodName);
860882
}
861883

862-
const NSObject<RCTBridgeModule> *module = instance_;
863-
NSMethodSignature *methodSignature = [module methodSignatureForSelector:selector];
884+
NSMethodSignature *methodSignature = getMethodSignature(methodName, selector);
864885
if (count > methodSignature.numberOfArguments - 2) {
865886
throw jsi::JSError(
866887
runtime,
@@ -986,36 +1007,28 @@ TraceSection s(
9861007

9871008
BOOL ObjCTurboModule::hasMethodArgConversionSelector(NSString *methodName, size_t argIndex)
9881009
{
989-
return (methodArgConversionSelectors_ != nullptr) && (methodArgConversionSelectors_[methodName] != nullptr) &&
990-
![methodArgConversionSelectors_[methodName][argIndex] isEqual:(id)kCFNull];
1010+
return getMethodArgConversionSelector(methodName, argIndex) != nullptr;
9911011
}
9921012

9931013
SEL ObjCTurboModule::getMethodArgConversionSelector(NSString *methodName, size_t argIndex)
9941014
{
995-
assert(hasMethodArgConversionSelector(methodName, argIndex));
996-
return (SEL)((NSValue *)methodArgConversionSelectors_[methodName][argIndex]).pointerValue;
1015+
std::lock_guard<std::mutex> lock(methodInfosMutex_);
1016+
auto it = methodInfos_.find(std::string_view(methodName.UTF8String));
1017+
if (it == methodInfos_.end() || argIndex >= it->second.argConversionSelectors.size()) {
1018+
return nullptr;
1019+
}
1020+
return it->second.argConversionSelectors[argIndex];
9971021
}
9981022

9991023
void ObjCTurboModule::setMethodArgConversionSelector(NSString *methodName, size_t argIndex, NSString *fnName)
10001024
{
1001-
if (methodArgConversionSelectors_ == nullptr) {
1002-
methodArgConversionSelectors_ = [NSMutableDictionary new];
1003-
}
1004-
1005-
if (methodArgConversionSelectors_[methodName] == nullptr) {
1006-
auto metaData = methodMap_.at([methodName UTF8String]);
1007-
auto argCount = metaData.argCount;
1008-
1009-
methodArgConversionSelectors_[methodName] = [NSMutableArray arrayWithCapacity:argCount];
1010-
for (int i = 0; i < argCount; i += 1) {
1011-
[methodArgConversionSelectors_[methodName] addObject:(id)kCFNull];
1012-
}
1025+
const char *methodNameUTF8 = methodName.UTF8String;
1026+
std::lock_guard<std::mutex> lock(methodInfosMutex_);
1027+
std::vector<SEL> &selectors = findOrCreateMethodInfo(methodNameUTF8).argConversionSelectors;
1028+
if (selectors.empty()) {
1029+
selectors.resize(methodMap_.at(methodNameUTF8).argCount, nullptr);
10131030
}
1014-
1015-
SEL selector = NSSelectorFromString(fnName);
1016-
NSValue *selectorValue = [NSValue valueWithPointer:selector];
1017-
1018-
methodArgConversionSelectors_[methodName][argIndex] = selectorValue;
1031+
selectors.at(argIndex) = NSSelectorFromString(fnName);
10191032
}
10201033

10211034
void ObjCTurboModule::setEventEmitterCallback(EventEmitterCallback eventEmitterCallback)

0 commit comments

Comments
 (0)