1. Прежде чем начать
Представьте, что вас спрашивают, возможно ли создать самый большой кроссворд в мире. Вы вспоминаете некоторые методы искусственного интеллекта, которые изучали в школе, и задаётесь вопросом, можно ли использовать Flutter для исследования алгоритмических возможностей решения сложных вычислительных задач.
В этой лабораторной работе вы сделаете именно это. В результате вы создадите инструмент для работы с алгоритмами построения кроссвордов. Существует множество различных определений корректного кроссворда, и эти методы помогут вам создавать головоломки, соответствующие вашему определению.
Используя этот инструмент в качестве основы, вы создаёте кроссворд, используя генератор кроссвордов для его решения. Эта головоломка доступна на Android, iOS, Windows, macOS и Linux. Вот как она выглядит на Android:
Предпосылки
- Завершение лабораторной работы по созданию вашего первого приложения Flutter
Чему вы учитесь
- Как использовать изоляты для выполнения ресурсоемкой вычислительной работы, не препятствуя при этом циклу рендеринга Flutter, с помощью комбинации
compute
функции Flutter и возможностей кэширования значений фильтра перестроенияselect
. - Как использовать преимущества неизменяемых структур данных с
built_value
иbuilt_collection
для реализации методов старого доброго искусственного интеллекта (GOFAI) на основе поиска, таких как поиск в глубину и поиск с возвратом. - Как использовать возможности пакета
two_dimensional_scrollables
для быстрого и интуитивно понятного отображения данных сетки.
Что вам нужно
- Flutter SDK .
- Visual Studio Code (VS Code) с плагинами Flutter и Dart .
- Компилятор для выбранной вами целевой платформы разработки. Эта практическая работа подходит для всех настольных платформ, Android и iOS. Вам понадобится VS Code для Windows, Xcode для macOS или iOS и Android Studio для Android.
2. Создать проект
Создайте свой первый проект Flutter
- Запустите VS Code.
- Откройте палитру команд (Ctrl+Shift+P в Windows/Linux, Cmd+Shift+P в macOS), введите «flutter new», а затем выберите в меню Flutter: New Project .
- Выберите «Пустое приложение» и укажите каталог для создания проекта. Это может быть любой каталог, не требующий повышенных привилегий и не содержащий пробелов в пути. Например, домашний каталог или
C:\src\
.
- Назовите свой проект
generate_crossword
. В оставшейся части этой лабораторной работы предполагается, что вы назвали своё приложениеgenerate_crossword
.
Flutter создаст папку вашего проекта, а VS Code откроет её. Теперь вам нужно перезаписать содержимое двух файлов базовым шаблоном приложения.
Скопируйте и вставьте исходное приложение
- На левой панели VS Code нажмите «Проводник» и откройте файл
pubspec.yaml
.
- Замените содержимое этого файла следующими зависимостями, необходимыми для генерации кроссворда:
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
Файл pubspec.yaml
содержит основную информацию о вашем приложении, такую как текущая версия и зависимости. Вы видите набор зависимостей, которые не являются частью обычного пустого приложения Flutter. Вы воспользуетесь всеми этими пакетами на следующих этапах.
Понимание зависимостей
Прежде чем углубляться в код, давайте разберемся, почему выбраны именно эти пакеты:
- built_value : создает неизменяемые объекты, которые эффективно разделяют память, что крайне важно для нашего алгоритма возврата.
- Riverpod : обеспечивает детальное управление состоянием с помощью
select()
для минимизации пересборок. - two_dimensional_scrollables : обрабатывает большие сетки без потери производительности
- Откройте файл
main.dart
в каталогеlib/
.
- Замените содержимое этого файла следующим:
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)),
),
),
),
),
);
}
- Запустите этот код, чтобы проверить, всё ли работает. Он должен отобразить новое окно с обязательной начальной фразой каждого нового проекта. Имеется атрибут
ProviderScope
указывающий, что это приложение будет использоватьriverpod
для управления состоянием.
Контрольная точка: базовый запуск приложения
В этот момент вы должны увидеть окно «Hello, World!». Если нет:
- Проверьте правильность установки Flutter.
- Проверьте, работает ли приложение с помощью
flutter run
- Убедитесь в отсутствии ошибок компиляции в терминале.
3. Добавьте слова
Строительные блоки для кроссворда
Кроссворд, по сути, представляет собой список слов. Слова расположены в сетке, некоторые по горизонтали, некоторые по вертикали, так что слова переплетаются. Разгадав одно слово, вы получите подсказки о словах, пересекающих это первое слово. Таким образом, хорошим первым строительным блоком будет список слов.
Хорошим источником этих слов является страница Питера Норвига «Данные о корпусе естественного языка» . Список SOWPODS , содержащий 267 750 слов, может стать хорошей отправной точкой.
На этом этапе вы загружаете список слов, добавляете его в качестве ресурса в свое приложение Flutter и подключаете провайдера Riverpod для загрузки списка в приложение при запуске.
Для начала выполните следующие шаги:
- Измените файл
pubspec.yaml
вашего проекта, добавив следующее объявление ресурсов для выбранного списка слов. В этом списке показан только фрагмент конфигурации вашего приложения, так как всё остальное осталось прежним.
pubspec.yaml
flutter:
uses-material-design: true
assets: # Add this line
- assets/words.txt # And this one.
Ваш редактор, вероятно, выделит эту последнюю строку предупреждением, поскольку вы еще не создали этот файл.
- Используя браузер и редактор, создайте каталог
assets
на верхнем уровне вашего проекта и создайте в нем файлwords.txt
с одним из списков слов, ссылки на которые приведены ранее.
Этот код был разработан с использованием списка SOWPODS, упомянутого ранее, но должен работать с любым списком слов, состоящим только из символов A-Z. Расширение этой кодовой базы для работы с другими наборами символов предоставляется читателю в качестве упражнения.
Загрузить слова
Чтобы написать код, отвечающий за загрузку списка слов при запуске приложения, выполните следующие действия:
- Создайте файл
providers.dart
в каталогеlib
. - Добавьте в файл следующее:
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)),
);
}
Это ваш первый поставщик Riverpod для этой кодовой базы.
Как работает этот провайдер:
- Асинхронно загружает список слов из ресурсов
- Фильтрует слова, включая только символы az длиной более 2 букв.
- Возвращает неизменяемый
BuiltSet
для эффективного произвольного доступа.
В этом проекте используется генерация кода для нескольких зависимостей, включая Riverpod.
- Чтобы начать генерацию кода, выполните следующую команду:
$ 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)
Он продолжит работать в фоновом режиме, обновляя сгенерированные файлы по мере внесения изменений в проект. После того, как эта команда сгенерирует код в providers.g.dart
, ваш редактор должен быть доволен кодом, добавленным в providers.dart
.
В Riverpod поставщики, такие как функция wordList
, которую вы определили ранее, обычно создаются лениво. Однако для целей этого приложения вам необходимо, чтобы список слов загружался с усердной загрузкой. В документации Riverpod предлагается следующий подход к работе с поставщиками, которые требуют усердной загрузки. Сейчас мы его реализуем.
- Создайте файл
crossword_generator_app.dart
в каталогеlib/widgets
. - Добавьте в файл следующее:
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;
}
}
Этот файл интересен с двух сторон. Первый — это виджет _EagerInitialization
, единственная задача которого — запрашивать у ранее созданного вами поставщика wordList
загрузку списка слов. Этот виджет достигает этой цели, прослушивая поставщика с помощью вызова ref.watch()
. Подробнее об этом методе можно узнать в документации Riverpod, посвящённой инициализации поставщиков Eager .
Второй интересный момент в этом файле, на который стоит обратить внимание, — это то, как Riverpod обрабатывает асинхронный контент. Как вы, возможно, помните, поставщик wordList
определён как асинхронная функция, поскольку загрузка контента с диска происходит медленно. Наблюдая за поставщиком списка слов в этом коде, вы получаете AsyncValue<BuiltSet<String>>
. Часть AsyncValue
этого типа является адаптером между асинхронным миром поставщиков и синхронным миром метода build
виджета.
Метод when
AsyncValue
обрабатывает три возможных состояния, в которых может находиться значение future. Future может быть успешно разрешён, и в этом случае вызывается обратный вызов data
, может находиться в состоянии ошибки, и в этом случае вызывается обратный вызов error
, или, наконец, всё ещё загружаться. Типы возвращаемых значений трёх обратных вызовов должны быть совместимыми, поскольку возвращаемое значение вызванного обратного вызова возвращается методом when
. В этом случае результат метода when отображается в виде body
виджета Scaffold
.
Создайте приложение с почти бесконечным списком
Чтобы интегрировать виджет CrosswordGeneratorApp
в ваше приложение, выполните следующие действия:
- Обновите файл
lib/main.dart
, добавив следующий код:
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
),
),
);
}
- Перезапустите приложение. Вы увидите прокручиваемый список, включающий все 267 750+ слов из словаря.
Что вы будете строить дальше
Теперь вы создадите основные структуры данных для своего кроссворда, используя неизменяемые объекты. Эта основа обеспечит эффективность алгоритмов и плавное обновление пользовательского интерфейса.
4. Отобразите слова в сетке.
На этом этапе вы создадите структуру данных для создания кроссворда с помощью пакетов built_value
и built_collection
. Эти два пакета позволяют создавать структуры данных как неизменяемые значения, что будет полезно как для передачи данных между изолятами, так и для упрощения реализации поиска в глубину и поиска с возвратом.
Для начала выполните следующие шаги:
- Создайте файл
model.dart
в каталогеlib
, а затем добавьте в файл следующее содержимое:
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;
В этом файле описывается начало структуры данных, которую вы будете использовать для создания кроссвордов. По сути, кроссворд представляет собой список горизонтальных и вертикальных слов, объединенных в сетку. Чтобы использовать эту структуру данных, вы создаете Crossword
соответствующего размера с помощью конструктора Crossword.crossword
, а затем добавляете слова с помощью метода addWord
. В рамках формирования итогового значения метод _fillCharacters
создает сетку из символов CrosswordCharacter
.
Чтобы использовать эту структуру данных, выполните следующие действия:
- Создайте файл
utils
в каталогеlib
, а затем добавьте в него следующее содержимое:
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));
}
}
Это расширение для BuiltSet
, которое упрощает извлечение случайного элемента множества. Методы расширения — хороший способ расширить классы, добавив дополнительную функциональность. Необходимо указать имя расширения, чтобы оно было доступно за пределами файла utils.dart
.
- В файле
lib/providers.dart
добавьте следующие импорты:
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 {
Эти импорты предоставляют ранее определённую модель поставщикам, которые вы собираетесь создать. Импорт dart:math
включён для Random
, импорт flutter/foundation.dart
— для debugPrint
, импорт model.dart
для model, а utils.dart
для расширения BuiltSet
.
- В конце того же файла добавьте следующих поставщиков:
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;
},
);
}
Эти изменения добавляют в ваше приложение два поставщика. Первый — Size
, который фактически представляет собой глобальную переменную, содержащую выбранное значение перечисления CrosswordSize
. Это позволит пользовательскому интерфейсу отображать и задавать размер создаваемого кроссворда. Второй поставщик, crossword
, представляет собой более интересное творение. Это функция, возвращающая серию Crossword
. Она построена с использованием поддержки генераторов Dart, что отмечено символом async*
в имени функции. Это означает, что вместо завершения на return она возвращает серию Crossword
, что значительно упрощает запись вычисления, возвращающего промежуточные результаты.
Из-за наличия пары вызовов ref.watch
в начале функции поставщика crossword
поток Crossword
будет перезапускаться системой Riverpod каждый раз, когда изменяется выбранный размер кроссворда и когда завершается загрузка списка слов.
Теперь, когда у вас есть код для генерации кроссвордов, пусть и наполненных случайными словами, было бы неплохо показать их пользователю инструмента.
- Создайте файл
crossword_widget.dart
в каталогеlib/widgets
со следующим содержимым:
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,
),
),
),
);
}
}
Этот виджет, являясь ConsumerWidget
, может напрямую полагаться на поставщик Size
для определения размера сетки, на которой будут отображаться символы Crossword
. Отображение этой сетки осуществляется с помощью виджета TableView
из пакета two_dimensional_scrollables
.
Стоит отметить, что каждая из отдельных ячеек, отрисовываемых вспомогательными функциями _buildCell
, содержит в возвращаемом дереве Widget
виджет Consumer
. Это служит границей обновления. Всё содержимое виджета Consumer
пересоздаётся при изменении возвращаемого значения ref.watch
. Возникает соблазн пересоздавать всё дерево при каждом изменении Crossword
, однако это требует большого объёма вычислений, который можно избежать, используя данную настройку.
Если взглянуть на параметр ref.watch
, можно увидеть ещё один способ избежать пересчёта макетов — использование crosswordProvider.select
. Это означает, что ref.watch
будет инициировать пересчёт содержимого TableViewCell
только при изменении символа, за отрисовку которого отвечает ячейка. Такое сокращение числа повторных отрисовок является важным фактором обеспечения адаптивности пользовательского интерфейса.
Чтобы предоставить пользователю доступ к CrosswordWidget
и поставщику Size
, измените файл crossword_generator_app.dart
следующим образом:
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.
}
Здесь произошло несколько изменений. Во-первых, код, отвечающий за отображение wordList
в виде ListView
, был заменён вызовом CrosswordWidget
, определённого в файле lib/widgets/crossword_widget.dart
. Другое важное изменение — это запуск меню для изменения поведения приложения, которое начинается с изменения размера кроссворда. В дальнейшем мы добавим больше кнопок MenuItemButton
. Запустите приложение, и вы увидите что-то вроде этого:
Символы отображаются в сетке, а также есть меню, позволяющее пользователю менять размер сетки. Но слова расположены не так, как в кроссворде. Это результат отсутствия ограничений на добавление слов в кроссворд. Короче говоря, это полный бардак. На следующем этапе вы начнёте его контролировать!
5. Обеспечить соблюдение ограничений
Что мы меняем и почему
В настоящее время ваш кроссворд допускает перекрывающиеся слова без проверки. Вы добавите проверку ограничений, чтобы слова правильно соединялись, как в настоящем кроссворде.
Цель этого шага — добавление кода в модель для реализации ограничений кроссворда. Существует множество различных типов кроссвордов, и стиль, который будет реализован в этой лабораторной работе, соответствует традициям английских кроссвордов. Модификация этого кода для генерации других типов кроссвордов, как всегда, предоставляется читателю в качестве упражнения.
Для начала выполните следующие шаги:
- Откройте файл
model.dart
и замените только модельCrossword
следующим:
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._();
}
Напоминаем, что для внесения изменений в файлы model.dart
и providers.dart
требуется запущенный build_runner
для обновления соответствующих файлов model.g.dart
и providers.g.dart
. Если эти файлы не обновились автоматически, сейчас самое время снова запустить build_runner
с помощью dart run build_runner watch -d
.
Чтобы воспользоваться этой новой возможностью на уровне модели, необходимо обновить уровень поставщика соответствующим образом.
- Отредактируйте файл
providers.dart
следующим образом:
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;
},
);
}
- Запустите приложение. В пользовательском интерфейсе ничего особенного не происходит, но, если посмотреть на логи, там много всего интересного.
Если задуматься, то мы видим, как случайно появляется кроссворд. Метод addWord
в модели Crossword
отклоняет любое предложенное слово, которое не подходит к текущему кроссворду, поэтому удивительно, что мы вообще видим появление чего-то.
Зачем переходить на фоновую обработку?
Вы можете заметить, что пользовательский интерфейс перестаёт реагировать во время генерации кроссворда. Это происходит потому, что генерация кроссворда включает в себя тысячи проверок. Эти вычисления блокируют цикл рендеринга Flutter с частотой 60 кадров в секунду, поэтому вы переносите тяжёлые вычисления на фоновые процессы. Преимущество такого подхода заключается в том, что пользовательский интерфейс остаётся плавным, пока генерация головоломки происходит в фоновом режиме.
Чтобы более методично подходить к выбору слов для проверки, было бы очень полезно вынести эти вычисления из потока пользовательского интерфейса в изолированный фоновый процесс. Во Flutter есть очень полезная обёртка для выполнения части работы в изолированном фоновом режиме — функция compute
.
- В файле
providers.dart
измените поставщика кроссворда следующим образом:
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;
},
);
}
Понимание ограничений изоляции
Этот код работает, но имеет скрытую проблему. У изолятов есть строгие правила относительно того, какие данные могут передаваться между ними, а проблема заключается в том, что замыкание «захватывает» ссылку на поставщика, которую нельзя сериализовать и передать другому изоляту.
Вы увидите это, когда система попытается отправить несериализуемые данные:
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)
Это результат того, что замыкание, которое compute
передаёт фоновому изолированному объекту, закрывающемуся через поставщика, который невозможно отправить через SendPort.send()
. Одним из способов решения этой проблемы является обеспечение отсутствия в замыкании данных, которые нельзя отправить.
Первым шагом является отделение поставщиков от кода изоляции.
- Создайте файл
isolates.dart
в каталогеlib
, а затем добавьте в него следующее содержимое:
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');
}
}
}
Этот код должен показаться вам знакомым. Это ядро того, что было в поставщике crossword
, но теперь в виде отдельной функции-генератора. Теперь вы можете обновить файл providers.dart
, чтобы использовать эту новую функцию для создания изолированного фонового объекта.
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.
},
);
}
Теперь у вас есть инструмент для создания кроссвордов разных размеров, при этом compute
головоломки происходит в фоновом режиме. Хотелось бы, чтобы код мог эффективнее определять, какие слова добавить в кроссворд.
6. Управляйте очередью работ
Понимание стратегии поиска
При создании кроссворда используется метод возврата — систематический метод проб и ошибок. Сначала приложение пытается поместить слово в определённое место, затем проверяет, подходит ли оно к существующим словам. Если подходит, сохраняйте слово и пробуйте следующее. Если нет, удаляйте его и пробуйте в другом месте.
Откат работает в кроссвордах, поскольку каждое расположение слов создаёт ограничения для последующих слов, где неверные расположения быстро обнаруживаются и отбрасываются. Неизменяемые структуры данных делают отмену изменений эффективной.
Проблема текущего кода отчасти заключается в том, что решаемая задача фактически является задачей поиска, а текущее решение — это поиск вслепую. Если код сосредоточится на поиске слов, которые будут соответствовать текущим словам, а не будет пытаться случайным образом разместить слова в любом месте сетки, то система будет находить решения быстрее. Один из способов решения этой проблемы — ввести очередь мест, для которых нужно попытаться найти слова.
Код строит возможные решения, проверяет их валидность и, в зависимости от валидности, либо включает их в набор, либо отбрасывает. Это пример реализации из семейства алгоритмов с возвратом. Реализацию значительно упрощают встроенные функции built_value
и built_collection
, которые позволяют создавать новые неизменяемые значения, производные от неизменяемого значения, из которого они были получены, и, следовательно, имеют общее состояние с ним. Это обеспечивает экономичную эксплуатацию потенциальных вариантов без затрат памяти, необходимых для глубокого копирования.
Для начала выполните следующие шаги:
- Откройте файл
model.dart
и добавьте в него следующее определение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;
- Если после добавления нового контента в этом файле в течение нескольких секунд остаются красные волнистые линии, убедитесь, что
build_runner
всё ещё запущен. Если нет, выполните командуdart run build_runner watch -d
.
В коде, который вы собираетесь внедрить, логирование будет показывать, сколько времени занимает создание кроссвордов разных размеров. Было бы здорово, если бы Durations отображались в удобном формате. К счастью, с помощью методов расширения мы можем добавить именно тот метод, который нам нужен.
- Отредактируйте файл
utils.dart
следующим образом:
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.
Этот метод расширения использует выражения switch и сопоставление с шаблонами по записям для выбора подходящего способа отображения различных значений длительности — от секунд до дней. Подробнее об этом стиле кода см. в практической работе « Погружение в шаблоны и записи Dart» .
- Чтобы интегрировать эту новую функциональность, замените файл
isolates.dart
, чтобы переопределить определение функцииexploreCrosswordSolutions
следующим образом:
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}',
);
}
Запуск этого кода создаст приложение, которое на первый взгляд выглядит идентично, но разница заключается во времени, которое требуется для поиска готового кроссворда. Вот кроссворд размером 80 x 44, созданный за 1 минуту и 29 секунд.
Контрольная точка: Эффективная работа алгоритма
Теперь генерация кроссвордов будет происходить значительно быстрее благодаря:
- Интеллектуальное размещение слов с ориентацией на точки пересечения
- Эффективный возврат в случае неудачного размещения
- Управление очередью работ для избежания лишних поисков
Конечно, возникает очевидный вопрос: а можем ли мы ехать быстрее? О да, можем.
7. Статистика поверхности
Зачем добавлять статистику?
Чтобы ускорить что-либо, полезно видеть, что происходит. Статистика помогает отслеживать прогресс, наблюдая за работой алгоритма в режиме реального времени. Она позволяет выявлять узкие места, понимая, на что алгоритм тратит своё время. Это позволяет оптимизировать производительность, принимая обоснованные решения о подходах к оптимизации.
Информацию, которую вы будете отображать, необходимо извлечь из WorkQueue и отобразить в пользовательском интерфейсе. Первым шагом будет определение нового класса модели, содержащего информацию, которую вы хотите отобразить.
Для начала выполните следующие шаги:
- Чтобы добавить класс
DisplayInfo
, отредактируйте файлmodel.dart
следующим образом:
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> {
- В конце файла внесите следующие изменения, чтобы добавить класс
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;
- Измените файл
isolates.dart
, чтобы открыть модельWorkQueue
следующим образом:
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}',
);
}
Теперь, когда фоновый изолят раскрывает очередь работ, встает вопрос, как и где извлекать статистику из этого источника данных.
- Замените старого поставщика кроссвордов поставщиком очереди работ, а затем добавьте больше поставщиков, которые извлекают информацию из потока поставщика очереди работ:
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,
);
}
Новые поставщики представляют собой сочетание глобального состояния, например, наложения информационного дисплея на сетку кроссворда, и производных данных, таких как время генерации кроссворда. Всё это осложняется тем, что прослушиватели некоторых из этих состояний являются временными. Ничто не отслеживает время начала и окончания вычисления кроссворда, если информационный дисплей скрыт, но они должны оставаться в памяти для обеспечения точности вычислений при отображении информационного дисплея. Параметр keepAlive
атрибута Riverpod
в этом случае очень полезен.
В отображении информации есть небольшая проблема. Нам нужна возможность отображать прошедшее время выполнения, но здесь нет ничего, что могло бы принудительно обновлять прошедшее время. Возвращаясь к практическому занятию «Создание пользовательских интерфейсов нового поколения во Flutter », вот полезный виджет, отвечающий именно этому требованию.
- Создайте файл
ticker_builder.dart
в каталогеlib/widgets
, а затем добавьте в него следующее содержимое:
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);
}
Этот виджет — настоящая кувалда. Он перестраивает своё содержимое в каждом кадре. Обычно это не приветствуется, но по сравнению с вычислительной нагрузкой на поиск кроссвордов, вычислительная нагрузка на перерисовку прошедшего времени в каждом кадре, вероятно, потонет в шуме. Чтобы воспользоваться этой новой информацией, пора создать новый виджет.
- Создайте файл
crossword_info_widget.dart
в каталогеlib/widgets
, а затем добавьте в него следующее содержимое:
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),
),
],
),
);
}
Этот виджет — яркий пример мощи поставщиков Riverpod. Он будет отмечен для пересборки при обновлении любого из пяти поставщиков. Последнее необходимое изменение на этом этапе — интеграция нового виджета в пользовательский интерфейс.
- Отредактируйте файл
crossword_generator_app.dart
следующим образом:
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),
),
);
}
Эти два изменения демонстрируют разные подходы к интеграции поставщиков. В методе build
CrosswordGeneratorApp
вы добавили новый конструктор Consumer
, содержащий область, принудительно перестраиваемую при отображении или скрытии информационного дисплея. С другой стороны, всё раскрывающееся меню представляет собой один ConsumerWidget
, который будет перестраиваться независимо от того, изменяется ли размер кроссворда или отображается или скрывается информационный дисплей. Выбор подхода всегда является инженерным компромиссом между простотой и стоимостью пересчёта макетов перестроенных деревьев виджетов.
Запустив приложение, пользователь теперь может лучше понять, как идёт генерация кроссворда. Однако ближе к концу генерации кроссворда мы видим период, когда цифры меняются, но сетка символов меняется очень мало.
Было бы полезно получить дополнительную информацию о том, что происходит и почему.
8. Распараллеливание с помощью потоков
Почему производительность ухудшается
По мере приближения к завершению кроссворда алгоритм замедляется, поскольку остаётся всё меньше допустимых вариантов расположения слов. Алгоритм перебирает множество комбинаций, которые не срабатывают. Однопоточная обработка не позволяет эффективно перебирать несколько вариантов.
Визуализация алгоритма
Чтобы понять, почему в конце всё замедляется, полезно визуализировать действия алгоритма. Ключевым элементом являются невыполненные locationsToTry
в WorkQueue
. TableView даёт нам удобный способ разобраться в этом. Мы можем изменить цвет ячейки в зависимости от того, находится ли она в locationsToTry
.
Для начала выполните следующие шаги:
- Измените файл
crossword_widget.dart
следующим образом:
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,
),
),
),
);
}
}
При запуске этого кода вы увидите визуализацию неисследованных мест, которые алгоритму еще предстоит исследовать.
Интересно наблюдать за тем, как кроссворд приближается к завершению, потому что остаётся множество неисследованных пунктов, которые не принесут никакой пользы. Есть несколько вариантов: один — завершить исследование, когда будет заполнен определённый процент ячеек кроссворда, а второй — исследовать несколько интересных пунктов одновременно. Второй путь звучит интереснее, так что самое время им заняться.
- Отредактируйте файл
isolates.dart
. Это практически полностью переписывает код, чтобы разделить вычисления, проведённые в одном фоновом изоляте, на пул из N фоновых изолятов.
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);
}
Понимание архитектуры мультиизоляции
Большая часть этого кода должна быть вам знакома, поскольку основная бизнес-логика не изменилась. Изменение заключается в том, что теперь существует два уровня compute
вызовов. Первый уровень отвечает за распределение отдельных позиций для поиска по N изолятам рабочих процессов, а затем за повторное объединение результатов после завершения всех N изолятов рабочих процессов. Второй уровень состоит из N изолятов рабочих процессов. Настройка N для достижения максимальной производительности зависит как от вашего компьютера, так и от обрабатываемых данных. Чем больше сетка, тем больше рабочих процессов могут работать вместе, не мешая друг другу.
Интересно отметить, как этот код теперь решает проблему захвата замыканиями данных, которые они не должны захватывать. Замыканий больше нет. Функции _generate
и _generateWorker
определены как функции верхнего уровня, не имеющие внешней среды для захвата данных. Аргументы этих функций и результаты их работы представлены в виде записей Dart. Это способ обойти семантику «одно значение на входе, одно значение на выходе» вызова compute
.
Теперь, когда у вас есть возможность создать пул фоновых работников для поиска слов, которые сцепляются в сетку для формирования кроссворда, пришло время предоставить эту возможность остальной части инструмента генератора кроссвордов.
- Отредактируйте файл
providers.dart
, отредактировав поставщик workQueue следующим образом:
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();
}
- Добавьте поставщик
WorkerCount
в конец файла следующим образом:
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.
Благодаря этим двум изменениям уровень поставщика теперь предоставляет возможность установить максимальное количество рабочих процессов для пула фоновой изоляции таким образом, чтобы функции изоляции были правильно настроены.
- Обновите файл
crossword_info_widget.dart
, изменивCrosswordInfoWidget
следующим образом:
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,
),
],
),
),
),
),
),
),
);
}
}
- Измените файл
crossword_generator_app.dart
, добавив следующий раздел в виджет_CrosswordGeneratorMenu
:
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),
),
);
}
Если вы запустите приложение сейчас, вы сможете изменить количество фоновых изолятов, которые создаются для поиска слов для вставки в кроссворд.
- Щелкните значок шестеренки, чтобы открыть контекстное меню, содержащее размер кроссворда, отображение статистики по сгенерированному кроссворду, а теперь и количество используемых изолятов.
Контрольная точка: многопоточная производительность
Запуск генератора кроссвордов значительно сократил время вычисления кроссворда 80x44 за счет одновременного использования нескольких ядер. Вы должны заметить:
- Более быстрое создание кроссвордов с большим количеством рабочих
- Плавная реакция пользовательского интерфейса во время генерации
- Статистика в реальном времени, показывающая прогресс генерации
- Визуальная обратная связь по областям исследования алгоритма
9. Превратите это в игру
Что мы создаем: играбельную игру-кроссворд.
Этот последний раздел на самом деле является бонусным раундом. Вы воспользуетесь всеми приемами, которые вы изучили при создании генератора кроссвордов, и будете использовать их для создания игры. Вы будете:
- Создавайте головоломки : используйте генератор кроссвордов, чтобы создавать решаемые головоломки.
- Создание вариантов выбора слов . Предоставьте несколько вариантов слов для каждой позиции.
- Включить взаимодействие : разрешить пользователям выбирать и размещать слова.
- Проверка решений : проверьте, правильно ли заполнен кроссворд.
Для создания кроссворда вы воспользуетесь генератором кроссвордов. Вы будете повторно использовать идиомы контекстного меню, чтобы дать пользователю возможность выбирать и отменять выбор слов, чтобы вставлять их в различные отверстия в форме слов в сетке. Все с целью разгадать кроссворд.
Я не буду говорить, что эта игра отполирована или закончена, это далеко не так. Существуют проблемы с балансом и сложностью, которые можно решить, улучшив выбор альтернативных слов. Не существует руководства, которое могло бы помочь пользователям разобраться в головоломке. Я даже не буду упоминать банальное «Ты победил!» экран.
Компромисс здесь заключается в том, что для правильной доработки этой прото-игры до полноценной игры потребуется значительно больше кода. Больше кода, чем должно быть в одной кодовой лаборатории. Вместо этого это этап ускоренного выполнения, предназначенный для закрепления методов, изученных в этой лаборатории, путем изменения того, где и как они используются. Надеюсь, это закрепит уроки, полученные ранее в этой лаборатории кода. Альтернативно вы можете пойти дальше и создать свой собственный опыт на основе этого кода. Мы будем рады увидеть, что вы создадите!
Для начала выполните следующие действия:
- Удалите все в каталоге
lib/widgets
. Вы будете создавать новые блестящие виджеты для своей игры. Просто здесь многое заимствовано из старых виджетов.
- Отредактируйте файл
model.dart
, чтобы обновить методaddWord
Crossword
следующим образом:
библиотека/модель.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;
}
}
Эта небольшая модификация вашей модели кроссворда позволяет добавлять слова, которые не перекрываются. Полезно позволить игрокам играть где угодно на доске и при этом иметь возможность использовать Crossword
в качестве базовой модели для хранения ходов игрока. Это просто список слов в определенных местах, расположенных в определенном направлении.
- Добавьте класс модели
CrosswordPuzzleGame
в конец файлаmodel.dart
.
библиотека/модель.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;
Обновления файла providers.dart
представляют собой интересный набор изменений. Большинство провайдеров, которые присутствовали для поддержки сбора статистики, были удалены. Возможность изменения количества фоновых изолятов удалена и заменена константой. Существует также новый провайдер, который предоставляет доступ к новой модели CrosswordPuzzleGame
, которую вы добавили ранее.
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);
Наиболее интересными частями провайдера Puzzle
являются уловки, предпринятые для того, чтобы скрыть затраты на создание игры CrosswordPuzzleGame
из Crossword
и списка wordList
, а также затраты на выбор слова. Оба этих действия, если они выполняются без фоновой изоляции, приводят к замедлению взаимодействия с пользовательским интерфейсом. Используя ловкость рук для получения промежуточного результата при вычислении окончательного результата в фоновом режиме, вы получаете отзывчивый пользовательский интерфейс, в то время как необходимые вычисления происходят в фоновом режиме.
- В теперь пустом каталоге
lib/widgets
создайте файлcrossword_puzzle_app.dart
со следующим содержимым:
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),
),
);
}
Большая часть этого файла должна быть вам уже знакома. Да, будут неопределённые виджеты, которые вы сейчас начнёте исправлять.
- Создайте файл
crossword_generator_widget.dart
и добавьте в него следующее содержимое:
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,
),
),
),
);
}
}
Это также должно быть достаточно знакомо. Основное отличие состоит в том, что вместо отображения символов генерируемых слов теперь отображается символ Юникода, обозначающий наличие неизвестного символа. Это действительно могло бы потребовать некоторой работы для улучшения эстетики.
- Создайте файл
crossword_puzzle_widget.dart
и добавьте в него следующее содержимое:
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),
);
}
}
Этот виджет немного более насыщенный, чем предыдущий, даже несмотря на то, что он создан из частей, которые вы видели в прошлом в других местах. Теперь при нажатии на каждую заполненную ячейку создается контекстное меню, в котором перечислены слова, которые пользователь может выбрать. Если слова были выбраны, то слова, которые конфликтуют, не могут быть выбраны. Чтобы отменить выбор слова, пользователь нажимает на пункт меню для этого слова.
Предполагая, что игрок может выбирать слова для заполнения всего кроссворда, вам понадобится надпись «Вы выиграли!» экран.
- Создайте файл
puzzle_completed_widget.dart
и добавьте в него следующее содержимое:
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),
),
);
}
}
Я уверен, что вы сможете взять это на вооружение и сделать его более интересным. Чтобы узнать больше об инструментах анимации, см. лабораторную работу «Создание пользовательских интерфейсов нового поколения во Flutter» .
- Отредактируйте файл
lib/main.dart
следующим образом:
библиотека/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
),
),
);
}
Запустив это приложение, вы увидите анимацию, пока генератор кроссвордов создает вашу головоломку. Затем вам будет предложена пустая головоломка, которую нужно решить. Если вы решите эту проблему, вы должны увидеть экран, который выглядит следующим образом:
10. Поздравления
Поздравляем! Вам удалось создать игру-головоломку с помощью Flutter!
Вы создали генератор кроссвордов, который превратился в игру-головоломку. Вы освоили выполнение фоновых вычислений в пуле изолятов. Вы использовали неизменяемые структуры данных, чтобы упростить реализацию алгоритма поиска с возвратом. И вы качественно провели время с TableView
, который пригодится вам в следующий раз, когда вам понадобится отобразить табличные данные.