kako.dev

開発、自作アプリのこと

CoroutineとPromiseをブリッジする Turbo Module の作り方

はじめに

何か Android に特化した Turbo Module を作ってみたくて、Gemini Nano を呼び出すライブラリを作った。

github.com

その過程で、ネイティブ内で非同期処理を行いJSへPromise を返す Turbo Moduleをどう実装するか一通り体験したので、手順をまとめる。


1. JS 側で spec を書く

まず、JS(TypeScript) から呼び出すインターフェースを定義する。 TurboModule を継承し、TurboModuleRegistry.get<Spec>() に渡す文字列が ネイティブ側で登録するモジュール名 になる。(ここでは GeminiNano

  // src/NativeGeminiNano.ts
  import type {TurboModule} from 'react-native';
  import {TurboModuleRegistry} from 'react-native';

  export interface Spec extends TurboModule {
    isAvailable(): Promise<boolean>;
    getAvailability(): Promise<GeminiNanoAvailability>;
    downloadModel(): Promise<void>;
    generateText(prompt: string): Promise<string>;
    generateTextStream(prompt: string): void; // ストリームは void + イベント
  }

  export default TurboModuleRegistry.get<Spec>('GeminiNano');

Promise を使う場合

Promise を返すメソッドを spec に定義すると、Codegen がネイティブ側で 最後の引数に Promise を受ける形へ変換する。

たとえば JS で isAvailable(): Promise と書くと、Kotlin 側では isAvailable(promise: Promise) になる。

generateTextStream のように Promise を返さず、イベントで結果を流すだけなら void にする。

———

2. package.json に Codegen 設定を書く

"codegenConfig": {
    "name": "NativeGeminiNanoSpec",
    "type": "modules",
    "jsSrcsDir": "src",
    "android": {
      "javaPackageName": "com.gemininano"
    }
  }
  • name: 生成される Spec クラス名のベース
  • jsSrcsDir: spec ファイルを探すディレクトリ
  • javaPackageName: Android 側の生成コードの package 名

———

3. Android 側の build 設定を入れる

android/build.gradle に必要な設定を入れる。

  apply plugin: "com.android.library"
  apply plugin: "kotlin-android"
  apply plugin: "com.facebook.react"

  android {
    sourceSets {
      main {
        java.srcDirs += [
          "${project.buildDir}/generated/source/codegen/java",
        ]
      }
    }
  }

  react {
    jsRootDir = file("../")
    libraryName = "GeminiNano"
    codegenJavaPackageName = "com.gemininano"
  }

  dependencies {
    implementation("com.facebook.react:react-android")
    // ...その他依存
  }

com.facebook.react plugin を入れることで、Android ビルド時に NativeGeminiNanoSpec が自動生成される。 sourceSets でその生成ソースを参照できるようにしておく。

———

4. Kotlin で generated spec を実装する

Promise への変換パターン

Kotlin 側は coroutine で処理して、直接 promise.resolve() / promise.reject() する。

  private fun rejectPromise(
    promise: Promise,
    error: Throwable,
  ) {
    val normalized = errorMapper.map(error)
    promise.reject(normalized.code, normalized.message, error)
  }

各メソッドでは coroutineScope.launch の中で suspend 処理を呼び、その結果を Promise に流す。

実装本体

// android/src/main/java/com/gemininano/GeminiNanoModule.kt

class GeminiNanoModule(
    reactContext: ReactApplicationContext,
) : NativeGeminiNanoSpec(reactContext) {

    private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    override fun getName(): String = NAME

    override fun isAvailable(promise: Promise) {
      moduleScope.launch {
        try {
          promise.resolve(service.getAvailability().isAvailable)
        } catch (error: Throwable) {
          rejectPromise(promise, error)
        }
      }
    }

    override fun generateText(prompt: String, promise: Promise) {
      moduleScope.launch {
        try {
          promise.resolve(service.generateText(prompt))
        } catch (error: Throwable) {
          rejectPromise(promise, error)
        }
      }
    }

    override fun generateTextStream(prompt: String) {
      streamJob = moduleScope.launch {
        try {
          val fullText = service.generateTextStream(prompt) { chunk ->
            eventEmitter.emit(GeminiNanoEventNames.STREAM_CHUNK, ...)
          }

          eventEmitter.emit(GeminiNanoEventNames.STREAM_END, ...)
        } catch (error: Throwable) {
          eventEmitter.emit(GeminiNanoEventNames.STREAM_ERROR, ...)
        }
      }
    }

    override fun invalidate() {
      streamJob?.cancel()
      moduleScope.cancel()
      service.close()
      super.invalidate()
    }

    companion object {
      const val NAME = "GeminiNano"
    }
  }

公開契約は spec と Codegen が持つので、実装側は generated spec を override するだけでよい。

JS の Promise と Kotlin の対応関係

JS (spec) Kotlin (generated spec) resolve/reject
Promise promise: Promise promise.resolve(true)
Promise prompt: String, promise: Promise promise.resolve("text")
Promise promise: Promise promise.resolve(null)
エラー時 - promise.reject(code, message, throwable)

———

5. Package で Turbo Module を登録する

// android/src/main/java/com/gemininano/GeminiNanoPackage.kt

class GeminiNanoPackage : BaseReactPackage() {
    override fun getModule(
      name: String,
      reactContext: ReactApplicationContext,
    ): NativeModule? {
      return if (name == GeminiNanoModule.NAME) {
        GeminiNanoModule(reactContext)
      } else {
        null
      }
    }

    override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
      return ReactModuleInfoProvider {
        mapOf(
          GeminiNanoModule.NAME to ReactModuleInfo(
            name = GeminiNanoModule.NAME,
            className = GeminiNanoModule::class.java.name,
            canOverrideExistingModule = false,
            needsEagerInit = false,
            isCxxModule = false,
            isTurboModule = true,
          ),
        )
      }
    }
  }

BaseReactPackage を継承して、getModule() と getReactModuleInfoProvider() を実装する。 isTurboModule = true を忘れずに。

———

6. autolinking 用の設定を置く

npm ライブラリとして配布するなら react-native.config.js を用意する。

  module.exports = {
    dependency: {
      platforms: {
        android: {
          sourceDir: './android',
          packageImportPath: 'import com.gemininano.GeminiNanoPackage;',
          packageInstance: 'new GeminiNanoPackage()',
        },
      },
    },
  };

autolinking がこのファイルを見て、Android 側へ自動登録する。

———

7. JS ラッパーを作る

// src/index.ts

  export const GeminiNano = {
    isAvailable(): Promise<boolean> {
      return getNativeModule().isAvailable();
    },

    generateTextStream(prompt: string, handlers: StreamHandlers = {}): () => void {
      const removeListeners = addStreamingListeners(handlers);
      getNativeModule().generateTextStream(prompt);
      return removeListeners;
    },
  };

  export const useGeminiNano = () => { /* ... */ };

———

8. Android ビルドで Codegen を走らせる

npm run build は npm 配布用の JS / d.ts を作るだけ。Codegen は別のタイミングで走る。

cd example/android
./gradlew :app:assembleDebug

このタイミングで generateCodegenSchemaFromJavaScript と generateCodegenArtifactsFromSchema が走り、NativeGeminiNanoSpec が生成される。

生成先: build/generated/source/codegen/java/com/gemininano/

———

Turbo Module のライフサイクル

Android

Turbo Module では、モジュールの生成と破棄に応じて初期化・解放を考える必要がある。 実務上よく使うのは次の 2 つ。

メソッド タイミング 用途
initialize() Module 初期化時 必要なら ReactApplicationContext を使った初期化
invalidate() Module 破棄時 リソース解放・状態リセット

このライブラリでは initialize() は特に不要だったので実装していない。 一方で invalidate() は使っていて、進行中の coroutine やストリームを止め、サービスを解放している。

Turbo Module はシングルトン

  • 初めてアクセスされたタイミングで生成される
  • 純粋な React Native アプリでは、生成後はアプリのライフタイム中ほぼ生き続けることが多い(シングルトン)
  • ただし、ネイティブ + RN 混在では破棄・再生成が起こることがある

invalidate() の実装例

このライブラリでは coroutine を使って Promise を解決しているので、Module 破棄時には scope を閉じる必要がある。

  override fun invalidate() {
    streamJob?.cancel()   // 進行中のストリームを止める
    moduleScope.cancel()  // coroutine をまとめて止める
    service.close()       // ML Kit クライアントを解放
    super.invalidate()    // 親も呼ぶ
  }

stateful な Module は invalidate() を正しく実装しないとリークしやすい。 逆に stateless で、解放すべきリソースを何も持たないなら何もしなくてもよい。

———

まとめ

Promise を使った Turbo Module の核心は 4 つ。

  1. spec で Promise を返すメソッドを定義する Codegen が Kotlin 側では最後の引数に Promise を受ける形へ変換する
  2. Kotlin の coroutine 結果を promise.resolve() / promise.reject() へ流す TaskCompletionSource は必須ではなく、coroutine から直接 Promise を解決する方がシンプルだった
  3. Package で登録する getModule() と getReactModuleInfoProvider() を実装し、isTurboModule = true を付ける
  4. invalidate() でリソースを解放する CoroutineScope の cancel、進行中ストリームの停止、ネイティブクライアントの close を行う