리액트 네이티브의 New Architecture에서 새로 도입된 랜더러인 Fabric가 기존 랜더러와 어떻게 다른지, 어떤 흐름으로 리액트 트리를 네이티브 뷰 트리로 변경시키는지, 정말로 빠른지 알아보고자 코드를 분석해 봤습니다.
목차
1. 리액트 랜더링
2. Old Arhitecture 랜더러 (이하 구 랜더러)
3. New Architecture 랜더러 (이하 Fabric)
3-1. 커밋
3-2. 마운트 예약
3-3. 마운트 실행 타이밍
5-1. 동시성 모드
5-2. 브라우저와의 유사성
1. 리액트 랜더링
react 랜더링 과정은 랜더 단계, 커밋 단계로 나뉩니다.
랜더 단계에서 beginWork로 leaf fiber까지 도달하면서 alternative 트리를 만들고, completeWork로 올라가면서 effect가 발생한 fiber를 연결리스트로 묶습니다.
그리고 커밋 단계에서 연결 리스트를 순회하면서 DOM API를 사용하여 실제 돔에 변경사항을 반영됩니다.
giodle님이 리액트 랜더링 과정을 상세하게 분석해 놓으셨는데, 몇 번 읽어보면 리액트를 이해하는데 많은 도움이 될 것이라고 생각합니다.
어쨌든 랜더 단계 완료 후 커밋 단계에서 commitRoot(Impl)가 실행됩니다.
이 중에서 새로운 노드를 삽입하는 경우만 살펴보겠습니다.
commitRootImpl > commitMutationEffects > commitMutationEffectsOnFiber > commitReconciliationEffects > commitPlacement > insertOrAppendPLacementNode > insertBefore
export function insertBefore(
parentInstance: Instance, // DOM !!
child: Instance | TextInstance,
beforeChild: Instance | TextInstance | SuspenseInstance,
): void {
parentInstance.insertBefore(child, beforeChild);
}
DOM의 insertBefore 메서드
https://developer.mozilla.org/en-US/docs/Web/API/Node/insertBefore
하지만 브라우저의 랜더링은 메인 스래드에서 처리되기 때문에, 커밋 단계까지 완료된 후에 리액트에서 메인 스래드의 점유를 해제하면, 레이아웃 페인팅 과정이 한 번에 처리됩니다. (브라우저의 랜더링 과정에서 레이아웃이 발생할 수 있는 DOM API를 배치처리하는 VDOM의 장점)
리액트 네이티브는 HostConfig으로 작동 환경에 고유한 동작만 주입했기 때문에, react-dom과 로직은 동일합니다.
하지만 이 고유 동작이 Old Architecture와 New Artchitecture가 어떻게 다른지 살펴보겠습니다.
2. Old Arhitecture 랜더러 (이하 구 랜더러)
react-dom과 다른 점은 UIManager에게 네이티브 뷰 조작을 요청한다는 것입니다.
commitRootImpl > commitMutationEffects > commitMutationEffectsOnFiber > commitReconciliationEffects > commitPlacement > insertOrAppendPLacementNode > insertBefore
function insertBefore(parentInstance, child, beforeChild) {
var children = parentInstance._children;
var index = children.indexOf(child); // Move existing child or add new child?
if (index >= 0) {
children.splice(index, 1);
var beforeChildIndex = children.indexOf(beforeChild);
children.splice(beforeChildIndex, 0, child);
ReactNativePrivateInterface.UIManager.manageChildren( // 요기 !!
parentInstance._nativeTag, // containerID
[index], // moveFromIndices
[beforeChildIndex], // moveToIndices
[], // addChildReactTags
[], // addAtIndices
[] // removeAtIndices
);
} else {
var _beforeChildIndex = children.indexOf(beforeChild);
children.splice(_beforeChildIndex, 0, child);
var childTag = typeof child === "number" ? child : child._nativeTag;
ReactNativePrivateInterface.UIManager.manageChildren( // 요기 !!
parentInstance._nativeTag, // containerID
[], // moveFromIndices
[], // moveToIndices
[childTag], // addChildReactTags
[_beforeChildIndex], // addAtIndices
[] // removeAtIndices
);
}
}
그렇다면 UIManager는 어떻게 정의되어 있을까요?
구 랜더러는 PaperUIManager로 명명되어 있습니다.
PaperUIManager는 NativeUIManager를 merge합니다.
NativeUIManager는 TurboModuleRegistry.getEnforcing를 사용하여 모듈을 가져오는데,
export default (TurboModuleRegistry.getEnforcing<Spec>('UIManager'): Spec);
TurboModuleRegistry.getEnforcing는 NativeModules를 사용합니다.
const NativeModules = require('../BatchedBridge/NativeModules');
function requireModule<T: TurboModule>(name: string): ?T {
if (turboModuleProxy != null) {
// ...
}
if (useLegacyNativeModuleInterop) {
// Backward compatibility layer during migration.
const legacyModule: ?T = NativeModules[name]; // 요기 !!
if (legacyModule != null) {
return legacyModule;
}
}
return null;
}
NativeModeles는 "리액트 네이티브에서 JS와 Java가 통신하는 방법" 포스트에서 살펴봤듯이 Java에서 C++을 통해 JSI로 자바스크립트에 등록한 객체이고, 여기서 얻어온 모듈에 대한 메서드 호출은 브릿지의 최종 구현체인 BatchedBridge에 요청하는 방식으로 진행됩니다.
그리고 BatchedBridge는 5ms 동안 요청을 모아서 한 번에 처리합니다. (처리 자체는 직렬화 과정 없이 JSI로 등록된 함수를 통해 직접 통신합니다.)
네이티브에서 "UIManager" 문자열로 자신을 노출하는 객체는 UIManagerModule입니다.
UIManagerModule은 Old Architecture에서 JS측의 요청을 받아 네이티브 뷰 트리를 조작하는 객체입니다.
즉, Old Architecture에선 리액트 트리와 네이티브 뷰 트리가 비동기적입니다.
즉, 리액트 트리와 네이티브 뷰 트리가 일치하는 것을 보장할 수 없습니다.
그렇다면 New Architecture에선 어떻게 처리될까요?
3. New Architecture 랜더러 (이하 Fabric)
공식 문서에서 Fabric은 C++ 영역에서 Yoga를 사용하여 Shadow(Yoga) 트리를 그려놓고, Shadow 트리를 다시 네이티브 뷰 트리로 그린다고 설명하고 있습니다.
https://reactnative.dev/architecture/render-pipeline#phase-3-mount
그럼 코드에선 어떻게 구현되어 있는지 확인해 보겠습니다.
commitMutationEffects > commitMutationEffectsOnFiber > commitReconciliationEffects > commitPlacement
function commitPlacement(finishedWork) {
{
return;
}
}
엥..? 아무것도 없습니다.
그렇다면 어떻게 네이티브 뷰에 변경사항을 반영하는 것일까요?
3-1. 커밋
구 랜더러에서 UIManager를 통해 네이티브 뷰 조작을 하는 반면, Fabric은 C++에서 JSI로 등록한 함수를 사용하여 Yoga 트리를 만드는 것으로 JS측 동작을 끝냅니다.
JSI로 다음과 같은 함수를 등록합니다.
const CACHED_PROPERTIES = [
'createNode',
'cloneNode',
'cloneNodeWithNewChildren',
'cloneNodeWithNewProps',
'cloneNodeWithNewChildrenAndProps',
'createChildSet',
'appendChild', // 주목 !!
'appendChildToSet',
'completeRoot', // 주목 !!
'measure',
'measureInWindow',
'measureLayout',
'configureNextLayoutAnimation',
'sendAccessibilityEvent',
'findShadowNodeByTag_DEPRECATED',
'setNativeProps',
'dispatchCommand',
'compareDocumentPosition',
'getBoundingClientRect',
];
실제 코드는 UIManagerBinding.cpp에서 JSI를 통해 주입합니다.
UIManagerBinding.cpp에서 등록한 appendChild의 경우, Fabric에선 appendChildNode 변수에 저장해 놓고 사용합니다.
appendChildNode를 어디서 사용하는지 보니, beginWork로 트리를 타고 내려가면서 fiber 단위로 C++의 Shadow 노드에 반영하고 있습니다.
function completeWork(current, workInProgress, renderLanes) {
var newProps = workInProgress.pendingProps; // Note: This intentionally doesn't check if we're hydrating because comparing
switch (workInProgress.tag) {
// ...
case HostComponent: {
popHostContext(workInProgress);
var _type2 = workInProgress.type;
if (current !== null && workInProgress.stateNode != null) {
updateHostComponent(current, workInProgress, _type2, newProps);
} else {
// ...
appendAllChildren(_instance3, workInProgress, false, false);
}
// ...
}
// ...
}
}
function appendAllChildren(
parent,
workInProgress,
needsVisibilityToggle,
isHidden
) {
{
// We only have the top Fiber that was created but we need recurse down its
// children to find all the terminal nodes.
var _node = workInProgress.child;
while (_node !== null) {
if (_node.tag === HostComponent) {
// ...
appendInitialChild(parent, instance);
} else if (_node.tag === HostText) {
// ...
appendInitialChild(parent, _instance);
}
// ...
}
}
}
function appendInitialChild(parentInstance, child) {
appendChildNode(parentInstance.node, child.node);
}
beginWork > updateHostComponent > completeWork (> updateHostComponent) > appendAllChildren > appendInitialChild > appendChildNode
그리고 completeWork로 올라오면서, 루트 노드를 업데이트할 때(마지막 fiber를 처리할 때),
즉, 랜더링 과정에서 커밋 단계에 진입하기 바로 전에 C++에서 JSI로 등록한 completeRoot 함수로 Yoga Tree를 커밋합니다.
function performUnitOfWork(unitOfWork) {
// ...
next = beginWork(current, unitOfWork, entangledRenderLanes);
// ...
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
function completeUnitOfWork(unitOfWork) {
var completedWork = unitOfWork;
do {
var current = completedWork.alternate;
var returnFiber = completedWork.return;
var next = void 0;
next = completeWork(current, completedWork, entangledRenderLanes);
// ...
var siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
completedWork = returnFiber; // climbing !!
workInProgress = completedWork;
} while (completedWork !== null);
// ...
}
function completeWork(current, workInProgress, renderLanes) {
// ...
switch (workInProgress.tag) {
// ...
case HostRoot: {
// ...
updateHostContainer(current, workInProgress);
// ...
}
}
// ...
}
function updateHostContainer(current, workInProgress) {
// ...
finalizeContainerChildren(container, newChildSet);
}
function finalizeContainerChildren(container, newChildren) {
completeRoot(container, newChildren);
}
HostRoot에 대한 completeWork > updateHostContainer > finalizeContainerChildren > completeRoot
completeRoot도 appendChild와 마찬가지로 UIManagerBinding.cpp에서 JSI를 통해 주입합니다.
if (methodName == "completeRoot") {
auto paramCount = 2;
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 {
// ...
uiManager->completeSurface( // 요기 !!
surfaceId,
shadowNodeList,
{.enableStateReconciliation = true,
.mountSynchronously = false, // 요기 !!
.shouldYield = nullptr});
return jsi::Value::undefined();
});
}
여기서 mountSynchronously 속성이 false로 설정되어 있는데,이름 그대로 마운트를 바로 실행하지 않는다는 의미입니다.
아직 리액트 랜더링 과정의 커밋 단계에서 실행할 passive clean(useEffect clean) effect, layout(useLayout) effect가 처리되지 않았기 때문에, 당연히 아직 네이티브 뷰 트리에 반영되면 안됩니다.
따라서 비동기로 다음 "틱"에 처리되도록 비동기로 설정한 것입니다.
이것은 공식 문서에도 설명되어 있고,
https://reactnative.dev/architecture/render-pipeline#phase-3-mount (Additional Details)
주석에도 설명되어 있습니다.
그리고 UIManager 객체를 통해 커밋합니다.
void UIManager::completeSurface(
SurfaceId surfaceId,
const ShadowNode::UnsharedListOfShared& rootChildren,
ShadowTree::CommitOptions commitOptions) {
shadowTreeRegistry_.visit(surfaceId, [&](const ShadowTree& shadowTree) {
auto result = shadowTree.commit( // 요기 !!
[&](const RootShadowNode& oldRootShadowNode) {
return std::make_shared<RootShadowNode>(
oldRootShadowNode,
ShadowNodeFragment{
.props = ShadowNodeFragment::propsPlaceholder(),
.children = rootChildren,
});
},
commitOptions);
// ...
});
}
커밋 단계의 주요 기능인 레이아웃 계산은 C++(백그라운드) 스래드에서 비동기적으로 실행되기 때문에 completeRoot는 매우 빠르게 완료됩니다.
https://reactnative.dev/architecture/render-pipeline#phase-2-commit (Additional Details)
3-2. 마운트 예약
커밋을 하면서 새로운 Shadow 트리(newRevision) 마운트까지 (비동기적으로) 요청합니다.
CommitStatus ShadowTree::commit(
const ShadowTreeCommitTransaction& transaction,
const CommitOptions& commitOptions) const {
// ...
tryCommit(transaction, commitOptions);
// ...
}
CommitStatus ShadowTree::tryCommit(
const ShadowTreeCommitTransaction& transaction,
const CommitOptions& commitOptions) const {
// ...
if (commitMode == CommitMode::Normal) {
mount(std::move(newRevision), commitOptions.mountSynchronously);
}
return CommitStatus::Succeeded;
}
void ShadowTree::mount(ShadowTreeRevision revision, bool mountSynchronously)
const {
mountingCoordinator_->push(std::move(revision));
// delegate_ = UIManager
delegate_.shadowTreeDidFinishTransaction(
mountingCoordinator_, mountSynchronously);
}
마운트는 대리자에게 요청하는데, UIManager 객체입니다.
void UIManager::shadowTreeDidFinishTransaction(
MountingCoordinator::Shared mountingCoordinator,
bool mountSynchronously) const {
SystraceSection s("UIManager::shadowTreeDidFinishTransaction");
if (delegate_ != nullptr) {
delegate_->uiManagerDidFinishTransaction(
std::move(mountingCoordinator), mountSynchronously);
}
}
그리고 UIManager도 대리자에게 요청하는데, Scheduler 객체입니다.
Scheduler 객체도 RuntimeScheduler 객체에게 마운트 동작(람다 함수)을 예약합니다.
void Scheduler::uiManagerDidFinishTransaction(
MountingCoordinator::Shared mountingCoordinator,
bool mountSynchronously) {
if (delegate_ != nullptr) {
// ...
auto weakRuntimeScheduler =
contextContainer_->find<std::weak_ptr<RuntimeScheduler>>(
"RuntimeScheduler");
auto runtimeScheduler = weakRuntimeScheduler.has_value()
? weakRuntimeScheduler.value().lock()
: nullptr;
if (runtimeScheduler && !mountSynchronously) { // mountSynchronously: false
auto surfaceId = mountingCoordinator->getSurfaceId();
runtimeScheduler->scheduleRenderingUpdate( // 요기 !!
surfaceId,
// 마운트 동작 (람다 함수)
[delegate = delegate_,
mountingCoordinator = std::move(mountingCoordinator)]() {
delegate->schedulerShouldRenderTransactions(mountingCoordinator); // 요기 !!
});
} else {
delegate_->schedulerShouldRenderTransactions(mountingCoordinator);
}
}
}
RuntimeScheduler 객체는 구현체로, New Architecture에선 자바로 부터 useModernRuntimeScheduler 플래그가 true주입되면서 RuntimeScheduelr_Modern이 사용됩니다.
useModernRuntimeScheduler 주입
RuntimeScheduelr_Modern 사용
그리고 마운트 동작(schedulerShouldRenderTransactions)을 처리하는 Scheduler의 대리자(delegate_)는 Binding 객체입니다.
Binding 객체의 schedulerShouldRenderTransactions 메서드는 MountingManager 객체에 실제 마운트 동작을 요청합니다.
void Binding::schedulerShouldRenderTransactions(
const MountingCoordinator::Shared& mountingCoordinator) {
auto mountingManager =
getMountingManager("schedulerShouldRenderTransactions");
if (ReactNativeFeatureFlags::
allowRecursiveCommitsWithSynchronousMountOnAndroid()) {
// ...
} else {
std::unique_lock<std::mutex> lock(pendingTransactionsMutex_);
for (auto& transaction : pendingTransactions_) {
mountingManager->executeMount(transaction); // 요기 !!
}
pendingTransactions_.clear();
}
}
RuntimeScheduelr_Modern 객체의 scheduleRenderingUpdate 메서드는 업데이트 큐(pendingRenderingUpdates_)에 작업을 넣고 끝냅니다.
void RuntimeScheduler_Modern::scheduleRenderingUpdate(
SurfaceId surfaceId,
RuntimeSchedulerRenderingUpdate&& renderingUpdate) {
SystraceSection s("RuntimeScheduler::scheduleRenderingUpdate");
if (ReactNativeFeatureFlags::batchRenderingUpdatesInEventLoop()) { // 요기 !!
surfaceIdsWithPendingRenderingUpdates_.insert(surfaceId);
pendingRenderingUpdates_.push(renderingUpdate); // 요기 !!
} else {
if (renderingUpdate != nullptr) {
renderingUpdate();
}
}
}
batchRenderingUpdatesInEventLoop는 New Architecture가 true인 경우 사용되도록 되어 있습니다.
그리고 다음 틱에 업데이트 큐를 소비하면서 Yoga Tree를 네이티브 뷰 트리에 반영합니다.
3-3. 마운트 실행 타이밍
그렇다면 업데이트 큐(마운트 작업)는 어떻게 다음 틱에 소비될까요?
SyncLane 이외의 레인에선 랜더링을 예약할 때 C++ RuntimeScheduler 객체의 unstable_scheduleCallback 메서드를 사용합니다.
function ensureRootIsScheduled(root) {
// ...
{
scheduleTaskForRootDuringMicrotask(root, now$1());
}
}
function scheduleTaskForRootDuringMicrotask(root, currentTime) {
// ...
var workInProgressRoot = getWorkInProgressRoot();
var workInProgressRootRenderLanes = getWorkInProgressRootRenderLanes();
var nextLanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes
);
if (includesSyncLane(nextLanes)) {
// ...
return SyncLane;
} else {
// ...
var newCallbackNode = scheduleCallback$1(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root)
);
}
}
function scheduleCallback$1(priorityLevel, callback) {
if (ReactCurrentActQueue$3.current !== null) {
// ...
} else {
return scheduleCallback$2(priorityLevel, callback);
}
}
var scheduleCallback$2 = Scheduler.unstable_scheduleCallback;
unstable_scheduleCallback는 예약된 작업을 처리하고, pendingRenderingUpdates_ 큐를 비우는 작업(updateRendering)까지 수행합니다.
if (propertyName == "unstable_scheduleCallback") {
return jsi::Function::createFromHostFunction(
runtime,
name,
3,
[this](
jsi::Runtime& runtime,
const jsi::Value&,
const jsi::Value* arguments,
size_t) noexcept -> jsi::Value {
SchedulerPriority priority = fromRawValue(arguments[0].getNumber());
auto callback = arguments[1].getObject(runtime).getFunction(runtime);
auto task = runtimeScheduler_->scheduleTask(priority, std::move(callback)); // 요기 !!
return valueFromTask(runtime, task);
});
}
void RuntimeScheduler_Modern::scheduleTask(std::shared_ptr<Task> task) {
// ...
if (shouldScheduleEventLoop) {
scheduleEventLoop();
}
}
void RuntimeScheduler_Modern::runEventLoop(
jsi::Runtime& runtime,
bool onlyExpired) {
// ...
while (syncTaskRequests_ == 0) {
// ...
runEventLoopTick(runtime, *topPriorityTask, currentTime);
}
}
void RuntimeScheduler_Modern::runEventLoopTick(
jsi::Runtime& runtime,
Task& task,
RuntimeSchedulerTimePoint taskStartTime) {
// ...
if (ReactNativeFeatureFlags::batchRenderingUpdatesInEventLoop()) {
// "Update the rendering" step.
updateRendering();
}
}
void RuntimeScheduler_Modern::updateRendering() {
// ...
while (!pendingRenderingUpdates_.empty()) {
auto& pendingRenderingUpdate = pendingRenderingUpdates_.front(); // 요기 !!
if (pendingRenderingUpdate != nullptr) {
pendingRenderingUpdate(); // 실행 !!
}
pendingRenderingUpdates_.pop();
}
}
하지만 SyncLane은 queueMicrotask를 사용하는데,
function ensureRootIsScheduled(root) {
// ...
if (ReactCurrentActQueue$3.current !== null) {
// ...
} else {
if (!didScheduleMicrotask) {
didScheduleMicrotask = true;
scheduleImmediateTask(processRootScheduleInMicrotask);
}
}
function scheduleImmediateTask(cb) {
// ...
if (supportsMicrotasks) {
scheduleMicrotask(function () {
// ...
cb();
});
} else {
// ...
}
}
var scheduleMicrotask =
typeof queueMicrotask === "function" ? queueMicrotask : scheduleTimeout;
표준 API이므로 당연히 리액트와 관련된 어떠한 작업도 하지 않습니다.
void JSCRuntime::queueMicrotask(const jsi::Function& callback) {
microtaskQueue_.emplace_back(callback.getFunction(*this));
}
첫 번째 랜더링은 DefaultLane에서 처리되므로 문제없지만, 일반적인 상태 변경은 SyncLane을 사용합니다.
이 부분은 리액트에서 생성한 노드가 C++의 Shadow 노드가 되고, Shadow 노드가 네이티브 뷰가 되는 과정까지 봐야하므로, 좀 복잡합니다.
너무 복잡해 져서 흐름도를 그리면서 분석했습니다.
파란색: EventDispatcher 전파 경로
빨간색: 커밋, 마운트
ㄴ굵은: 로직 흐름
ㄴ얇은: 데이터 전파 경로
점선: C++과 JVM 사이 참조 전달
처음엔 흐름을 파악하지 못하다가 리액트 네이티브 팀원으로 부터 운 좋게 힌트를 얻어 해결할 수 있었습니다.
이 부분은 내용이 많아서 시간될 때 천천히 따라가 보시는 것을 추천드립니다.
1. 먼저 ComponentFactory의 buildRegistryFunction을 봐야 합니다.
buildRegistryFunction은 EventDispatcher를 받아서 CompnentDescriptorRegistry 객체를 반환하는 람다 함수입니다.
2. CompnentDescriptorRegistry 객체는 미리 정의된 ComponentDescriptor 객체(View, Text, ScrollView 등) 모음이고,
3. ComponentDescriptor 객체를 사용하여 Shadow 노드를 생성할 수 있습니다.
이때 받은 EventDispatcher 객체를 모든 Shadow 노드(createNode from ReactFabric.js)가 공유하는 속성을 가지고 있는 ShadowNodeFamily 객체를 만들 때 사용합니다.
Shadow 노드는 생성할 때 ShadowNodeFamily 객체를 입력으로 받고,
ShadowNodeFamily 객체를 참조하여 EventEmitter를 얻을 수 있습니다.
4. JVM 영역에서 ComponentFactory C++ 객체를 생성하고 buildRegistryFunction 메서드 주입
5. ReactInstanceManager에서 UIManager를 생성하고 C++에 참조를 전달할 때(installFabricUIManager),
6. C++ Scheduler 객체에서 buildRegistryFunction 메서드 실행하여 ComponentDescriptorRegistry 객체를 생성하고 UIManager 객체에 전달합니다.
7. 리액트 랜더링 과정에서 Host 컴포넌트의 fiber 노드를 생성할 때, C++에서 JSI로 정의한 createNode를 호출하고,
8. ComponentDescriptorRegistry 객체에서 적절한 ComponentDescriptor를 가져와 Shadow 노드를 생성합니다. (Fabric 랜더링 과정 중 랜더 단계)
9. 위에서 설명한 completeRoot에서 마운트를 예약할 때, 새로운 Yoga Tree와 이전 Yoga Tree를 재귀적으로 비교하여 변경점이 발생한 노드를 ShadowViewMutation 배열로 만들고, pendingTransaction_ 변수에 저장해 놓습니다.
10. Shadow 노드는 ShadowView로 변경할 수 있고, ShadowView는 ShadowViewMutation으로 변환할 수 있습니다.
11. 예약된 마운트 동작은 Binding 객체의 schedulerShouldRenderTransactions 메서드고, 해당 메서드는 JVM에서 받은 FabricUIManager 참조(javaUIManager)를 통해 MountingTransaction 객체에 포함된 ShadowViewMutation 객체를 네이티브 뷰 트리에 반영합니다.
이때 JVM 측에 IntBufferBatchMountItem 객체를 만들어서 다시 FabricUIManager에게 전달합니다.
IntBufferBatchMountItem 객체를 만들 때 전달한 objBufferArray 배열에 EventEmitterWrpper 객체가 포함되어 있습니다.
JVM의 UI 마운트 작업은 최종적으로 MountItem의 execute 메서드에 의해 처리되는데, 네이티브 뷰가 생성될 때, objBufferArray 배열에 전달된 EventEmitter 객체를 추출해서, 리액트 태그가 키인 해시 테이블(mTagToViewState)에 저장해 놓습니다.
어떤 뷰에서 이벤트가 발생해서 JS측으로 이벤트를 보낼 땐, mTagToViewState 테이블을 조회해서 찾은 EventEmitter를 통해 보냅니다.
RCTEventEmitter 구현체를 얻는 부분을 제외하고는 기존과 다르지 않습니다.
참고: 터치 이벤트 흐름 > 1-2-4. EventDispatcher
이제 JVM에서 이벤트를 보낼 때, C++에서 생성한 EventEmitter를 사용할 수 있게 되었습니다.
JVM에서 실행할 수 있도록, EventEmitterWrapper로 감싸져 있을 뿐입니다.
사실 EventEmiter도 Scheduler에서 생성된 EventDispatcher 객체의 래퍼 객체입니다.
이제 C++의 EventDispatcher가 어떻게 예약된 마운트를 트리거하는지만 보면 됩니다.
12. JVM에서 FabricUIManager 객체를 만들어 installFabricUIManager 함수를 통해 C++로 참조를 전달할 때, EventBeatManager 객체도 함께 전달합니다. (FabricUIManager 객체에도 전달합니다.)
EventBeatManager 객체는 ComponentFactory 객체처럼 구현체가 C++에 있는 껍데기 객체입니다.
EventBeatManager 객체를 AsyncEventBeat 객체를 만들 때 생성자에 입력하고,
13. AsyncEventBeat 객체는 자기 자신을 EventBeatManager의 observer로 등록합니다.
AsyncEventBeat 객체는 팩토리 함수로 래핑되어 EventDispatcher 객체 내부에서 생성됨과 동시에 EventQueue 객체에 입력됩니다.
JVM에서 이벤트를 보내면, EventDispatcher는 이벤트를 EventQueue에 넣습니다.
EventQueue에 이벤트를 넣는 작업은 EventQueue를 생성할 때 입력한 AsyncEventBeat 객체의 request 메서드를 실행합니다.
이 메서드는 FabricUIManager의 onRequestEventBeat 메서드를 호출합니다.
FabricUIManager의 onRequestEventBeat 메서드는 FabricEventDispatcher 객체의 dispatchAllEvents 메서드를 호출합니다.
Choreographer.FrameCallback 구현체가 Choreographer 구현체의 postFrameCallback에 의해 다음 프레임에 예약됩니다.
Choreographer.FrameCallback 구현체(다음 프레임에 doFrame 메서드 실행)는 이벤트 리스너의 onBatchEventDispatched 메서드를 실행시킵니다.
FabricUIManager는 생성시 받은 EventBeatManager 객체를 FabricEventDispatcher의 이벤트 리스너로 추가합니다.
EventBeatManager 객체의 onBatchedEventDispatched 메서드는 C++ EventBeatManager 객체의 tick 메서드를 실행시킵니다.
EventBeatManager 객체의 tick 메서드는 13번에서 등록한 AsyncEventBeat 객체의 tick 메서드를 실행합니다.
AsyncEventBeat 객체의 tick 메서드는 RuntimeExecutor 함수로 이벤트를 보내는 동작을 예약합니다.
RuntimeExecutor 함수는 installFabricUIManager에서 만들어진 함수로 RuntimeScheduler_Modern 객체의 scheduleWork 메서드를 통해 작업을 예약합니다.
RuntimeScheduler_Modern 객체의 scheduleWork 메서드는 최종적으로 runEventLoopTick 메서드를 실행하면서 Yoga Tree를 커밋하고 마운트 작업을 넣은 pendingRenderingUpdates_ 큐를 비우는 작업(updateRendering)을 수행합니다.
그리고 runEvenTloopTick에선 pendingRenderingUpdates_ 큐를 비우기 전에 microtask 큐를 먼저 비웁니다.
리액트가 이벤트를 처리하는 작업은 이벤트 핸들러를 가지고 있는 노드를 찾아서 실행시키는 작업까지 동적으로 수행합니다.
만약 이벤트 핸들러에서 상태를 변경하면 기본적으로 SyncLane에서 실행되고, 이것은 위에서 언급했다시피 microtask로 예약됩니다.
정리하면 네이티브에서 이벤트를 보내면, C++에선 JSI로 이벤트 핸들러 함수까지 호출하고, 핸들러에서 microtask(예를 들면, 리랜더링)를 예약했다면, 그것까지 기다렸다가 마운트 작업을 수행합니다.
4. Fabric은 구 랜더러에 비해 항상 빠를까?
하지만 New Architecture를 사용할 때 사용자 경험이 항상 좋은 것은 아닙니다.
New Architecture에선 완성된 리액트 트리를 한 번에 네이티브 뷰 트리에 반영하는 반면, Old Architecture에선 브릿지를 통해 5ms 단위로 끊어서 업데이트하기 때문에, 분산해서 업데이트할 수 있습니다.
New Architecture(Bridgeless)를 사용했을 때 더 느려졌다고 하는 보고도 있습니다.
https://github.com/facebook/react-native/issues/47490
공식 문서에서는 여전히 (역)직렬화에 의한 성능 저하를 언급하고 있지만, 이전 포스트에서 살펴봤듯이 자바와 자바스크립트간 모듈 호출과 이벤트 전송은 JSI와 JNI로 대체되었고, (역)직렬화가 어디선가 사용되었든 큰 영향을 미치지는 못할 것 같습니다.
실제로 아래 App 컴포넌트를 랜더링하면 오히려 New Architecture에서 더 많은 버벅임이 발생했습니다.
import { Dimensions, FlatList, View } from "react-native";
export function getRandomColor() {
// 16진수 색상 코드를 생성합니다.
const randomColor =
'#' +
Math.floor(Math.random() * 0xffffff)
.toString(16)
.padStart(6, '0');
return randomColor;
}
const screenWidth = Dimensions.get('screen').width;
const itemWidth = screenWidth / 13 - 0.00001;
const data = Array.from({length: 500}).map(() =>
Array.from({length: 169}).map(getRandomColor),
);
const App = () => (
<FlatList
style={{flex: 1}}
data={data}
renderItem={({item: colors, index}) => {
return (
<View
style={{flexDirection: 'row', flexWrap: 'wrap', marginBottom: 10}}>
{colors.map(color => (
<View
key={color}
style={{
width: itemWidth,
height: 10,
backgroundColor: color,
}}
/>
))}
</View>
);
}}
/>
);
사실 위의 예는 극단적인 경우고, 무거운 화면도 flash-list나 InteractionManager 등으로 충분히 극복할 수 있다고 생각합니다.
5. 그래도 New Architecture로 넘어가야 하는 이유
0.76버전부터 New Architecture가 기본으로 설정되고, interop layers가 개선되면서 구 랜더러 기반으로 작성된 라이브러리도 별다른 설정없이, New Architecture에서 사용할 수 있게 되었습니다.
하지만 아직 초기 버전이고, 호환성 문제나 버그가 발생할 수 있습니다.
그래도 다음과 같은 이유로 넘어갈 준비를 해 놔야 한다고 생각합니다.
5-1. 동시성 모드
리액트는 공식 문서에 설명되어 있듯이 "UI 라이브러리"일 뿐이고, 특정 플랫폼에 종속되어 있지 않지만, 최근에 추가된 동시성 모드는 리액트 트리와 호스트 트리의 동일성이 전제 조건이 됩니다.
사실 구 랜더러에서도 동시성 랜더링관련 함수가 있기는 하지만 사용되지는 않았습니다.
Old Architecture에선 다음 예제 코드에서 버튼을 눌러도 workLoopConcurrent가 아니라, workLoopSync가 호출됩니다.
const [isPending, startTransition] = useTransition(); const [tab, setTab] = useState('about'); return ( <Button title="클릭" onPress={() => { startTransition(() => { setTab('home'); }); }} /> );
루트 노드를 만들 때 LegacyRoot로 설정되기 때문에,
ReactNativeRenderer-dev.js
https://github.com/facebook/react-native/blob/v0.75.3/packages/react-native/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js#L27736
ReactNativeRenderer-prod.js
https://github.com/facebook/react-native/blob/v0.75.3/packages/react-native/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js#L9335C44-L9335C45
requestUpdateLane에서 SyncLane을 반환합니다.
https://github.com/facebook/react-native/blob/v0.75.3/packages/react-native/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js#L22698-L22699
https://github.com/facebook/react-native/blob/v0.75.3/packages/react-native/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js#L7884
루트 노드를 만드는 부분을 ConcurrentRoot로 바꾸면 Concurrent 모드가 작동하긴 합니다.import * as React from 'react'; import {View, Button} from 'react-native'; export default function ManyTiles() { const [value, setValue] = React.useState(0); console.log('🚀 ~ ManyTiles ~ value:', value); return ( <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', }}> <Button title="클릭" onPress={() => { setTimeout(() => { setValue(prev => prev + 1); }); setTimeout(() => { setValue(prev => prev + 1); }); }} /> </View> ); }
리액트 시스템 내에서 발생한 리랜더링은 리액트17에서도 배치 처리를 지원했습니다.
setTimeout같이 리액트 시스템 밖에서 발생한 이벤트는 리액트18 이전에선 배치처리가 되지않았지만, 위의 코드에서 배치 처리가 작동하긴 합니다. (이전 리랜더링 처리 시간에 따라 배치처리 될 때도 있고 안될 때도 있지만)
New Architecture의 랜더러인 Fabric은 이러한 조건을 만족합니다.
리액트 랜더링 과정에서 C++에 Yoga 트리를 만들어 놓고(커밋), 리액트 랜더링이 완료되면, Yoga 트리를 네이티브 뷰 트리에 한 번에 반영(마운트)합니다.
즉, 리액트 트리와 네이티브 뷰 트리가 동일함을 보장할 수 있게 되었습니다.
New Architecture에선 리액트18에 도입된 기능(동시성 랜더링 등)을 이용할 수 있게 되었습니다.
여기서 리액트 트리와 네이티브 뷰 트리가 일치한다는 것은 어떤 의미가 있길래 동시성 기능의 필요 조건일까요?
제 생각이지만, 리액트18의 핵심 기능인 동시성 모드는 중요한 "업데이트를 먼저 처리"하고 사용자에게 보여주어야 하는데, 리액트 트리와 네이티브 뷰 트리가 일치하지 않는다면, 화면이 개발자의 의도대로 보이지 않기 때문인 것으로 추정됩니다.
5-2. 브라우저와의 유사성
5-1을 포괄하는 개념이지만, New Architecture의 목표중 하나는 웹 플랫폼과 비슷하게 동작하는 것을 목표로 하는 것 같습니다.
What can I expect from enabling the New Architecture > Updates to the event loop model#motivation
덕분에 앞으로 New Architecture에서 개발자 경험이 더 좋아질 것 같습니다.
IntersectionObserver나 ResizeObserver API도 추가될 수도 있을 것 같습니다.
'리액트 네이티브' 카테고리의 다른 글
wix/react-native-navigation 코드 분석 (0) | 2024.10.29 |
---|---|
flash-list로 복잡한 레이아웃 구현하기 (0) | 2024.10.16 |
리액트 네이티브에서 IntersectionObserver를 대체하는 방법 (0) | 2024.10.16 |
리액트 네이티브 WebView 중첩 (0) | 2024.10.03 |
리액트 네이티브 스크롤뷰 중첩 (0) | 2024.10.03 |