この記事はメドレーアドベントカレンダーの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 ↩