アプリケーション構成
Bitkeyで開発しているアプリケーションの一つであるworkhubアプリではReactNativeとSwiftUIを組み合わせて開発をしています。徐々にフルSwiftUIアプリケーションとなるように移行する中で適切に画面同士のイベントのやり取りを行う必要性が出ました。
アプリケーション構成の詳細は以下のスライドに記載しています。
何をしたいのか
React NativeのDOMにコールバック関数を記述すると、その処理がSwiftで受け取れる。コールバック処理は引数を取ることができてSwift側から値を入力できるようにする。
つまるところこのような感じで書きたい。
<SwiftUIView
onAppear={(e)=>{ console.log("Appear!") }}
onInteractive={(e)=>{ console.log(e.nativeEvent.swiftValue) }}
>
class Props: ObservableObject {
var onAppear: () -> Void
var onInteractive: (Int) -> Void
}
struct SomeView: View {
@ObservedObject var props: Props = .init()
var body: some View {
Button("onAppear") { props.onAppear() }
Button("onInteractive") { props.onInteractive(1) }
}
}
なぜしたいのか
このように書ける場合はView同士のやりとりが起きていることはTS側のDOMを見るだけ、Swift側のViewを見るだけで分かる。また直感的に書けるため開発の効率がよい。
逆にコレが書けない場合ではEventEmitterを利用して画面が生成された場合にはその画面に紐づくEventEmitterのsubscriptionを一つずつ定義する必要が生まれる。画面に紐付かないためunsubscription漏れが発生しうる可能性があり不要にメモリ上にEventEmitterが生成され続ける可能性が残る。これはかなり辛い。
いつ使えるのか
画面を定義して画面同士で値をやりとりする場合には常に関係する。また単純に画面同士での値のやりとりを行うため汎用的なものになる。
どこのコードに作用するのか
これを実現するアプリに関わるテンプレートコードは以下のようになる。またこれは以下のような関係性を持つ。
どうやって実現されるのか
基本的にReact NativeがiOSネイティブとのやりとりに用いているものはObjCによるマクロ定義とSelectorを用いた実行時に動的に呼び出し先を見に行く方法になる。また実行時にはReact Nativeの制約により先頭文字列”on”から始まるものがメソッドとして読み込まれる。つまりObjcSomeViewManagerでTS側に渡したいものは全て先頭にonが付く必要がある。
ここでマクロ定義とセレクターにより動いているものの展開された形を見てみたい。
#define RCT_REMAP_VIEW_PROPERTY(name, keyPath, type) \
+(NSArray<NSString *> *)propConfig_##name RCT_DYNAMIC \
{ \
return @[ @ #type, @ #keyPath ]; \
}
#define RCT_EXPORT_SWIFTUI_CALLBACK(name, type, proxyClass) \
RCT_REMAP_VIEW_PROPERTY(name, __custom__, type) \
- (void)set_##name:(id)json forView:(UIView *)view withDefaultView:(UIView *)defaultView RCT_DYNAMIC { \
NSMutableDictionary *storage = [proxyClass storage]; \
proxyClass *proxy = storage[[NSValue valueWithNonretainedObject:view]]; \
void (^eventHandler)(NSDictionary *event) = ^(NSDictionary *event) { \
RCTComponentEvent *componentEvent = [[RCTComponentEvent alloc] initWithName:@""#name \
viewTag:view.reactTag \
body:event]; \
[self.bridge.eventDispatcher sendEvent:componentEvent]; \
}; \
proxy.name = eventHandler; \
}
RCT_EXPORT_SWIFTUI_CALLBACK(onXXX, RCTDirectEventBlock, SwiftSomeViewProxy);
// clang -E としてマクロ展開をした結果
+(NSArray<NSString *> *)propConfig_onXXX RCT_DYNAMIC {
return @[ @ "RCTBubblingEventBlock", @ "__custom__" ];
}
- (void)set_onXXX:(id)json
forView:(UIView *)view
withDefaultView:(UIView *)defaultView RCT_DYNAMIC {
NSMutableDictionary *storage = [SwiftSomeViewProxy storage];
SwiftSomeViewProxy *proxy = storage[[NSValue valueWithNonretainedObject:view]];
void (^eventHandler)(NSDictionary *event) = ^(NSDictionary *event) {
RCTComponentEvent *componentEvent = [[RCTComponentEvent alloc] initWithName:@"""onXXX"
viewTag:view.reactTag
body:event];
[self.bridge.eventDispatcher sendEvent:componentEvent];
};
proxy.onXXX = eventHandler;
};
SwiftUIのコールバックを使うというマクロを組んだわけだが、これは2つのメソッドを最終的に生成することを目的としている。ここで生成した propConfig_
という名前のメソッドがReact Nativeがセレクターを使う理由になる。これが付与されている名前のメソッド全てを見ることでシステムに存在するコールバックは全てみつかることになる。ObjC, Swiftでは実行時にメソッドの名前を知ることができるセレクターという機能がある、他ではAddTargetなどで要求されるものにもセレクターを目にすることができる。React Nativeでは特定のタイミングでセレクターを処理して生成した名前を見つけ on
が先頭についている関数を見つけるとよしなに頑張りブリッジのコードとして内部で登録の作業に入る。詳しい内容はReact NativeのiOS部分を見れば分かる。
また、この他にも呼び出し先に対してどのDOMからコールバックを持ってくるかという情報も型情報を実行時に読み取り選択している。以下のコードはReact Nativeのコードから持ってきたtypedefの定義になる。全て同じ形をしているが別の名前で定義している。
/**
* These block types can be used for mapping input event handlers from JS to view
* properties. Unlike JS method callbacks, these can be called multiple times.
*/
typedef void (^RCTDirectEventBlock)(NSDictionary *body);
typedef void (^RCTBubblingEventBlock)(NSDictionary *body);
typedef void (^RCTCapturingEventBlock)(NSDictionary *body);
if ([type isEqualToString:@"RCTBubblingEventBlock"]) {
[bubblingEvents addObject:RCTNormalizeInputEventName(name)];
// TODO(109509380): Remove this gating
if (!RCTViewConfigEventValidAttributesDisabled()) {
propTypes[name] = @"BOOL";
}
}
これらの情報を用いてbridgeのeventDispatcherにEventとして飛ばすことを教えてあげて、イベントハンドラーをProxyに持たせることにより接続が完了する。
Tips
RCTDirectEventBlockとRCTBubblingEventBlockはTS側のViewで対象とするDOMに直接付与されたのか、上で定義されたコールバックも使えるのかを判断する。
- RCTDirectEventBlock: requireNativeComponentで持ってきたところでしか使えない。
- RCTBubblingEventBlock: requireNativeComponentが含まれているDOMでDOMから最小距離にある要求する名前にマッチしたものを利用する。(Bubblingなので徐々に上がっていく)