備忘録として色々書いておきます。
作ったもの
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がかなり分離しやすかったというのが(だからこそ?)感想です。あと、静的型チェックはやっぱり便利。
環境構築のはなし
気が向いたら書く。
- Windows install - Flutter 公式
- Get the Dart SDK
- 絶対にくじけないFlutter開発環境構築(VS Code)
- flutter_web
- Flutter for Webが発表されたので、早速動かしてみた!!
flutter がインストールできれば、flutter doctor の指摘を埋めていけばOK。
flutter_web
で Dart SDK も必要だったり、protobuf
の更新が必要だったりは一時的なものだと思う。
flutter_web
自体がまだ experimental なので。
dependencies:
flutter_web: any
flutter_web_ui: any
protobuf: ^0.13.12
Flutter と flutter_web
フォルダを分けて、flutter_web
のプロジェクトを別途作った後、
lib/
のコードをコピーして、migration guide のあれやこれをやる。
今回は、
import 'package:flutter/material.dart';
を
import 'package:flutter_web/material.dart';
に書き換えただけで動いている。しゅごい。
ごいたの概要
ごいたは、4人が2人ずつペアになり、ペアで対戦するボードゲームです。最初に32駒を8枚ずつ配りきります。
今回は、各種条件を満たす初期局面が生じる確率を簡単に知れるプログラムを作りました。
プログラムの理解に必要なのは、以下の知識です
- 初期局面を作るのは、32駒の配列をシャッフルすればいいだけ。
- 本来は8枚ずつ非公開で配るが、絞り込むためには全てオープンに扱っていい
- 枚数の異なる8種の駒があり、それが誰の手元に何枚あるかでフィルタリングしたい
- 自分の味方を相方と呼ぶ。
- 円形に座って、反時計回りに、自分、下家(敵1)、相方(味方)、上家(敵2)、とよぶ。
ロジックのはなし
goita.dart がロジック部分です。
Simulate でやっていること
main.dart の simulate
関数が処理の実体で、
- ランダムな局面を試行回数分生成
- それぞれの局面がフィルタを pass するかどうか test する
単純なコードになっています。強いていうなら、test する関数を作る部分がキモです。
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 クラスは、シャッフルされた局面をコンストラクタで作るだけの入れ物です。
(元コードだと縦長になるので圧縮表記しています)。
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 する関数を構築するのがメインの仕事です。
generateTestFunction
が TestFunction
の形のテスト関数を返します。Game -> bool
ですね。
typedef bool TestFunction(Game game);
class Filter {
// ...(中略)...
TestFunction generateTestFunction() {
// ... 後述 ...
}
フィルタは
型 | 述語の説明 | プログラムでの型名 |
---|---|---|
Game -> List | 「誰の」手元の駒に | CondTargetFunc |
List -> num | 「どの駒の枚数」が | List.fold で計算 |
num -> num -> bool | 「n」と比べて「少ない/以下/同数/以上/より多い」 | CondTypeFunc |
という3つの述語で構成できるようにしました。それぞれ、以下のように宣言しておきます(名前はリファクタしたい)
// 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
で合成したクロージャを作ります。
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_preferences
、flutter_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 に定数をくくりだしています。
class Routes {
static const main = "/";
static const edit_filter = "/edit_filter";
static const config = "/config";
}
main
の中身は、Getting Started のと大差ないです。設定の読み込みが最初に挟まってるだけです。
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
側に書きます。
class FilterEditor extends StatefulWidget {
@override
FilterEditorState createState() => FilterEditorState();
}
State
側では、保持したい変数を定義して、コンストラクタで初期値を与えます。
初期値として、引数を渡したい場合、名前でルーティングする場合ここではない場所に書くのが楽です。もちろん StatefulWidget のコンストラクタでもわたせます。
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
が走るので、新しい画面に更新されます。
画面は、appBar
と body
で構成されており、主に body
の中に色々な Widget
を入れ後にしていく作りです。
入れ子が深くなってきたら children
に渡すリスト単位でくくっていくと良さそうです。
あたりに情報があります。
上下に並べたければ Column
、左右に並べたければ Row
、グリッド用に GridView
など様々な Widget
があるので、
その組合せを試行錯誤することになると思います。
VSCodeの補完やメソッドの引数表示支援がわりと便利だった。もう一声、キーワード引数の情報などが欲しいことも。
今回作った画面と、その構成の概略です。
コードもほぼそのままです。DropdownButton
を作る部分はヘルパ関数を用意しています。
@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",),
),
]));
}
}
_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
で値を返せるような仕組みがある。async
と await
で書きやすい。
呼び出し部分
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);
}
});
}
値を返している部分
FlatButton(
onPressed: () { Navigator.pop(context, Filter(koma, n, condType, condTarget)); },
child: Text("Apply",),
),
pushNamed
には、aurgments:
という省略可能な引数があって、それで遷移先の画面に引数を渡せる。
フィルタの編集機能を付けたければ、この引数を利用すれば可能(Future Works)だが、特に必要ないので省略している。
その他細々としたこと
ボタンを Disabled にする
onPressed
に null
を渡すと Disabled になる。
参考: How do I disable a Button in Flutter?
onPressed: shouldPrevDisabled()
? null
: () { setState(() { });
GridView の高さを調整する
SizedBox
に入れるだけでは駄目だった。横8列、最大高さ300で計算させるようにした。
参考: FlutterでGridViewItemの高さを変える
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の機能ではなく、まだ触ってないので割愛。