Expo SDK 56 のネイティブコード:インラインモジュールと型生成

Swift と Kotlin のモジュールをアプリのファイルの隣に直接書けば、SDK 56 が対応する TypeScript インターフェースを自動生成する。

日本語
コピー
Native code in Expo SDK 56: inline modules and type generation

React Native アプリにネイティブコードを組み込むのは、それなりに手間がかかる。Expo modules がある程度は助けてくれるが、使ううえで大きな引っかかりが2つある。

  • パッケージのボイラープレート:まずモジュールを作る必要があり、モジュール自体が独立したパッケージなので、それを組み上げないとネイティブコードを書き始められない。

  • インターフェースが二重になる:TypeScript のモジュールインターフェースを手で管理し、ネイティブ側(Swift と Kotlin)と一致させ続けなければならない。

SDK 56 ではこの2つをなんとかすることにして、inline-modulesexpo-type-information パッケージをリリースした。これで新しいモジュールを書くのが速く、楽になる。

inline modules と型生成

inline modules の狙いはオーバーヘッドを最小限にすることだ。Swift と Kotlin のモジュールをプロジェクト構成の中に直接書ける。ほかのアプリのファイルの隣でいい。カスタムネイティブビューが必要なら、App.tsx の隣に NativeView.ktNativeView.swift を作ってビューを書けばいい。

inline modules の設定

inline modules の設定はきわめて単純で、アプリの設定ファイルに watchedDirectories を指定するだけだ。これは inline modules を置くプロジェクト内のディレクトリのリストになる。npx expo prebuild を実行してネイティブプロジェクトを同期すれば、あとは使える。

{
  "expo": {
    "experiments": {
      "inlineModules": {
        "watchedDirectories": ["app"]
      }
    }
  }
}

"app"watchedDirectories に追加すれば、app ディレクトリとそのサブディレクトリ(app/nested/app/nested/directory/ など)のどこにでも Swift と Kotlin のファイルを作れる。たとえば app/nested/InlineModule.swift を開いて Expo module を書ける。

internal import ExpoModulesCore

class InlineModule: Module {
  public func definition() -> ModuleDefinition {
    Constant("Hello") {
      return "Hello iOS inline modules!"
    }
  }
}

書き終えたら JavaScript から requireNativeModule('InlineModule') でアクセスできる。このモジュールでビューを作ったなら、requireNativeView('InlineModule') でインポートできる。

型生成を加える

inline modules は Expo module を作るのに必要なボイラープレートの大半を消してくれるが、型生成はモジュールの呼び出しを簡単にしてくれる。ネイティブモジュールを書いても、快適に使うには TypeScript インターフェースが要る(型チェックと自動補完が効く)。これを担うのが expo-type-information パッケージで、Swift モジュールを解析して対応する TypeScript の型を自動生成する。

このパッケージには強力な CLI ツールが付属している。

CLI ツール

CLI には inline module 専用のコマンドが 1 つある: inline-modules-interface。これはプロジェクト内のすべての Swift inline module を検出し、それぞれに対して 2 つの TypeScript ファイルを生成する。

このコマンドを実行すると、生成されたファイルのペアが Swift ファイルの隣に現れる: InlineModule.generated.tsInlineModule.tsx だ。

InlineModule
  • 生成ファイル[ModuleName].generated.ts):モジュールの型情報をすべて含む。モジュール DSL の宣言(関数、定数、クラス、ビューなど)と、変換可能な Swift の構文(enum と record struct)をカバーする。このファイルはコマンドを実行するたびに上書きされる。
/*Automatically generated by expo-type-information.*/

import { ViewProps } from 'react-native';

import { NativeModule } from 'expo';

export declare class InlineModuleNativeModuleType extends NativeModule {
  readonly Hello: string;
}
  • 安定ファイル[ModuleName].tsx):モジュールのインターフェースを再エクスポートし、メインビューが存在する場合はそのデフォルトエクスポートを提供する。このファイルは編集可能で、一度変更を加えると上書きされなくなる。
// File hash: 8dfc86f5416afbe08cc1ee581c850fc9cec446479211d85501d9a5e2d24cc534
import { InlineModuleNativeModuleType } from './InlineModule.generated';

import { requireNativeModule, requireNativeView } from 'expo';

const InlineModule: InlineModuleNativeModuleType =
  requireNativeModule<InlineModuleNativeModuleType>('InlineModule');

export const Hello: string = InlineModule.Hello;

TypeScript のモジュールインターフェースをこのように分割しておくと、生成結果の不十分な箇所を自分で調整できるうえ、ネイティブ側を更新したときにコアの宣言マッピングを再生成できる。

制限

  • ファイル名:インラインモジュールの名前は、それを定義するファイル名と完全に一致していなければならない。さらに、モジュール名はグローバルに一意である必要がある。この名前はグローバルオブジェクトからモジュールを取り出すための識別子として使われるからだ。したがって、同じプロジェクト内に app/InlineView.swiftsrc/InlineView.swift を同時に置くことはできない。

  • 言語とプラットフォームのサポート:型生成が現在サポートしているのは Swift モジュールのみで、しかも macOS でしか使えない。

解決できない型

ネイティブモジュールが宣言する型を解決できないことがある。主な原因は SourceKitten の制約と、完全なコンパイルを行わず渡されたファイルだけを解析するという方針にある。Swift の型を解決できない場合、TypeScript 側では unknown 型を生成する。

よくあるのは次のようなケースだ。

  • ネストした宣言(例:DSL Class):SourceKitten は深くネストしたクロージャの解析に制約があり、Class の内部で宣言された型を見つけるのが難しい。そのためクラスメソッドの戻り値の型が正しく解決されない(引数の解析は問題ない)。ExpoBlob モジュールを例にすると:
import Foundation
import ExpoModulesCore

public class ExpoBlob: Module {
  public func definition() -> ModuleDefinition {
    Name("ExpoBlob")

    Class(Blob.self) {
      Constructor { (blobParts: [EitherOfThree<String, Blob, TypedArray>]?, options: BlobOptions?) in
        let endings = options?.endings ?? .transparent
        let blobPartsProcessed = processBlobParts(blobParts, endings: endings)
        return Blob(blobParts: blobPartsProcessed, options: options ?? BlobOptions())
      }

      Property("size") { (blob: Blob) in
        blob.size
      }

      Property("type") { (blob: Blob) in
        blob.type
      }

      Function("slice") { (blob: Blob, start: Int?, end: Int?, contentType: String?) in
        let blobSize = blob.size
        let safeStart = start ?? 0
        let safeEnd = end ?? blobSize

        let relativeStart = safeStart < 0 ? max(blobSize + safeStart, 0) : min(safeStart, blobSize)
        let relativeEnd = safeEnd < 0 ? max(blobSize + safeEnd, 0) : min(safeEnd, blobSize)

        return blob.slice(start: relativeStart, end: relativeEnd, contentType: contentType ?? "")
      }

      AsyncFunction("text") { (blob: Blob) async -> String in
        await blob.text()
      }

      AsyncFunction("bytes") { (blob: Blob) async -> Data in
        let bytes = await blob.bytes()
        return Data(bytes)
      }
    }
  }
}
/*Automatically generated by expo-type-information.*/

import { ViewProps } from 'react-native';

import { NativeModule } from 'expo';

// These types haven't been defined in provided file(s).
export type Data = unknown;
export type TypedArray = unknown;

export type BlobOptions = {
  type: string;
  endings: EndingType;
};

export enum EndingType {
  transparent = 'transparent',
  native = 'native'
}
export enum BlobPart {
  string = 'string',
  blob = 'blob',
  data = 'data'
}

export declare class Blob {
  slice(
    blob: Blob,
    start: number | undefined,
    end: number | undefined,
    contentType: string | undefined
  ): unknown /*The type couldn't be resolved automatically.*/;
  text(blob: Blob): Promise<string>;
  bytes(blob: Blob): Promise<Data>;
  readonly size: unknown /*The type couldn't be resolved automatically.*/;
  readonly type: unknown /*The type couldn't be resolved automatically.*/;
  constructor(
    blobParts: (string | Blob | TypedArray)[] | undefined,
    options: BlobOptions | undefined
  );
}

export declare class ExpoBlobNativeModuleType extends NativeModule {
  Blob: typeof Blob;
}

ExpoBlob では、slicetypesize の戻り値の型がいずれも解決されていない点に注意してほしい。

この問題は、Swift のコードでクロージャの戻り値の型を手動で注釈すればたいてい解決する。

  • インポートされた宣言: SourceKitten の使い方では渡されたファイルだけを解析し、インポートは無視する。外部の関数や型が戻り値に影響している場合、ツールはそれを解決できないことがある。

  • 戻り値の型: return キーワードを省略し、クロージャにも明示的な注釈を付けていないと、ツールが戻り値の型を推論するのは難しい。これを避けるには DSL 宣言に注釈を付けるか、return キーワードを必ず入れる。return の型推論を強化したいなら、CLI の --type-inference PREPROCESS_AND_INFERENCE オプションを試すといい。実装がまれに失敗するためデフォルトではオフになっているが、自分のモジュールで役立つかどうかはいつでも試せる。

内部の仕組み

ここからは inline modulesexpo-type-information パッケージが内部でどう動いているかを見ていく。

Inline modules

inline modules を使うと何が起きるのかを追ってみよう。app/nested/InlineModule.swift にモジュールを作ったとする。まず、アプリの設定ファイルで watchedDirectories リストを設定する必要がある。ここでは app フォルダだけを使う。

{
  "expo": {
    "experiments": {
      "inlineModules": {
        "watchedDirectories": ["app"]
      }
    }
  }
}

プリビルド

アプリ設定を更新したら、npx expo prebuild を実行してネイティブプロジェクトを更新する。これが主にやることは2つある。

  • Xcode プロジェクトを更新し、app フォルダをファイルシステム同期グループにする。これにより、このフォルダ(およびサブフォルダ)内のすべてのファイルが Xcode エディタに表示され、iOS ビルドにも自動的に含まれるようになる。
ファイルシステム同期グループ

watchedDirectories で iOS と Android 両方のプロジェクト属性を更新する。Android では、これはファイルを Android Studio に取り込むための必須の手順だ。これらの属性は後続の autolinking フェーズでも使われる。

{
  "expo.jsEngine": "hermes",
  "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
  "expo.inlineModules.watchedDirectories": "[\"app\"]"
}
# ...
expo.inlineModules.watchedDirectories=["app"]

Android プロジェクト

Android 側では、プロジェクトの更新は Gradle の設定フェーズで行われる。トリガーとなるタイミングは 2 つある。Android Studio で Sync Project with Gradle Files ボタンを手動でクリックするか、アプリのビルド前に自動で発火するか(たとえば npx expo run:android を使う場合)だ。

このフェーズでは watchedDirectories に対応するフォルダ構造が作られ、それらの watchedDirectories サブツリー内に Kotlin ファイルへのシンボリックリンクが張られる。

ディレクトリの監視

こうしたミラー構造を作ると、次のことが保証される:

  • ネイティブファイルはコンパイルされる:インラインモジュールはすべて Android Studio 上で見え、Android ビルドを実行するとコンパイルされる。

  • それ以外のファイルは無視される:プロジェクト内の他のファイルは見えない。JavaScript や TypeScript のファイルはこのミラーディレクトリにないため、Android Studio のインデックスには載らない。

自動リンク

Expo のモジュールはすべてグローバルオブジェクトにぶら下がり、ネイティブの module provider を通じて公開される。iOS と Android のビルド時には、通常の Expo モジュールとインラインモジュールへの参照をすべて含む module provider クラスが生成される。JavaScript で requireNativeModule('InlineModule') を呼び出しても、それはこのグローバルオブジェクトにアクセスする処理を包んだだけのものだ。

型生成

型生成の考え方は単純だ。ネイティブモジュールの宣言はそれ自体がすでに高度に構造化されているので、ネイティブコードからそのまま TypeScript インターフェースを生成できる。expo-type-information パッケージがやっているのはまさにこれで、大きく四つの部分からなる:

  • Swift パーサー

  • モジュール型の抽象レイヤー

  • TypeScript コードジェネレーター

  • CLI ツール

これらが連携して、モジュールの TypeScript インターフェースを自動生成する。

型システム

DSL でモジュールを定義するときは、そのモジュールのネイティブインターフェースを渡す。関数、定数、クラス、ビューなどで、Swift の型システムではいずれも厳密に型付けされている。

しかし TypeScript 側からモジュールを呼び出すとき、目の前にあるのは TypeScript の型システムにおける JavaScript オブジェクトだ。これは Swift や Kotlin とは本質的に異なり、両者の間に厳密な一対一の対応は存在しない。そのため、データが JavaScript から Swift へ渡る際の変換は、いつも直感的とは限らない。

Expo は多くの型にコンバーターを用意しているが、異なる TypeScript の構造がまったく同じ Swift の型に変換されることもある。

型システム

たとえば Swift の UIColor を扱うとき、Expo はさまざまな JavaScript オブジェクトを変換できる。色文字列('red')、16進文字列(#00ffaa00)、16進数値(0xff66dd00)などだ。TypeScript ではこれらにそれぞれ別の型注釈を付けられる。stringnumberColorValuereact-native 由来)はいずれも有効な TS 型で、どれも UIColor に変換される。

現時点で対応しているのは最も基本的な型のマッピングだけだ(numberstringboolean など)。全リストはリファレンスドキュメントで確認できる。expo-modules-core にすでにあるコンバーターをもとに、このパッケージの型マッピングは今後も追加していく。

このライブラリの仕組み

ここからは expo-type-information パッケージを構成する各コンポーネントを詳しく見ていく。

Swift ファイルのパース

Swift ファイルのパースには SourceKitten を使う。SourceKitten はコード全体の構造化情報を返すので、Swift DSL、enum、struct を解析できる。

InlineModule.swiftHello 定数宣言を例に取る。

Constant("Hello") {
  return "Hello iOS inline modules!"
}

この Swift 宣言に対して SourceKitten が出力するものは次のとおり。

{
  "key.bodylength": 56,
  "key.bodyoffset": 124,
  "key.kind": "source.lang.swift.expr.call",
  "key.length": 66,
  "key.name": "Constant",
  "key.namelength": 8,
  "key.nameoffset": 115,
  "key.offset": 115,
  "key.substructure": [
    {
      "key.bodylength": 7,
      "key.bodyoffset": 124,
      "key.kind": "source.lang.swift.expr.argument",
      "key.length": 7,
      "key.offset": 124
    },
    {
      "key.bodylength": 48,
      "key.bodyoffset": 133,
      "key.kind": "source.lang.swift.expr.argument",
      "key.length": 48,
      "key.offset": 133,
      "key.substructure": [
        {
          "key.bodylength": 46,
          "key.bodyoffset": 134,
          "key.kind": "source.lang.swift.expr.closure",
          "key.length": 48,
          "key.offset": 133,
          "key.substructure": [
            {
              "key.bodylength": 46,
              "key.bodyoffset": 134,
              "key.kind": "source.lang.swift.stmt.brace",
              "key.length": 48,
              "key.offset": 133
            }
          ]
        }
      ]
    }
  ]
}

SourceKitten の大きな利点は、単一ファイルだけをパースできることだ。これは諸刃の剣でもある。Xcode プロジェクト全体をコンパイルする時間を省けるため、TypeScript インターフェースを繰り返し再生成するワークフローでは欠かせない。一方で、プロジェクト全体にアクセスできないということは、他のファイルで定義された型や関数をパーサーが解決できないということでもある。

型情報の抽象化

次は Expo モジュールに関する型情報の抽象レイヤーだ。この抽象は基盤となるネイティブ言語に依存しない。つまり将来 Kotlin のサポートも追加できる。TS 宣言の生成に使うため、TypeScript の型システムに近い形になっている。SourceKitten のパーサーが出力するものから、この抽象を組み立てている。

/**
 * `FileTypeInformation` object abstracts over type related information in a file.
 * The abstraction is closely related to Typescript and expo NativeModules (both to be independent of the actual native side
 * and to give accurate information about what and how we can use the given module).
 * @header TypeInfoTypes
 */
export type FileTypeInformation = {
  /**
   * @field Set of all type identifiers declared and used in the file.
   */
  usedTypeIdentifiers: Set<string>;
  /**
   * @field Set of all type identifiers declared in the file.
   */
  declaredTypeIdentifiers: Set<string>;
  /**
   * @field For parametrized types it is the maximum number of parameters this type is used with.
   * This map is useful if we want to infer how many parameters a type declared in other file has.
   *
   * For example if `Set<string>` exists in a file then inferredTypeParametersCount['Set'] == 1.
   * If `Map<number, string>` exists then inferredTypeParametersCount['Map'] == 2.
   * If you use both `SomeParametrizedType<Type1, Type2>` and `SomeParametrizedType<Type3>` then inferredTypeParametersCount['SomeParametrizedType'] == 2.
   */
  inferredTypeParametersCount: Map<string, number>;
  /**
   * @field Maps string identifier to the appropriate declaration object. For now only enum and records identifiers are mapped.
   */
  typeIdentifierDefinitionMap: TypeIdentifierDefinitionMap;
  /**
   * @field Array of all module classes declared in the given file.
   */
  moduleClasses: ModuleClassDeclaration[];
  /**
   * @field Array of all record classes declared in the given file.
   */
  records: RecordType[];
  /**
   * @field Array of all enums declared in the given file.
   */
  enums: EnumType[];
};

TypeScript の生成

Expo モジュールの型抽象ができたら、次は TypeScript の抽象構文木(AST)を生成する。Compiler API で構築し、生成が必要な各種宣言(import、enum、class、function、type、interface など)を扱うための独自ラッパーを組み合わせる。AST を組み立てたあとは、Prettier で生成した TypeScript をフォーマットする。

CLI

ここまでの機能はすべて 1 つの CLI ツールにまとめてある。通常のモジュールとインラインモジュールを操作するための使いやすいコマンド群と、前述の各ステップの関数をデバッグするためのコマンドを提供している。

さらに詳しく

インラインモジュール型生成のチュートリアル、そしてインラインモジュールexpo-type-information パッケージのリファレンスドキュメントを参照してほしい。

どちらの機能もまだ実験的で、開発を続けている最中だ。フィードバックを強く求めている。GitHub で issue や pull request を立てるか、ツイートで感想を聞かせてほしい。

出典: Expo Blog← ホームへ戻る