【Expo】expo-cameraのバーコード読み取りで枠外の誤検出と多重発火を防ぐ

鳥井京祐
鳥井京祐約18分で読めます
はてなブックマーク

はじめに

こんにちは、株式会社TOKOSの鳥井です!
今回はExpoのexpo-cameraでバーコードを読み取る方法についてまとめてみました💪

最近開発しているモバイルアプリで、商品のバーコードを読み取る機能を実装しました。
読み取り自体はすぐに実装できたのですが、実際に使える状態にするまでには2つの問題がありました🥲

1つは、同じバーコードを何度も処理してしまう問題です。
もう1つは、読み取り枠の外にあるバーコードまで読んでしまう問題です。

本記事ではexpo-cameraを用いたバーコード読み取りの基本的な実装方法と上記の問題の解決方法を紹介していきます!

対象読者

  • expo-cameraのバーコード読み取り機能を実装する方
  • バーコード読み取りで誤検出や多重発火に悩んでいる方

完成イメージ

本記事で最終的に作る画面はこちらです。

画面全体にカメラのプレビューを表示し、中央に読み取り枠を重ねています。
また、下部のボタンの長押し中に枠内で検出されたバーコードを処理するようにしています。

まずはexpo-cameraの基本的な使い方から紹介します!

expo-cameraの基本的な使い方

expo-cameraは、Expo SDKに含まれるカメラのパッケージです!
写真や動画の撮影に加えて、バーコードの読み取りにも対応しています!

Camera

A React component that renders a preview for the device's front or back camera.

docs.expo.dev

読み取りに使うのはCameraViewと2つのpropsです。

app/scan.tsx
import { CameraView, useCameraPermissions } from "expo-camera"
import { StyleSheet, Text } from "react-native"
 
export default function ScanScreen() {
  const [permission] = useCameraPermissions({ request: true })
 
  if (!permission?.granted) {
    return <Text>カメラの使用許可が必要です</Text>
  }
 
  return (
    <CameraView
      style={StyleSheet.absoluteFill}
      barcodeScannerSettings={{ barcodeTypes: ["ean13", "ean8", "upc_a", "upc_e"] }}
      onBarcodeScanned={(result) => {
        console.log(result.data)
      }}
    />
  )
}

barcodeScannerSettingsのbarcodeTypesで、読み取りたい形式を配列で指定します。
読み取りに成功するとonBarcodeScannedが呼ばれます。
引数のresultには、読み取った結果がBarcodeScanningResult型のオブジェクトで渡されます。
resultに含まれる値の一覧は、公式ドキュメントで確認できます。

ひとまずこれにて読み取りはできるようになりました!

barcodeTypesに指定できる代表的な形式は以下の通りです!
これ以外の形式も指定可能で、詳しくは公式ドキュメントをご覧ください!

値規格種類主な用途
ean13EAN-131次元日本国内の商品。13桁でJANコードの標準タイプ
ean8EAN-81次元日本国内の小さい商品。8桁でJANコードの短縮タイプ
upc_aUPC-A1次元北米の商品。12桁
upc_eUPC-E1次元北米の小さい商品。UPC-Aを短縮した形式
code128Code 1281次元英数字と記号を扱う。物流のラベル
qrQR Code2次元URLや決済

今回扱うのはJANコードとU.P.C.という2つの商品コードです。
JANコードは耳馴染みのある方が多いと思いますが、こちらは日本で使われる商品コードの規格名です。
U.P.C.はUniversal Product Codeの略で、北米での商品コードの規格名です。
また、JANコード・U.P.C.の商品コードはそれぞれEAN-13とEAN-8、UPC-AとUPC-Eという規格のバーコードで表現されます。
そのため、この4つの規格に対応するean13・ean8・upc_a・upc_eを指定しています。

カメラの権限を要求する

カメラを使うにはOSの許可が必要です!
先ほどのコードで使っているuseCameraPermissionsは、現在の状態と許可を要求する関数を返します。
状態が分かるまでpermissionはnullで、許可されているかどうかはgrantedで判定します。

useCameraPermissionsの引数に{ request: true }を渡すことで、マウント時にフックが許可を要求し、OSのダイアログが表示されます。
既定では状態を取得するだけなので、渡さない場合は許可を要求する関数requestPermissionを自分で呼ぶ必要があります。
公式ドキュメントの例では、ボタンの押下時にrequestPermissionを呼んでいます。

許可を求めるときの文言は、設定ファイルのapp.jsonで指定します。
expoのpluginsにexpo-cameraを追加し、オプションのcameraPermissionに文言を書きます。
この文言は、ビルド時にiOSのInfo.plistのNSCameraUsageDescriptionへ書き出されます。
NSCameraUsageDescriptionは、許可ダイアログに表示される説明文です。

recordAudioAndroidは既定がtrueで、AndroidにRECORD_AUDIOの権限が追加されます。
バーコードの読み取りだけなら不要なのでfalseにしています。

app.json
{
  "expo": {
    "plugins": [
      [
        "expo-camera",
        {
          "cameraPermission": "バーコードを読み取るためにカメラへのアクセスを許可してください",
          "recordAudioAndroid": false
        }
      ]
    ]
  }
}

12桁で返るバーコードを13桁に戻す

このアプリでは、読み取ったコードを使って商品テーブルを検索しています。
ところが、商品テーブルに登録されているはずの商品が一部見つかりませんでした。
読み取り結果を確認すると、13桁のはずのJANコードが12桁で返っていました🥲

JANコードの標準タイプは13桁のGTIN-13です。
一方、北米で使われるU.P.C.は12桁のGTIN-12で、桁数が1つ少なくなっています。

GTINとは

GS1 Japan (一般財団法人流通システム開発センター)公式サイト

www.gs1jp.org

北米のUPC-Aを13桁で表すと、先頭に0が付きます。
expo-cameraの読み取り結果では、この先頭の0が外れて12桁になります。

商品テーブルはJANコードを13桁で持っているため、12桁のままでは検索が一致しません。
そこで、12桁で返ってきたときは先頭に0を補って13桁に揃える関数を用意しました!

libs/normalizeJanCode.ts
export const normalizeJanCode = (data: string) => (data.length === 12 ? `0${data}` : data)

短縮された形式のean8とupc_eは桁数の扱いが異なるため、この関数では変換していません。
短縮された形式の商品も扱う場合は、商品テーブルのデータに合わせた変換を別途用意してください。

鳥井
鳥井

特定の商品だけ検索しても見つからないので、最初はAPI側を疑っていました。読み取った値をログに出して、ようやく桁数の違いに気づきました🥲

問題点1:同じバーコードを何度も処理してしまう

今の実装のままだと、カメラにバーコードが映った際に検出が無限ループしてしまいます。

動画では、検出した回数と処理した回数を画面の左上に出しています。
バーコードを映している間、どちらの数字も増え続けています。

onBarcodeScannedは、カメラがバーコードを検出するたびに呼ばれる仕様となっています。
ここで読み取った商品を取得するAPIを叩くと、同じ商品のリクエストが何度も飛んでしまいます。

この処理を1回で止めるために、次の2つを実装します!

  1. ボタンの長押し中だけ読み取る
  2. refで同期的にガードする

1. ボタンの長押し中だけ読み取る

今回のアプリは、複数の商品のバーコードが近くに並んだ状態で使われる想定です。
枠に合わせてからボタンを押してもらえば、途中で映ったバーコードを読まずに済みます。
そのため、読み取りのタイミングはボタンの長押し中だけにすることにしました!

これを実現するため、PressableのonPressInとonPressOutで押している状態を取得します。

components/ScanButton.tsx
import { Pressable, StyleSheet, Text } from "react-native"
 
type Props = {
  isPressed: boolean
  onPressIn: () => void
  onPressOut: () => void
}
 
export function ScanButton({ isPressed, onPressIn, onPressOut }: Props) {
  return (
    <Pressable
      accessibilityRole="button"
      accessibilityLabel={isPressed ? "スキャン中" : "スキャンボタン"}
      onPressIn={onPressIn}
      onPressOut={onPressOut}
      style={isPressed && styles.pressed}
    >
      <Text>読み取り</Text>
    </Pressable>
  )
}
 
const styles = StyleSheet.create({
  pressed: { opacity: 0.6 },
})

画面側では、ScanButtonをCameraViewの上に重ねます。
9行目で押している状態をuseStateで持ち、21行目でボタンを押していないときの検出を弾いています。

app/scan.tsx
import { CameraView, useCameraPermissions } from "expo-camera"
import { useState } from "react"
import { StyleSheet, Text, View } from "react-native"
 
import { ScanButton } from "@/components/ScanButton"
 
export default function ScanScreen() {
  const [permission] = useCameraPermissions({ request: true })
  const [isButtonPressed, setIsButtonPressed] = useState(false)
 
  if (!permission?.granted) {
    return <Text>カメラの使用許可が必要です</Text>
  }
 
  return (
    <View style={styles.container}>
      <CameraView
        style={StyleSheet.absoluteFill}
        barcodeScannerSettings={{ barcodeTypes: ["ean13", "ean8", "upc_a", "upc_e"] }}
        onBarcodeScanned={(result) => {
          if (!isButtonPressed) return
          console.log(result.data)
        }}
      />
      <View style={styles.footer}>
        <ScanButton
          isPressed={isButtonPressed}
          onPressIn={() => setIsButtonPressed(true)}
          onPressOut={() => setIsButtonPressed(false)}
        />
      </View>
    </View>
  )
}
 
const styles = StyleSheet.create({
  container: { flex: 1 },
  footer: { position: "absolute", right: 32, bottom: 48, left: 32 },
})

2. refで同期的にガードする

押している状態をuseStateだけで持つと、ガードが間に合いません。
setStateで更新した値を読めるのは次のレンダリング以降です。
onBarcodeScannedはそれを待たずに何度も呼ばれるため、更新前の値で判定してしまいます。

そこで、判定にはuseRefを使います!
refは再レンダリングを挟まずに値が反映されるので、次の検出をすぐに弾けます!

ボタンの見た目は再レンダリングが必要なため、useStateも併せて持ちます。
判定用がref、表示用がstateという分担です。

今回、判定用のrefと表示用のstateを更新する処理と一緒にカスタムフックのuseScannerにまとめています!

hooks/useScanner.ts
import type { BarcodeScanningResult } from "expo-camera"
import { useRef, useState } from "react"
 
import { normalizeJanCode } from "@/libs/normalizeJanCode"
 
export function useScanner() {
  const isButtonPressedRef = useRef(false)
  const hasScannedRef = useRef(false)
  const [isButtonPressed, setIsButtonPressed] = useState(false)
  const [scannedJan, setScannedJan] = useState("")
 
  const handleBarcodeScanned = (result: BarcodeScanningResult) => {
    if (!isButtonPressedRef.current || hasScannedRef.current) return
    hasScannedRef.current = true
    setScannedJan(normalizeJanCode(result.data))
  }
 
  const startScanning = () => {
    if (hasScannedRef.current) return
    isButtonPressedRef.current = true
    setIsButtonPressed(true)
  }
 
  const stopScanning = () => {
    isButtonPressedRef.current = false
    setIsButtonPressed(false)
  }
 
  const reset = () => {
    isButtonPressedRef.current = false
    hasScannedRef.current = false
    setIsButtonPressed(false)
    setScannedJan("")
  }
 
  return {
    isButtonPressed,
    scannedJan,
    startScanning,
    stopScanning,
    reset,
    handleBarcodeScanned,
  }
}

13行目でボタンを押していない場合と読み取り済みの場合を弾き、14行目で読み取り済みのフラグを立てるので、1回の長押しの中で2回目以降は処理されません。
次のバーコードを読むときはresetを呼び、フラグと値をリセットします。

検出の回数は増え続けますが、処理の回数を1回で止めることができました!

鳥井
鳥井

同じ意味の値をrefとstateで2つ持つのは冗長に見えますが、判定用と表示用で役割は異なるため分ける必要があります!

問題点2:読み取り枠の外のバーコードまで読んでしまう

次に、読み取り枠の外にあるバーコードを読んでしまう問題を解消していきます!

枠を描いていても、カメラが映している範囲すべてが検出の対象となっています。
分かりにくいですが、動画では枠内に入れたバーコードではなく、その上のバーコードが読み取られてしまっています。

expo-cameraには読み取り範囲を指定するpropがない

まず、expo-camera側で範囲を絞れないかを確認しました。
barcodeScannerSettingsが受け取るBarcodeSettingsのプロパティはbarcodeTypesだけで、範囲を指定するpropはありません🥲

そのため、検出した結果の座標を見て、枠の内側かどうかを自分で判定する必要があります。

onBarcodeScannedが受け取るBarcodeScanningResultにはboundsが含まれます。
これは検出したバーコードを囲う矩形で、originとsizeを持ちます。
ただし、公式ドキュメントには次の注記があります。

bounds in some case will be representing an empty rectangle. Moreover, bounds doesn't have to bound the whole barcode. For some types, they will represent the area used by the scanner.

(訳)boundsは、空の矩形を表す場合があります。さらにboundsは、バーコード全体を囲うとは限りません。形式によっては、スキャナーが使用した領域を表します。

boundsを正確な矩形として扱おうとすると、判定が厳しくなりすぎます。
バーコード全体を囲うとは限らないため、四隅が枠に収まっているかを条件にすると、枠に合わせた商品まで弾いてしまいます。
そこで判定には、矩形の中心点だけを使います。

読み取り枠の座標をonLayoutで測る

boundsと枠の位置を比べるには、2つを同じ基準の座標で表す必要があります。
boundsの座標の詳しい説明は公式ドキュメントを確認してみてください!

今回はCameraViewを画面全体に広げているので、boundsは画面の左上を起点とした座標になります。
そのため、枠の位置も画面の左上を起点とした座標で用意する必要があります。

ただし、枠のサイズをJS側に定数で持つと、スタイルと二重管理になってしまいます。
そこで、実際に描画されたレイアウトをonLayoutで取得します!

React Nativeの公式ドキュメント

The most fundamental component for building a UI, View is a container that supports layout with flexbox, style, some touch handling, and accessibility controls. View maps directly to the native view equivalent on whatever platform React Native is running on, whether that is a UIView, `, android.view`, etc.

reactnative.dev

枠は、半透明の黒いオーバーレイをくり抜く形で作っています。
まず画面を縦に3分割し、上下をオーバーレイ、中央を帯にします。
帯は読み取り枠と同じ高さで、画面の左端から右端までを占める部分です。
次にその帯を横に3分割し、左右をオーバーレイ、中央を読み取り枠にします。
帯はコードのrow、読み取り枠はholeにあたります。

components/ScanFrame.tsx
import { useRef } from "react"
import type { LayoutChangeEvent, LayoutRectangle } from "react-native"
import { StyleSheet, View } from "react-native"
 
import type { ScanArea } from "@/types/scan"
 
type Props = {
  onScanAreaChange: (scanArea: ScanArea) => void
}
 
export function ScanFrame({ onScanAreaChange }: Props) {
  const rowLayoutRef = useRef<LayoutRectangle | null>(null)
  const holeLayoutRef = useRef<LayoutRectangle | null>(null)
 
  const emitScanArea = () => {
    const rowLayout = rowLayoutRef.current
    const holeLayout = holeLayoutRef.current
    if (rowLayout === null || holeLayout === null) return
 
    onScanAreaChange({
      x: rowLayout.x + holeLayout.x,
      y: rowLayout.y + holeLayout.y,
      width: holeLayout.width,
      height: holeLayout.height,
    })
  }
 
  const handleRowLayout = (event: LayoutChangeEvent) => {
    rowLayoutRef.current = event.nativeEvent.layout
    emitScanArea()
  }
 
  const handleHoleLayout = (event: LayoutChangeEvent) => {
    holeLayoutRef.current = event.nativeEvent.layout
    emitScanArea()
  }
 
  return (
    <View style={StyleSheet.absoluteFill} pointerEvents="none">
      <View style={styles.mask} />
      <View style={styles.row} onLayout={handleRowLayout}>
        <View style={styles.sideMask} />
        <View style={styles.hole} onLayout={handleHoleLayout} />
        <View style={styles.sideMask} />
      </View>
      <View style={styles.mask} />
    </View>
  )
}
 
const styles = StyleSheet.create({
  mask: { flex: 1, backgroundColor: "rgba(0, 0, 0, 0.8)" },
  row: { flexDirection: "row", height: 200 },
  sideMask: { width: "12.5%", backgroundColor: "rgba(0, 0, 0, 0.8)" },
  hole: { flex: 1 },
})

枠の座標を組み立てているのは21行目と22行目です!
onLayoutが返すxとyは、親要素の左上を起点として、自分の左上までの距離を表します。
2つのレイアウトが持つ値は次のとおりです。

値何を表すか値の中身
rowLayout.x画面の左上から帯の左上までの横の距離0(帯は幅いっぱいに広がるため)
rowLayout.y画面の左上から帯の左上までの縦の距離上のオーバーレイの高さ
holeLayout.x帯の左上から読み取り枠の左上までの横の距離左のオーバーレイの幅
holeLayout.y帯の左上から読み取り枠の左上までの縦の距離0(読み取り枠は帯の高さいっぱいに広がるため)

xどうしとyどうしを足すと、画面の左上から読み取り枠の左上までの距離になります。
これがScanAreaのxとyです。

検出位置が枠の内側かを判定する

枠の矩形が取れたので、あとは判定するだけです!
boundsから中心点を求め、枠の内側に入っているかを調べます!

枠を測る前に検出が起きる場合もあるため、scanAreaがnullのときは判定せず、読み取りを通すようにしました。
また先ほどの注記のとおり、boundsは空の矩形になる場合があるため、サイズが0のときも同じく通します。

types/scan.ts
export type ScanArea = {
  x: number
  y: number
  width: number
  height: number
}
libs/isBarcodeInScanArea.ts
import type { BarcodeScanningResult } from "expo-camera"
 
import type { ScanArea } from "@/types/scan"
 
export function isBarcodeInScanArea(bounds: BarcodeScanningResult["bounds"], scanArea: ScanArea | null) {
  if (scanArea === null) return true
 
  const { origin, size } = bounds
  if (size.width === 0 || size.height === 0) return true
 
  const centerX = origin.x + size.width / 2
  const centerY = origin.y + size.height / 2
 
  return (
    centerX >= scanArea.x
    && centerX <= scanArea.x + scanArea.width
    && centerY >= scanArea.y
    && centerY <= scanArea.y + scanArea.height
  )
}

この判定をuseScannerに組み込みます!
枠の座標はレイアウトのたびに届くので、useStateではなくrefに持たせます。

hooks/useScanner.ts
const scanAreaRef = useRef<ScanArea | null>(null) 
 
const setScanArea = (scanArea: ScanArea) => {
  scanAreaRef.current = scanArea
}
 
const handleBarcodeScanned = (result: BarcodeScanningResult) => {
  if (!isButtonPressedRef.current || hasScannedRef.current) return
  if (!isBarcodeInScanArea(result.bounds, scanAreaRef.current)) return
  hasScannedRef.current = true
  setScannedJan(normalizeJanCode(result.data))
}

これで、枠の中のバーコードのみが検出されるようになりました🙌

全体の実装

ここまでの内容をまとめると、フックは次のようになります!
読み取りに成功した合図として、expo-hapticsで触覚フィードバックも返しています。

hooks/useScanner.ts
import type { BarcodeScanningResult } from "expo-camera"
import * as Haptics from "expo-haptics"
import { useRef, useState } from "react"
 
import { isBarcodeInScanArea } from "@/libs/isBarcodeInScanArea"
import { normalizeJanCode } from "@/libs/normalizeJanCode"
import type { ScanArea } from "@/types/scan"
 
export function useScanner() {
  const isButtonPressedRef = useRef(false)
  const hasScannedRef = useRef(false)
  const scanAreaRef = useRef<ScanArea | null>(null)
  const [isButtonPressed, setIsButtonPressed] = useState(false)
  const [scannedJan, setScannedJan] = useState("")
 
  const setScanArea = (scanArea: ScanArea) => {
    scanAreaRef.current = scanArea
  }
 
  const handleBarcodeScanned = (result: BarcodeScanningResult) => {
    if (!isButtonPressedRef.current || hasScannedRef.current) return
    if (!isBarcodeInScanArea(result.bounds, scanAreaRef.current)) return
    hasScannedRef.current = true
    void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
    setScannedJan(normalizeJanCode(result.data))
  }
 
  const startScanning = () => {
    if (hasScannedRef.current) return
    isButtonPressedRef.current = true
    setIsButtonPressed(true)
  }
 
  const stopScanning = () => {
    isButtonPressedRef.current = false
    setIsButtonPressed(false)
  }
 
  const reset = () => {
    isButtonPressedRef.current = false
    hasScannedRef.current = false
    setIsButtonPressed(false)
    setScannedJan("")
  }
 
  return {
    isButtonPressed,
    scannedJan,
    startScanning,
    stopScanning,
    reset,
    setScanArea,
    handleBarcodeScanned,
  }
}

画面側では、CameraViewとScanFrameを重ねてフックとつなぎます。
カメラの権限は、最初のコードと同じく{ request: true }で要求しています。
読み取った後の表示は、本来は商品を取得してシートなどに出す部分です。
ここでは読み取った商品コードをそのまま出しています。

app/scan.tsx
import { CameraView, useCameraPermissions } from "expo-camera"
import { Pressable, StyleSheet, Text, View } from "react-native"
 
import { ScanButton } from "@/components/ScanButton"
import { ScanFrame } from "@/components/ScanFrame"
import { useScanner } from "@/hooks/useScanner"
 
export default function ScanScreen() {
  const [permission] = useCameraPermissions({ request: true })
  const { isButtonPressed, scannedJan, startScanning, stopScanning, reset, setScanArea, handleBarcodeScanned } =
    useScanner()
 
  if (!permission?.granted) {
    return <Text>カメラの使用許可が必要です</Text>
  }
 
  return (
    <View style={styles.container}>
      <CameraView
        style={StyleSheet.absoluteFill}
        barcodeScannerSettings={{ barcodeTypes: ["ean13", "ean8", "upc_a", "upc_e"] }}
        onBarcodeScanned={handleBarcodeScanned}
      />
      <ScanFrame onScanAreaChange={setScanArea} />
      <View style={styles.footer}>
        <ScanButton isPressed={isButtonPressed} onPressIn={startScanning} onPressOut={stopScanning} />
      </View>
      {scannedJan === "" ? null : (
        <View>
          <Text>JAN : {scannedJan}</Text>
          <Pressable accessibilityRole="button" onPress={reset}>
            <Text>閉じる</Text>
          </Pressable>
        </View>
      )}
    </View>
  )
}
 
const styles = StyleSheet.create({
  container: { flex: 1 },
  footer: { position: "absolute", right: 32, bottom: 48, left: 32 },
})

おわりに

本記事では、expo-cameraでバーコードを読み取る実装を紹介しました。

onBarcodeScannedは検出のたびに呼ばれるため、多重発火はrefで同期的に止めます。
読み取り範囲を指定するpropはないので、枠の座標をonLayoutで測り、boundsの中心点で判定します。
どちらも数行で済みますが、入れるまでは業務で使える精度になりませんでした。

バーコードを扱う画面を作るときは、ぜひ参考にしてください💪

同じモバイルアプリでは、FlashListで固定ヘッダー付きのリストも実装しました。
タブを切り替えたときのチラつきを解消する方法は、以下の記事で紹介しています!ぜひ読んでみてください!

【React Native】FlashListとuseDeferredValueで固定ヘッダー付きリストを実装する

React NativeのFlashListとReactのuseDeferredValueを組み合わせて、スクロールに追従する固定ヘッダーと、タブ切り替え時のチラつき防止を実装する方法をコード例とともに解説します。

鳥井
鳥井

最後まで読んでいただきありがとうございました!カメラ周りは実機でしか確認できない挙動が多いので、また気づきがあれば記事にします😎

この記事を書いた人

鳥井京祐
鳥井京祐

TOKOSのバックエンドエンジニアです。最近はフロントエンド、特にReactに興味があります!趣味は麻雀です!