UUUO Tech Blog

水産流通のDXや業務効率化を実現するアプリケーション「UUUO」「atohama」を開発・展開している株式会社ウーオのテックブログです。プロダクトにまつわる様々な情報発信を行います。

既存のFlutterアプリからFlutter Webを立ち上げて運用してみた

こんにちはウーオでエンジニア兼PdMをしている三京(t3qyo)です。
UUUOではモバイルアプリの開発言語にFlutterを採用しています。 最近そのモバイルアプリを既存のリソースを活かしながら同じリポジトリを使って(モノレポで)Webにデプロイしたので、デプロイの際の注意点や運用の感想を書きます。

デプロイしたのは以下の出品者用の管理画面です。

デプロイしたWebページ
デプロイしたWebページ

Firebase Hostingにデプロイする

1.Flutter Web Buildを開始する

公式ページ をご参照ください。

10秒ほどでlocalhostでの立ち上げが完了します。

Firebase Hostingへのデプロイは こちらの記事が参考になります。

2. Firebase CLIをインストール

npm install -g firebase-tools

3. firebase init hosting する

以下の設定でfirebaseの初期設定と、GitHub Actionsでのデプロイの初期設定ができます。

---
=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add, 
but for now we'll just set up a default project.

? Please select an option: Use an existing project
? Select a default Firebase project for this directory: uuuo (uuuo)
i  Using project uuuo (uuuo)

=== Hosting Setup

? Which Firebase features do you want to set up for this directory? Press Space to select features, then Enter to confirm your choices. Hosting: Configure files for Firebase Hosting
 and (optionally) set up GitHub Action deploys

=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add, 
but for now we'll just set up a default project.

i  Using project uuuo (uuuo)

=== Hosting Setup

Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.

### 重要、どのディレクトリを公開するか
? What do you want to use as your public directory? build/web
? Configure as a single-page app (rewrite all urls to /index.html)? Yes
? Set up automatic builds and deploys with GitHub? Yes
? File build/web/index.html already exists. Overwrite? Yes
✔  Wrote build/web/index.html

i  Detected a .git folder at /Users/t.sankyo/works/hobby/sample_web
i  Authorizing with GitHub to upload your service account to a GitHub repository's secrets store.

Waiting for authentication...

✔  Success! Logged into GitHub as t-sankyo

? For which GitHub repository would you like to set up a GitHub workflow? (format: user/repository) t-sankyo/flutter_web_sample

✔  Created service account github-action-617732841 with Firebase Hosting admin permissions.
✔  Uploaded service account JSON to GitHub as secret FIREBASE_SERVICE_ACCOUNT_UUUO.
i  You can manage your secrets at https://github.com/t-sankyo/flutter_web_sample/settings/secrets.

? Set up the workflow to run a build script before every deploy? Yes
? What script should be run before every deploy? flutter build web

✔  Created workflow file /Users/t.sankyo/works/hobby/sample_web/.github/workflows/firebase-hosting-pull-request.yml
? Set up automatic deployment to your site's live channel when a PR is merged? No

i  Action required: Visit this URL to revoke authorization for the Firebase CLI GitHub OAuth App:
https://github.com/settings/connections/applications/89cf50f02ac6aaed3484
i  Action required: Push any new workflow file(s) to your repo

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...

✔  Firebase initialization complete!


重要

What do you want to use as your public directory?
(どのディレクトリを公開しますか?)


Flutterの場合、デフォルトで build/webに生成されるので、build/web を指定してください

以下のファイルが自動生成されます。

変更をpushするとGitHubのActionsタブでActionが作成されています。

また、デプロイに必要なGCPのサービスアカウントもSecretsに設定されています。(楽ですね!)

4. GitHub ActionsのEnvironmentを設定する

Firebase Hostingには Preview Channelという機能があり、ランダムなURLでデフォルト7日間だけ有効な環境を作ることができます。(便利)

UUUOではPreview Channelを含め、3環境運用しています。

環境 用途
Preview環境 Pull Requestレビュー用
Staging環境 QA用
Production環境 本番リリース用

それぞれの環境に対して、GitHubの SettingsからEnvironmentを設定しました。

key secret/variable
FIREBASE_SERVICE_ACCOUNT secret 該当環境へのデプロイ権限を持つサービスアカウントキーを指定します。複数環境必要ない場合は、自動生成された FIREBASE_SERVICE_ACCOUNT_... を使うで良いと思います。
GCPコンソールから取得できます。
CHANNEL_ID variable Staging, Productioの場合は live を、Previewチャンネルの場合は pr-${{ github.ref_name }} を設定します。
PROJECT_ID variable デプロイ先のプロジェクトIDを指定します

5. GitHub ActionsのymlでEnvironmentを使うよう設定/ 手動デプロイに変更

4. で作成したVariablesとSecretsを使うように設定していきます。
また、firebase init で生成された firebase-hosting-pull-request.yml ではPRを立ち上げた時に自動でデプロイされてしまう設定となっています。
同じリポジトリ内で管理しているモバイルアプリ側の変更の際にWebにデプロイされたくはないので、デプロイを手動にするなどの対応をしていきます。

'on': pull_request の部分をEnvironmentで分岐できるよう、以下のように設定します。

on:
  workflow_dispatch:
    inputs:
      environment:
        type: environment
        default: 'Preview_web'
        description: 'Either Preview_web or Staging_web or Production_web'
        required: true

また、jobsを以下のように設定します。

jobs:
  build_and_preview:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Install flutter # Flutterのインストール
        uses: subosito/flutter-action@v2
        with:
          channel: "stable"
          cache: true
      - name: Download Flutter packages # Flutterのパッケージダウンロード
        run: flutter pub get
      - name: flutter build web # ビルド
        run: flutter build web -t lib/main.dart --release --web-renderer html
      - uses: FirebaseExtended/action-hosting-deploy@v0 # デプロイ
        with:
          repoToken: "${{ secrets.GITHUB_TOKEN }}"
          firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_UUUO_TOOLS }}"
          projectId: ${{ vars.PROJECT_ID }}
          channelId: ${{ vars.CHANNEL_ID }}

Environmentsのsecretsとvariablesを使うことでかなりスッキリとかけたのではないでしょうか。

また、デフォルトのビルドでは日本語のフォントが正しく表示されなかったのでbuild時のオプションに、--web-renderer htmlを設定しました。

デフォルトではPCブラウザでは canvaskit、モバイルブラウザでは html のrendererが設定されるようです。 https://docs.flutter.dev/development/platform-integration/web/renderers

上記の対応を行うと、GitHub Actionsからリリースすることができるようになります。

ミニマムのソースをこちら においておりますので、よければご確認ください。

Flutter Webを運用してみての気づきなど

UUUOでFlutter on the Webを運用してみての気づきを書きます。

1. ブラウザの検索が効かない 😢

これは事前に知っておきたかったです。 2023年3月時点、Ctrl + F での検索は完全に対応されていません。(issue)
具体的には、htmlでレンダリングすると、画面の表示されている範囲内のDOMの要素は検索対象となるようですが、範囲外の要素を検索したいときに、検索対象になりません。
対応するにはクライアント側でキー操作をオーバーライドするなどのハックが必要になるようです。

2. DataTableを大量にレンダリングする処理は厳しかった。😢

下にスクロールでページングしていくような処理を DataTable を用いて実装しようとしましたが、
大量のWidgetレンダリング処理が発生し、画面がカクカクするようになってしまいました。
ページングする際は、下スクロールではなく、以下のようなスタンダードなページングUIを作ってタップに画面が切り替わるようにしたほうが良さそうです。

3. 全角入力されることを考慮する 👆

Webでは TextFormFieldの keyboardType: TextInputType.number, を指定しても、 全角数字の入力が可能であるため、 以下の処理でアンフォーカス時に半角変換処理を入れました。

/// 全角数字を半角数字に変換する
String alphanumericToHalfLength() {
  // 文字コードから65248を引くと全角を半角に変換できる
  const fullLengthCode = 65248;
  final regex = RegExp(r'^[0-9]+$');
  final string = runes.map<String>((rune) {
    final char = String.fromCharCode(rune);
    return regex.hasMatch(char)
        ? String.fromCharCode(rune - fullLengthCode)
        : char;
  });
  return string.join();
}

アンフォーカス時の半角変換

4. URL指定での直接遷移を考慮する必要がある 👆

モバイルアプリでは、URLを指定して直接アクセスすることはできませんが、 Webではリソースに対してIDを指定してアクセスをすることがあります。

モバイルでは前の画面から値を受け取ることができましたが、Webではそれができません。 今回は GoRouter を使うことでIDの取得部分を対応しました。

以下のイメージです

  static final router = GoRouter(
    routes: [
      GoRoute(
        name: WebPurchaseDetailPage.routeName,
        path: '${WebPurchaseDetailPage.routeName}/:id',
        pageBuilder: (context, state) {
          // IDを取得して画面に渡す
          final id = int.parse(state.params['id']) ?? 0;
          return pageBuilder(
            context,
            WebPurchaseDetailPage(
              id: id,
            ),
            state,
          );
        },
      ),
...

5. モバイルアプリとWebのページは基本的に分離するのが良い 👆

以前別の対応でモノレポでのWebとアプリの対応を行った際、随所に kIsWeb の分岐を書く必要があり、可読性と保守性が悪くなっていました。
その反省を活かし、今回はWebとアプリでPage, Controllerを分離し、ドメインロジックのみ共有する方針をとりました。
まだモバイルアプリでの方の開発の方が多いため開発時にWebのことを気にしながら開発する必要がなくなり、割と良かったです。

以下のイメージで分けています。

├── domain ### 共通で使うドメインモデル
│   ├── entity
│   │   ├── announcement.dart
│   │   ├── announcement.freezed.dart
│   │   ├── announcement.g.dart
├── ui  ## モバイルの画面、controller, state
│   ├── announcement
│   │   ├── announcement_controller.dart
│   │   ├── announcement_page.dart
│   │   ├── announcement_state.dart
│   │   └── announcement_state.freezed.dart
└── web ## webの画面、controller, state
    ├── announcement
    │   ├── web_announcement_controller.dart
    │   ├── web_announcement_page.dart
    │   ├── web_announcement_state.dart
    │   └── web_announcement_state.freezed.dart

6. (そのうちやりたい)モバイルアプリのコミットか、Webのコミットかをラベル、コミットメッセージなどでわかるようにしたい。👆

モバイルアプリのリリースタイミングとWebのリリースタイミングが異なるので、Pull RequestのタイトルやコミットメッセージなどでこれはWebのコミットなのかモバイルアプリのコミットなのか、
それとも両方かをわかりやすくしたほうが良いと思われます。

7. キャッシュの期限の設定 👆

firebase deployでデプロイした場合のデフォルトのキャッシュ設定は割と長く、ユーザーが古い画面を見続けてしまうことがあったため、以下の header を設定しています。

{
  "hosting": {
    ...
    "rewrites": [...],
/// 以下を追加
    "headers": [
      {
        "source": "**/*.@(eot|otf|ttf|ttc|woff|font.css)",
        "headers": [
          {
            "key": "Access-Control-Allow-Origin",
            "value": "*"
          },
          {
            "key": "Cache-Control",
            "value": "public, max-age=7200"
          }
        ]
      },
      {
        "source": "**/*.@(jpg|jpeg|gif|png)",
        "headers": [
          {
            "key": "Access-Control-Allow-Origin",
            "value": "*"
          },
          {
            "key": "Cache-Control",
            "value": "public, max-age=7200"
          }
        ]
      },
      {
        "source": "**/*.@(js|css)",
        "headers": [
          {
            "key": "Access-Control-Allow-Origin",
            "value": "*"
          },
          {
            "key": "Cache-Control",
            "value": "public, max-age=7200"
          }
        ]
      }
    ]
  }
}

8. (今の所)各ブラウザ別の特別な対応は必要ない 😄

一昔前はIE対応などが必要なことが多かったように思いますが、最近はそんなことがないのでしょうか。

9. パフォーマンス(そこまで?)悪くない? 😄

当初はレンダリングの問題などで、パフォーマンス、操作性を危惧していましたが、FirebaseのHostingで自動的にキャッシュも効いているということもあり、
ある程度問題なさそうということがわかりました!
ただ、ListViewを多数レンダリングするなどすると著しくパフォーマンスが下がり、ページ描画、スクロールにに時間がかかるということがありました。

10. Railshamlを使って書くよりは画面構成、ステート管理がだいぶ楽 😄

Flutterの恩恵によるところが大きいですが、 モバイル開発で慣れているステート管理方法や、Widget構築でWeb画面を作成できるのは、
モバイル開発者の多いチームにとっては技術の再習得も必要なく、得意分野を活かす形で対応できて良かったです。

同じように管理画面をRailshamlを使って書いているのですが、それと比べると動的なステート管理もしやすくかなり便利でした。


以上です。
これからもFlutter Webの開発をやっていくので、また何かTipsがあれば共有します!

では!



ウーオでは水産流通を革新するため、プロダクトを通じてあらゆるアプローチをしています。ウーオの事業やプロダクト開発にご興味がある方は、以下をぜひご覧ください 👇

uuuo.co.jp

open.talentio.com

各ポジションの募集要項はこちら

ソフトウェアエンジニア(Mobile Application/Flutter) / 株式会社ウーオ

ソフトウェアエンジニア(Frontend/Flutter on the Web) / 株式会社ウーオ

ソフトウェアエンジニア(Backend/Ruby on Rails) / 株式会社ウーオ