
九保すこひです(フリーランスのITコンサルタント、エンジニア)
さてさて、前回記事「Laravel + Vue + GraphQLでデータ取得」では最近知った新しい技術をテーマにした内容をお届けしました。
そして、今回はその流れで「知ってはいたけど、なかなかチャレンジする機会がなかった」内容をお届けしたいと思います。
ズバリ、それは・・・
PWA(Progressive Web Application)
です。
PWA
とは、簡単に言うと「ウェブの技術でスマホのアプリがつくれる」というもので、大部分のシェアを占める iPhone、Android 両方に対応しています。(厳密にはiOSの場合、インストール部分などで未整備の部分もあります)
そこで!
今回はLaravel
を使って、PWA
を実装してみたいと思います。
ぜひ楽しみながらやってみましょう
(最後に今回開発したソースコード一式をダウンロードできますよ)
「正直言うと、手こずりました」
開発環境: Laravel 7.x(サンプルは5.8)
目次 [非表示]
やりたいこと
今回は日本の5大都市の天気予報(3時間毎)を表示するアプリを作ってみます。
見た目はこんな感じです。(サンプルはこちら)
では、実際に作業をしていきましょう
開発前の注意点
PWA
をブラウザで実行するにはHTTPS
での接続が必須です。
しかも、自己証明によるHTTPSも不可です。
そのため、これを解決するには「接続環境のエラーについて」を参考にして環境をつくってみてください。
なお、ローカル環境にHTTPS
を用意するには「コピペでOK!ローカル環境にHTTPSを導入する(nginx編)」が参考になるかと思います。
準備する
マニフェストを用意する
PWA
に必要な、ウェブアプリケーション情報がわかる「マニフェスト」と呼ばれるファイルを作成します。
なお、マニフェストにはアイコン(512 x 512ピクセル)が必要になりますのでお好みで用意しておいてください。もし、適当なアイコンがない場合は以下のものを使ってください。
(この画像は、こちらのページからダウンロードしたもので、パブリックドメイン・ライセンスです。)
では、マニフェストを簡単にオンラインで作成できるWeb App Manifest Generatorにアクセスしてください。
すると次のようなフォームが表示されるので、
- App Name: 天気予報テスト(アプリ名)
- Short Name: 天気予報テスト(短いアプリ名)
- Display Mode: Standalone
- Start URL: /pwa_forecast
と入力します。
次に、先ほど用意した512 x 512
ピクセルのアイコンを選択し、「GENERATE .ZIP」ボタンをクリックしてください。
すると、自動的にZIPファイルがダウンロードされますので、中身を展開してLaravel
のpublic
フォルダに設置します。
OpenWeatherMapに登録する
天気予報の情報を取得するために、OpenWeatcherMap
(無料プランあります)に登録してAPIキーを取得します。
登録ページにアクセスすると、次のページが表示されるので、
- ユーザー名
- メールアドレス
- パスワード
- パスワード(確認)
- 「16歳以上です」のチェック
- 利用規約同意チェック
- 「ロボットではありません」のチェック
に入力をして「Create Account」ボタンをクリックしてください。
すると、次のような表示がでるので、Purpose
(目的)を「Weather widget for web」にして「Save」ボタンをクリックします。
すると、入力したメールアドレスに次のようなメッセージが届きますので、「Verify your email」をクリックして本登録を完了させてください。
本登録が完了したら、(ログインして)ページ上部にある「API keys」をクリックして、APIキーを取得します。
APIキーを取得したら、後で管理しやすいようにLaravel
の.env
に書き込んでおきます。
/.env
OPEN_WEATHER_MAP_API=(あなたのAPIキー)
PWAアプリをつくる
では、ここからが本番です。
PWA
アプリ本体をつくっていきましょう
Service Workerをつくる
では、先にブラウザのバックグラウンドで動くことになる「Service Woker」と呼ばれるファイルを作っていきます。
/public/sw.js
const appKey = 'my-forecast-app';
self.addEventListener('install', e => { //
インストールされたとき ・・・ ①
//
基本ファイルのキャッシュを保存
caches.open(appKey)
.then(cache => {
cache.addAll([
'/pwa_forecast',
'https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css',
'https://cdn.jsdelivr.net/npm/vue@2.6.11'
]);
})
});
self.addEventListener('fetch', e => { //
データ取得するとき ・・・ ②
e.respondWith(getFetchResponse(e.request));
});
async function getFetchResponse(request) {
const url = new URL(request.url);
if(url.host === location.host) { // 自分のサイト内のURLの場合
// キャッシュ優先
const cachedResponse = await caches.match(request);
if(cachedResponse) {
return cachedResponse;
}
return fetch(request);
} else {
// ネットワーク優先
const cache = await caches.open(appKey);
try {
const response = await fetch(request);
await cache.put(request, response.clone());
return response;
} catch {
return await cache.match(request);
}
}
}
この中では大きく2つの部分に分かれています。
① インストールされたとき
Service Worker
がインストールされた時に実行されることになります。この部分では、メインのHTML
やCSS
、JS
ファイルをキャッシュすることになります。
② データ取得するとき
この部分では、通信状況によって以下4つのパターンに対応しています。
- 自分のサイトのURLで、通信OKの時
- 自分のサイトのURLで、通信NGの時
- 外部サイトのURLで、通信OKの時
- 外部サイトのURLで、通信NGの時
つまり、今回の例で言うと、
- 天気データは外部サイトから取得するので、できるだけ最新データを取得するけど、もし通信NGだったら直前にキャッシュされたデータを使う(ネットワーク優先)
- 逆に、自分のサイトの場合はアイコンなど変化がないので、できるだけキャッシュから読み込む(キャッシュ優先)
となります。
ルートをつくる
今回はテストですので、コントローラーを省略して開発を進めます。
/routes/web.php
//
追加
Route::get('pwa_forecast', function(){ return view('pwa_forecast'); });
ビューをつくる
次にビューをつくります。
/resources/views/pwa_forecast.blade.php
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
<link rel="manifest" href="/manifest.json">
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-3">
<div class="row">
<div class="col-md-8">
<h1>天気予報テスト</h1>
</div>
<div class="col-md-4 text-right mb-3">
<button class="btn btn-primary" type="button" @click="onInstallationClick">ホーム画面へ登録</button>
</div>
<div class="col-md-12">
<select
class="form-control mb-3"
v-model="currentCityId"
@change="getForecast">
<option
v-for="c in cities"
v-text="c.name"
:value="c.id">
</option>
</select>
<div class="card mb-2" v-for="f in forecasts">
<div class="card-body">
<img :src="forecastIcon(f.weather)">
<span v-text="f.dt_txt"></span>
</div>
</div>
<div class="pl-2" v-if="!forecasts.length">
データが見つかりません。
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script>
<script>
new Vue({
el: '#app',
data: {
currentCityId: 1850144, // 東京
appId: '{{ env('OPEN_WEATHER_MAP_API') }}',
cities: [
{ id: 2128295, name: '札幌' },
{ id: 1850144, name: '東京' },
{ id: 1856057, name: '名古屋' },
{ id: 1853909, name: '大阪' },
{ id: 1863967, name: '福岡' }
],
forecasts: [],
promptEvent: null,
},
methods: {
async getForecast() { // 天気データを取得
this.forecasts = [];
if(this.currentCityId > 0) {
const url = `https://api.openweathermap.org/data/2.5/forecast?id=${this.currentCityId}&appid=${this.appId}`;
const response = await fetch(url);
if(response.ok) {
const data = await response.json();
this.forecasts = data.list;
}
}
},
forecastIcon(weather) {
// OpenWeatherMapが提供するアイコンのURL
return `https://openweathermap.org/img/wn/${weather[0].icon}.png`;
},
onInstallationClick() { //
PWAアプリをインストールする部分
this.promptEvent.prompt();
this.promptEvent.userChoice
.then(choice => {
if(choice.outcome === 'accepted') {
console.log('PWA: インストールを許可しました。');
} else {
console.log('PWA: インストールを拒否しました。');
}
this.promptEvent = null;
});
}
},
computed: {
showButton() {
return (this.promptEvent !== null);
}
},
created() {
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
this.promptEvent = e;
});
//
Service Workerをインストールする部分
if('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(registration => {
if(registration.installing) {
console.log('Service Worker: インストール成功!');
location.reload(); //
インストール直後はリロード
} else {
this.getForecast();
}
})
.catch(error => {
console.log('Service Worker: インストール失敗...');
});
} else {
this.getForecast();
}
}
});
</script>
</body>
</html>
この中で重要なのは、created()
内のService Worker
をインストールする部分です。
Service Worker
は、navigator.serviceWorker.register('***')
でブラウザにインストールしますが、この直後にlocation.reload()
でページを再読み込みしています。
これは、Service Worker
がインストールされた直後は天気データなどを取得する時のfetch
イベントが実行されないためです。(厳密には方法があるようですが、あまりおすすめしません)
そして、再読み込みされた時に初めてgetForecast()
で天気データを取得することになり、このデータはService Worker
でキャッシュされ、オフライン状態でも表示できるようになります。
テストしてみる
では、実際にAndroid
でテストしてみましょう!
うまく天気情報が取得できています。
次に「ホーム画面へ登録」ボタンをクリックしてPWA
アプリをインストールしてみましょう。
インストールが完了するとこうなりました。
そして、このアイコンをクリックすると次のような起動画面まで表示してくれます。
成功です
なお、天気データを取得後に一旦オフラインにして実行してみてもうまくキャッシュからデータ表示されました。
その他の実装
個人的に運営している「街角コレクション」というサイトで「pwa アプリ」を公開しています。
興味がある方は、ぜひインストールして試してみてください。
ダウンロードする
今回実際に開発したソースコード一式を以下からダウンロードできます。
※ただし、アイコンやマニフェスト、APIキーはご自身で準備していただく必要があります。
PWAアプリをLaravel + Vueでつくるおわりに
ということで、今回はLaravel
でPWA
アプリを作ってみました。
正直なことを言うと、記事の途中で書いた「Service Workerがインストールされた直後はfetchイベントが効かない」という点でハマってしまい、久しぶりに目薬を指すぐらい画面を凝視することになりました
なお、iPhone
については「ホーム画面に追加」機能がサポートされていないので、今すぐPWA
をモバイル・アプリとして使うには難しい部分があると思いますが、今後には期待が持てます。
というのも、PWA
アプリをAndroid
で実行してみましたがあまりウェブアプリという感じがせず、スピードも申し分がないものでした。
iPhone
にもAndroid
にもアプリをつくりたいけど、予算があまりなかったり、そこまで難しい機能は必要ないという場合には有力な選択肢になるような気がしています。
ぜひ、みなさんもチャレンジしてみてくださいね。
ではでは〜
「長時間ハマったのに、
寝て起きたら5分で解決する
現象って名前ついてるのかな」