1. قبل البدء
تخيَّل أن يُطلب منك تحديد ما إذا كان من الممكن إنشاء أكبر كلمات متقاطعة في العالم. تتذكّر بعض تقنيات الذكاء الاصطناعي التي درستها في المدرسة وتتساءل عمّا إذا كان بإمكانك استخدام Flutter لاستكشاف الخيارات الخوارزمية لإنشاء حلول للمشاكل التي تتطلّب قدرًا كبيرًا من الحساب.
في هذا الدرس العملي، ستنفّذ ذلك بالضبط. في النهاية، ستنشئ أداة للعب في مساحة الخوارزميات لإنشاء ألغاز شبكة الكلمات. تتوفّر العديد من التعريفات المختلفة لما يُعدّ أحجية كلمات متقاطعة صالحة، وتساعدك هذه التقنيات في إنشاء أحاجٍ تتوافق مع تعريفك للأحجية.
باستخدام هذه الأداة كأساس، يمكنك بعد ذلك إنشاء لعبة كلمات متقاطعة تستخدم أداة إنشاء الكلمات المتقاطعة لإنشاء اللعبة ليحلّها المستخدم. يمكن استخدام هذا اللغز على أجهزة Android وiOS وWindows وmacOS وLinux. في ما يلي الخطوات على جهاز Android:
المتطلبات الأساسية
- إكمال الدرس التطبيقي حول الترميز تطبيق Flutter الأول
ما ستتعلمه
- كيفية استخدام العزلات لتنفيذ مهام تتطلّب قدرًا كبيرًا من العمليات الحسابية بدون إعاقة حلقة العرض في Flutter من خلال الجمع بين وظيفة
compute
في Flutter وإمكانات التخزين المؤقت للقيم في فلتر إعادة الإنشاءselect
في Riverpod - كيفية الاستفادة من بنى البيانات غير القابلة للتغيير باستخدام
built_value
وbuilt_collection
لتنفيذ تقنيات الذكاء الاصطناعي القديمة الجيدة المستندة إلى البحث، مثل البحث في العمق والتراجع - كيفية استخدام إمكانات حزمة
two_dimensional_scrollables
لعرض بيانات الشبكة بطريقة سريعة وسهلة
المتطلبات
- حزمة تطوير البرامج (SDK) من Flutter
- محرِّر Visual Studio Code (VS Code) مع إضافتَي Flutter وDart
- برنامج مترجم للغة البرمجة التي اخترتها. يعمل هذا الدرس العملي على جميع أنظمة التشغيل لأجهزة الكمبيوتر المكتبي وAndroid وiOS. تحتاج إلى VS Code لاستهداف Windows، وXcode لاستهداف macOS أو iOS، وAndroid Studio لاستهداف Android.
2. إنشاء مشروع
إنشاء مشروع Flutter الأول
- افتح VS Code.
- افتح "لوحة الأوامر" (Ctrl+Shift+P في نظام التشغيل Windows أو Linux، وCmd+Shift+P في نظام التشغيل macOS)، واكتب "flutter new"، ثم اختَر Flutter: مشروع جديد في القائمة.
- اختَر تطبيق فارغ، ثم اختَر دليلًا لإنشاء مشروعك فيه. يجب أن يكون هذا أي دليل لا يتطلب امتيازات مرتفعة أو يحتوي على مسافة في مساره. وتشمل الأمثلة الدليل الرئيسي أو
C:\src\
.
- أدخِل اسمًا لمشروعك
generate_crossword
. يفترض بقية هذا الدرس التطبيقي العملي أنّك سمّيت تطبيقك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: تنشئ عناصر غير قابلة للتغيير تتشارك الذاكرة بكفاءة، وهو أمر بالغ الأهمية لخوارزمية التتبُّع الخلفي.
- Riverpod: توفّر إدارة دقيقة للحالة باستخدام
select()
لتقليل عمليات إعادة الإنشاء - two_dimensional_scrollables: للتعامل مع الشبكات الكبيرة بدون التأثير سلبًا في الأداء
- افتح ملف
main.dart
في دليلlib/
.
- استبدِل محتوى هذا الملف بما يلي:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: Scaffold(
body: Center(
child: Text('Hello, World!', style: TextStyle(fontSize: 24)),
),
),
),
),
);
}
- نفِّذ هذه التعليمات البرمجية للتأكّد من أنّ كل شيء يعمل بشكل سليم. يجب أن تعرض نافذة جديدة تحتوي على عبارة البداية الإلزامية لكل مشروع جديد في كل مكان. هناك
ProviderScope
يشير إلى أنّ هذا التطبيق سيستخدمriverpod
لإدارة الحالة.
نقطة التحقّق: تشغيل التطبيق الأساسي
في هذه المرحلة، من المفترض أن تظهر لك نافذة بعنوان "Hello, World!". في حال عدم توفر ذلك، يُرجى اتباع ما يلي:
- التأكّد من تثبيت Flutter بشكلٍ صحيح
- التحقّق من أنّ التطبيق يعمل مع
flutter run
- التأكّد من عدم حدوث أي أخطاء في التجميع في نافذة المحطة الطرفية
3- إضافة كلمات
اللبنات الأساسية لأحجية الكلمات المتقاطعة
تتضمّن الكلمات المتقاطعة في الأساس قائمة من الكلمات. يتم ترتيب الكلمات في شبكة، بعضها بشكل أفقي وبعضها بشكل عمودي، بحيث تتشابك الكلمات. يؤدي حلّ كلمة واحدة إلى تقديم أدلة حول الكلمات التي تتقاطع مع تلك الكلمة الأولى. وبالتالي، فإنّ أول وحدة أساسية جيدة هي قائمة بالكلمات.
يمكنك العثور على مصدر جيد لهذه الكلمات في صفحة بيانات مجموعة النصوص للغة الطبيعية التي أعدّها "بيتر نورفيغ". قائمة SOWPODS هي نقطة بداية مفيدة، إذ تضم 267,750 كلمة.
في هذه الخطوة، ستنزّل قائمة بالكلمات وتضيفها كأصل إلى تطبيق Flutter، ثم ترتّب موفّر Riverpod لتحميل القائمة في التطبيق عند بدء تشغيله.
للبدء في ذلك، اتبع الخطوات التالية:
- عدِّل ملف
pubspec.yaml
في مشروعك لإضافة بيان الأصول التالي لقائمة الكلمات المحدّدة. لا تعرض هذه القائمة سوى مقطع 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 لقاعدة الرموز البرمجية هذه.
طريقة عمل هذا الموفِّر:
- تحميل قائمة الكلمات من مواد العرض بشكل غير متزامن
- فلترة الكلمات لتضمين الأحرف من أ إلى ي فقط التي تتألف من أكثر من حرفَين
- تعرض هذه الدالة
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
. تتيح هاتان الحزمتان إنشاء بنى بيانات كقيم غير قابلة للتغيير، ما سيكون مفيدًا لكلّ من تمرير البيانات بين العمليات المعزولة وتسهيل تنفيذ البحث الأول في العمق والتراجع.
للبدء في ذلك، اتبع الخطوات التالية:
- أنشئ ملف
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
، وهي طريقة أسهل بكثير لكتابة عملية حسابية تعرض نتائج وسيطة.
بسبب توفّر زوج من استدعاءات 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
في الخطوات المستقبلية. شغِّل تطبيقك، وسيظهر لك ما يلي:
تظهر الأحرف في شبكة وقائمة تتيح للمستخدم تغيير حجم الشبكة. لكن الكلمات ليست مرتبة مثل أحجية الكلمات المتقاطعة. ويرجع ذلك إلى عدم فرض أي قيود على كيفية إضافة الكلمات إلى الكلمات المتقاطعة. باختصار، إنّها فوضى. هذا ما ستبدأ في السيطرة عليه في الخطوة التالية.
5- فرض القيود
التغييرات التي سنجريها وسببها
تسمح حاليًا لعبة الكلمات المتقاطعة بتداخل الكلمات بدون التحقّق من صحتها. ستضيف عملية التحقّق من القيود لضمان تشابك الكلمات بشكلٍ صحيح مثل أحجية الكلمات المتقاطعة الحقيقية.
الهدف من هذه الخطوة هو إضافة رمز إلى النموذج لفرض قيود الكلمات المتقاطعة. هناك العديد من الأنواع المختلفة من ألغاز الكلمات المتقاطعة، وسيتّبع هذا الدرس التطبيقي البرمجي أسلوب ألغاز الكلمات المتقاطعة الإنجليزية. وكما هو الحال دائمًا، يُترك للقارئ مهمة تعديل هذا الرمز لإنشاء أنماط أخرى من الكلمات المتقاطعة.
للبدء في ذلك، اتبع الخطوات التالية:
- افتح الملف
model.dart
واستبدِل نموذجCrossword
فقط بما يلي:
lib/model.dart
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword,
/// or checking the proposed solution.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Checks if this crossword is valid.
bool get valid {
// Check that there are no duplicate words.
final wordSet = words.map((word) => word.word).toBuiltSet();
if (wordSet.length != words.length) {
return false;
}
for (final MapEntry(key: location, value: character)
in characters.entries) {
// All characters must be a part of an across or down word.
if (character.acrossWord == null && character.downWord == null) {
return false;
}
// All characters must be within the crossword puzzle.
// No drawing outside the lines.
if (location.x < 0 ||
location.y < 0 ||
location.x >= width ||
location.y >= height) {
return false;
}
// Characters above and below this character must be related
// by a vertical word
if (characters[location.up] case final up?) {
if (character.downWord == null) {
return false;
}
if (up.downWord != character.downWord) {
return false;
}
}
if (characters[location.down] case final down?) {
if (character.downWord == null) {
return false;
}
if (down.downWord != character.downWord) {
return false;
}
}
// Characters to the left and right of this character must be
// related by a horizontal word
final left = characters[location.left];
if (left != null) {
if (character.acrossWord == null) {
return false;
}
if (left.acrossWord != character.acrossWord) {
return false;
}
}
final right = characters[location.right];
if (right != null) {
if (character.acrossWord == null) {
return false;
}
if (right.acrossWord != character.acrossWord) {
return false;
}
}
}
return true;
}
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
if (words.isNotEmpty && !overlap) {
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width,
(_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
للتذكير، تتطلّب التغييرات التي تجريها على الملفَين model.dart
وproviders.dart
تشغيل build_runner
لتعديل الملفَين model.g.dart
وproviders.g.dart
المعنيَين. إذا لم يتم تعديل هذه الملفات تلقائيًا، ننصحك الآن بإعادة الخطوة build_runner
باستخدام dart run build_runner watch -d
.
للاستفادة من هذه الإمكانية الجديدة في طبقة النموذج، عليك تعديل طبقة مقدّم الخدمة لتتطابق معها.
- عدِّل ملف
providers.dart
على النحو التالي:
lib/providers.dart
import 'dart:convert';
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model;
import 'utils.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
var candidate = crossword.addWord( // Edit from here
word: word, direction: direction, location: location);
await Future.delayed(Duration(milliseconds: 10));
if (candidate != null) {
debugPrint('Added word: $word');
crossword = candidate;
yield crossword;
} else {
debugPrint('Failed to add word: $word');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
- شغِّل تطبيقك. لن يحدث الكثير في واجهة المستخدم، ولكن ستحدث الكثير من العمليات إذا نظرت إلى السجلات.
إذا فكّرت في ما يحدث هنا، ستلاحظ أنّنا نرى ظهور الكلمات المتقاطعة بشكل عشوائي. ترفض الطريقة addWord
في النموذج Crossword
أي كلمة مقترَحة لا تتناسب مع الكلمات المتقاطعة الحالية، لذا من المدهش أن نرى أي شيء يظهر على الإطلاق.
لماذا يجب الانتقال إلى المعالجة في الخلفية؟
قد تلاحظ أنّ واجهة المستخدم أصبحت غير مستجيبة أثناء إنشاء الكلمات المتقاطعة. يحدث ذلك لأنّ إنشاء الكلمات المتقاطعة يتضمّن آلاف عمليات التحقّق من الصحة. تحظر هذه العمليات الحسابية حلقة العرض بمعدل 60 لقطة في الثانية في Flutter، لذا عليك نقل العمليات الحسابية المعقّدة إلى عمليات معزولة في الخلفية. ويوفّر ذلك ميزة الحفاظ على سلاسة واجهة المستخدم أثناء إنشاء اللغز في الخلفية
استعدادًا لاختيار الكلمات التي يجب تجربتها بشكل أكثر منهجية، سيكون من المفيد جدًا نقل هذه العملية الحسابية من سلسلة التعليمات الخاصة بواجهة المستخدم إلى عملية معزولة في الخلفية. توفّر Flutter أداة تغليف مفيدة جدًا لتنفيذ جزء من العمل في عملية معزولة في الخلفية، وهي الدالة compute
.
- في الملف
providers.dart
، عدِّل مقدّم الكلمات المتقاطعة على النحو التالي:
lib/providers.dart
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool()
? model.Direction.across
: model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width),
_random.nextInt(size.height),
);
try { // Edit from here
var candidate = await compute((
(String, model.Direction, model.Location) wordToAdd,
) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word,
direction: direction,
location: location,
);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
فهم القيود المفروضة على العزل
يعمل هذا الرمز ولكنّه يتضمّن مشكلة مخفية. تتضمّن عمليات العزل قواعد صارمة بشأن البيانات التي يمكن تمريرها بينها، والمشكلة هي أنّ الإغلاق "يستحوذ" على مرجع مقدّم الخدمة، الذي لا يمكن تسلسله وإرساله إلى عملية عزل أخرى.
ستظهر لك هذه الرسالة عندما يحاول النظام إرسال بيانات غير قابلة للتسلسل:
flutter: Error running isolate: Invalid argument(s): Illegal argument in isolate message: object is unsendable - Library:'dart:async' Class: _Future@4048458 (see restrictions listed at `SendPort.send()` documentation for more information) flutter: <- Instance of 'AutoDisposeStreamProviderElement<Crossword>' (from package:riverpod/src/stream_provider.dart) flutter: <- Context num_variables: 2 <- Context num_variables: 1 parent:{ Context num_variables: 2 } flutter: <- Context num_variables: 1 parent:{ Context num_variables: 1 parent:{ Context num_variables: 2 } } flutter: <- Closure: () => Crossword? (from package:generate_crossword/providers.dart)
هذه هي نتيجة الإغلاق الذي يتم تسليمه من compute
إلى عملية الإغلاق المعزولة في الخلفية عبر موفّر، والذي لا يمكن إرساله عبر SendPort.send()
. أحد الحلول لهذه المشكلة هو التأكّد من عدم وجود أي شيء لا يمكن إرساله.
تتمثّل الخطوة الأولى في فصل موفّري الخدمات عن رمز 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. إدارة قائمة انتظار العمل
فهم استراتيجية البحث
تستخدم عملية إنشاء الكلمات المتقاطعة التراجع، وهو نهج منظَّم يعتمد على التجربة والخطأ. يحاول تطبيقك أولاً وضع كلمة في موقع جغرافي، ثم يتحقّق مما إذا كانت تتناسب مع الكلمات الحالية. إذا كان كذلك، احتفظ به وجرِّب الكلمة التالية. إذا لم يكن كذلك، أزِلها وجرِّب مكانًا آخر.
تعمل هذه الطريقة مع الكلمات المتقاطعة لأنّ كل موضع للكلمة يفرض قيودًا على الكلمات المستقبلية، ويتم رصد المواضع غير الصالحة والتخلي عنها بسرعة. تساهم بنى البيانات غير القابلة للتغيير في تسهيل عملية "التراجع" عن التغييرات.
أحد جوانب المشكلة في الرمز الحالي هو أنّ المشكلة التي يتم حلّها هي في الواقع مشكلة بحث، والحلّ الحالي هو البحث بشكل أعمى. إذا كان الرمز يركّز على العثور على كلمات يمكن إضافتها إلى الكلمات الحالية، بدلاً من محاولة وضع الكلمات بشكل عشوائي في أي مكان على الشبكة، سيتمكّن النظام من العثور على حلول بشكل أسرع. إحدى طرق التعامل مع هذه المشكلة هي إنشاء قائمة انتظار عمل للمواقع الجغرافية لمحاولة العثور على كلمات لها.
ينشئ الرمز حلولاً محتملة، ويتحقّق مما إذا كان الحل المحتمل صالحًا، ثم يدمج الحل المحتمل أو يتجاهله استنادًا إلى صلاحيته. هذا مثال على التنفيذ من مجموعة خوارزميات التراجع. يتم تسهيل عملية التنفيذ هذه بشكل كبير من خلال 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.
تستفيد طريقة الإضافة هذه من تعابير التبديل ومطابقة الأنماط على السجلات لاختيار الطريقة المناسبة لعرض المدد المختلفة التي تتراوح بين ثوانٍ وأيام. لمزيد من المعلومات حول هذا النوع من الرموز، يمكنك الاطّلاع على برنامج 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}',
);
}
سيؤدي تشغيل هذا الرمز إلى إنشاء تطبيق يبدو مطابقًا للتطبيق الأصلي من حيث الشكل، ولكن الفرق يكمن في المدة التي يستغرقها العثور على أحجية كلمات متقاطعة مكتملة. في ما يلي أحجية كلمات متقاطعة بحجم 80 × 44 تم إنشاؤها في دقيقة واحدة و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
مفيدة جدًا في هذه الحالة.
عند عرض شاشة المعلومات، هناك مشكلة بسيطة. نريد أن نتمكّن من عرض وقت التشغيل المنقضي، ولكن لا يوجد هنا أي شيء يفرض التحديث المستمر للوقت المنقضي. بالرجوع إلى الدرس التطبيقي حول الترميز إنشاء واجهات مستخدم من الجيل التالي في 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. المعالجة المتوازية باستخدام سلاسل المحادثات
أسباب تدهور الأداء
مع اقتراب الكلمات المتقاطعة من الاكتمال، تتباطأ الخوارزمية لأنّه يتبقى عدد أقل من خيارات وضع الكلمات الصالحة. ويجرّب محرّك البحث العديد من المجموعات التي لن تنجح. لا يمكن للمعالجة ذات السلسلة الواحدة استكشاف خيارات متعددة بكفاءة
تصوُّر الخوارزمية
لفهم سبب تباطؤ الأمور في النهاية، من المفيد أن تتمكّن من تصوّر ما تفعله الخوارزمية. الجزء الأساسي هو locationsToTry
المتبقّي في WorkQueue
. توفّر لنا TableView طريقة مفيدة للتحقيق في ذلك. يمكننا تغيير لون الخلية استنادًا إلى ما إذا كانت في locationsToTry
.
للبدء في ذلك، اتبع الخطوات التالية:
- عدِّل ملف
crossword_widget.dart
على النحو التالي:
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch( // Add from here
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
); // To here.
if (character != null) { // Modify from here
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text(character.character),
), // To here.
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
عند تشغيل هذا الرمز، سيظهر لك تمثيل مرئي للمواقع الجغرافية المعلقة التي لم تستكشفها الخوارزمية بعد.
الأمر المثير للاهتمام في متابعة هذه العملية هو أنّ هناك مجموعة من النقاط التي يجب التحقيق فيها والتي لن تؤدي إلى أي نتيجة مفيدة. هناك خياران هنا: الأول هو الحدّ من التحقيق بمجرد ملء نسبة مئوية معيّنة من خلايا الكلمات المتقاطعة، والثاني هو التحقيق في نقاط اهتمام متعددة في وقت واحد. يبدو المسار الثاني أكثر متعة، لذا حان الوقت لتجربته.
- عدِّل الملف
isolates.dart
. هذه إعادة كتابة شبه كاملة للرمز البرمجي لتقسيم ما كان يتم حسابه في عملية تنفيذ واحدة في الخلفية إلى مجموعة من N عمليات تنفيذ في الخلفية.
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
required int maxWorkerCount,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
try {
workQueue = await compute(_generate, (workQueue, maxWorkerCount));
yield workQueue;
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint(
'Generated ${workQueue.crossword.width} x '
'${workQueue.crossword.height} crossword in '
'${DateTime.now().difference(start).formatted} '
'with $maxWorkerCount workers.',
);
}
Future<WorkQueue> _generate((WorkQueue, int) workMessage) async {
var (workQueue, maxWorkerCount) = workMessage;
final candidateGeneratorFutures = <Future<(Location, Direction, String?)>>[];
final locations = workQueue.locationsToTry.keys.toBuiltList().rebuild(
(b) => b
..shuffle()
..take(maxWorkerCount),
);
for (final location in locations) {
final direction = workQueue.locationsToTry[location]!;
candidateGeneratorFutures.add(
compute(_generateCandidate, (
workQueue.crossword,
workQueue.candidateWords,
location,
direction,
)),
);
}
try {
final results = await candidateGeneratorFutures.wait;
var crossword = workQueue.crossword;
for (final (location, direction, word) in results) {
if (word != null) {
final candidate = crossword.addWord(
location: location,
word: word,
direction: direction,
);
if (candidate != null) {
crossword = candidate;
}
} else {
workQueue = workQueue.remove(location);
}
}
workQueue = workQueue.updateFrom(crossword);
} catch (e) {
debugPrint('$e');
}
return workQueue;
}
(Location, Direction, String?) _generateCandidate(
(Crossword, BuiltSet<String>, Location, Direction) searchDetailMessage,
) {
final (crossword, candidateWords, location, direction) = searchDetailMessage;
final target = crossword.characters[location];
if (target == null) {
return (location, direction, candidateWords.randomElement());
}
// Filter down the candidate word list to those that contain the letter
// at the current location
final words = candidateWords.toBuiltList().rebuild(
(b) => b
..where((b) => b.characters.contains(target.character))
..shuffle(),
);
int tryCount = 0;
final start = DateTime.now();
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return switch (direction) {
Direction.across => (location.leftOffset(index), direction, word),
Direction.down => (location.upOffset(index), direction, word),
};
}
final deltaTime = DateTime.now().difference(start);
if (tryCount >= 1000 || deltaTime > Duration(seconds: 10)) {
return (location, direction, null);
}
}
}
return (location, direction, null);
}
فهم بنية العزل المتعدّد
من المفترض أن يكون معظم هذا الرمز مألوفًا لأنّ منطق النشاط التجاري الأساسي لم يتغيّر. التغيير الذي طرأ هو أنّ هناك الآن طبقتَين من طلبات compute
. الطبقة الأولى مسؤولة عن توزيع المواضع الفردية على N من عمال البحث المعزولين، ثم إعادة دمج النتائج عند انتهاء جميع عمال البحث المعزولين. تتألف الطبقة الثانية من N عزل عامل. يعتمد ضبط N للحصول على أفضل أداء على كلّ من الكمبيوتر والبيانات المعنية. كلما كانت الشبكة أكبر، زاد عدد العمال الذين يمكنهم العمل معًا بدون أن يعيق بعضهم البعض.
النقطة المهمة التي يجب ملاحظتها هي كيفية تعامل هذا الرمز الآن مع مشكلة عمليات الإغلاق التي تستخدم عناصر لا يجب استخدامها. لم يعُد هناك أي إغلاقات. يتم تعريف الدالتَين _generate
و_generateWorker
على أنّهما دالتان من المستوى الأعلى، أي ليس لديهما بيئة محيطة يمكن استخلاص البيانات منها. تكون وسيطات هاتين الدالتين ونتائجهما على شكل سجلات Dart. هذه طريقة للتعامل مع دلالات القيمة الواحدة في مقابل القيمة الواحدة خارج compute
.
بعد أن أصبح بإمكانك إنشاء مجموعة من العمليات التي تعمل في الخلفية للبحث عن كلمات تتشابك في شبكة لتشكيل أحجية الكلمات المتقاطعة، حان الوقت لإتاحة هذه الإمكانية لبقية أداة إنشاء أحجية الكلمات المتقاطعة.
- عدِّل ملف
providers.dart
من خلال تعديل مقدّم workQueue على النحو التالي:
lib/providers.dart
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* {
final workers = ref.watch(workerCountProvider); // Add this line
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: workers.count, // Add this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
}
- أضِف موفّر
WorkerCount
إلى نهاية الملف على النحو التالي:
lib/providers.dart
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref
.watch(workQueueProvider)
.when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
enum BackgroundWorkers { // Add from here
one(1),
two(2),
four(4),
eight(8),
sixteen(16),
thirtyTwo(32),
sixtyFour(64),
oneTwentyEight(128);
const BackgroundWorkers(this.count);
final int count;
String get label => count.toString();
}
/// A provider that holds the current number of background workers to use.
@Riverpod(keepAlive: true)
class WorkerCount extends _$WorkerCount {
var _count = BackgroundWorkers.four;
@override
BackgroundWorkers build() => _count;
void setCount(BackgroundWorkers count) {
_count = count;
ref.invalidateSelf();
}
} // To here.
من خلال هذين التغييرين، تتيح طبقة الموفّر الآن طريقة لضبط الحدّ الأقصى لعدد العاملين في مجموعة الخلفية المعزولة بطريقة يتم بها ضبط وظائف العزل بشكل صحيح.
- عدِّل ملف
crossword_info_widget.dart
من خلال تعديلCrosswordInfoWidget
على النحو التالي:
lib/widgets/crossword_info_widget.dart
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final workerCount = ref.watch(workerCountProvider).label; // Add this line
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 32.0, bottom: 32.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}',
),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount,
),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount,
),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount,
),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount,
),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage,
),
_CrosswordInfoRichText( // Add from here
label: 'Max worker count',
value: workerCount,
), // To here.
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted,
),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining',
value: remaining.formatted,
),
],
),
),
),
),
),
),
);
}
}
- عدِّل ملف
crossword_generator_app.dart
بإضافة القسم التالي إلى الأداة_CrosswordGeneratorMenu
:
lib/widgets/crossword_generator_app.dart
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton(
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () => ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
),
for (final count in BackgroundWorkers.values) // Add from here
MenuItemButton(
leadingIcon: count == ref.watch(workerCountProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
onPressed: () =>
ref.read(workerCountProvider.notifier).setCount(count),
child: Text(count.label), // To here.
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
إذا شغّلت التطبيق الآن، ستتمكّن من تعديل عدد العمليات المعزولة في الخلفية التي يتم إنشاء مثيل لها للبحث عن كلمات يمكن إدراجها في الكلمات المتقاطعة.
- انقر على رمز الترس في أعلى الصفحة لفتح قائمة السياق التي تحتوي على خيارات حجم الكلمات المتقاطعة، وما إذا كنت تريد عرض الإحصاءات على الكلمات المتقاطعة التي تم إنشاؤها، وعدد الكلمات المنعزلة التي تريد استخدامها.
نقطة التحقّق: الأداء المتعدّد سلاسل التعليمات
وقد أدّى تشغيل أداة إنشاء الكلمات المتقاطعة إلى تقليل وقت الحوسبة بشكل كبير لكلمات متقاطعة بحجم 80x44 من خلال استخدام نوى متعددة في الوقت نفسه. يجب أن تلاحظ ما يلي:
- إنشاء أسرع للكلمات المتقاطعة مع زيادة عدد العاملين
- استجابة سلسة لواجهة المستخدم أثناء الإنشاء
- إحصاءات في الوقت الفعلي تعرض مستوى تقدّم عملية الإنشاء
- ملاحظات مرئية حول مناطق استكشاف الخوارزمية
9- تحويلها إلى لعبة
ما نعمل على إنشائه: لعبة الكلمات المتقاطعة "هيّا نلعب"
هذا القسم الأخير هو في الواقع جولة إضافية. ستستخدم جميع الأساليب التي تعلّمتها أثناء إنشاء أداة إنشاء الكلمات المتقاطعة، وستستفيد من هذه الأساليب لإنشاء لعبة. عليك إجراء ما يلي:
- إنشاء الألغاز: استخدِم أداة إنشاء الكلمات المتقاطعة لإنشاء ألغاز يمكن حلّها
- إنشاء خيارات للكلمات: تقديم خيارات متعددة للكلمات لكل موضع
- تفعيل التفاعل: السماح للمستخدمين باختيار الكلمات ووضعها
- التحقّق من صحة الحلول: تحقَّق مما إذا كان الكلمات المتقاطعة المكتملة صحيحة.
ستستخدم أداة إنشاء الكلمات المتقاطعة لإنشاء لعبة كلمات متقاطعة. ستعيد استخدام عبارات قائمة السياق لتمكين المستخدم من اختيار الكلمات وإلغاء اختيارها لوضعها في الفتحات المختلفة على شكل كلمات في الشبكة. كل ذلك بهدف إكمال الكلمات المتقاطعة.
لن أقول إنّ هذه اللعبة مصقولة أو مكتملة، بل إنّها بعيدة عن ذلك في الواقع. هناك مشاكل في التوازن والصعوبة يمكن حلّها من خلال تحسين اختيار الكلمات البديلة. لا يتوفّر برنامج تعليمي لإرشاد المستخدمين إلى اللغز. لن أتطرّق حتى إلى شاشة "لقد فزت" البسيطة.
والجدير بالذكر أنّ تحويل هذه اللعبة التجريبية إلى لعبة كاملة يتطلّب كتابة المزيد من الرموز البرمجية. مقدار كبير من الرموز البرمجية لا يتناسب مع درس برمجي واحد لذلك، هذه خطوة سريعة مصمَّمة لتعزيز التقنيات التي تم تعلّمها حتى الآن في هذا الدرس التطبيقي حول الترميز من خلال تغيير مكان استخدامها وطريقة استخدامها. نأمل أن يؤكّد ذلك على الدروس التي تعلّمتها سابقًا في هذا الدرس العملي. بدلاً من ذلك، يمكنك المتابعة وإنشاء تجاربك الخاصة استنادًا إلى هذا الرمز. نتشوّق لرؤية ما ستنشئه.
للبدء في ذلك، اتبع الخطوات التالية:
- احذف كل شيء في الدليل
lib/widgets
. ستنشئ أدوات جديدة ولامعة للعبتك. وهي تستند إلى حد كبير إلى الأدوات القديمة.
- عدِّل ملف
model.dart
لتعديل طريقةaddWord
فيCrossword
على النحو التالي:
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
هي الاستراتيجيات المتّبعة لتجاهل تكلفة إنشاء 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,
),
),
),
);
}
}
يجب أن يكون هذا الإجراء مألوفًا أيضًا. يكمن الاختلاف الأساسي في أنّه بدلاً من عرض أحرف الكلمات التي يتم إنشاؤها، يتم الآن عرض حرف يونيكود للإشارة إلى وجود حرف غير معروف. يمكن تحسين المظهر الجمالي لهذا التطبيق.
- أنشئ ملف
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.
لقد أنشأت أداة لإنشاء الكلمات المتقاطعة تحوّلت إلى لعبة ألغاز. لقد أتقنت تنفيذ العمليات الحسابية في الخلفية في مجموعة من العمليات المعزولة. استخدمت هياكل بيانات غير قابلة للتغيير لتسهيل تنفيذ خوارزمية التراجع. وقد أمضيت وقتًا ممتعًا مع TableView
، ما سيفيدك في المرة القادمة التي تحتاج فيها إلى عرض بيانات جدولية.