10
8

More than 5 years have passed since last update.

Flutter web でアプリを試作してみた

Last updated at Posted at 2019-06-12

備忘録として色々書いておきます。

作ったもの

flutter_web でビルドして、Firebase でデプロイしています。

ごいたシミュレータ で実行できる。githubリポジトリはこちら

ランダムにごいたの初期局面を生成し、それをフィルターで絞り込んだ局面数を表示するプログラムです。
「+条件」を押すと条件を追加でき、条件リストを管理できます。

ごいたシミュレータ のスクリーンショット

現在、集合演算は AND のみで、Simulate を押すと「条件1つめ、かつ、条件2つめ、...」に合致する局面数と、
実際の局面を下部に表示するようになっています。
Safari (iOS), Chrome (Android), Firefox (Win10) など各種環境で動きます。

  • Android 実機と、iOS 実機も試してみたい(Future Works)。
  • 集合演算を増やしたい(Future Works)

開発は、まず Flutter で Android のエミュレータ向けに書いて、flutter_web のプロジェクトにコードをコピーして、
最低限の修正とビルドで動いています(すごい)。VSCode でほとんど完結しました。

Material Icons も使えるし、とにかく快適。400行ほどで動くものがGUI込みでかけた(100行位は整形で増えてる)のでコンパクト。

それでいて、ロジックとGUIがかなり分離しやすかったというのが(だからこそ?)感想です。あと、静的型チェックはやっぱり便利。

環境構築のはなし

気が向いたら書く。

flutter がインストールできれば、flutter doctor の指摘を埋めていけばOK。

flutter_web で Dart SDK も必要だったり、protobuf の更新が必要だったりは一時的なものだと思う。
flutter_web 自体がまだ experimental なので。

pubspec.yaml
dependencies:
  flutter_web: any
  flutter_web_ui: any
  protobuf: ^0.13.12

Flutter と flutter_web

フォルダを分けて、flutter_web のプロジェクトを別途作った後、
lib/ のコードをコピーして、migration guide のあれやこれをやる。

今回は、

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

main.dart
import 'package:flutter_web/material.dart';

に書き換えただけで動いている。しゅごい。

ごいたの概要

ごいたは、4人が2人ずつペアになり、ペアで対戦するボードゲームです。最初に32駒を8枚ずつ配りきります。

今回は、各種条件を満たす初期局面が生じる確率を簡単に知れるプログラムを作りました。

プログラムの理解に必要なのは、以下の知識です

  • 初期局面を作るのは、32駒の配列をシャッフルすればいいだけ。
  • 本来は8枚ずつ非公開で配るが、絞り込むためには全てオープンに扱っていい
  • 枚数の異なる8種の駒があり、それが誰の手元に何枚あるかでフィルタリングしたい
    • 自分の味方を相方と呼ぶ。
    • 円形に座って、反時計回りに、自分、下家(敵1)、相方(味方)、上家(敵2)、とよぶ。

ロジックのはなし

goita.dart がロジック部分です。

Simulate でやっていること

main.dartsimulate 関数が処理の実体で、

  1. ランダムな局面を試行回数分生成
  2. それぞれの局面がフィルタを pass するかどうか test する

単純なコードになっています。強いていうなら、test する関数を作る部分がキモです。

main.dart
List<Game> simulate(num trials, List<Filter> filters) {
  var iter = Iterable.generate(trials, (i) => Game());  // 1. 局面生成
  filters.forEach((filter) => iter = iter.where(filter.testFunc));  // 2. フィルタを順に test して絞り込む
  return iter.toList();  // 扱いやすさのために、リストに直して返す
}

Iterable.generate で一気に局面を生成しているのは無駄の極みで、
局面を生成しながら、合成されたフィルタを test するように改善する余地があります(Future Works)。

なお、元コードには、compute を使って並列化しようとした痕跡があり、
Future<List<Game>> を返す async な関数ですが、ここでは単純なコードに直しています。
compute でメインスレッドと別スレッドで動かせばUIが固まらず、キャンセルなどもつけられるはずです。

ブラウザでは Web Worker を使わないと駄目なので、
それも Future Works です。

Game クラス

今回 Game クラスは、シャッフルされた局面をコンストラクタで作るだけの入れ物です。
(元コードだと縦長になるので圧縮表記しています)。

goita.dart
enum Koma { SHI, GON, UMA, GIN, KIN, HI, KAKU, OU } // 駒の定義
// ...(中略)...
final initYama = List.filled(10, Koma.SHI) +
    List.filled(4, Koma.GON) + List.filled(4, Koma.UMA) + List.filled(4, Koma.GIN) + List.filled(4, Koma.KIN) +
    List.filled(2, Koma.HI) + List.filled(2, Koma.KAKU) + List.filled(2, Koma.OU);  // 初期局面を生成
//... (中略)...
class Game {
  List<Koma> yama;
  Game() {
    yama = List.from(initYama);  // List.from は shallow copy だが、Koma は数値なので deep copy になる
    yama.shuffle();              // shuffle は destructive で自分自身を変更する
  }
  //... (後略)...
}

Filter クラス

Filter クラスは指定された条件に、Game の局面が合致するかどうかを test する関数を構築するのがメインの仕事です。
generateTestFunctionTestFunction の形のテスト関数を返します。Game -> bool ですね。

goita.dart
typedef bool TestFunction(Game game);
class Filter {
// ...(中略)...
  TestFunction generateTestFunction() {
    // ... 後述 ...
  }

フィルタは

述語の説明 プログラムでの型名
Game -> List 「誰の」手元の駒に CondTargetFunc
List -> num 「どの駒の枚数」が List.fold で計算
num -> num -> bool 「n」と比べて「少ない/以下/同数/以上/より多い」 CondTypeFunc

という3つの述語で構成できるようにしました。それぞれ、以下のように宣言しておきます(名前はリファクタしたい)

goita.dart
// typedef List CondTargetFunc(Game game); と特殊化しわすれて、List<Koma> と List<dynamic> が混在してハマった
typedef List<Koma> CondTargetFunc(Game game);
List<Koma> getP1(Game game) { return game.yama.sublist( 0,  8); }  // 自分の駒
List<Koma> getP2(Game game) { return game.yama.sublist( 8, 16); }  // 下家の駒
List<Koma> getP3(Game game) { return game.yama.sublist(16, 24); }  // 相方の駒
List<Koma> getP4(Game game) { return game.yama.sublist(24, 32); }  // 上家の駒
List<Koma> getPAIRFRIEND(Game game) { return game.yama.sublist(0,  8) + game.yama.sublist(16, 24); }  // 自分+相方の駒
List<Koma> getPAIRENEMY(Game game)  { return game.yama.sublist(8, 16) + game.yama.sublist(24, 32); }  // 敵ペアの駒
List<Koma> getWHOLE(Game game) { return game.yama; }  // 全体の駒、論理演算で欲しくなることがあるかもと用意
// ...(中略)...
typedef bool CondTypeFunc(int n); // n と比較するクロージャを返す関数
CondTypeFunc genLess(_n) { return (n) => n < _n; }
CondTypeFunc genLessThan(_n) { return (n) => n <= _n; }
CondTypeFunc genEqual(_n) { return (n) => n == _n; }
CondTypeFunc genMoreThan(_n) { return (n) => n >= _n; }
CondTypeFunc genMore(_n) { return (n) => n > _n; }

Filter.generateTestFunction で合成したクロージャを作ります。

goita.dart
typedef bool TestFunction(Game game);
class Filter {
// ...(中略)...
  TestFunction generateTestFunction() {
    final getKomaListFunc = CondTargetFuncs[_target];       // 数える対象の駒を取り出す関数を拘束
    final compareFunc = CondTypeFuncGenerators[_type](_n);  // 数を比較する関数を拘束
    return (Game game) {
      final komaList = getKomaListFunc(game);                                           // 条件に指定された駒を取り出し
      final int n = komaList.fold(0, (prev, koma) => prev + ((koma == _koma) ? 1 : 0)); // 指定された駒を数え
      return compareFunc(n);                                                            // 条件の枚数と比較する
    };  // test 関数(クロージャ)を構築して返す
  }

残念ながら、dart では eval が使えないため、クロージャで表現しています。
性能上、駒を数える部分も拘束しておいた方が良いのかどうかは試していません。
Filter.count が無いのはちょっと面倒くさかったです。

eval が使えれば、関数を3つ呼ぶ部分をインライン展開できるので、高速化できると思います。
Web Worker で List<Filter> -> List<Game> を作れば、色々と都合が良いと思います(Future Works)。

設定の保存部分

設定の保存部分はちょっと苦労したので、別個の記事に書こうかなぁと思います。

Storage クラスに抽象化しているのですが、flutter 版 では shared_preferences/shared_preferencesflutter_web 版 では localStorage を使うように環境別のコードになっています。労せず環境別のコードを用意出来るのも良いと思いました。

GUIのはなし

  • メモ

    • なれるまでレイアウトの計算が出来ない、という旨のエラーに遭遇した。
    • タイマーで描画ループが動いているっぽい。
    • BloC パターンはまだ試せていない

Column, Row をベースに組み立てればOK。レスポンシブにしたくない部分は SizeBox で固定値の指定も出来る。目一杯広げたいなら Expanded など。
画面サイズもfinal Size size = MediaQuery.of(context).size; で取れる。

幸い、Hot Reload で試行錯誤しやすいが、ブラウザのインスペクタなどは使えないと思った方がよかった。

画面設計

3つの画面があります。メインの画面、フィルタを編集する画面、設定を変更する画面です。

今回は名前ベースで作ったので、たとえば、https://sim-goita.web.app/#/config にアクセスすると、設定画面をいきなり開くことが出来ます。
routes.dart に定数をくくりだしています。

routes.dart
class Routes {
  static const main = "/";
  static const edit_filter = "/edit_filter";
  static const config = "/config";
}

mainの中身は、Getting Started のと大差ないです。設定の読み込みが最初に挟まってるだけです。

main.dart
void main() async {
  await Storage.init();  // 設定の読み出し
  runApp(MyApp());       // App を実行
}
// ...(中略)...
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(  // Material デザインのアプリ
      title: "ごいたシミュレータ",
      initialRoute: Routes.main,
      routes: {  // 名前に対応した画面を登録する
        Routes.main: (context) => MainScreen(),
        Routes.edit_filter: (context) => FilterEditor(),
        Routes.config: (context) => ConfigScreen(),
      },
    );
  }
}

画面の作り方

シンプルな FilterEditor で説明します。filter_editor.dart がコード全体です。
状態を持つ画面を作りたい場合、状態を表現する State を継承したクラスと、その入れ物 StatefulWidget を継承したクラスを作ります。
ほとんどのコード、というか、定義は State 側に書きます。

filter_editor.dart
class FilterEditor extends StatefulWidget {
  @override
  FilterEditorState createState() => FilterEditorState();
}

State 側では、保持したい変数を定義して、コンストラクタで初期値を与えます。
初期値として、引数を渡したい場合、名前でルーティングする場合ここではない場所に書くのが楽です。もちろん StatefulWidget のコンストラクタでもわたせます

filter_editor.dart
class FilterEditorState extends State<FilterEditor> {
  Koma koma;
  int n;
  CondType condType;
  CondTarget condTarget;

  FilterEditorState() {
    koma = Koma.SHI;
    condType = CondType.EQUAL;
    condTarget = CondTarget.P1;
    n = 1;
  }

画面の定義は State.build を override します。
setState で状態を変更すると、良い塩梅で、この build が走るので、新しい画面に更新されます。

画面は、appBarbody で構成されており、主に body の中に色々な Widget を入れ後にしていく作りです。
入れ子が深くなってきたら children に渡すリスト単位でくくっていくと良さそうです。

あたりに情報があります。

上下に並べたければ Column、左右に並べたければ Row、グリッド用に GridView など様々な Widget があるので、
その組合せを試行錯誤することになると思います。

VSCodeの補完やメソッドの引数表示支援がわりと便利だった。もう一声、キーワード引数の情報などが欲しいことも。

今回作った画面と、その構成の概略です。

Screenshot_2019-06-12 ごいたシミュレータ(2).png

filter_editor.png

コードもほぼそのままです。DropdownButton を作る部分はヘルパ関数を用意しています。

filter_editor.dart
  @override
  Widget build(BuildContext context) {
    var ns = List.generate(maxN()+1, (i) => i); /* 駒毎に存在しうる上限の数のリストを生成 */
    return Scaffold(
        appBar: AppBar(
          title: Text('フィルタの編集'),
        ),
        body: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          mainAxisSize: MainAxisSize.min,
          children: [
            Card(
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.center,
                mainAxisAlignment: MainAxisAlignment.center,
                children:[
                  /* ここで無名関数を作らないと不自然なエラーが出ることがある */
                  _buildDropdownButton(
                    condTarget,
                    (newT) { setState(() {condTarget = newT;}); },
                    CondTargets, CondTargetToName),
                  Text(" が "),
                  _buildDropdownButton(
                    koma, /* 駒が切り替わったときに、n が大きすぎたりしないように調整している */
                    (newK) { setState(() {koma = newK; n = min(n ?? 0, maxN());}); },
                    Komas, KomaToName),
                  Text(" を "),
                  _buildDropdownButton(
                    n,
                    (newN) { setState(() {n = newN;}); },
                    ns, NstoText),
                  Text(" 枚 "),
                  _buildDropdownButton(
                    condType,
                    (newT) { setState(() {condType = newT;}); },
                    CondTypes,
                    CondTypeToName),
                  Text(" 所持"),
                ])
            )
            ,
            FlatButton(
              onPressed: () { Navigator.pop(context, Filter(koma, n, condType, condTarget)); },
              child: Text("Apply",),
            ),
        ]));
  }
}
filter_editor.dart
  _buildDropdownButton<_T>(value, onChanged, list, nameDict) {
    return DropdownButton<_T>(
      value: value,
      onChanged: onChanged,
      items: list.map<DropdownMenuItem<_T>>((_T value) {
        return DropdownMenuItem<_T>(
          value: value,
          child: Text(nameDict[value]),
        );
      }).toList(),
    );
}

DropdownButton の内容が切り替わる辺りは、Flutter の DropdownButton の項目を、他の状態に応じて変更する として別記事に書いた。

画面間での値の受け渡し

push で呼び出し、pop で値を返せるような仕組みがある。asyncawait で書きやすい。

呼び出し部分

main.dart
  appendFilter(index) async {
    /* 画面遷移して、その結果を受け取る */
    final result = await Navigator.pushNamed(context, Routes.edit_filter);
    /* キャンセルされた場合は、フィルタを追加しない */
    if (result == null) {
      return;
    }
    /* 結果を Filter に cast し直す */
    Filter filter = result as Filter;
    /* setState 内で状態を変更。後は build で _filters から ListView が出来上がる */
    setState(() {
      if (index < 0 || index >= _filters.length) {
        _filters.add(filter);
      } else {
        _filters.insert(index, filter);
      }
    });
}

値を返している部分

filter_editor.dart
            FlatButton(
              onPressed: () { Navigator.pop(context, Filter(koma, n, condType, condTarget)); },
              child: Text("Apply",),
            ),

pushNamed には、aurgments: という省略可能な引数があって、それで遷移先の画面に引数を渡せる。

フィルタの編集機能を付けたければ、この引数を利用すれば可能(Future Works)だが、特に必要ないので省略している。

その他細々としたこと

ボタンを Disabled にする

onPressednull を渡すと Disabled になる。
参考: How do I disable a Button in Flutter?

main.dart
                    onPressed: shouldPrevDisabled()
                        ? null
                        : () { setState(() { });

GridView の高さを調整する

SizedBox に入れるだけでは駄目だった。横8列、最大高さ300で計算させるようにした。
参考: FlutterでGridViewItemの高さを変える

main.dart
    final Size size = MediaQuery.of(context).size;
    final gridHeight = 300.0;
    final itemWidth = (size.width - 60.0) / 8.0;
    final itemHeight = gridHeight / 4.0;
    final gridAspectRatio = (itemWidth / itemHeight);
    // ...(中略)...
              SizedBox(
                  width: size.width - 60, height: gridHeight,
                  child: GridView.count(
                      shrinkWrap: true, primary: false, crossAxisSpacing: 10.0, 
                      crossAxisCount: 8, // 横の並びを8駒にする
                      childAspectRatio: gridAspectRatio, // ...(以下略)...

IconButton の padding を最小化したい

MaterialButton を使う。

スワイプで ListView のアイテムを削除

Material Icons を使いたい

Flutter Webでマテリアルアイコンが表示されないとき

assets の配置でパス合わせ

manifest がなくて怒られた/おいても404

暗黙のキャストや dynamic

profile には flutter で実機がいりそう

もちろん DateTime.difference で経過時間の手動計測はできる。
結果は Duration で返ってくる。

Firebase を使いこなせば、ユーザー環境の処理時間といった情報の収集などもできる様子。それはFirebaseの機能でflutterの機能ではなく、まだ触ってないので割愛。

10
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
8