1. לפני שמתחילים
תארו לעצמכם שואלים אתכם אם אפשר ליצור את תשבץ התשובות הגדול בעולם. אתם נזכרים בטכניקות AI שלמדתם בבית הספר, ורוצים לדעת אם אפשר להשתמש ב-Flutter כדי לבחון את האפשרויות האלגוריתמיות ליצירת פתרונות לבעיות שדורשות הרבה חישובים.
ב-Codelab הזה תלמדו איך לעשות את זה. בסוף התהליך, תבנו כלי שיאפשר לכם לשחק עם אלגוריתמים ליצירת תשבצים. יש הרבה הגדרות שונות למה נחשב תשבץ תקף, והטכניקות האלה עוזרות לכם ליצור תשבצים שתואמים להגדרה שלכם.
על בסיס הכלי הזה, יוצרים תשבץ מילים באמצעות מחולל התשבצים, כדי שהמשתמש יוכל לפתור אותו. אפשר להשתמש בחידה הזו ב-Android, ב-iOS, ב-Windows, ב-macOS וב-Linux. כך זה נראה ב-Android:
דרישות מוקדמות
- השלמת ה-Codelab Your first Flutter app
מה לומדים
- איך משתמשים ב-isolates כדי לבצע עבודה שדורשת הרבה משאבי מחשוב בלי להפריע ללולאת הרינדור של Flutter, באמצעות שילוב של הפונקציה
compute
של Flutter ויכולות השמירה במטמון של ערכים של מסנן הבנייה מחדשselect
של Riverpod. - איך אפשר להשתמש במבני נתונים שלא ניתן לשנות עם
built_value
ועםbuilt_collection
כדי להטמיע טכניקות של 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.
- פותחים את לוח הפקודות (Ctrl+Shift+P ב-Windows/Linux, Cmd+Shift+P ב-macOS), מקלידים flutter new ואז בוחרים באפשרות Flutter: New Project בתפריט.
- בוחרים באפשרות Empty application (אפליקציה ריקה) ואז בוחרים את הספרייה שבה רוצים ליצור את הפרויקט. צריך לבחור ספרייה שלא דורשת הרשאות גבוהות או שלא מכילה רווח בנתיב שלה. לדוגמה, ספריית הבית או
C:\src\
.
- נותנים שם לפרויקט
generate_crossword
. בהמשך ה-codelab הזה נניח ששם האפליקציה הואgenerate_crossword
.
מערכת Flutter יוצרת את תיקיית הפרויקט ופותחת אותה ב-VS Code. עכשיו תהיה החלפה של התוכן של שני קבצים עם פיגום בסיסי של האפליקציה.
העתקה והדבקה של האפליקציה הראשונית
- בחלונית הימנית של 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.9.0
dependencies:
flutter:
sdk: flutter
built_collection: ^5.1.1
built_value: ^8.10.1
characters: ^1.4.0
flutter_riverpod: ^2.6.1
intl: ^0.20.2
riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
two_dimensional_scrollables: ^0.3.7
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
build_runner: ^2.5.4
built_value_generator: ^8.10.1
custom_lint: ^0.7.6
riverpod_generator: ^2.6.5
riverpod_lint: ^2.6.5
flutter:
uses-material-design: true
קובץ pubspec.yaml
מציין מידע בסיסי על האפליקציה, כמו הגרסה הנוכחית שלה והתלות שלה. מוצגת לכם קבוצה של תלויות שלא נכללות באפליקציית Flutter ריקה רגילה. בשלבים הבאים תהיה לכם אפשרות להשתמש בכל החבילות האלה.
הסבר על יחסי התלות
לפני שמתחילים לכתוב קוד, חשוב להבין למה נבחרו החבילות הספציפיות האלה:
- built_value: יוצר אובייקטים שלא ניתן לשנות אותם, שמשתפים זיכרון בצורה יעילה. זה חשוב לאלגוריתם של ה-backtracking.
- Riverpod: מספק ניהול מצב ברמת פירוט גבוהה עם
select()
כדי למזער את מספר הבנייה מחדש - two_dimensional_scrollables: מטפל ברשתות גדולות בלי לפגוע בביצועים
- פותחים את הקובץ
main.dart
בספרייהlib/
.
- מחליפים את התוכן של הקובץ בתוכן הבא:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: Scaffold(
body: Center(
child: Text('Hello, World!', style: TextStyle(fontSize: 24)),
),
),
),
),
);
}
- מריצים את הקוד הזה כדי לוודא שהכול עובד. בכל מקום, צריך להציג חלון חדש עם משפט הפתיחה הנדרש של כל פרויקט חדש. מוצג
ProviderScope
שמציין שהאפליקציה הזו תשתמש ב-riverpod
לניהול מצב.
נקודת ביקורת: הפעלת אפליקציה בסיסית
בשלב הזה אמור להופיע החלון 'Hello, World!'. אם לא:
- בודקים שהתקנת Flutter בוצעה בצורה תקינה
- אימות הפעלת האפליקציה באמצעות
flutter run
- מוודאים שאין שגיאות הידור במסוף
3. הוספת מילים
אבני בניין לתשבץ
תשבץ הוא בעצם רשימה של מילים. המילים מסודרות ברשת, חלקן לרוחב וחלקן לאורך, כך שהן משתלבות זו בזו. פתרון של מילה אחת נותן רמזים לגבי המילים שחוצות את המילה הראשונה. לכן, אבן הבניין הראשונה והטובה ביותר היא רשימת מילים.
מקור טוב למילים האלה הוא הדף Natural Language Corpus Data של פיטר נורוויג. רשימת SOWPODS היא נקודת התחלה טובה, עם 267,750 מילים.
בשלב הזה, מורידים רשימת מילים, מוסיפים אותה כנכס לאפליקציית Flutter ומגדירים ספק Riverpod לטעינת הרשימה באפליקציה בזמן ההפעלה.
כדי להתחיל, בצע את הצעדים הבאים:
- משנים את קובץ
pubspec.yaml
של הפרויקט כדי להוסיף את הצהרת הנכס הבאה לרשימת המילים שבחרתם. בדף האפליקציה הזה מוצג רק קטע ה-Flutter של הגדרות האפליקציה, כי שאר ההגדרות נשארו ללא שינוי.
pubspec.yaml
flutter:
uses-material-design: true
assets: # Add this line
- assets/words.txt # And this one.
סביר להניח שעורך הטקסט ידגיש את השורה האחרונה הזו באזהרה, כי עדיין לא יצרתם את הקובץ הזה.
- באמצעות הדפדפן והעורך, יוצרים ספרייה בשם
assets
ברמה העליונה של הפרויקט ויוצרים בה קובץ בשםwords.txt
עם אחת מרשימות המילים שצוינו קודם.
הקוד הזה תוכנן עם רשימת ה-SOWPODS שהוזכרה קודם, אבל הוא אמור לפעול עם כל רשימת מילים שמורכבת רק מתווים A-Z. הרחבת בסיס הקוד הזה כדי שיפעל עם ערכות תווים שונות היא תרגיל לקורא.
טעינת המילים
כדי לכתוב את הקוד שאחראי לטעינת רשימת המילים בהפעלת האפליקציה, פועלים לפי השלבים הבאים:
- יוצרים קובץ
providers.dart
בספרייהlib
. - מוסיפים לקובץ את השורות הבאות:
lib/providers.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
זהו ספק Riverpod הראשון שלכם בבסיס הקוד הזה.
איך הספק הזה עובד:
- טעינת רשימת המילים מנכסים באופן אסינכרוני
- מסננים מילים כך שיכללו רק תווים a-z באורך של יותר מ-2 אותיות
- מחזירה
BuiltSet
שלא ניתן לשינוי לגישה אקראית יעילה
הפרויקט הזה משתמש ביצירת קוד למספר יחסי תלות, כולל Riverpod.
- כדי להתחיל ליצור קוד, מריצים את הפקודה הבאה:
$ dart run build_runner watch -d [INFO] Generating build script completed, took 174ms [INFO] Setting up file watchers completed, took 5ms [INFO] Waiting for all file watchers to be ready completed, took 202ms [INFO] Reading cached asset graph completed, took 65ms [INFO] Checking for updates since last build completed, took 680ms [INFO] Running build completed, took 2.3s [INFO] Caching finalized dependency graph completed, took 42ms [INFO] Succeeded after 2.3s with 122 outputs (243 actions)
הוא ימשיך לפעול ברקע ויעדכן את הקבצים שנוצרו כשמבצעים שינויים בפרויקט. אחרי שהפקודה הזו יוצרת את הקוד ב-providers.g.dart
, העורך אמור להיות מרוצה מהקוד שהוספתם ל-providers.dart
.
ב-Riverpod, ספקים כמו הפונקציה wordList
שהגדרתם קודם בדרך כלל מופעלים באופן עצלני. עם זאת, לצורך האפליקציה הזו, צריך לטעון את רשימת המילים באופן מיידי. במסמכי התיעוד של Riverpod מוצעת הגישה הבאה לטיפול בספקי מידע שצריך לטעון באופן מיידי. עכשיו תטמיעו את זה.
- יוצרים קובץ
crossword_generator_app.dart
בספרייהlib/widgets
. - מוסיפים לקובץ את השורות הבאות:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer(
builder: (context, ref, _) {
final wordListAsync = ref.watch(wordListProvider);
return wordListAsync.when(
data: (wordList) => ListView.builder(
itemCount: wordList.length,
itemBuilder: (context, index) {
return ListTile(title: Text(wordList.elementAt(index)));
},
),
error: (error, stackTrace) => Center(child: Text('$error')),
loading: () => Center(child: CircularProgressIndicator()),
);
},
),
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
הקובץ הזה מעניין משני כיוונים נפרדים. הראשון הוא הווידג'ט _EagerInitialization
, שהמטרה היחידה שלו היא לדרוש מספק wordList
שיצרתם קודם לטעון את רשימת המילים. הווידג'ט הזה משיג את המטרה הזו באמצעות האזנה לספק באמצעות קריאת הפונקציה ref.watch()
. אפשר לקרוא מידע נוסף על הטכניקה הזו במסמכי התיעוד של Riverpod בנושא הפעלה אוטומטית של ספקי נתונים.
נקודה מעניינת נוספת שכדאי לשים לב אליה בקובץ הזה היא איך Riverpod מטפל בתוכן אסינכרוני. כפי שאתם בוודאי זוכרים, ספק wordList
מוגדר כפונקציה אסינכרונית, כי טעינת תוכן מהדיסק היא איטית. כשצופים בספק רשימת המילים בקוד הזה, מקבלים AsyncValue<BuiltSet<String>>
. החלק AsyncValue
בסוג הזה הוא מתאם בין העולם האסינכרוני של הספקים לבין העולם הסינכרוני של השיטה build
של הווידג'ט.
השיטה AsyncValue
של when
מטפלת בשלושת המצבים הפוטנציאליים שבהם יכול להיות הערך העתידי. יכול להיות שהבעיה תיפתר בהמשך, ובמקרה כזה יופעל הקריאה החוזרת data
. יכול להיות שהבעיה תהיה במצב שגיאה, ובמקרה כזה יופעל הקריאה החוזרת error
. יכול להיות גם שהבעיה עדיין בטעינה. לשלוש פונקציות ההתקשרות חזרה צריכים להיות סוגים תואמים של ערכים מוחזרים, כי הערך המוחזר של פונקציית ההתקשרות חזרה שנקראת מוחזר על ידי השיטה when
. בדוגמה הזו, התוצאה של השיטה when מוצגת כ-body
בווידג'ט Scaffold
.
יצירת אפליקציה עם רשימה כמעט אינסופית
כדי לשלב את הווידג'ט CrosswordGeneratorApp
באפליקציה, פועלים לפי השלבים הבאים:
- מעדכנים את הקובץ
lib/main.dart
ומוסיפים את הקוד הבא:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_generator_app.dart'; // Add this import
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordGeneratorApp(), // Remove what was here and replace
),
),
);
}
- מפעילים מחדש את האפליקציה. אמורה להופיע רשימה נגללת של כל 267,750 המילים במילון.
מה תפַתחו בשלב הבא
עכשיו תיצרו את מבני הנתונים העיקריים של תשבץ התשובות באמצעות אובייקטים שלא ניתן לשנות. הבסיס הזה יאפשר שימוש באלגוריתמים יעילים ועדכונים חלקים בממשק המשתמש.
4. הצגת המילים ברשת
בשלב הזה תיצרו מבנה נתונים ליצירת תשבץ באמצעות חבילות built_value
ו-built_collection
. שני החבילות האלה מאפשרות ליצור מבני נתונים כערכים שלא ניתן לשנות, וזה שימושי גם להעברת נתונים בין Isolates וגם להטמעה קלה יותר של חיפוש לעומק ושל חזרה אחורה.
כדי להתחיל, בצע את הצעדים הבאים:
- יוצרים קובץ
model.dart
בספרייהlib
ומוסיפים לקובץ את התוכן הבא:
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
static Serializer<Location> get serializer => _$locationSerializer;
/// The horizontal part of the location. The location is 0 based.
int get x;
/// The vertical part of the location. The location is 0 based.
int get y;
/// Returns a new location that is one step to the left of this location.
Location get left => rebuild((b) => b.x = x - 1);
/// Returns a new location that is one step to the right of this location.
Location get right => rebuild((b) => b.x = x + 1);
/// Returns a new location that is one step up from this location.
Location get up => rebuild((b) => b.y = y - 1);
/// Returns a new location that is one step down from this location.
Location get down => rebuild((b) => b.y = y + 1);
/// Returns a new location that is [offset] steps to the left of this location.
Location leftOffset(int offset) => rebuild((b) => b.x = x - offset);
/// Returns a new location that is [offset] steps to the right of this location.
Location rightOffset(int offset) => rebuild((b) => b.x = x + offset);
/// Returns a new location that is [offset] steps up from this location.
Location upOffset(int offset) => rebuild((b) => b.y = y - offset);
/// Returns a new location that is [offset] steps down from this location.
Location downOffset(int offset) => rebuild((b) => b.y = y + offset);
/// Pretty print a location as a (x,y) coordinate.
String prettyPrint() => '($x,$y)';
/// Returns a new location built from [updates]. Both [x] and [y] are
/// required to be non-null.
factory Location([void Function(LocationBuilder)? updates]) = _$Location;
Location._();
/// Returns a location at the given coordinates.
factory Location.at(int x, int y) {
return Location((b) {
b
..x = x
..y = y;
});
}
}
/// The direction of a word in a crossword.
enum Direction {
across,
down;
@override
String toString() => name;
}
/// A word in a crossword. This is a word at a location in a crossword, in either
/// the across or down direction.
abstract class CrosswordWord
implements Built<CrosswordWord, CrosswordWordBuilder> {
static Serializer<CrosswordWord> get serializer => _$crosswordWordSerializer;
/// The word itself.
String get word;
/// The location of this word in the crossword.
Location get location;
/// The direction of this word in the crossword.
Direction get direction;
/// Compare two CrosswordWord by coordinates, x then y.
static int locationComparator(CrosswordWord a, CrosswordWord b) {
final compareRows = a.location.y.compareTo(b.location.y);
final compareColumns = a.location.x.compareTo(b.location.x);
return switch (compareColumns) {
0 => compareRows,
_ => compareColumns,
};
}
/// Constructor for [CrosswordWord].
factory CrosswordWord.word({
required String word,
required Location location,
required Direction direction,
}) {
return CrosswordWord(
(b) => b
..word = word
..direction = direction
..location.replace(location),
);
}
/// Constructor for [CrosswordWord].
/// Use [CrosswordWord.word] instead.
factory CrosswordWord([void Function(CrosswordWordBuilder)? updates]) =
_$CrosswordWord;
CrosswordWord._();
}
/// A character in a crossword. This is a single character at a location in a
/// crossword. It may be part of an across word, a down word, both, but not
/// neither. The neither constraint is enforced elsewhere.
abstract class CrosswordCharacter
implements Built<CrosswordCharacter, CrosswordCharacterBuilder> {
static Serializer<CrosswordCharacter> get serializer =>
_$crosswordCharacterSerializer;
/// The character at this location.
String get character;
/// The across word that this character is a part of.
CrosswordWord? get acrossWord;
/// The down word that this character is a part of.
CrosswordWord? get downWord;
/// Constructor for [CrosswordCharacter].
/// [acrossWord] and [downWord] are optional.
factory CrosswordCharacter.character({
required String character,
CrosswordWord? acrossWord,
CrosswordWord? downWord,
}) {
return CrosswordCharacter((b) {
b.character = character;
if (acrossWord != null) {
b.acrossWord.replace(acrossWord);
}
if (downWord != null) {
b.downWord.replace(downWord);
}
});
}
/// Constructor for [CrosswordCharacter].
/// Use [CrosswordCharacter.character] instead.
factory CrosswordCharacter([
void Function(CrosswordCharacterBuilder)? updates,
]) = _$CrosswordCharacter;
CrosswordCharacter._();
}
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Add a word to the crossword at the given location and direction.
Crossword addWord({
required Location location,
required String word,
required Direction direction,
}) {
return rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width,
(_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([Location, Crossword, CrosswordWord, CrosswordCharacter])
final Serializers serializers = _$serializers;
בקובץ הזה מתואר תחילת מבנה הנתונים שבו תשתמשו ליצירת תשבצים. בבסיסו, תשבץ הוא רשימה של מילים אופקיות ואנכיות שמשולבות ברשת. כדי להשתמש במבנה הנתונים הזה, צריך ליצור Crossword
בגודל המתאים באמצעות בנאי בשם Crossword.crossword
, ואז להוסיף מילים באמצעות השיטה addWord
. כחלק מהבנייה של הערך הסופי, נוצרת רשת של CrosswordCharacter
s באמצעות השיטה _fillCharacters
.
כדי להשתמש במבנה הנתונים הזה, פועלים לפי השלבים הבאים:
- יוצרים קובץ
utils
בספרייהlib
ומוסיפים לקובץ את התוכן הבא:
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
זוהי הרחבה של BuiltSet
שמאפשרת לשלוף בקלות רכיב אקראי מתוך הקבוצה. שימוש בשיטות הרחבה הוא דרך טובה להרחיב מחלקות עם פונקציונליות נוספת. צריך לתת שם לתוסף כדי שהוא יהיה זמין מחוץ לקובץ utils.dart
.
- בקובץ
lib/providers.dart
, מוסיפים את שורות הייבוא הבאות:
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart'; // Add this import
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model; // And this import
import 'utils.dart'; // And this one
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
פעולות הייבוא האלה חושפות את המודל שהוגדר קודם לספקים שאתם עומדים ליצור. הייבוא של dart:math
כלול ב-Random
, הייבוא של flutter/foundation.dart
כלול ב-debugPrint
, model.dart
כלול במודל ו-utils.dart
כלול בתוסף BuiltSet
.
- בסוף אותו קובץ, מוסיפים את הספקים הבאים:
lib/providers.dart
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool()
? model.Direction.across
: model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width),
_random.nextInt(size.height),
);
crossword = crossword.addWord(
word: word,
direction: direction,
location: location,
);
yield crossword;
await Future.delayed(Duration(milliseconds: 100));
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
השינויים האלה מוסיפים לאפליקציה שני ספקים. הראשון הוא Size
, שהוא למעשה משתנה גלובלי שמכיל את הערך שנבחר של ספירת CrosswordSize
. כך ממשק המשתמש יוכל להציג את גודל התשבץ שנמצא בתהליך בנייה וגם להגדיר אותו. הספק השני, crossword
, הוא יצירה מעניינת יותר. זו פונקציה שמחזירה סדרה של ערכים מסוג Crossword
. היא נוצרת באמצעות התמיכה של Dart בגנרטורים, כפי שמסומן על ידי async*
בפונקציה. המשמעות היא שבמקום להסתיים בהחזרה, היא מחזירה סדרה של Crossword
s, שזו דרך הרבה יותר קלה לכתוב חישוב שמחזיר תוצאות ביניים.
בגלל נוכחות של זוג קריאות ref.watch
בתחילת הפונקציה של ספק crossword
, המערכת של Riverpod תפעיל מחדש את הזרם של Crossword
בכל פעם שהגודל שנבחר של התשבץ ישתנה וכשרשימת המילים תסיים להיטען.
עכשיו יש לכם קוד ליצירת תשבצים, גם אם הם מלאים במילים אקראיות, ויהיה נחמד להציג אותם למשתמש בכלי.
- יוצרים קובץ
crossword_widget.dart
בספרייהlib/widgets
עם התוכן הבא:
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
crosswordProvider.select(
(crosswordAsync) => crosswordAsync.when(
data: (crossword) => crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
if (character != null) {
return Container(
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: Text(
character.character,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
הווידג'ט הזה, שהוא ConsumerWidget
, יכול להסתמך ישירות על ספק Size
כדי לקבוע את גודל הרשת שבה יוצגו התווים של Crossword
. התצוגה של הרשת הזו מתבצעת באמצעות הווידג'ט TableView
מהחבילה two_dimensional_scrollables
.
חשוב לציין שכל תא שמוצג על ידי פונקציות העזר _buildCell
מכיל בעץ Widget
המוחזר שלו ווידג'ט Consumer
. הוא משמש כגבול לרענון. כל מה שבתוך הווידג'ט Consumer
נוצר מחדש כשערך ההחזרה של ref.watch
משתנה. יש פיתוי ליצור מחדש את כל העץ בכל פעם שמתבצע שינוי ב-Crossword
, אבל זה גורם להרבה חישובים שאפשר לדלג עליהם באמצעות ההגדרה הזו.
אם תסתכלו על הפרמטר של ref.watch
, תראו שיש עוד שכבה של הימנעות מחישוב מחדש של פריסות, באמצעות crosswordProvider.select
. כלומר, הפונקציה ref.watch
תפעיל בנייה מחדש של התוכן של TableViewCell
רק כשהתו שהתא אחראי לעיבוד שלו משתנה. הפחתת העיבוד מחדש היא חלק חשוב בשמירה על היענות ממשק המשתמש.
כדי לחשוף את ספק CrosswordWidget
וספק Size
למשתמש, משנים את קובץ crossword_generator_app.dart
באופן הבא:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_widget.dart'; // Add this import
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()], // Add this line
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(child: CrosswordWidget()), // Replace what was here before
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget { // Add from here
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
); // To here.
}
יש כמה שינויים. קודם כול, הקוד שאחראי על עיבוד wordList
כ-ListView
הוחלף בקריאה ל-CrosswordWidget
שהוגדר בקובץ lib/widgets/crossword_widget.dart
. שינוי משמעותי נוסף הוא הוספה של תפריט לשינוי ההתנהגות של האפליקציה, שמתחיל בשינוי הגודל של התשבץ. עוד MenuItemButton
s יתווספו בשלבים הבאים. מריצים את האפליקציה ורואים משהו כזה:
מוצגים תווים בטבלה ותפריט שמאפשר למשתמש לשנות את גודל הטבלה. אבל המילים לא מסודרות כמו בתשבץ. הסיבה לכך היא שלא הוגדרו אילוצים לגבי אופן הוספת המילים לתשבץ. בקיצור, זה בלגן. בשלב הבא תתחילו להשתלט על המצב.
5. אכיפת מגבלות
מה משתנה ולמה
כרגע, התשבץ מאפשר מילים חופפות ללא אימות. תוסיפו בדיקת אילוצים כדי לוודא שהמילים משתלבות כמו בתשבץ אמיתי.
המטרה של השלב הזה היא להוסיף קוד למודל כדי לאכוף את האילוצים של תשבץ. יש הרבה סוגים שונים של תשבצים, והסגנון שבו נשתמש בסדנת הקוד הזו הוא סגנון מסורתי של תשבצים באנגלית. כמו תמיד, השארנו לקוראים את האפשרות לשנות את הקוד הזה כדי ליצור סגנונות אחרים של תשבצים.
כדי להתחיל, בצע את הצעדים הבאים:
- פותחים את הקובץ
model.dart
ומחליפים רק את המודלCrossword
במודל הבא:
lib/model.dart
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword,
/// or checking the proposed solution.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Checks if this crossword is valid.
bool get valid {
// Check that there are no duplicate words.
final wordSet = words.map((word) => word.word).toBuiltSet();
if (wordSet.length != words.length) {
return false;
}
for (final MapEntry(key: location, value: character)
in characters.entries) {
// All characters must be a part of an across or down word.
if (character.acrossWord == null && character.downWord == null) {
return false;
}
// All characters must be within the crossword puzzle.
// No drawing outside the lines.
if (location.x < 0 ||
location.y < 0 ||
location.x >= width ||
location.y >= height) {
return false;
}
// Characters above and below this character must be related
// by a vertical word
if (characters[location.up] case final up?) {
if (character.downWord == null) {
return false;
}
if (up.downWord != character.downWord) {
return false;
}
}
if (characters[location.down] case final down?) {
if (character.downWord == null) {
return false;
}
if (down.downWord != character.downWord) {
return false;
}
}
// Characters to the left and right of this character must be
// related by a horizontal word
final left = characters[location.left];
if (left != null) {
if (character.acrossWord == null) {
return false;
}
if (left.acrossWord != character.acrossWord) {
return false;
}
}
final right = characters[location.right];
if (right != null) {
if (character.acrossWord == null) {
return false;
}
if (right.acrossWord != character.acrossWord) {
return false;
}
}
}
return true;
}
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
if (words.isNotEmpty && !overlap) {
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width,
(_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
תזכורת קצרה: כדי לעדכן את הקבצים model.g.dart
ו-providers.g.dart
, צריך להפעיל את build_runner
כדי שהשינויים שאתם מבצעים בקבצים model.dart
ו-providers.dart
יתעדכנו. אם הקבצים האלה לא התעדכנו באופן אוטומטי, עכשיו זה זמן טוב להתחיל את build_runner
מחדש עם dart run build_runner watch -d
.
כדי ליהנות מהיכולת החדשה הזו בשכבת המודל, צריך לעדכן את שכבת הספק בהתאם.
- עורכים את קובץ
providers.dart
באופן הבא:
lib/providers.dart
import 'dart:convert';
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model;
import 'utils.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
var candidate = crossword.addWord( // Edit from here
word: word, direction: direction, location: location);
await Future.delayed(Duration(milliseconds: 10));
if (candidate != null) {
debugPrint('Added word: $word');
crossword = candidate;
yield crossword;
} else {
debugPrint('Failed to add word: $word');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
- מריצים את האפליקציה. לא קורה הרבה בממשק המשתמש, אבל קורים הרבה דברים אם מסתכלים על היומנים.
אם חושבים על מה שקורה כאן, רואים שתשבץ מופיע באקראי. השיטה addWord
במודל Crossword
דוחה כל מילה מוצעת שלא מתאימה לתשבץ הנוכחי, ולכן מדהים שאנחנו רואים משהו בכלל.
למה כדאי לעבור לעיבוד ברקע?
יכול להיות שתשימו לב שממשק המשתמש לא מגיב במהלך יצירת תשבץ. הסיבה לכך היא שיצירת תשבץ כוללת אלפי בדיקות אימות. החישובים האלה חוסמים את לולאת העיבוד של Flutter בקצב של 60fps, ולכן צריך להעביר חישובים כבדים לאיזולטים ברקע. היתרון הוא שממשק המשתמש נשאר חלק בזמן שהפאזל נוצר ברקע
כדי לבחור בצורה יותר שיטתית אילו מילים לנסות איפה, כדאי מאוד להעביר את החישוב הזה משרשור ממשק המשתמש לבידוד ברקע. ל-Flutter יש עטיפה שימושית מאוד להעברת חלק מהעבודה ולהרצתה בבידוד ברקע – הפונקציה compute
.
- בקובץ
providers.dart
, משנים את ספק התשבצים באופן הבא:
lib/providers.dart
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool()
? model.Direction.across
: model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width),
_random.nextInt(size.height),
);
try { // Edit from here
var candidate = await compute((
(String, model.Direction, model.Location) wordToAdd,
) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word,
direction: direction,
location: location,
);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
הסבר על הגבלות הבידוד
הקוד הזה פועל אבל יש בו בעיה נסתרת. ל-Isolates יש כללים מחמירים לגבי הנתונים שאפשר להעביר ביניהם. הבעיה היא שהסגירה 'תופסת' את ההפניה לספק, שלא ניתן לסדר אותה בסדרות ולשלוח אותה ל-isolate אחר.
ההודעה הזו תוצג כשהמערכת תנסה לשלוח נתונים שלא ניתן לסדר בסדרות:
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()
. אחת הדרכים לפתור את הבעיה היא לוודא שאין שום דבר שהסגירה צריכה לסגור שלא ניתן לשליחה.
השלב הראשון הוא להפריד את הספקים מהקוד של Isolate.
- יוצרים קובץ
isolates.dart
בספרייהlib
ומוסיפים לו את התוכן הבא:
lib/isolates.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
final _random = Random();
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
while (crossword.characters.length <
crossword.width * crossword.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool() ? Direction.across : Direction.down;
final location = Location.at(
_random.nextInt(crossword.width),
_random.nextInt(crossword.height),
);
try {
var candidate = await compute(((String, Direction, Location) wordToAdd) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word,
direction: direction,
location: location,
);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
}
הקוד הזה אמור להיראות מוכר. היא מהווה את ליבת הפונקציונליות של ספק crossword
, אבל עכשיו היא פועלת כפונקציית גנרטור עצמאית. עכשיו אפשר לעדכן את הקובץ providers.dart
כדי להשתמש בפונקציה החדשה הזו ליצירת מופע של הבידוד ברקע.
lib/providers.dart
// Drop the dart:math import, the _random instance moved to isolates.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart'; // Add this import
import 'model.dart' as model;
// Drop the utils.dart import
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
// Drop the _random instance
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword( // Edit from here
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyCrossword;
},
loading: () async* {
yield emptyCrossword; // To here.
},
);
}
עכשיו יש לכם כלי ליצירת תשבצים בגדלים שונים, והפתרון של התשבץ מתבצע ברקע.compute
עכשיו, אם רק הקוד היה יעיל יותר בהחלטה אילו מילים לנסות להוסיף לתשבץ.
6. ניהול תור העבודה
הסבר על האסטרטגיה לרשת החיפוש
התשבץ נוצר באמצעות backtracking, גישה שיטתית של ניסוי וטעייה. קודם האפליקציה מנסה למקם מילה במיקום מסוים, ואז בודקת אם היא מתאימה למילים הקיימות. אם כן, משאירים אותה ומנסים את המילה הבאה. אם לא, צריך להסיר אותו ולנסות במקום אחר.
התכונה 'חזרה אחורה' פועלת בתשבצים כי כל מיקום של מילה יוצר אילוצים למילים הבאות, ומיקומים לא תקינים מזוהים במהירות ונפסלים. מבני נתונים שלא ניתן לשנות מאפשרים לבטל שינויים בצורה יעילה.
חלק מהבעיה בקוד כמו שהוא כרגע הוא שהבעיה שנפתרת היא למעשה בעיית חיפוש, והפתרון הנוכחי הוא חיפוש עיוור. אם הקוד יתמקד במציאת מילים שיתחברו למילים הקיימות, במקום לנסות באופן אקראי להציב מילים בכל מקום ברשת, המערכת תמצא פתרונות מהר יותר. אחת הדרכים לעשות את זה היא ליצור תור עבודה של מיקומים שצריך לנסות למצוא להם מילים.
הקוד יוצר פתרונות אפשריים, בודק אם הפתרון האפשרי תקף, ובהתאם לתוקף שלו, משלב אותו או מבטל אותו. זוהי דוגמה להטמעה ממשפחת האלגוריתמים של backtracking. ההטמעה הזו קלה יותר בזכות 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
.
בקוד שאתם עומדים להוסיף לו רישום ביומן, כדי להראות כמה זמן לוקח ליצור תשבצים בגדלים שונים. יהיה נחמד אם משכי הזמן יוצגו בפורמט מסודר. למזלנו, בעזרת שיטות הרחבה אנחנו יכולים להוסיף את השיטה המדויקת שאנחנו צריכים.
- עורכים את הקובץ
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 Dive into Dart's patterns and records.
- כדי לשלב את הפונקציונליות החדשה הזו, צריך להחליף את הקובץ
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}',
);
}
הפעלת הקוד הזה תגרום ליצירת אפליקציה שנראית זהה מבחוץ, אבל ההבדל הוא כמה זמן ייקח למצוא תשבץ מוכן. הנה תשבץ בגודל 80x44 שנוצר תוך דקה ו-29 שניות.
נקודת עצירה: אלגוריתם יעיל בפעולה
עכשיו יצירת התשבץ אמורה להיות מהירה משמעותית, בזכות:
- נקודות מפגש של טירגוט חכם לפי מיקום מודעה
- מעקב יעיל אחרי חזרה אחורה כשמיקומים נכשלים
- ניהול תור העבודה כדי למנוע חיפושים מיותרים
השאלה המתבקשת היא כמובן, האם אפשר להגיע מהר יותר? כן, כן, אנחנו יכולים.
7. נתונים סטטיסטיים של פלטפורמות
למה כדאי להוסיף נתונים סטטיסטיים?
כדי ליצור משהו מהר, כדאי לראות מה קורה. הנתונים הסטטיסטיים עוזרים לכם לעקוב אחרי ההתקדמות ולראות את הביצועים של האלגוריתם בזמן אמת. הוא מאפשר לכם לזהות צווארי בקבוק על ידי הבנה של המקומות שבהם האלגוריתם מבלה את הזמן שלו. כך תוכלו לשפר את הביצועים על ידי קבלת החלטות מושכלות לגבי שיטות האופטימיזציה.
המידע שיוצג צריך להישלף מ-WorkQueue ולהופיע בממשק המשתמש. שלב ראשון מומלץ הוא להגדיר מחלקה חדשה של מודל שמכילה את המידע שרוצים להציג.
כדי להתחיל, בצע את הצעדים הבאים:
- עורכים את קובץ
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/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* { // Modify this provider
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
} // To here.
@Riverpod(keepAlive: true) // Add from here to end of file
class StartTime extends _$StartTime {
@override
DateTime? build() => _start;
DateTime? _start;
void start() {
_start = DateTime.now();
ref.invalidateSelf();
}
}
@Riverpod(keepAlive: true)
class EndTime extends _$EndTime {
@override
DateTime? build() => _end;
DateTime? _end;
void clear() {
_end = null;
ref.invalidateSelf();
}
void end() {
_end = DateTime.now();
ref.invalidateSelf();
}
}
const _estimatedTotalCoverage = 0.54;
@riverpod
Duration expectedRemainingTime(Ref ref) {
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final workQueueAsync = ref.watch(workQueueProvider);
return workQueueAsync.when(
data: (workQueue) {
if (startTime == null || endTime != null || workQueue.isCompleted) {
return Duration.zero;
}
try {
final soFar = DateTime.now().difference(startTime);
final completedPercentage = min(
0.99,
(workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height) /
_estimatedTotalCoverage),
);
final expectedTotal = soFar.inSeconds / completedPercentage;
final expectedRemaining = expectedTotal - soFar.inSeconds;
return Duration(seconds: expectedRemaining.toInt());
} catch (e) {
return Duration.zero;
}
},
error: (error, stackTrace) => Duration.zero,
loading: () => Duration.zero,
);
}
/// A provider that holds whether to display info.
@Riverpod(keepAlive: true)
class ShowDisplayInfo extends _$ShowDisplayInfo {
var _display = true;
@override
bool build() => _display;
void toggle() {
_display = !_display;
ref.invalidateSelf();
}
}
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref
.watch(workQueueProvider)
.when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
הספקים החדשים הם שילוב של מצב גלובלי, בצורה של האם תצוגת המידע צריכה להיות מוצגת על גבי רשת התשבץ, ונתונים נגזרים כמו זמן הריצה של יצירת התשבץ. הכול מסובך בגלל העובדה שהמאזינים לחלק מהמצב הזה הם זמניים. אם תצוגת המידע מוסתרת, שום דבר לא מקשיב לזמני ההתחלה והסיום של חישוב התשבץ, אבל הם צריכים להישאר בזיכרון כדי שהחישוב יהיה מדויק כשתצוגת המידע תוצג. הפרמטר keepAlive
של מאפיין Riverpod
שימושי מאוד במקרה הזה.
יש בעיה קלה בהצגת המידע. אנחנו רוצים להציג את משך הזמן שחלף מאז ההפעלה, אבל אין כאן שום דבר שיגרום לעדכון קבוע של הזמן שחלף. ב-codelab Building next generation UIs in Flutter יש ווידג'ט שימושי בדיוק למטרה הזו.
- יוצרים קובץ
ticker_builder.dart
בספרייהlib/widgets
ומוסיפים לו את התוכן הבא:
lib/widgets/ticker_builder.dart
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// A Builder widget that invokes its [builder] function on every animation frame.
class TickerBuilder extends StatefulWidget {
const TickerBuilder({super.key, required this.builder});
final Widget Function(BuildContext context) builder;
@override
State<TickerBuilder> createState() => _TickerBuilderState();
}
class _TickerBuilderState extends State<TickerBuilder>
with SingleTickerProviderStateMixin {
late final Ticker _ticker;
@override
void initState() {
super.initState();
_ticker = createTicker(_handleTick)..start();
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
void _handleTick(Duration elapsed) {
setState(() {
// Force a rebuild without changing the widget tree.
});
}
@override
Widget build(BuildContext context) => widget.builder.call(context);
}
הווידג'ט הזה הוא פטיש. התוכן שלו נבנה מחדש בכל פריים. בדרך כלל לא מומלץ לעשות את זה, אבל בהשוואה לעומס החישובים של חיפוש תשובות לתשבצים, עומס החישובים של צביעה מחדש של הזמן שחלף בכל פריים כנראה ייעלם ברעש. כדי להשתמש במידע החדש הזה, צריך ליצור ווידג'ט חדש.
- יוצרים קובץ
crossword_info_widget.dart
בתיקייהlib/widgets
ומוסיפים לו את התוכן הבא:
lib/widgets/crossword_info_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import '../utils.dart';
import 'ticker_builder.dart';
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 32.0, bottom: 32.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}',
),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount,
),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount,
),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount,
),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount,
),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage,
),
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted,
),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining',
value: remaining.formatted,
),
],
),
),
),
),
),
),
);
}
}
class _CrosswordInfoRichText extends StatelessWidget {
final String label;
final String value;
const _CrosswordInfoRichText({required this.label, required this.value});
@override
Widget build(BuildContext context) => RichText(
text: TextSpan(
children: [
TextSpan(text: '$label ', style: DefaultTextStyle.of(context).style),
TextSpan(
text: value,
style: DefaultTextStyle.of(
context,
).style.copyWith(fontWeight: FontWeight.bold),
),
],
),
);
}
הווידג'ט הזה הוא דוגמה מצוינת ליכולות של ספקי Riverpod. הווידג'ט הזה יסומן לבנייה מחדש כשמישהו מחמשת הספקים יעדכן. השינוי האחרון שנדרש בשלב הזה הוא שילוב הווידג'ט החדש בממשק המשתמש.
- עורכים את קובץ
crossword_generator_app.dart
באופן הבא:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_info_widget.dart'; // Add this import
import 'crossword_widget.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer( // Modify from here
builder: (context, ref, child) {
return Stack(
children: [
Positioned.fill(child: CrosswordWidget()),
if (ref.watch(showDisplayInfoProvider)) CrosswordInfoWidget(),
],
);
},
), // To here.
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton( // Add from here
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () => ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
), // To here.
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
שני השינויים האלה מדגימים גישות שונות לשילוב ספקים. בשיטה CrosswordGeneratorApp
של build
, הוספתם בונה Consumer
חדש שמכיל את האזור שנאלץ להיבנות מחדש כשהצגת המידע מוצגת או מוסתרת. מצד שני, כל התפריט הנפתח הוא ConsumerWidget
אחד, שייבנה מחדש אם תשנו את הגודל של תשבץ המילים או אם תציגו או תסתירו את תצוגת המידע. הבחירה בגישה מסוימת תמיד תלויה בשיקולים הנדסיים של פשטות לעומת העלות של חישוב מחדש של פריסות של עצי ווידג'טים שנבנו מחדש.
עכשיו, כשמריצים את האפליקציה, המשתמש מקבל תובנות נוספות לגבי ההתקדמות של יצירת התשבץ. עם זאת, לקראת סוף יצירת התשבץ אנחנו רואים שיש תקופה שבה המספרים משתנים, אבל יש מעט מאוד שינויים ברשת התווים.
כדאי לקבל תובנות נוספות לגבי מה שקורה ולמה.
8. הפעלת תהליכים מקבילים באמצעות שרשורים
למה הביצועים יורדים
ככל שהתשבץ מתקרב לסיום, האלגוריתם מאט כי נשארות פחות אפשרויות מיקום תקפות למילים. האלגוריתם מנסה הרבה שילובים שלא יעבדו. עיבוד עם thread יחיד לא מאפשר לבחון כמה אפשרויות ביעילות
הדמיה של האלגוריתם
כדי להבין למה הדברים מתנהלים לאט בסוף, כדאי להציג באופן ויזואלי את הפעולות שהאלגוריתם מבצע. חלק חשוב הוא locationsToTry
המצטיין בWorkQueue
. התצוגה של הטבלה מאפשרת לנו לבדוק את זה בצורה שימושית. אפשר לשנות את צבע התא בהתאם למיקום שלו בlocationsToTry
.
כדי להתחיל, בצע את הצעדים הבאים:
- משנים את הקובץ
crossword_widget.dart
באופן הבא:
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch( // Add from here
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
); // To here.
if (character != null) { // Modify from here
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text(character.character),
), // To here.
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
כשמריצים את הקוד הזה, מוצגת ויזואליזציה של המיקומים שלא נבדקו עדיין על ידי האלגוריתם.
הדבר המעניין בצפייה בתהליך ההתקדמות של התשבץ לקראת השלמתו הוא שיש מערך של נקודות שצריך לבדוק שלא יובילו לשום דבר מועיל. יש כאן כמה אפשרויות: האחת היא להגביל את הבדיקה ברגע שאחוז מסוים של משבצות התשבץ מתמלאות, והשנייה היא לבדוק כמה נקודות עניין בו-זמנית. הדרך השנייה נשמעת יותר כיפית, אז הגיע הזמן לנסות אותה.
- עורכים את קובץ
isolates.dart
. הקוד נכתב מחדש כמעט לגמרי כדי לפצל את מה שחושב בבידוד רקע אחד למאגר של N בידודים ברקע.
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
required int maxWorkerCount,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
try {
workQueue = await compute(_generate, (workQueue, maxWorkerCount));
yield workQueue;
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint(
'Generated ${workQueue.crossword.width} x '
'${workQueue.crossword.height} crossword in '
'${DateTime.now().difference(start).formatted} '
'with $maxWorkerCount workers.',
);
}
Future<WorkQueue> _generate((WorkQueue, int) workMessage) async {
var (workQueue, maxWorkerCount) = workMessage;
final candidateGeneratorFutures = <Future<(Location, Direction, String?)>>[];
final locations = workQueue.locationsToTry.keys.toBuiltList().rebuild(
(b) => b
..shuffle()
..take(maxWorkerCount),
);
for (final location in locations) {
final direction = workQueue.locationsToTry[location]!;
candidateGeneratorFutures.add(
compute(_generateCandidate, (
workQueue.crossword,
workQueue.candidateWords,
location,
direction,
)),
);
}
try {
final results = await candidateGeneratorFutures.wait;
var crossword = workQueue.crossword;
for (final (location, direction, word) in results) {
if (word != null) {
final candidate = crossword.addWord(
location: location,
word: word,
direction: direction,
);
if (candidate != null) {
crossword = candidate;
}
} else {
workQueue = workQueue.remove(location);
}
}
workQueue = workQueue.updateFrom(crossword);
} catch (e) {
debugPrint('$e');
}
return workQueue;
}
(Location, Direction, String?) _generateCandidate(
(Crossword, BuiltSet<String>, Location, Direction) searchDetailMessage,
) {
final (crossword, candidateWords, location, direction) = searchDetailMessage;
final target = crossword.characters[location];
if (target == null) {
return (location, direction, candidateWords.randomElement());
}
// Filter down the candidate word list to those that contain the letter
// at the current location
final words = candidateWords.toBuiltList().rebuild(
(b) => b
..where((b) => b.characters.contains(target.character))
..shuffle(),
);
int tryCount = 0;
final start = DateTime.now();
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return switch (direction) {
Direction.across => (location.leftOffset(index), direction, word),
Direction.down => (location.upOffset(index), direction, word),
};
}
final deltaTime = DateTime.now().difference(start);
if (tryCount >= 1000 || deltaTime > Duration(seconds: 10)) {
return (location, direction, null);
}
}
}
return (location, direction, null);
}
הסבר על ארכיטקטורת Multi-Isolate
רוב הקוד הזה אמור להיות מוכר לכם, כי הלוגיקה העסקית הבסיסית לא השתנתה. מה שהשתנה הוא שיש עכשיו שתי שכבות של שיחות compute
. השכבה הראשונה אחראית להקצאת מיקומים נפרדים לחיפוש ל-N מבודדי עובדים, ולאחר מכן לשילוב מחדש של התוצאות כשכל N מבודדי העובדים מסיימים. השכבה השנייה מורכבת מ-N מבודדי worker. התאמת הערך של N כדי להשיג את הביצועים הטובים ביותר תלויה במחשב ובנתונים הרלוונטיים. ככל שהרשת גדולה יותר, כך יותר עובדים יכולים לעבוד יחד בלי להפריע אחד לשני.
הדבר המעניין הוא לראות איך הקוד הזה מטפל עכשיו בבעיה של סגירות שתופסות דברים שהן לא אמורות לתפוס. אין כרגע חסימות. הפונקציות _generate
ו-_generateWorker
מוגדרות כפונקציות ברמה העליונה, שאין להן סביבה מסביב שאפשר לתעד ממנה. הארגומנטים של שתי הפונקציות האלה והתוצאות שלהן הם בצורה של רשומות Dart. זו דרך לעקוף את הסמנטיקה של קריאת compute
, שבה ערך אחד מוזן וערך אחד יוצא.
עכשיו יש לך אפשרות ליצור מאגר של עובדים ברקע כדי לחפש מילים שמשתלבות ברשת ויוצרות תשבץ, הגיע הזמן לחשוף את היכולת הזו לשאר כלי יצירת התשבצים.
- עורכים את קובץ
providers.dart
על ידי עריכת ספק workQueue באופן הבא:
lib/providers.dart
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* {
final workers = ref.watch(workerCountProvider); // Add this line
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: workers.count, // Add this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
}
- מוסיפים את ספק
WorkerCount
לסוף הקובץ באופן הבא:
lib/providers.dart
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref
.watch(workQueueProvider)
.when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
enum BackgroundWorkers { // Add from here
one(1),
two(2),
four(4),
eight(8),
sixteen(16),
thirtyTwo(32),
sixtyFour(64),
oneTwentyEight(128);
const BackgroundWorkers(this.count);
final int count;
String get label => count.toString();
}
/// A provider that holds the current number of background workers to use.
@Riverpod(keepAlive: true)
class WorkerCount extends _$WorkerCount {
var _count = BackgroundWorkers.four;
@override
BackgroundWorkers build() => _count;
void setCount(BackgroundWorkers count) {
_count = count;
ref.invalidateSelf();
}
} // To here.
בעקבות שני השינויים האלה, שכבת הספק חושפת עכשיו דרך להגדיר את המספר המקסימלי של עובדים במאגר של בידוד הרקע, כך שפונקציות הבידוד מוגדרות בצורה נכונה.
- מעדכנים את הקובץ
crossword_info_widget.dart
על ידי שינויCrosswordInfoWidget
באופן הבא:
lib/widgets/crossword_info_widget.dart
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final workerCount = ref.watch(workerCountProvider).label; // Add this line
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 32.0, bottom: 32.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}',
),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount,
),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount,
),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount,
),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount,
),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage,
),
_CrosswordInfoRichText( // Add from here
label: 'Max worker count',
value: workerCount,
), // To here.
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted,
),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining',
value: remaining.formatted,
),
],
),
),
),
),
),
),
);
}
}
- משנים את קובץ
crossword_generator_app.dart
ומוסיפים את הקטע הבא לווידג'ט_CrosswordGeneratorMenu
:
lib/widgets/crossword_generator_app.dart
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton(
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () => ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
),
for (final count in BackgroundWorkers.values) // Add from here
MenuItemButton(
leadingIcon: count == ref.watch(workerCountProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
onPressed: () =>
ref.read(workerCountProvider.notifier).setCount(count),
child: Text(count.label), // To here.
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
אם מריצים את האפליקציה עכשיו, אפשר לשנות את מספר הבידודים ברקע שמופעלים כדי לחפש מילים להוספה לתשבץ.
- לוחצים על סמל גלגל השיניים כדי לפתוח את התפריט ההקשרי שכולל את הגודל של התשבץ, את האפשרות להציג את הנתונים הסטטיסטיים בתשבץ שנוצר ועכשיו גם את מספר המילים המבודדות לשימוש.
נקודת ביניים: ביצועים של Multi-threaded
הפעלת מחולל תשבצים הפחיתה באופן משמעותי את זמן החישוב של תשבץ בגודל 80x44 באמצעות שימוש בכמה ליבות בו-זמנית. תשימו לב לפרטים הבאים:
- יצירה מהירה יותר של תשבצים עם מספר גבוה יותר של עובדים
- רספונסיביות חלקה של ממשק המשתמש במהלך היצירה
- נתונים סטטיסטיים בזמן אמת שמציגים את התקדמות היצירה
- משוב ויזואלי על אזורים שבהם האלגוריתם מחפש
9. הופכים את השיחה למשחק
מה אנחנו מפתחים: משחק תשבץ אינטראקטיבי
החלק האחרון הזה הוא למעשה סבון בונוס. תשתמשו בכל הטכניקות שלמדתם במהלך בניית מחולל התשבצים כדי לבנות משחק. תצטרכו:
- יצירת חידות: שימוש בכלי ליצירת תשבצים כדי ליצור חידות שאפשר לפתור
- יצירת אפשרויות מילים: מספקים כמה אפשרויות מילים לכל מיקום
- הפעלת אינטראקציה: המשתמשים יכולים לבחור מילים ולהציב אותן
- אימות הפתרונות: בדיקה אם תשבץ המילים שהושלם נכון
תשתמשו במחולל תשבצים כדי ליצור תשבץ. תשתמשו שוב בביטויים מהתפריט ההקשרי כדי לאפשר למשתמש לבחור מילים ולבטל את הבחירה שלהן, כדי להכניס אותן לחורים השונים בצורת מילים ברשת. הכול במטרה להשלים את התשבץ.
לא אומר שהמשחק הזה מלוטש או גמור, הוא רחוק מזה. יש בעיות של איזון וקושי שאפשר לפתור באמצעות שיפור הבחירה של מילים חלופיות. אין הדרכה שתעזור למשתמשים לפתור את החידה. אני אפילו לא אציין את המסך הבסיסי "זכית!".
החיסרון הוא שיידרש הרבה יותר קוד כדי להפוך את אב הטיפוס הזה למשחק מלא. יותר קוד ממה שצריך להיות ב-codelab יחיד. לכן, במקום זאת, זהו שלב של ריצה מהירה שנועד לחזק את הטכניקות שנלמדו עד כה ב-codelab הזה, על ידי שינוי המקום והאופן שבהם משתמשים בהן. אני מקווה שההסבר הזה מחזק את הלקחים שלמדתם קודם ב-codelab הזה. אפשרות אחרת היא להשתמש בקוד הזה כדי ליצור חוויות משלכם. נשמח לראות מה תבנו!
כדי להתחיל, בצע את הצעדים הבאים:
- מוחקים את כל מה שיש בספרייה
lib/widgets
. תצטרכו ליצור ווידג'טים חדשים ומבריקים למשחק. הווידג'טים האלה דומים מאוד לווידג'טים הישנים.
- כדי לעדכן את שיטת
addWord
שלCrossword
, עורכים את הקובץmodel.dart
באופן הבא:
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
כמודל בסיסי לאחסון המהלכים של השחקן. זו רק רשימה של מילים במיקומים ספציפיים, שמוצבות בכיוון מסוים.
- מוסיפים את מחלקת המודל
CrosswordPuzzleGame
לסוף הקובץmodel.dart
.
lib/model.dart
/// Creates a puzzle from a crossword and a set of candidate words.
abstract class CrosswordPuzzleGame
implements Built<CrosswordPuzzleGame, CrosswordPuzzleGameBuilder> {
static Serializer<CrosswordPuzzleGame> get serializer =>
_$crosswordPuzzleGameSerializer;
/// The [Crossword] that this puzzle is based on.
Crossword get crossword;
/// The alternate words for each [CrosswordWord] in the crossword.
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>> get alternateWords;
/// The player's selected words.
BuiltList<CrosswordWord> get selectedWords;
bool canSelectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return true;
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild(
(b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
),
);
}
return null !=
puzzle.crosswordFromSelectedWords.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false,
);
}
CrosswordPuzzleGame? selectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return rebuild((b) => b.selectedWords.remove(crosswordWord));
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild(
(b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
),
);
}
// Check if the selected word meshes with the already selected words.
// Note this version of the crossword does not enforce overlap to
// allow the player to select words anywhere on the grid. Enforcing words
// to be solved in order is a possible alternative.
final updatedSelectedWordsCrossword = puzzle.crosswordFromSelectedWords
.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false,
);
// Make sure the selected word is in the crossword or is an alternate word.
if (updatedSelectedWordsCrossword != null) {
if (puzzle.crossword.words.contains(crosswordWord) ||
puzzle.alternateWords[location]?[direction]?.contains(word) == true) {
return puzzle.rebuild(
(b) => b
..selectedWords.add(
CrosswordWord.word(
word: word,
location: location,
direction: direction,
),
),
);
}
}
return null;
}
/// The crossword from the selected words.
Crossword get crosswordFromSelectedWords => Crossword.crossword(
width: crossword.width,
height: crossword.height,
words: selectedWords,
);
/// Test if the puzzle is solved. Note, this allows for the possibility of
/// multiple solutions.
bool get solved =>
crosswordFromSelectedWords.valid &&
crosswordFromSelectedWords.words.length == crossword.words.length &&
crossword.words.isNotEmpty;
/// Create a crossword puzzle game from a crossword and a set of candidate
/// words.
factory CrosswordPuzzleGame.from({
required Crossword crossword,
required BuiltSet<String> candidateWords,
}) {
// Remove all of the currently used words from the list of candidates
candidateWords = candidateWords.rebuild(
(p0) => p0.removeAll(crossword.words.map((p1) => p1.word)),
);
// This is the list of alternate words for each word in the crossword
var alternates =
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>>();
// Build the alternate words for each word in the crossword
for (final crosswordWord in crossword.words) {
final alternateWords = candidateWords.toBuiltList().rebuild(
(b) => b
..where((b) => b.length == crosswordWord.word.length)
..shuffle()
..take(4)
..sort(),
);
candidateWords = candidateWords.rebuild(
(b) => b.removeAll(alternateWords),
);
alternates = alternates.rebuild(
(b) => b.updateValue(
crosswordWord.location,
(b) => b.rebuild(
(b) => b.updateValue(
crosswordWord.direction,
(b) => b.rebuild((b) => b.replace(alternateWords)),
ifAbsent: () => alternateWords,
),
),
ifAbsent: () => {crosswordWord.direction: alternateWords}.build(),
),
);
}
return CrosswordPuzzleGame((b) {
b
..crossword.replace(crossword)
..alternateWords.replace(alternates);
});
}
factory CrosswordPuzzleGame([
void Function(CrosswordPuzzleGameBuilder)? updates,
]) = _$CrosswordPuzzleGame;
CrosswordPuzzleGame._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo,
CrosswordPuzzleGame, // Add this line
])
final Serializers serializers = _$serializers;
העדכונים בקובץ providers.dart
הם אוסף מעניין של שינויים. רוב הספקים שהיו זמינים לתמיכה באיסוף נתונים סטטיסטיים הוסרו. היכולת לשנות את מספר הבידודים של הרקע הוסרה והוחלפה בקבוע. יש גם ספק חדש שנותן גישה למודל CrosswordPuzzleGame
החדש שהוספתם קודם.
lib/providers.dart
import 'dart:convert';
// Drop the dart:math import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
const backgroundWorkerCount = 4; // Add this line
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* {
final size = ref.watch(sizeProvider); // Drop the ref.watch(workerCountProvider)
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
// Drop the startTimeProvider and endTimeProvider refs
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: backgroundWorkerCount, // Edit this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
} // Drop the endTimeProvider ref
@riverpod // Add from here to end of file
class Puzzle extends _$Puzzle {
model.CrosswordPuzzleGame _puzzle = model.CrosswordPuzzleGame.from(
crossword: model.Crossword.crossword(width: 0, height: 0),
candidateWords: BuiltSet<String>(),
);
@override
model.CrosswordPuzzleGame build() {
final size = ref.watch(sizeProvider);
final wordList = ref.watch(wordListProvider).value;
final workQueue = ref.watch(workQueueProvider).value;
if (wordList != null &&
workQueue != null &&
workQueue.isCompleted &&
(_puzzle.crossword.height != size.height ||
_puzzle.crossword.width != size.width ||
_puzzle.crossword != workQueue.crossword)) {
compute(_puzzleFromCrosswordTrampoline, (
workQueue.crossword,
wordList,
)).then((puzzle) {
_puzzle = puzzle;
ref.invalidateSelf();
});
}
return _puzzle;
}
Future<void> selectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) async {
final candidate = await compute(_puzzleSelectWordTrampoline, (
_puzzle,
location,
word,
direction,
));
if (candidate != null) {
_puzzle = candidate;
ref.invalidateSelf();
} else {
debugPrint('Invalid word selection: $word');
}
}
bool canSelectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) {
return _puzzle.canSelectWord(
location: location,
word: word,
direction: direction,
);
}
}
// Trampoline functions to disentangle these Isolate target calls from the
// unsendable reference to the [Puzzle] provider.
Future<model.CrosswordPuzzleGame> _puzzleFromCrosswordTrampoline(
(model.Crossword, BuiltSet<String>) args,
) async =>
model.CrosswordPuzzleGame.from(crossword: args.$1, candidateWords: args.$2);
model.CrosswordPuzzleGame? _puzzleSelectWordTrampoline(
(model.CrosswordPuzzleGame, model.Location, String, model.Direction) args,
) => args.$1.selectWord(location: args.$2, word: args.$3, direction: args.$4);
החלקים הכי מעניינים בPuzzle
provider הם האסטרטגיות שבהן הוא משתמש כדי להסתיר את העלות של יצירת CrosswordPuzzleGame
מ-Crossword
ומ-wordList
, ואת העלות של בחירת מילה. שתי הפעולות האלה, כשמבצעים אותן בלי עזרה של Isolate ברקע, גורמות לאינטראקציה איטית בממשק המשתמש. השיטה הזו מאפשרת להציג תוצאת ביניים בזמן שתוצאת הסופית מחושבת ברקע, וכך מתקבל ממשק משתמש רספונסיבי בזמן שהחישובים הנדרשים מתבצעים ברקע.
- בספרייה
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),
);
}
}
הווידג'ט הזה קצת יותר מורכב מהווידג'ט הקודם, למרות שהוא מורכב מחלקים שכבר ראיתם במקומות אחרים בעבר. עכשיו, כשלוחצים על כל תא עם תוכן, מופיע תפריט הקשר עם רשימה של המילים שהמשתמש יכול לבחור. אם בחרתם מילים, לא תוכלו לבחור מילים שמתנגשות עם הבחירה. כדי לבטל את הבחירה במילה, המשתמש מקיש על פריט התפריט של המילה הזו.
בהנחה שהשחקן יכול לבחור מילים כדי למלא את כל התשבץ, צריך ליצור מסך עם הכיתוב 'ניצחת!'.
- יוצרים קובץ
puzzle_completed_widget.dart
ומוסיפים לו את התוכן הבא:
lib/widgets/puzzle_completed_widget.dart
import 'package:flutter/material.dart';
class PuzzleCompletedWidget extends StatelessWidget {
const PuzzleCompletedWidget({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Text(
'Puzzle Completed!',
style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
),
);
}
}
אני בטוחה שתוכל לקחת את זה ולעשות את זה יותר מעניין. מידע נוסף על כלים ליצירת אנימציות זמין במאמר יצירת ממשקי משתמש מהדור הבא ב-Flutter.
- עורכים את קובץ
lib/main.dart
באופן הבא:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_puzzle_app.dart'; // Update this line
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Puzzle', // Update this line
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordPuzzleApp(), // Update this line
),
),
);
}
כשמריצים את האפליקציה הזו, רואים את האנימציה בזמן שיוצר התשבצים יוצר את התשבץ. לאחר מכן יוצג לכם פאזל ריק שצריך לפתור. אם תפתרו את החידה, יוצג לכם מסך שנראה כך:
10. מזל טוב
מעולה! הצלחת ליצור משחק פאזל באמצעות Flutter!
יצרת מחולל תשבצים שהפך למשחק פאזל. הצלחתם להריץ חישובים ברקע במאגר של בידודים. השתמשתם במבני נתונים שלא ניתן לשנות כדי להקל על ההטמעה של אלגוריתם backtracking. היה לך זמן איכות עם TableView
, וזה יעזור לך בפעם הבאה שתצטרך להציג נתונים בטבלה.