React Native New Architectureをサポートしました

こんにちは。App SDK Unitでエンジニアをやっている大島です。Reproでは多くのプラットフォームに対応したSDKを提供しており、それらの開発・保守を担当しています。

さて、プラットフォームの1つであるReact Nativeですが、v0.74からNew Architectureがデフォルトで有効になりました。
これにより、「いざ有効にしてみたら、利用していたライブラリが動かなくなった」という経験をした方もいるのではないでしょうか。

私たちのSDKも例外ではなく、同様の課題に直面しました。しかし、当時は対応事例がほとんど見つからず、手探りで実装を進めることになりました。

この記事では、その過程で得たNew Architectureにおける知っておくべき技術要素や、New Architectureをサポートする時のTipsをまとめています。今後、ライブラリやアプリを開発する方々の知見になりましたら幸いです。

New Architectureとは何か

New Architectureとは、React Nativeを高速化・安定化させるための根本的な再設計のことです。従来の性能のボトルネックだった「ブリッジ」をなくし、JavaScriptとネイティブコードが直接通信することで実現しています。
React Native v0.74からデフォルトで有効となっていて、設定で無効にできますが、有効だとRepro SDKを導入しても動作しないことが確認できたため、New Architectureを有効にしても動作するようサポートすることになりました。

備考: Legacy Architecture(これまでのReact Nativeのアーキテクチャ

これまでのReact Nativeでは、ネイティブコードの呼び出しはNativeModulesと呼ばれるモジュールで実現していました。JavaScriptからネイティブコードを呼び出す際、その命令は一度JSON形式の文字列に変換され、非同期の「ブリッジ」を通ってネイティブ側に渡されていました。このJSONへの変換・復元コストと、非同期通信による渋滞がパフォーマンスのボトルネック(速度低下の一因)となっていました。
New Architecture環境では、このNativeModulesを呼び出す仕組み自体が無効になるため、SDKが動作しないという問題が発生していました。

React Native v0.80からはこのLegacy Architectureに対するPull Requestの受付を停止しているため、いずれサポートが必須となります。

サポートまでの道のり

New Architectureの仕組みを理解する上で、知っておくべき重要な要素が2つあります。1つは中核を担うCodegenとSpec、そしてもう1つが、移行に欠かせない後方互換性です。

React Native Codegen1

Codegenとは、React NativeのJavaScriptコードとネイティブコードの「型」を自動で同期させる仕組みのことです。新アーキテクチャの信頼性を支える重要な役割を担っています。これまでは開発者が手作業で両方のコードの型を一致させる必要がありましたが、CodegenがTypeScriptなどで書かれた型定義を元に連携用のコードを自動生成することで、開発の手間を省き、実行時のエラーを未然に防ぎます。

TypeScriptで書かれた共通の型定義を元に、iOSAndroidの両プラットフォームで利用されるC++のブリッジコードを生成することで、クロスプラットフォームでの型安全性を実現しています。
Codegenがあることで、開発者はネイティブで実行したいコードを、JavaScriptから呼び出す関数を用意するだけでつなぎ込みを行ってくれます。そのため、React Nativeでネイティブコードを実行したい場合に知っておくべき、非常に重要な要素です。

Codegenが動作するようにするには、 package.json に以下の設定を追加します。
この設定を追加することで、ネイティブのビルドプロセスにおいてブリッジコードが自動生成されます。主な実行タイミングは、iOSでは pod install 時、Androidでは Gradleによるビルド時(例: yarn android の実行時)です。

    "codegenConfig": {
        "name": "SampleSpec",
        "type": "modules",
        "jsSrcsDir": "src", // Specファイルが保存されているディレクトリ名
        "android": {
            "javaPackageName": "io.android.sample"
        }
    }

Spec

Specとは、JavaScriptとネイティブ間でやり取りする機能やプロパティの仕様を定義した、TypeScript/Flowで書かれる型定義ファイルのことです。これは、ネイティブモジュールやコンポーネントの「設計書」の役割を果たします。このファイルが「唯一の信頼できる情報源(Single Source of Truth)」となり、CodegenはSpecの定義に書かれた通りの型で、両サイドをつなぐ連携コードを自動で生成します。

前述のCodegenが動作してブリッジコードを生成するために必要です。Specの具体例を以下に示します。
React NativeのApp.tsなどで setUserID を呼び出すことで、ブリッジコードを経由してネイティブコードで実装された setUserID を呼び出します。

import type { TurboModule } from "react-native/Libraries/TurboModule/RCTExport";
import { TurboModuleRegistry } from "react-native";

export interface Spec extends TurboModule {
  setUserID(userId: string): void;
  getUserID(callback: () => void): void;
}

export default TurboModuleRegistry.get<Spec>("SampleModule") as Spec | null;

Legacy Architectureとの後方互換

Repro SDKがNew Architectureにサポートしたからといって、Repro SDKをご利用いただくユーザーの方々が全てNew Architectureを有効にするわけではないため、New Architectureが無効な状態でもSDKが動作するようにする必要があります。
そのため、Turbo Modules as Legacy Native Modules2を参考に、New ArchitectureをサポートしながらLegacy Architectureもこれまで通り動作させる必要がありました。

iOS

iOSの場合、同じファイル内で RCT_NEW_ARCH_ENABLED を使ってNew ArchitectureとLegacy Architectureのコードを書き分けます。New ArchitectureではSpecファイル名 + Spec を継承します。
New Architectureにのみ getTurboModule を実装します。

▼ヘッダー SampleBridge.h

#ifdef RCT_NEW_ARCH_ENABLED
@interface SampleBridge: NSObject <NativeSampleBridgeSpec>
@end
#else
@interface SampleBridge : NSObject <RCTBridgeModule>
@end
#endif

▼実装 SampleBridge.mm

#import "SampleBridge.h"

// ...

#ifdef RCT_NEW_ARCH_ENABLED
- (void)setUserID:(NSString *)userID
{
    // 処理
}

- (void)getUserID:(RCTResponseSenderBlock)callback
{
    // 処理
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params
{
    return std::make_shared<facebook::react::NativeSampleBridgeSpecJSI>(params);
}

#else

RCT_EXPORT_METHOD(setUserID:(NSString *)userID)
{
    // 処理
}

RCT_EXPORT_METHOD(getUserID:(RCTResponseSenderBlock)callback)
{
    // 処理
}

#endif

Android

Androidの場合、まず build.gradle に、New Architectureが有効かどうかを判定する処理と、それぞれのアーキテクチャ用のソースディレクトリを指定します。

def isNewArchitectureEnabled() {
    return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
}

// ...

    sourceSets {
        main {
            java.srcDirs += ['src/main/java']
            if (isNewArchitectureEnabled()) {
                java.srcDirs += ['src/newarch']
            } else {
                java.srcDirs += ['src/oldarch']
            }
        }
    }

// ...

build.gradle の指定に従い、 src/newarchsrc/oldarch にそれぞれの処理を実装します。src/main/java は共通処理が入ります。
SampleBridgeModule.java にネイティブで実行したい処理を実装し、 SampleBridgePackage.javaSampleBridgeModule.java をつなぎこむための処理を実装します。

New Architecture用の処理のサンプルはこちらです。(Legacy Architectureはこれまでと変わらないため割愛)

▼ネイティブ処理本体を記述する SampleBridgeModule.java

package io.android.sample;

// ...

import io.android.sample.NativeSampleBridgeSpec;

public class SampleBridgeModule extends NativeSampleBridgeSpec {

    @Override
    @NonNull
    public String getName() {
        return "SampleBridgeModule";
    }

    @Override
    public void setUserID(final String userId) {
        // 処理
    }

    @Override
    public void getUserID(Callback callback) {
        // 処理
    }
}

▼作成したモジュールをReact Nativeに登録する SampleBridgePackage.java

package io.android.sample;

// ...
import com.facebook.react.TurboReactPackage;

public class SampleBridgePackage extends TurboReactPackage {

    @Nullable
    @Override
    public NativeModule getModule(String name, @NonNull ReactApplicationContext reactContext) {
        if (name.equals(SampleBridgeModule.NAME)) {
            return new SampleBridgeModule(reactContext);
        } else {
            return null;
        }
    }

    @Override
    public ReactModuleInfoProvider getReactModuleInfoProvider() {
        return new ReactModuleInfoProvider() {
            @Override
            public Map<String, ReactModuleInfo> getReactModuleInfos() {
                final Map<String, ReactModuleInfo> moduleInfos = new HashMap<>();

                moduleInfos.put(
                    SampleBridgeModule.NAME,
                    new ReactModuleInfo(
                            SampleBridgeModule.NAME,
                            SampleBridgeModule.NAME,
                            false, // canOverrideExistingModule
                            false, // needsEagerInit
                            true, // hasConstants
                            false, // isCxxModule
                            true // isTurboModule
                ));
                return moduleInfos;
            }
        };
    }
}

対応する時に起きたこと

New Architectureのサポートは、想像していたより一筋縄ではいきませんでした。ここでは、実際に起きたトラブルについてお話しします。

Codegenをどのように使えば良いかわからない

New Architectureについて調べ始めて、まず必要と書かれていたのがSpecとCodegenでした。Specファイルを用意しCodegenを実行(gradlewやpod install)して、ネイティブコードが呼び出せるか一通りの動作を確認しましたが、以下のような情報がほとんどなく試行錯誤しながらの実装となりました。

  • SDKを導入した後に、Codegenの実行を明示的にする必要があるのか
  • 実行して生成されたファイルはどこにあるのか
  • 生成されたファイルをSDKに含めて提供する必要があるのか

結果的に、SDKをインストールしてReact Nativeアプリ全体をビルドするだけでCodegenが動作するとわかったため、 SDK側にはSpecファイルのみを含め、生成されたコードは含めない という方針になりました。

New Architectureのドキュメントが古い

New Architectureサポートを始めた当初、ドキュメントはGithubに置いてあるリポジトリのみでした。そのため、サンプルコード通りに実装して動かなかった場合や、定数を用意する方法は何かなど、少し特殊なケースが発生するとAIに聞くかその都度自分で実装して動かして確認するしかありませんでした。
現在はTurbo Native Modules: iOSTurbo Native Modules: Androidが用意されています。

また、サンプルコードも別リポジトリに用意されていましたが、2022年から更新されておらず書き方も間違っているところがあり、混乱する要因となっていました。
このリポジトリは、現在はArchiveされており、他に情報がなく開発中はかなり困りました。この時の経験が、このブログを書くモチベーションとなっています。

おわりに

New Architectureのサポートに着手してから、およそ2か月程度でSDKをリリースできました。React Nativeのソースコードnpmにありますのでご興味ありましたらご参照ください。
ほとんど知識がない状態からこのスピードで実装できたのは、Geminiが他社の対応事例を紹介してくれたり、動作しなかった時に調査する点を案内してくれたりと、AIの力が大きいです。こちらは別の機会で登壇した資料があります。こちらもご興味ありましたらご参照ください。

WE ARE HIRING!

Reproではエンジニアを募集しています。App SDK UnitではReact Nativeの他にFlutter/Unity/Cordova/Cocos2d-x向けのSDKを提供しています。iOS/Androidに加えて多様な言語で開発をすることに興味がありましたら、ぜひお話させてください。ご連絡をお待ちしております。


  1. AIサービスのCodegenとは別物なのでご注意ください。React Native Codegenで検索する必要があります。
  2. Turbo Modulesとは、New Architectureのネイティブモジュールの仕組みのことです。