1. Zanim zaczniesz
Wyobraź sobie, że ktoś pyta Cię, czy można stworzyć największą na świecie krzyżówkę. Przypominasz sobie techniki AI, których uczyłeś się w szkole, i zastanawiasz się, czy możesz użyć Fluttera do zbadania opcji algorytmicznych, aby tworzyć rozwiązania problemów wymagających dużej mocy obliczeniowej.
W tym ćwiczeniu zrobisz dokładnie to. Na koniec stworzysz narzędzie do tworzenia łamigłówek z siatką słów. Istnieje wiele różnych definicji tego, co jest prawidłową krzyżówką, a te techniki pomagają tworzyć krzyżówki, które pasują do Twojej definicji.
Na podstawie tego narzędzia możesz następnie utworzyć krzyżówkę, która będzie zawierać wygenerowane przez nie hasła. Ta łamigłówka jest dostępna na urządzeniach z Androidem, iOS, Windows, macOS i Linux. Oto jak to wygląda na Androidzie:
Wymagania wstępne
- Ukończenie ćwiczenia Pierwsza aplikacja we Flutterze
Czego się dowiesz
- Jak używać izolowanych procesów do wykonywania wymagających obliczeniowo zadań bez zakłócania pętli renderowania Fluttera za pomocą kombinacji funkcji
compute
Fluttera i możliwości buforowania wartości filtra przebudowy Riverpodaselect
. - Jak wykorzystać niezmienne struktury danych za pomocą
built_value
ibuilt_collection
do wdrożenia opartych na wyszukiwaniu tradycyjnych technik AI (GOFAI), takich jak przeszukiwanie w głąb i wycofywanie. - Jak korzystać z funkcji pakietu
two_dimensional_scrollables
, aby szybko i intuicyjnie wyświetlać dane w siatce.
Wymagania
- Pakiet SDK Flutter.
- Visual Studio Code (VS Code) z wtyczkami Flutter i Dart.
- Oprogramowanie kompilatora dla wybranego środowiska docelowego. Te warsztaty działają na wszystkich platformach komputerowych, Androidzie i iOS. Aby kierować reklamy na Windowsa, musisz używać VS Code, aby kierować reklamy na macOS lub iOS – Xcode, a aby kierować reklamy na Androida – Android Studio.
2. Utwórz projekt
Tworzenie pierwszego projektu Flutter
- Uruchom VS Code.
- Otwórz paletę poleceń (Ctrl+Shift+P w systemie Windows/Linux, Cmd+Shift+P w systemie macOS), wpisz „flutter new”, a następnie w menu wybierz Flutter: New Project (Flutter: nowy projekt).
- Wybierz Pusta aplikacja, a potem wybierz katalog, w którym chcesz utworzyć projekt. Powinien to być dowolny katalog, który nie wymaga podwyższonych uprawnień ani nie zawiera spacji w ścieżce. Może to być na przykład katalog domowy lub
C:\src\
.
- Nadaj nazwę projektowi
generate_crossword
. W dalszej części tego ćwiczenia zakłada się, że aplikacja ma nazwęgenerate_crossword
.
Flutter utworzy teraz folder projektu, a VS Code go otworzy. Teraz zastąpisz zawartość 2 plików podstawowym szkieletem aplikacji.
Kopiowanie i wklejanie początkowej aplikacji
- W lewym okienku VS Code kliknij Eksplorator i otwórz plik
pubspec.yaml
.
- Zastąp zawartość tego pliku tymi zależnościami potrzebnymi do generowania krzyżówek:
pubspec.yaml
name: generate_crossword
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: ^3.9.0
dependencies:
flutter:
sdk: flutter
built_collection: ^5.1.1
built_value: ^8.10.1
characters: ^1.4.0
flutter_riverpod: ^2.6.1
intl: ^0.20.2
riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
two_dimensional_scrollables: ^0.3.7
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
build_runner: ^2.5.4
built_value_generator: ^8.10.1
custom_lint: ^0.7.6
riverpod_generator: ^2.6.5
riverpod_lint: ^2.6.5
flutter:
uses-material-design: true
Plik pubspec.yaml
zawiera podstawowe informacje o aplikacji, takie jak jej aktualna wersja i zależności. Widzisz zbiór zależności, które nie są częścią normalnej pustej aplikacji Flutter. W kolejnych krokach skorzystasz ze wszystkich tych pakietów.
Zrozumienie zależności
Zanim przejdziemy do kodu, dowiedzmy się, dlaczego wybrano te konkretne pakiety:
- built_value: tworzy niezmienne obiekty, które efektywnie współdzielą pamięć, co ma kluczowe znaczenie dla naszego algorytmu wycofywania.
- Riverpod: zapewnia precyzyjne zarządzanie stanem za pomocą
select()
, aby zminimalizować przebudowy. - two_dimensional_scrollables: obsługuje duże siatki bez obniżania wydajności.
- Otwórz plik
main.dart
w katalogulib/
.
- Zastąp zawartość tego pliku tymi wierszami:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: Scaffold(
body: Center(
child: Text('Hello, World!', style: TextStyle(fontSize: 24)),
),
),
),
),
);
}
- Uruchom ten kod, aby sprawdzić, czy wszystko działa. Powinno się w nim wyświetlać nowe okno z obowiązkowym zwrotem początkowym każdego nowego projektu. Obok nazwy aplikacji znajduje się ikona
ProviderScope
, która wskazuje, że aplikacja będzie używaćriverpod
do zarządzania stanem.
Punkt kontrolny: podstawowa aplikacja działa
W tym momencie powinno się pojawić okno „Hello, World!”. Jeśli nie:
- Sprawdź, czy Flutter jest prawidłowo zainstalowany.
- Sprawdź, czy aplikacja działa z
flutter run
- Sprawdź, czy w terminalu nie ma błędów kompilacji.
3. Dodawanie słów
Elementy składowe krzyżówki
Krzyżówka to w zasadzie lista słów. Słowa są ułożone w siatce, niektóre w poziomie, inne w pionie, tak aby się ze sobą przeplatały. Rozwiązanie jednego słowa daje wskazówki dotyczące słów, które się z nim krzyżują. Dlatego dobrym pierwszym elementem jest lista słów.
Dobrym źródłem tych słów jest strona Natural Language Corpus Data Petera Norviga. Lista SOWPODS to przydatny punkt wyjścia, ponieważ zawiera 267 750 słów.
W tym kroku pobierzesz listę słów, dodasz ją jako zasób do aplikacji Flutter i skonfigurujesz dostawcę Riverpod, aby wczytywał listę do aplikacji podczas uruchamiania.
Aby rozpocząć, wykonaj następujące czynności:
- Zmodyfikuj plik
pubspec.yaml
projektu, aby dodać deklarację komponentu dla wybranej listy słów. Ta lista zawiera tylko sekcję Flutter konfiguracji aplikacji, ponieważ reszta pozostała bez zmian.
pubspec.yaml
flutter:
uses-material-design: true
assets: # Add this line
- assets/words.txt # And this one.
Edytor prawdopodobnie wyróżni ten ostatni wiersz ostrzeżeniem, ponieważ nie masz jeszcze tego pliku.
- W przeglądarce i edytorze utwórz katalog
assets
na najwyższym poziomie projektu i utwórz w nim plikwords.txt
z jedną z list słów, do których linki podaliśmy wcześniej.
Ten kod został zaprojektowany z użyciem wspomnianej wcześniej listy SOWPODS, ale powinien działać z każdą listą słów, która zawiera tylko znaki z zakresu A–Z. Rozszerzenie tego kodu, aby działał z różnymi zestawami znaków, pozostawiamy czytelnikowi.
Wczytaj słowa
Aby napisać kod odpowiedzialny za wczytywanie listy słów podczas uruchamiania aplikacji, wykonaj te czynności:
- Utwórz plik
providers.dart
w katalogulib
. - Dodaj do pliku te elementy:
lib/providers.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
To Twój pierwszy dostawca Riverpod w tym kodzie.
Jak działa ten dostawca:
- Asynchroniczne wczytywanie listy słów z zasobów
- Filtruje słowa, aby uwzględniać tylko znaki a–z dłuższe niż 2 litery.
- Zwraca niezmienny obiekt
BuiltSet
, który umożliwia wydajny dostęp losowy.
Ten projekt korzysta z generowania kodu w przypadku wielu zależności, w tym Riverpod.
- Aby rozpocząć generowanie kodu, uruchom to polecenie:
$ dart run build_runner watch -d [INFO] Generating build script completed, took 174ms [INFO] Setting up file watchers completed, took 5ms [INFO] Waiting for all file watchers to be ready completed, took 202ms [INFO] Reading cached asset graph completed, took 65ms [INFO] Checking for updates since last build completed, took 680ms [INFO] Running build completed, took 2.3s [INFO] Caching finalized dependency graph completed, took 42ms [INFO] Succeeded after 2.3s with 122 outputs (243 actions)
Będzie on nadal działać w tle i aktualizować wygenerowane pliki w miarę wprowadzania zmian w projekcie. Gdy to polecenie wygeneruje kod w providers.g.dart
, edytor powinien zaakceptować kod dodany do providers.dart
.
W Riverpod dostawcy, tacy jak zdefiniowana wcześniej funkcja wordList
, są zwykle tworzeni na żądanie. W przypadku tej aplikacji lista słów musi być jednak wczytywana od razu. Dokumentacja Riverpod sugeruje następujące podejście do obsługi dostawców, którzy muszą być wczytywani od razu. Teraz to zrobisz.
- Utwórz plik
crossword_generator_app.dart
w katalogulib/widgets
. - Dodaj do pliku te elementy:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer(
builder: (context, ref, _) {
final wordListAsync = ref.watch(wordListProvider);
return wordListAsync.when(
data: (wordList) => ListView.builder(
itemCount: wordList.length,
itemBuilder: (context, index) {
return ListTile(title: Text(wordList.elementAt(index)));
},
),
error: (error, stackTrace) => Center(child: Text('$error')),
loading: () => Center(child: CircularProgressIndicator()),
);
},
),
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
Ten plik jest interesujący z dwóch różnych powodów. Pierwszy to widżet _EagerInitialization
, którego jedynym zadaniem jest wymaganie od utworzonego wcześniej dostawcy wordList
wczytania listy słów. Ten widżet realizuje ten cel, nasłuchując dostawcy za pomocą wywołania ref.watch()
. Więcej informacji o tej technice znajdziesz w dokumentacji Riverpod w sekcji Eager initialization of providers (Inicjowanie dostawców z wyprzedzeniem).
Drugą ciekawą rzeczą, na którą warto zwrócić uwagę w tym pliku, jest sposób, w jaki Riverpod obsługuje treści asynchroniczne. Jak pamiętasz, wordList
dostawca jest zdefiniowany jako funkcja asynchroniczna, ponieważ wczytywanie treści z dysku jest powolne. Jeśli w tym kodzie obserwujesz dostawcę listy słów, otrzymujesz AsyncValue<BuiltSet<String>>
. Część AsyncValue
tego typu to adapter między asynchronicznym światem dostawców a synchronicznym światem metody build
widżetu.
Metoda AsyncValue
when
obsługuje 3 potencjalne stany, w których może znajdować się wartość przyszła. Przyszłość mogła zostać rozwiązana pomyślnie, w którym to przypadku wywoływane jest wywołanie zwrotne data
. Może też być w stanie błędu, w którym to przypadku wywoływane jest wywołanie zwrotne error
, lub może być nadal w trakcie ładowania. Typy zwracane przez 3 wywołania zwrotne muszą być zgodne, ponieważ wartość zwracana przez wywołane wywołanie zwrotne jest zwracana przez metodę when
. W tym przypadku wynik metody when jest wyświetlany jako body
widżetu Scaffold
.
Tworzenie aplikacji z niemal nieskończoną listą
Aby zintegrować widżet CrosswordGeneratorApp
z aplikacją, wykonaj te czynności:
- Zaktualizuj plik
lib/main.dart
, dodając ten kod:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_generator_app.dart'; // Add this import
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordGeneratorApp(), // Remove what was here and replace
),
),
);
}
- Uruchom ponownie aplikację. Powinna się wyświetlić przewijana lista zawierająca ponad 267 750 słów ze słownika.
Co utworzysz
Teraz utworzysz podstawowe struktury danych dla krzyżówki za pomocą obiektów niezmiennych. Te podstawy umożliwią wydajne działanie algorytmów i płynne aktualizacje interfejsu.
4. Wyświetlanie słów w siatce
W tym kroku utworzysz strukturę danych do tworzenia krzyżówki za pomocą pakietów built_value
i built_collection
. Te 2 pakiety umożliwiają tworzenie struktur danych jako wartości niezmiennych, co będzie przydatne zarówno do przekazywania danych między izolowanymi procesami, jak i do znacznie łatwiejszego wdrażania przeszukiwania w głąb i wycofywania.
Aby rozpocząć, wykonaj następujące czynności:
- Utwórz plik
model.dart
w katalogulib
, a następnie dodaj do niego ten kod:
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
static Serializer<Location> get serializer => _$locationSerializer;
/// The horizontal part of the location. The location is 0 based.
int get x;
/// The vertical part of the location. The location is 0 based.
int get y;
/// Returns a new location that is one step to the left of this location.
Location get left => rebuild((b) => b.x = x - 1);
/// Returns a new location that is one step to the right of this location.
Location get right => rebuild((b) => b.x = x + 1);
/// Returns a new location that is one step up from this location.
Location get up => rebuild((b) => b.y = y - 1);
/// Returns a new location that is one step down from this location.
Location get down => rebuild((b) => b.y = y + 1);
/// Returns a new location that is [offset] steps to the left of this location.
Location leftOffset(int offset) => rebuild((b) => b.x = x - offset);
/// Returns a new location that is [offset] steps to the right of this location.
Location rightOffset(int offset) => rebuild((b) => b.x = x + offset);
/// Returns a new location that is [offset] steps up from this location.
Location upOffset(int offset) => rebuild((b) => b.y = y - offset);
/// Returns a new location that is [offset] steps down from this location.
Location downOffset(int offset) => rebuild((b) => b.y = y + offset);
/// Pretty print a location as a (x,y) coordinate.
String prettyPrint() => '($x,$y)';
/// Returns a new location built from [updates]. Both [x] and [y] are
/// required to be non-null.
factory Location([void Function(LocationBuilder)? updates]) = _$Location;
Location._();
/// Returns a location at the given coordinates.
factory Location.at(int x, int y) {
return Location((b) {
b
..x = x
..y = y;
});
}
}
/// The direction of a word in a crossword.
enum Direction {
across,
down;
@override
String toString() => name;
}
/// A word in a crossword. This is a word at a location in a crossword, in either
/// the across or down direction.
abstract class CrosswordWord
implements Built<CrosswordWord, CrosswordWordBuilder> {
static Serializer<CrosswordWord> get serializer => _$crosswordWordSerializer;
/// The word itself.
String get word;
/// The location of this word in the crossword.
Location get location;
/// The direction of this word in the crossword.
Direction get direction;
/// Compare two CrosswordWord by coordinates, x then y.
static int locationComparator(CrosswordWord a, CrosswordWord b) {
final compareRows = a.location.y.compareTo(b.location.y);
final compareColumns = a.location.x.compareTo(b.location.x);
return switch (compareColumns) {
0 => compareRows,
_ => compareColumns,
};
}
/// Constructor for [CrosswordWord].
factory CrosswordWord.word({
required String word,
required Location location,
required Direction direction,
}) {
return CrosswordWord(
(b) => b
..word = word
..direction = direction
..location.replace(location),
);
}
/// Constructor for [CrosswordWord].
/// Use [CrosswordWord.word] instead.
factory CrosswordWord([void Function(CrosswordWordBuilder)? updates]) =
_$CrosswordWord;
CrosswordWord._();
}
/// A character in a crossword. This is a single character at a location in a
/// crossword. It may be part of an across word, a down word, both, but not
/// neither. The neither constraint is enforced elsewhere.
abstract class CrosswordCharacter
implements Built<CrosswordCharacter, CrosswordCharacterBuilder> {
static Serializer<CrosswordCharacter> get serializer =>
_$crosswordCharacterSerializer;
/// The character at this location.
String get character;
/// The across word that this character is a part of.
CrosswordWord? get acrossWord;
/// The down word that this character is a part of.
CrosswordWord? get downWord;
/// Constructor for [CrosswordCharacter].
/// [acrossWord] and [downWord] are optional.
factory CrosswordCharacter.character({
required String character,
CrosswordWord? acrossWord,
CrosswordWord? downWord,
}) {
return CrosswordCharacter((b) {
b.character = character;
if (acrossWord != null) {
b.acrossWord.replace(acrossWord);
}
if (downWord != null) {
b.downWord.replace(downWord);
}
});
}
/// Constructor for [CrosswordCharacter].
/// Use [CrosswordCharacter.character] instead.
factory CrosswordCharacter([
void Function(CrosswordCharacterBuilder)? updates,
]) = _$CrosswordCharacter;
CrosswordCharacter._();
}
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Add a word to the crossword at the given location and direction.
Crossword addWord({
required Location location,
required String word,
required Direction direction,
}) {
return rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width,
(_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([Location, Crossword, CrosswordWord, CrosswordCharacter])
final Serializers serializers = _$serializers;
Ten plik opisuje początek struktury danych, której będziesz używać do tworzenia krzyżówek. Krzyżówka to lista słów ułożonych poziomo i pionowo, które są ze sobą powiązane w siatce. Aby użyć tej struktury danych, utwórz Crossword
o odpowiednim rozmiarze za pomocą konstruktora o nazwie Crossword.crossword
, a następnie dodaj słowa za pomocą metody addWord
. W ramach tworzenia ostatecznej wartości metoda _fillCharacters
tworzy siatkę CrosswordCharacter
.
Aby użyć tej struktury danych, wykonaj te czynności:
- Utwórz plik
utils
w katalogulib
, a następnie dodaj do niego ten kod:
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
Jest to rozszerzenie funkcji BuiltSet
, które ułatwia pobieranie losowego elementu zbioru. Metody rozszerzające to dobry sposób na rozszerzenie klas o dodatkowe funkcje. Nazwanie rozszerzenia jest wymagane, aby było ono dostępne poza plikiem utils.dart
.
- Dodaj do pliku
lib/providers.dart
te instrukcje importu:
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart'; // Add this import
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model; // And this import
import 'utils.dart'; // And this one
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
Te importy udostępniają zdefiniowany wcześniej model dostawcom, których zamierzasz utworzyć. Import dart:math
jest uwzględniony w przypadku Random
, import flutter/foundation.dart
– w przypadku debugPrint
, model.dart
– w przypadku modelu, a utils.dart
– w przypadku rozszerzenia BuiltSet
.
- Na końcu tego samego pliku dodaj tych dostawców:
lib/providers.dart
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool()
? model.Direction.across
: model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width),
_random.nextInt(size.height),
);
crossword = crossword.addWord(
word: word,
direction: direction,
location: location,
);
yield crossword;
await Future.delayed(Duration(milliseconds: 100));
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
Te zmiany dodają do aplikacji 2 dostawców. Pierwszy to Size
, czyli zmienna globalna zawierająca wybraną wartość wyliczenia CrosswordSize
. Dzięki temu interfejs będzie mógł wyświetlać i ustawiać rozmiar tworzonego krzyżówki. Drugi dostawca, crossword
, to ciekawsza kreacja. Jest to funkcja, która zwraca serię znaków Crossword
. Jest ona tworzona przy użyciu obsługi generatorów w Dart, co jest oznaczone symbolem async*
przy funkcji. Oznacza to, że zamiast kończyć się na zwróceniu wartości, daje serię Crossword
, co znacznie ułatwia pisanie obliczeń zwracających wyniki pośrednie.
Ze względu na obecność pary wywołań ref.watch
na początku funkcji dostawcy crossword
strumień Crossword
zostanie ponownie uruchomiony przez system Riverpod za każdym razem, gdy zmieni się wybrany rozmiar krzyżówki i gdy lista słów zakończy wczytywanie.
Masz już kod do generowania krzyżówek, choć pełnych losowych słów. Warto byłoby pokazać je użytkownikowi narzędzia.
- Utwórz w katalogu
lib/widgets
plikcrossword_widget.dart
z tą zawartością:
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
crosswordProvider.select(
(crosswordAsync) => crosswordAsync.when(
data: (crossword) => crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
if (character != null) {
return Container(
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: Text(
character.character,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
Ten widżet, będący elementem ConsumerWidget
, może bezpośrednio korzystać z usługi Size
, aby określić rozmiar siatki do wyświetlania znaków Crossword
. Wyświetlanie tej siatki jest możliwe dzięki widżetowi TableView
z pakietu two_dimensional_scrollables
.
Warto zauważyć, że poszczególne komórki renderowane przez funkcje pomocnicze _buildCell
zawierają w zwracanym drzewie Widget
widżet Consumer
. Działa to jako granica odświeżania. Wszystko w Consumer
widżecie jest odtwarzane, gdy zmieni się zwrócona wartość ref.watch
. Może się wydawać, że za każdym razem, gdy zmienia się Crossword
, trzeba odtworzyć całe drzewo, ale takie podejście wymaga wielu obliczeń, których można uniknąć, stosując tę konfigurację.
Jeśli przyjrzysz się parametrowi ref.watch
, zobaczysz kolejną warstwę unikania ponownego obliczania układów za pomocą crosswordProvider.select
. Oznacza to, że ref.watch
spowoduje ponowne utworzenie zawartości TableViewCell
tylko wtedy, gdy zmieni się znak, za którego renderowanie odpowiada komórka. Ograniczenie ponownego renderowania jest niezbędne, aby interfejs użytkownika był responsywny.
Aby udostępnić użytkownikowi dostawców CrosswordWidget
i Size
, zmień plik crossword_generator_app.dart
w ten sposób:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_widget.dart'; // Add this import
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()], // Add this line
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(child: CrosswordWidget()), // Replace what was here before
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget { // Add from here
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
); // To here.
}
Zmieniło się tu kilka rzeczy. Po pierwsze, kod odpowiedzialny za renderowanie wordList
jako ListView
został zastąpiony wywołaniem funkcji CrosswordWidget
zdefiniowanej w pliku lib/widgets/crossword_widget.dart
. Kolejną ważną zmianą jest wprowadzenie menu, które umożliwia zmianę działania aplikacji, począwszy od zmiany rozmiaru krzyżówki. W kolejnych krokach dodamy więcej MenuItemButton
. Uruchom aplikację. Zobaczysz coś takiego:
Znaki są wyświetlane w siatce, a menu umożliwia zmianę jej rozmiaru. Słowa nie są jednak ułożone jak w krzyżówce. Wynika to z braku ograniczeń dotyczących sposobu dodawania słów do krzyżówki. Krótko mówiąc, to bałagan. W następnym kroku zaczniesz nad tym panować.
5. Egzekwowanie ograniczeń
Co się zmienia i dlaczego
Obecnie Twoja krzyżówka umożliwia nakładanie się słów bez weryfikacji. Dodasz sprawdzanie ograniczeń, aby upewnić się, że słowa prawidłowo się ze sobą łączą, jak w prawdziwej krzyżówce.
Celem tego kroku jest dodanie do modelu kodu, który będzie wymuszać ograniczenia krzyżówki. Istnieje wiele różnych rodzajów krzyżówek, a styl, który będzie obowiązywał w tym laboratorium, jest zgodny z tradycją angielskich krzyżówek. Zmiana tego kodu w celu generowania innych stylów krzyżówek pozostaje, jak zawsze, zadaniem dla czytelnika.
Aby rozpocząć, wykonaj następujące czynności:
- Otwórz plik
model.dart
i zastąp tylko modelCrossword
tym kodem:
lib/model.dart
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword,
/// or checking the proposed solution.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Checks if this crossword is valid.
bool get valid {
// Check that there are no duplicate words.
final wordSet = words.map((word) => word.word).toBuiltSet();
if (wordSet.length != words.length) {
return false;
}
for (final MapEntry(key: location, value: character)
in characters.entries) {
// All characters must be a part of an across or down word.
if (character.acrossWord == null && character.downWord == null) {
return false;
}
// All characters must be within the crossword puzzle.
// No drawing outside the lines.
if (location.x < 0 ||
location.y < 0 ||
location.x >= width ||
location.y >= height) {
return false;
}
// Characters above and below this character must be related
// by a vertical word
if (characters[location.up] case final up?) {
if (character.downWord == null) {
return false;
}
if (up.downWord != character.downWord) {
return false;
}
}
if (characters[location.down] case final down?) {
if (character.downWord == null) {
return false;
}
if (down.downWord != character.downWord) {
return false;
}
}
// Characters to the left and right of this character must be
// related by a horizontal word
final left = characters[location.left];
if (left != null) {
if (character.acrossWord == null) {
return false;
}
if (left.acrossWord != character.acrossWord) {
return false;
}
}
final right = characters[location.right];
if (right != null) {
if (character.acrossWord == null) {
return false;
}
if (right.acrossWord != character.acrossWord) {
return false;
}
}
}
return true;
}
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
if (words.isNotEmpty && !overlap) {
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width,
(_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
Przypominamy, że zmiany wprowadzane w plikach model.dart
i providers.dart
wymagają uruchomienia build_runner
, aby zaktualizować odpowiednie pliki model.g.dart
i providers.g.dart
. Jeśli te pliki nie zostały automatycznie zaktualizowane, teraz jest dobry moment, aby ponownie rozpocząć build_runner
od dart run build_runner watch -d
.
Aby korzystać z tej nowej funkcji w warstwie modelu, musisz zaktualizować warstwę dostawcy.
- Zmodyfikuj plik
providers.dart
w ten sposób:
lib/providers.dart
import 'dart:convert';
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model;
import 'utils.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
var candidate = crossword.addWord( // Edit from here
word: word, direction: direction, location: location);
await Future.delayed(Duration(milliseconds: 10));
if (candidate != null) {
debugPrint('Added word: $word');
crossword = candidate;
yield crossword;
} else {
debugPrint('Failed to add word: $word');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
- Uruchom aplikację. W interfejsie nie dzieje się zbyt wiele, ale w logach widać, że wiele się dzieje.
Jeśli zastanowisz się nad tym, co się tu dzieje, zobaczysz, że krzyżówka pojawia się losowo. Metoda addWord
w modelu Crossword
odrzuca każde proponowane słowo, które nie pasuje do obecnej krzyżówki, więc to niesamowite, że w ogóle coś się pojawia.
Dlaczego warto przejść na przetwarzanie w tle?
Podczas generowania krzyżówki interfejs może przestać odpowiadać. Dzieje się tak, ponieważ generowanie krzyżówek obejmuje tysiące kontroli weryfikacyjnych. Te obliczenia blokują pętlę renderowania Fluttera z częstotliwością 60 klatek na sekundę, więc przenieś wymagające obliczenia do izolowanych procesów w tle. Dzięki temu interfejs użytkownika działa płynnie, a łamigłówka generuje się w tle.
Aby bardziej metodycznie wybierać słowa do wypróbowania, warto przenieść to obliczenie z wątku interfejsu do izolatu w tle. Flutter ma bardzo przydatną funkcję opakowującą, która umożliwia wykonanie części pracy w izolowanym procesie w tle – jest to funkcja compute
.
- W pliku
providers.dart
zmodyfikuj dostawcę krzyżówki w ten sposób:
lib/providers.dart
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool()
? model.Direction.across
: model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width),
_random.nextInt(size.height),
);
try { // Edit from here
var candidate = await compute((
(String, model.Direction, model.Location) wordToAdd,
) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word,
direction: direction,
location: location,
);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
Informacje o ograniczeniach izolacji
Ten kod działa, ale ma ukryty problem. Izolaty mają ścisłe reguły dotyczące tego, jakie dane można między nimi przekazywać. Problem polega na tym, że zamknięcie „przechwytuje” odwołanie do dostawcy, którego nie można serializować i wysyłać do innego izolatu.
Ten komunikat pojawi się, gdy system spróbuje wysłać dane, których nie można serializować:
flutter: Error running isolate: Invalid argument(s): Illegal argument in isolate message: object is unsendable - Library:'dart:async' Class: _Future@4048458 (see restrictions listed at `SendPort.send()` documentation for more information) flutter: <- Instance of 'AutoDisposeStreamProviderElement<Crossword>' (from package:riverpod/src/stream_provider.dart) flutter: <- Context num_variables: 2 <- Context num_variables: 1 parent:{ Context num_variables: 2 } flutter: <- Context num_variables: 1 parent:{ Context num_variables: 1 parent:{ Context num_variables: 2 } } flutter: <- Closure: () => Crossword? (from package:generate_crossword/providers.dart)
Jest to spowodowane zamknięciem, które compute
przekazuje do izolatu w tle, zamykając dostawcę, którego nie można wysłać przez SendPort.send()
. Jednym z rozwiązań jest upewnienie się, że w zamknięciu nie ma niczego, co nie może zostać wysłane.
Pierwszym krokiem jest oddzielenie dostawców od kodu Isolate.
- Utwórz plik
isolates.dart
w katalogulib
i dodaj do niego tę treść:
lib/isolates.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
final _random = Random();
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
while (crossword.characters.length <
crossword.width * crossword.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool() ? Direction.across : Direction.down;
final location = Location.at(
_random.nextInt(crossword.width),
_random.nextInt(crossword.height),
);
try {
var candidate = await compute(((String, Direction, Location) wordToAdd) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word,
direction: direction,
location: location,
);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
}
Ten kod powinien być dość znajomy. Jest to rdzeń tego, co było w crossword
dostawcy, ale teraz jako samodzielna funkcja generatora. Teraz możesz zaktualizować plik providers.dart
, aby użyć tej nowej funkcji do utworzenia instancji izolatu w tle.
lib/providers.dart
// Drop the dart:math import, the _random instance moved to isolates.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart'; // Add this import
import 'model.dart' as model;
// Drop the utils.dart import
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
// Drop the _random instance
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword( // Edit from here
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyCrossword;
},
loading: () async* {
yield emptyCrossword; // To here.
},
);
}
Dzięki temu masz teraz narzędzie, które tworzy krzyżówki o różnych rozmiarach, a compute
rozwiązywania krzyżówki odbywa się w izolowanym tle. Teraz tylko kod mógłby być bardziej wydajny w decyzji, jakie słowa spróbować dodać do krzyżówki.
6. Zarządzanie kolejką zadań
Omówienie strategii wyszukiwania
Do generowania krzyżówek używamy wstecznego śledzenia, czyli systematycznej metody prób i błędów. Najpierw aplikacja próbuje umieścić słowo w określonym miejscu, a potem sprawdza, czy pasuje ono do istniejących słów. Jeśli tak, zachowaj je i spróbuj z następnym słowem. Jeśli nie, wyjmij go i spróbuj w innym miejscu.
W przypadku krzyżówek metoda ta działa, ponieważ umieszczenie każdego słowa tworzy ograniczenia dla kolejnych słów, a nieprawidłowe umieszczenia są szybko wykrywane i porzucane. Niezmienne struktury danych umożliwiają wydajne „cofanie” zmian.
Problem z obecną wersją kodu polega na tym, że rozwiązywany problem jest w zasadzie problemem wyszukiwania, a obecne rozwiązanie jest wyszukiwaniem w ciemno. Jeśli kod skupi się na znajdowaniu słów, które pasują do obecnych słów, zamiast losowo umieszczać słowa w dowolnym miejscu siatki, system będzie szybciej znajdować rozwiązania. Jednym ze sposobów jest wprowadzenie kolejki zadań z lokalizacjami, dla których należy znaleźć słowa.
Kod tworzy proponowane rozwiązania, sprawdza, czy są one prawidłowe, a następnie w zależności od wyniku weryfikacji uwzględnia je lub odrzuca. To przykład implementacji algorytmu z rodziny algorytmów z backtrackingiem. To wdrożenie jest znacznie ułatwione przez funkcje built_value
i built_collection
, które umożliwiają tworzenie nowych niezmiennych wartości, które pochodzą z niezmiennej wartości, z której zostały utworzone, i dlatego współdzielą z nią wspólny stan. Umożliwia to tanie wykorzystanie potencjalnych kandydatów bezpłatnie pamięci wymaganych do głębokiego kopiowania.
Aby rozpocząć, wykonaj następujące czynności:
- Otwórz plik
model.dart
i dodaj do niego tę definicjęWorkQueue
:
lib/model.dart
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
// Add from here
/// A work queue for a worker to process. The work queue contains a crossword
/// and a list of locations to try, along with candidate words to add to the
/// crossword.
abstract class WorkQueue implements Built<WorkQueue, WorkQueueBuilder> {
static Serializer<WorkQueue> get serializer => _$workQueueSerializer;
/// The crossword the worker is working on.
Crossword get crossword;
/// The outstanding queue of locations to try.
BuiltMap<Location, Direction> get locationsToTry;
/// Known bad locations.
BuiltSet<Location> get badLocations;
/// The list of unused candidate words that can be added to this crossword.
BuiltSet<String> get candidateWords;
/// Returns true if the work queue is complete.
bool get isCompleted => locationsToTry.isEmpty || candidateWords.isEmpty;
/// Create a work queue from a crossword.
static WorkQueue from({
required Crossword crossword,
required Iterable<String> candidateWords,
required Location startLocation,
}) => WorkQueue((b) {
if (crossword.words.isEmpty) {
// Strip candidate words too long to fit in the crossword
b.candidateWords.addAll(
candidateWords.where(
(word) => word.characters.length <= crossword.width,
),
);
b.crossword.replace(crossword);
b.locationsToTry.addAll({startLocation: Direction.across});
} else {
// Assuming words have already been stripped to length
b.candidateWords.addAll(
candidateWords.toBuiltSet().rebuild(
(b) => b.removeAll(crossword.words.map((word) => word.word)),
),
);
b.crossword.replace(crossword);
crossword.characters
.rebuild(
(b) => b.removeWhere((location, character) {
if (character.acrossWord != null && character.downWord != null) {
return true;
}
final left = crossword.characters[location.left];
if (left != null && left.downWord != null) return true;
final right = crossword.characters[location.right];
if (right != null && right.downWord != null) return true;
final up = crossword.characters[location.up];
if (up != null && up.acrossWord != null) return true;
final down = crossword.characters[location.down];
if (down != null && down.acrossWord != null) return true;
return false;
}),
)
.forEach((location, character) {
b.locationsToTry.addAll({
location: switch ((character.acrossWord, character.downWord)) {
(null, null) => throw StateError(
'Character is not part of a word',
),
(null, _) => Direction.across,
(_, null) => Direction.down,
(_, _) => throw StateError('Character is part of two words'),
},
});
});
}
});
WorkQueue remove(Location location) => rebuild(
(b) => b
..locationsToTry.remove(location)
..badLocations.add(location),
);
/// Update the work queue from a crossword derived from the current crossword
/// that this work queue is built from.
WorkQueue updateFrom(final Crossword crossword) =>
WorkQueue.from(
crossword: crossword,
candidateWords: candidateWords,
startLocation: locationsToTry.isNotEmpty
? locationsToTry.keys.first
: Location.at(0, 0),
).rebuild(
(b) => b
..badLocations.addAll(badLocations)
..locationsToTry.removeWhere(
(location, _) => badLocations.contains(location),
),
);
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue, // Add this line
])
final Serializers serializers = _$serializers;
- Jeśli po dodaniu nowych treści do tego pliku czerwone zygzaki nadal się w nim pojawiają przez kilka sekund, sprawdź, czy
build_runner
nadal działa. Jeśli nie, uruchom poleceniedart run build_runner watch -d
.
W kodzie, do którego za chwilę dodasz rejestrowanie, zobaczysz, ile czasu zajmuje tworzenie krzyżówek o różnych rozmiarach. Dobrze byłoby, gdyby czas trwania był wyświetlany w ładnie sformatowanej formie. Na szczęście dzięki metodom rozszerzającym możemy dodać dokładnie taką metodę, jakiej potrzebujemy.
- Zmodyfikuj plik
utils.dart
w ten sposób:
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
// Add from here
/// An extension on [Duration] that adds a method to format the duration.
extension DurationFormat on Duration {
/// A human-readable string representation of the duration.
/// This format is tuned for durations in the seconds to days range.
String get formatted {
final hours = inHours.remainder(24).toString().padLeft(2, '0');
final minutes = inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = inSeconds.remainder(60).toString().padLeft(2, '0');
return switch ((inDays, inHours, inMinutes, inSeconds)) {
(0, 0, 0, _) => '${inSeconds}s',
(0, 0, _, _) => '$inMinutes:$seconds',
(0, _, _, _) => '$inHours:$minutes:$seconds',
_ => '$inDays days, $hours:$minutes:$seconds',
};
}
} // To here.
Ta metoda rozszerzania wykorzystuje wyrażenia switch i dopasowywanie wzorców do rekordów, aby wybrać odpowiedni sposób wyświetlania różnych czasów trwania, od sekund po dni. Więcej informacji o tym stylu kodu znajdziesz w samouczku Dive into Dart's patterns and records (po angielsku).
- Aby zintegrować tę nową funkcję, zastąp plik
isolates.dart
, aby ponownie zdefiniować funkcjęexploreCrosswordSolutions
w ten sposób:
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild(
(b) => b
..where((b) => b.characters.contains(target.character))
..shuffle(),
);
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword);
yield crossword;
} else {
workQueue = workQueue.remove(location);
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint(
'${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}',
);
}
Uruchomienie tego kodu spowoduje powstanie aplikacji, która na pierwszy rzut oka będzie wyglądać identycznie, ale różnica będzie polegać na tym, jak długo zajmie znalezienie gotowej krzyżówki. Oto krzyżówka o wymiarach 80 × 44 wygenerowana w 1 minutę i 29 sekund.
Punkt kontrolny: działanie wydajnego algorytmu
Generowanie krzyżówek powinno być teraz znacznie szybsze dzięki:
- Punkty przecięcia inteligentnego kierowania na miejsca docelowe
- Skuteczne wycofywanie się, gdy miejsca docelowe nie działają
- Zarządzanie kolejką zadań, aby uniknąć zbędnych wyszukiwań
Oczywiste pytanie brzmi: czy możemy przyspieszyć? Owszem, tak.
7. Statystyki interfejsu
Dlaczego warto dodawać statystyki?
Aby coś przyspieszyć, warto wiedzieć, co się dzieje. Statystyki pomagają monitorować postępy i sprawdzać skuteczność algorytmu w czasie rzeczywistym. Umożliwia identyfikowanie wąskich gardeł przez sprawdzenie, na co algorytm poświęca najwięcej czasu. Dzięki temu możesz dostosowywać skuteczność kampanii, podejmując przemyślane decyzje dotyczące metod optymalizacji.
Wyświetlane informacje muszą być wyodrębnione z kolejki WorkQueue i wyświetlane w interfejsie. Przydatnym pierwszym krokiem jest zdefiniowanie nowej klasy modelu, która zawiera informacje, które chcesz wyświetlić.
Aby rozpocząć, wykonaj następujące czynności:
- Aby dodać klasę
DisplayInfo
, zmodyfikuj plikmodel.dart
w ten sposób:
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
import 'package:intl/intl.dart'; // Add this import
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
- Na końcu pliku wprowadź te zmiany, aby dodać klasę
DisplayInfo
:
lib/model.dart
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
}
// Add from here.
/// Display information for the current state of the crossword solve.
abstract class DisplayInfo implements Built<DisplayInfo, DisplayInfoBuilder> {
static Serializer<DisplayInfo> get serializer => _$displayInfoSerializer;
/// The number of words in the grid.
String get wordsInGridCount;
/// The number of candidate words.
String get candidateWordsCount;
/// The number of locations to explore.
String get locationsToExploreCount;
/// The number of known bad locations.
String get knownBadLocationsCount;
/// The percentage of the grid filled.
String get gridFilledPercentage;
/// Construct a [DisplayInfo] instance from a [WorkQueue].
factory DisplayInfo.from({required WorkQueue workQueue}) {
final gridFilled =
(workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height));
final fmt = NumberFormat.decimalPattern();
return DisplayInfo(
(b) => b
..wordsInGridCount = fmt.format(workQueue.crossword.words.length)
..candidateWordsCount = fmt.format(workQueue.candidateWords.length)
..locationsToExploreCount = fmt.format(workQueue.locationsToTry.length)
..knownBadLocationsCount = fmt.format(workQueue.badLocations.length)
..gridFilledPercentage = '${(gridFilled * 100).toStringAsFixed(2)}%',
);
}
/// An empty [DisplayInfo] instance.
static DisplayInfo get empty => DisplayInfo(
(b) => b
..wordsInGridCount = '0'
..candidateWordsCount = '0'
..locationsToExploreCount = '0'
..knownBadLocationsCount = '0'
..gridFilledPercentage = '0%',
);
factory DisplayInfo([void Function(DisplayInfoBuilder)? updates]) =
_$DisplayInfo;
DisplayInfo._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo, // Add this line.
])
final Serializers serializers = _$serializers;
- Zmodyfikuj plik
isolates.dart
, aby udostępnić modelWorkQueue
w ten sposób:
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({ // Modify this line
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild(
(b) => b
..where((b) => b.characters.contains(target.character))
..shuffle(),
);
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword); // Drop the yield crossword;
} else {
workQueue = workQueue.remove(location);
}
yield workQueue; // Add this line.
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint(
'${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}',
);
}
Teraz, gdy izolacja w tle udostępnia kolejkę zadań, pozostaje kwestia, jak i gdzie uzyskać statystyki z tego źródła danych.
- Zastąp starego dostawcę krzyżówek dostawcą kolejki zadań, a potem dodaj kolejnych dostawców, którzy będą pobierać informacje ze strumienia dostawcy kolejki zadań:
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* { // Modify this provider
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
} // To here.
@Riverpod(keepAlive: true) // Add from here to end of file
class StartTime extends _$StartTime {
@override
DateTime? build() => _start;
DateTime? _start;
void start() {
_start = DateTime.now();
ref.invalidateSelf();
}
}
@Riverpod(keepAlive: true)
class EndTime extends _$EndTime {
@override
DateTime? build() => _end;
DateTime? _end;
void clear() {
_end = null;
ref.invalidateSelf();
}
void end() {
_end = DateTime.now();
ref.invalidateSelf();
}
}
const _estimatedTotalCoverage = 0.54;
@riverpod
Duration expectedRemainingTime(Ref ref) {
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final workQueueAsync = ref.watch(workQueueProvider);
return workQueueAsync.when(
data: (workQueue) {
if (startTime == null || endTime != null || workQueue.isCompleted) {
return Duration.zero;
}
try {
final soFar = DateTime.now().difference(startTime);
final completedPercentage = min(
0.99,
(workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height) /
_estimatedTotalCoverage),
);
final expectedTotal = soFar.inSeconds / completedPercentage;
final expectedRemaining = expectedTotal - soFar.inSeconds;
return Duration(seconds: expectedRemaining.toInt());
} catch (e) {
return Duration.zero;
}
},
error: (error, stackTrace) => Duration.zero,
loading: () => Duration.zero,
);
}
/// A provider that holds whether to display info.
@Riverpod(keepAlive: true)
class ShowDisplayInfo extends _$ShowDisplayInfo {
var _display = true;
@override
bool build() => _display;
void toggle() {
_display = !_display;
ref.invalidateSelf();
}
}
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref
.watch(workQueueProvider)
.when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
Nowi dostawcy to połączenie stanu globalnego, np. informacji o tym, czy wyświetlacz informacji powinien być nakładany na siatkę krzyżówki, oraz danych pochodnych, takich jak czas generowania krzyżówki. Wszystko to komplikuje fakt, że odbiorcy niektórych z tych stanów są przejściowi. Jeśli wyświetlanie informacji jest ukryte, nic nie nasłuchuje czasu rozpoczęcia i zakończenia obliczeń krzyżówki, ale te informacje muszą pozostać w pamięci, aby obliczenia były dokładne, gdy wyświetlanie informacji jest widoczne. W tym przypadku bardzo przydatny jest parametr keepAlive
atrybutu Riverpod
.
Wyświetlanie informacji ma jednak pewien mankament. Chcemy mieć możliwość wyświetlania czasu trwania, ale nie ma tu niczego, co wymuszałoby ciągłą aktualizację czasu trwania. W samouczku dotyczącym tworzenia interfejsów nowej generacji w Flutterze znajdziesz przydatny widżet, który spełnia to wymaganie.
- Utwórz w katalogu
lib/widgets
plikticker_builder.dart
, a następnie dodaj do niego tę treść:
lib/widgets/ticker_builder.dart
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// A Builder widget that invokes its [builder] function on every animation frame.
class TickerBuilder extends StatefulWidget {
const TickerBuilder({super.key, required this.builder});
final Widget Function(BuildContext context) builder;
@override
State<TickerBuilder> createState() => _TickerBuilderState();
}
class _TickerBuilderState extends State<TickerBuilder>
with SingleTickerProviderStateMixin {
late final Ticker _ticker;
@override
void initState() {
super.initState();
_ticker = createTicker(_handleTick)..start();
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
void _handleTick(Duration elapsed) {
setState(() {
// Force a rebuild without changing the widget tree.
});
}
@override
Widget build(BuildContext context) => widget.builder.call(context);
}
Ten widżet to młot. Odtwarza treści w każdej klatce. Zazwyczaj jest to odradzane, ale w porównaniu z obciążeniem obliczeniowym związanym z wyszukiwaniem krzyżówek obciążenie obliczeniowe związane z odświeżaniem upływającego czasu w każdej klatce prawdopodobnie zniknie w szumie. Aby wykorzystać te nowo uzyskane informacje, utwórz nowy widżet.
- Utwórz plik
crossword_info_widget.dart
w katalogulib/widgets
i dodaj do niego ten kod:
lib/widgets/crossword_info_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import '../utils.dart';
import 'ticker_builder.dart';
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 32.0, bottom: 32.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}',
),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount,
),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount,
),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount,
),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount,
),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage,
),
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted,
),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining',
value: remaining.formatted,
),
],
),
),
),
),
),
),
);
}
}
class _CrosswordInfoRichText extends StatelessWidget {
final String label;
final String value;
const _CrosswordInfoRichText({required this.label, required this.value});
@override
Widget build(BuildContext context) => RichText(
text: TextSpan(
children: [
TextSpan(text: '$label ', style: DefaultTextStyle.of(context).style),
TextSpan(
text: value,
style: DefaultTextStyle.of(
context,
).style.copyWith(fontWeight: FontWeight.bold),
),
],
),
);
}
Ten widżet jest doskonałym przykładem możliwości dostawców Riverpod. Gdy którykolwiek z 5 dostawców zaktualizuje dane, widżet zostanie oznaczony do ponownego utworzenia. Ostatnią wymaganą zmianą w tym kroku jest zintegrowanie nowego widżetu z interfejsem.
- Zmodyfikuj plik
crossword_generator_app.dart
w ten sposób:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_info_widget.dart'; // Add this import
import 'crossword_widget.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer( // Modify from here
builder: (context, ref, child) {
return Stack(
children: [
Positioned.fill(child: CrosswordWidget()),
if (ref.watch(showDisplayInfoProvider)) CrosswordInfoWidget(),
],
);
},
), // To here.
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton( // Add from here
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () => ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
), // To here.
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
Te 2 zmiany pokazują różne podejścia do integracji dostawców. W metodzie CrosswordGeneratorApp
build
wprowadziliśmy nowy Consumer
, który zawiera obszar wymuszający ponowne renderowanie, gdy wyświetlacz informacji jest widoczny lub ukryty. Z drugiej strony całe menu to jeden ConsumerWidget
, który zostanie przebudowany niezależnie od tego, czy zmieniasz rozmiar krzyżówki, czy wyświetlasz lub ukrywasz informacje. Wybór podejścia zawsze wiąże się z kompromisem między prostotą a kosztem ponownego obliczania układów przebudowanych drzew widżetów.
Uruchomienie aplikacji daje użytkownikowi więcej informacji o postępach w generowaniu krzyżówki. Pod koniec generowania krzyżówki widzimy jednak okres, w którym liczby się zmieniają, ale w siatce znaków zachodzą bardzo niewielkie zmiany.
Przydatne byłoby uzyskanie dodatkowych informacji o tym, co się dzieje i dlaczego.
8. Równoległe przetwarzanie za pomocą wątków
Dlaczego wydajność się pogarsza
Gdy krzyżówka jest prawie gotowa, algorytm zwalnia, ponieważ pozostaje mniej prawidłowych opcji umieszczenia słów. Algorytm wypróbowuje wiele kombinacji, które nie działają. Przetwarzanie jednowątkowe nie może skutecznie sprawdzać wielu opcji.
Wizualizacja algorytmu
Aby zrozumieć, dlaczego pod koniec proces zwalnia, warto zobaczyć, co robi algorytm. Kluczowym elementem jest znakomity locationsToTry
w WorkQueue
. Widok tabeli to przydatny sposób na zbadanie tego problemu. Możemy zmienić kolor komórki w zależności od tego, czy znajduje się ona w locationsToTry
.
Aby rozpocząć, wykonaj następujące czynności:
- Zmodyfikuj plik
crossword_widget.dart
w ten sposób:
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch( // Add from here
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
); // To here.
if (character != null) { // Modify from here
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text(character.character),
), // To here.
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
Po uruchomieniu tego kodu zobaczysz wizualizację nieprzebadanych jeszcze lokalizacji.
Ciekawą rzeczą w obserwowaniu postępów w rozwiązywaniu krzyżówki jest to, że pozostaje wiele punktów do zbadania, które nie przyniosą niczego przydatnego. Możesz to zrobić na 2 sposoby: ograniczyć sprawdzanie do momentu, w którym zostanie wypełniony określony odsetek pól krzyżówki, lub sprawdzać jednocześnie kilka punktów. Druga ścieżka wydaje się ciekawsza, więc czas ją wybrać.
- Edytuj plik
isolates.dart
. Jest to niemal całkowite przepisanie kodu, aby podzielić to, co było obliczane w jednym izolacie w tle, na pulę N izolatów w tle.
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
required int maxWorkerCount,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
try {
workQueue = await compute(_generate, (workQueue, maxWorkerCount));
yield workQueue;
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint(
'Generated ${workQueue.crossword.width} x '
'${workQueue.crossword.height} crossword in '
'${DateTime.now().difference(start).formatted} '
'with $maxWorkerCount workers.',
);
}
Future<WorkQueue> _generate((WorkQueue, int) workMessage) async {
var (workQueue, maxWorkerCount) = workMessage;
final candidateGeneratorFutures = <Future<(Location, Direction, String?)>>[];
final locations = workQueue.locationsToTry.keys.toBuiltList().rebuild(
(b) => b
..shuffle()
..take(maxWorkerCount),
);
for (final location in locations) {
final direction = workQueue.locationsToTry[location]!;
candidateGeneratorFutures.add(
compute(_generateCandidate, (
workQueue.crossword,
workQueue.candidateWords,
location,
direction,
)),
);
}
try {
final results = await candidateGeneratorFutures.wait;
var crossword = workQueue.crossword;
for (final (location, direction, word) in results) {
if (word != null) {
final candidate = crossword.addWord(
location: location,
word: word,
direction: direction,
);
if (candidate != null) {
crossword = candidate;
}
} else {
workQueue = workQueue.remove(location);
}
}
workQueue = workQueue.updateFrom(crossword);
} catch (e) {
debugPrint('$e');
}
return workQueue;
}
(Location, Direction, String?) _generateCandidate(
(Crossword, BuiltSet<String>, Location, Direction) searchDetailMessage,
) {
final (crossword, candidateWords, location, direction) = searchDetailMessage;
final target = crossword.characters[location];
if (target == null) {
return (location, direction, candidateWords.randomElement());
}
// Filter down the candidate word list to those that contain the letter
// at the current location
final words = candidateWords.toBuiltList().rebuild(
(b) => b
..where((b) => b.characters.contains(target.character))
..shuffle(),
);
int tryCount = 0;
final start = DateTime.now();
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return switch (direction) {
Direction.across => (location.leftOffset(index), direction, word),
Direction.down => (location.upOffset(index), direction, word),
};
}
final deltaTime = DateTime.now().difference(start);
if (tryCount >= 1000 || deltaTime > Duration(seconds: 10)) {
return (location, direction, null);
}
}
}
return (location, direction, null);
}
Omówienie architektury z wieloma izolacjami
Większość tego kodu powinna być Ci znana, ponieważ podstawowa logika biznesowa nie uległa zmianie. Zmieniło się to, że są teraz 2 warstwy połączeń compute
. Pierwsza warstwa odpowiada za przekazywanie poszczególnych pozycji do wyszukiwania do N izolatów roboczych, a następnie za ponowne łączenie wyników po zakończeniu pracy wszystkich N izolatów roboczych. Druga warstwa składa się z N izolatów instancji roboczych. Dostosowanie wartości N w celu uzyskania najlepszej wydajności zależy zarówno od komputera, jak i od danych. Im większa siatka, tym więcej pracowników może pracować razem, nie przeszkadzając sobie nawzajem.
Ciekawostką jest to, jak ten kod radzi sobie teraz z problemem zamykania funkcji, które przechwytują elementy, których nie powinny. Obecnie nie ma żadnych zamknięć. Funkcje _generate
i _generateWorker
są zdefiniowane jako funkcje najwyższego poziomu, które nie mają otoczenia, z którego można by pobrać dane. Argumenty i wyniki obu tych funkcji mają postać rekordów Dart. Pozwala to obejść semantykę wywołania compute
, która polega na tym, że jedna wartość wejściowa daje jedną wartość wyjściową.
Teraz, gdy masz możliwość utworzenia puli pracowników w tle, którzy będą wyszukiwać słowa pasujące do siebie w siatce, aby utworzyć krzyżówkę, możesz udostępnić tę funkcję reszcie narzędzia do generowania krzyżówek.
- Zmodyfikuj plik
providers.dart
, edytując dostawcę workQueue w ten sposób:
lib/providers.dart
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* {
final workers = ref.watch(workerCountProvider); // Add this line
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: workers.count, // Add this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
}
- Dodaj dostawcę
WorkerCount
na końcu pliku w ten sposób:
lib/providers.dart
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref
.watch(workQueueProvider)
.when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
enum BackgroundWorkers { // Add from here
one(1),
two(2),
four(4),
eight(8),
sixteen(16),
thirtyTwo(32),
sixtyFour(64),
oneTwentyEight(128);
const BackgroundWorkers(this.count);
final int count;
String get label => count.toString();
}
/// A provider that holds the current number of background workers to use.
@Riverpod(keepAlive: true)
class WorkerCount extends _$WorkerCount {
var _count = BackgroundWorkers.four;
@override
BackgroundWorkers build() => _count;
void setCount(BackgroundWorkers count) {
_count = count;
ref.invalidateSelf();
}
} // To here.
Dzięki tym 2 zmianom warstwa dostawcy udostępnia teraz sposób ustawiania maksymalnej liczby procesów roboczych w puli izolowanych procesów w tle, tak aby funkcje izolowane były prawidłowo skonfigurowane.
- Zaktualizuj plik
crossword_info_widget.dart
, modyfikującCrosswordInfoWidget
w ten sposób:
lib/widgets/crossword_info_widget.dart
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final workerCount = ref.watch(workerCountProvider).label; // Add this line
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 32.0, bottom: 32.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}',
),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount,
),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount,
),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount,
),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount,
),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage,
),
_CrosswordInfoRichText( // Add from here
label: 'Max worker count',
value: workerCount,
), // To here.
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted,
),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining',
value: remaining.formatted,
),
],
),
),
),
),
),
),
);
}
}
- Zmodyfikuj plik
crossword_generator_app.dart
, dodając do widżetu_CrosswordGeneratorMenu
tę sekcję:
lib/widgets/crossword_generator_app.dart
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton(
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () => ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
),
for (final count in BackgroundWorkers.values) // Add from here
MenuItemButton(
leadingIcon: count == ref.watch(workerCountProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
onPressed: () =>
ref.read(workerCountProvider.notifier).setCount(count),
child: Text(count.label), // To here.
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
Jeśli teraz uruchomisz aplikację, będziesz mieć możliwość modyfikowania liczby izolowanych procesów w tle, które są tworzone w celu wyszukiwania słów pasujących do krzyżówki.
- Kliknij ikonę koła zębatego, aby otworzyć menu kontekstowe zawierające opcje rozmiaru krzyżówki, wyświetlania statystyk wygenerowanej krzyżówki oraz liczbę izolowanych słów do użycia.
Punkt kontrolny: wydajność wielowątkowa
Uruchomienie generatora krzyżówek znacznie skróciło czas obliczeń w przypadku krzyżówki o wymiarach 80 x 44 – dzięki jednoczesnemu wykorzystaniu wielu rdzeni. Zauważysz, że:
- Szybsze generowanie krzyżówek dzięki większej liczbie pracowników
- Płynna responsywność interfejsu użytkownika podczas generowania
- Statystyki w czasie rzeczywistym pokazujące postępy w generowaniu
- Wizualne potwierdzenie obszarów eksploracji algorytmu
9. Zamień to w grę
Co tworzymy: interaktywna gra w krzyżówkę
Ostatnia sekcja to tak naprawdę runda dodatkowa. Wykorzystasz wszystkie techniki, których nauczyłeś się podczas tworzenia generatora krzyżówek, aby zbudować grę. W ramach ćwiczenia:
- Generowanie łamigłówek: użyj generatora krzyżówek, aby tworzyć łamigłówki, które można rozwiązać.
- Tworzenie opcji słów: podaj kilka opcji słów dla każdego miejsca.
- Włącz interakcję: umożliwia użytkownikom wybieranie i umieszczanie słów.
- Sprawdź rozwiązania: sprawdź, czy wypełniona krzyżówka jest prawidłowa.
Użyjesz generatora krzyżówek do utworzenia krzyżówki. Użyjesz idiomów menu kontekstowego, aby umożliwić użytkownikowi wybieranie i odznaczanie słów, które mają być umieszczane w różnych otworach w siatce. Wszystko po to, aby rozwiązać krzyżówkę.
Nie powiem, że ta gra jest dopracowana lub ukończona, bo w rzeczywistości jest daleka od tego. Występują problemy z równowagą i trudnością, które można rozwiązać, poprawiając wybór alternatywnych słów. Nie ma samouczka, który wprowadzałby użytkowników w łamigłówkę. Nie wspomnę nawet o minimalistycznym ekranie „Wygrałeś(-aś)!”.
W tym przypadku jednak, aby przekształcić tę prototypową grę w pełną wersję, trzeba będzie napisać znacznie więcej kodu. Więcej kodu niż powinno być w jednym laboratorium. Jest to więc krok, który ma na celu utrwalenie technik poznanych do tej pory w tym laboratorium, poprzez zmianę miejsca i sposobu ich użycia. Mam nadzieję, że utrwali to wiedzę zdobytą wcześniej w tym laboratorium. Możesz też na podstawie tego kodu tworzyć własne rozwiązania. Chętnie zobaczymy, co udało Ci się stworzyć.
Aby rozpocząć, wykonaj następujące czynności:
- Usuń wszystko z katalogu
lib/widgets
. Będziesz tworzyć nowe widżety do swojej gry. który w dużej mierze korzysta ze starych widżetów.
- Edytuj plik
model.dart
, aby zaktualizować metodęaddWord
Crossword
w ten sposób:
lib/model.dart
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
bool requireOverlap = true, // Add this parameter
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
// Edit from here
// If overlap is required, make sure that the word overlaps with an existing
// word. Skip this test if the crossword is empty.
if (words.isNotEmpty && !overlap && requireOverlap) { // To here.
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
Ta niewielka modyfikacja modelu krzyżówki umożliwia dodawanie słów, które się nie pokrywają. Pozwala to graczom grać w dowolnym miejscu na planszy i nadal używać Crossword
jako modelu bazowego do przechowywania ruchów gracza. To tylko lista słów w określonych miejscach, umieszczonych w określonym kierunku.
- Dodaj klasę modelu
CrosswordPuzzleGame
na końcu plikumodel.dart
.
lib/model.dart
/// Creates a puzzle from a crossword and a set of candidate words.
abstract class CrosswordPuzzleGame
implements Built<CrosswordPuzzleGame, CrosswordPuzzleGameBuilder> {
static Serializer<CrosswordPuzzleGame> get serializer =>
_$crosswordPuzzleGameSerializer;
/// The [Crossword] that this puzzle is based on.
Crossword get crossword;
/// The alternate words for each [CrosswordWord] in the crossword.
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>> get alternateWords;
/// The player's selected words.
BuiltList<CrosswordWord> get selectedWords;
bool canSelectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return true;
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild(
(b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
),
);
}
return null !=
puzzle.crosswordFromSelectedWords.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false,
);
}
CrosswordPuzzleGame? selectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return rebuild((b) => b.selectedWords.remove(crosswordWord));
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild(
(b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
),
);
}
// Check if the selected word meshes with the already selected words.
// Note this version of the crossword does not enforce overlap to
// allow the player to select words anywhere on the grid. Enforcing words
// to be solved in order is a possible alternative.
final updatedSelectedWordsCrossword = puzzle.crosswordFromSelectedWords
.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false,
);
// Make sure the selected word is in the crossword or is an alternate word.
if (updatedSelectedWordsCrossword != null) {
if (puzzle.crossword.words.contains(crosswordWord) ||
puzzle.alternateWords[location]?[direction]?.contains(word) == true) {
return puzzle.rebuild(
(b) => b
..selectedWords.add(
CrosswordWord.word(
word: word,
location: location,
direction: direction,
),
),
);
}
}
return null;
}
/// The crossword from the selected words.
Crossword get crosswordFromSelectedWords => Crossword.crossword(
width: crossword.width,
height: crossword.height,
words: selectedWords,
);
/// Test if the puzzle is solved. Note, this allows for the possibility of
/// multiple solutions.
bool get solved =>
crosswordFromSelectedWords.valid &&
crosswordFromSelectedWords.words.length == crossword.words.length &&
crossword.words.isNotEmpty;
/// Create a crossword puzzle game from a crossword and a set of candidate
/// words.
factory CrosswordPuzzleGame.from({
required Crossword crossword,
required BuiltSet<String> candidateWords,
}) {
// Remove all of the currently used words from the list of candidates
candidateWords = candidateWords.rebuild(
(p0) => p0.removeAll(crossword.words.map((p1) => p1.word)),
);
// This is the list of alternate words for each word in the crossword
var alternates =
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>>();
// Build the alternate words for each word in the crossword
for (final crosswordWord in crossword.words) {
final alternateWords = candidateWords.toBuiltList().rebuild(
(b) => b
..where((b) => b.length == crosswordWord.word.length)
..shuffle()
..take(4)
..sort(),
);
candidateWords = candidateWords.rebuild(
(b) => b.removeAll(alternateWords),
);
alternates = alternates.rebuild(
(b) => b.updateValue(
crosswordWord.location,
(b) => b.rebuild(
(b) => b.updateValue(
crosswordWord.direction,
(b) => b.rebuild((b) => b.replace(alternateWords)),
ifAbsent: () => alternateWords,
),
),
ifAbsent: () => {crosswordWord.direction: alternateWords}.build(),
),
);
}
return CrosswordPuzzleGame((b) {
b
..crossword.replace(crossword)
..alternateWords.replace(alternates);
});
}
factory CrosswordPuzzleGame([
void Function(CrosswordPuzzleGameBuilder)? updates,
]) = _$CrosswordPuzzleGame;
CrosswordPuzzleGame._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo,
CrosswordPuzzleGame, // Add this line
])
final Serializers serializers = _$serializers;
Aktualizacje pliku providers.dart
to ciekawy zbiór zmian. Większość dostawców, którzy byli obecni, aby wspierać zbieranie statystyk, została usunięta. Możliwość zmiany liczby izolowanych elementów tła została usunięta i zastąpiona stałą. Jest też nowy dostawca, który daje dostęp do nowego modelu CrosswordPuzzleGame
dodanego wcześniej.
lib/providers.dart
import 'dart:convert';
// Drop the dart:math import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
const backgroundWorkerCount = 4; // Add this line
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* {
final size = ref.watch(sizeProvider); // Drop the ref.watch(workerCountProvider)
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
// Drop the startTimeProvider and endTimeProvider refs
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: backgroundWorkerCount, // Edit this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
} // Drop the endTimeProvider ref
@riverpod // Add from here to end of file
class Puzzle extends _$Puzzle {
model.CrosswordPuzzleGame _puzzle = model.CrosswordPuzzleGame.from(
crossword: model.Crossword.crossword(width: 0, height: 0),
candidateWords: BuiltSet<String>(),
);
@override
model.CrosswordPuzzleGame build() {
final size = ref.watch(sizeProvider);
final wordList = ref.watch(wordListProvider).value;
final workQueue = ref.watch(workQueueProvider).value;
if (wordList != null &&
workQueue != null &&
workQueue.isCompleted &&
(_puzzle.crossword.height != size.height ||
_puzzle.crossword.width != size.width ||
_puzzle.crossword != workQueue.crossword)) {
compute(_puzzleFromCrosswordTrampoline, (
workQueue.crossword,
wordList,
)).then((puzzle) {
_puzzle = puzzle;
ref.invalidateSelf();
});
}
return _puzzle;
}
Future<void> selectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) async {
final candidate = await compute(_puzzleSelectWordTrampoline, (
_puzzle,
location,
word,
direction,
));
if (candidate != null) {
_puzzle = candidate;
ref.invalidateSelf();
} else {
debugPrint('Invalid word selection: $word');
}
}
bool canSelectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) {
return _puzzle.canSelectWord(
location: location,
word: word,
direction: direction,
);
}
}
// Trampoline functions to disentangle these Isolate target calls from the
// unsendable reference to the [Puzzle] provider.
Future<model.CrosswordPuzzleGame> _puzzleFromCrosswordTrampoline(
(model.Crossword, BuiltSet<String>) args,
) async =>
model.CrosswordPuzzleGame.from(crossword: args.$1, candidateWords: args.$2);
model.CrosswordPuzzleGame? _puzzleSelectWordTrampoline(
(model.CrosswordPuzzleGame, model.Location, String, model.Direction) args,
) => args.$1.selectWord(location: args.$2, word: args.$3, direction: args.$4);
Najciekawsze aspekty Puzzle
to strategie, które mają na celu zatuszowanie kosztów utworzenia CrosswordPuzzleGame
z Crossword
i wordList
oraz kosztów wyboru słowa. Obie te czynności wykonywane bez pomocy funkcji izolowania tła mogą powodować powolne działanie interfejsu. Dzięki zastosowaniu pewnego triku polegającego na wyświetleniu wyniku pośredniego podczas obliczania wyniku końcowego w tle uzyskasz responsywny interfejs, a wymagane obliczenia będą wykonywane w tle.
- W pustym katalogu
lib/widgets
utwórz plikcrossword_puzzle_app.dart
z tą zawartością:
lib/widgets/crossword_puzzle_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_generator_widget.dart';
import 'crossword_puzzle_widget.dart';
import 'puzzle_completed_widget.dart';
class CrosswordPuzzleApp extends StatelessWidget {
const CrosswordPuzzleApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordPuzzleAppMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Puzzle'),
),
body: SafeArea(
child: Consumer(
builder: (context, ref, _) {
final workQueueAsync = ref.watch(workQueueProvider);
final puzzleSolved = ref.watch(
puzzleProvider.select((puzzle) => puzzle.solved),
);
return workQueueAsync.when(
data: (workQueue) {
if (puzzleSolved) {
return PuzzleCompletedWidget();
}
if (workQueue.isCompleted &&
workQueue.crossword.characters.isNotEmpty) {
return CrosswordPuzzleWidget();
}
return CrosswordGeneratorWidget();
},
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(child: Text('$error')),
);
},
),
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordPuzzleAppMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
Większość tego pliku powinna być już znana. Tak, będą nieokreślone widżety, które teraz zaczniesz naprawiać.
- Utwórz plik
crossword_generator_widget.dart
i dodaj do niego tę treść:
lib/widgets/crossword_generator_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordGeneratorWidget extends ConsumerWidget {
const CrosswordGeneratorWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
);
if (character != null) {
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text('•'), // https://www.compart.com/en/unicode/U+2022
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
Powinno to być dość oczywiste. Główna różnica polega na tym, że zamiast wyświetlać znaki generowanych słów, wyświetlasz teraz znak Unicode, który oznacza obecność nieznanego znaku. Warto popracować nad estetyką.
- Utwórz plik
crossword_puzzle_widget.dart
i dodaj do niego tę treść:
lib/widgets/crossword_puzzle_widget.dart
import 'package:built_collection/built_collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordPuzzleWidget extends ConsumerWidget {
const CrosswordPuzzleWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
puzzleProvider.select(
(puzzle) => puzzle.crossword.characters[location],
),
);
final selectedCharacter = ref.watch(
puzzleProvider.select(
(puzzle) =>
puzzle.crosswordFromSelectedWords.characters[location],
),
);
final alternateWords = ref.watch(
puzzleProvider.select((puzzle) => puzzle.alternateWords),
);
if (character != null) {
final acrossWord = character.acrossWord;
var acrossWords = BuiltList<String>();
if (acrossWord != null) {
acrossWords = acrossWords.rebuild(
(b) => b
..add(acrossWord.word)
..addAll(
alternateWords[acrossWord.location]?[acrossWord
.direction] ??
[],
)
..sort(),
);
}
final downWord = character.downWord;
var downWords = BuiltList<String>();
if (downWord != null) {
downWords = downWords.rebuild(
(b) => b
..add(downWord.word)
..addAll(
alternateWords[downWord.location]?[downWord.direction] ??
[],
)
..sort(),
);
}
return MenuAnchor(
builder: (context, controller, _) {
return GestureDetector(
onTapDown: (details) =>
controller.open(position: details.localPosition),
child: AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
child: Text(selectedCharacter?.character ?? ''),
),
),
),
);
},
menuChildren: [
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Across'),
),
for (final word in acrossWords)
_WordSelectMenuItem(
location: acrossWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.across,
),
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Down'),
),
for (final word in downWords)
_WordSelectMenuItem(
location: downWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.down,
),
],
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
class _WordSelectMenuItem extends ConsumerWidget {
const _WordSelectMenuItem({
required this.location,
required this.word,
required this.selectedCharacter,
required this.direction,
});
final Location location;
final String word;
final CrosswordCharacter? selectedCharacter;
final Direction direction;
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(puzzleProvider.notifier);
return MenuItemButton(
onPressed:
ref.watch(
puzzleProvider.select(
(puzzle) => puzzle.canSelectWord(
location: location,
word: word,
direction: direction,
),
),
)
? () => notifier.selectWord(
location: location,
word: word,
direction: direction,
)
: null,
leadingIcon:
switch (direction) {
Direction.across => selectedCharacter?.acrossWord?.word == word,
Direction.down => selectedCharacter?.downWord?.word == word,
}
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(word),
);
}
}
Ten widżet jest nieco bardziej złożony niż poprzedni, mimo że został utworzony z elementów, które były już używane w innych miejscach. Teraz po kliknięciu każdej wypełnionej komórki wyświetla się menu kontekstowe z listą słów, które użytkownik może wybrać. Jeśli słowa zostały wybrane, nie można wybrać słów, które z nimi kolidują. Aby odznaczyć słowo, użytkownik klika element menu odpowiadający temu słowu.
Zakładając, że gracz może wybrać słowa, aby wypełnić całą krzyżówkę, musisz mieć ekran „Gratulacje!”.
- Utwórz plik
puzzle_completed_widget.dart
i dodaj do niego tę treść:
lib/widgets/puzzle_completed_widget.dart
import 'package:flutter/material.dart';
class PuzzleCompletedWidget extends StatelessWidget {
const PuzzleCompletedWidget({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Text(
'Puzzle Completed!',
style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
),
);
}
}
Jestem pewien, że możesz to wykorzystać i uczynić bardziej interesującym. Więcej informacji o narzędziach do animacji znajdziesz w samouczku Building next generation UIs in Flutter.
- Zmodyfikuj plik
lib/main.dart
w ten sposób:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_puzzle_app.dart'; // Update this line
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Puzzle', // Update this line
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordPuzzleApp(), // Update this line
),
),
);
}
Po uruchomieniu tej aplikacji zobaczysz animację, gdy generator krzyżówek będzie tworzyć Twoją łamigłówkę. Następnie wyświetli się pusta łamigłówka do rozwiązania. Po rozwiązaniu zadania powinien wyświetlić się ekran podobny do tego:
10. Gratulacje
Gratulacje! Udało Ci się stworzyć grę logiczną za pomocą Fluttera.
Stworzyłeś generator krzyżówek, który stał się grą logiczną. Masz już opanowane uruchamianie obliczeń w tle w puli izolowanych środowisk. Używasz niezmiennych struktur danych, aby ułatwić wdrożenie algorytmu wycofywania. Poświęciłeś(-aś) czas na naukę TableView
, co przyda Ci się przy następnym wyświetlaniu danych tabelarycznych.