Top View


Author Daiki Urata

Expo Modules APIを使ってFeliCaの読み取りを実装する

2023/08/03

はじめに

以前のExpoアプリではネイティブ機能などをSwiftやKotlinでそれぞれ自前で処理を書こうとすると、Prebuild してmanaged workflowから外れなければなりませんでした。

しかしExpo Modules APIの登場で、Prebuildせずとも自作モジュールとしてmanaged workflowのExpoプロジェクトに組み込めるようになりました。

今回はそのExpo Modules APIを使って、FeliCaの読み取りモジュールを作成、交通系ICカードの情報を取得できるところまで実装してみます。

Expoプロジェクト作成

まずはBlankなExpoプロジェクトを作成します。

$ npx create-expo-app --template expo-template-blank-typescript
$ cd アプリ名
$ npm run start

無事にアプリが起動できればOKです。

ネイティブモジュールを新規作成

そして本題のネイティブモジュールを作っていきます。

npx create-expo-module@latest --local

色々質問されて進めていくとmodulesというところに自分のつけたモジュール名のディレクトリが作られていると思います。今回はnfc-module という名前にしました。

AndroidのFeliCa読み取り

まずはAndroid側のFeliCa読み取り処理を書いていきます。

必要なファイル

create-expo-module コマンド実行後はmodules/nfc-module/android以下に色々ファイルが作られますが、今回Android側の必要なファイルは以下になります。これ以外のファイルは削除してください。

modules/nfc-module/android/
├── build.gradle
└── src
    └── main
        ├── AndroidManifest.xml
        └── java
            └── expo
                └── modules
                    └── nfcmodule
                        └── NfcModule.kt

ソースコード

AndroidではNfcAdapterを使って読み取りを実現します。

NfcModule.ktのコードは以下です。

package expo.modules.nfcmodule

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.Promise
import android.nfc.NfcAdapter
import android.nfc.Tag

class NfcReaderCallback(private val promise: Promise) : NfcAdapter.ReaderCallback {
  override fun onTagDiscovered(tag: Tag?) {
    val idmString = tag?.id?.joinToString("") { "%02x".format(it) }
    promise.resolve(idmString)
  }
}

class NfcModule : Module() {
  var nfcAdapter: NfcAdapter? = null
  override fun definition() = ModuleDefinition {
    Name("NfcModule")

    AsyncFunction("scan") { promise: Promise ->
      nfcAdapter?.enableReaderMode(
          appContext.currentActivity,
          NfcReaderCallback(promise),
          NfcAdapter.FLAG_READER_NFC_F,
          null
      )
    }

    OnCreate {
      nfcAdapter = NfcAdapter.getDefaultAdapter(appContext.reactContext)
    }
  }
}

NfcModuleクラスでExpoのModuleを継承して、そのdefinition関数にJavaScript側で使用する定数や関数などの処理を書いていくことになります。

使えるAPIは公式ドキュメントを参照してください。

ここでは以下APIを利用しています。

  • Name: モジュール名を指定できる
  • AsyncFunction: JavaScript側で呼ぶ関数が定義できる
  • OnCreate: モジュールの初期化後すぐに呼ばれる処理、何かセットアップ処理を書きたい時に使う

ICカードを読み取った後に呼ばれるNfcReaderCallbackクラスのonTagDiscoveredではFeliCaの固有IDであるIDmを結果として返すようにしています。

iOSのFeliCa読み取り

次にiOS側のFeliCa読み取り処理です。

必要なファイル

create-expo-module コマンド実行後はmodules/nfc-module/ios以下に初期ファイルが作られます。

今回iOS側の必要なファイルは2つだけになります。

modules/nfc-module/ios
├── NfcModule.podspec
└── NfcModule.swift

ソースコード

iOS側はNfcModule.swiftに変更を加えるだけでOKです。

import ExpoModulesCore
import CoreNFC

public class NfcModule: Module {
  var session: NfcSession?
  var semaphore: DispatchSemaphore?
  public func definition() -> ModuleDefinition {
    Name("NfcModule")

    AsyncFunction("scan") { (promise: Promise) in
        session?.startSession()
        DispatchQueue.global(qos: .background).async {
            self.semaphore?.wait()
            promise.resolve(self.session?.message)
        }
    }
      
    OnCreate {
        semaphore = DispatchSemaphore(value: 0)
        session = NfcSession(semaphore: semaphore!)
    }
  }
}

class NfcSession: NSObject, NFCTagReaderSessionDelegate {
    var session: NFCTagReaderSession?
    let semaphore: DispatchSemaphore
    var message: String?

    init (semaphore: DispatchSemaphore) {
        self.semaphore = semaphore
    }
    
    func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {
        print("tagReaderSessionDidBecomeActive")
    }
    
    func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
        print("Error: \(error.localizedDescription)")
        self.semaphore.signal()
        self.session = nil
    }
    
    func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
        let tag = tags.first!
        session.connect(to: tag) { error in
            if nil != error {
                session.invalidate(errorMessage: "Error!")
                self.semaphore.signal()
                return
            }
            guard case .feliCa(let feliCaTag) = tag else {
                session.invalidate(errorMessage: "This is not FeliCa!")
                self.semaphore.signal()
                return
            }
            let idm = feliCaTag.currentIDm.map { String(format: "%.2hhx", $0) }.joined()
            self.message = idm
            session.alertMessage = "Success!"
            session.invalidate()
            self.semaphore.signal()
        }
    }
    
    func startSession() {
        self.session = NFCTagReaderSession(pollingOption: [.iso14443, .iso15693, .iso18092], delegate: self, queue: nil)
        session?.alertMessage = "交通系ICカードをかざしてください"
        session?.begin()
    }
}

iOS側もExpoのModuleを継承してdefinition関数内で同じくJavaScript側で呼び出す処理を書いていきます。

利用するAPIはAndroidと同じでName、AsyncFunction、OnCreateです。

そして実際に読み取り処理が書かれているNfcSessionクラスではNFCTagReaderSessionDelegateプロトコルを使用してAndroidと同じくIDmを結果として出力するようにしています。

私がKotlinもSwiftもあまり詳しくないのと、エラーハンドリングなど考えず最低限動くコードにしているので、詳しいコードの説明は今回省きます。

Expo(React Native)側で呼び出す関数をエクスポートする

最後はmodules/nfc-module/index.tsでiOS/Androidでそれぞれ容易した scan 関数をエクスポートします。

import NfcModule from './src/NfcModule';

export async function scan(): Promise<string> {
  return await NfcModule.scan();
}

ご覧の通り、今回は初回生成されたsrc以下にあるView系のファイルなどは不要なため、src以下のファイルは

modules/nfc-module/src
└── NfcModule.ts

となっています。他は削除しました。

FeliCaを読み取るExpoアプリを作成する

モジュールが完成したのであとはApp.tsxでインポートしてきて scan を呼び出すだけです。

import { StatusBar } from 'expo-status-bar';
import { Button, StyleSheet, Text, View } from 'react-native';
import { scan } from './modules/nfc-module';
import { useState } from 'react';

export default function App() {
  const [idm, setIdm] = useState<string>();
  const onPress = async () => {
    const resulit = await scan();
    setIdm(resulit);
  }
  return (
    <View style={styles.container}>
      <StatusBar style="auto" />
      <Button title='Scan' onPress={onPress}/>
      <Text style={{ padding: 12 }}>IDm: {idm ?? '-'}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

これでネイティブコードの部分は完成になります。

app.json

動かす前に最後の仕上げとしてNFC機能を使用するための権限周りの設定をapp.jsonに追加します。

{
  "expo": {
	  .
		.
		.
    "ios": {
      .
			.
      "infoPlist": {
        "NFCReaderUsageDescription": "NFC読み取りのため",
        "com.apple.developer.nfc.readersession.felica.systemcodes": [
          "0003",
          "FE00"
        ]
      },
      "entitlements": {
        "com.apple.developer.nfc.readersession.formats": [
          "TAG"
        ]
      }
    },
    "android": {
			.
			.
      "permissions": [
        "NFC"
      ]
    },
  }
}

iOSはAndroidよりも厳しく、読み取るFeliCaのシステムコードを指定する必要がありました。

システムコードは以下を参考にさせていただきました。 0003FE03 を指定しておけば大体をカバーできそうです。

開発ビルド(expo-dev-client)アプリを用意する

通常の npm run start とExpo Goを使ってデバッグする方法では作成したネイティブモジュールは動きません。

そこで開発ビルドアプリを使用することでネイティブモジュールのデバッグを実機で行うことができます。

開発ビルドアプリの作成方法は基本公式ドキュメントの流れに沿っていきます。

$ npm install -g eas-cli
$ npx expo install expo-dev-client
$ eas login

ビルドにはExpoが提供するExpo Application Services(EAS)を利用するため、EAS CLIのインストールとログインが必要です。

次に以下のようなeas.jsonを用意します。

{
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {}
  }
}

重要なのは build.developmentdevelopmentClient: truedistribution: "internal"にすることです。

準備ができたらまずはAndroidを以下コマンドでビルドします。

$ eas build --profile development --platform android

iOSは少し面倒でad hocプロビジョニングプロファイルを作成、動かしたい端末をそこへ登録して、開発ビルドアプリを配布する必要があります。

これには有料のApple Developerメンバーシップに加入する必要があります。

メンバーシップに加入したAppleアカウントの準備ができたら以下コマンドを叩きます。

$ eas device:create

コマンド実行後、表示された手順に従い端末を登録します。

その後ビルドを開始します。

$ eas build --profile development --platform ios

開発ビルドアプリで動かす

Expoのコンソールでビルドが無事に終わったを確認できたら、iOS/Androidでそれぞれ端末にインストールします。

あとはExpo Goの時と同じで、 npm run start で開発サーバーを起動して、発行されたQRコードをスキャンするとブラウザでExpo Goで動かすか開発ビルド(Development build)で動かすか聞かれるので開発ビルドの方を選択すると、インストールした開発ビルドアプリが起動します。

無事に起動したら「Scan」ボタンをタップ後に交通系ICカードを端末にかざしてうまく読み取りが成功すればIDmが表示されます。

Expo FeliCa reader app demo image

Prebuildでのデバッグ

今回は容易したSwiftやKotlinのコードが確実に動くとわかっていたので、いきなり開発ビルドアプリを作成して動作確認できたので良かったのですが、実際の開発ではそうはいきません。

開発ビルドを使わずにネイティブモジュールの開発を行う場合はPrebuildを行います。

ここではPrebuildの詳しい説明は行いませんが、簡単にいうとXcodeやAndroid Studioでそのまま動かせるようなネイティブコードを生成することです。

これによりExpo GoではなくXcodeやAndroidからネイティブコードを実行し(Expo CLIからも実行できます)、実機にアプリをインストールしてデバッグできるというわけです。

Prebuildコマンドは以下です。

$ npx expo prebuild

Prebuildした後は、コマンドで出力されたiosディレクトリをXcodeで開いて動かしたり、androidディレクトリをAndroid Studioで動かすことが可能です。 また、 npx expo run:ios または npx expo run:android コマンドでも起動できます。

注意点としてExpo Modules APIを使って自作のネイティブモジュールの開発が完了したら、Prebuildで生成/変更されたコードは削除または戻す必要があります(git commitしない)。

npmにモジュールを公開する

Expoの公式ドキュメントでは自作したネイティブモジュールを公開、テストする方法も親切に書かれています。

ここでは詳しく解説しませんが、私も試しにドキュメント通りにやってみたら、簡単に公開できました。

expo-felica-readerという名前で公開して使えるので、興味のある方は試してみてください。

おわりに

今回ネイティブモジュールを作ってみた感想としては、Expo Modules APIで簡単にネイティブ機能を実装できました。

しかし、やはりデバッグがPrebuildして試さないといけないので、そこが大変ではありました。ただ、一度モジュールを作ってしまえばあとはexpo-dev-clientで簡単に実装したネイティブ機能を動かすことができるので便利です。

今回紹介しませんでしたが、Config PluginBuild Propertiesを活用することでmanaged workflowから外れることなくネイティブ側の設定値や機能を追加、変更できるので、とても開発しやすくなった印象です。

Daiki Urata

Daiki Urata

Twitter X

フロントエンド/モバイルアプリなどを主に開発しています。