Flutter Webを使って一瞬でPWAを作る

# 目的
某顧客にPWAのメリットを説明するにあたって、デモをしたかったので作成しました。今後も同様のケースが考えられるので、ノウハウとしてまとめておきます。ついでに途中で詰まったことも記録しておきます。

# ざっくりと技術要素説明
## Flutter Web
Google社が提供している、iOSやAndroidアプリのクロスプラットフォームフレームワークです。もともとはモバイル向けがメインでしたが、最近はWebにも対応しています。言語はDartを使用します。
ちなみにクロスプラットフォーム開発の競合としてはXamarinやReact Nativeがありますが、GitHubのスター数はFlutterが一番多いですね。私はJavaの経験が最も長いので、個人的にはJavaに近いDartが使えるのも魅力です。

## Firebase
現在はGoogle Cloudの一部となっているmBaaS(mobile Backend as a Service)です。認証の仕組みやホスティング、データベースやオブジェクトストレージ等、アプリ開発を行う上では必須なバックエンドを一括で提供してくれます。今回はホスティングサービス目的で使用します。

## PWA
Progressive Web Appsの略称です。めちゃくちゃざっくり言うと、**「これまでiOS用やAndroid用に別々に作っていたモバイルアプリを、Webアプリにしてしまおう！そしてWebアプリをネイティブアプリかのように見せかけてしまおう！」** という技術です。

以下は通常のWebアプリにSafariを使ってアクセスした例です。
<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/b9598bd2-371f-a3df-24e6-3fd04385c2f0.jpeg" />

iOSの場合、以下のように「ホーム画面に追加」を行うことができます。
<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/993fa7cb-3fe3-6354-27c7-972891dc1aba.jpeg" />


PWA化されているものであれば、以下のようにあたかもネイティブアプリのような表示になります。
<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/d59af03f-ff0f-f2fe-e240-df899e0c32d2.jpeg" />

PWA化のメリットはいくつかあります。

* ネイティブアプリとして公開する面倒な手続きや審査が不要
* ネイティブアプリではないため、ユーザは「インストール」の手間が不要
* 実態はWebアプリなので、クロスプラットフォームが実現しやすい！

もちろん、プッシュ通知等も実現できます。

そして・・・
**Flutterなら、デフォルトでPWA対応してくれます！！！**　神。
# Flutterアプリの作成

## 環境構築
まずはFlutterをインストールします。(https://flutter.dev/docs/get-started/install/windows)
インストール後はFlutter Webを有効にしていきます。

```bash
 flutter channel beta
 flutter upgrade
 flutter config --enable-web
```
## 新規アプリの作成
新しいアプリケーションを作成します。
うまくいけば、大量にファイルが生成されるはずです。

```bash
flutter create demoapp
```

## アプリをちょっとだけいじる
初期コードだと面白くないので、ちょっとしたものを作ります。

```dart
import 'dart:html';

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        // This is the theme of your application.
        //
        // Try running your application with "flutter run". You'll see the
        // application has a blue toolbar. Then, without quitting the app, try
        // changing the primarySwatch below to Colors.green and then invoke
        // "hot reload" (press "r" in the console where you ran "flutter run",
        // or simply save your changes to "hot reload" in a Flutter IDE).
        // Notice that the counter didn't reset back to zero; the application
        // is not restarted.
        primarySwatch: Colors.blue,
      ),
      home: DemoApp(),
    );
  }
}

//アプリ全体
class DemoApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: "Sample Items",
      theme: ThemeData(
        primarySwatch: Colors.indigo,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: ItemListPage(),
    );
  }
}

class ItemListPage extends StatefulWidget {
  @override
  _ItemListPageState createState() => _ItemListPageState();
}

class _ItemListPageState extends State<ItemListPage> {
  List<String> itemList = [];
  static final String dummyImageURL =
      "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/profile-images/1568357938";

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Item List"),
      ),
      body: ListView.builder(
        itemCount: itemList.length,
        itemBuilder: (context, index) {
          return Dismissible(
            key: ObjectKey(itemList[index]),
            direction: DismissDirection.endToStart,
            onDismissed: (direction) {
              setState(() {
                itemList.removeAt(index);
              });
            },
            background: Container(
              alignment: AlignmentDirectional.centerEnd,
              color: Colors.red,
              child: Padding(
                padding: EdgeInsets.fromLTRB(0.0, 0.0, 10.0, 0.0),
                child: Icon(
                  Icons.delete,
                  color: Colors.white,
                ),
              ),
            ),
            child: Card(
              child: ListTile(
                title: Text(itemList[index]),
                leading: Image.network(dummyImageURL),
              ),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: () async {
          final newListText = await Navigator.of(context).push(
            MaterialPageRoute(
              builder: (context) {
                return ItemAddPage();
              },
            ),
          );

          if (newListText != null) {
            setState(
              () => {
                itemList.add(newListText),
              },
            );
          }
        },
      ),
    );
  }
}

class ItemAddPage extends StatefulWidget {
  @override
  _ItemAddPageState createState() => _ItemAddPageState();
}

class _ItemAddPageState extends State<ItemAddPage> {
  String itemTitle = "";

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Add Item"),
      ),
      body: Container(
        padding: EdgeInsets.all(48),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Container(
              child: TextField(
                maxLines: 1,
                decoration: InputDecoration(
                  labelText: "Input your new Item!",
                  border: OutlineInputBorder(),
                ),
                onChanged: (String newItemTitle) {
                  setState(
                    () => {
                      itemTitle = newItemTitle,
                    },
                  );
                },
              ),
              width: double.infinity,
              margin: EdgeInsets.all(1),
            ),
            Container(
              width: double.infinity,
              margin: EdgeInsets.all(1),
              child: RaisedButton(
                color: Colors.indigo,
                onPressed: () {
                  Navigator.of(context).pop(itemTitle);
                },
                child: Text(
                  "Add!",
                  style: TextStyle(color: Colors.white),
                ),
              ),
            ),
            Container(
              width: double.infinity,
              margin: EdgeInsets.all(1),
              child: FlatButton(
                onPressed: () {
                  Navigator.of(context).pop();
                },
                child: Text(
                  "Abort",
                  style: TextStyle(color: Colors.white),
                ),
                color: Colors.redAccent,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

```

ローカル環境で動作確認してみます。

```bash
flutter run -d chrome --web-port=8080 --web-hostname=127.0.0.1
```

こんな感じのアプリになりました。
![flutter-demoapp1gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/a8b8e499-b68c-c804-1c32-a155038fbae8.gif)

出来上がったら、最後にWeb用としてビルドします。FlutterはDart言語で開発していきますが、以下コマンドを実行することでWebアプリとして動くJavaScriptのプログラムへ変換してくれます。

```bash
flutter build web
```

# Firebaseでホストする
ローカル環境のままでも良いのですが、どうせならFirebaseでホストして本番環境っぽく見せていきたいと思います。

## Firebase CLIをインストール
CLIを使うと楽にデプロイができるので、Firebase CLIをインストールします。

```bash
npm install -g firebase-tools
```

インストールしたら、ログインをします。ブラウザが立ち上がり、Googleアカウント情報の入力が求められます。

```bash
firebase login
```

なお、ここでfirebaseのコマンドが見つからないという状況が発生することがあります。
以下コマンドを叩いて、(not in PATH env variable)と表示されるようならば、表示されているフォルダへのパスが通っていないのでパスを通します。

```bash
npm -g bin

/path/to/bin
(not in PATH env variable)
```

## プロジェクトの作成
ログイン後、以下のようにコマンドを叩いて進めていきます。

```bash
firebase init

? Are you ready to proceed? (Y/n)  Y
```

ここはちょっと癖がありますが、カーソルキーで移動してスペースキーで選択です。今回はHosting機能を使いたいので、Hostingを選択します。

```
? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices. (Press <space> to select, <a> to toggle all, <i> to invert selection)
>( ) Database: Configure Firebase Realtime Database and deploy rules
 ( ) Firestore: Deploy rules and create indexes for Firestore
 ( ) Functions: Configure and deploy Cloud Functions
 (*) Hosting: Configure and deploy Firebase Hosting sites
 ( ) Storage: Deploy Cloud Storage security rules
 ( ) Emulators: Set up local emulators for Firebase features
 ( ) Remote Config: Get, deploy, and rollback configurations for Remote Config
```

プロジェクトをどうするか聞かれるので、新しいプロジェクトを作ります。

```
? Please select an option:
  Use an existing project
> Create a new project
  Add Firebase to an existing Google Cloud Platform project 
  Don't set up a default project
```

新しいプロジェクト名を入力します。

```
? Please specify a unique project id (warning: cannot be modified afterward) [6-30 characters]:
 flutter-demoapp20210122
? What would you like to call your project? (defaults to your project ID) flutter-demoapp20210122
```

公開用のファイル置き場をどこにするか聞かれるので、今回はbuild\webとします。

```
? What do you want to use as your public directory? (public) build\web
```

SPAはNoにします。

```
? Configure as a single-page app (rewrite all urls to /index.html)? (y/N)N
```

既にindex.htmlが存在する場合は上書きするかどうか聞かれます。今回はYesとします。

```
? File build\web/index.html already exists. Overwrite? Yes
```

## デプロイする
以下コマンドでデプロイしていきます。Deploy Complete!と表示されれば成功です。

```bash
firebase deploy

~略~
+  Deploy complete!
Hosting URL: https://flutter-demoapp20210122.web.app
```

Hosting URLにアクセスしてみます。
よしよしよし！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/83c29c6e-80ca-2f5f-dc59-f113d2dded86.png)

ちなみに、Chromeの場合はPWA化されているWebアプリの場合、設定項目から「インストール」を選ぶことができます。Qiitaさんもこうなっていますね。

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/c5666c29-95c2-1608-3207-c3af2e817d20.png width=400 />

そういえばここで、延々とデフォルトページみたいなものが表示されて、全くデプロイが反映されないことがありました。この場合は再度```flutter build web```を試してみると良いと思います。

では、私物のiPhoneでアクセスしてみます。

Safariで開くとこんな感じで...
<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/6552829a-9aa1-ab92-89d2-794a6e72974a.jpeg" />

ホーム画面にもちゃんと追加できますね。
<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/2320c399-1793-931d-3ebf-9f062b0f39ba.jpeg" />

アプリとして開いてみるとこんな感じです。
GIFのせいでカクついて見えますが、実際はすごくサクサクです。
![S__60915774.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/590332b8-30b1-adfc-0221-b208a0e7de50.gif)

PWAはキャッシュ機能（ServiceWorker）のおかげで、オフライン環境でも正常に動かすように作り込めます。もちろんリアルタイムに外部APIを叩くようなものは難しいですが...。
ちなみに今回のアプリも機内モードで試してみましたが、画像部分以外は正常に動作しました（画像は別サーバに置いてあるので...）。

# まとめ
* モバイル向けだったFlutterがWebアプリの開発にも使える！
* PWAはメリットがたくさん！Flutterならデフォルトで対応してくれるよ
* ホスティングはFirebaseが使いやすかった