Webの技術だけで作るQRコードリーダー

この記事は[PWA Advent Calendar 2019](https://qiita.com/advent-calendar/2019/pwa)の1日目の記事です。

以前、[PWA Night vol.9](https://pwanight.connpass.com/event/147857/) で、Web技術だけで作るQRコードリーダーという内容で紹介したのですが、当日は時間の問題で概要程度だったのでもう少し技術的な詳細を書きます。([当日のスライドはこちら](https://speakerdeck.com/kandai/webji-shu-dakedezuo-ruqrkodorida))

# 作ったもの

まずは、実際に見ていただくのがイメージつきやすいと思うので動作しているGIFです。

![qr.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3531/eacd0b8b-c3f2-6b94-71fb-231b1c908482.gif)

QRコードを読み込んで結果を表示するというものですが、これがブラウザで動いています。

実際に公開されていますのでよかったら使ってみてください。

サイト：https://simple-qr.netlify.com/
GitHub：https://github.com/KanDai/simple-qr-reader

確認している対応ブラウザは以下です。

- Android Chrome
- Android Firefox
- iOS Safari

PWA化しているのでインストールして使うことができます。

# QRリーダーの実装

全体像はこんなイメージです。

![img.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3531/207790fc-ad6a-b3f6-5c5e-487439ea141b.png)

## JavaScriptでデバイスのカメラにアクセス

[MediaDevices.getUserMedia()](https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia)というAPIを使用することで、JavaScriptで端末のカメラやマイクの入力ストリームを取得・操作することができます。

以下のコードだけで、Webページに端末のカメラの映像が映し出されます。
※ カメラやマイクが付いていない端末やこのAPIに対応していない環境では確認できません。

```index.html
<div class="reader">
  <video id="js-video" class="reader-video" autoplay playsinline></video>
</div>
```

```app.js
const video  = document.querySelector('#js-video')

navigator.mediaDevices
    .getUserMedia({
        audio: false,
        video: {
            facingMode: {
                exact: 'environment'
            }
        }
    })
    .then(function(stream) {
        video.srcObject = stream
        video.onloadedmetadata = function(e) {
            video.play()
        }
    })
    .catch(function(err) {
        alert('Error!!')
    })
```

`getUserMedia()` の引数の `video` の値を変えることで、リアカメラやフロントカメラなど使用するカメラを切り替えることができます。

```app.css
*,
*:before,
*:after {
    box-sizing: border-box;
}

html, body {
    margin: 0;
    padding: 0;
    height: 100%;
    font-family: sans-serif;
}

.reader {
    width: 100vw;
    height: 100%;
    position: relative;
}

.reader-video {
    background-color: #000;
    width: 100%;
    height: 100%;
    object-fit: fill;
}
```

カメラの映像を表示するのに `width: 100%; height: 100%;` で画面全体を使えると思ってたのですが上手くいかず `object-fit: fill` も追加で指定することで画面全体を使えるようになりました。

## 取得した画像データからQRコードのデータを取得

`getUserMedia()` で受け取った入力ストリームを `canvas` を使って画像データに変換します。

次に、画像データを解析して、QRコードがあればデータを取得しますが、ここは[jsQR](https://github.com/cozmo/jsQR)というライブラリを使用しました。
以下のような簡単な記述で、画像にQRコードが含まれていればデータを取得できるのでとても助かりました。

```js
const code = jsQR(imageData, width, height, options?);

if (code) {
  console.log("Found QR code", code);
}
```

実際のコードは以下。

```index.html
<div style="display:none">
  <canvas id="js-canvas"></canvas>
</div>
```

`canvas`要素は、実際に画面上に表示される必要はないので、親要素を `display:none` で非表示にしてます。

```app.js
const canvas = document.querySelector('#js-canvas')
const ctx = canvas.getContext('2d')

const checkImage = () => {
    // 取得している動画をCanvasに描画
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height)

    // Canvasからデータを取得
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)

    // jsQRに渡す
    const code = jsQR(imageData.data, canvas.width, canvas.height)

    // QRコードの読み取りに成功したらモーダル開く
    // 失敗したら再度実行
    if (code) {
        openModal(code.data)
    } else {
        setTimeout(() => { checkImage() }, 200)
    }
}
```

変換した画像データを解析して、QRコードの読み取りがうまくいけばモーダルで取得結果を表示、エラーだったら再実行というのを0.2秒間隔で処理しています。
最初は負荷を気にして1秒とかで試していたのですが、それだとちょっと遅いなという感覚だったので調整していって0.2秒に落ち着きました。

##  モーダルを表示

モーダルの表示は受け取ったURLを書き換えて表示しているだけです。

```index.html
<div id="js-modal" class="modal-overlay">
  <div class="modal">
    <div class="modal-cnt">
      <span class="modal-title">読み取り結果</span>
      <span id="js-result" class="modal-result"></span>
    </div>
    <a href="" id="js-link" class="modal-btn" target="_blank">開く</a>
    <button type="button" id="js-modal-close" class="modal-btn">閉じる</button>
  </div>
</div>
```

```app.js
const openModal = function(url) {
    document.querySelector('#js-result').innerText = url
    document.querySelector('#js-link').setAttribute('href', url)
    document.querySelector('#js-modal').classList.add('is-show')
}

document.querySelector('#js-modal-close')
    .addEventListener('click', () => {
        document.querySelector('#js-modal').classList.remove('is-show')
        checkImage()
    })
```

モーダルを閉じた場合は、再度画像データを取得して解析する処理が走るようになっています。

```css
.modal-overlay {
    display: none;
    position: fixed;
    justify-content: center;
    align-items: center;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100%;
    background-color: rgba(0, 0, 0, .7);
}

.modal-overlay.is-show {
    display: flex;
}
```

# PWA化する

ここまででQRリーダー自体はできたので、次はPWA化について紹介します。

## Web App Manifestの設定とPWACompat

`manifest.json` は特別なことはなく以下のような記述です。

```manifest.json
{
  "name": "Simple QR Reader",
  "short_name": "Simple QR",
  "theme_color": "#000000",
  "background_color": "#ffffff",
  "display": "standalone",
  "orientation": "portrait",
  "Scope": "/",
  "start_url": "/",
  "icons": [
    {
      "src": "images/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    // …　その他のアイコンサイズ記載
  ]
}
```

Android向けには、これを読み込ませるだけでOKなのですが、iOS向けにPWA化する時は `apple-mobile-web-app-capable` などの独自のmetaタグを入れる必要があります。

そこで、今回はGoogleが提供する[PWACompat](https://developers.google.com/web/updates/2018/07/pwacompat)というライブラリを使用してみました。
PWACompatは `manifest.json` の情報から、Web App Manifestを解釈しないブラウザのために、関連するmetaタグやlink要素を自動的に挿入してくれるので、iOS向けの記述などをする必要がありません！

HTMLの `<head>` 内に以下のタグを追記するだけです。

```index.html
<link rel="manifest" href="manifest.webmanifest" />
<script async src="https://cdn.jsdelivr.net/npm/pwacompat@2.0.9/pwacompat.min.js" integrity="sha384-VcI6S+HIsE80FVM1jgbd6WDFhzKYA0PecD/LcIyMQpT4fMJdijBh0I7Iblaacawc" crossorigin="anonymous"></script>
```

実際にやってみましたが簡単すぎて感動しました。

## Workboxを使用したキャッシュ

ServiceWorkerのキャッシュ制御には[WorkBox](https://developers.google.com/web/tools/workbox)を使用しました。
こちらも、Googleが提供するキャッシュの制御をサポートしてくれるライブラリです。

とはいえ、使用しているファイル数も少ないですし動的な処理もないので記述自体はこれだけです。

```sw.js
importScripts('https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js');

workbox.routing.registerRoute(
    '/',
    new workbox.strategies.NetworkFirst()
)

workbox.routing.registerRoute(
    new RegExp(/(.*\.js|.*\.css|.*\.jpg|.*\.png|.*\.ico)/),
    new workbox.strategies.StaleWhileRevalidate({
        cacheName: 'assets',
        plugins: [
            new workbox.expiration.Plugin({
                maxEntries: 20
            }),
        ],
    })
)
```

TOPページのHTMLだけ `NetworkFirst` の設定でキャッシュして、それ以外の静的アセットは `StaleWhileRevalidate` の設定でキャッシュしています。
(プリキャッシュでもう少し細かく設定したりしようと思ったのですが少し楽をしました…)

## ~~iOSのPWAでカメラ使えない問題~~

~~アプリ自体はこれで完成ですが、冒頭で少し説明したように、iOSではgetUsermediaがSafariでしか動作しないため、standaloneで動作するPWAではカメラにアクセスすることが出来ません。これは今後のアップデートで是非とも対応してほしいところです…~~

<img width="864" alt="caniuse.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3531/ced3139f-32d9-a408-9e6a-3f60d0af3ebc.jpeg">

https://caniuse.com/#search=getusermedia

**2020/4/30追記
2020/3/25に提供されたiOS13.4から動作するようになったみたいです！
実際に試してみたところ動作するのが確認できました。**

### 非対応のブラウザにはHTML Media Captureを使う？

`<input type="file">` の拡張で[HTML Media Capture](https://www.w3.org/TR/html-media-capture/)という機能があります。
通常の動作だと、端末に保存してある画像などを選択する画面になりますが、下記のように `capture` 属性を記述すると直接カメラを起動することが出来ます。

```html
<input type="file" name="file" accept="image/*" capture="environment" />
```

`capture` 属性は `user` でフロントカメラ、 `environment` でリアのカメラが立ち上がります。
撮影した画像はFileListオブジェクトで取得できるので、それを使ってQRコードの解析をすることはできると思います。(今回のアプリではそこまでやってませんが…)

# 追記

ブラウザ標準のShape Detection APIというAPIを使って同じものを実装する記事を書きました。
[続・Webの技術だけで作るQRコードリーダー](https://qiita.com/kan_dai/items/3486880236a2fcd9b527)

# PWAのカンファレンスやります

コードは[GitHubで公開している](https://github.com/KanDai/simple-qr-reader)ので、コード全体が気になる方は覗いてみてください！

Webの技術だけでできることが、どんどん広がっているのでWebのエンジニアとしてはとても嬉しいですね！
ネイティブアプリでしか実現できなかったようなこともWebでできるようになってくるとなれば色々と夢が広がります！

そんな夢が広がるPWAですが、[PWA Night](https://pwanight.connpass.com/)というPWAをテーマにしたコミュニティを運営してます。
東京では毎月第３水曜日、大阪でも2ヶ月に1回のペースでイベントをやっています。

そして、2020年2月1日（土）に[PWA Night CONFERENCE 2020](https://conf2020.pwanight.jp/)というカンファレンスをやります！

豪華スピーカーを迎えて、PWAの事例やWebの最新技術の情報などを知れる1日になりますのでぜひご参加ください！