Dieses Tutorial ist als Einstieg in Flutter & Dart konzipiert und nimmt sich daher viel Zeit für ausführliche Erklärungen. Wir beginnen mit dem Datenmodell für Minesweeper.
Der Code, der in diesem Tutorial entwickelt wird, wurde veröffentlicht in einem Repo unter AUSoftAndreas/flutter-minesweeper. Jedes Kapitel hat einen zugeordneten Branch. Dieses Kapitel ist beispielsweise Branch "step_a" als Branch zugeordnet. Wenn Ihr also nachvollziehen wollt, wie die App nach und nach entstanden ist, dann clont Euch das Repository und dann wechselt (in VSCode unten links) den Branch und geht auf "step_a". Gegebenenfalls müsst Ihr nochmal pullen, damit es wirklich der richtige Stand ist, aber prinzipiell sollten es sofort weniger Dateien sein und eine deutlich schlechtere App. :-)
Ich startete mit einer blanken Vorlage aus dem VSCode-Menu. Also Strg + Shift + P und dann "Flutter: New Application Project". Dann wird das bekannte Beispiel mit dem Counter installiert, wobei ich den entsprechenden Code sofort löschte.
Meine main.dart ist daher sehr spartanisch:
import 'package:flutter/material.dart';
import 'package:minesweeper/ui/start_screen.dart';
void main() {
runApp(
MyApp(),
);
}
/// Base class of app
class MyApp extends StatelessWidget {
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return MaterialApp(
routes: {
'/': (context) => StartScreen(),
},
initialRoute: '/',
);
}
}
Ich erkläre kurz, was die main.dart tut. Im Wesentlichen ist das der Startpunkt
für die App. Man sieht die void main()
, die den eigentlichen Einstiegspunkt
darstellt. Sie tut nicht mehr als die App zu starten, was zwar im Hintergrund
sicher einiges an Prozessen startet, uns aber eher wenig beschäftigt.
MyApp ist das erste Widget, sozusagen das Stammwidget für alles, was kommt.
Sämtliche GUI-Elemente in Flutter sind Widgets. Sie sind alle untereinander
relativ austauschbar insofern, als dass sie alle sich auf die Basisklasse Widget
beziehen. Dadurch ist der Bau einer GUI sehr flexibel, weil ein Widget wieder
ein anderes Widget als child haben kann - und da ein Widget so vieles sein kann,
gibt es da einfach viel Auswahl.
In jeder Klasse, die wiederum auf Widgets basiert (wie hier MyApp) gilt es eine build-Methode. Diese Methode ist das, was die Ausgabe auf dem Bildschirm erzeugt. Sie läuft unter Umständen, zu denen wir noch kommen werden, viele Male pro Sekunde ab. Sie kann aber auch über Minuten nicht laufen, wenn Ihr Euch einfach einen Screen in der App anschaut. Sie würde aber beispielsweise sofort ablaufen, wenn Ihr das Handy dreht. Die build-Methode returnt jeweils Widgets und die werden auf dem Bildschirm gerendert. Die Annotation '@override' wird vom Linter gewollt, weil technisch gesehen wir die build-Methode unserer Mutterklasse (StatelessWidget) überschreiben. Das wird also bei jeder build-Methode passieren. Die Zeile mit dem ignore sagt einfach, dass wir den blauen Linter-Hinweis ignorieren wollen, der sonst erschiene, wenn wir die Zeile nicht hätten. Das könnt ihr ja ausprobieren.
In unserem Fall setzt diese build-Methode einfach nur ein paar Werte fest. Das passiert mit dem MaterialApp-Widget. Hier geht es vor allem um die routes. Das heißt, dass wir unseren Screens in der App (also den verschiedenen GUI- Bildschirmen) jeweils einen Namen zuordnen. Den Namen '/' (was ein Standard für den Startscreen ist) ordnen wir also die Klasse StartScreen zu.) Ihr ahnt es, auch StartScreen wird eine Tochter eines Widgets sein und in seiner build-Methode ein Widget returnen? Exakt.
Außerdem sagen wir, dass die erste Route (initialRoute), also der erste geladene Screen der StartScreen sein soll. Sonst passiert hier nix. Kommen wir zum StartScreen:
import 'package:flutter/material.dart';
/// Welcome Screen for the app
class StartScreen extends StatelessWidget {
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return Material(
child: Container(
child: Center(
child: IconButton(
icon: const Icon(Icons.ac_unit),
onPressed: () {},
),
),
),
);
}
}
Selber Aufbau. Und das erste kleine Beispiel dafür, wie GUI-Screens in Flutter beschrieben werden. Diese Baumstruktur mit den Einrückungen ist typisch (und im Prinzip HTML nicht ganz unähnlich). Gehen wir die Widgets durch:
<div></div>
in
HTML, also einfach eben ein Container um das, was da drin ist. Im Kern wird
einfach die ganze Seite erstmal in einen Container gepackt.Icon(Icons.ac_unit)
) und man muss auch definieren, was passieren soll,
sobald der Button gedrückt wird. In dem Fall definierte ich eine anonyme Funktion
ohne Inhalt. Also eigentlich passiert nichts.*Was Ihr an dieser Stelle tun könnt und solltet:
Material
platzieren, rechts clicken und "Refactor" auswählen im
Kontextmenu. Seht, welche Möglichkeiten Ihr habt. Wählt "Remove widget" und seht,
dass VSCode Euch hilft. Es entfernt das Material-Widget und die dadurch notwendige
Klammer am Ende des Blocks. alle anderen Widgets rücken eine Ebene hoch (also
weiter nach links im Editor). Nun könnt Ihr den Container ebenfalls markieren und
rechtsclicken. Wiederum "refactor" wählen und "Wrap with widget". Dann wird
Euer Container in ein noch zu benenndes Widget gehüllt. Nennt das wieder
Material
und wir haben den Anfangszustand wieder erreicht. VSCode hilft Euch
sehr bei der Tipparbeit und das solltet Ihr in Zukunft oft nutzen.child: Container(
child: Center(
zu
child: Container(
color: Colors.red,
child: Center(
Okay. Halten wir fest: Wir haben jetzt eine App, die nix kann und nach nix aussieht. :-)
Die Grundlage ist eigentlich immer, dass man mit den Daten beginnt. Die UI ist ja nur eine grafische Representation des sogenannten State. Wir müssen nun überlegen, was den State der App ausmacht, also den Zustand, den - würde man ihn komplett kennen - einen in die Lage versetzen würde, den Bildschirm zu zeichnen.
Es ist nicht allzu schlimm, wenn man dabei etwas vergisst. Man kann ja nachlegen, aber das Arbeiten an GUI etc. macht keinen Sinn ohne ein Verständnis davon zu haben, was man eigentlich darstellen will. Ich habe mir folgende Sachen erstmal überlegt:
Mein allererster, kleiner Schritt war daher, dass ich auch eine Klasse für
Positionen schaffen wollte. Das ist nicht zwingend notwendig, aber eine Klasse
heißt ja zugleich, dass sie zu einem gültigen Variablentyp wird - und mir fielen
viele Beispiele ein, wo es praktisch wäre, eine Position irgendwo zu speichern
oder zu übergeben. Gesagt, getan, eine Klasse Position
war geschaffen:
import 'package:flutter/foundation.dart';
/// Defines an absolute position (of a block)
@immutable
class Position {
/// Horizontal component of position, from 0 (left) to numCols-1 (right)
final int _x;
/// Vertical component of position, from 0 (bottom) to numRows-1 (top)
final int _y;
/// Standard constructor for position
const Position(this._x, this._y);
/// Horizontal component of position, from 0 (left) to numCols-1 (right)
int get x => _x;
/// Vertical component of position, from 0 (bottom) to numRows-1 (top)
int get y => _y;
/// Overridden toString method, for better output instead of "Instance of <Posiion>"
@override
String toString() => 'Position($x/$y)';
@override
bool operator ==(Object o) => o is Position && o.x == _x && o.y == _y;
@override
int get hashCode => 1000 * x + y;
}
Keine Sorge, das ist alles einfacher als es aussieht. Bereinigen wir es mal um Kommentare und Annotationen, die ja funktional nicht wirklich notwendig sind.
Annotationen sind Markierungen wie zum Beispiel @override
. Sie tun
eigentlich nichts, sie zeigen sind nur ein Weg, mit dem Dart SDK zu
kommunizieren. Mit @override
signalisieren wir dem Dart SDK, dass wir
uns dessen bewusst sind, dass wir hier Methoden überschreiben, die eine
eine Klasse von einer Mutterklasse (oder Mutter der Mutter ...) geerbt
hat. Das ist zulässig, könnte aber auch versehentlich geschehen, weil man
einfach den selben Namen nutzt. Durch @override
sagen wir, dass es nicht
zufällig geschah. Ebenso sagen wir dem SDK durch @immutable
, dass wir
hier eine unveränderliche Klasse schaffen wollen - und das SDK wird uns
bei jedem Verstoß gegen diesen Vorsatz korrigieren.
Kommentare werden in Dart durch //
eingeleitet und haben keinerlei
Auswirkungen auf das Programm. Sie dienen nur uns als Gedankenstütze und
sorgen dafür, dass sich andere in Eurem Code besser zurechtfinden.
Effizientes Kommentieren ist eine wichtige Schlüsselqualifikation.
import 'package:flutter/foundation.dart';
@immutable
class Position {
final int _x;
final int _y;
const Position(this._x, this._y);
int get x => _x;
int get y => _y;
String toString() => 'Position($x/$y)';
bool operator ==(Object o) => o is Position && o.x == _x && o.y == _y;
int get hashCode => 1000 * x + y;
}
Gehen wir den Code durch:
final int _x;
final int _y;
Eine Position wird bestimmt durch eine X- und eine Y-Koordinate. Beides sind
Ganzzahlen, weil es keine halbe Position gibt auf dem Spielfeld. Also definieren
wir beides als Attribute einer Position. Da ich eigentlich nicht will, dass eine
Position im Nachhinein von irgendwem geändert wird, sind beide als private
definiert, was durch den Unterstrich klar gemacht wird. Wenn man aber eine Position
hat, dann soll man sie durchaus auslesen können. Man soll sie nur nicht
verändern dürfen.int get x => _x;
int get y => _y;
Diese Zeilen, sogenante Getter, sorgen dafür, dass die Attribute auslesbar sind.
Sie definieren ein öffentliches Attrubut x bzw. y, das man von außen getten, also
lesen kann. Man bekommt dann (=>) den Inhalt der beiden privaten Attribute
_x bzw. _y ausgegeben. Einfach sich daran erinnern:
int get x => _x;
ist nur eine andere Schreibweise für
int get x {
return _x;
}
const Position(this._x, this._y);
Eine Position wird zukünftig erstellt, indem man zum Beispiel einfach
Position(2,3) schreibt, damit beschreibt man eindeutig den Block in der 3.
Spalte von rechts und der 4. Zeile von unten (Indizes sind meistens 0-basiert,
also es gibt Zeile/Spalte 0). Es werden (da wir keine []
oder {}
verwenden)
positionale Argumente benötigt, man kann auch keines weglassen. Das erste
Argument, was man verwendet, wird automatisch in _x geschrieben, das zweite
automatisch in _y. this
steht stellvertretend für "dieses Objekt". Das
const
wurde mir seitens Linter empfohlen. Generell ist es eine Art Zusicherung,
dass das Objekt, wenn es denn von irgendwem erstellt wurde, sich nie wieder
ändert. Und das ist ja hier der Fall. Eine Position bleibt immer genau diese
Position. Der Inhalt der Position mag sich vielleicht verändern (also der
Block, der da liegt), aber "3 Reihen von unten, 5 Blöcke von links" bleibt
immer "3 Reihen von unten, 5 Blöcke von links".Und das ist schon das Ende für den Code, der wirklich klassenbezogen ist. Da ich
hin und wieder Log-Ausgaben produziere und ich da nicht jedesmal etwas schreiben will wie
log('Position erstellt mit dem Koordinaten ${pos.x} / ${pos.y}');
habe ich
die toString-Methode, die jedes Objekt hat, überschrieben. Die sorgte in ihrer
Standardfassung dafür, dass ein log('$pos');
immer die Ausgabe Instance of <Position>
erzeugt, was wenig aussagefähig ist. Durch meine Veränderung wird jetzt bspw.
Position(2/3)
ausgegeben.
Für die nächsten beiden Codeabschnitte müssen wir kurz etwas Theorie wiederholen. Eine Variable enthält in der Regel nicht das Objekt selbst, sondern einen Verweis darauf. Bei den sogenannten "Primitives" (primitive Werte wie String, int, double) ist alles so eingerichtet, dass man Werte miteinander vergleichen kann. Bei den eigenen Klassen allerdings nicht. Man beachte folgenden Code (den Ihr in einem Tool wie DartPad gut nachvollziehen könnt):
class SimpleClass {
String attribute;
SimpleClass(this.attribute);
}
void main() {
var a = 'Hallo';
var b = 'Hallo';
print(a==b); // => true
var c = SimpleClass('Hallo');
var d = SimpleClass('Hallo');
print(c==d); // => false
}
Man sieht, dass er bei zwei Instanzen der SimpleClass nicht wirklich prüft, ob
die Inhalte gleich sind. Stattdessen sagt er einfach "es sind zwei verschiedene
Instanzen, also sind sie ungleich". Das ist natürlich, gerade bei großen Klassen,
auch die viel schnellere Prüfroutine und oft genug passt sie. Hier aber nicht.
Wir werden oft Positionen vergleichen und dazu werden wir Positionen erzeugen
und dann soll eben gelten, dass Position(2,3) == Position(2,3)
ist.
Daher overriden wir den Operator ==
mit dem nachfolgenden Code:
bool operator ==(Object o) => o is Position && o.x == _x && o.y == _y;
Wir sagen dadurch: Eine Position(x/y) ist dann gleich einem anderen Objekt (ganz allgemein), wenn auch das andere Objekt eine Position ist und wenn dann auch x und y übereinstimmen mit unseren Attributen _x und _y (man beachte die Unterstriche, denn die sind ja privat, wobei wir auch x und y schreiben könnten, denn unsere Klasse würde dann über den Getter ja auch _x und _y bekommen).
Und schließlich gab es die Linter-Aufforderung, dass man nie nur den Equality-Operator
overriden soll, sondern auch den sogenannten hashCode, der dem Computer
dabei hilft, Werte zu vergleichen. Er soll den Inhalt schneller repräsentieren
als wenn man wirklich händisch alle Attribute vergleicht. Nunja, die von mir
gefundene Funktion 1000 * x + y
garantiert, dass jede wirklich verschiedene
Position auch einen unterschiedlichen hashWert hat und deswegen nahm ich sie.
Alle anderen Änderungen an der Datei waren Linter-bezogen, also weil er zum Beispiel bemängelte, dass eine Dokumentation fehlt oder dass man das Keyword final verwenden soll. Das gilt zum Beispiel für _x und _y. Warum? Nun, wenn ich keinerlei Setter habe, mit dem man den Wert von _x und _y ändern kann und auch keine meiner Methoden in dieser Klasse es tut, dann sind die Variablen per definitionem final, also unveränderbar.
Das heißt, dass ein Objekt der Klasse nach Instanziierung nie verändert werden darf. Warum? Weil man verhindern will, dass ein Vergleich ergibt, dass zwei Objekte gleich sind, dann verändert man bei einem der beiden die Eigenschaften und schon sind sie eben nicht mehr gleich. Nunja, uns störte es nicht, weil eine Position hier sich nie ändert.
Im Endeffekt haben wir dann eine Klasse, mit der sich Objekte instanziieren lassen, die jeweils eine Position auf dem Spielfeld repräsentieren. Ein kleiner, wichtiger Schritt.
Relativ häufig erinnert uns der Linter daran, dass wir unsere Variablen als
final
deklarieren sollen. Ebenso häufig wird er uns später daran erinnern,
dass wir ein Widget mit const
markieren sollen. Was ist damit gemeint?
Für den Rechner ist es mehr Arbeit, wenn er bei einer Variablen dafür sorgen muss, dass der Wert sich auch ändern kann. Daher nehmen wir ihm Arbeit ab, wenn wir ihm sagen, dass eine Variable sich nicht ändern wird. Dabei gibt es zwei Stufen.
Eine Variable ist const
, wenn Ihr Wert schon dann feststeht, wenn wir den
Code schreiben. Wenn wir beispielsweise var a = 2
schreiben und diesen
Wert nie ändern, dann ist es eigentlich sogar eine Konstante. Denn uns ist
klar, dass der Wert 2 ist und immer sein wird, solange wir das Programm
nicht umschreiben. Der Wert ist also schon zur Compilezeit feststehend.
Eine Variable ist final
wenn Ihr während das Programm läuft ein für uns bei
der Programmierung nicht vorhersagbarer Wert zugewiesen wird und sich dieser
danach nicht mehr ändert.
Im Endeffekt kann man das alles weg lassen, aber es optimiert die Geschwindigkeit,
wenn wir möglichst häufig final
und const
einsetzen. Beispielsweise
weiß der Rechner bei einem Neurendern der GUI, dass ein Widget-Zweig, der nur
konstante Widgets enthält, nicht neu gezeichnet werden muss.
Als nächstes habe ich mich dem Block zugewendet. An jeder Position sitzt später ein Block und der hat ebenfalls Informationen, die ihm zugeordnet sind. Im ersten Schritt habe ich nur diese Informationen erstmal abgebildet.
import 'package:minesweeper/models/position.dart';
/// Class representing a single block on the playing field of the game.
class Block {
/// Absolute position of block on field.
final Position _position;
/// Indicates whether block is mined or not
final bool _mine;
/// Indicates whether block is mined or not
bool get mine => _mine;
/// Indicates whether block is fagged or not
bool flagged = false;
/// How many mines are on the neighboring 8 blocks
int _closeMines = 0;
/// How many mines are on the neighboring 8 blocks
int get closeMines => _closeMines;
/// Indicates whether the player already opened this block or not
bool open = false;
/// Standard contructor for a block.
Block({required position, mine = false})
: _position = position,
_mine = mine;
/// List of all neighboring blocks. Convenience getter, so that we do not
/// have to implement the same logic whenever we need that information
List<Block> _neighbors(Map<Position, Block> map) {
final list = <Block>[];
if (map[Position(_position.x + 0, _position.y + 1)] != null) list.add(map[Position(_position.x + 0, _position.y + 1)]!);
if (map[Position(_position.x + 1, _position.y + 1)] != null) list.add(map[Position(_position.x + 1, _position.y + 1)]!);
if (map[Position(_position.x + 1, _position.y + 0)] != null) list.add(map[Position(_position.x + 1, _position.y + 0)]!);
if (map[Position(_position.x + 1, _position.y - 1)] != null) list.add(map[Position(_position.x + 1, _position.y - 1)]!);
if (map[Position(_position.x + 0, _position.y - 1)] != null) list.add(map[Position(_position.x + 0, _position.y - 1)]!);
if (map[Position(_position.x - 1, _position.y - 1)] != null) list.add(map[Position(_position.x - 1, _position.y - 1)]!);
if (map[Position(_position.x - 1, _position.y + 0)] != null) list.add(map[Position(_position.x - 1, _position.y + 0)]!);
if (map[Position(_position.x - 1, _position.y + 1)] != null) list.add(map[Position(_position.x - 1, _position.y + 1)]!);
return list;
}
/// Updates the count of mines within the neighboring fields
void updateCloseMines(Map<Position, Block> map) {
_neighbors(map).forEach((block) {
if (block.mine) _closeMines++;
});
}
}
Wieder ist der Part eigentlich verdaubar. Was geschieht?
Wie jede Klasse braucht auch der Block einen Konstruktor, also eine Funktion,
mit der man einen Block produzieren kann. Hier verlangt (required
) der Konstruktor nach
einer Position. Die Angabe, ob hier eine Mine liegt, kann man auch weglassen (dann nein).
Ein bischen ungewohnt ist die Form des Konstruktors. Dart unterscheidet beim Konstruktor sozusagen drei verschiedene Orte, an denen man Werte setzen kann.
Konstruktor(1) : 2 {
3
}
Wir können Werte direkt in der Argumentenliste(1) entgegennehmen (this.x bei der
Position zum Beispiel). Das klappt aber nicht, wenn die Attribute einen Unterstich
im Namen haben und benannte Attribute sind. Es würde klappen mit
Block(this._position, this._mine)
.... Aber ich mag es häufig, wenn die Attribute mit
Namen aufgerufen werden müssen, also Block(position: Bla, mine: blun)
. Daher die
Schreibweise mit den geschweiften Klammern und dann geht dieses direkte Weiterreichen
per this
nicht. Dann gibt es wie bei jeder Funktion die Möglichkeit, etwas im Body
zu tun (3) und natürlich können wir hier auch etwas in die Attribute der Klasse
schreiben. Dann ist das Objekt aber bereits erstellt! Es ist sozusagen der Code,
der laufen soll, nachdem das Objekt instanziiert ist. Man kann hier beispielsweise
keine finale Variable mehr ändern, auch nicht wenn das die Erstbelegung ist, weil
die Variable wurde ja schon erstellt. Und dann gibt es eben den Block 2, der
in meiner Variante verwendet wurde. Hier können Attribute initialisiert werden,
während das Objekt noch nicht existiert - und für die Attribute, deren Belegung
nicht durch die Argumentenliste gehandhabt wurde.
Wenn man übrigens keinen Body (3) benötigt, kann man stattdessen einfach ein Semikolon setzen, was hier getan wurde. Auch darauf wird man aber vom Linter hingewiesen, wenn man das stattdessen mit Klammern schreiben. An der Stelle können wir mal festhalten, dass der Linter uns vieles von dem erheblich erleichtert, was uns jetzt noch schwer vorkommen mag.
So, und dann sind da noch zwei Methoden, die auch erst einen Ticken später geschrieben wurden, als die Spielfeld-Klasse geschrieben war, und die tatsächlich was mit dem Spiel zu tun haben. Zuerst muss man sich vergegenwärtigen, dass mehrfach im Spiel ein Block mit seinen Nachbarn interagieren muss. Einmal dann, wenn er die Anzahl der Minen im Umfeld berechnen muss. Dann muss er gucken, welcher Nachbar eine Mine hat. Und das andere mal, hier noch nicht implementiert, wenn er selbst geöffnet wird. Wenn nämlich der Block eine 0 Minen in der Umgebung hat, dann deckt er auch alle seine Nachbarn auf, die wiederum ihre Nachbarn aufdecken, so sie auch 0 Minen im Umfeld haben. In beiden Fällen ist es sinnvoll, nicht jedesmal den langen Block zu schreiben nach dem Motto "mein Nachbar oberhalb, der oben rechts, der rechts, der unten rechts, der....", sondern das einmal zu tun und deswegen habe ich eine Methode geschrieben, die einfach eine Liste aller Nachbarblöcke produziert. Das macht die anderen beiden Methoden schlanker. Gucken wir uns den Code an:
List<Block> _neighbors(Map<Position, Block> map) {
final list = <Block>[];
if (map[Position(_position.x + 0, _position.y + 1)] != null) list.add(map[Position(_position.x + 0, _position.y + 1)]!);
if (map[Position(_position.x + 1, _position.y + 1)] != null) list.add(map[Position(_position.x + 1, _position.y + 1)]!);
if (map[Position(_position.x + 1, _position.y + 0)] != null) list.add(map[Position(_position.x + 1, _position.y + 0)]!);
if (map[Position(_position.x + 1, _position.y - 1)] != null) list.add(map[Position(_position.x + 1, _position.y - 1)]!);
if (map[Position(_position.x + 0, _position.y - 1)] != null) list.add(map[Position(_position.x + 0, _position.y - 1)]!);
if (map[Position(_position.x - 1, _position.y - 1)] != null) list.add(map[Position(_position.x - 1, _position.y - 1)]!);
if (map[Position(_position.x - 1, _position.y + 0)] != null) list.add(map[Position(_position.x - 1, _position.y + 0)]!);
if (map[Position(_position.x - 1, _position.y + 1)] != null) list.add(map[Position(_position.x - 1, _position.y + 1)]!);
return list;
}
Zuerst zur Deklaration der Methode. Die Methode hat einen Rückgabewert vom Typ
List<Block>
, also eine Liste von Blöcken. Sie heißt _neighbors. Der Unterstrich
macht sie wieder private, sie ist also nicht von außerhalb der Klasse aufzurufen.
Die Methode basiert darauf, dass man ihr eine map aller Positionen im Spiel
als Parameter übergibt. Eine Map ist eine Zuordnung von Werten zu anderen
Werten. In anderen Sprachen nennt man sowas einen assoziativen Array.
Eine typische Liste sieht zum Beispiel so aus:
var a = {
"name": "Andreas",
"hobby": "Programmieren",
"partner": "Mariia",
"hund": "Lizy"
};
Ich denke man erkennt das System. Das wäre eine Map<String, String>, weil jedem
String (links) ein anderer String (rechts) zugeordnet ist. Wenn man keinen genauen
Map-Type definiert, dann ist eine Map in Dart standardmäßig eine Map<String, dynamic>
,
also eine Zuordnung eines Strings (sozusagen der Name der Eigenschaft) zu
irgendwas Unbestimmbarem (String, Zahl, etc.).
Wenn man in meinem Beispiel wissen wollte, wie mein Hund heißt, müsste man dann folgendermaßen vorgehen:
var b = a["hund"];
Man fragt also nach der Zuordnung zu dem String "hund" in der Map.
Unsere Map, die wir bekommen werden, ist eine Map, wo jeder Position ihr jeweiliger Block zugeordnet ist. Die kommt aus der Field-Klasse, mit der wir uns später beschäftigen.
Zurück zu unserem Code: Ws wird eine Liste namens list als Variable definiert. Das kann man entweder manchen wie bei der Methodendeklaration, also man könnte schreiben
final List<Block> list = [];
Dann meckert aber der Linter, weil er nicht will, dass wir den Typ hinschreiben.
Die gewählte Variante final list = <Block>[];
hingegen gefällt dem Linter.
Das []
zeigt dem Linter es ist eine Liste, aber sie ist noch leer.
Die nächsten Zeilen prüfen jeweils, ob es für die rechnerisch berechnete Nachbar-Position einen Block gibt. Wenn die Position außerhalb des Spielfelds liegt, wird es keinen Block dort geben, insofern müssen wir diese Fälle nicht gesondert abfangen. Wenn es keinen Eintrag in der Map gibt, dann gibt es da keinen Block. Wenn es einen gibt, dann wird unsere Nachbarn-Liste um einen Eintrag ergänzt. Das sind also maximal 8 Einträge. Das Hinzufügen zur Liste geschieht über list.add.
Dem aufmerksamsten aller Leser mag sich die Frage stellen, wieso wir Einträge einer
Liste hinzufügen dürfen, obwohl die Liste doch final
ist. Hier verweise ich
nochmal auf Ausführungen zuvor: Eine Variable enthält in der Regel nicht die
Werte selbst, sondern nur eine Referenz auf diese Werte. Wenn wir einer Liste
einen Wert hinzufügen, dann verändern wir diese Referenz nicht. Insofern ist
das final
-kompatibel. Was hingegen nicht geht, wäre eine neue Liste zu erstellen,
jetzt mit allen Elementen der alten Liste plus der neue Eintrag. Das wäre eben
eine neue Liste und damit eine neue Referenz.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:minesweeper/models/block.dart';
import 'package:minesweeper/models/position.dart';
@immutable
/// Class representing the game field
class Field {
/// A map (associative array), linking each Position to its corresponding block.
late final Map<Position, Block> map;
/// Number of mines on the field
final int mines;
/// Number of rows
final int numRows;
/// Number of columns
final int numCols;
/// Is the game already lost?
final bool gameLost;
/// Will the next click of the user plant a flag (instead of opening a block)?
final bool flagMode;
/// Constructs a game field for our game
/// It is an empty field. We just use it for instantiating the game in
/// lib/notifiers/_notfifiers.dart. It will be reconstructed many times using
/// the .withArguments constructor.
Field()
: map = {},
mines = 0,
numRows = 0,
numCols = 0,
gameLost = false,
flagMode = false;
/// Constructs a game field for our game
Field.withArguments({required this.numRows, required this.numCols, required this.mines})
: gameLost = false,
flagMode = false {
final newMap = <Position, Block>{};
var leftMines = mines;
var leftFields = numRows * numCols;
final rng = math.Random();
for (var col = 0; col < numCols; col++) {
for (var row = 0; row < numRows; row++) {
final pos = Position(col, row);
final prob = (100 * leftMines) ~/ leftFields;
final mine = rng.nextInt(100) < prob;
newMap[pos] = Block(position: pos, mine: mine);
if (mine) {
leftMines--;
}
leftFields--;
}
}
// Let each field calculate its nearby mines
newMap.forEach((pos, block) {
block.updateCloseMines(newMap);
});
map = newMap;
}
// ignore: prefer_const_constructors_in_immutables
Field._internal({
required this.map,
required this.mines,
required this.numRows,
required this.numCols,
required this.gameLost,
required this.flagMode,
});
/// Returns new Field object, which is a copy of this object, but with
/// some differences specified when calling this method.
Field copyWith({Map<Position, Block>? map, int? mines, int? numRows, int? numCols, bool? gameLost, bool? flagMode}) => Field._internal(
map: map ?? this.map,
mines: mines ?? this.mines,
numRows: numRows ?? this.numRows,
numCols: numCols ?? this.numCols,
gameLost: gameLost ?? this.gameLost,
flagMode: flagMode ?? this.flagMode,
);
}
Das ist natürlich datenseitig das Kernstück unserer Arbeit. Deswegen ist es auch ein bisserl mehr. Argumente schenke ich mir diesmal
Ich habe einen mehr oder weniger leeren Konstruktor angelegt. Das ist nur nötig, weil ich aus Gründen des State Management, mit denen wir uns im nächsten Kapitel beschäftigen werden, schon beim Starten der App einen State haben muss. Das repräsentiert also sozusagen ein 0-Spielfeld.
Dann gibt es zwei weitere Konstruktoren. Dart erlaubt beliebig viele Konstruktoren für eine Klasse, wobei die sich durch einen Punkt und dann eine Namensergänzung unterscheidbar sind. Man kann also entweder ein mehr oder weniger leeres Spielfeld erstellen (Standard-Konstruktor) oder mit einigen Argumenten ein vernünftiges Spielfeld (Field.withArguments, wobei ich fürchte, dass das falsches Englisch war, aber nun ist es zu spät, um das umzsuchreiben).
Außerdem haben wir die Möglichkeit geschaffen, alle Feinheiten zu kontrollieren. Dafür wurde ein Konstruktor geschaffen, mit dem man quasi alles bestimmen kann. Dieser dient uns in der copyWith-Methode, zu der wir nun kommen.
Der Dart-Weg mit den beliebig vielen Konstruktoren ist sehr smart und schön. Prinzipiell ist es in den meisten Programmiersprachen so, dass es möglich ist, mehrere Konstruktoren zu definieren.
Mal ein praktisches Beispiel: Angenommen wir wollen ein Schüler-Objekt in einer Schulverwaltung instanziieren (weil wir dann einige Berechnungen vornehmen wollen). Die Objekterstellung erfolgt an vielen Stellen (Programmteilen) und an diesen Stellen stehen uns unterschiedliche Informationen zur Verfügung zur Instanziierung. Beispiele:
Viele Sprachen gehen aber den Weg der sogenannten Überladung von Konstruktoren. Das heißt, dass man mehrere Konstruktoren definiert, die sich nur in ihren Argumenten unterscheiden. Wir hätten also einen Konstruktor, der nur ein int-Argument (die ID) annimmt und einen, der ganz viele Werte annimmmt und für unseren Test-Schüler noch einen Konstruktor, der gar keine Argumente hat. Wenn wir nun irgendwie eine Zeile haben wie
Schueler a = new Schueler(); // JAVA-Syntax, nicht wundern :-)
dann leitet der Compiler aus der Tatsache, dass wir den Schueler-Konstruktor ohne Argumente aufgerufen haben, ab, dass wir die Testdaten wollen und keinen konkreten Schüler.
Der Dart-Weg ist da deutlich lesbarer und flexibler:
var a = Schueler.mockData();
var b = Schueler.fromId(id);
var c = Schueler({name: 'Andreas', ......});
Diese Art der Funktion ist bei State Management mit sogenanntem immutable State wichtig. Auch wenn wir darauf nochmal zu sprechen kommen werden, sei die Grundlage zumindest erähnt: Der State ist der Gesamtstatus der App (ggf. kann eine App auch mehrere Teil-States haben, aber so komplex ist diese App nicht). Das Grundproblem sind die Updates der GUI, wenn sich der State ändert. Und ganz klassisch gibt es zwei, logisch nachvolziehbare, Ansätze:
Die copyWith-Methode erzeugt eine Kopie des jetzigen States mit Abweichungen. Wenn wir also beispielsweise eine Zeile haben wie
state = state.copyWith(gameLost: true);
dann führt das dazu, dass der Variablen state ein neuer Wert zugewiesen wird. Dieser neue Wert ist eine Kopie des alten States, nur eben mit einem veränderten Attribut.
Gucken wir uns die copyWith-Methode ruhig im Detail an:
Field copyWith({Map<Position, Block>? map, int? mines, int? numRows, int? numCols, bool? gameLost, bool? flagMode}) => Field._internal(
map: map ?? this.map,
mines: mines ?? this.mines,
numRows: numRows ?? this.numRows,
numCols: numCols ?? this.numCols,
gameLost: gameLost ?? this.gameLost,
flagMode: flagMode ?? this.flagMode,
);
Die Fragezeichen bei den Argumenten sagen, dass hier auch null
-Werte übergeben
werden können. Wenn wir kein Argument angeben, dann sind das jeweils null
-Werte.
Unser Beispiel-Methodenaufruf mit dem gameLost
-Attribut übergibt eben für jede
Variable den Wert null
, bis auf die Variable gameLost
, der gibt sie den Wert
true.
Die Funktion ist kurz und übergibt uns direkt per =>
ihren Rückgabewert. Dieser
ist ein neu konstruiertes Field-Objekt (ein neuer State), und hier übergeben wir
jedem Attribut prinzipiell die Variablen, die wir bei unserem Funktionsaufruf
bekamen. Wenn es aber null
-Werte sind, wird stattdessen der aktuelle Wert des
Attributs in this
(diesem) Objekt übergeben. In der Wirkung führt das dazu, dass
bei obigem Funktionsaufruf `state.copyWith(gameLost: true) also ein neues
Field-Objekt erstellt wird und dieses Objekt bekommt als Attrubute:
null
. Wenn der Wert aber null
ist (das macht der ??
-Operator, er
fragt, ob der Wert null
ist), dann wird stattdessen der Wert unseres Attribus
map übergeben. Im vorliegenden Fall wird also der gegenwärtige Wert von map in das
neue Field-Objekt übernommen.??
-Operator nicht an, denn der Wert true
ist ja nicht null
.Kommen wir noch zu diesem Code. Das ist ja der Konstruktor, der nicht irgendwelchen Dummy-Kreationen zu Beginn geschuldet ist und auch nicht dem Konstruieren eines Objektes im Zuge des State Managament. Hier geht es wirklich um das Generieren eines neuen Spielfelds. Lasst uns diesen Code erstmal erneut anschauen:
/// Constructs a game field for our game
Field.withArguments({required this.numRows, required this.numCols, required this.mines})
: gameLost = false,
flagMode = false {
final newMap = <Position, Block>{};
var leftMines = mines;
var leftFields = numRows * numCols;
final rng = math.Random();
for (var col = 0; col < numCols; col++) {
for (var row = 0; row < numRows; row++) {
final pos = Position(col, row);
final prob = (100 * leftMines) ~/ leftFields;
final mine = rng.nextInt(100) < prob;
newMap[pos] = Block(position: pos, mine: mine);
if (mine) {
leftMines--;
}
leftFields--;
}
}
// Let each field calculate its nearby mines
newMap.forEach((pos, block) {
block.updateCloseMines(newMap);
});
map = newMap;
}
Gehen wir wieder alles von oben an durch:
numRows
, numCols
). Außerdem muss man ihm sagen,
wie viele Minen das Spielfeld enthalten soll.gameLost
und flagMode
werden dann im Nachgang auf false gesetzt, noch während
das Objekt nicht instanziiert ist. Damit sind alle Attribute des Objekts
definiert, bevor der eigentliche Body des Konstruktors läuft, so wie das bei
final
-Attributen in einer immutable-Klasse gefordert ist - bis auf die map.
Um die Map zu generieren, brauchen wir Informationen aus den anderen Feldern und
daher können wir die Map erst im Body definieren. Das führt dazu, dass wir bei
der Attribitsdeklaration das Keyword late
hinzufügen mussten. Das heißt
übersetzt "ja, dieser Wert kommt später. Ich übernehme die Verantwortung dafür,
dass der Wert auf jeden Fall initialisiert wurde - und zwar nicht auf null
,
gib mir nur etwas Zeit".final newMap = <Position, Block>{};
eine neue Map an, also
eine Zuordnung von Werten zu anderen Werten, hier wird Positionen jeweils ein
Block zugeordnet. Das ist die Map, mit der wir uns schon im letzten Abschnitt
beschäftigt haben.
Wir richten uns zwei Felder ein, leftMines
(für die Anzahl der noch zu platzierenden
Minen im Spielfeld, was logischerweise mit der Anzahl der Minen beginn) und
leftFields
(für die Anzahl der noch zu instanziierenden Blöcke, was logischertweise
am Anfang die Anzahl der Spalten mal die Anzahl der Zeilen ist).final rng = math.Random();
instanziieren wir einen Zufallszahlengenerator,
der aus der math-Bibliothek von dart kommt (siehe Imports am Anfang der Datei).
Da die Math-Bibliothek auch eine Logarithmus-Funktion enthält und diese mit
log
bezeichnet ist, ich aber ein Freund der log
-Funktion (aus der developer-
Bibliothek von Dart) bin, die Logausgaben im Debugmodus produziert, sind
log
-Befehle nicht mehr eindeutig definiert. Aus diesem Grund wurde beim
Import der math-Bibliothek (oben gucken) diese mit dem Begriff math
assoziiert und hier
wird dann jedem Verweis auf die Bibliothek ein math.
vorangestellt. Das ist
einfach eine Reaktion auf eine Fehlermeldung, als ich auch log
-Ausgaben in der
Klasse hatte.col
hoch und produziert soviele Durchgänge wie es Spalten gibt.row
hoch und produziert so viele Durchgänge
wie es Reihen gibt.final pos = Position(col, row);
die dem Schleifendurchlauf
entsprechende Position, also von (0/0) bis (Anzahl Spalten -1 / Anzahl Zeilen - 1)..nextInt(100)
erwürfelt nie
die 100, sondern eben 0 bis 99, zufällig).newMap
fügen wir jetzt jeweils einen neuen Eintrag hinzu, also
die Zuordnung der Position (die sich aus der Schleife ergibt) zu einem
Block, den wir in diesem Moment auch instanziieren, mit Mine oder ohne, je
nach vorherigen Berechnungen.late
initialisiert worden und ist nicht null
, womit dann auch alles okay ist.Wir haben jetzt einen Zwischenstand erreicht, bei dem die Daten einigermßan passen, aber die App kann noch nicht viel, wie folgender Screenshot beweist. :-)
Der Code führt das Projekt ist weiterhin unter AUSoftAndreas/flutter-minesweeper abrufbar. Der für diesen Tutorial-Abschnitt relevante Branch ist "step_b". Wechselt auf diesen Branch.
Bei manchen meiner / unserer Projekte nutzen wir eine Library namens Bloc im Hintergrund, die als Mittler zwischen Daten und GUI funginert (das schon mehrfach angesprochene State Management). Aus persönlichen Lerngründen habe ich mich für Minesweeper dazu entschieden, mit einer relativ neuen Lösung namens Riverpod zu arbeiten, einfach weil ich das ausprobieren wollte.
Im Endeffekt führt jedes State Management System dazu, dass man reativ bequem mit sich veränderndem State arbeiten kann. Ungefähre Grundidee von diesen Systemen ist:
StatefulWidget
lernt, dann kann
es sein, dass Ihr diese nicht notwendig habt, wenn Ihr Bloc oder Riverpod nutzt.Das StatefulWidget
war eine für den sogenannten local State. Local State meint
den State eines GUI-Bildschirms. Es ging darum, dass Benutzereingaben ja zu
Änderungen dessen führen können, was auf genau dem Screen, auf dem der
Benutzer etwas getan hat, zu Änderungen führt. Beispielsweise habt Ihr einen
Button, mit dem der User zwischen Day und Night Mode der App switchen kann.
Natürlich wollt Ihr, dass ein Klick des Users dann dazu führt, dass auf einmal
alles dunkler ist. Also muss die GUI neu gerendert werden. Das war das
Aufgabengebiet des StatefulWidget
. Ein Problem war aber, wenn das, was den
State ändert, von außen kommt. Bibliotheken wie bloc oder Riverpod nehmen einem
den Aufwand ab, das im Detail zu arrangieren, dass zum Beispiel irgendwelche
neuen Daten erstmal jedem GUI-Screen einzeln bekannt gemacht werden müssen.
Ausgehend von den Beschreibungen auf der Riverpod-Homepage habe ich erstmal das Riverpod-Package und das damit zusammenhängende "Flutter-Hooks"- Package in unser Projekt integriert. Das ist recht einfach und geschieht über die pubspec.yaml-Datei im Stammverzeichnis.
Riverpod musste auch installiert werden. Die Installation von Libraries ist erdenklich einfach. Dazu musste einach die /pubspec.yaml geändert werden. In dieser Datei werden alle Libraries gelistet, die zum Einsatz kommen, zusammen mit ihren Versionsnummern. Der interessante Abschnitt ist dieser:
environment:
sdk: ">=2.12.0-0 <3.0.0"
dependencies:
flutter:
sdk: flutter
flutter_hooks: ^0.16.0-nullsafety.0
hooks_riverpod: ^0.13.0-nullsafety.3
Der sdk-Eintrag sagt, welche Version des Dart SDK (Software Development Kit) zum Einsatz kommen wird. Wenn eine Version größer als 2.12 da steht, dann wählt man damit automatisch aus, dass man das neue Dart-Feature null-safety verwenden will. Dazu folgt irgendwann ein Artikel im Wiki, aber im Wesentlichen kann ich das zusammenfassen als:
null
initialisiert. Das ist der Wert,
den sie hat, wenn an ihr nicht einen anderen zuweist.null
ist insofern problematisch, als dass man mit diesem Wert vieles nicht
machen kann und daher pausenlos sogenannte null-Checks durchführen muss. Beispiele
(Länge eines Strings bestimmen, aber im String steht null
oder eine Rechenoperation
durchführen, aber eine der Zahlen ist null
). Das ergibt jeweils eine Fehlermeldung
und deswegen gibt es überall Checks a la if(variable == null)
.null
ist.null
als
Wert haben dürfen und solchen, die es nicht dürfen. Eine normal bezeichnete
Variable (String
, int
, double
) darf kein null
enthalten. Eine Variable, die
null
enthalten kann, wird mit ? gekennzeichnet (String?
, int?
, double?
).int a;
a = 3;
In diesem Fall hätte a
zumindest für einen kurzen Moment den Wert null
und
das ist nicht zulässig.
Also, durch den Eintrag in der pubspec.yaml
sagte ich, dass ich mich den Regeln
der null-Safety unterwerfen werde. Das war von Anfang an der Fall, weil man
ja schon im letzten Tutorial-Abschnitt sah, dass ich an manchen Stellen mit ?
hinter dem Variablentyp gearbeitet habe.
Außerdem sage ich in der pubspec.yaml
ebenfalls, dass zwei
Libraries / Packages installiert werden.
Die Versionsnummern kann man jeweils den Webseiten der Packages entnehmen. Ich nahm jeweils die neueste Version, die aber ebenfalls null-safe sein muss. Ihr könnt Euch auf pub.dev ja auch einfach zum Spaß mal ansehen, was es alles für Packages gibt.
Ich hab zusätzlich die Datei /analysis_options.yaml
aus unserem Tetris-Projekt
kopiert, damit ich auch die vielen Code-Verbesserungsvorschläge bekomme, Dokumentationen
nicht vergesse, etc.
Aufgrund der Installation von Riverpod folgte ich dessen Turorial. Irgendwo, mmöglichst weit oben im Widget-Tree muss man einen Provider-Scope definieren, also ab wo der State bekannt sein soll. Das geschieht einfach, indem MyApp, das ja die Oberklasse für alles ist, noch eine Stufe oben drauf bekommt.
void main() {
runApp(
ProviderScope(
child: MyApp(),
),
);
}
Außerdem muss irgendwo festgelegt werden, welche Provider von State es gibt.
Bei Riverpod braucht man dafür eine globale Variable, die eine Referenz auf
das das StateNotifier-Objekt enthält, das den State verwaltet. Dazu später mehr.
Dafür habe ich eine eigene Datei /lib/notifiers/_notifier.dart
angelegt, die
nur den folgenden Inhalt hat:
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:minesweeper/notifiers/field_notifier.dart';
/// Global variable holding a reference to our state object
final globFieldNotifier = StateNotifierProvider((ref) => FieldNotifier());
Folglich lässt sich dann über die Variable globFieldNotifier
jederzeit ein
Verweis auf das zuständige Objekt der FieldNotifier-Klasse finden. Das soll
jetzt echt nicht so klingen, als müsse man das verstehen. Ich folge hier auch
blind einem Tutorial. Es sind einfach Voraussetzungen, die man schaffen muss,
um das Riverpod-Package zu verwenden. :-)
Aber im Kern gitl einfach, dass hier ein FieldNotifier
-Objekt erstellt wird und
die Referenz darauf ist folglich immer abrufbar über die Variable. Das
FieldNotifier
-Objekt bzw. die Klasse habe ich geschrieben, zu der kommen wir
also noch, aber die Grundidee ist einfach: Dieses Objekt wird, solange die
App läuft, unseren State verwalten. Sie nimmt Nachrichten aus dem GUI entgegen
und sie gewöhrt dem GUI Zugriff auf Informationen aus dem State der App (also
im Wesentlichen Auskünfte über den Zustand des Spielfelds). Dadurch, dass jeder
Zugriff über sie läuft, ist sichergestellt, dass auch immer die GUI-Updates
ausgelöst werden, wenn ein neuer State vorliegt.
Ja, jetzt haben wir so lange über Riverpod gesprochen und über StateNotifier als zentralen Hub für alle Interaktionen mit der GUI. Das muss ja eine wahnsinnig komplexe Klasse sein, oder? Nunja, hier der Code:
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:minesweeper/models/field.dart';
/// The FieldNotifier is the connection between GUI and state. All state
/// changes go through FieldNotifier. It extends StateNotifier which handles
/// all GUI updates. Part of Riverpod.
class FieldNotifier extends StateNotifier<Field> {
/// Constructor (from Riverpod tutorial)
FieldNotifier() : super(Field());
/// Creates a new playing field ... just by instantiating a new state
/// with a new playing field
void create({required int numRows, required int numCols, required int mines}) {
state = Field.withArguments(numRows: numRows, numCols: numCols, mines: mines);
}
}
Eigentlich passiert weniges, wie man sieht. Ich bin auch nur dem Tutorial gefolgt bei der Klassendefinition. Die einzige Ergänzung ist der Name und die Klasse des zuständigen State-Objektes, was ja in unserem Fall Field ist.
Wirklich geschrieben habe ich dann lediglich die erste Interaktionsmöglichkeit.
Man kann ein neues Spielfeld generieren mit der Methode create. Es lohnt sich,
kurz darüber zu sprechen, wie es funktioniert. Die Methode ruft offensichtlich
den Konstruktor Field.withArguments
auf, über den wir schon redeten. Dieser
konstruiert ein komplettes Spielfeld. Und dieses neue Spielfeld wird dann
der Variablen state zugewiesen (bzw. dem Attribut). Aber wir haben doch gar kein
Attribut state definiert? Genau. Aber unsere Klasse hier extends StateNotifier
.
Und das heißt, dass sie alle Methoden und Attribute von StateNotifier erbt und
StateNotifier hat eben ein Attribut namens state, das wir somit auch haben.
Ich habe keinerlei Ahnung, wie Riverpod intern funktioniert, aber die folgende Erklärung ist für mich hinreichend sinnvoll:
StateNotifier
hat eigentlich ein privates Feld _state
, hat aber auch Setter
und Getter dafür definiert. Durch den Getter erhalten wir dann den Inhalt
dieses privaten Attributs. Wichtiger noch: Die GUI kann über uns den State
abfragen und ihn darstellen.Field
-Objektes.
Unser Spielfeld ist also der State._state
implementiert. Das heißt wir schreiben
einen State in das Attribut _state
(private) durch den Setter state
(public).
Und dieser Setter wurde so geschrieben, dass - wann immer wir ihn nutzen, also
einen neuen State setzen, er automatisch auch die GUI informiert, dass sie sich
jetzt neu zu bauen hat.state
einen neuen
Wert zuweisen, wird automatisch die GUI upgedated.Alle anderen Änderungen, die sich durch den Einbau von Riverpod ergaben, sind
gering. Im Wesentlichen führte es dazu, dass sowohl in main(), als auch im
StartScreen das oberste Widget jetzt ein HookWidget
ist, kein StatelessWidget
mehr. HookWidgets sind eben auch ein Teil von Riverpod, und es wird darauf
hinauslaufen, dass durch diese Widgets Riverpod Zugriff auf die GUI bekommt.
Wir haben immer noch keine wirkliche GUI, nur einen leeren Bildschirm mit einem IconButton in der Mitte. Wir haben aber schon komplexe Datenstrukturen geschrieben und dabei können uns Fehler unterlaufen sein. Deswegen generieren wir jetzt einige Tests, um das richtige Verhalten unserer Daten sicherzustellen.
Dazu existiert nun eine Datei /lib/test/field_test.dart
. Dart ist so aufgebaut, dass
es alle Dateien mit _test am Ende des Dateinamens nicht als normalen Teil der
App betrachtet, sondern als separat ausführbare Tests. Schauen wir in den Code:
import 'package:flutter_test/flutter_test.dart';
import 'package:minesweeper/notifiers/field_notifier.dart';
void main() {
test('Field should be created and have the right number of fields/mines', () {
final fieldNotifier = FieldNotifier();
// ignore: cascade_invocations
fieldNotifier.create(numRows: 10, numCols: 10, mines: 20);
// ignore: invalid_use_of_protected_member
final state = fieldNotifier.state;
var numMines = 0;
var numFields = 0;
var numFlags = 0;
state.map.forEach((pos, block) {
numFields++;
if (block.mine) numMines++;
if (block.flagged) numFlags++;
});
expect(numMines, 20);
expect(numFields, 100);
expect(numFlags, 0);
});
}
Zuerst wird ein Test definiert mit frei wählbarer Beschreibung. Der Test selbst wird als anonyme Funktion definiert. Wie sieht unser Test aus?
Wir instanziieren einen FieldNotifier, was ja normal unsere App auch macht. Wir
geben diesem den Befehl, ein neues Spiel zu kreieren mit einer festgelegten
Anzahl an Minen und Feldern. Danach lesen wir den state aus. Das sollte jetzt
genau das instanziierte neue Spielfeld sein, da der state
immer den aktuellen
Stand produziert. Nun gehen wir durch die Map, die Positionen und Blöcke
miteinander verbindet und über die wir schon redeten. Wir gehen alle Blöcke
durch und zählen die Minen und Flags.
Am Ende sagen wir, welchen Wert wir eigentlich erwarten, wenn die Klasse richtig funktioniert. Wenn das nicht stimmt, dann schlägt unser Test fehl.
In VSCode gibt es die Schaltfläche Test. Hier sieht man, dass VSCode unseren definierten Test erkannt hat. Und wir können diesen (oder alle) Test laufen lassen. Wenn wir es tun, erhalten wir die Info, dass die Tests erfolgreich durchlaufen wurden.
Wir haben eine App, die immer noch nix kann und nach nix aussieht, aber es gibt zumindest einige Datenmodelle, die auch zu funktionieren scheinen. Jetzt wird es Zeit, dass wir auch was sehen.
Der Code führt das Projekt ist weiterhin unter AUSoftAndreas/flutter-minesweeper abrufbar. Der für diesen Tutorial-Abschnitt relevante Branch ist "step_c". Wechselt auf diesen Branch.
Wir bekommen in dieser Lektion endlich eine GUI. Das heißt, dass wir wirklich etwas sehen und dann natürlich auch an der Game Logik arbeiten können.
Eine Voraussetzung ist aber noch zu schaffen: Wir müssen den neuen Screen auch
erreichen können. Zuerst gilt es, in der main.dart
eine neue Route für den
neuen Screen anzulegen.
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:minesweeper/ui/match_screen.dart';
import 'package:minesweeper/ui/start_screen.dart';
void main() {
runApp(
ProviderScope(
child: MyApp(),
),
);
}
/// This class is the central class of the app, it starts everything else
class MyApp extends HookWidget {
@override
// ignore: prefer_expression_function_bodies
Widget build(BuildContext context) {
return MaterialApp(
routes: {
'/': (context) => StartScreen(),
'/match': (context) => MatchScreen(),
},
initialRoute: '/',
);
}
}
Neu ist nur, dass jetzt zwei Routes definiert sind.
Und wenn wir auf diesen IconButton auf unserem StartScreen klicken, wollen wir zum Spiel kommen (es muss aber auch ein Spiel vorbereitet werden). Das heißt, dass wir hier zum ersten Mal wirklich Riverpod nutzen und uns auf diesem Weg Kontrolle über den State geben.
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:minesweeper/notifiers/_notifiers.dart';
/// StartScreen of the app
class StartScreen extends HookWidget {
@override
Widget build(BuildContext context) {
final fieldNotifier = useProvider(globFieldNotifier);
return Material(
child: Container(
child: Center(
child: IconButton(
icon: const Icon(Icons.ac_unit),
onPressed: () {
fieldNotifier.create(numRows: 10, numCols: 10, mines: 20);
Navigator.pushNamed(context, '/match');
},
),
),
),
);
}
}
Dank Provider bekommen wir durch die einfache Zeile final fieldNotifier = useProvider(globFieldNotifier);
Zugriff
auf das zuständige FieldNotifier-Objekt. Den brauchen wir im onPressed des IconButtons.
Durch den Aufruf der Methode Navigator.pushNamed(context, '/match');
wechseln
wir den Screen zum Match-Screen. Der Navigator ist ein durch Flutter bereitgestelltes
Tool, mit dem wir durch die App navigieren können.
Nun kommt Euer erster wirklich umfangreicher Flutter-Screen und er wird Euch erschlagen auf den ersten Blick. Bleibt dabei und versucht, Euch nach und nach einzufinden in den Code.
Wir machen das hier ausnahmsweise abschnittsweise:
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:minesweeper/models/block.dart';
import 'package:minesweeper/models/field.dart';
import 'package:minesweeper/models/position.dart';
import 'package:minesweeper/notifiers/_notifiers.dart';
import 'package:minesweeper/notifiers/field_notifier.dart';
/// Central GUI Screen. Represents a playing field and handles user input
class MatchScreen extends HookWidget {
@override
Widget build(BuildContext context) {
final fieldNotifier = useProvider(globFieldNotifier);
final field = useProvider(globFieldNotifier.state);
final height = MediaQuery.of(context).size.height * 0.8;
final width = MediaQuery.of(context).size.width;
final max = height > width ? width : height;
final orientation = MediaQuery.of(context).orientation;
Die Klasse heißt MatchScreen
und wie das bei Riverpod üblich ist, ist es ein
HookWidget
. Das ermöglicht uns, über die Methode useProvider()
jeweils auf den
von uns definierten FieldNotifier
und auf seinen State, also das Field
zuzugreifen.
Das wurde ja gesagt, dass das die zentrale Aufgabe von Riverpod ist. Wie das
erreicht wird, kann uns prinzipiell egal sein. Das sind also nur aus der
Anleitung abgeschriebene Codezeilen.
Danach beziehen wir Höhe und Breite des Bildschirms (bzw. Fenster) in Pixeln.
Diese Angaben verwenden wir später beim Aufbau. Auch hier: Wann immer man sowas
braucht, googelt man dann "flutter find out screen size". Mittels ternärem Operator
(also die a ? b : c
-Operation) legen wir fest, was die größere der beiden
Auflösungen ist und wir merken uns die gegenwärtige Orientierung des Geräts
(das ist entweder Portrait oder Landscape).
Und nun kann die GUI-Definition beginnen:
return Material(
child: Container(
color: field.gameLost ? Colors.red[700] : Colors.grey[300],
height: height,
Das Material-Widget wurde von diversen anderen Widgets ausdrücklich verlangt (per Fehlermeldung). Was es macht, kann man nachlesen. Ich verstehe es so, dass den Look der GUI beeinflusst. Nutzte man es nicht, hab es eine Fehlermeldung. Deswegen nutze ich es. :-)
Danach kommt ein Container. Dieser Container umfasst im Prinzip unsere gesamte GUI. Die Breite ist automatisch auf die Bildschirmbreite gesetzt. Die Höhe habe ich manuell auf die oben festgelegte Höhe beschränkt. Der Container wird grau gefärbt, wenn das Spiel läuft, und rot, wenn das Spiel verloren ist. Der Container ist eigentlich unser Hintergrund für den Bildschirm.
child: Flex(
direction: orientation == Orientation.landscape ? Axis.horizontal : Axis.vertical,
children: [
Innerhalb unseres Hintergrund-Containers wird dann ein Flex-Widget gesetzt. Ein Flex-Widget ist im Wesentlichen eine Art, andere Widgets entweder vertikal (untereinander) oder horizontal (nebeneinander) anzuordnen. In diesem Fall machen wir die Richtung abhängig von der Orientierung des Geräte. Sind wir im Landscape-Modus, dann werden wir die folgenden Elemente nebeneinander anorden, sonst übereinander.
Man beachte: Ein Flex
-Widget hat nicht, wie die meisten Widgets, ein Child,
sondern mehrere Children. Das ist ja auch sinnig, wenn man drüber nachdenkt:
Wenn das Flex
-Widget Widgets unter- oder nebeneinander anordnet, dann muss
es dazu auch mehr als ein Widget habe. Das Flex
-Widget hat noch ein paar
Besonderheiten, aber im Wesentlichen verhält es sich wie entweder eine Spalte
oder eine Zeile, je nachdem wie das Attribut direction
festgelegt wurde.
Da es mehrere Kinder sein können, wird hinter "children" nicht direkt das nächste Objekt instanziiert, sondern die eckigen Klammern heißen eindeutig: Jetzt folgt eine Liste von Elementen. Direkte Kinder des Flex sind folglich alle Elemente der Liste auf der nächsten Ebene in Sachen Einrückung.
Expanded(
child: GridView.count(
crossAxisCount: field.numCols,
children: _buildBlocks(field, fieldNotifier),
),
),
Flex(
direction: orientation == Orientation.landscape ? Axis.vertical : Axis.horizontal,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.all(max > 500 ? max * 0.05 : 25),
child: Container(
decoration: BoxDecoration(
color: field.flagMode ? Colors.green : Colors.transparent,
border: Border.all(),
),
child: IconButton(
icon: const Icon(Icons.flag),
iconSize: max > 500 ? max * 0.1 : 50,
onPressed: () {
fieldNotifier.toggleFlagMode();
},
),
),
),
Padding(
padding: EdgeInsets.all(max > 500 ? max * 0.05 : 25),
child: Container(
decoration: BoxDecoration(
border: Border.all(),
),
child: IconButton(
icon: const Icon(Icons.replay),
iconSize: max > 500 ? max * 0.1 : 50,
onPressed: () {
fieldNotifier.create(numRows: 10, numCols: 10, mines: 20);
},
),
),
),
],
),
Das ist zwar viel Code, aber wenn Ihr genau guckt, dann erkennt Ihr, dass unser Flex direkt zwei Kinder hat. Das eine ist der Expanded-Block, das andere ist ein erneuter Flex-Block. Alles andere sind wiederum Nachkommen dieser Widgets.
Wenden wir uns diesen beiden Blöcken einzeln zu.
Expanded(
child: GridView.count(
crossAxisCount: field.numCols,
children: _buildBlocks(field, fieldNotifier),
),
),
Ein Expanded-Widget
sorgt dafür, dass sein Child innerhalb eines Flex
-Layouts den maximal verfügbaren
Platz einnimmt und entsprechend alle anderen Children des Flex
nur noch den
jeweils minimalen Platz, den sie halt benötigen, bekommen. Wir wollen also
VIEL Platz für das, was danach kommt und das ist hier ein
GridView-Widget.
Ein GridView
ist ein Gitter von Elementen, ganz ähnlich dem CSS-Grid, das Ihr
auch schon kennt. Sprich: Die nachfolgenden Elemente sortieren sich in
Spalten und Zeilen ein. Wir haben für unser GridView
einen besonderen Konstruktor
genutzt (wir erinnern uns daran, dass man in Dart mehrere Konstruktoren
definieren kann, mit dem Klassennamen und dann ein Punkt, genau wie hier).
Der GridView.count
-Konstruktor erlaubt uns, unser Layout allein mit Hilfe eines
crossAxisCount
-Attributs festzulegen.
Da stellt sich die nächste Frage ... Was ist ein crossAxisCount
? Grundsätzlich,
an vielen Stellen, unterscheidet Flutter zwischen der MainAxis
(Hauptachse) und
der CrossAxis
(Querachse). Das hängt vom Umfeld ab. Einem Grid kann man sagen,
in welche Richtung es bei Bedarf (also sehr viele Widgets) scrollen soll.
Standard ist, dass ein Grid von oben nach unten scrollt. Die MainAxis
ist nun
gleichzusetzen mit der Scroll-Richtung. Und die CrossAxis
ist die Achse, die
quer zur Scroll-Richtung liegt. Unser GridView
scrollt von oben nach unten, also
ist die CrossAxis
von links nach rechts. Mit crossAxisCount
teilen wir dem
GridView
einfach mit, wie viele Items es nebeneinander darstellen soll. Und wie
viele Items nebeneinander sollen es sein? So viele wie es Spalten in unserem
Spielfeld gibt.
Die Items selbst übergeben wir normalerweise einfach als Liste im children-Attribut,
so wie bei dem Flex
auch. Hier gehen wir einen anderen Weg, indem wir einfach
eine Methode namens _buildBlocks
aufrufen, die dann die entsprechenden Widgets,
eines für jedes Element unseres GridViews produziert.
Anstatt jetzt streng weiter von oben nach unten im Code uns zu bewegen, folgen
wir jetzt mal diesem Funktionsaufruf und schauen uns die Funktion _buildBlocks
an.
List<Widget> _buildBlocks(Field field, FieldNotifier fieldNotifier) {
final widgets = <Widget>[];
for (var row = field.numRows - 1; row >= 0; row--) {
for (var col = 0; col < field.numCols; col++) {
final block = field.map[Position(col, row)];
if (block != null) {
widgets.add(
InkWell(
onTap: () {
fieldNotifier.handleClick(block);
},
child: _buildBlock(block),
),
);
}
}
}
return widgets;
}
_buildBlocks
ist eine private Methode, also nicht von außerhalb unserer Klasse
aufrufbar. Damit wir Zugriff auf das Spielfeld bzw. den FieldNotifier
haben, lassen
wir uns diese als Argumente mitgeben. Der Rückgabewert von _buildBlocks
ist eine
List von Widgets - und das passt ja auch zu dem Punkt, an dem wir die Funktion
aufgerufen haben. Wir waren ja innerhalb des children-Attributs von GridView, also
waren wir an einer Stelle, wo ja eine Liste von Widgets verlangt wird.
Okay, was macht _buildBlocks
nun? Es baut wieder zwei verschachtelte Schleifen und
produziert so jede mögliche Adresse auf unserem Spielfeld. Da unser GridView
zwar
von links nach rechts die Items gerne hätte, aber eben auch von oben nach unten,
gleichzeitig unsere Art der Nummerierung von Positionen aber zwar von links nach
rechts, zugleich aber von unten nach oben verläuft (wir hatten Zeile 0 als die
unterste Zeile definiert), müssen wir die row-Schleife etwas anders als gewohnt
ausbauen.
Wenn wir also eine Position generiert haben, dann prüfen wir anhand der Map in
unserer Field
-Instanz, ob zu dieser Position
in unserem Field ein Block
existiert.
Wenn einer existiert, dann erweitern wir unsere Liste von Widgets, die wir
am Ende returnen um ein InkWell-Widget.
Ein InkWell
-Widget empfängt Clicks (bzw. Bildschirm-Tappen) und sorgt dafür, dass
der User ein optisches Feedback darauf erhält, indem eine Art Tintenklecks auf
dem darunter liegenden Widget angezeigt wird (kurz nur) und das Widget kurz
aufgehellt wird. Außerdem können wir noch eine Funktion hinterlegen, die das
beinhaltet, was geschehen soll, wenn jemand klickt.
In unserem Fall sagen wir, dass die Methode handleClick
des FieldNotifiers
aufgerufen werden soll und wir melden ihm, in welchem Block das geschah. Das
Child des InkWell
ist also das Objekt, was man eigentlich sieht (ein InkWell
ist sozusagen unsichtbar, solange nicht geklickt wurde). In unserem Fall wurde
das produzieren dieses Inhalts wieder ausgelagert an eine weitere Methode
_buildBlock
, die für jeden einzelnen Block aufgerufen wird. Folgen wir auch
hier weiter der Logik.
Widget _buildBlock(Block block) {
var color = Colors.transparent;
if (block.open && !block.mine) {
color = Colors.white;
}
return Container(
decoration: BoxDecoration(
border: Border.all(),
color: color,
),
child: FittedBox(
child: Center(
child: _buildBlockCenter(block),
),
),
);
}
Für jeden Block läuft diese Funktion einmal. Zuerst wird eine Farbe festgelegt auf transparent, aber wenn der Block bereits geöffnet wurde und da keine Mine liegt, dann wird die Farbe auf weiß umgestellt. Wo nutzen wir die Farbe?
Nun, letztlich returnen wir aus dieser Funktion eine
Container-Widget.
Dieser Container
hat als decoration
ein Box-Decoration.
Das ist ein Weg, um einem Container
mehr Optik zu verleihen. Man kann hier
Farben definieren, aber eben auch Rahmen und dergleichen. Wir verleihen ihm
einen Rahmen auf allen Seiten. Der Container
bekommt außerdem die zuvor
festgelegte Farbe.
In dem Container
(als child) ist dann wiederum ein
FittedBox-Widget.
Ein FittedBox
-Widget sorgt dafür, dass sein Child gegebenenfalls gedehnt oder
gestaucht wird, um in die Box zu passen. Nit anderen Worten: Egal, welche
Icons oder Texte wir nun da rein schreiben... Sie werden so skaliert, dass
sie den Block füllen.
Für den Fall, dass der Inhalt nicht exakt passt, wird er zudem mit dem
Center-Widget innerhalb
des Feldes zentriert. Was genau da zentriert wird, haben wir erneut in eine
Methode ausgelagert, nun namens _buildBlockCenter
.
Widget _buildBlockCenter(Block block) {
var color = Colors.black;
if (block.flagged) {
return const Icon(Icons.flag);
} else if (!block.open) {
return const Text('');
} else if (block.mine) {
return const Text('M');
} else {
if (block.closeMines <= 2) {
color = Colors.green;
} else if (block.closeMines <= 4) {
color = Colors.blue;
} else {
color = Colors.red;
}
return Text(
block.closeMines == 0 ? '' : block.closeMines.toString(),
style: TextStyle(
color: color,
),
);
}
}
Es wird wieder eine Standardfarbe (schwarz) festgelegt. Eigentlich passiert hier nichts Komplexes mehr... Je nachdem, wie der Status des Feldes ist (geflagged, geöffnet, mit Mine oder ohne und Anzahl der Minen im Umfeld wird irgendwas in den Block geschrieben.
Zusammengenommen ist das oberste Expanded
-Widget also dafür zuständig dafür, dass
gesamte Spielfeld darzustellen.
Kommen wir zurück zum Anfang. Wir hatten also ein Flex
, das mal in Längs- und
mal in Querrichtung läuft, je nach Handy-Orientierung. Konzentrieren wir uns
mal auf den Landscape-Modus. Dann ist das Flex
horizontal aufgebaut. Links wäre
unser zuvor besprochenes Expanded
-Widget, was das eigentliche Spielfeld
darstellt, wie wir uns gerade erarbeitet haben.
Rechts davon wäre jetzt ein erneutes Flex-Widget.
Wir wissen schon, dass ein Flex
-Widget eine Art Zeile oder Spalte definiert, in
der mehrere Widgets übereinander oder nebeneinander platziert werden. Ob über- oder
nebeneinander ergibt sich aus dem Attribut direction
. Hier machen wir es
wieder von der Orientierung des Geräts abhängig. Wenn wir im Landscape-Modus sind, dann
haben wir also links das Spielfeld und daneben eine Spalte mit meherern Elementen
untereinander. Im Portrait-Modus haben wir oben das Spielfeld und darunter eine
Zeile mit mehreren Elementen.
Flex(
direction: orientation == Orientation.landscape ? Axis.vertical : Axis.horizontal,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
Durch das Zusammenspiel von MainAxisSize.min
und MainAxisAlignment
erreichen wir,
dass die Elemente in unserer Spalte/Zeile eng beieinander gehalten und in der
Mitte der Zeilen / Spalte gehalten werden.
Kommen wir abschließend zu dem, was da unter- oder nebeneinander dargestellt werden soll.
Flex(
direction: orientation == Orientation.landscape ? Axis.vertical : Axis.horizontal,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.all(max > 500 ? max * 0.05 : 25),
child: Container(
decoration: BoxDecoration(
color: field.flagMode ? Colors.green : Colors.transparent,
border: Border.all(),
),
child: IconButton(
icon: const Icon(Icons.flag),
iconSize: max > 500 ? max * 0.1 : 50,
onPressed: () {
fieldNotifier.toggleFlagMode();
},
),
),
),
Padding(
padding: EdgeInsets.all(max > 500 ? max * 0.05 : 25),
child: Container(
decoration: BoxDecoration(
border: Border.all(),
),
child: IconButton(
icon: const Icon(Icons.replay),
iconSize: max > 500 ? max * 0.1 : 50,
onPressed: () {
fieldNotifier.create(numRows: 10, numCols: 10, mines: 20);
},
),
),
),
],
),
Beide children sind ähnlich aufgebaut. Erst kommt ein
Padding-Widget.
Ein Padding-Widget produziert einfach Abstand zu anderen Objekten, ähnlich wie
das Padding im CSS. Padding
ist ringsherum (um das Child) mindestens 50 Pixel
groß und wächst danach in Abhängigkeit von der ScreenSize.
Innerhalb des Paddings ist wieder ein altbekanntes Container
-widget. Der Container
hat eine Linie außenrum und im Falle des ersten Buttons wird er grün
eingefärbt, falls man sich derzeit im flag-Modus befndet.
Innerhalb des Container
sitzt dann ein IconButton-Widget,
einmal mit einem Flagge-Icon und einmal mit einem Reload-Icon. Außerdem wurde
jeweils eine Funtion unterlegt, die ausgeführt wird, wenn man den entsprechenden
Button drücktt.
Im nächsten Teil des Tutorials kümmern wir uns um die Spiellogik. So ein bisschen ist die ja schon angeklungen, weil die GUI stellenweise Methoden des FieldNotifiers aufgerufen hat, die wir noch nicht besprochen haben.
Der Code führt das Projekt ist weiterhin unter AUSoftAndreas/flutter-minesweeper abrufbar. Der für diesen Tutorial-Abschnitt relevante Branch ist "step_d". Wechselt auf diesen Branch.
Ich kann gar nicht genug betonen, dass Programmieren immer auch Ausprobieren und Experimentieren bedeutet. Man muss nicht für alles einen großen Plan haben. auch wenn er oft hilft.
Daher hab ich einiges umgestrickt in diesem Teil des Projektes, einfach weil ich mehr über Riverpod lernte - und vor allem lernte, was nicht so funktionierte, wie ich es wollte. :-)
Ich wollte, dass man auswählen kann, wie groß das Spielfeld ist. Aus diversen Gründen habe ich mich dazu entschieden, die folgenden drei Settings festzulegen:
Für jeden dieser Werte setzte ich zudem einen Minimal- und Maximalwert fest und
definierte diese als Konstanten. Alles zusammen kam dann in die Klasse
Settings
in /lib/models/settings.dart
. Um ehrlich zu sein habe ich vorher
eine Weile versucht, das einfach irgendwie als globale Variablen zu speichern
und diese per Riverpod zu verwalten - und irgendwie hat es nicht geklappt.
Allerdings ist das Ergebnis auch besser und übersichtlicher.
Der Code der Klasse ist wenig spektakulär
import 'package:flutter/foundation.dart';
@immutable
/// General settings of the app
class Settings {
/*-----------------------------------------------------------------------------\
| Constants |
\-----------------------------------------------------------------------------*/
/// Standard number of columns
static const stdCols = 10;
/// Minimum number of columns
static const stdColsMin = 4;
/// Maximum number of columns
static const stdColsMax = 30;
/// Standard number of rows
static const stdRows = 10;
/// Minimum number of rows
static const stdRowsMin = 4;
/// Maximum number of rows
static const stdRowsMax = 30;
/// Standard percentage of mines on field
static const stdMinePercentage = 15;
/// Minimum percentage of mines on field
static const stdMinePercentageMin = 5;
/// Maximum percentage of mines on field
static const stdMinePercentageMax = 50;
/*-----------------------------------------------------------------------------\
| Attributes |
\-----------------------------------------------------------------------------*/
final int _rows;
final int _cols;
final int _minePercentage;
/*-----------------------------------------------------------------------------\
| Getters / Setters for attributes |
\-----------------------------------------------------------------------------*/
int get rows => _rows;
int get cols => _cols;
int get minePercentage => _minePercentage;
/*-----------------------------------------------------------------------------\
| Constructors |
\-----------------------------------------------------------------------------*/
/// Standard constructor, only constructs a fixed standard game
const Settings()
: _rows = stdRows,
_cols = stdCols,
_minePercentage = stdMinePercentage;
/// Internal constructor, used by copyWith method
const Settings._internal({required int rows, required int cols, required int minePercentage})
: _rows = rows,
_cols = cols,
_minePercentage = minePercentage;
/*-----------------------------------------------------------------------------\
| Methods |
\-----------------------------------------------------------------------------*/
/// Copies the current settings into a new Settings object, changing values in that moment
Settings copyWith({int? rows, int? cols, int? minePercentage}) => Settings._internal(
cols: cols ?? _cols,
rows: rows ?? _rows,
minePercentage: minePercentage ?? _minePercentage,
);
}
Auch wenn es lang ist ... Es sind wahrlich nicht viele Überraschungen darin. Diesen
Code solltet Ihr inzwischen weitestgehend verstehen. Bisher haben wir nicht
mit Konstanten gearbeitet. Es sind festgelegte Werte, die einen Vorteil haben:
Wenn man sie an diversen Stellen nutzt und dann im Nachhinein doch einen anderen
Wert festlegt, dann muss man nicht an zig Stellen suchen, wo man noch den falschen
Wert stehen hat. Alle Informationen liegen hier in der Klasse beisammen. Und wenn
wir hier den Wert von bspw. stdMinePercentageMax
ändern, dann ist er überall
im Code automatisch anders.
Neu dürfte nur sein, dass alle Konstanten ein static
vorweggestellt bekamen, und
das ist wieder einen kleinen Ausflug wert.
Kernelement der objektorientierten Programmierung ist, dass die Daten und
Methoden in Klassen definiert werden, aber dann später sich auf Objekte, also
eine einzelne Instanz dieser Klasse beziehen. Wir legen also beispielsweise fest,
dass die Klasse Schueler
in unserer Schulverwaltung ein Attribut name
beinhaltet.
Das heißt, dass jeder unserer 2.000 Schüler ein Attribut name
hat.
Wir könnten aber auch in der Klasse die Information speichern, dass Schüler keinen Zugang zur Lehrertoilette haben.
static const zugangLehrertoilette = false;
Das static
sorgt dafür, dass diese Information nicht bei den 2.000 Schülern
jeweils einzeln abgespeichert wird, sondern stattdessen einmal für die gesamte
Klasse. Auch statische Methoden sind denkbar. Sie dürfen sich dann nicht
auf das Objekt beziehen, sondern haben eher einen allgemeinen Einsatzzweck.
Der Rest der Klasse ist ziemlicher Standard. Wieder legen wir einen Konstruktor
an, der mehr oder weniger ein Standard-Setting-Objekt instanziiert (weil wir
für Riverpod am Anfang ein Objekt brauchen) und einen Konstruktor, mit dem man
alle Feinheiten einstellen kann, der aber nur intern genutzt wird im Zusammenhang
mit der copyWith-Methode. Diese ist analog zu der Methode in /lib/models/field.dart
aufgebaut.
Wir hatten ja bereits den Zustand, dass das Spiel als verloren galt, wenn man
eine Mine aufdeckte. Mir ist aber bewusst geworden, dass ein Spiel auch
gewonnen werden kann, und deswegen habe ich diese Informationen zusammengefasst
und auch hier eine neue Klasse geschaffen. Diese ist aber ein kleiner Sonderfall,
nämlich eine enum
. Code:
/// Status of game
enum GameStatus {
/// The game is running
gameRunning,
/// The game is over because it is already won
gameWon,
/// The game is over because it is already lost
gameLost
}
Tja, überschaubar, nicht wahr? Aber was ist eine Enum? Nun, eine Enum ist
im Wesentlichen eine Aufzählung von möglichen Werten für etwas. Es hilft dabei,
klareren Code zu schreiben, denn anstatt sich das beispielsweise als Zahlen
zu merken (running = 1, verloren = 2, etc.), schreibt man dann einfach Code
wie x = GameStatus.gameRunning;
. Man kann dann auch leicht weitere mögliche
Werte später nachpflegen.
Aufgrund dessen musste ich die Klasse Field
in /lib/models/field.dart
umschreiben. Hinzu kam noch eine gewisse Strategie, welche Teile der Spiellogik
ich in Field
und welche im FieldNotifier
unterbringe. Das kann man im
Wesentlichen natürlich handhaben, wie man will. Mein Grundgedanke war aber, dass
eine Klasse eigentlich die Aufgabe hat, seine eigenen Daten zu verwalten. In
diesem Zusammenhang fand ich es suboptimal, wenn der FieldNotifier
dafür
zuständig ist, zum Beispiel festzulegen, dass jemand gewonnen hat. Wenn die
Klasse Field
das nicht selbst verwaltet, dann produziert sie inkonsistente
Zustände, also beispielsweise ein Spielfeld, bei dem eine Mine aufgedeckt ist,
dass aber noch GameStatus.gameRunning
behauptet.
import 'dart:developer';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:minesweeper/models/block.dart';
import 'package:minesweeper/models/game_status.dart';
import 'package:minesweeper/models/position.dart';
@immutable
/// Class representing the game field
class Field {
/*-----------------------------------------------------------------------------\
| Attributes |
\-----------------------------------------------------------------------------*/
/// A map (associative array), linking each Position to its corresponding block.
late final Map<Position, Block> map;
/// Number of mines on the field
final int _mines;
/// Percentage of mines on the field
final int _minePercentage;
/// Number of rows
final int _numRows;
/// Number of columns
final int _numCols;
/// The status of the game
late final GameStatus _gameStatus;
/*-----------------------------------------------------------------------------\
| Getters and Setters for private attributes |
\-----------------------------------------------------------------------------*/
/// Number of mines on the field
int get mines => _mines;
/// Number of rows
int get numRows => _numRows;
/// Number of columns
int get numCols => _numCols;
/// The status of the game
GameStatus get gameStatus => _gameStatus;
/*-----------------------------------------------------------------------------\
| Constructors |
\-----------------------------------------------------------------------------*/
/// Constructs a game field for our game
/// It is an empty field. We just use it for instantiating the game in
/// lib/notifiers/_notfifiers.dart. It will be reconstructed many times using
/// the .withArguments constructor.
Field()
: map = {},
_mines = 0,
_minePercentage = 0,
_numRows = 0,
_numCols = 0,
_gameStatus = GameStatus.gameRunning;
/// Constructs a game field for our game
Field.withArguments({required numRows, required numCols, required minePercentage})
: _numRows = numRows,
_numCols = numCols,
_minePercentage = minePercentage,
_mines = (numRows * numCols * minePercentage) ~/ 100,
_gameStatus = GameStatus.gameRunning {
final newMap = <Position, Block>{};
var leftMines = _mines;
var leftFields = _numRows * _numCols;
final rng = math.Random();
for (var col = 0; col < _numCols; col++) {
for (var row = 0; row < _numRows; row++) {
final pos = Position(col, row);
final prob = (100 * leftMines) ~/ leftFields;
final mine = rng.nextInt(100) < prob;
newMap[pos] = Block(position: pos, mine: mine);
if (mine) {
leftMines--;
}
leftFields--;
}
}
// Let each field calculate its nearby mines
newMap.forEach((pos, block) {
block.updateCloseMines(newMap);
});
map = newMap;
}
// ignore: prefer_const_constructors_in_immutables
Field._internal({
required this.map,
required mines,
required minePercentage,
required numRows,
required numCols,
required gameStatus,
}) : _minePercentage = minePercentage,
_mines = mines,
_numRows = numRows,
_numCols = numCols {
_gameStatus = _calculateGameStatus();
}
/*-----------------------------------------------------------------------------\
| Methods |
\-----------------------------------------------------------------------------*/
/// Returns new Field object, which is a copy of this object, but with
/// some differences specified when calling this method.
Field copyWith({Map<Position, Block>? map, int? minePercentage, int? mines, int? numRows, int? numCols, GameStatus? gameStatus}) => Field._internal(
map: map ?? this.map,
mines: mines ?? _mines,
minePercentage: minePercentage ?? _minePercentage,
numRows: numRows ?? _numRows,
numCols: numCols ?? _numCols,
gameStatus: gameStatus ?? _gameStatus,
);
GameStatus _calculateGameStatus() {
var status = GameStatus.gameWon;
for (var pos in map.keys) {
final block = map[pos]!;
if (block.mine && block.open) {
for (var block2 in map.values) {
if (block2.mine) {
block2.open = true;
}
}
return GameStatus.gameLost;
}
if (!block.mine && !block.open) {
status = GameStatus.gameRunning;
}
}
// If the game is won now, we open all fields that are still closed
if (status == GameStatus.gameWon) {
for (var block in map.values) {
if (block.mine && !block.open) block.open = true;
}
}
return status;
}
/*-----------------------------------------------------------------------------\
| Fake getters |
\-----------------------------------------------------------------------------*/
/// Number of flags set on the field
int get numberOfFlagsSet {
var flags = 0;
map.forEach((pos, block) {
if (block.flagged) flags++;
});
return flags;
}
/// Number of flags still to be places
/// The number is limited to the number of mines on the field
int get numberOfFlagsLeft => _mines - numberOfFlagsSet;
}
Ich habe insgesamt einige Attribute auf private gestellt, die es noch nicht waren. Das ist fast immer eine gute Idee. Neu sind eigentlich, bis auf die Implementation von GameStatus, nur ein paar Methoden am Ende.
In _calculateGameStatus()
wird der Status des Spiels berechnet. Das geschieht
jedesmal, wenn ein neues Field-Objekt durch die copyWith-Methode produziert wird,
was ja bei jeder Veränderung des Status passiert. Die Logik dieser Methode ist:
Im FieldNotifier
in lib/notifiers/field_notifier.dart
gab es nicht allzuviel
Neues:
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:minesweeper/models/block.dart';
import 'package:minesweeper/models/field.dart';
import 'package:minesweeper/models/game_status.dart';
/// The FieldNotifier is the connection between GUI and state. All state
/// changes go through FieldNotifier. It extends StateNotifier which handles
/// all GUI updates. Part of Riverpod.
class FieldNotifier extends StateNotifier<Field> {
/// Standard constructor
FieldNotifier() : super(Field());
/// Creates a new playing field ... just by instantiating a new state
/// with a new playing field
void create({required int numRows, required int numCols, required int minePercentage}) {
state = Field.withArguments(numRows: numRows, numCols: numCols, minePercentage: minePercentage);
}
/// Is triggered by users clicking on a certain block in the game (MatchScreen)
// ignore: avoid_positional_boolean_parameters
void handleClick(Block block, bool flagMode) {
if (state.gameStatus != GameStatus.gameRunning) return;
if (flagMode) {
block.flagged = !block.flagged;
state = state.copyWith();
return;
}
if (block.flagged) return;
if (block.open) return;
block.reveal(state.map);
state = state.copyWith();
}
}
Die create()
-Methode wurde leicht angepasst, weil wir nicht mehr die
Anzahl der Minen direkt mitgeben, sondern einen Prozentsatz für den Anteil der
Minen (damit, wenn man später die Spielfeldgröße erhöht, man das Spiel nicht
automatisch leichter macht).
Die in einem früheren Zustand noch vorhandene Methode toggleFlagMode()
musste
weichen, auch aufgrund einer strukturellen Überlegung. Ob wir im FlagMode sind,
also ob wir Fähnchen setzen statt Blöcke zu öffnen, ist eigentlich eher eine
GUI-Funktion und sollte dann auch dort verwaltet werden. Warum? Nunja, wenn wir
zum Beispiel daraus irgendwann eine Windows-Version machen, dann ist es
wahrscheinlich so, dass wir einfach Rechtsclicks als "Fähnchen setzen" interpretieren,
und dann macht es wenig Sinn, überhaupt einen FlagMode zu haben bzw. zwischen
Modi hin und her zu schalten. Viel sinnvoller ist es, wenn die GUI, die ja
die Klicks mitbekommt, bei jedem einzelnen Klick auch sagt "das war ein FlagMode-Klick"
(oder eben nicht).
Nur die Methode handleClick()
ist neu:
Nun ging es darum, die Settings, ähnlich wie das Field, prinzipiell immer zur Verfügung zu stellen, egal wo wir uns auf irgendwelchen Seiten aufhalten. Hinzu kommt, dass die GUI auf Updates der Daten reagiert.
Unsere Settings
-Klasse bekam daher, ebenso wie das Field
mit FieldNotifier
eine
Br+cke zwischen GUI und Daten - unter dem folgerichtigen Namen SettingsNotifier
in der Datei /lib/notifiers/settings-notifier.dart
.
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:minesweeper/models/settings.dart';
/// The FieldNotifier is the connection between GUI and state. All state
/// changes go through FieldNotifier. It extends StateNotifier which handles
/// all GUI updates. Part of Riverpod.
class SettingsNotifier extends StateNotifier<Settings> {
/// Standard constructor
SettingsNotifier() : super(Settings());
void setCols(int cols) {
state = state.copyWith(cols: cols);
}
void setRows(int rows) {
state = state.copyWith(rows: rows);
}
void setMinePercentage(int minePerc) {
state = state.copyWith(minePercentage: minePerc);
}
}
Die Klasse tut nicht viel, außer eben diese Brücke darzustellen, die man halt braucht. Hinzu kommen die Funktionen, mit denen man die Settings-Einstellungen verändern kann.
Ebenso wie der FieldNotifier
war dann auch der SettingsNotifier
noch als
globale Variable abzulegen, sodass man auf diesen zugreifen kann. Das
geschah und geschieht in lib/notifiers/_notifiers.dart
. Ja, das ist aus
dem Tutorial für Riverpod, das habe ich mir auch nicht ausgedacht. :-)
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:minesweeper/notifiers/field_notifier.dart';
import 'package:minesweeper/notifiers/settings_notifier.dart';
/// Global variable holding a reference to our state object
final globFieldNotifier = StateNotifierProvider((ref) => FieldNotifier());
/// Global variable holding a reference to an int indicating number of columns in the game
final globalSettingsNotifier = StateNotifierProvider((ref) => SettingsNotifier());
Die Änderungen im MatchScreen könnt Ihr Euch angucken, sie sind nicht allzu spannend:
Field
-Attribut "gameLost" bezogen, mussten
wir auf die Enum GameStatus umstellen.Für den StartScreen
in /lib/ui/start_screen.dart
gilt, dass wir endlich
weg kommen von dem leeren Bildschirm mit dem einen traurigen Button. Und weil
die langen Codes eines Flutter-Bildschirms abschreckend sind, gehen wir auch
diesen schrittweise durch:
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:minesweeper/models/settings.dart';
import 'package:minesweeper/notifiers/_notifiers.dart';
/// Welcome Screen for the app
class StartScreen extends HookWidget {
@override
Widget build(BuildContext context) {
final fieldNotifier = useProvider(globFieldNotifier);
final settingsNotifier = useProvider(globalSettingsNotifier);
final settings = useProvider(globalSettingsNotifier.state);
Mehr oder weniger aus Tutorial geklaute Zugriffe auf die State-Objekte mittels Riverpod, sodass wir sie dann hier verwenden können.
return Scaffold(
appBar: AppBar(title: const Text('Flutter-Minesweeper')),
Ein Scaffold-Widget baut einen Standard-Bildschirm mit Titelseite und Body und, wenn man das will, auch mit Drawer-Menu und Fußleiste etc.
body: Padding(
padding: const EdgeInsets.all(16),
child: Flex(
direction: Axis.vertical,
children: [
const Text(
'Spiel-Einstellungen',
style: TextStyle(
decoration: TextDecoration.underline,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
Wie man sieht, kann man bei einem Text mittels TextStyle-Objekt noch einiges mehr an Einstellungen vornehmen.
Padding(
padding: const EdgeInsets.only(top: 24),
child: Text('Anzahl Reihen: ${settings.rows}'),
),
Hier wird erstmals auf den State zugegriffen.
Slider(
value: settings.rows.toDouble(),
min: Settings.stdRowsMin.toDouble(),
max: Settings.stdRowsMax.toDouble(),
label: 'Reihen: ${settings.rows}',
onChanged: (newValue) {
settingsNotifier.setRows(newValue.toInt());
},
),
Padding(
padding: const EdgeInsets.only(top: 24),
child: Text('Anzahl Spalten: ${settings.cols}'),
),
Slider(
value: settings.cols.toDouble(),
min: Settings.stdColsMin.toDouble(),
max: Settings.stdColsMax.toDouble(),
label: 'Spalten: ${settings.cols}}',
onChanged: (newValue) {
settingsNotifier.setCols(newValue.toInt());
},
),
Padding(
padding: const EdgeInsets.only(top: 24),
child: Text('Minen-Anteil(%): ${settings.minePercentage} %'),
),
Slider(
value: settings.minePercentage.toDouble(),
min: Settings.stdMinePercentageMin.toDouble(),
max: Settings.stdMinePercentageMax.toDouble(),
label: 'Minen-Anteil: ${settings.minePercentage} %',
onChanged: (newValue) {
settingsNotifier.setMinePercentage(newValue.toInt());
},
),
Padding(
padding: const EdgeInsets.only(top: 48),
child: ElevatedButton(
child: const Text(
'Spiel starten',
style: TextStyle(fontSize: 22),
),
onPressed: () {
fieldNotifier.create(numRows: settings.rows, numCols: settings.cols, minePercentage: settings.minePercentage);
Navigator.pushNamed(context, '/match');
},
),
),
],
),
),
);
}
}
Wie man sieht, werden hier drei Slider produziert und mit den Settings-Werten verknüpft.
Dadurch, dass der FieldNotifier sozusagen die Schnittstelle zwischen GUI und Daten darstellt, ist hier die meiste Game-Logik untergebracht. Fangen wir hier an. Wir haben ja schon einige Elemente der FieldNotifier-Klasse durchgesprochen und deswegen konzentrieren wir uns jetzt hier auf einzelne Methoden.
/// Handles click events from GUI.
void handleClick(Block block) {
if (state.gameLost) return;
if (block.open) return;
if (state.flagMode) {
block.flagged = !block.flagged;
state = state.copyWith();
return;
}
if (block.flagged) return;
block.reveal(state.map);
if (block.mine) {
state.map.forEach(
(pos, block) {
if (block.mine) block.open = true;
},
);
state = state.copyWith(gameLost: true);
return;
}
state = state.copyWith();
}
Diese Methode wird von der GUI ausgelöst, wann immer wir auf einen Block im Spielfeld klicken. Einfache Logik:
Immer bedenken .. Wir haben diese state = ???
-Anweisungen, in denen ja
jeweils ein neuer State (meistens per copyWidth
-Methode des alten States)
deklariert wird. Diese Zeilen sind es, die jeweils die GUI updaten lassen.
Die einzige Funktion, die in FieldNotifier aufgerufen wird, mit der wir uns noch nicht beschäftigt haben, ist die reveal-Methode der Block-Klasse. Gucken wir uns also auch diese an.
/// Reveals (opens) this block. If this block has no mines in its neighboring
/// fields, it will reveal its neighbors too.
void reveal(Map<Position, Block> map) {
if (open) return;
open = true;
if (mine) return;
if (closeMines == 0) {
_neighbors(map).forEach((block) {
if (!block.open) {
block.reveal(map);
}
});
}
}
Ziemlich simple Mechanik. Prinzipiell führt die Methode dazu, dass der Block geöffnet wird. Wenn es sich um ein Feld mit dem Minen-drumherum-Wert 0 handelt, dann werden auch alle Nachbarn aufgedeckt. Ausnahme: Da technisch auch Minen den Wert "0 Minen im Umfeld" haben können, werden Minenfelder zuvor ausgenommen.
In ersten Versuchen mit dieser Art des Aufdeckens produzierte ich eine Endlosschleife, weil ein Feld seine Nachbarn aufdeckte, aber jeder von diesen Nachbarn auch wieder seine Nachbarn aufdecken wollte und da dann unendlich oft Blöcke sich gegenseitig aufforderten, einander zu revealen. Jetzt ist klar, dass die Öffnung des Feldes zuerst erfolgt und DANN die Nachbarn geprüft werden. Das heißt der Block, der den Prozess startete, startet ihn nicht erneut, weil er ja dann beim nächsten Durchgang schon open ist.
Im nächsten Schritt des Tutorials geht es ans Feintuning:
Aber so im Wesentlichen bin ich schon ganz zufrieden mit dem Zustand. Und deswegen kann es durchaus ein Weilchen dauern, bis ich mich diesem Tutorial wieder zuwende.