この記事はメドレーアドベントカレンダーの9日目の記事です。
はじめに
この記事ではReact NativeでViewにstylesを指定してから画面にスタイリングされた要素が表示されるまでに行われている処理を解説します。とりわけJSXがUIView
やandroid.view
などの各プラットフォームのUI表現に変換されるまでに行われている処理について解説します。
React Nativeは内部でiOS/Androidなどのネイティブの機構を呼び出して画面を描画しています。つまり、React Nativeの画面描画のおおまかな目的は、JSXを各プラットフォームのUI表現に変換することです。各プラットフォームのUI表現に変換したあとは、それぞれの描画機構を用いて結果を画面に反映します。
本記事では、UIView
がどう動くのか?android.View
がどう動くのか?など、各プラットフォームの描画機構については触れません(私も知りません)。React Nativeが各プラットフォームに描画の仕事を任せるまでについてのみ解説します。
- 本家Reactのレンダリングについて多少の知識があることを前提とします
-
f86a14e4adb64c5292785fdf8a381e26ef034eac
時点のfacebook/react-nativeレポジトリの内容を基にこの記事は執筆されています。引用したコードもその時点でのコードです。現在とは内容が異なるかもしれない点に注意してください。https://github.com/facebook/react-native/blob/f86a14e4adb64c5292785fdf8a381e26ef034eac/
全体的な概観
単純な<View>
にstyle
を指定する場合に発生する処理の流れを示します。
function App(): React.JSX.Element {
return <View style={styles.view} />;
}
const styles = StyleSheet.create({
view: {
backgroundColor: '#FF0000',
width: 100,
height: 100,
},
});
export default App;
- トランスパイルされて
react/jsx-runtime
のjsx(View, props)
の呼び出しに変換される。jsx(View, props)
の実行は最終的にはtype: RCTView
を持つReact Elementを作成する。 -
1.
で作成されたReact Element(のツリー)をRendererに渡し、React Elementをネイティブ側で具体的にどのように表示するかを管理する内部表現であるShadow Node(のツリー)を作成する。 -
2.
で作成されたShadow Node(のツリー)からUIView
やandroid.View
など各プラットフォームのUI表現を構築する
具体的な処理
jsx(View, { style: styles.view })
の実行
まずはView
という関数コンポーネントが何を実行するのかを確認しましょう。
ソースコード上でView.js
の実装を確認します。重要な箇所だけを抜粋します。
const View: component(
ref: React.RefSetter<React.ElementRef<typeof ViewNativeComponent>>,
...props: ViewProps
) = React.forwardRef(
(
{
...
}: ViewProps,
forwardedRef,
) => {
...
const actualView = (
<ViewNativeComponent
...
ref={forwardedRef}
/>
);
...
return actualView;
},
);
<ViewNativeComponent>
というコンポーネントを返しています。このコンポーネントはどのような定義でしょうか?
const ViewNativeComponent: HostComponent<Props> =
NativeComponentRegistry.get<Props>('RCTView', () => ({
uiViewClassName: 'RCTView',
}));
NativeComponentRegistry.get
という関数呼び出しの結果がViewNativeComponent
の実体のようです。
NativeComponentRegistry.get<Props>('RCTView', ...)
が行っていることはざっくりと以下の二つです。
- ネイティブ側で定義された
RCTView
のViewConfig
を読み込む(ViewConfig
はネイティブコンポーネントの設定情報、つまりサポートするスタイル・アクセシビリティ・イベントハンドラなどのことです。) -
RCTView
という文字列を返す
1.
は副作用的な処理で、RCTView
のViewConfig
を後で読み込みやすいように記憶しておく処理です。
2.
の処理に着目すると、jsx(View, { style: styles.view })
は最終的には以下のようなReact Elementの出力になることが分かります。
{
type: "RCTView",
props: { style: styles.view },
key: null,
ref: null
};
では、このReactElement
の情報はどのようにネイティブに渡されるのでしょうか?
RendererによるShadow Nodeの作成
React Nativeアプリケーションのエントリーポイントであるindex.js
ファイルには以下のようなコードが書かれています。
import {AppRegistry} from 'react-native';
import App from './App';
import {name as appName} from './app.json';
AppRegistry.registerComponent(appName, () => App);
registerComponent
の処理を遡っていくと、ReactFabricという機構のrender
という関数を呼び出していることが分かります。render
の実装は以下です。正直、なんのことか分からないと思います。
exports.render = function (element, containerTag, callback, concurrentRoot) {
var root = roots.get(containerTag);
root ||
((root = concurrentRoot ? 1 : 0),
(concurrentRoot = new FiberRootNode(
containerTag,
root,
!1,
"",
onRecoverableError,
null
)),
(root = createFiber(3, null, null, 1 === root ? 1 : 0)),
(concurrentRoot.current = root),
(root.stateNode = concurrentRoot),
(root.memoizedState = { element: null, isDehydrated: !1, cache: null }),
initializeUpdateQueue(root),
(root = concurrentRoot),
roots.set(containerTag, root));
updateContainer(element, root, null, callback);
a: if (((element = root.current), element.child))
switch (element.child.tag) {
case 27:
case 5:
element = getPublicInstance(element.child.stateNode);
break a;
default:
element = element.child.stateNode;
}
else element = null;
return element;
};
このrender
はFiberを作成してReactの更新を管理しています。本家Reactと同様です。
React Nativeが本家と異なるのは、ここでShadow Nodeというものを同時に作成します。Shadow NodeとはReact Elementから受け継いだプロパティ、ネイティブでどう配置されるか、などの情報を持ったデータです。
Shadow Nodeの作成の処理はrender
関数内で呼ばれているのですが、実際の処理はC++で行われています。JavascriptからC++の処理を呼び出すためのJSIという橋渡しの機構を使っています。1
実際にJavascriptから呼び出されるShadow Nodeを作成するC++の処理は以下のようになっています。
if (methodName == "createNode") {
auto paramCount = 5;
return jsi::Function::createFromHostFunction(
runtime,
name,
paramCount,
[uiManager, methodName, paramCount](
jsi::Runtime& runtime,
const jsi::Value& /*thisValue*/,
const jsi::Value* arguments,
size_t count) -> jsi::Value {
try {
...
return valueFromShadowNode(
runtime,
uiManager->createNode(
tagFromValue(arguments[0]),
stringFromValue(runtime, arguments[1]),
surfaceIdFromValue(runtime, arguments[2]),
RawProps(runtime, arguments[3]),
std::move(instanceHandle)),
true);
} catch (const std::logic_error& ex) {
LOG(FATAL) << "logic_error in createNode: " << ex.what();
}
});
}
雰囲気を把握するために、createNode
の呼び出しにReact Elementの情報を入れます
uiManager->createNode(
tagFromValue(Reactノードを一意に識別するタグ),
stringFromValue(runtime, "RCTView"),
surfaceIdFromValue(runtime, おそらくノードが属するレンダリングコンテキストのid、モーダルコンテキストとか),
RawProps(runtime, {style: styles.view}),
std::move(...)),
true);
つまり、Reactのツリーにおけるノードに対して、「どんなUI要素でどんなPropsを持っているのか」の情報を持っているC++のデータを作成しています。
React Elementのツリーに対応するShadow Nodeのツリーが作成されたら、Yogaという機構によって画面上でのレイアウトが決定されます。
このレイアウトの決定にcreateNode
の第四引数として渡していたスタイリング情報が使われます。すなわち、<View>
に渡したstyle
のうちレイアウトに関係する値が使われます!
ちなみにYogaが使うスタイルプロパティは以下です。明らかにレイアウトに関係しそうですね。
"direction", "flexDirection", "justifyContent", "alignContent", "alignItems", "alignSelf", "position", "flexWrap", "display", "flex", "flexGrow", "flexShrink", "flexBasis", "margin", "padding", "rowGap", "columnGap", "gap", "minWidth", "maxWidth", "minHeight", "maxHeight", "aspectRatio", "left", "right", "top", "bottom", "start", "end", "inset", "insetStart", "insetEnd", "insetInline", "insetInlineStart", "insetInlineEnd", "insetBlock", "insetBlockEnd", "insetBlockStart", "insetVertical", "insetHorizontal", "insetTop", "insetBottom", "insetLeft", "insetRight", "marginStart", "marginEnd", "marginInline", "marginInlineStart", "marginInlineEnd", "marginBlock", "marginBlockStart", "marginBlockEnd", "marginVertical", "marginHorizontal", "marginTop", "marginBottom", "marginLeft", "marginRight", "paddingStart", "paddingEnd", "paddingInline", "paddingInlineStart", "paddingInlineEnd", "paddingBlock", "paddingBlockStart", "paddingBlockEnd", "paddingVertical", "paddingHorizontal", "paddingTop", "paddingBottom", "paddingLeft", "paddingRight
Shadow Node -> 各プラットフォームの表現への変換
ここまでで、React上でのレイアウト表現をC++のデータに変換する処理が行われました。ここからさらに、C++のデータをJavaやObjective-Cなど各プラットフォームの表現に変換する必要があります。
Shadow Nodeによるツリーが作成されると、各プラットフォームに対応するUI表現を作る処理がC++から呼び出されます。
AndroidもiOSも同様のプロセスなのですが、Androidの場合を説明します。
内部的には様々な処理を経るのですが詳細は割愛して(読み解けなかった)、最終的に以下の関数が呼ばれます。
public void createViewUnsafe(
@NonNull String componentName,
int reactTag,
@Nullable ReadableMap props,
@Nullable StateWrapper stateWrapper,
@Nullable EventEmitterWrapper eventEmitterWrapper,
boolean isLayoutable) {
...
try {
ReactStylesDiffMap propMap = new ReactStylesDiffMap(props);
ViewState viewState = new ViewState(reactTag);
viewState.mCurrentProps = propMap;
viewState.mStateWrapper = stateWrapper;
viewState.mEventEmitter = eventEmitterWrapper;
mTagToViewState.put(reactTag, viewState);
if (isLayoutable) {
ViewManager viewManager = mViewManagerRegistry.get(componentName);
// View Managers are responsible for dealing with inital state and props.
viewState.mView =
viewManager.createView(
reactTag, mThemedReactContext, propMap, stateWrapper, mJSResponderHandler);
viewState.mViewManager = viewManager;
}
} finally {
...
}
}
class ViewState
はReact上のノードの識別子とAndroid上でのView
についての情報を持つクラスです。
注目すべきは以下の箇所です
viewState.mView =
viewManager.createView(
reactTag, mThemedReactContext, propMap, stateWrapper, mJSResponderHandler);
viewManager.createView
はReact上のノードの識別子やpropMap
を受け取って、View
のインスタンスを返しています。propMap
は要するに<View>
に与えたstyle
などのpropsから作られたデータです。
すなわち、ここで<View>
に指定したstyle
がネイティブのUI表現に変換されています!
Shadow Nodeの時点ではレイアウトに関するスタイルプロパティしか使われていませんでしたが、ここでcolor
などのレイアウトに関わらない情報も使われました。
まとめ
繰り返しになりますが、ここまで確認した処理の流れをまとめます。
-
<View style={styles.view}/>
が実行されて、React Elementが作られる - React Elementがネイティブ上ではどんなUI要素でどんなプロパティを持っていてどこに表示されるかを表すShadow Nodeが作られる
-
Shadow Nodeが各プラットフォームに渡されて、
UIView
やandroid.View
などの固有の表現に変換される
おわりに
個人的にReact Nativeのスタイリングライブラリを作成したいと思い、今回の内容を調べ始めました。特に、「React Nativeのネイティブ部分とのやりとりとの都合で、style
の渡し方によってどの程度のパフォーマンスの差が出るか?」を調べていました。
気にすべきことはReactのレイヤーでReconciliationを効率よくやれるか(オブジェクトのequalityチェックを気にするなど)に留まっている、というのが現段階での結論です。具体的な処理までは追えていないので間違っているかもしれません(有識者、求む!)。
Fabric
の実装ファイルは9,000行を超えておりなおかつjs
ファイルで書かれていたり、様々な箇所で言語を超えたインターフェース抽象化が行われているためエディターでのコードジャンプが困難であったり、処理を読み解くのに非常に苦労しました。
根気強く協力してくれたChatGPTに感謝です。
明日は@yuya333さんによる、「React+fabric.jsで作る手書きアプリ」です。
Fabric続きですね。ちなみにReact NativeのFabricとfabric.jsは全く関係ないです。
参考資料
- facebook/react-native
- reactwg/react-native-new-architecture
- About the New Architecture
- React Native New Architecture
- React Nativeの Re-architecture について。
- React NativeのレイアウトエンジンYogaの仕組み [前編]
- React NativeのレイアウトエンジンYogaの仕組み [後編]
- React Nativeの次世代アーキテクチャTurboModuleとJSIの話
-
JSIだけで数本記事が書ける一大トピックなので詳細な説明は割愛します。公式の詳細ドキュメントや、偉大な先人たちが書かれた解説記事があるので、そちらを参照されたいです。
React Nativeの次世代アーキテクチャTurboModuleとJSIの話
Deep dive into React Native JSI ↩