1. 始める前に
世界最大のクロスワード パズルを作ることが可能かどうか尋ねられると想像してみてください。あなたは学校で学習した AI 手法を思い出し、計算負荷の高い問題の解決策を Flutter で解決できるアルゴリズムを習得できないかと考えました。
この Codelab では、まさにそのとおりです。最終的に、単語グリッドパズルを構築するアルゴリズムの分野でプレイするためのツールが完成します。有効なクロスワード パズルにはさまざまな定義があり、これらのテクニックは自分の定義に合ったパズルを作るのに役立ちます。
このツールを土台として、クロスワード ジェネレータを使用してユーザーが解くパズルを作成するクロスワード パズルを作成します。このパズルは、Android、iOS、Windows、macOS、Linux で使用できます。Android では次のようになります。
前提条件
- 初めての Flutter アプリ Codelab を修了している
学習内容
- Flutter の
compute
関数と Riverpod のselect
再ビルド フィルタの値キャッシュ機能を組み合わせて、Flutter のレンダリング ループを妨げることなく、アイソレートを使用して計算コストの高い処理を行う方法。 built_value
とbuilt_collection
で不変のデータ構造を利用して、深度優先検索や後戻りなどの検索ベースの Good Old Fashioned AI(GOFAI)手法を簡単に実装する方法。two_dimensional_scrollables
パッケージの機能を使用して、グリッドデータを迅速かつ直感的に表示する方法。
必要なもの
- Flutter SDK。
- Visual Studio Code(VS Code)と Flutter プラグインと Dart プラグイン。
- 選択した開発ターゲットのコンパイラ ソフトウェア。この Codelab は、すべてのデスクトップ プラットフォーム、Android、iOS で使用できます。Windows をターゲットとするには VS Code、macOS または iOS をターゲットとするには Xcode、Android には Android Studio が必要です。
2. プロジェクトを作成する
最初の Flutter プロジェクトを作成する
- VS Code を起動します。
- コマンドラインで「flutter new」と入力し、メニューで [Flutter: New Project] を選択します。
- [Empty application] を選択し、プロジェクトを作成するディレクトリを選択します。これは、昇格した権限を必要としないディレクトリか、パスにスペースがある任意のディレクトリです。たとえば、ホーム ディレクトリや
C:\src\
などです。
- プロジェクトに
generate_crossword
という名前を付けます。この Codelab の残りの部分では、アプリにgenerate_crossword
という名前を付けたことを前提としています。
Flutter によってプロジェクト フォルダが作成され、VS Code がそのフォルダを開きます。次に、2 つのファイルの内容を、このアプリの基本的なスキャフォールドで上書きします。
最初のアプリをコピーして貼り付ける
- VS Code の左側のペインで、[Explorer] をクリックして
pubspec.yaml
ファイルを開きます。
- このファイルの内容を次のように置き換えます。
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
pubspec.yaml
ファイルでは、現在のバージョンや依存関係など、アプリの基本情報を指定します。ここには、通常の空の Flutter アプリには含まれていない依存関係の集まりが表示されます。以降のステップでは、これらすべてのパッケージを利用できます。
lib/
ディレクトリにあるmain.dart
ファイルを開きます。
- このファイルの内容を次のように置き換えます。
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),
),
),
),
),
),
);
}
- このコードを実行して、すべてが正常に機能することを確認します。新しいウィンドウが開き、あらゆる場所で新しいプロジェクトの開始時に必須のフレーズが表示されます。
ProviderScope
があり、このアプリは状態管理にriverpod
を使用することを示します。
3. 単語を追加
クロスワード パズルの構成要素
クロスワードとは、基本的には単語の羅列です。単語が交差するように、グリッド状に並んで配置されています。1 つの単語を解くと、その最初の単語にまたがって出現する単語についての手がかりが得られます。したがって、最初の構成要素は単語のリストである必要があります。
こうした単語の優れたソースは、Peter Norvig 氏の Natural Language Corpus Data のページです。267,750 語の SOWPODS リストを出発点として活用できる。
このステップでは、単語のリストをダウンロードして Flutter アプリにアセットとして追加し、起動時にリストがアプリに読み込まれるように Riverpod プロバイダを調整します。
まず、次の手順に従います。
- プロジェクトの
pubspec.yaml
ファイルを変更して、選択した単語リストに次のアセット宣言を追加します。このリストには、アプリの構成のフラッター スタンザのみが表示されています。他の部分は同じままです。
pubspec.yaml
flutter:
uses-material-design: true
assets: // Add this line
- assets/words.txt // And this one.
このファイルはまだ作成されていないため、エディタでこの最後の行が警告とともにハイライト表示される可能性があります。
- ブラウザとエディタを使用して、プロジェクトの最上位レベルに
assets
ディレクトリを作成し、上記のリンク先の単語リストのいずれかを含むwords.txt
ファイルを作成します。
このコードは上記の SOWPODS リストに基づいて作成されていますが、A ~ Z の文字のみで構成される単語リストで使用できます。さまざまな文字セットを使用できるようにこのコードベースを拡張するのは、演習として残しておきましょう。
単語を読み込む
アプリ起動時に単語リストを読み込むコードを記述するには、次の手順を行います。
lib
ディレクトリにproviders.dart
ファイルを作成します。- ファイルに以下を追加します。
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)));
}
これは、このコードベースの最初の Riverpod プロバイダです。エディターから、未定義のクラスや未生成のターゲットといった領域がいくつかあることがわかります。このプロジェクトでは、Riverpod を含む複数の依存関係にコード生成を使用しているため、未定義のクラスエラーが予想されます。
- コードの生成を開始するには、次のコマンドを実行します。
$ dart run build_runner watch -d [INFO] Generating build script completed, took 174ms [INFO] Setting up file watchers completed, took 5ms [INFO] Waiting for all file watchers to be ready completed, took 202ms [INFO] Reading cached asset graph completed, took 65ms [INFO] Checking for updates since last build completed, took 680ms [INFO] Running build completed, took 2.3s [INFO] Caching finalized dependency graph completed, took 42ms [INFO] Succeeded after 2.3s with 122 outputs (243 actions)
引き続きバックグラウンドで実行され、プロジェクトに変更を加えると生成されたファイルが更新されます。このコマンドで providers.g.dart
にコードが生成されると、エディタは、上記で providers.dart
に追加したコードに満足するはずです。
Riverpod では、上で定義した wordList
関数のようなプロバイダのインスタンス化は通常遅延します。ただし、このアプリの目的上、単語リストを積極的に読み込む必要があります。Riverpod のドキュメントでは、積極的に読み込む必要があるプロバイダに対処する次のアプローチを提案しています。次はこれを実装します。
lib/widgets
ディレクトリにcrossword_generator_app.dart
ファイルを作成します。- ファイルに以下を追加します。
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
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;
}
}
このファイルは、2 つの異なる方向から興味深いものです。1 つ目は _EagerInitialization
ウィジェットです。これは、上記で作成した wordList
プロバイダに単語リストを読み込むことを要求することを唯一の目的としています。このウィジェットは、ref.watch()
呼び出しを使用してプロバイダをリッスンすることで、この目的を実現しています。この手法について詳しくは、Riverpod のドキュメントのプロバイダの積極的な初期化をご覧ください。
このファイルで注目すべき 2 つ目の注目点は、Riverpod が非同期コンテンツを処理する方法です。すでに説明したように、wordList
プロバイダは、ディスクからのコンテンツの読み込みが遅いため、非同期関数として定義されています。このコードの単語リスト プロバイダを監視すると、AsyncValue<BuiltSet<String>>
を受け取ります。その型の AsyncValue
部分は、プロバイダの非同期とウィジェットの build
メソッドの同期間のアダプターです。
AsyncValue
の when
メソッドは、将来の値がとれる 3 つの潜在的な状態を処理します。Future は正常に解決された可能性があります。その場合は data
コールバックが呼び出され、エラー状態になっている可能性があります。その場合は error
コールバックが呼び出され、最終的には読み込み中である可能性があります。呼び出されたコールバックの戻り値は when
メソッドによって返されるため、3 つのコールバックの戻り値の型は互換性のある戻り値の型でなければなりません。この例では、when メソッドの結果が Scaffold
ウィジェットの body
として表示されます。
ほぼ無限のリストのアプリを作成する
CrosswordGeneratorApp
ウィジェットをアプリに統合する手順は次のとおりです。
- 次のコードを追加して
lib/main.dart
ファイルを更新します。
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_generator_app.dart'; // Add this import
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordGeneratorApp(), // Remove what was here and replace
),
),
);
}
- アプリを再起動します。スクロール リストが表示され、この状態がほぼ永続的に表示されます。
4. 単語をグリッド形式で表示します
このステップでは、built_value
パッケージと built_collection
パッケージを使用して、クロスワード パズルを作成するためのデータ構造を作成します。この 2 つのパッケージを使用すると、データ構造を不変の値として構築できます。これは、分離間でデータを簡単に渡すことや、深度優先の検索と後戻りを簡単に実装できるようにする場合に役立ちます。
まず、次の手順に従います。
lib
ディレクトリにmodel.dart
ファイルを作成し、次の内容をファイルに追加します。
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
static Serializer<Location> get serializer => _$locationSerializer;
/// The horizontal part of the location. The location is 0 based.
int get x;
/// The vertical part of the location. The location is 0 based.
int get y;
/// Returns a new location that is one step to the left of this location.
Location get left => rebuild((b) => b.x = x - 1);
/// Returns a new location that is one step to the right of this location.
Location get right => rebuild((b) => b.x = x + 1);
/// Returns a new location that is one step up from this location.
Location get up => rebuild((b) => b.y = y - 1);
/// Returns a new location that is one step down from this location.
Location get down => rebuild((b) => b.y = y + 1);
/// Returns a new location that is [offset] steps to the left of this location.
Location leftOffset(int offset) => rebuild((b) => b.x = x - offset);
/// Returns a new location that is [offset] steps to the right of this location.
Location rightOffset(int offset) => rebuild((b) => b.x = x + offset);
/// Returns a new location that is [offset] steps up from this location.
Location upOffset(int offset) => rebuild((b) => b.y = y - offset);
/// Returns a new location that is [offset] steps down from this location.
Location downOffset(int offset) => rebuild((b) => b.y = y + offset);
/// Pretty print a location as a (x,y) coordinate.
String prettyPrint() => '($x,$y)';
/// Returns a new location built from [updates]. Both [x] and [y] are
/// required to be non-null.
factory Location([void Function(LocationBuilder)? updates]) = _$Location;
Location._();
/// Returns a location at the given coordinates.
factory Location.at(int x, int y) {
return Location((b) {
b
..x = x
..y = y;
});
}
}
/// The direction of a word in a crossword.
enum Direction {
across,
down;
@override
String toString() => name;
}
/// A word in a crossword. This is a word at a location in a crossword, in either
/// the across or down direction.
abstract class CrosswordWord
implements Built<CrosswordWord, CrosswordWordBuilder> {
static Serializer<CrosswordWord> get serializer => _$crosswordWordSerializer;
/// The word itself.
String get word;
/// The location of this word in the crossword.
Location get location;
/// The direction of this word in the crossword.
Direction get direction;
/// Compare two CrosswordWord by coordinates, x then y.
static int locationComparator(CrosswordWord a, CrosswordWord b) {
final compareRows = a.location.y.compareTo(b.location.y);
final compareColumns = a.location.x.compareTo(b.location.x);
return switch (compareColumns) { 0 => compareRows, _ => compareColumns };
}
/// Constructor for [CrosswordWord].
factory CrosswordWord.word({
required String word,
required Location location,
required Direction direction,
}) {
return CrosswordWord((b) => b
..word = word
..direction = direction
..location.replace(location));
}
/// Constructor for [CrosswordWord].
/// Use [CrosswordWord.word] instead.
factory CrosswordWord([void Function(CrosswordWordBuilder)? updates]) =
_$CrosswordWord;
CrosswordWord._();
}
/// A character in a crossword. This is a single character at a location in a
/// crossword. It may be part of an across word, a down word, both, but not
/// neither. The neither constraint is enforced elsewhere.
abstract class CrosswordCharacter
implements Built<CrosswordCharacter, CrosswordCharacterBuilder> {
static Serializer<CrosswordCharacter> get serializer =>
_$crosswordCharacterSerializer;
/// The character at this location.
String get character;
/// The across word that this character is a part of.
CrosswordWord? get acrossWord;
/// The down word that this character is a part of.
CrosswordWord? get downWord;
/// Constructor for [CrosswordCharacter].
/// [acrossWord] and [downWord] are optional.
factory CrosswordCharacter.character({
required String character,
CrosswordWord? acrossWord,
CrosswordWord? downWord,
}) {
return CrosswordCharacter((b) {
b.character = character;
if (acrossWord != null) {
b.acrossWord.replace(acrossWord);
}
if (downWord != null) {
b.downWord.replace(downWord);
}
});
}
/// Constructor for [CrosswordCharacter].
/// Use [CrosswordCharacter.character] instead.
factory CrosswordCharacter(
[void Function(CrosswordCharacterBuilder)? updates]) =
_$CrosswordCharacter;
CrosswordCharacter._();
}
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Add a word to the crossword at the given location and direction.
Crossword addWord({
required Location location,
required String word,
required Direction direction,
}) {
return rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width, (_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
])
final Serializers serializers = _$serializers;
このファイルでは、クロスワードの作成に使用するデータ構造の最初の部分を記述します。クロスワード パズルの核心は、単語が横方向と縦方向に並んでグリッドに並んでいるものです。このデータ構造を使用するには、Crossword.crossword
という名前付きコンストラクタで適切なサイズの Crossword
を作成し、addWord
メソッドを使用して単語を追加します。ファイナライズされた値の構築の一環として、_fillCharacters
メソッドによって CrosswordCharacter
のグリッドが作成されます。
このデータ構造を使用する手順は次のとおりです。
lib
ディレクトリにutils
ファイルを作成し、次の内容をファイルに追加します。
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
これは、セットのランダムな要素を簡単に取得できるようにする BuiltSet
の拡張機能です。拡張メソッドを使用すると、機能を追加してクラスを簡単に拡張できます。拡張機能を utils.dart
ファイルの外部で使用できるようにするには、拡張機能に名前を付ける必要があります。
lib/providers.dart
ファイルに次のインポートを追加します。
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart'; // Add this import
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model; // And this import
import 'utils.dart'; // And this one
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
これらのインポートにより、上で定義したモデルが、これから作成するプロバイダに公開されます。Random
の dart:math
インポート、debugPrint
の flutter/foundation.dart
インポート、モデルの model.dart
、BuiltSet
拡張機能の utils.dart
が含まれています。
- 同じファイルの末尾に次のプロバイダを追加します。
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;
},
);
}
これらの変更により、アプリに 2 つのプロバイダが追加されます。1 つ目は Size
です。これは実質的に、CrosswordSize
列挙型で現在選択されている値を含むグローバル変数です。これにより、作成中のクロスワードの表示とサイズを UI で設定できるようになります。2 番目のプロバイダ crossword
は、より興味深いオブジェクトです。一連の Crossword
を返す関数です。これは、関数の async*
でマークされているように、Dart のジェネレータサポートを使用してビルドされています。つまり、戻り値で終了する代わりに、一連の Crossword
を生成することで、中間結果を返す計算を簡単に記述できます。
crossword
プロバイダ関数の開始時にペアの ref.watch
呼び出しが存在するため、クロスワードの選択サイズが変更されるたび、および単語リストの読み込みが完了するたびに、Crossword
のストリームが Riverpod システムによって再開されます。
クロスワードを生成するコードは、無作為な単語が多くあるものの、生成するコードが揃ったので、ツールのユーザーに提示してみましょう。
lib/widgets
ディレクトリに、次の内容のcrossword_widget.dart
ファイルを作成します。
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
crosswordProvider.select(
(crosswordAsync) => crosswordAsync.when(
data: (crossword) => crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
if (character != null) {
return Container(
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: Text(
character.character,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
このウィジェットは ConsumerWidget
であるため、Size
プロバイダに直接依存して、Crossword
の文字を表示するグリッドのサイズを決定できます。このグリッドの表示は、two_dimensional_scrollables
パッケージの TableView
ウィジェットで行われます。
_buildCell
ヘルパー関数によってレンダリングされる個々のセルには、返される Widget
ツリーに Consumer
ウィジェットが含まれていることに注意してください。これは更新境界として機能します。ref.watch
の戻り値が変更されると、Consumer
ウィジェット内のすべてが再作成されます。Crossword
が変更されるたびにツリー全体を再作成したくなるかもしれませんが、計算量が多くなり、この設定ではスキップできます。
ref.watch
のパラメータを見ると、crosswordProvider.select
を使用してレイアウトの再計算を回避する別のレイヤがあることがわかります。つまり、ref.watch
は、セルがレンダリングする文字が変更された場合にのみ、TableViewCell
のコンテンツの再ビルドをトリガーします。このように再レンダリングを減らすことは、UI の応答性を維持するための重要な要素です。
CrosswordWidget
プロバイダと Size
プロバイダをユーザーに公開するには、crossword_generator_app.dart
ファイルを次のように変更します。
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_widget.dart'; // Add this import
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()], // Add this line
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: CrosswordWidget(), // 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.
}
いくつかの点が変更されています。まず、wordList
を ListView
としてレンダリングするコードが、前のファイルで定義した CrosswordWidget
の呼び出しに置き換えられました。もう 1 つの大きな変更点は、クロスワードのサイズを変更するなど、アプリの動作を変更するためのメニューが最初に表示されることです。今後のステップで、さらに多くの MenuItemButton
が追加される予定です。アプリを実行すると、次のように表示されます。
グリッドに文字が表示され、ユーザーがグリッドのサイズを変更できるメニューが表示されている。しかし、単語はクロスワード パズルのようには並べられていません。これは、クロスワードへの単語の追加方法に関する制約が一切適用されていないためです。要するに、混乱しています。次のステップで、自分で管理できるものがあるということです。
5. 制約を適用する
このステップの目標は、モデルにコードを追加してクロスワード制約を適用することです。クロスワード パズルにはさまざまな種類があり、この Codelab で適用するスタイルは、英語のクロスワード パズルの伝統に従います。このコードを変更して、他のスタイルのクロスワード パズルを生成することは、これまでのように、読者にとっての課題となります。
まず、次の手順に従います。
model.dart
ファイルを開き、Crossword
モデルを次のように置き換えます。
lib/model.dart
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword,
/// or checking the proposed solution.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Checks if this crossword is valid.
bool get valid {
// Check that there are no duplicate words.
final wordSet = words.map((word) => word.word).toBuiltSet();
if (wordSet.length != words.length) {
return false;
}
for (final MapEntry(key: location, value: character)
in characters.entries) {
// All characters must be a part of an across or down word.
if (character.acrossWord == null && character.downWord == null) {
return false;
}
// All characters must be within the crossword puzzle.
// No drawing outside the lines.
if (location.x < 0 ||
location.y < 0 ||
location.x >= width ||
location.y >= height) {
return false;
}
// Characters above and below this character must be related
// by a vertical word
if (characters[location.up] case final up?) {
if (character.downWord == null) {
return false;
}
if (up.downWord != character.downWord) {
return false;
}
}
if (characters[location.down] case final down?) {
if (character.downWord == null) {
return false;
}
if (down.downWord != character.downWord) {
return false;
}
}
// Characters to the left and right of this character must be
// related by a horizontal word
final left = characters[location.left];
if (left != null) {
if (character.acrossWord == null) {
return false;
}
if (left.acrossWord != character.acrossWord) {
return false;
}
}
final right = characters[location.right];
if (right != null) {
if (character.acrossWord == null) {
return false;
}
if (right.acrossWord != character.acrossWord) {
return false;
}
}
}
return true;
}
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
if (words.isNotEmpty && !overlap) {
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width, (_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
なお、model.dart
ファイルと providers.dart
ファイルに変更を加えるには、build_runner
を実行してそれぞれの model.g.dart
ファイルと providers.g.dart
ファイルを更新する必要があります。これらのファイルが自動的に更新されていない場合は、この時点で dart run build_runner watch -d
を使用して build_runner
を再開することをおすすめします。
モデルレイヤでこの新機能を利用するには、それに合わせてプロバイダ レイヤを更新する必要があります。
providers.dart
ファイルを次のように編集します。
lib/providers.dart
import 'dart:convert';
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model;
import 'utils.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
var candidate = crossword.addWord( // Edit from here
word: word, direction: direction, location: location);
await Future.delayed(Duration(milliseconds: 10));
if (candidate != null) {
debugPrint('Added word: $word');
crossword = candidate;
yield crossword;
} else {
debugPrint('Failed to add word: $word');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
- アプリを実行します。UI には何も表示されませんが、ログを見ると多くのことが起きています。
何が起こっているのか考えてみると、クロスワードが偶然に現れています。Crossword
モデルの addWord
メソッドは、現在のクロスワードに合わない候補の単語をすべて拒否します。そのため、何かが出現するのは驚くべきことです。
どの単語を試すかについてより系統的に選択するための準備として、この計算を UI スレッドからバックグラウンド分離に移動しておくと非常に便利です。Flutter には、一連の処理を取得してバックグラウンド分離で実行するための非常に便利なラッパー、compute
関数があります。
providers.dart
ファイルで、クロスワード プロバイダを次のように変更します。
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;
},
);
}
このコードは機能します。しかし、そこには罠が仕掛けられています。このパスをそのまま使用すると、最終的に次のようなロギングエラーになります。
flutter: Error running isolate: Invalid argument(s): Illegal argument in isolate message: object is unsendable - Library:'dart:async' Class: _Future@4048458 (see restrictions listed at `SendPort.send()` documentation for more information) flutter: <- Instance of 'AutoDisposeStreamProviderElement<Crossword>' (from package:riverpod/src/stream_provider.dart) flutter: <- Context num_variables: 2 <- Context num_variables: 1 parent:{ Context num_variables: 2 } flutter: <- Context num_variables: 1 parent:{ Context num_variables: 1 parent:{ Context num_variables: 2 } } flutter: <- Closure: () => Crossword? (from package:generate_crossword/providers.dart)
これは、compute
がバックグラウンド分離に引き渡しているクロージャの結果であり、プロバイダをクローズした結果です。プロバイダは SendPort.send()
経由で送信できません。この問題の解決策の 1 つは、クロージャで閉じるために送信できないものがないことを確認することです。
最初のステップは、プロバイダを分離コードから分離することです。
lib
ディレクトリにisolates.dart
ファイルを作成し、次の内容を追加します。
lib/isolates.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
final _random = Random();
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
while (
crossword.characters.length < crossword.width * crossword.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool() ? Direction.across : Direction.down;
final location = Location.at(
_random.nextInt(crossword.width), _random.nextInt(crossword.height));
try {
var candidate = await compute(((String, Direction, Location) wordToAdd) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word, direction: direction, location: location);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
}
このコードはかなり見覚えがあるはずです。これは crossword
プロバイダの中核をなすものですが、現在はスタンドアロンのジェネレータ関数になっています。これで、providers.dart
ファイルを更新して、この新しい関数を使用してバックグラウンド分離をインスタンス化できるようになりました。
lib/providers.dart
// Drop the dart:math import, the _random instance moved to isolates.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_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.
},
);
}
これで、さまざまなサイズのクロスワード パズルを作成するツールが完成しました。compute
は、背景の分離領域でパズルを実行するものです。では、クロスワード パズルに追加しようとする単語を決める際に、このコードがもっと効率的になることを願っています。
6. 作業キューを管理する
現状のコードの問題の一部は、解決される問題が実質的に検索問題であり、現在の解決策では盲目的検索であることです。グリッド上の任意の場所に無作為に配置しようとするのではなく、コードが現在の単語に結び付く単語を見つけることに集中すれば、システムはより早く解決策を見つけられるようになります。これに対処する一つの方法として、位置の作業キューを導入して、対象となる単語を探します。
このコードは現在、候補のソリューションをビルドして、候補のソリューションが有効かどうかを確認し、有効性に応じて候補を組み込むか破棄します。これは、アルゴリズムのバックトラッキング ファミリーの実装例です。この実装は、built_value
と built_collection
によって大幅に簡素化されます。これにより、新しいイミュータブルな値を導出して、その派生元の不変値と共通の状態を共有する新しいイミュータブル値を作成できます。これにより、ディープコピーに必要なメモリコストなしに、潜在的な候補を安価に悪用できます。
まず、次の手順に従います。
model.dart
ファイルを開き、次のWorkQueue
定義を追加します。
lib/model.dart
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
// Add from here
/// A work queue for a worker to process. The work queue contains a crossword
/// and a list of locations to try, along with candidate words to add to the
/// crossword.
abstract class WorkQueue implements Built<WorkQueue, WorkQueueBuilder> {
static Serializer<WorkQueue> get serializer => _$workQueueSerializer;
/// The crossword the worker is working on.
Crossword get crossword;
/// The outstanding queue of locations to try.
BuiltMap<Location, Direction> get locationsToTry;
/// Known bad locations.
BuiltSet<Location> get badLocations;
/// The list of unused candidate words that can be added to this crossword.
BuiltSet<String> get candidateWords;
/// Returns true if the work queue is complete.
bool get isCompleted => locationsToTry.isEmpty || candidateWords.isEmpty;
/// Create a work queue from a crossword.
static WorkQueue from({
required Crossword crossword,
required Iterable<String> candidateWords,
required Location startLocation,
}) =>
WorkQueue((b) {
if (crossword.words.isEmpty) {
// Strip candidate words too long to fit in the crossword
b.candidateWords.addAll(candidateWords
.where((word) => word.characters.length <= crossword.width));
b.crossword.replace(crossword);
b.locationsToTry.addAll({startLocation: Direction.across});
} else {
// Assuming words have already been stripped to length
b.candidateWords.addAll(
candidateWords.toBuiltSet().rebuild(
(b) => b.removeAll(crossword.words.map((word) => word.word))),
);
b.crossword.replace(crossword);
crossword.characters
.rebuild((b) => b.removeWhere((location, character) {
if (character.acrossWord != null &&
character.downWord != null) {
return true;
}
final left = crossword.characters[location.left];
if (left != null && left.downWord != null) return true;
final right = crossword.characters[location.right];
if (right != null && right.downWord != null) return true;
final up = crossword.characters[location.up];
if (up != null && up.acrossWord != null) return true;
final down = crossword.characters[location.down];
if (down != null && down.acrossWord != null) return true;
return false;
}))
.forEach((location, character) {
b.locationsToTry.addAll({
location: switch ((character.acrossWord, character.downWord)) {
(null, null) =>
throw StateError('Character is not part of a word'),
(null, _) => Direction.across,
(_, null) => Direction.down,
(_, _) => throw StateError('Character is part of two words'),
}
});
});
}
});
WorkQueue remove(Location location) => rebuild((b) => b
..locationsToTry.remove(location)
..badLocations.add(location));
/// Update the work queue from a crossword derived from the current crossword
/// that this work queue is built from.
WorkQueue updateFrom(final Crossword crossword) => WorkQueue.from(
crossword: crossword,
candidateWords: candidateWords,
startLocation: locationsToTry.isNotEmpty
? locationsToTry.keys.first
: Location.at(0, 0),
).rebuild((b) => b
..badLocations.addAll(badLocations)
..locationsToTry
.removeWhere((location, _) => badLocations.contains(location)));
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue, // Add this line
])
final Serializers serializers = _$serializers;
- この新しいコンテンツを数秒以上追加した後、このファイルに赤い波線が残っている場合は、
build_runner
が実行中であることを確認してください。実行されていない場合は、dart run build_runner watch -d
コマンドを実行します。
これから、さまざまなサイズのクロスワードを作成するのにかかる時間を示すロギングをコードに導入しようとしています。Durations がなんらかの形で適切にフォーマットされた表示があれば便利です。幸いなことに、拡張メソッドを使用すれば、必要なメソッドを正確に追加できます。
utils.dart
ファイルを次のように編集します。
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
// Add from here
/// An extension on [Duration] that adds a method to format the duration.
extension DurationFormat on Duration {
/// A human-readable string representation of the duration.
/// This format is tuned for durations in the seconds to days range.
String get formatted {
final hours = inHours.remainder(24).toString().padLeft(2, '0');
final minutes = inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = inSeconds.remainder(60).toString().padLeft(2, '0');
return switch ((inDays, inHours, inMinutes, inSeconds)) {
(0, 0, 0, _) => '${inSeconds}s',
(0, 0, _, _) => '$inMinutes:$seconds',
(0, _, _, _) => '$inHours:$minutes:$seconds',
_ => '$inDays days, $hours:$minutes:$seconds',
};
}
} // To here.
この拡張方法では、switch 式とレコードのパターン マッチングを利用して、秒から日までのさまざまな期間を表示するための適切な方法を選択します。このスタイルのコードの詳細については、Codelab の Dart のパターンとレコードについてをご覧ください。
- この新機能を統合するには、
isolates.dart
ファイルを置き換えて、次のようにexploreCrosswordSolutions
関数の定義方法を再定義します。
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.characters.contains(target.character))
..shuffle());
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword);
yield crossword;
} else {
workQueue = workQueue.remove(location);
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint('${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}');
}
このコードを実行すると、アプリは表面的にはまったく同じように見えますが、異なるのは完成したクロスワード パズルを見つけるのにかかる時間です。こちらは 1 分 29 秒で生成された 80 x 44 のクロスワード パズルです。
当然、もっと速く進めることはできないか、というのは当然の問いです。そうだ、できる。
7. サーフェスの統計情報
ものを迅速に作成するためには、何が起こっているのかを確認することが大切です。これを支援する一つの方法は、進行中のプロセスに関する情報を明らかにすることです。次に、インストルメンテーションを追加して、その情報をホバーする情報パネルとして表示します。
表示する情報は、ワークキューから抽出して UI で表示する必要があります。
最初のステップは、表示したい情報を含む新しいモデルクラスを定義することです。
まず、次の手順に従います。
- 次のように
model.dart
ファイルを編集して、DisplayInfo
クラスを追加します。
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
import 'package:intl/intl.dart'; // Add this import
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
- ファイルの最後に、次の変更を加えて
DisplayInfo
クラスを追加します。
lib/model.dart
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
}
// Add from here.
/// Display information for the current state of the crossword solve.
abstract class DisplayInfo implements Built<DisplayInfo, DisplayInfoBuilder> {
static Serializer<DisplayInfo> get serializer => _$displayInfoSerializer;
/// The number of words in the grid.
String get wordsInGridCount;
/// The number of candidate words.
String get candidateWordsCount;
/// The number of locations to explore.
String get locationsToExploreCount;
/// The number of known bad locations.
String get knownBadLocationsCount;
/// The percentage of the grid filled.
String get gridFilledPercentage;
/// Construct a [DisplayInfo] instance from a [WorkQueue].
factory DisplayInfo.from({required WorkQueue workQueue}) {
final gridFilled = (workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height));
final fmt = NumberFormat.decimalPattern();
return DisplayInfo((b) => b
..wordsInGridCount = fmt.format(workQueue.crossword.words.length)
..candidateWordsCount = fmt.format(workQueue.candidateWords.length)
..locationsToExploreCount = fmt.format(workQueue.locationsToTry.length)
..knownBadLocationsCount = fmt.format(workQueue.badLocations.length)
..gridFilledPercentage = '${(gridFilled * 100).toStringAsFixed(2)}%');
}
/// An empty [DisplayInfo] instance.
static DisplayInfo get empty => DisplayInfo((b) => b
..wordsInGridCount = '0'
..candidateWordsCount = '0'
..locationsToExploreCount = '0'
..knownBadLocationsCount = '0'
..gridFilledPercentage = '0%');
factory DisplayInfo([void Function(DisplayInfoBuilder)? updates]) =
_$DisplayInfo;
DisplayInfo._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo, // Add this line.
])
final Serializers serializers = _$serializers;
- 次のように
isolates.dart
ファイルを変更して、WorkQueue
モデルを公開します。
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({ // Modify this line
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.characters.contains(target.character))
..shuffle());
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword); // Drop the yield crossword;
} else {
workQueue = workQueue.remove(location);
}
yield workQueue; // Add this line.
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint('${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}');
}
バックグラウンドの分離によって作業キューが明らかになった今、このデータソースから統計情報をどこで、どのように取得するかが問題になります。
- 古いクロスワード プロバイダを作業キュー プロバイダに置き換えてから、作業キュー プロバイダのストリームから情報を取得するプロバイダを追加します。
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_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,
);
}
新しいプロバイダは、情報表示をクロスワード グリッドの上にオーバーレイするかどうかという形式で、グローバルな状態と、クロスワード生成の実行時間などの派生データが混在しています。この状態の一部に対するリスナーは一時的なものであるため、これはすべて複雑になります。情報表示が非表示の場合は、クロスワード計算の開始時間と終了時間を何もリッスンしていませんが、情報表示が表示されているときに計算を正確に行うには、メモリに残しておく必要があります。この場合、Riverpod
属性の keepAlive
パラメータは非常に便利です。
情報ディスプレイを表示する際に、わずかにシワがある。現在の経過時間を表示する機能が必要ですが、現在の経過時間をコンスタントに更新することを簡単に強制できるものはありません。Codelab Flutter での次世代 UI の構築に再びアクセスすると、この要件を満たす便利なウィジェットがあります。
lib/widgets
ディレクトリにticker_builder.dart
ファイルを作成し、次の内容を追加します。
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);
}
このウィジェットは大ハンマーです。フレームごとにコンテンツが再構築されます。これは一般的に気が遠くなるかもしれませんが、クロスワード パズルを検索するという計算負荷に比べると、各フレームで経過時間を再描画する計算負荷はおそらくノイズの中に消えてしまうでしょう。この新たに取得した情報を活用するために、新しいウィジェットを作成します。
lib/widgets
ディレクトリにcrossword_info_widget.dart
ファイルを作成し、次の内容を追加します。
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),
),
],
),
);
}
このウィジェットは、Riverpod のプロバイダの能力を示す代表例です。このウィジェットは、5 つのプロバイダのいずれかが更新されると、再ビルド対象としてマークされます。このステップで最後に必要な変更は、この新しいウィジェットを UI に統合することです。
crossword_generator_app.dart
ファイルを次のように編集します。
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_info_widget.dart'; // Add this import
import 'crossword_widget.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer( // Modify from here
builder: (context, ref, child) {
return Stack(
children: [
Positioned.fill(
child: CrosswordWidget(),
),
if (ref.watch(showDisplayInfoProvider)) CrosswordInfoWidget(),
],
);
},
), // To here.
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
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),
),
);
}
ここでの 2 つの変更は、プロバイダを統合するための異なるアプローチを示しています。CrosswordGeneratorApp
の build
メソッドで、新しい Consumer
ビルダーを導入し、情報表示の表示 / 非表示時に強制的に再ビルドする領域を追加しました。一方、プルダウン メニュー全体は 1 つの ConsumerWidget
で、クロスワードのサイズ変更でも、情報表示の表示 / 非表示でも再構築されます。どちらのアプローチを採用するかは、常にシンプルさと、再構築されたウィジェット ツリーのレイアウトを再計算するコストとのエンジニアリング上のトレードオフとなります。
アプリを実行すると、クロスワード生成の進行状況をユーザーが詳しく把握できます。しかし、クロスワード生成の終わりに近づくと、数字が変化している期間がありますが、文字のグリッドはほとんど変化していません。
何が、なぜ起こっているのかについて、追加の分析情報が得られると便利です。
8. スレッドによる並列化
最終的に処理が遅くなる原因を理解するには、アルゴリズムの動作を可視化できると便利です。重要な要素は、WorkQueue
の未処理の locationsToTry
です。TableView を使用すると、この状況を簡単に調べることができます。セルの色は、locationsToTry
に含まれているかどうかに基づいて変更できます。
まず、次の手順に従います。
crossword_widget.dart
ファイルを次のように変更します。
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch( // Add from here
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
); // To here.
if (character != null) { // Modify from here
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text(character.character),
), // To here.
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
このコードを実行すると、アルゴリズムがまだ調査していない未解決の場所が可視化されます。
クロスワードが完成に向かって進む様子を見ると興味深い点は、調査すべきポイントの配列が残っていて、有益な結果につながらないことです。これには 2 つのオプションがあります。1 つ目は、クロスワード セルの一定の割合が入力されたら調査を制限する、2 つ目は、一度に複数のスポットを調査する方法です。2 つ目のパスの方が楽しく聞こえるので、そのようにしましょう。
isolates.dart
ファイルを編集します。これはコードのほぼ完全な書き直しで、1 つのバックグラウンド分離で計算されていたものを N 個のバックグラウンド分離のプールに分割しました。
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
required int maxWorkerCount,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
try {
workQueue = await compute(_generate, (workQueue, maxWorkerCount));
yield workQueue;
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint('Generated ${workQueue.crossword.width} x '
'${workQueue.crossword.height} crossword in '
'${DateTime.now().difference(start).formatted} '
'with $maxWorkerCount workers.');
}
Future<WorkQueue> _generate((WorkQueue, int) workMessage) async {
var (workQueue, maxWorkerCount) = workMessage;
final candidateGeneratorFutures = <Future<(Location, Direction, String?)>>[];
final locations = workQueue.locationsToTry.keys.toBuiltList().rebuild((b) => b
..shuffle()
..take(maxWorkerCount));
for (final location in locations) {
final direction = workQueue.locationsToTry[location]!;
candidateGeneratorFutures.add(compute(_generateCandidate,
(workQueue.crossword, workQueue.candidateWords, location, direction)));
}
try {
final results = await candidateGeneratorFutures.wait;
var crossword = workQueue.crossword;
for (final (location, direction, word) in results) {
if (word != null) {
final candidate = crossword.addWord(
location: location, word: word, direction: direction);
if (candidate != null) {
crossword = candidate;
}
} else {
workQueue = workQueue.remove(location);
}
}
workQueue = workQueue.updateFrom(crossword);
} catch (e) {
debugPrint('$e');
}
return workQueue;
}
(Location, Direction, String?) _generateCandidate(
(Crossword, BuiltSet<String>, Location, Direction) searchDetailMessage) {
final (crossword, candidateWords, location, direction) = searchDetailMessage;
final target = crossword.characters[location];
if (target == null) {
return (location, direction, candidateWords.randomElement());
}
// Filter down the candidate word list to those that contain the letter
// at the current location
final words = candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.characters.contains(target.character))
..shuffle());
int tryCount = 0;
final start = DateTime.now();
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return switch (direction) {
Direction.across => (location.leftOffset(index), direction, word),
Direction.down => (location.upOffset(index), direction, word),
};
}
final deltaTime = DateTime.now().difference(start);
if (tryCount >= 1000 || deltaTime > Duration(seconds: 10)) {
return (location, direction, null);
}
}
}
return (location, direction, null);
}
コアビジネス ロジックは変わっていないため、このコードのほとんどは見慣れているはずです。変更点は、compute
呼び出しのレイヤが 2 つになったことです。最初のレイヤは、個々のポジションを N 個のワーカー分離する場所まで取り出して検索し、N 個のワーカー分離したすべての分離が終了したときに結果を結合し直す役割を担います。2 番目のレイヤは、N 個のワーカー分離で構成されます。N をチューニングして最高のパフォーマンスを得るかどうかは、コンピュータと対象のデータの両方によって異なります。グリッドが大きいほど、より多くのワーカーが互いの邪魔をすることなく連携できます。
興味深い点は、キャプチャすべきでないものをキャプチャするクロージャの問題を、このコードがどのように処理するかという点です。現在、閉鎖はありません。_generate
関数と _generateWorker
関数はトップレベル関数として定義されており、キャプチャ元の周囲環境はありません。これら両方の関数に渡される引数と結果は、Dart レコードの形式になっています。これは、compute
呼び出しの 1 つの値と 1 つの値のセマンティクスを簡単に回避できる方法です。
グリッド内に連動してクロスワード パズルを形成する単語を検索するバックグラウンド ワーカーのプールを作成できるようになったので、今度はクロスワード生成ツールの他の部分にもこの機能を公開します。
- 次のように workQueue プロバイダを編集して、
providers.dart
ファイルを編集します。
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();
}
- 次のように、ファイルの末尾に
WorkerCount
プロバイダを追加します。
lib/providers.dart
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref.watch(workQueueProvider).when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
enum BackgroundWorkers { // Add from here
one(1),
two(2),
four(4),
eight(8),
sixteen(16),
thirtyTwo(32),
sixtyFour(64),
oneTwentyEight(128);
const BackgroundWorkers(this.count);
final int count;
String get label => count.toString();
}
/// A provider that holds the current number of background workers to use.
@Riverpod(keepAlive: true)
class WorkerCount extends _$WorkerCount {
var _count = BackgroundWorkers.four;
@override
BackgroundWorkers build() => _count;
void setCount(BackgroundWorkers count) {
_count = count;
ref.invalidateSelf();
}
} // To here.
これら 2 つの変更により、プロバイダ レイヤは、分離関数が正しく構成されるようにバックグラウンド分離プールの最大ワーカー数を設定する方法を公開します。
CrosswordInfoWidget
を次のように変更して、crossword_info_widget.dart
ファイルを更新します。
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),
],
),
),
),
),
),
),
);
}
}
- 次のセクションを
_CrosswordGeneratorMenu
ウィジェットに追加して、crossword_generator_app.dart
ファイルを変更します。
lib/widgets/crossword_generator_app.dart
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton(
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () =>
ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
),
for (final count in BackgroundWorkers.values) // Add from here
MenuItemButton(
leadingIcon: count == ref.watch(workerCountProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
onPressed: () =>
ref.read(workerCountProvider.notifier).setCount(count),
child: Text(count.label), // To here.
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
アプリを実行すると、インスタンス化された背景分離の数を変更して、クロスワードに挿入する単語を検索できます。
- 歯車アイコンをクリックするとコンテキスト メニューが開き、クロスワードのサイズ設定、現在生成されているクロスワードの統計情報を表示するかどうか、さらに使用するアイソレーションの数が表示されます。
クロスワード ジェネレータを実行すると、複数のコアを同時に使用することで 80x44 のクロスワードの計算時間が大幅に短縮されました。
9. ゲームに変える
この最後のセクションはボーナスラウンドです。クロスワード ジェネレータを作成する際に学んだすべてのテクニックを駆使し、それらのテクニックを使ってゲームを制作します。クロスワード ジェネレータを使用して、クロスワード パズルを作成します。コンテキスト メニューのイディオムを再利用して、グリッド内のさまざまな単語の形の穴にユーザーが単語を選択または選択解除できるようにします。すべてはクロスワードを完成させることが目的です。
このゲームが洗練されている、あるいは完成したとは言えません。実際はそうとは言えません。バランスや難易度の問題は、代わりの言葉の選択を改善することで解決できます。ユーザーを導くチュートリアルはなく、思考アニメーションには望まれないことがたくさん残っています。もう勝ちました!表示されます。
この場合のトレードオフは、このプロトゲームを適切に改良して本格的なゲームにするには、大量のコードが必要になることです。1 つの Codelab では必要のないコードが多いそこで、スピードランのステップとして、この Codelab でこれまでに学んだテクニックを強化するために、テクニックを使用する場所と方法を変更します。この Codelab で先に学んだ教訓を後押しできれば幸いです。または、このコードに基づいて独自のエクスペリエンスを構築することもできます。皆様が構築されるアプリを楽しみにしております。
まず、次の手順に従います。
lib/widgets
ディレクトリ内のすべての内容を削除します。皆さんのゲーム用に新しいウィジェットが作れます。これはたまたま、古いウィジェットから多くを借用しているだけです。model.dart
ファイルを編集して、Crossword
のaddWord
メソッドを次のように更新します。
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;
}
}
クロスワード モデルを少し変更するだけで、重複しない単語を追加できます。プレーヤーがボードのどこでもプレイできるようにしながら、プレーヤーの動きを保存するためのベースモデルとして Crossword
を使用できると便利です。これは、特定の位置にある、特定の方向に配置された単語のリストです。
model.dart
ファイルの末尾にCrosswordPuzzleGame
モデルクラスを追加します。
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;
providers.dart
ファイルの更新は、興味深い変更の集まりです。これまで統計情報の収集をサポートしていたほとんどのプロバイダーは廃止されています。バックグラウンド分離物の数を変更する機能が削除され、定数に置き換えられました。また、先ほど追加した新しい CrosswordPuzzleGame
モデルにアクセスできる新しいプロバイダもあります。
lib/providers.dart
import 'dart:convert';
// Drop the dart:math import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_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);
Puzzle
プロバイダの最も興味深い部分は、Crossword
と wordList
から CrosswordPuzzleGame
を作成するコストと、単語を選択するためのコストを詳しく説明するための戦略です。どちらの操作も、バックグラウンド分離を使わずに実行すると、UI 操作が遅くなります。最終結果をバックグラウンドで計算しながら、手作業で中間結果を押し出すことで、必要な計算をバックグラウンドで実行しながら、レスポンシブ UI を作成できます。
- 空になった
lib/widgets
ディレクトリに、次の内容のcrossword_puzzle_app.dart
ファイルを作成します。
lib/widgets/crossword_puzzle_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_generator_widget.dart';
import 'crossword_puzzle_widget.dart';
import 'puzzle_completed_widget.dart';
class CrosswordPuzzleApp extends StatelessWidget {
const CrosswordPuzzleApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordPuzzleAppMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Puzzle'),
),
body: SafeArea(
child: Consumer(builder: (context, ref, _) {
final workQueueAsync = ref.watch(workQueueProvider);
final puzzleSolved =
ref.watch(puzzleProvider.select((puzzle) => puzzle.solved));
return workQueueAsync.when(
data: (workQueue) {
if (puzzleSolved) {
return PuzzleCompletedWidget();
}
if (workQueue.isCompleted &&
workQueue.crossword.characters.isNotEmpty) {
return CrosswordPuzzleWidget();
}
return CrosswordGeneratorWidget();
},
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(child: Text('$error')),
);
}),
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordPuzzleAppMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
このファイルのほとんどは、すでに知っているはずです。はい。未定義のウィジェットがあります。これから修正を開始します。
crossword_generator_widget.dart
ファイルを作成し、次の内容を追加します。
lib/widgets/crossword_generator_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordGeneratorWidget extends ConsumerWidget {
const CrosswordGeneratorWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
);
if (character != null) {
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text('•'), // https://www.compart.com/en/unicode/U+2022
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
これについてもある程度知っているはずです。主な違いは、生成される単語の文字を表示する代わりに、不明な文字が存在することを示す Unicode 文字を表示するようになったことです。デザインを改善するため、なんらかの工夫が必要になる可能性があります。
crossword_puzzle_widget.dart
ファイルを作成し、次の内容を追加します。
lib/widgets/crossword_puzzle_widget.dart
import 'package:built_collection/built_collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordPuzzleWidget extends ConsumerWidget {
const CrosswordPuzzleWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(puzzleProvider
.select((puzzle) => puzzle.crossword.characters[location]));
final selectedCharacter = ref.watch(puzzleProvider.select((puzzle) =>
puzzle.crosswordFromSelectedWords.characters[location]));
final alternateWords = ref
.watch(puzzleProvider.select((puzzle) => puzzle.alternateWords));
if (character != null) {
final acrossWord = character.acrossWord;
var acrossWords = BuiltList<String>();
if (acrossWord != null) {
acrossWords = acrossWords.rebuild((b) => b
..add(acrossWord.word)
..addAll(alternateWords[acrossWord.location]
?[acrossWord.direction] ??
[])
..sort());
}
final downWord = character.downWord;
var downWords = BuiltList<String>();
if (downWord != null) {
downWords = downWords.rebuild((b) => b
..add(downWord.word)
..addAll(alternateWords[downWord.location]
?[downWord.direction] ??
[])
..sort());
}
return MenuAnchor(
builder: (context, controller, _) {
return GestureDetector(
onTapDown: (details) =>
controller.open(position: details.localPosition),
child: AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
child: Text(selectedCharacter?.character ?? ''),
),
),
),
);
},
menuChildren: [
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Across'),
),
for (final word in acrossWords)
_WordSelectMenuItem(
location: acrossWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.across,
),
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Down'),
),
for (final word in downWords)
_WordSelectMenuItem(
location: downWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.down,
),
],
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
class _WordSelectMenuItem extends ConsumerWidget {
const _WordSelectMenuItem({
required this.location,
required this.word,
required this.selectedCharacter,
required this.direction,
});
final Location location;
final String word;
final CrosswordCharacter? selectedCharacter;
final Direction direction;
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(puzzleProvider.notifier);
return MenuItemButton(
onPressed: ref.watch(puzzleProvider.select((puzzle) =>
puzzle.canSelectWord(
location: location, word: word, direction: direction)))
? () => notifier.selectWord(
location: location, word: word, direction: direction)
: null,
leadingIcon: switch (direction) {
Direction.across => selectedCharacter?.acrossWord?.word == word,
Direction.down => selectedCharacter?.downWord?.word == word,
}
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(word),
);
}
}
このウィジェットは前のものよりも少し複雑ですが、過去に他の場所で使用されていた部品から作られています。これで、データが入力された各セルをクリックすると、ユーザーが選択できる単語を一覧表示するコンテキスト メニューが生成されます。単語が選択されている場合、競合する単語は選択できません。単語の選択を解除するには、その単語のメニュー項目をタップします。
プレーヤーが単語を選択してクロスワード全体を埋められると仮定すると、「You't win!」というトークンが必要です。表示されます。
puzzle_completed_widget.dart
ファイルを作成し、次の内容を追加します。
lib/widgets/puzzle_completed_widget.dart
import 'package:flutter/material.dart';
class PuzzleCompletedWidget extends StatelessWidget {
const PuzzleCompletedWidget({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Text(
'Puzzle Completed!',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
),
),
);
}
}
もっと面白くなってみてはいかがでしょうか。アニメーション ツールの詳細については、Flutter で次世代 UI を作成するの Codelab をご覧ください。
lib/main.dart
ファイルを次のように編集します。
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
),
),
);
}
このアプリを実行すると、クロスワード生成ツールによってパズルが生成され、アニメーションが表示されます。その後、空白のパズルが表示されます。解決すると、次のような画面が表示されます。
10.完了
これで、Flutter でパズルゲームを作成することができました。
クロスワード ジェネレータを作成して、パズルゲームになりました。分離のプールでのバックグラウンド計算の実行を習得しました。また、不変データ構造を使用して、バックトラッキング アルゴリズムを簡単に実装しました。また、TableView
で有意義な時間を過ごしました。これは、次に表形式のデータを表示する必要があるときに便利です。