はじめに
- 本記事は、Flutter初心者がproviderクラスを使ってコード書いてみた、という記事ですので、コード等の細かいミスやお作法の違いはあるかもしれませんが、ご了承ください。
- 逆に、〇ソコードが掲載してあり、それがちょっとましになる過程は載せてありますので、今あるものにProviderパターンを適用したいという人に役に立てば幸いです。
- 参考ページ、動画もいくつか載せてありますので、まずそちらを見ていただいた方がいいかもしれません。むしろそっちだけでもわかるかもしれません笑
最終的にできたアプリ
下記のようなアプリができています。矢印を押すと2Dマップ上を勇者(黄色アイコン)が魔王(赤アイコン)を求めて動き回り、到達して倒す、というものです(大げさ)。
ちなみにこれはProviderのありなしに関わらず同様のアプリはできますが、Providerなしのほうのコードではここまでは到達できていません。
Providerパターンに行き着いた背景
2DマップでRPG風の簡単なアプリを作成したくてFlutterのゲームエンジンを探っていたのですが、どうも、帯に短したすきに長し、であまりいいものがなく...。一番有名?なflameというパッケージはどうもFlutter2.0以降には対応していないようで、公式からExampleをダウンロードしても動かないという始末。
まあ、元々複雑なゲームを作ろうとしているわけではないので、勉強がてら自分で作ってしまおうと考えたのが発端でした。
Providerパターンとは
調べていると、Flutterの設計において、設計パターンでどうやら最新?
?はやり?のようであるProviderパターンというものを耳にしたので、使ってみようといろいろと調べてみた。下記2サイトが入口でした。
64. FlutterのProviderパターンを3分で理解する - Tamappe Life Log
うん、どちらの記事もすばらしいけど、いかんせん概念がわからない...。
状態のありなしでコードをわける、ということが大事なのはわかるけど、それってStatelessとStatefullで本来Flutter自体の仕様で区別されているからいいんじゃないの?というところからのスタートです(初心者)。
また、これらのサンプルアプリはどちらもFlutterのデフォルトサンプルアプリだし、またネットで調べてみても他も同様なものが多く、自分の目指すものにどのように適用するかも含めてそもそもを理解したい、という気持ちがありました。
Youtubeで調べてみた
で、最近、特にFlutter関連は海外の記事が良質なものが多く、特にYoutubeでリアルタイムコーディングしているものについては、リファクタリングの過程も含めて説明してくれるので役に立つことがわかり、今回もYoutubeで検索してみました。すると、下記の2つの動画にいき着きました。
※英語ですが、自動生成の字幕をオンにしてコードを追うと何となく理解できます。
1. Providerを"釣り"の例えで説明してくれる動画
1つめは「シンプルに説明するよ!」というこの動画。これは本当に、コードというより、Providerとはどういうことができるのか、というのを例えを使って概念的に説明してくれています。
曰く、Providerとは釣りで、上流から流した魚(データ)を途中でそれぞれの釣り師(Widget)が魚を釣り上げる(データを参照する)、ということがわかりました。
釣り、というのは結局よくわからなかったのですが(ひどい)、上流からデータを流すと下流で同じデータが使える、ということがよくわかりました。
2. テクニカルな部分を説明してくれる動画
1の動画の内容が例えだったのに対し、こちらはドキュメントに沿ってきっちり解説してくれて、またサンプルのコードもデフォルトとは別のもので作成しています。
これら2つの動画を見てから、先に紹介した2つの日本語の記事とコード(あとから読めば動画で説明していたことはほぼ書いてありました)、それと公式ドキュメントを追うと、いわゆる「完全に理解した」状態になったので、さっそくコーディングしてみました。
まずは何も考えずに
こちらがまず何も考えずに書いたコードです。我ながらひどい...ちなみにこれでもエラーなしで一応動きます。
import 'package:flutter/material.dart';
import 'package:game_project/domain/actor.dart';
import 'package:game_project/domain/map_tile.dart';
class FieldMapPage extends StatefulWidget {
@override
_FieldMapPageState createState() => _FieldMapPageState();
}
class _FieldMapPageState extends State<FieldMapPage> {
// ここらへんで値を保持している。
MapTile mapTile = MapTile();
Actor actor = new Actor(3, 7);
void setting(int x, int y) {
actor.x = x;
actor.y = y;
}
@override
Widget build(BuildContext context) {
mapTile.addActor(actor, 0, 0);
return Scaffold(
appBar: AppBar(title: Text("hoge"),),
backgroundColor: Colors.white,
body: Column(
children: <Widget>[
Expanded(
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 10),
itemBuilder: (BuildContext context, int index) {
return mapTile.tiles[index];
},
itemCount: mapTile.tiles.length,
),
),
Row(
children: [
ElevatedButton(
onPressed: () {
Actor act = mapTile.addActor(actor, -1, 0);
setting(act.x, act.y);
// ここで状態変化
// 今のこちらの変数をMAPに送ってやって変化したのをセットしなおし
setState(() {
});
},
child: Icon(Icons.arrow_back),
),
ElevatedButton(
onPressed: () {
Actor act = mapTile.addActor(actor, 0, -1);
setting(act.x, act.y);
setState(() {
});
},
child: Icon(Icons.arrow_upward),
),
ElevatedButton(
onPressed: () {
Actor act = mapTile.addActor(actor, 0, 1);
setting(act.x, act.y);
setState(() {
});
},
child: Icon(Icons.arrow_downward),
),
ElevatedButton(
onPressed: () {
Actor act = mapTile.addActor(actor, 1, 0);
setting(act.x, act.y);
setState(() {
});
},
child: Icon(Icons.arrow_forward),
),
],
),
],
)
);
}
}
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'actor.dart';
class MapTile {
Widget g = Container();
Widget b = Container();
MapTile() {
this.g = _m(Colors.green);
this.b = _m(Colors.black);
this.tiles.addAll([
g,g,g,g,g,g,g,g,g,g,
g,b,b,b,g,b,b,b,b,g,
g,b,b,b,g,b,b,b,b,g,
g,b,b,b,g,g,g,b,g,g,
g,g,b,g,g,g,g,b,g,g,
g,g,b,g,g,g,g,b,g,g,
g,b,b,b,b,b,b,b,b,g,
g,b,b,b,b,b,b,b,b,g,
g,b,b,b,b,b,b,b,b,g,
g,g,g,g,g,g,g,g,g,g,
]);
}
Actor addActor(Actor actor, int x, int y) {
if (x == 0 && y == 0) {
this.tiles[(actor.x - 1) + (actor.y - 1) * 10] = _m(Colors.yellow);
return actor;
}
Widget move = this.tiles[(actor.x - 1 + x) + (actor.y - 1 + y) * 10];
Widget move2 = this.tiles[(actor.x - 1) + (actor.y - 1) * 10];
if (move != this.g) {
this.tiles[(actor.x - 1 + x) + (actor.y - 1 + y) * 10] = _m(Colors.yellow);
if (move2 != this.g) {
this.tiles[(actor.x - 1) + (actor.y - 1) * 10] = _m(Colors.black);
}
return Actor(actor.x + x, actor.y + y);
} else {
return actor;
}
}
List<Widget> tiles = [];
Widget _m(Color color) {
return SizedBox(
height: 5.0,
width: 5.0,
child: Container(
color: color,
padding: EdgeInsets.all(1.0),
margin: EdgeInsets.all(1.0),
)
);
}
}
※このほかに、main.dartとActor.dartがありますが、状態とはあまり関係ないので掲載していません
繰り返しが何回もあるだろ!とか、作法がなっていない!とかそういう突込みは山ほどあるかと思いますが、そこはだいぶ大目に見ていただいて、まあこの書き方ですと、とりあえず処理は別クラスでやってるけど値は全部メインのWidgetで保持しておきますよ、という書き方になっています。
Providerを使って書き直し
ということで、Providerパッケージを適用して下記の通り書き直し。
import 'package:flutter/material.dart';
import 'package:game_project/map/field_map_model.dart';
import 'package:provider/provider.dart';
class FieldMapPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<FieldMapModel>( // 変更を感知するProvider
create: (_) => FieldMapModel(),
child: Scaffold(
appBar: AppBar(title: Text("RPG training"),),
backgroundColor: Colors.white,
// providerの流れをキャッチする、というイメージのConsumer。
// FieldMapModelのcontextをここで再生成して、下に流す。
body: Consumer<FieldMapModel>(
// このbuilderで、(context, model, child)が引数として渡される。
// modelがFieldMapModelそのものなので、それを使う。
builder: (_, mapTile, __) {
return Column(
children: <Widget>[
// Mapはうまいやり方が思いつかなかったのでとりあえずGridviewで作成
// Mapの幅(crossAxisCount)もmodelから引っ張った方がよさそう
Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
height: 400,
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 10),
itemBuilder: (BuildContext context, int index) {
return mapTile.tiles[index];
},
itemCount: mapTile.tiles.length,
),
),
),
Padding(
// コントローラー。押すとmodelのmoveActorを呼び出して、
// 引数判定し、赤マスだとコメントが出るようにした。
// Widgetまとめてうまくできそう。
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton(
onPressed: () {
if (mapTile.moveActor("fighter", -1, 0) == 3) {
final snackBar = SnackBar(
backgroundColor: Colors.green,
content: Text("You Win!!")
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},
child: Icon(Icons.arrow_back),
),
ElevatedButton(
onPressed: () {
if (mapTile.moveActor("fighter", 0, -1) == 3) {
final snackBar = SnackBar(
backgroundColor: Colors.green,
content: Text("You Win!!")
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},
child: Icon(Icons.arrow_upward),
),
ElevatedButton(
onPressed: () {
if (mapTile.moveActor("fighter", 0, 1) == 3) {
final snackBar = SnackBar(
backgroundColor: Colors.green,
content: Text("You Win!!")
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},
child: Icon(Icons.arrow_downward),
),
ElevatedButton(
onPressed: () {
if (mapTile.moveActor("fighter", 1, 0) == 3) {
final snackBar = SnackBar(
backgroundColor: Colors.green,
content: Text("You Win!!")
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},
child: Icon(Icons.arrow_forward),
),
],
),
),
],
);
}
)
),
);
}
}
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../domain/actor.dart';
import 'dart:math' as math;
class FieldMapModel extends ChangeNotifier {
// 外からアクセスできる変数。
// tiles => mapデータ
// fighter => 主人公
List<Widget> tiles = [];
Map<String, Actor> actor = {
"fighter" : Actor(0, 0, "fighter", 2),
"enemy" : Actor(0, 0, "enemy", 3),
};
// 外からアクセスできない変数。
// マップデータをintで格納
List<int> _mapList = [];
// コンストラクタ。マップとキャラの初期位置を決める。
FieldMapModel() {
_mapList = [
1,1,1,1,1,1,1,1,1,1,
1,0,0,0,0,0,0,0,0,1,
1,0,0,0,0,0,0,0,0,1,
1,0,0,0,0,0,0,0,0,1,
1,0,0,0,0,0,0,0,0,1,
1,0,0,0,0,0,0,0,0,1,
1,0,0,0,0,0,0,0,0,1,
1,0,0,0,0,0,0,0,0,1,
1,0,0,0,0,0,0,0,0,1,
1,1,1,1,1,1,1,1,1,1,
];
// ここでintをWidgetに変換している。
for (var tile in _mapList) {
tiles.add(_m(tile));
}
// マップと別にキャラをセット。ここら辺無駄な気がする。
actor.forEach((key, _) {
setInitialPosition(key);
});
}
// キャラたちをマップに配置
void setInitialPosition(String _actor) {
var _rand = new math.Random();
int _initialAddress = 0;
while (true) {
_initialAddress = _rand.nextInt(_mapList.length);
if (_mapList[_initialAddress] == 0) {
// ここら辺が無駄。多分、_mapListだけ格納して再描画の方がよさそう。そのうち直す。
tiles[_initialAddress] = _m(actor[_actor]!.color);
_mapList[_initialAddress] = actor[_actor]!.color;
setActorPosition(_actor, _initialAddress);
return;
} else {
_initialAddress = _rand.nextInt(_mapList.length);
}
}
}
// キャラを動かす処理。移動先のタイルの種類を返す。
int moveActor(String actorType, int x, int y) {
var currentAddress = (actor[actorType]!.x - 1) + (actor[actorType]!.y - 1) * 10;
var newAddress = (actor[actorType]!.x - 1 + x) + (actor[actorType]!.y - 1 + y) * 10;
int tileType = _mapList[newAddress];
// 壁なら動かさずに終了
if (_mapList[newAddress] == 1) {
return tileType;
}
// 壁じゃないなら動かす
setActorPosition(actorType, newAddress);
tiles[newAddress] = _m(actor[actorType]!.color);
_mapList[newAddress] = actor[actorType]!.color;
if (_mapList[currentAddress] != 1) {
this.tiles[currentAddress] = _m(0);
_mapList[currentAddress] = 0;
}
//providerの画面更新のコマンド
notifyListeners();
// Statelessにreturnしていいのかなぁ。tileTypeもグローバルにしておけばよさそう。
return tileType;
}
// 数字と色の関係。キャラが増えてきたら混乱しそう。
Color castIntToColor(int c) {
switch (c) {
case 1:
return Colors.green;
case 2:
return Colors.yellow;
case 3:
return Colors.red;
default:
return Colors.black;
}
}
// 1次元ポジションから2次元ポジションへの変換。
void setActorPosition(String act, int position) {
actor[act]!.x = (position + 1) % 10;
actor[act]!.y = (position + 10) ~/ 10;
}
// タイルのWidget。
Widget _m(int c) {
// 0: 床, 1: 壁, 2: キャラ, 3: 敵
Color color = castIntToColor(c);
return SizedBox(
height: 5.0,
width: 5.0,
child: Container(
color: color,
padding: EdgeInsets.all(1.0),
margin: EdgeInsets.all(1.0),
)
);
}
}
書き直していないところも多々あるのでご勘弁なのですが、とりあえず大きな変化はきちんとStatefull=>Statelessへの変更ができました。つまり、変数の受け渡しはせずに、状態を管理するModelクラスの方の値を、Statelessクラスの操作によって変化させているということですね。しかも、操作方法も基本はModelのほうに任せてあって、Statelessのほうは本当に表示のみの機能になるという。
適用してみた感想
Vue.jsの双方向バインディングを使っていろいろと試しました(*1)が、ここまで明確に、しかも簡単に状態保持・非状態保持をわけることは自分の力ではできなかったので、状態管理、という観点からはProviderかなり使いやすいのかなと感じました。また、Providerも種類があり、情報垂れ流し状態や、流してるだけで変化は見ないものなどいろいろですので、幅広く使えるのかなと思います。
また、最新の設計パターンということでとっつきにくいのかなと思いましたが、実装としては全体をProviderで括るだけでよく非常に簡単でした。むしろ、Statelessの方で状態を持たないということが明確に意識できるので、コードが非常に書きやすくなりました。
逆に考えると、設計パターンというのはどのように楽に書くか、ということに尽きるかなと思いますので、Providerはその点もきちんと考えられているな、という印象です。
以上
参考
*1 以前Vue.jsを使ってTodo+RPGアプリ作成しました。自分は現役で使用中。今回もそれへの適用を見据えてミニゲーム作ってます。