1. Antes de começar
Imagine que alguém pergunta se é possível criar o maior desafio de palavras cruzadas do mundo. Você se lembra de algumas técnicas de IA que estudou na escola e se pergunta se é possível usar o Flutter para explorar as opções de algoritmos e criar soluções para problemas com uso intensivo de computação.
Neste codelab, você vai fazer exatamente isso. No final, você vai criar uma ferramenta para jogar no espaço de algoritmos para a criação de quebra-cabeças de grade de palavras. Existem muitas definições para o que são palavras cruzadas válidas, e essas técnicas ajudam a criar quebra-cabeças que se encaixem na sua definição.
Com essa ferramenta como base, você cria um quebra-cabeça que usa o gerador de palavras cruzadas para construir o quebra-cabeça para o usuário resolver. Esse quebra-cabeça pode ser usado no Android, iOS, Windows, macOS e Linux. Aqui está no Android:
Pré-requisitos
- Já ter feito o codelab Seu primeiro app do Flutter.
Conteúdo do laboratório
- Como usar isolamentos para fazer trabalhos com alto custo computacional sem impedir o loop de renderização do Flutter com uma combinação da função
compute
do Flutter e da recriação dos recursos de armazenamento em cache de valor do filtroselect
(links em inglês) do Riverpod. - Como aproveitar as estruturas de dados imutáveis com
built_value
ebuilt_collection
para facilitar a implementação das técnicas da Good Old Fashioned AI (GOFAI) baseadas em pesquisa, como pesquisa em profundidade e retrocesso. - Como usar os recursos do pacote
two_dimensional_scrollables
para mostrar dados de grade de maneira rápida e intuitiva.
O que é necessário
- O SDK do Flutter.
- Visual Studio Code (VS Code) com os plug-ins do Flutter e do Dart (links em inglês).
- Compilador de software para o destino de desenvolvimento escolhido. Este codelab funciona para todas as plataformas de computador, Android e iOS. Você precisa do VS Code para o Windows, do Xcode para segmentar com macOS ou iOS e do Android Studio para segmentar o Android.
2. Criar um projeto
Criar seu primeiro projeto do Flutter
- Inicie o VS Code.
- Na linha de comando, digite flutter new e selecione Flutter: novo projeto no menu.
- Selecione Empty application e escolha um diretório para criar o projeto. Esse diretório precisa ser qualquer diretório que não exija privilégios elevados ou que tenha um espaço no caminho. Por exemplo, seu diretório principal ou
C:\src\
.
- Nomeie seu projeto como
generate_crossword
. O restante deste codelab presume que você nomeou seu app comogenerate_crossword
.
Agora, a pasta do projeto será criada pelo Flutter e aberta pelo VS Code. Agora você vai substituir o conteúdo de dois arquivos por um scaffolding básico do app.
Copiar e colar o app inicial
- No painel esquerdo do VS Code, clique em Explorer e abra o arquivo
pubspec.yaml
.
- Substitua o conteúdo do arquivo pelo indicado abaixo.
pubspec.yaml
name: generate_crossword
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.3 <4.0.0'
dependencies:
built_collection: ^5.1.1
built_value: ^8.9.2
characters: ^1.3.0
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
intl: ^0.19.0
riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
two_dimensional_scrollables: ^0.2.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
build_runner: ^2.4.9
built_value_generator: ^8.9.2
custom_lint: ^0.6.4
riverpod_generator: ^2.4.0
riverpod_lint: ^2.3.10
flutter:
uses-material-design: true
O arquivo pubspec.yaml
especifica informações básicas sobre o app, como a versão atual e as dependências. Você verá uma coleção de dependências que não fazem parte de um app vazio normal do Flutter. Você vai se beneficiar de todos esses pacotes nas próximas etapas.
- Abra o arquivo
main.dart
no diretóriolib/
.
- Substitua o conteúdo do arquivo pelo indicado abaixo.
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(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: Scaffold(
body: Center(
child: Text(
'Hello, World!',
style: TextStyle(fontSize: 24),
),
),
),
),
),
);
}
- Execute este código para conferir se tudo está funcionando. Ela precisa mostrar uma nova janela com a frase inicial obrigatória de cada novo projeto em todos os lugares. Há um
ProviderScope
, indicando que esse app vai usar oriverpod
para gerenciamento de estado.
3. Adicionar palavras
Elementos básicos para palavras cruzadas
As palavras cruzadas são, em sua essência, uma lista de palavras. As palavras são organizadas em uma grade, algumas transversais, outras para baixo, de modo que as palavras se interliguem. A resolução de uma palavra dá pistas sobre as palavras que cruzam a primeira palavra. Assim, o primeiro elemento básico precisa ser uma lista de palavras.
Uma boa fonte para essas palavras é a página Dados do corpus de linguagem natural de Peter Norvig. A lista SOWPODS é um ponto de partida útil, com 267.750 palavras.
Nesta etapa, você faz o download de uma lista de palavras, adiciona-a como um recurso ao app Flutter e organiza um provedor do Riverpod para carregar a lista no app na inicialização.
Para começar, siga estas etapas:
- Modifique o arquivo
pubspec.yaml
do projeto para adicionar a seguinte declaração de recurso à lista de palavras selecionada. Esta lista mostra apenas a estrofe do Flutter da configuração do seu app, já que o restante permaneceu o mesmo.
pubspec.yaml
flutter:
uses-material-design: true
assets: // Add this line
- assets/words.txt // And this one.
Seu editor provavelmente destacará esta última linha com um aviso porque você ainda não criou esse arquivo.
- Usando seu navegador e editor, crie um diretório
assets
no nível superior do projeto e crie um arquivowords.txt
nele com uma das listas de palavras vinculadas acima.
Este código foi projetado com a lista SOWPODS mencionada acima, mas deve funcionar com qualquer lista de palavras que consista apenas de caracteres de A a Z. Estender essa base de código para funcionar com diferentes conjuntos de caracteres é um exercício para o leitor.
Carregue as palavras
Para escrever o código responsável por carregar a lista de palavras na inicialização do app, siga estas etapas:
- Crie um arquivo
providers.dart
no diretóriolib
. - Adicione o seguinte ao arquivo:
lib/providers.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/services.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(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)));
}
Esse é o primeiro provedor do Riverpod para essa base de código. O editor reclamará de várias áreas como uma classe indefinida ou um destino não gerado. Esse projeto usa a geração de código para várias dependências, incluindo o Riverpod. Portanto, os erros de classe indefinido são esperados.
- Para começar a gerar o código, execute o seguinte comando:
$ 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)
Ele continuará sendo executado em segundo plano, atualizando os arquivos gerados à medida que você fizer alterações no projeto. Depois que esse comando gerar o código em providers.g.dart
, o editor vai gostar do código que você adicionou ao providers.dart
acima.
No Riverpod, provedores como a função wordList
definida acima geralmente são instanciados lentamente. No entanto, para os fins deste app, é necessário que a lista de palavras seja carregada com antecedência. A documentação do Riverpod sugere a abordagem a seguir para lidar com os provedores que você precisa carregar com antecedência. Você vai implementar isso agora.
- Crie um arquivo
crossword_generator_app.dart
em um diretóriolib/widgets
. - Adicione o seguinte ao arquivo:
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;
}
}
Este arquivo é interessante de duas direções separadas. O primeiro é o widget _EagerInitialization
, que tem a única missão de exigir que o provedor wordList
criado acima carregue a lista de palavras. Esse widget atinge esse objetivo ouvindo o provedor usando a chamada ref.watch()
. Leia mais sobre essa técnica na documentação do Riverpod sobre Inicialização antecipada de provedores (em inglês).
O segundo ponto interessante a observar nesse arquivo é a forma como o Riverpod lida com o conteúdo assíncrono. Como você deve se lembrar, o provedor wordList
é definido como uma função assíncrona, porque o carregamento de conteúdo do disco é lento. Ao observar o provedor de lista de palavras no código, você recebe uma AsyncValue<BuiltSet<String>>
. A parte AsyncValue
desse tipo é um adaptador entre o mundo assíncrono de provedores e o mundo síncrono do método build
do widget.
O método when
do AsyncValue
processa os três estados em que o valor futuro pode estar. O futuro pode ter sido resolvido com sucesso. Nesse caso, o callback data
foi invocado. Ele pode estar em um estado de erro (quando o callback error
é invocado) ou, finalmente, ainda pode estar sendo carregado. Os tipos de retorno dos três callbacks precisam ter tipos compatíveis, já que o retorno do callback chamado é retornado pelo método when
. Nesse caso, o resultado do método "when" é mostrado como o body
do widget Scaffold
.
Criar um app de lista quase infinita
Para integrar o widget do CrosswordGeneratorApp
ao app, siga estas etapas:
- Atualize o arquivo
lib/main.dart
adicionando o seguinte código:
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(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordGeneratorApp(), // Remove what was here and replace
),
),
);
}
- Reinicie o app. Você verá uma lista de rolagem que dura quase indefinidamente.
4. Exibir as palavras em uma grade
Nesta etapa, você vai criar uma estrutura de dados para criar palavras cruzadas usando os pacotes built_value
e built_collection
. Esses dois pacotes permitem a construção de estruturas de dados como valores imutáveis, o que é útil para transmitir facilmente dados entre isolados e para facilitar a implementação da primeira pesquisa em profundidade e do retrocesso.
Para começar, siga estas etapas:
- Crie um arquivo
model.dart
no diretóriolib
e adicione o seguinte conteúdo ao arquivo:
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;
Esse arquivo descreve o início da estrutura de dados que você usará para criar palavras cruzadas. Em sua essência, palavras cruzadas são uma lista de palavras horizontais e verticais interligadas em uma grade. Para usar essa estrutura de dados, crie um Crossword
do tamanho apropriado com o construtor chamado Crossword.crossword
e adicione palavras usando o método addWord
. Como parte da construção do valor finalizado, uma grade de CrosswordCharacter
s é criada pelo método _fillCharacters
.
Para usar essa estrutura de dados, siga estas etapas:
- Crie um arquivo
utils
no diretóriolib
e adicione o seguinte conteúdo ao arquivo:
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));
}
}
Essa é uma extensão do BuiltSet
que facilita a recuperação de um elemento aleatório do conjunto. Os métodos de extensão facilitam a extensão de classes com funcionalidade adicional. É necessário nomear a extensão para disponibilizá-la fora do arquivo utils.dart
.
- No arquivo
lib/providers.dart
, adicione as seguintes importações:
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 {
Essas importações expõem o modelo definido acima aos provedores que você está prestes a criar. A importação dart:math
está incluída para Random
, a importação flutter/foundation.dart
está incluída para debugPrint
, model.dart
para o modelo e utils.dart
para a extensão BuiltSet
.
- No final do mesmo arquivo, adicione os seguintes provedores:
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(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));
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;
},
);
}
Essas mudanças adicionam dois provedores ao seu app. A primeira é Size
, que é uma variável global que contém o valor selecionado da enumeração CrosswordSize
. Isso permitirá que a interface exiba e defina o tamanho das palavras cruzadas em construção. O segundo provedor, crossword
, é uma criação mais interessante. É uma função que retorna uma série de Crossword
s. Ele foi criado usando o suporte do Dart para geradores, conforme marcado pelo async*
na função. Isso significa que, em vez de terminar em um retorno, ele produz uma série de Crossword
s, uma maneira muito mais fácil de escrever um cálculo que retorna resultados intermediários.
Devido à presença de um par de chamadas ref.watch
no início da função de provedor crossword
, o fluxo de Crossword
s será reiniciado pelo sistema Riverpod sempre que o tamanho selecionado das palavras cruzadas mudar e quando a lista de palavras terminar de carregar.
Agora que você tem o código para gerar palavras cruzadas, ainda que cheios de palavras aleatórias, seria bom mostrá-los ao usuário da ferramenta.
- Crie um arquivo
crossword_widget.dart
no diretóriolib/widgets
com o seguinte conteúdo:
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),
),
),
);
}
}
Esse widget, sendo um ConsumerWidget
, pode depender diretamente do provedor Size
para determinar o tamanho da grade para mostrar os caracteres da Crossword
. A exibição dessa grade é realizada com o widget TableView
do pacote two_dimensional_scrollables
.
É importante notar que cada célula individual renderizada pelas funções auxiliares _buildCell
contém um widget Consumer
na árvore Widget
retornada. Isso funciona como um limite de atualização. Tudo o que está no widget Consumer
é recriado quando o valor retornado de ref.watch
muda. É tentador recriar toda a árvore toda vez que o Crossword
muda. No entanto, isso causa muita computação que pode ser ignorada usando essa configuração.
O parâmetro de ref.watch
mostra que há outra camada para evitar a recomputação de layouts usando crosswordProvider.select
. Isso significa que o ref.watch
só acionará uma recriação do conteúdo de TableViewCell
quando o caractere que a célula é responsável pela renderização mudar. Essa redução na nova renderização é uma parte essencial para manter a interface responsiva.
Para expor os provedores CrosswordWidget
e Size
ao usuário, mude o arquivo crossword_generator_app.dart
da seguinte maneira:
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(), // Replaces everything that 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.
}
Algumas coisas mudaram aqui. Primeiro, o código responsável por renderizar o wordList
como um ListView
foi substituído por uma chamada para o CrosswordWidget
definida no arquivo anterior. Outra mudança importante é o início de um menu para mudar o comportamento do aplicativo, começando com a mudança do tamanho das palavras cruzadas. Mais MenuItemButton
s serão adicionados nas próximas etapas. Execute o app. Você vai encontrar algo parecido com isto:
Há caracteres sendo exibidos em uma grade e um menu que permitem que o usuário altere o tamanho da grade. Mas as palavras não são dispostas como palavras cruzadas. Isso acontece porque não há restrições quanto à forma de adicionar palavras às palavras cruzadas. Em resumo, é uma bagunça. Algo que você começará a controlar na próxima etapa!
5. Aplicar restrições
O objetivo desta etapa é adicionar código ao modelo para aplicar restrições de palavras cruzadas. Existem muitos tipos diferentes de quebra-cabeças, e o estilo deste codelab segue as tradições dos quebra-cabeças em inglês. Modificar esse código para gerar outros estilos de palavras cruzadas é, como sempre, um exercício para o leitor.
Para começar, siga estas etapas:
- Abra o arquivo
model.dart
e substitua o modeloCrossword
pelo seguinte:
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._();
}
Como um lembrete rápido, as mudanças que você está fazendo nos arquivos model.dart
e providers.dart
exigem que o build_runner
esteja em execução para atualizar os respectivos arquivos model.g.dart
e providers.g.dart
. Se esses arquivos não tiverem sido atualizados automagicamente, agora é um bom momento para iniciar o build_runner
novamente com o dart run build_runner watch -d
.
Para aproveitar esse novo recurso na camada de modelo, é necessário atualizar a camada do provedor de acordo.
- Edite o arquivo
providers.dart
desta forma:
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;
},
);
}
- Execute o app. Não acontece muita coisa na interface, mas muita coisa acontece se você analisar os registros.
Se você pensar sobre o que está acontecendo aqui, estamos vendo palavras cruzadas aparecerem por acaso. O método addWord
no modelo Crossword
está rejeitando qualquer palavra proposta que não se enquadre nas palavras cruzadas atuais. Por isso, é incrível ver nada aparecer.
Como preparação para ser mais metódico na escolha das palavras, seria muito útil mover esse cálculo da linha de execução de interface para um isolamento em segundo plano. O Flutter tem um wrapper muito útil para executar uma parte do trabalho em um plano isolado em segundo plano: a função compute
(link em inglês).
- No arquivo
providers.dart
, modifique o provedor de palavras cruzadas da seguinte maneira:
lib/providers.dart
@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));
try {
var candidate = await compute( // Edit from here.
((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;
},
);
}
Este código funciona. No entanto, ele contém uma armadilha. Se continuar nesse caminho, você acabará recebendo um erro registrado como este:
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)
Esse é o resultado do fechamento que o compute
está entregando ao isolamento em segundo plano em um provedor, que não pode ser enviado pelo SendPort.send()
. Uma correção para isso é garantir que não haja nada para a interdição que não possa ser enviado.
A primeira etapa é separar os provedores do código isolado.
- Crie um arquivo
isolates.dart
no diretóriolib
e adicione o seguinte conteúdo a ele:
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');
}
}
}
Esse código precisa ser razoavelmente familiar. Ele é o núcleo do provedor crossword
, mas agora como uma função de gerador independente. Agora você pode atualizar o arquivo providers.dart
para usar essa nova função e instanciar o isolamento em segundo plano.
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_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(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();
}
}
// Drop the _random instance
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = // Edit from here
model.Crossword.crossword(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.
},
);
}
Com isso, agora você tem uma ferramenta que cria palavras cruzadas de diferentes tamanhos, com a compute
de descobrir o quebra-cabeça acontecendo em um plano de fundo isolado. Agora, se o código fosse mais eficiente ao decidir quais palavras tentar adicionar às palavras cruzadas.
6. Gerenciar a fila de trabalho
Parte do problema com o código é que o problema que está sendo resolvido é efetivamente um problema de pesquisa, e a solução atual é a pesquisa cega. Se o código se concentrar em encontrar palavras que serão anexadas às palavras atuais, em vez de tentar aleatoriamente colocar palavras em qualquer lugar da grade, o sistema encontrará soluções mais rapidamente. Uma maneira de abordar isso é introduzir uma fila de trabalho de locais para tentar encontrar palavras.
Atualmente, o código cria soluções candidatas, verifica se a solução candidata é válida e, dependendo da validade, a incorpora ou a descarta. Este é um exemplo de implementação da família de algoritmos de retrocesso. Essa implementação é muito facilitada por built_value
e built_collection
, que permitem a criação de novos valores imutáveis que derivam e, assim, compartilham o estado comum com o valor imutável de origem. Isso permite a exploração barata de possíveis candidatos sem os custos de memória necessários para cópia profunda.
Para começar, siga estas etapas:
- Abra o arquivo
model.dart
e adicione a seguinte definição deWorkQueue
a ele:
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;
- Se ainda houver rabiscos vermelhos neste arquivo depois de adicionar o novo conteúdo por mais de alguns segundos, confirme se o
build_runner
ainda está em execução. Caso contrário, execute o comandodart run build_runner watch -d
.
No código, você vai introduzir a geração de registros para mostrar quanto tempo leva para criar palavras cruzadas em vários tamanhos. Seria ótimo se Durações tivesse algum tipo de exibição bem formatada. Felizmente, com os métodos de extensão, podemos adicionar o método exato que precisamos.
- Edite o arquivo
utils.dart
da seguinte forma:
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.
Esse método de extensão aproveita as expressões switch e a correspondência de padrões nos registros para selecionar a maneira apropriada de exibir durações diferentes, de segundos a dias. Para saber mais sobre esse estilo de código, consulte o codelab Mergulhe nos padrões e registros do Dart (link em inglês).
- Para integrar essa nova funcionalidade, substitua o arquivo
isolates.dart
para redefinir como a funçãoexploreCrosswordSolutions
é definida da seguinte maneira:
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}');
}
A execução desse código resulta em um app idêntico superficialmente, mas a diferença está no tempo necessário para encontrar palavras cruzadas concluídas. Aqui está um desafio de palavras cruzadas de 80 x 44 gerado em 1 minuto e 29 segundos.
A pergunta óbvia é: podemos ir mais rápido? Ah, sim, podemos.
7. Estatísticas de superfície
Ao fazer algo rápido, é útil ver o que está acontecendo. Uma coisa que ajuda nisso é destacar informações sobre o processo enquanto ele está em andamento. Agora é hora de adicionar instrumentação e exibir essas informações como um painel de informações ao passar o cursor.
As informações que você vai exibir precisam ser extraídas da WorkQueue e mostradas na interface.
Uma primeira etapa útil é definir uma nova classe de modelo que contenha as informações que você quer exibir.
Para começar, siga estas etapas:
- Edite o arquivo
model.dart
desta forma para adicionar a classeDisplayInfo
:
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> {
- No final do arquivo, faça as seguintes mudanças para adicionar a classe
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;
- Modifique o arquivo
isolates.dart
para expor o modeloWorkQueue
da seguinte maneira:
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}');
}
Agora que o isolamento em segundo plano está expondo a fila de trabalho, é preciso saber como e onde derivar estatísticas dessa fonte de dados.
- Substitua o provedor de palavras cruzadas antigo por um provedor de fila de trabalho e adicione mais provedores que derivam informações do fluxo do provedor de fila de trabalho:
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_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(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();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef 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(ExpectedRemainingTimeRef 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,
);
}
Os novos provedores são uma mistura de estado global, na forma de a exibição de informações ser sobreposta sobre a grade de palavras cruzadas e dados derivados, como o tempo de execução da geração de palavras cruzadas. Tudo isso é complicado pelo fato de que os ouvintes de alguns desse estado são temporários. Se a exibição de informações estiver oculta, os horários de início e término do cálculo de palavras cruzadas não serão detectados, mas eles precisarão permanecer na memória para que o cálculo tenha precisão quando a exibição de informações for mostrada. O parâmetro keepAlive
do atributo Riverpod
é muito útil nesse caso.
Há um pequeno amassado na exibição do visor de informações. Queremos a capacidade de mostrar o tempo de execução decorrido no momento, mas não há nada aqui que force com facilidade a atualização constante desse tempo. Voltando ao codelab Como criar interfaces de última geração no Flutter, veja um widget útil apenas para esse requisito.
- Crie um arquivo
ticker_builder.dart
no diretóriolib/widgets
e adicione o seguinte conteúdo a ele:
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);
}
Esse widget é uma marreta. Ele recria o conteúdo em cada frame. Isso geralmente é desaprovado, mas em comparação com a carga computacional de procurar palavras cruzadas, a carga computacional de repintar o tempo decorrido de cada frame provavelmente desaparecerá no meio do ruído. Para aproveitar essas informações recém-derivadas, é hora de criar um novo widget.
- Crie um arquivo
crossword_info_widget.dart
no diretóriolib/widgets
e adicione o seguinte conteúdo a ele:
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 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),
),
],
),
);
}
Esse widget é um excelente exemplo do poder dos fornecedores da Riverpod. Esse widget será marcado para recriação quando qualquer um dos cinco provedores for atualizado. A última mudança necessária nesta etapa é a integração do novo widget na interface.
- Edite o arquivo
crossword_generator_app.dart
desta forma:
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(
menu Children: [
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),
),
);
}
Essas duas mudanças demonstram abordagens diferentes para integrar provedores. No método build
do CrosswordGeneratorApp
, você introduziu um novo builder Consumer
para conter a área forçada a ser recriada quando a exibição de informações é mostrada ou oculta. Por outro lado, todo o menu suspenso é uma ConsumerWidget
, que será recriada se for o redimensionamento das palavras cruzadas ou a exibição ou ocultação da exibição de informações. Qual abordagem adotar é sempre uma troca de engenharia de simplicidade versus o custo de recalcular layouts de árvores de widgets reconstruídas.
A execução do app agora oferece ao usuário mais insights sobre o progresso da geração de palavras cruzadas. No entanto, perto do fim da geração de palavras cruzadas, vemos que há um período em que os números estão mudando, mas há muito pouca mudança na grade de caracteres.
Seria útil ter mais informações sobre o que está acontecendo e por quê.
8. Carregar em paralelo com linhas de execução
Para entender por que as coisas ficam lentas no final, é útil poder visualizar o que o algoritmo está fazendo. Uma parte importante é o locationsToTry
pendente no WorkQueue
. A TableView é uma maneira útil de investigar isso. Podemos mudar a cor da célula com base em locationsToTry
ou não.
Para começar, siga estas etapas:
- Modifique o arquivo
crossword_widget.dart
da seguinte maneira:
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),
),
),
);
}
}
Ao executar esse código, você verá uma visualização dos locais pendentes que o algoritmo ainda está para investigar.
O interessante de assistir isso à medida que as palavras cruzadas avançam em direção à conclusão é que há uma série de pontos a serem investigados que não resultarão em nada útil. Há algumas opções aqui: uma é limitar a investigação quando uma determinada porcentagem das células de palavras cruzadas estiver preenchida e a segunda é investigar vários pontos de interesse de uma só vez. O segundo caminho parece mais divertido, então vamos lá.
- Edite o arquivo
isolates.dart
. Essa é quase uma reescrita completa do código para dividir o que estava sendo calculado em um isolamento de segundo plano em um pool de N isolados de segundo plano.
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);
}
A maior parte desse código deve ser familiar, já que a lógica de negócios principal não mudou. O que mudou é que agora há duas camadas de chamadas compute
. A primeira camada é responsável por distribuir posições individuais para pesquisar N isolados de workers e, em seguida, recombinar os resultados quando todos os N workers isolados estiverem concluídos. A segunda camada consiste nos N isolados de workers. O ajuste do N para obter o melhor desempenho depende tanto do computador quanto dos dados em questão. Quanto maior a grade, mais workers podem trabalhar juntos sem atrapalhar uns aos outros.
Um ponto interessante é observar como esse código agora lida com o problema de fechamentos que capturam coisas que não devem ser capturadas. Não há interdições no momento. As funções _generate
e _generateWorker
são definidas como funções de nível superior, que não têm um ambiente ao redor para capturar. Os argumentos em e os resultados de ambas as funções estão na forma de registros Dart. Essa é uma maneira fácil de contornar o único valor em, um valor fora da semântica da chamada compute
.
Agora que você sabe criar um pool de workers em segundo plano para procurar palavras que se interligam em uma grade para formar palavras cruzadas, é hora de expor esse recurso ao restante da ferramenta geradora de palavras cruzadas.
- Edite o arquivo
providers.dart
editando o provedor de workQueue da seguinte maneira:
lib/providers.dart
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef 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();
}
- Adicione o provedor
WorkerCount
ao final do arquivo da seguinte forma:
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.
Com essas duas mudanças, a camada do provedor agora expõe uma maneira de definir a contagem máxima de workers para o pool de isolamento em segundo plano de uma forma que as funções de isolamento estejam configuradas corretamente.
- Atualize o arquivo
crossword_info_widget.dart
modificando oCrosswordInfoWidget
da seguinte maneira:
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 these two lines
label: 'Max worker count', value: workerCount),
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),
],
),
),
),
),
),
),
);
}
}
- Modifique o arquivo
crossword_generator_app.dart
adicionando a seguinte seção ao widget_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),
),
);
}
Se você executar o app agora, vai poder modificar o número de isolamentos em segundo plano que estão sendo instanciados para procurar palavras e encaixar nas palavras cruzadas.
- Clique no ícone de engrenagem para abrir o menu contextual que contém o dimensionamento das palavras cruzadas, se quer mostrar as estatísticas sobre as palavras cruzadas geradas no momento e o número de isolamentos que serão usados.
Executar o gerador de palavras cruzadas reduziu significativamente o tempo de computação de palavras cruzadas de 80 x 44 usando vários núcleos simultaneamente.
9. Transforme o jogo em um jogo
Essa última seção é uma rodada bônus. Você usará todas as técnicas que aprendeu na construção do gerador de palavras cruzadas e as usará para criar um jogo. Você vai usar o gerador de palavras cruzadas para criar um quebra-cabeça. Você vai reutilizar os idiomas do menu contextual para permitir que o usuário selecione e desmarque palavras para colocar nos vários buracos em forma de palavra na grade. Tudo isso com o objetivo de completar palavras cruzadas.
Não vou dizer que esse jogo está sofisticado ou finalizado, está longe de ser. Existem questões de equilíbrio e dificuldade que podem ser resolvidas com o aprimoramento da escolha de palavras alternativas. Não há tutorial para direcionar os usuários, e a animação pensativa deixa muito a desejar. Nem vou nem mencionar o esqueleto de "Você ganhou!" tela.
A desvantagem aqui é que transformar corretamente esse protojogo em um jogo completo exige muito mais código. Um único codelab inclui mais códigos do que o necessário. Em vez disso, esta é uma etapa de speedrun criada para reforçar as técnicas aprendidas até agora neste codelab, mudando o local e a forma como elas são usadas. Esperamos que isso reforce as lições aprendidas anteriormente neste codelab. Como alternativa, você pode criar suas próprias experiências com base nesse código. Adoraríamos ver o que você criou!
Para começar, siga estas etapas:
- Exclua tudo no diretório
lib/widgets
. Você criará widgets novos e brilhantes para o jogo. O que acontece é que muitos dos widgets antigos são usados emprestados. - Edite o arquivo
model.dart
para atualizar o métodoaddWord
doCrossword
desta forma:
lib/model.dart
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
bool requireOverlap = true, // Add this parameter
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
// Edit from here
// If overlap is required, make sure that the word overlaps with an existing
// word. Skip this test if the crossword is empty.
if (words.isNotEmpty && !overlap && requireOverlap) { // To here.
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
Com essa pequena modificação do seu modelo de palavras cruzadas, é possível adicionar palavras que não se sobrepõem. Isso é útil para permitir que os jogadores joguem em qualquer lugar do tabuleiro e usem o Crossword
como um modelo base para armazenar os movimentos. É apenas uma lista de palavras em locais específicos colocados em uma direção específica.
- Adicione a classe de modelo
CrosswordPuzzleGame
ao final do arquivomodel.dart
.
lib/model.dart
/// Creates a puzzle from a crossword and a set of candidate words.
abstract class CrosswordPuzzleGame
implements Built<CrosswordPuzzleGame, CrosswordPuzzleGameBuilder> {
static Serializer<CrosswordPuzzleGame> get serializer =>
_$crosswordPuzzleGameSerializer;
/// The [Crossword] that this puzzle is based on.
Crossword get crossword;
/// The alternate words for each [CrosswordWord] in the crossword.
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>> get alternateWords;
/// The player's selected words.
BuiltList<CrosswordWord> get selectedWords;
bool canSelectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return true;
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild((b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
));
}
return null !=
puzzle.crosswordFromSelectedWords.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false);
}
CrosswordPuzzleGame? selectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return rebuild((b) => b.selectedWords.remove(crosswordWord));
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild((b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
));
}
// Check if the selected word meshes with the already selected words.
// Note this version of the crossword does not enforce overlap to
// allow the player to select words anywhere on the grid. Enforcing words
// to be solved in order is a possible alternative.
final updatedSelectedWordsCrossword =
puzzle.crosswordFromSelectedWords.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false,
);
// Make sure the selected word is in the crossword or is an alternate word.
if (updatedSelectedWordsCrossword != null) {
if (puzzle.crossword.words.contains(crosswordWord) ||
puzzle.alternateWords[location]?[direction]?.contains(word) == true) {
return puzzle.rebuild((b) => b
..selectedWords.add(CrosswordWord.word(
word: word, location: location, direction: direction)));
}
}
return null;
}
/// The crossword from the selected words.
Crossword get crosswordFromSelectedWords => Crossword.crossword(
width: crossword.width, height: crossword.height, words: selectedWords);
/// Test if the puzzle is solved. Note, this allows for the possibility of
/// multiple solutions.
bool get solved =>
crosswordFromSelectedWords.valid &&
crosswordFromSelectedWords.words.length == crossword.words.length &&
crossword.words.isNotEmpty;
/// Create a crossword puzzle game from a crossword and a set of candidate
/// words.
factory CrosswordPuzzleGame.from({
required Crossword crossword,
required BuiltSet<String> candidateWords,
}) {
// Remove all of the currently used words from the list of candidates
candidateWords = candidateWords
.rebuild((p0) => p0.removeAll(crossword.words.map((p1) => p1.word)));
// This is the list of alternate words for each word in the crossword
var alternates =
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>>();
// Build the alternate words for each word in the crossword
for (final crosswordWord in crossword.words) {
final alternateWords = candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.length == crosswordWord.word.length)
..shuffle()
..take(4)
..sort());
candidateWords =
candidateWords.rebuild((b) => b.removeAll(alternateWords));
alternates = alternates.rebuild(
(b) => b.updateValue(
crosswordWord.location,
(b) => b.rebuild(
(b) => b.updateValue(
crosswordWord.direction,
(b) => b.rebuild((b) => b.replace(alternateWords)),
ifAbsent: () => alternateWords,
),
),
ifAbsent: () => {crosswordWord.direction: alternateWords}.build(),
),
);
}
return CrosswordPuzzleGame((b) {
b
..crossword.replace(crossword)
..alternateWords.replace(alternates);
});
}
factory CrosswordPuzzleGame(
[void Function(CrosswordPuzzleGameBuilder)? updates]) =
_$CrosswordPuzzleGame;
CrosswordPuzzleGame._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo,
CrosswordPuzzleGame, // Add this line
])
final Serializers serializers = _$serializers;
As atualizações do arquivo providers.dart
são um pacote interessante de mudanças. A maioria dos provedores disponíveis para oferecer suporte à coleta de estatísticas foi removida. A capacidade de mudar o número de isolamentos em segundo plano foi removida e substituída por uma constante. Há também um novo provedor que dá acesso ao novo modelo CrosswordPuzzleGame
que você acabou de adicionar acima.
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_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(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();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef 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);
As partes mais interessantes do provedor de Puzzle
são as estratégias usadas para ignorar as despesas de criação da CrosswordPuzzleGame
usando um Crossword
e um wordList
e a despesa da seleção de uma palavra. Essas duas ações, quando realizadas sem a ajuda de "isolar" em segundo plano, causam uma interação lenta com a interface. Ao usar algum truque para enviar um resultado intermediário e calcular o resultado final em segundo plano, você terá uma interface responsiva enquanto os cálculos necessários estão ocorrendo em segundo plano.
- No diretório
lib/widgets
agora vazio, crie um arquivocrossword_puzzle_app.dart
com o seguinte conteúdo:
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),
),
);
}
Você provavelmente já conhece a maior parte desse arquivo. Sim, haverá widgets indefinidos, que você vai começar a corrigir.
- Crie um arquivo
crossword_generator_widget.dart
e adicione o seguinte conteúdo a ele:
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),
),
),
);
}
}
Você também precisa conhecer bem esse recurso. A principal diferença é que, em vez de exibir os caracteres das palavras que estão sendo geradas, agora você está exibindo um caractere Unicode para indicar a presença de um caractere desconhecido. Isso pode dar um pouco de trabalho para melhorar a estética.
- Crie o arquivo
crossword_puzzle_widget.dart
e adicione o seguinte conteúdo a ele:
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),
);
}
}
Este widget é um pouco mais intenso do que o anterior, embora tenha sido construído com peças que já foram usadas em outros lugares no passado. Agora, cada célula preenchida produz um menu de contexto quando clicada, que lista as palavras que o usuário pode selecionar. Se houver palavras selecionadas, não será possível selecionar palavras em conflito. Para desmarcar uma palavra, o usuário toca no item de menu dela.
Supondo que o jogador possa selecionar palavras para preencher todas as palavras cruzadas, você precisa de um "Você ganhou!" tela.
- Crie um arquivo
puzzle_completed_widget.dart
e adicione o seguinte conteúdo a ele:
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,
),
),
);
}
}
Tenho certeza de que você pode deixar isso mais interessante. Para saber mais sobre as ferramentas de animação, consulte o codelab Como criar interfaces de última geração no Flutter (link em inglês).
- Edite o arquivo
lib/main.dart
desta forma:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_puzzle_app.dart'; // Update this line
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Puzzle', // Update this line
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordPuzzleApp(), // Update this line
),
),
);
}
Ao executar este app, você vai ver uma animação enquanto o gerador de palavras cruzadas gera o quebra-cabeça. Em seguida, será apresentado a você um quebra-cabeça em branco para resolver. Supondo que você solucione o problema, uma tela semelhante a esta será exibida:
10. Parabéns
Parabéns! Você conseguiu criar um quebra-cabeças com o Flutter.
Você criou um gerador de palavras cruzadas que se tornou um quebra-cabeças. Você dominou a execução de cálculos em segundo plano em um pool de isolamentos. Você usou estruturas de dados imutáveis para facilitar a implementação de um algoritmo de retrocesso. Além disso, você passou um tempo de qualidade usando o TableView
, que será útil na próxima vez que você precisar exibir dados tabulares.