この記事の要点
Android で NfcV(ISO 15693)系タグを連続的に・安定して読み書きするとき、標準の Tag ディスパッチではなく Reader Mode で能動的に通信するケースの実装ノウハウをまとめます。ハマりやすいのは次の3つです。
ReaderMode のフラグ指定:掴みたい技術だけを指定する
接続直後のハング:一部端末で最初の通信が固まる → 捨て読みで吸収
Compose の ViewModel 共有事故:似た画面が状態を共有してしまう
NfcAdapter.enableReaderMode() で NfcV を扱う方向けです。
1. 前提:なぜ Reader Mode を使うのか
Android のNFCには2つの読み方があります。
Tag ディスパッチ:かざすと OS が Intent を飛ばしてくれる受け身の方式
Reader Mode:アプリが能動的にポーリングして掴みにいく方式
機器を操作するような双方向・連続の通信では Reader Mode を選びます。NDEF の自動処理が挟まると通信が乱れるため、それも切る前提です。
2. 罠その1:フラグには掴みたい技術"だけ"を指定する
NfcV を掴みたいのに反応しない、という最初の壁は、余計な technology フラグを混ぜていることが原因になりがちです。
// NG:A/B/F/V を全部盛りにすると、端末によっては NfcV を掴み損ねる
val flags = FLAG_READER_NFC_A or FLAG_READER_NFC_B or
FLAG_READER_NFC_F or FLAG_READER_NFC_V
// OK:NfcV だけ。NDEF チェックも切る
val flags = FLAG_READER_NFC_V or FLAG_READER_SKIP_NDEF_CHECK
複数フラグを立てると、端末側の RF ディスカバリが別の技術を先に拾い、狙った NfcV セッションが安定しないことがあります。掴みたいものだけを指定するのが安定します。
FLAG_READER_SKIP_NDEF_CHECK も重要です。これが無いと接続後に OS が勝手に NDEF を読みにいき、こちらの通信と衝突します。RF が余計なアクセスをしないよう抑える、という点で、共有メモリ型タグICのアービター問題(記事①)と根は同じです。
3. 罠その2:接続直後の最初の通信がハングする
もっとも厄介なのがこれです。一部メーカーの端末で、次の症状が出ます。
connect() した直後の最初の transceive() が約1000msハングして TagLost を投げる。
一方で2回目以降の同じ通信は正常速度で通る。
1接続の予算が1秒ちょっとしかないのに、最初の1発でそれを使い切ってしまうため、致命的です。
対処:接続直後に「捨て読み」を1発入れる
最初のハングを、結果を使わないダミー通信でわざと受け止めます。
fun openSession(tag: Tag) {
val nfcV = NfcV.get(tag)
nfcV.connect()
Thread.sleep(20) // 一部端末向け:RF安定待ち
// ★捨て読み:最初のハング/例外をここで吸収する
runCatching {
nfcV.transceive(dummyReadCommand())
}.onFailure {
Log.w(TAG, "dummy read failed (expected on some devices), ignoring")
}
// これ以降の通信は正常速度で通る
}
例外が飛んでも runCatching で握りつぶす。この1発を捧げることで本命の通信が生きます。
見落としやすいのは「捨て読みを置く位置」
捨て読みをセッション初期化処理のあとに置くと効きません。ハングするのは初期化処理の中の最初の通信なので、捨て読みがその手前に無いと意味がないからです。
原則はこうです。
捨て読みは connect() の直後、他のどの通信よりも前に置く
すでに動いている端末向けの既存処理は順序を変えず、追加だけで対処する(動いている経路を壊さない)
メーカーごとに RF スタックの挙動が違うので、「1台で動いた」を安定と見なさず、複数端末で必ず検証するのが安全です。
4. 罠その3:Compose の ViewModel が意図せず共有される
これは NFC 固有ではなく Compose の話ですが、中身のロジックが共通な画面が2つあるときに刺さります。
たとえば「管理者向けの操作画面」と「利用者向けの操作画面」でロジックを共通化し、同じ ViewModel を使い回すと、次の問題が起きます。
// 両方の画面がこれを呼ぶと、同一インスタンスを共有してしまう
val vm: LockViewModel = viewModel()
viewModel() は ViewModelStoreOwner(多くの場合 Activity)単位でインスタンスをキャッシュします。キーを指定しないと2つの画面が同じ ViewModel を掴み、入力中の値や処理フェーズが混線します。片方の画面の入力がもう片方に漏れる、といった事故です。
対処:キーで明示的に分ける
// 画面ごとに別インスタンスを保証する
val vmAdmin: LockViewModel = viewModel(key = "admin_lock")
val vmUser: LockViewModel = viewModel(key = "user_lock")
同じクラスでもキーが違えば別インスタンスになります。ロジックを共通化しながら状態を分離できます。
応用:共通ロジックを「差し替え」で使い回す
共通 ViewModel を「既定の対象」で動かしておき、必要なときだけ対象を差し替える設計も有効です。
class LockViewModel : ViewModel() {
private var target = DEFAULT_TARGET
// 既定値のまま呼べば従来の挙動。bind すると対象が変わる。
fun bind(newTarget: Target) {
if (target == newTarget) return // ★冪等にする
target = newTarget
resetState()
}
}
bind() を冪等にしておくのが地味に効きます。Compose は再コンポーズで LaunchedEffect が再発火することがあり、毎回 resetState() が走ると操作中に状態が初期化されます。「同じ対象なら何もしない」の早期 return で防げます。
5. まとめ
NFC実装は「1台で動いた」からが本番です。メーカーごとの RF スタックの違いに、地道な検証で対処していく世界だと捉えておくと心構えができます。
| 罠 | 対処 |
|---|---|
| NfcV を掴めない | フラグは NfcV だけ+NDEF チェックを切る |
| 接続直後にハングする | connect() 直後に捨て読み、位置は最前 |
| 端末ごとの癖 | 複数メーカーで実機確認、動く経路は壊さない |
| ViewModel が混線 | viewModel(key=...) で明示分離 |
| 再コンポーズで状態リセット | bind() を冪等にする |