はじめに
PWA
昨今なにかと話題になっているPWA。
PWAって何?という話はさまざまな記事が出ているのでここでは割愛します。
とにかく、ペドロさんも
As an SEO, if you're not getting acquainted with Progressive Web Apps (PWAs), you're missing the boat!
とツイートしていたり、このツイートを取り上げているこちらの記事では、さまざまなサービスがPWAを導入した効果が載っています。PWAの効果すごい!
というわけで私も自身が運営するサービスにPWAを導入してみます。
前提
今回はバージョン 5.0.2 の Rails で実装しています。
実装
PWAに必要なもの
PWAを導入するには以下が必要になります。
- レスポンシブ対応
- HTTPS対応していること
- Serviceworkerの導入
- Manifestの設定
レスポンシブ対応
色々な方法がありますが、CSSで
@media all and (min-width: 769px){
  // 769px 以上の画面用の CSS
}
@media all and (max-width: 768px){
  // 768px 以下の画面用の CSS
}
@media all and (max-width: 640px){
  // 640px 以下の画面用の CSS
}
のようにしてサイズを指定して切り分けることができます。
HTTPS対応
証明書のインストール等のインフラの設定もあるものの、
Rails側では
Rails.application.configure do
  # ...
  
  # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
  config.force_ssl = true
  
  # ...
end
とするだけで基本的にはよいはず。(ここのページはhttpがいい!とかあると別途対応が必要になります。)
Serviceworkerの導入
そもそもServiceworkerってなんぞやというと「クライアント側でユーザーが見ている画面とは別にバックグラウンドで動かせるスクリプト」です。
ややこしい説明はここではしませんが、こいつのおかげでPWAの機能である、PUSH通知やオフラインでも画面を見せたりすることができます。
今回は serviceworker-rails という gem を使って実装しました。
まずはgemをインストールします。
gem 'serviceworker-rails'
$ bundle install
そして初期ファイルを作成します。
手で作ることもできますが、面倒くさいので今回はコマンドを叩いて作成します。
ちなみに私は下記コマンドを叩いたとき「serviceworker なんて知らねえよ!」と怒られ、
spring stop をして通るようにしました。
$ rails g serviceworker:install
      create  app/assets/javascripts/manifest.json.erb
      create  app/assets/javascripts/serviceworker.js.erb
      create  app/assets/javascripts/serviceworker-companion.js
      create  config/initializers/serviceworker.rb
      append  app/assets/javascripts/application.js
      append  config/initializers/assets.rb
      insert  app/views/layouts/application.html.haml
      create  public/offline.html
上記が初期ファイルになります。この初期ファイルの中でさまざまな設定が書かれています。
設定
Rails.configuration.assets.precompile += %w[serviceworker.js manifest.json]
Rails.application.configure do
  config.serviceworker.routes.draw do
    # map to assets implicitly
    match "/serviceworker.js"
    match "/manifest.json"
    # Examples
    #
    # map to a named asset explicitly
    # match "/proxied-serviceworker.js" => "nested/asset/serviceworker.js"
    # match "/nested/serviceworker.js" => "another/serviceworker.js"
    #
    # capture named path segments and interpolate to asset name
    # match "/captures/*segments/serviceworker.js" => "%{segments}/serviceworker.js"
    #
    # capture named parameter and interpolate to asset name
    # match "/parameter/:id/serviceworker.js" => "project/%{id}/serviceworker.js"
    #
    # insert custom headers
    # match "/header-serviceworker.js" => "another/serviceworker.js",
    #   headers: { "X-Resource-Header" => "A resource" }
    #
    # anonymous glob exposes `paths` variable for interpolation
    # match "/*/serviceworker.js" => "%{paths}/serviceworker.js"
  end
end
ここで serviceworker.js と manifest.json の2ファイルを読み込み、パスを指定しています。
もしこれらのファイルをディレクトリの中に置き直したのであれば、config/initializers/serviceworker.rb の "Examples" に沿って記入してください。
if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/serviceworker.js', { scope: './' })
    .then(function(reg) {
      console.log('[Companion]', 'Service worker registered!');
    });
}
var CACHE_VERSION = 'v1';
var CACHE_NAME = CACHE_VERSION + ':sw-cache-';
function onInstall(event) {
  console.log('[Serviceworker]', "Installing!", event);
  event.waitUntil(
    caches.open(CACHE_NAME).then(function prefill(cache) {
      return cache.addAll([
        // make sure serviceworker.js is not required by application.js
        // if you want to reference application.js from here
        '<%#= asset_path "www_domain/application.js" %>',
        '<%= asset_path "www_domain/application.css" %>',
        '/offline.html',
      ]);
    })
  );
}
function onActivate(event) {
  console.log('[Serviceworker]', "Activating!", event);
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          // Return true if you want to remove this cache,
          // but remember that caches are shared across
          // the whole origin
          return cacheName.indexOf(CACHE_VERSION) !== 0;
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    })
  );
}
// Borrowed from https://github.com/TalAter/UpUp
function onFetch(event) {
  event.respondWith(
    // try to return untouched request from network first
    fetch(event.request).catch(function() {
      // if it fails, try to return request from the cache
      return caches.match(event.request).then(function(response) {
        if (response) {
          return response;
        }
        // if not found in cache, return default offline content for navigate requests
        if (event.request.mode === 'navigate' ||
          (event.request.method === 'GET' && event.request.headers.get('accept').includes('text/html'))) {
          console.log('[Serviceworker]', "Fetching offline content", event);
          return caches.match('/offline.html');
        }
      })
    })
  );
}
self.addEventListener('install', onInstall);
self.addEventListener('activate', onActivate);
self.addEventListener('fetch', onFetch);
app/assets/javascripts/serviceworker-companion.js と app/assets/javascripts/serviceworker.js.erb では
- register
- install
- activate
- fetch
のそれぞれの Serviceworker のイベントごとの挙動を設定しています。
続いて application.js に serviceworker-companion.js を読み込ませます。
//= require serviceworker-companion
最後に Serviceworker の目玉機能の1つでもある、オフラインページです。
ユーザーがオフライン時に表示される画面の設定です。Rails のデフォルトの 404ページや 500ページと同様、public 配下にデフォルトのファイルができているので、文言やデザインを変えたい方はこちらをいじると変えることができます。
また、404等と同様、public 配下ではなく、動的に作り直すこともできるようです。
ちなみにオフライン用のファイルとして public/offline.html が使われるのは、app/assets/javascripts/serviceworker.js.erb 内の onInstall 関数で設定されているからなので、設定すれば、offline.html 以外のファイルもオフラインファイルとして設定できると思います。
デフォルトの offline.html は以下。
<!DOCTYPE html>
<html>
<head>
  <title>You are not connected to the Internet</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <style>
  body {
    background-color: #EFEFEF;
    color: #2E2F30;
    text-align: center;
    font-family: arial, sans-serif;
    margin: 0;
  }
  div.dialog {
    width: 95%;
    max-width: 33em;
    margin: 4em auto 0;
  }
  div.dialog > div {
    border: 1px solid #CCC;
    border-right-color: #999;
    border-left-color: #999;
    border-bottom-color: #BBB;
    border-top: #B00100 solid 4px;
    border-top-left-radius: 9px;
    border-top-right-radius: 9px;
    background-color: white;
    padding: 7px 12% 0;
    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
  }
  h1 {
    font-size: 100%;
    color: #730E15;
    line-height: 1.5em;
  }
  div.dialog > p {
    margin: 0 0 1em;
    padding: 1em;
    background-color: #F7F7F7;
    border: 1px solid #CCC;
    border-right-color: #999;
    border-left-color: #999;
    border-bottom-color: #999;
    border-bottom-left-radius: 4px;
    border-bottom-right-radius: 4px;
    border-top-color: #DADADA;
    color: #666;
    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
  }
  </style>
</head>
<body>
  <!-- This file lives in public/offline.html -->
  <div class="dialog">
    <div>
      <h1>It looks like you've lost your Internet connection</h1>
      <p>You may need to reconnect to Wi-Fi.</p>
    </div>
  </div>
</body>
</html>
Serviceworker の導入は以上です。
確認
ここまでの設定がちゃんと動作しているのか、ローカル環境で確認してみましょう。
PWAの導入にはHTTPS対応していることが条件と上述しましたが、localhost ドメインであれば HTTP でも動くようになっているので、
普段 puma の機能で hogehoge.dev や hogehoge.test など使っているかたも我慢して localhost でアクセスするようにしてください。
ここで rails s でサーバー立ち上げて確認しようと思ったのですが、一瞬アクセスできるもののサーバーが
* Restarting...
=> Booting Puma
=> Rails 5.0.2 application starting in development on http://localhost:3000
=> Run `rails server -h` for more startup options
A server is already running. Check /Users/hirotakasasaki/Sites/favy-inbound/tmp/pids/server.pid.
Exiting
と強制終了させられるようになり確認できなくなりました。。。
puma が rails s と相性が悪いようなので、pumactl を使えということでしたのでそちらで。
pumactl start で localhost を立ち上げると落ちなくなりました。(この辺はまたちゃんと調べるようにします。)
で、確認方法ですが、コンソールを開けば終わりです。
Serviceworker のイベントそれぞれに console.log(◯◯); と親切に書いてくれているので、それらがコンソールにちゃんと表示されていればOK。
また、検証ツールのメニューの >> を押すと現れる Application の中でも確認できます。

Status が "activated and is runninig" となっているのがわかると思います。
また Application の Offline にチェックを入れてリロードすると、オフラインでの挙動を確かめられたり、
今回は実装していませんが、Push 通知や同期処理のテストもできるみたいですね!
Manifest の導入
最後に Manifest の導入をします。
Manifest は簡単にいうとホーム画面に追加したときの設定です。
設定
設定は上で作られた app/assets/javascripts/manifest.json.erb 内で行います。
以下がデフォルトでできたファイルです。
<% icon_sizes = Rails.configuration.serviceworker.icon_sizes %>
{
  "name": "app name",
  "short_name": "app short name",
  "start_url": "/",
  "icons": [
  <% icon_sizes.map { |s| "#{s}x#{s}" }.each.with_index do |dim, i| %>
    {
      "src": "<%= image_path "serviceworker-rails/heart-#{dim}.png" %>",
      "sizes": "<%= dim %>",
      "type": "image/png"
    }<%= i == (icon_sizes.length - 1) ? '' : ',' %>
  <% end %>
  ],
  "theme_color": "#000000",
  "background_color": "#FFFFFF",
  "display": "fullscreen",
  "orientation": "portrait"
}
JSONでさまざまな設定がされているようですが、それぞれの意味と値はこんな感じです。
| キー | 内容 | 
|---|---|
| name | アイコンのラベルとして使われる名前 | 
| short_name | nameが入り切らないときなどに表示される名前 | 
| start_url | ユーザーがアプリケーションを起動したときに最初にロードされるURL | 
| icons | Serviceworkerでさまざまな場所で使われるアイコン。 それぞれの用途の推奨サイズに一番近い画像が使われます。 ただし、iOSの場合、 app_touch_iconがアイコンに使われてしまうのでそちらをちゃんと設定しないといけない模様。・src(画像のパス) ・sizes(画像のサイズ。例:"128x128") ・type(例:"image/png") の3つを指定します。 デフォルトでは Rails.configuration.serviceworker.icon_sizesのサイズでループ処理で設定していますが、サイズの中身は36 48 60 72 76 96 120 152 180 192 512です。 | 
| theme_color | テーマカラー。アンドロイドのタスクスイッチャーではこの色で囲まれるらしい。 | 
| background_color | アプリの背景色。そもそもCSSで各々のサイトは背景色をつけていることも多いと思いますが、アプリの起動からコンテンツをロードするまでの間などにこの色が使われます。 | 
| display | アプリの表示の仕方を設定。 ・fullscreen(デバイスのメニューバー含めて非表示) ・standalone(ブラウザのUIを非表示。ネイティブアプリと同じ感じ。) ・browser(通常のブラウザ表示) | 
| orientation | ページの最初の向きを設定。 ・landscape にするとデフォルトで横向きになるので、横表示のみで表示するゲームなどには便利。他にも以下の値を指定できます。 any・natural・landscape-primary・landscape-secondary・portrait・portrait-primary・portrait-secondary | 
確認
先程と同様、検証ツール内の Application の中の Manifest というところを開くと、
Manifest の設定が反映されているか確認することができます。
また IP を使って同じ wifi に繋げば、実機確認もできるのと、手軽にやるなら Xcode の Simulator を使ってすることもできます。

実際にタップすると、ちゃんと動くのが確認できるかと思います。display を standalone で指定したので、ネイティブアプリっぽくなってかっこいい!!
まとめ
そんなこんなでそこまで知識がなくとも serviceworker-rails のおかげでこんなにも簡単にPWAを導入することができました。
Push通知やカメラアプリを起動するなどまだまだやれることはたくさんあるので、これから試していきたいと思います。
こんな便利そうなPWAですが、iOSに組み込まれたのが最近ということもあり、Android と挙動が違う部分も多々あり、
対応しないといけないこともあるようです(戻るボタンをつけないと前のページに戻れないなど)。
この辺もまとめてくれている方いるので参考にしながら対応してみてください。


