はじめに
何か Android に特化した Turbo Module を作ってみたくて、Gemini Nano を呼び出すライブラリを作った。
その過程で、ネイティブ内で非同期処理を行いJSへPromise を返す Turbo Moduleをどう実装するか一通り体験したので、手順をまとめる。
- はじめに
- 1. JS 側で spec を書く
- 2. package.json に Codegen 設定を書く
- 3. Android 側の build 設定を入れる
- 4. Kotlin で generated spec を実装する
- 5. Package で Turbo Module を登録する
- 6. autolinking 用の設定を置く
- 7. JS ラッパーを作る
- 8. Android ビルドで Codegen を走らせる
- 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
たとえば JS で isAvailable(): 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 つ。
- spec で Promise
を返すメソッドを定義する Codegen が Kotlin 側では最後の引数に Promise を受ける形へ変換する - Kotlin の coroutine 結果を promise.resolve() / promise.reject() へ流す TaskCompletionSource は必須ではなく、coroutine から直接 Promise を解決する方がシンプルだった
- Package で登録する getModule() と getReactModuleInfoProvider() を実装し、isTurboModule = true を付ける
- invalidate() でリソースを解放する CoroutineScope の cancel、進行中ストリームの停止、ネイティブクライアントの close を行う