1. ก่อนเริ่มต้น
ลองจินตนาการว่าเขาสามารถเป็นปริศนาครอสเวิร์ดที่ใหญ่ที่สุดในโลกได้ไหม คุณจำเทคนิค AI บางอย่างที่เคยเรียนที่โรงเรียนและสงสัยว่าจะใช้ Flutter เพื่อสำรวจตัวเลือกอัลกอริทึมเพื่อสร้างวิธีแก้ปัญหาด้านการคำนวณที่ซับซ้อนได้หรือไม่
ใน Codelab นี้ คุณก็ทำแบบนี้ได้เลย ในตอนจบ คุณจะได้สร้างเครื่องมือเพื่อให้ใช้อัลกอริทึมสำหรับสร้างปริศนาแบบตารางคำศัพท์ ปริศนาอักษรไขว้ที่ถูกต้องมีอยู่มากมายหลายแบบด้วยกัน และเทคนิคเหล่านี้จะช่วยคุณสร้างปริศนาที่เหมาะกับคำจำกัดความของคุณ
เมื่อมีเครื่องมือนี้เป็นฐานแล้ว คุณก็สามารถต่อจิ๊กซอว์ครอสเวิร์ดโดยใช้โปรแกรมสร้างเกมครอสเวิร์ดเพื่อสร้างปริศนาให้ผู้ใช้ไขได้ ปริศนานี้ใช้งานได้ใน Android, iOS, Windows, macOS และ Linux จะเปิดให้ใน Android นะ
ข้อกำหนดเบื้องต้น
- การทำ Codelab ของแอป Flutter แรกของคุณเสร็จสมบูรณ์แล้ว
สิ่งที่ได้เรียนรู้
- วิธีใช้ Isolated เพื่อทำงานด้านการคำนวณที่มีค่าใช้จ่ายสูงโดยไม่ขัดขวางการวนแสดงผลของ Flutter ด้วยการรวมฟังก์ชัน
compute
ของ Flutter และselect
สร้างความสามารถในการแคชค่าของตัวกรองอีกครั้ง - วิธีใช้ประโยชน์จากโครงสร้างข้อมูลที่เปลี่ยนแปลงไม่ได้ด้วย
built_value
และbuilt_collection
เพื่อทำให้เทคนิค Good Old Fashioned AI (GOFAI) ที่อิงตามการค้นหา เช่น การค้นหาแบบเน้นข้อมูลเชิงลึกและการย้อนกลับ ใช้งานได้ง่าย - วิธีใช้ความสามารถของแพ็กเกจ
two_dimensional_scrollables
เพื่อแสดงข้อมูลตารางกริดอย่างง่ายและรวดเร็ว
สิ่งที่ต้องมี
- Flutter SDK
- โค้ด Visual Studio (โค้ด VS) กับปลั๊กอิน Flutter และ Dart
- ซอฟต์แวร์คอมไพเลอร์สำหรับเป้าหมายการพัฒนาที่คุณเลือก Codelab นี้ใช้งานได้กับทุกแพลตฟอร์มเดสก์ท็อป Android และ iOS คุณต้องใช้ VS Code เพื่อกำหนดเป้าหมาย Windows, Xcode เพื่อกำหนดเป้าหมายเป็น macOS หรือ iOS และใช้ Android Studio เพื่อกำหนดเป้าหมายเป็น Android
2. สร้างโปรเจ็กต์
สร้างโปรเจ็กต์ Flutter แรก
- เปิด VS Code
- ในบรรทัดคำสั่ง ให้ป้อน Flutter new แล้วเลือก Flutter: New Project ในเมนู
- เลือกแอปพลิเคชันว่าง แล้วเลือกไดเรกทอรีที่จะสร้างโปรเจ็กต์ ไดเรกทอรีนี้ควรเป็นไดเรกทอรีที่ไม่ต้องใช้สิทธิ์ในระดับสูงขึ้นหรือมีพื้นที่ว่างในเส้นทาง ตัวอย่างเช่น ไดเรกทอรีหน้าแรกหรือ
C:\src\
- ตั้งชื่อโปรเจ็กต์ของคุณว่า
generate_crossword
ส่วนที่เหลือของ Codelab จะถือว่าคุณตั้งชื่อแอปว่าgenerate_crossword
ตอนนี้ Flutter จะสร้างโฟลเดอร์โปรเจ็กต์และ VS Code จะเปิดขึ้น คุณจะเขียนทับเนื้อหาของไฟล์ 2 ไฟล์ด้วยโครงข่ายพื้นฐานของแอป
คัดลอกและวางแอปเริ่มต้น
- คลิก Explorer ในแผงด้านซ้ายของ VS Code แล้วเปิดไฟล์
pubspec.yaml
- โดยแทนที่เนื้อหาของไฟล์นี้ด้วยข้อมูลต่อไปนี้
pubspec.yaml
name: generate_crossword
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.3 <4.0.0'
dependencies:
built_collection: ^5.1.1
built_value: ^8.9.2
characters: ^1.3.0
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
intl: ^0.19.0
riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
two_dimensional_scrollables: ^0.2.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
build_runner: ^2.4.9
built_value_generator: ^8.9.2
custom_lint: ^0.6.4
riverpod_generator: ^2.4.0
riverpod_lint: ^2.3.10
flutter:
uses-material-design: true
ไฟล์ pubspec.yaml
ระบุข้อมูลพื้นฐานเกี่ยวกับแอป เช่น เวอร์ชันปัจจุบันและทรัพยากร Dependency คุณจะเห็นคอลเล็กชันทรัพยากร Dependency ที่ไม่ได้เป็นส่วนหนึ่งของแอป Flutter ที่ว่างเปล่าตามปกติ คุณจะได้รับประโยชน์จากแพ็กเกจเหล่านี้ทั้งหมดในเร็วๆ นี้
- เปิดไฟล์
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(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: Scaffold(
body: Center(
child: Text(
'Hello, World!',
style: TextStyle(fontSize: 24),
),
),
),
),
),
);
}
- เรียกใช้โค้ดนี้เพื่อตรวจสอบว่าทุกอย่างทำงานได้ โดยควรแสดงหน้าต่างใหม่พร้อมวลีเริ่มต้นที่บังคับใช้ของทุกโปรเจ็กต์ใหม่ในทุกที่ มี
ProviderScope
ซึ่งบ่งชี้ว่าแอปนี้จะใช้riverpod
สำหรับการจัดการรัฐ
3. เพิ่มคำ
องค์ประกอบที่ใช้สร้างสรรค์ปริศนาอักษรไขว้
ปริศนาอักษรไขว้คือรายการคำซึ่งมีอยู่ในหัวใจ คำถูกจัดเรียงเป็นรูปตาราง ชิดขวาง บางส่วน ลดบางส่วน จนทำให้คำสอดประสานกัน การแก้โจทย์คำเพียงคำเดียวจะให้เบาะแสเกี่ยวกับคำที่ข้ามคำแรกนั้น ดังนั้น องค์ประกอบพื้นฐานแรกจะต้องเป็นรายการคำ
แหล่งข้อมูลที่ดีของคำเหล่านี้คือหน้า Natural Language Corpus Data ของ Peter Norvig รายการ SOWPODS เป็นจุดเริ่มต้นที่มีประโยชน์ โดยมี 267,750 คำ
ในขั้นตอนนี้ คุณจะต้องดาวน์โหลดรายการคำ เพิ่มเป็นชิ้นงานในแอป Flutter และจัดเตรียมผู้ให้บริการ Riverpod ให้โหลดรายการลงในแอปเมื่อเริ่มต้นใช้งาน
ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:
- แก้ไขไฟล์
pubspec.yaml
ของโปรเจ็กต์เพื่อเพิ่มการประกาศชิ้นงานต่อไปนี้สำหรับรายการคําที่เลือก ข้อมูลนี้แสดงเฉพาะโครงสร้างที่กระจัดกระจายของการกำหนดค่าแอป เนื่องจากส่วนที่เหลือยังคงเหมือนเดิม
pubspec.yaml
flutter:
uses-material-design: true
assets: // Add this line
- assets/words.txt // And this one.
โปรแกรมแก้ไขของคุณอาจไฮไลต์บรรทัดสุดท้ายพร้อมคำเตือนเนื่องจากคุณยังไม่ได้สร้างไฟล์นี้
- โดยใช้เบราว์เซอร์และเครื่องมือแก้ไข ให้สร้างไดเรกทอรี
assets
ที่ระดับบนสุดของโปรเจ็กต์และสร้างไฟล์words.txt
ในไฟล์โดยประกอบด้วยรายการคำรายการใดรายการหนึ่งที่ลิงก์ไว้ข้างต้น
โค้ดนี้ได้รับการออกแบบตามรายการ SOWPODS ที่กล่าวถึงข้างต้น แต่ควรใช้งานกับรายการคำใดๆ ที่มีเฉพาะอักขระ A-Z เท่านั้น การขยายฐานของโค้ดนี้ให้ทำงานกับชุดอักขระที่ต่างกันถือเป็นแบบฝึกหัดสำหรับผู้อ่าน
โหลดคำ
ในการเขียนโค้ดสำหรับโหลดรายการคำเมื่อเริ่มต้นแอป ให้ทำตามขั้นตอนต่อไปนี้
- สร้างไฟล์
providers.dart
ในไดเรกทอรีlib
- เพิ่มข้อมูลต่อไปนี้ลงในไฟล์
lib/providers.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
นี่คือผู้ให้บริการ Riverpod รายแรกสำหรับ Codebase นี้ คุณจะสังเกตเห็นว่ามีหลายส่วนที่ผู้แก้ไขของคุณจะบ่นว่าเป็นคลาสที่ไม่ได้กำหนดหรือเป้าหมายที่ยังไม่สร้างขึ้น โปรเจ็กต์นี้ใช้การสร้างโค้ดสำหรับทรัพยากร Dependency ต่างๆ หลายรายการ รวมถึง 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
ที่คุณกำหนดไว้ข้างต้นแบบ Lazy Loading แต่สำหรับจุดประสงค์ของแอปนี้ คุณจะต้องโหลดรายการคำให้ตั้งใจ เอกสารประกอบของ 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;
}
}
ไฟล์นี้น่าสนใจจาก 2 ทิศทางที่แยกกัน อย่างแรกคือวิดเจ็ต _EagerInitialization
ซึ่งมีเป้าหมายเพียงอย่างเดียวคือกำหนดให้ผู้ให้บริการ wordList
ที่คุณสร้างไว้ข้างต้นโหลดรายการคำ วิดเจ็ตนี้บรรลุวัตถุประสงค์นี้ด้วยการฟังผู้ให้บริการโดยใช้การโทรของ ref.watch()
อ่านข้อมูลเพิ่มเติมเกี่ยวกับเทคนิคนี้ได้ในเอกสารประกอบของ Riverpod เรื่องการเริ่มต้นผู้ให้บริการแบบตั้งใจ
ประเด็นที่น่าสนใจข้อที่ 2 ที่ควรทราบในไฟล์นี้คือวิธีที่ Riverpod จัดการเนื้อหาแบบไม่พร้อมกัน คุณอาจจำได้ว่าผู้ให้บริการ wordList
จัดเป็นฟังก์ชันอะซิงโครนัส เนื่องจากการโหลดเนื้อหาจากดิสก์ทำได้ช้า เมื่อดูผู้ให้บริการรายการคำในโค้ดนี้ คุณจะได้รับ AsyncValue<BuiltSet<String>>
AsyncValue
ของประเภทดังกล่าวเป็นอะแดปเตอร์ระหว่างโลกแบบอะซิงโครนัสของผู้ให้บริการกับโลกแบบซิงโครนัสของเมธอด build
ของวิดเจ็ต
เมธอด when
ของ AsyncValue
จะรองรับเงื่อนไขที่เป็นไปได้ 3 อย่างที่อาจอยู่ในค่าในอนาคต ในอนาคตอาจได้รับการแก้ไขเรียบร้อยแล้ว ซึ่งในกรณีนี้อาจมีการเรียกใช้ Callback ของ data
อาจอยู่ในสถานะข้อผิดพลาด ซึ่งในกรณีนี้มีการเรียก error
หรือสุดท้ายก็อาจจะยังคงโหลดอยู่ ประเภทการคืนสินค้าของ Callback ทั้ง 3 รายการต้องมีประเภทการแสดงผลที่เข้ากันได้ เนื่องจากเมธอด 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(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordGeneratorApp(), // Remove what was here and replace
),
),
);
}
- รีสตาร์ทแอป คุณจะเห็นรายการแบบเลื่อนที่ต่อเนื่องไปเกือบตลอดกาล
4. แสดงคำในตารางกริด
ในขั้นตอนนี้ คุณจะต้องสร้างโครงสร้างข้อมูลสำหรับการสร้างปริศนาอักษรไขว้โดยใช้แพ็กเกจ built_value
และ built_collection
แพ็กเกจทั้งสองนี้ทำให้สามารถสร้างโครงสร้างข้อมูลเป็นค่าที่เปลี่ยนแปลงไม่ได้ ซึ่งจะเป็นประโยชน์สำหรับทั้งการส่งข้อมูลระหว่าง Isolates ได้อย่างง่ายดาย และทำให้การใช้การค้นหาครั้งแรกแบบเจาะลึกและการย้อนกลับได้ง่ายดายขึ้นมาก
ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:
- สร้างไฟล์
model.dart
ในไดเรกทอรีlib
แล้วเพิ่มเนื้อหาต่อไปนี้ลงในไฟล์
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
static Serializer<Location> get serializer => _$locationSerializer;
/// The horizontal part of the location. The location is 0 based.
int get x;
/// The vertical part of the location. The location is 0 based.
int get y;
/// Returns a new location that is one step to the left of this location.
Location get left => rebuild((b) => b.x = x - 1);
/// Returns a new location that is one step to the right of this location.
Location get right => rebuild((b) => b.x = x + 1);
/// Returns a new location that is one step up from this location.
Location get up => rebuild((b) => b.y = y - 1);
/// Returns a new location that is one step down from this location.
Location get down => rebuild((b) => b.y = y + 1);
/// Returns a new location that is [offset] steps to the left of this location.
Location leftOffset(int offset) => rebuild((b) => b.x = x - offset);
/// Returns a new location that is [offset] steps to the right of this location.
Location rightOffset(int offset) => rebuild((b) => b.x = x + offset);
/// Returns a new location that is [offset] steps up from this location.
Location upOffset(int offset) => rebuild((b) => b.y = y - offset);
/// Returns a new location that is [offset] steps down from this location.
Location downOffset(int offset) => rebuild((b) => b.y = y + offset);
/// Pretty print a location as a (x,y) coordinate.
String prettyPrint() => '($x,$y)';
/// Returns a new location built from [updates]. Both [x] and [y] are
/// required to be non-null.
factory Location([void Function(LocationBuilder)? updates]) = _$Location;
Location._();
/// Returns a location at the given coordinates.
factory Location.at(int x, int y) {
return Location((b) {
b
..x = x
..y = y;
});
}
}
/// The direction of a word in a crossword.
enum Direction {
across,
down;
@override
String toString() => name;
}
/// A word in a crossword. This is a word at a location in a crossword, in either
/// the across or down direction.
abstract class CrosswordWord
implements Built<CrosswordWord, CrosswordWordBuilder> {
static Serializer<CrosswordWord> get serializer => _$crosswordWordSerializer;
/// The word itself.
String get word;
/// The location of this word in the crossword.
Location get location;
/// The direction of this word in the crossword.
Direction get direction;
/// Compare two CrosswordWord by coordinates, x then y.
static int locationComparator(CrosswordWord a, CrosswordWord b) {
final compareRows = a.location.y.compareTo(b.location.y);
final compareColumns = a.location.x.compareTo(b.location.x);
return switch (compareColumns) { 0 => compareRows, _ => compareColumns };
}
/// Constructor for [CrosswordWord].
factory CrosswordWord.word({
required String word,
required Location location,
required Direction direction,
}) {
return CrosswordWord((b) => b
..word = word
..direction = direction
..location.replace(location));
}
/// Constructor for [CrosswordWord].
/// Use [CrosswordWord.word] instead.
factory CrosswordWord([void Function(CrosswordWordBuilder)? updates]) =
_$CrosswordWord;
CrosswordWord._();
}
/// A character in a crossword. This is a single character at a location in a
/// crossword. It may be part of an across word, a down word, both, but not
/// neither. The neither constraint is enforced elsewhere.
abstract class CrosswordCharacter
implements Built<CrosswordCharacter, CrosswordCharacterBuilder> {
static Serializer<CrosswordCharacter> get serializer =>
_$crosswordCharacterSerializer;
/// The character at this location.
String get character;
/// The across word that this character is a part of.
CrosswordWord? get acrossWord;
/// The down word that this character is a part of.
CrosswordWord? get downWord;
/// Constructor for [CrosswordCharacter].
/// [acrossWord] and [downWord] are optional.
factory CrosswordCharacter.character({
required String character,
CrosswordWord? acrossWord,
CrosswordWord? downWord,
}) {
return CrosswordCharacter((b) {
b.character = character;
if (acrossWord != null) {
b.acrossWord.replace(acrossWord);
}
if (downWord != null) {
b.downWord.replace(downWord);
}
});
}
/// Constructor for [CrosswordCharacter].
/// Use [CrosswordCharacter.character] instead.
factory CrosswordCharacter(
[void Function(CrosswordCharacterBuilder)? updates]) =
_$CrosswordCharacter;
CrosswordCharacter._();
}
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Add a word to the crossword at the given location and direction.
Crossword addWord({
required Location location,
required String word,
required Direction direction,
}) {
return rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width, (_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
])
final Serializers serializers = _$serializers;
ไฟล์นี้อธิบายจุดเริ่มต้นของโครงสร้างข้อมูลที่คุณจะใช้สำหรับการสร้างปริศนาอักษรไขว้ เกมปริศนาอักษรไขว้คือรายการคำแนวนอนและแนวตั้งที่เรียงต่อกันเป็นตาราง ในการใช้โครงสร้างข้อมูลนี้ ให้คุณสร้าง Crossword
ของขนาดที่เหมาะสมด้วยตัวสร้างที่มีชื่อ Crossword.crossword
แล้วเพิ่มคำโดยใช้เมธอด addWord
โดยเมธอด _fillCharacters
จะสร้างตารางกริดของ CrosswordCharacter
ขึ้นเป็นส่วนหนึ่งของการสร้างค่าที่สรุป
หากต้องการใช้โครงสร้างข้อมูลนี้ ให้ทำตามขั้นตอนต่อไปนี้
- สร้างไฟล์
utils
ในไดเรกทอรีlib
แล้วเพิ่มเนื้อหาต่อไปนี้ลงในไฟล์
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
นี่เป็นส่วนขยายบน BuiltSet
ที่ช่วยให้เรียกข้อมูลองค์ประกอบแบบสุ่มของชุดได้อย่างง่ายดาย การใช้ส่วนขยายช่วยให้ขยายชั้นเรียนด้วยฟังก์ชันอื่นๆ ได้อย่างง่ายดาย ต้องตั้งชื่อส่วนขยายเพื่อทำให้ส่วนขยายพร้อมใช้งานนอกไฟล์ utils.dart
- ในไฟล์
lib/providers.dart
ให้เพิ่มการนำเข้าต่อไปนี้
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart'; // Add this import
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model; // And this import
import 'utils.dart'; // And this one
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
การนำเข้าเหล่านี้จะแสดงโมเดลที่กำหนดไว้ข้างต้นแก่ผู้ให้บริการที่คุณกำลังจะสร้าง การนำเข้า dart:math
รวมอยู่สำหรับ Random
รวมการนำเข้า flutter/foundation.dart
สำหรับ debugPrint
, model.dart
สำหรับโมเดล และ 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(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
crossword = crossword.addWord(
word: word, direction: direction, location: location);
yield crossword;
await Future.delayed(Duration(milliseconds: 100));
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
การเปลี่ยนแปลงเหล่านี้จะเพิ่มผู้ให้บริการ 2 รายในแอป ค่าแรกคือ Size
ซึ่งเป็นตัวแปรร่วมที่มีประสิทธิภาพที่มีค่าที่เลือกไว้ในปัจจุบันของการแจกแจง CrosswordSize
ซึ่งจะทำให้ UI สามารถแสดงและกำหนดขนาดของอักษรไขว้ที่อยู่ระหว่างการสร้างได้ ผู้ให้บริการรายที่ 2 ที่ชื่อว่า crossword
เป็นผลงานที่น่าสนใจมากขึ้น เป็นฟังก์ชันที่แสดงผลชุดของ Crossword
โดยสร้างโดยใช้การรองรับเครื่องกำเนิดไฟฟ้าของ Dart ตามเครื่องหมาย async*
ในฟังก์ชัน ซึ่งหมายความว่าแทนที่จะสิ้นสุดด้วยการส่งคืนผลลัพธ์ จะได้ผลลัพธ์เป็น Crossword
หลายชุด ซึ่งช่วยให้เขียนการคํานวณที่แสดงผลการค้นหาระดับกลางได้ง่ายขึ้น
เนื่องจากมีการเรียกใช้ ref.watch
2 คู่ที่จุดเริ่มต้นของฟังก์ชันผู้ให้บริการ crossword
ระบบ Riverpod จึงจะรีสตาร์ทสตรีมของ Crossword
s ทุกครั้งที่ขนาดที่เลือกของอักษรไขว้เปลี่ยนแปลงและเมื่อรายการคำโหลดเสร็จ
ตอนนี้คุณมีโค้ดสำหรับสร้างปริศนาอักษรไขว้แล้ว แม้จะมีคำแบบสุ่มอยู่มากมาย แต่ก็เป็นการดีที่จะให้แสดงคำเหล่านั้นกับผู้ใช้เครื่องมือ
- สร้างไฟล์
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
แต่ละเซลล์จะมีวิดเจ็ต Consumer
อยู่ในแผนผัง Widget
ที่แสดงผล ซึ่งทำหน้าที่เป็นขอบเขตการรีเฟรช ทุกอย่างในวิดเจ็ต Consumer
จะสร้างขึ้นใหม่เมื่อค่าที่ ref.watch
แสดงผลมีการเปลี่ยนแปลง คุณอาจอยากสร้างแผนผังต้นไม้ทั้งหมดขึ้นใหม่ทุกครั้งที่ Crossword
เปลี่ยนแปลง แต่วิธีนี้ทำให้เกิดการคำนวณจำนวนมาก ซึ่งคุณสามารถข้ามได้โดยใช้การตั้งค่านี้
หากดูพารามิเตอร์ของ ref.watch
คุณจะเห็นว่ามีชั้นป้องกันการคำนวณเลย์เอาต์ใหม่อีกชั้นหนึ่งโดยใช้ crosswordProvider.select
ซึ่งหมายความว่า ref.watch
จะทริกเกอร์การสร้างเนื้อหาของ TableViewCell
ใหม่ต่อเมื่ออักขระที่เซลล์รับผิดชอบในการแสดงผลมีการเปลี่ยนแปลงเท่านั้น ซึ่งการลดการแสดงผลซ้ำนี้เป็นส่วนสำคัญที่ทำให้ UI ปรับเปลี่ยนตามอุปกรณ์อยู่เสมอ
หากต้องการแสดงผู้ให้บริการ CrosswordWidget
และ Size
ต่อผู้ใช้ ให้เปลี่ยนไฟล์ crossword_generator_app.dart
ดังนี้
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_widget.dart'; // Add this import
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()], // Add this line
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: CrosswordWidget(), // Replaces everything that was here before
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget { // Add from here
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
); // To here.
}
มีบางสิ่งเปลี่ยนแปลงที่นี่ ประการแรก โค้ดที่ทำหน้าที่แสดงผล wordList
เป็น ListView
ถูกแทนที่ด้วยการเรียก CrosswordWidget
ที่กำหนดไว้ในไฟล์ก่อนหน้า การเปลี่ยนแปลงสำคัญอีกอย่างคือจุดเริ่มต้นของเมนูสำหรับเปลี่ยนลักษณะการทำงานของแอป โดยเริ่มจากการเปลี่ยนขนาดของอักษรไขว้ เราจะเพิ่มMenuItemButton
อื่นๆ อีกในขั้นตอนต่อๆ ไป เรียกใช้แอป คุณจะเห็นบางสิ่งดังนี้
มีอักขระที่แสดงในตารางกริดและเมนูที่ให้ผู้ใช้เปลี่ยนขนาดของตารางได้ แต่ข้อความไม่ได้เรียงออกมาเหมือนปริศนาอักษรไขว้ นี่เป็นผลมาจากการไม่บังคับใช้ข้อจำกัดใดๆ ในการเพิ่มคำลงในปริศนาอักษรไขว้ พูดง่ายๆ ก็คืองานยุ่ง สิ่งที่คุณจะเริ่มนำมาควบคุมได้ในขั้นตอนถัดไป
5. บังคับใช้ข้อจำกัด
เป้าหมายของขั้นตอนนี้คือการเพิ่มโค้ดไปยังโมเดลเพื่อบังคับใช้ข้อจำกัดแบบครอสเวิร์ด เกมไขปัญหาครอสเวิร์ดมีหลายประเภท และรูปแบบที่ Codelab นี้จะบังคับใช้ตามธรรมเนียมเกมไขปัญหาครอสเวิร์ดภาษาอังกฤษ การปรับเปลี่ยนโค้ดนี้เพื่อสร้างปริศนาอักษรไขว้รูปแบบอื่นๆ เป็นแบบฝึกหัดสำหรับผู้อ่านเช่นเคย
ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:
- เปิดไฟล์
model.dart
และแทนที่โมเดลCrossword
ด้วยข้อมูลต่อไปนี้
lib/model.dart
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword,
/// or checking the proposed solution.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Checks if this crossword is valid.
bool get valid {
// Check that there are no duplicate words.
final wordSet = words.map((word) => word.word).toBuiltSet();
if (wordSet.length != words.length) {
return false;
}
for (final MapEntry(key: location, value: character)
in characters.entries) {
// All characters must be a part of an across or down word.
if (character.acrossWord == null && character.downWord == null) {
return false;
}
// All characters must be within the crossword puzzle.
// No drawing outside the lines.
if (location.x < 0 ||
location.y < 0 ||
location.x >= width ||
location.y >= height) {
return false;
}
// Characters above and below this character must be related
// by a vertical word
if (characters[location.up] case final up?) {
if (character.downWord == null) {
return false;
}
if (up.downWord != character.downWord) {
return false;
}
}
if (characters[location.down] case final down?) {
if (character.downWord == null) {
return false;
}
if (down.downWord != character.downWord) {
return false;
}
}
// Characters to the left and right of this character must be
// related by a horizontal word
final left = characters[location.left];
if (left != null) {
if (character.acrossWord == null) {
return false;
}
if (left.acrossWord != character.acrossWord) {
return false;
}
}
final right = characters[location.right];
if (right != null) {
if (character.acrossWord == null) {
return false;
}
if (right.acrossWord != character.acrossWord) {
return false;
}
}
}
return true;
}
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
if (words.isNotEmpty && !overlap) {
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width, (_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
โปรดอย่าลืมว่าการเปลี่ยนแปลงที่คุณทำในไฟล์ model.dart
และ providers.dart
จะต้องเรียกใช้ build_runner
เพื่ออัปเดตไฟล์ model.g.dart
และ providers.g.dart
ที่เกี่ยวข้อง หากไฟล์เหล่านี้ไม่ได้อัปเดตตัวเองโดยอัตโนมัติ ตอนนี้ก็เป็นโอกาสดีที่จะเริ่มต้น 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;
},
);
}
- เรียกใช้แอป แทบไม่มีสิ่งใดเกิดขึ้นใน UI แต่มีอะไรเกิดขึ้นมากมายถ้าคุณดูบันทึก
ถ้าคุณคิดว่าเกิดอะไรขึ้นที่นี่ เราจะเห็นอักษรไขว้ปรากฏขึ้นโดยบังเอิญ เมธอด addWord
ในรูปแบบ Crossword
จะปฏิเสธคำที่เสนอซึ่งไม่เหมาะกับคำไขว้ปัจจุบัน ดังนั้นจึงเป็นเรื่องน่ามหัศจรรย์ที่เราเห็นทุกอย่างปรากฏขึ้น
เพื่อเป็นการเตรียมพร้อมสำหรับความมีระเบียบมากขึ้นในการเลือกคำที่จะลองใช้ในส่วนไหน การย้ายการคำนวณนี้ออกจากเธรด UI และการไปแยกไว้เบื้องหลังจะช่วยได้มาก Flutter มี Wrapper ที่มีประโยชน์อย่างยิ่งสำหรับการรวบรวมงานบางส่วนและเรียกใช้ในเบื้องหลัง ซึ่งก็คือฟังก์ชัน compute
- ในไฟล์
providers.dart
ให้แก้ไขผู้ให้บริการคำไขว้ดังนี้
lib/providers.dart
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
try {
var candidate = await compute( // Edit from here.
((String, model.Direction, model.Location) wordToAdd) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word, direction: direction, location: location);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
โค้ดนี้ใช้งานได้ อย่างไรก็ตาม แท็กดังกล่าวมีกับดัก หากยังคงเดินตามเส้นทางนี้ ท้ายที่สุดแล้วจะมีข้อผิดพลาดที่บันทึกไว้ดังตัวอย่างต่อไปนี้
flutter: Error running isolate: Invalid argument(s): Illegal argument in isolate message: object is unsendable - Library:'dart:async' Class: _Future@4048458 (see restrictions listed at `SendPort.send()` documentation for more information) flutter: <- Instance of 'AutoDisposeStreamProviderElement<Crossword>' (from package:riverpod/src/stream_provider.dart) flutter: <- Context num_variables: 2 <- Context num_variables: 1 parent:{ Context num_variables: 2 } flutter: <- Context num_variables: 1 parent:{ Context num_variables: 1 parent:{ Context num_variables: 2 } } flutter: <- Closure: () => Crossword? (from package:generate_crossword/providers.dart)
นี่เป็นผลมาจากการปิดที่ compute
ส่งมอบให้กับพื้นหลังโดยแยกปิดผู้ให้บริการ ซึ่งส่งผ่าน SendPort.send()
ไม่ได้ วิธีแก้ไขอย่างหนึ่งคือตรวจสอบว่าไม่มีข้อมูลการปิดระบบที่ส่งข้อความไม่ได้
ขั้นตอนแรกคือการแยกผู้ให้บริการออกจากรหัส "แยก"
- สร้างไฟล์
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_annotation/riverpod_annotation.dart';
import 'isolates.dart'; // Add this import
import 'model.dart' as model;
// Drop the utils.dart import
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
// Drop the _random instance
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = // Edit from here
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyCrossword;
},
loading: () async* {
yield emptyCrossword; // To here.
},
);
}
คราวนี้คุณก็จะได้มีเครื่องมือสร้างปริศนาอักษรไขว้ขนาดต่างๆ กัน โดยมี compute
ในการไขปริศนาที่เกิดขึ้นในเบื้องหลัง คราวนี้ถ้ามีเพียงโค้ดที่จะมีประสิทธิภาพมากขึ้นเมื่อต้องตัดสินใจว่าจะลองเพิ่มคำใดลงในปริศนาอักษรไขว้
6. จัดการคิวงาน
ส่วนหนึ่งของปัญหาของโค้ดตามที่เห็นคือ ปัญหาที่กำลังแก้ไขคือปัญหาด้านการค้นหาอย่างมีประสิทธิภาพ และวิธีแก้ปัญหาในปัจจุบันคือการค้นหาแบบบอด หากโค้ดมุ่งไปที่การค้นหาคำที่จะแนบไปกับคำปัจจุบัน แทนที่จะพยายามสุ่มวางคำตรงไหนก็ได้ในตาราง ระบบจะหาวิธีแก้ปัญหาได้เร็วขึ้น วิธีการก็คือการแนะนำคิวงานของสถานที่ตั้งเพื่อพยายามค้นหาคำ
ปัจจุบันโค้ดจะสร้างโซลูชันที่รอการพิจารณา ตรวจสอบว่าโซลูชันที่รอการพิจารณานั้นถูกต้องหรือไม่ และขึ้นอยู่กับว่าระบบรวมโซลูชันที่รอการพิจารณาไว้หรือทิ้งไป ทั้งนี้ขึ้นอยู่กับความถูกต้อง นี่คือตัวอย่างการใช้งานจากกลุ่มอัลกอริทึมย้อนหลัง การติดตั้งใช้งานนี้ง่ายขึ้นมากเมื่อ built_value
และ built_collection
ทําให้สร้างค่าที่เปลี่ยนแปลงไม่ได้ใหม่ซึ่งได้มา ดังนั้นจึงแชร์สถานะทั่วไปกับค่าที่เปลี่ยนแปลงไม่ได้ซึ่งได้มา ทำให้สามารถแสวงหาผลประโยชน์ราคาถูกจากผู้มีโอกาสเป็นผู้สมัครโดยไม่ต้องเปลืองหน่วยความจำสำหรับการคัดลอกแบบละเอียด
ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:
- เปิดไฟล์
model.dart
และเพิ่มคำจำกัดความWorkQueue
ต่อไปนี้ลงในไฟล์
lib/model.dart
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
// Add from here
/// A work queue for a worker to process. The work queue contains a crossword
/// and a list of locations to try, along with candidate words to add to the
/// crossword.
abstract class WorkQueue implements Built<WorkQueue, WorkQueueBuilder> {
static Serializer<WorkQueue> get serializer => _$workQueueSerializer;
/// The crossword the worker is working on.
Crossword get crossword;
/// The outstanding queue of locations to try.
BuiltMap<Location, Direction> get locationsToTry;
/// Known bad locations.
BuiltSet<Location> get badLocations;
/// The list of unused candidate words that can be added to this crossword.
BuiltSet<String> get candidateWords;
/// Returns true if the work queue is complete.
bool get isCompleted => locationsToTry.isEmpty || candidateWords.isEmpty;
/// Create a work queue from a crossword.
static WorkQueue from({
required Crossword crossword,
required Iterable<String> candidateWords,
required Location startLocation,
}) =>
WorkQueue((b) {
if (crossword.words.isEmpty) {
// Strip candidate words too long to fit in the crossword
b.candidateWords.addAll(candidateWords
.where((word) => word.characters.length <= crossword.width));
b.crossword.replace(crossword);
b.locationsToTry.addAll({startLocation: Direction.across});
} else {
// Assuming words have already been stripped to length
b.candidateWords.addAll(
candidateWords.toBuiltSet().rebuild(
(b) => b.removeAll(crossword.words.map((word) => word.word))),
);
b.crossword.replace(crossword);
crossword.characters
.rebuild((b) => b.removeWhere((location, character) {
if (character.acrossWord != null &&
character.downWord != null) {
return true;
}
final left = crossword.characters[location.left];
if (left != null && left.downWord != null) return true;
final right = crossword.characters[location.right];
if (right != null && right.downWord != null) return true;
final up = crossword.characters[location.up];
if (up != null && up.acrossWord != null) return true;
final down = crossword.characters[location.down];
if (down != null && down.acrossWord != null) return true;
return false;
}))
.forEach((location, character) {
b.locationsToTry.addAll({
location: switch ((character.acrossWord, character.downWord)) {
(null, null) =>
throw StateError('Character is not part of a word'),
(null, _) => Direction.across,
(_, null) => Direction.down,
(_, _) => throw StateError('Character is part of two words'),
}
});
});
}
});
WorkQueue remove(Location location) => rebuild((b) => b
..locationsToTry.remove(location)
..badLocations.add(location));
/// Update the work queue from a crossword derived from the current crossword
/// that this work queue is built from.
WorkQueue updateFrom(final Crossword crossword) => WorkQueue.from(
crossword: crossword,
candidateWords: candidateWords,
startLocation: locationsToTry.isNotEmpty
? locationsToTry.keys.first
: Location.at(0, 0),
).rebuild((b) => b
..badLocations.addAll(badLocations)
..locationsToTry
.removeWhere((location, _) => badLocations.contains(location)));
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue, // Add this line
])
final Serializers serializers = _$serializers;
- หากยังมีการยึกยือสีแดงอยู่ในไฟล์นี้หลังจากเพิ่มเนื้อหาใหม่นี้นานกว่า 2-3 วินาที ให้ตรวจสอบว่า
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.
เมธอดส่วนขยายนี้ใช้ประโยชน์จากนิพจน์สวิตช์และการจับคู่รูปแบบจากระเบียน เพื่อเลือกวิธีที่เหมาะสมในการแสดงระยะเวลาที่แตกต่างกันตั้งแต่วินาทีไปจนถึงวัน สำหรับข้อมูลเพิ่มเติมเกี่ยวกับรูปแบบโค้ดนี้ โปรดดู Codelab เกี่ยวกับเจาะลึกรูปแบบและบันทึกของ Dart
- หากต้องการผสานรวมฟังก์ชันใหม่นี้ ให้แทนที่ไฟล์
isolates.dart
เพื่อกำหนดวิธีกำหนดฟังก์ชันexploreCrosswordSolutions
ใหม่ดังนี้
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.characters.contains(target.character))
..shuffle());
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword);
yield crossword;
} else {
workQueue = workQueue.remove(location);
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint('${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}');
}
การเรียกใช้โค้ดนี้จะทำให้แอปมีหน้าตาเหมือนกันบนพื้นผิว แต่ต่างกันตรงที่ระยะเวลาในการค้นหาปริศนาอักษรไขว้ที่เสร็จสมบูรณ์ นี่คือปริศนาอักษรไขว้ขนาด 80 x 44 ที่สร้างขึ้นใน 1 นาที 29 วินาที
แน่นอนว่าคำถามที่เห็นได้ชัดคือ เราจะไปถึงเร็วขึ้นได้หรือไม่ ใช่ เราทำได้
7. แสดงสถิติ
การทำให้อะไรสักอย่างรวดเร็วช่วยให้เห็นสิ่งที่กำลังเกิดขึ้น สิ่งหนึ่งที่ช่วยเราในเรื่องนี้ได้คือการแสดงข้อมูลเกี่ยวกับกระบวนการที่กำลังดำเนินการอยู่ ดังนั้นในตอนนี้ก็ถึงเวลาเพิ่มการใช้เครื่องมือและแสดงข้อมูลดังกล่าวเป็นแผงข้อมูลแบบวางเมาส์เหนือ
ข้อมูลที่คุณจะแสดงจะต้องดึงออกจาก WorkQueue และแสดงใน UI
ขั้นตอนแรกที่มีประโยชน์คือการกำหนดคลาสโมเดลใหม่ที่มีข้อมูลที่คุณต้องการแสดง
ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:
- แก้ไขไฟล์
model.dart
ดังต่อไปนี้เพื่อเพิ่มคลาสDisplayInfo
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
import 'package:intl/intl.dart'; // Add this import
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
- ในตอนท้ายของไฟล์ ให้ทำการเปลี่ยนแปลงต่อไปนี้เพื่อเพิ่มชั้นเรียน
DisplayInfo
lib/model.dart
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
}
// Add from here.
/// Display information for the current state of the crossword solve.
abstract class DisplayInfo implements Built<DisplayInfo, DisplayInfoBuilder> {
static Serializer<DisplayInfo> get serializer => _$displayInfoSerializer;
/// The number of words in the grid.
String get wordsInGridCount;
/// The number of candidate words.
String get candidateWordsCount;
/// The number of locations to explore.
String get locationsToExploreCount;
/// The number of known bad locations.
String get knownBadLocationsCount;
/// The percentage of the grid filled.
String get gridFilledPercentage;
/// Construct a [DisplayInfo] instance from a [WorkQueue].
factory DisplayInfo.from({required WorkQueue workQueue}) {
final gridFilled = (workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height));
final fmt = NumberFormat.decimalPattern();
return DisplayInfo((b) => b
..wordsInGridCount = fmt.format(workQueue.crossword.words.length)
..candidateWordsCount = fmt.format(workQueue.candidateWords.length)
..locationsToExploreCount = fmt.format(workQueue.locationsToTry.length)
..knownBadLocationsCount = fmt.format(workQueue.badLocations.length)
..gridFilledPercentage = '${(gridFilled * 100).toStringAsFixed(2)}%');
}
/// An empty [DisplayInfo] instance.
static DisplayInfo get empty => DisplayInfo((b) => b
..wordsInGridCount = '0'
..candidateWordsCount = '0'
..locationsToExploreCount = '0'
..knownBadLocationsCount = '0'
..gridFilledPercentage = '0%');
factory DisplayInfo([void Function(DisplayInfoBuilder)? updates]) =
_$DisplayInfo;
DisplayInfo._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo, // Add this line.
])
final Serializers serializers = _$serializers;
- แก้ไขไฟล์
isolates.dart
เพื่อแสดงโมเดลWorkQueue
ดังนี้
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({ // Modify this line
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.characters.contains(target.character))
..shuffle());
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword); // Drop the yield crossword;
} else {
workQueue = workQueue.remove(location);
}
yield workQueue; // Add this line.
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint('${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}');
}
เมื่อการแยกพื้นหลังแสดงคิวงานแล้ว ตอนนี้จึงเป็นคำถามว่า จะดึงข้อมูลสถิติจากแหล่งข้อมูลนี้ได้อย่างไรและที่ไหน
- แทนที่ผู้ให้บริการครอสเวิร์ดเดิมด้วยผู้ให้บริการคิวงาน แล้วเพิ่มผู้ให้บริการที่ดึงข้อมูลมาจากสตรีมของผู้ให้บริการคิวงาน ดังนี้
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* { // Modify this provider
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword =
model.Crossword.crossword(width: size.width, height: size.height);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
} // To here.
@Riverpod(keepAlive: true) // Add from here to end of file
class StartTime extends _$StartTime {
@override
DateTime? build() => _start;
DateTime? _start;
void start() {
_start = DateTime.now();
ref.invalidateSelf();
}
}
@Riverpod(keepAlive: true)
class EndTime extends _$EndTime {
@override
DateTime? build() => _end;
DateTime? _end;
void clear() {
_end = null;
ref.invalidateSelf();
}
void end() {
_end = DateTime.now();
ref.invalidateSelf();
}
}
const _estimatedTotalCoverage = 0.54;
@riverpod
Duration expectedRemainingTime(ExpectedRemainingTimeRef ref) {
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final workQueueAsync = ref.watch(workQueueProvider);
return workQueueAsync.when(
data: (workQueue) {
if (startTime == null || endTime != null || workQueue.isCompleted) {
return Duration.zero;
}
try {
final soFar = DateTime.now().difference(startTime);
final completedPercentage = min(
0.99,
(workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height) /
_estimatedTotalCoverage));
final expectedTotal = soFar.inSeconds / completedPercentage;
final expectedRemaining = expectedTotal - soFar.inSeconds;
return Duration(seconds: expectedRemaining.toInt());
} catch (e) {
return Duration.zero;
}
},
error: (error, stackTrace) => Duration.zero,
loading: () => Duration.zero,
);
}
/// A provider that holds whether to display info.
@Riverpod(keepAlive: true)
class ShowDisplayInfo extends _$ShowDisplayInfo {
var _display = true;
@override
bool build() => _display;
void toggle() {
_display = !_display;
ref.invalidateSelf();
}
}
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref.watch(workQueueProvider).when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
ผู้ให้บริการใหม่เป็นการผสมผสานระหว่างรัฐที่ดำเนินธุรกิจทั่วโลกในรูปแบบที่จะวางการแสดงข้อมูลบนตารางครอสเวิร์ดหรือไม่ และข้อมูลที่ได้ เช่น เวลาที่ใช้ในการสร้างปริศนาอักษรไขว้ ทั้งหมดนี้เป็นเรื่องซับซ้อนเพราะการที่ผู้ฟังสถานะนี้บางส่วนเป็นแบบชั่วคราว จะไม่มีการฟังเวลาเริ่มต้นและเวลาสิ้นสุดของการคำนวณครอสเวิร์ดหากการแสดงข้อมูลถูกซ่อนไว้ แต่การแสดงผลข้อมูลจะต้องอยู่ในหน่วยความจำหากการคำนวณถูกต้องเมื่อการแสดงข้อมูลแสดงขึ้น พารามิเตอร์ keepAlive
ของแอตทริบิวต์ Riverpod
มีประโยชน์มากในกรณีนี้
ในการแสดงข้อมูล มีรอยย่นเล็กน้อย เราต้องการแสดงเวลาที่ผ่านไปในปัจจุบันได้ แต่ไม่มีสิ่งใดในที่นี้ที่จะบังคับการอัปเดตอย่างต่อเนื่องของเวลาที่ล่วงไปได้โดยง่าย ย้อนกลับไปที่ Codelab เกี่ยวกับการสร้าง UI รุ่นใหม่ใน 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
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(
right: 32.0,
bottom: 32.0,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16, color: Theme.of(context).colorScheme.primary),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}'),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage),
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining', value: remaining.formatted),
],
),
),
),
),
),
),
);
}
}
class _CrosswordInfoRichText extends StatelessWidget {
final String label;
final String value;
const _CrosswordInfoRichText({required this.label, required this.value});
@override
Widget build(BuildContext context) => RichText(
text: TextSpan(
children: [
TextSpan(
text: '$label ',
style: DefaultTextStyle.of(context).style,
),
TextSpan(
text: value,
style: DefaultTextStyle.of(context)
.style
.copyWith(fontWeight: FontWeight.bold),
),
],
),
);
}
วิดเจ็ตนี้ถือเป็นตัวอย่างที่ดีในการขับเคลื่อนผู้ให้บริการของ Riverpod วิดเจ็ตนี้จะถูกทำเครื่องหมายไว้สำหรับการสร้างใหม่เมื่อผู้ให้บริการใดๆ ใน 5 รายอัปเดต การเปลี่ยนแปลงที่จำเป็นสุดท้ายในขั้นตอนนี้คือการผสานรวมวิดเจ็ตใหม่นี้เข้ากับ UI
- แก้ไขไฟล์
crossword_generator_app.dart
ดังนี้
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_info_widget.dart'; // Add this import
import 'crossword_widget.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer( // Modify from here
builder: (context, ref, child) {
return Stack(
children: [
Positioned.fill(
child: CrosswordWidget(),
),
if (ref.watch(showDisplayInfoProvider)) CrosswordInfoWidget(),
],
);
},
), // To here.
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menu Children: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton( // Add from here
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () =>
ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
), // To here.
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
การเปลี่ยนแปลง 2 รายการข้างต้นนี้แสดงวิธีการผสานรวมผู้ให้บริการที่ต่างกัน ในเมธอด build
ของ CrosswordGeneratorApp
คุณได้แนะนำเครื่องมือสร้าง Consumer
ใหม่เพื่อให้มีพื้นที่ที่บังคับให้สร้างใหม่เมื่อการแสดงข้อมูลแสดงขึ้นหรือซ่อนไว้ ในอีกแง่หนึ่ง เมนูแบบเลื่อนลงทั้งหมดจะเป็น ConsumerWidget
หนึ่งรายการ ซึ่งจะได้รับการสร้างใหม่ ไม่ว่าจะเป็นการปรับขนาดของอักษรไขว้ หรือการแสดงหรือซ่อนการแสดงข้อมูล แนวทางที่ควรใช้ย่อมต้องแลกกับความเรียบง่ายทางวิศวกรรมเสมอเมื่อเทียบกับต้นทุนในการคำนวณเลย์เอาต์ของวิดเจ็ตต้นไม้ที่สร้างใหม่
การเรียกใช้แอปในขณะนี้ช่วยให้ผู้ใช้ได้รับข้อมูลเชิงลึกเกี่ยวกับความคืบหน้าของการสร้างครอสเวิร์ด อย่างไรก็ตาม ในช่วงท้ายของการสร้างปริศนาอักษรไขว้ เราพบว่ามีบางช่วงเวลาที่ตัวเลขมีการเปลี่ยนแปลง แต่ตารางอักขระนั้นมีการเปลี่ยนแปลงน้อยมาก
การได้รับข้อมูลเชิงลึกเพิ่มเติมเกี่ยวกับสิ่งที่เกิดขึ้นและสาเหตุจะเป็นประโยชน์มาก
8. โหลดพร้อมกันกับชุดข้อความ
การทำความเข้าใจว่าทำไมสิ่งต่างๆ ถึงล่าช้าในช่วงท้าย การแสดงภาพว่าอัลกอริทึมกำลังทำอะไรจึงมีประโยชน์ ส่วนสำคัญคือ locationsToTry
ที่โดดเด่นใน WorkQueue
TableView เป็นวิธีที่มีประโยชน์ในการตรวจสอบเรื่องนี้ เราสามารถเปลี่ยนสีเซลล์โดยขึ้นอยู่กับว่าอยู่ใน locationsToTry
หรือไม่
ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:
- แก้ไขไฟล์
crossword_widget.dart
ดังนี้
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch( // Add from here
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
); // To here.
if (character != null) { // Modify from here
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text(character.character),
), // To here.
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
เมื่อคุณเรียกใช้โค้ดนี้ คุณจะเห็นภาพของตำแหน่งคงค้างที่อัลกอริทึมยังไม่ได้ตรวจสอบ
สิ่งที่น่าสนใจในการรับชมเรื่องนี้ขณะที่รูปแบบอักษรไขว้ดำเนินการจนเสร็จสิ้นคือมีคะแนนที่ต้องตรวจสอบเพิ่มเติมซึ่งจะไม่ทำให้ข้อมูลมีประโยชน์ใดๆ ซึ่งมี 2 ตัวเลือกดังต่อไปนี้ หนึ่ง คือการจำกัดการตรวจสอบเมื่อมีการเติมข้อมูลในเซลล์ไขว้ตามที่ระบุ และที่สองคือตรวจสอบจุดที่น่าสนใจหลายจุดในคราวเดียว เส้นทางที่สองฟังดูสนุกกว่า งั้นมาเล่นกันเลย
- แก้ไขไฟล์
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
2 เลเยอร์ เลเยอร์แรกมีหน้าที่ทำฟาร์มแต่ละตำแหน่งเพื่อค้นหาให้คนทำงาน N แยกออกมา จากนั้นจึงรวมผลลัพธ์อีกครั้งเมื่อผู้ปฏิบัติงาน N คนแยกครบถ้วนเสร็จแล้ว เลเยอร์ที่สองประกอบด้วยการแยกผู้ปฏิบัติงาน N การปรับ N เพื่อให้ได้รับประสิทธิภาพที่ดีที่สุดขึ้นอยู่กับทั้งคอมพิวเตอร์ของคุณและข้อมูลที่เป็นปัญหา ยิ่งตารางกริดมีขนาดใหญ่เท่าใด ผู้ปฏิบัติงานก็จะทำงานร่วมกันได้ดีขึ้นโดยไม่ขัดขวางกันและกัน
รอยย่นที่น่าสนใจอย่างหนึ่งคือการบันทึกว่าโค้ดนี้จะจัดการกับปัญหาการปิดซึ่งจับภาพสิ่งที่ไม่ควรจับภาพอย่างไร ปัจจุบันไม่มีการปิดถนน ฟังก์ชัน _generate
และ _generateWorker
กำหนดเป็นฟังก์ชันระดับบนสุดที่ไม่มีสภาพแวดล้อมโดยรอบให้ดึงข้อมูล อาร์กิวเมนต์และผลลัพธ์ของฟังก์ชันทั้งสองนี้อยู่ในรูปแบบของระเบียน Dart นี่เป็นวิธีง่ายๆ ในการจัดการกับค่า 1 ค่าใน 1 ความหมายของการเรียก compute
ตอนนี้คุณสามารถสร้างกลุ่มผู้ปฏิบัติงานที่ทำงานอยู่เบื้องหลังเพื่อค้นหาคำที่เชื่อมกันอยู่ในตารางกริดเพื่อไขปริศนาอักษรไขว้ได้ ก็ถึงเวลาเปิดใช้ความสามารถดังกล่าวในเครื่องมือสร้างครอสเวิร์ดอื่นๆ แล้ว
- แก้ไขไฟล์
providers.dart
โดยแก้ไขผู้ให้บริการ WorkQueue ดังนี้
lib/providers.dart
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* {
final workers = ref.watch(workerCountProvider); // Add this line
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword =
model.Crossword.crossword(width: size.width, height: size.height);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: workers.count, // Add this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
}
- เพิ่มผู้ให้บริการ
WorkerCount
ต่อท้ายไฟล์ดังนี้
lib/providers.dart
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref.watch(workQueueProvider).when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
enum BackgroundWorkers { // Add from here
one(1),
two(2),
four(4),
eight(8),
sixteen(16),
thirtyTwo(32),
sixtyFour(64),
oneTwentyEight(128);
const BackgroundWorkers(this.count);
final int count;
String get label => count.toString();
}
/// A provider that holds the current number of background workers to use.
@Riverpod(keepAlive: true)
class WorkerCount extends _$WorkerCount {
var _count = BackgroundWorkers.four;
@override
BackgroundWorkers build() => _count;
void setCount(BackgroundWorkers count) {
_count = count;
ref.invalidateSelf();
}
} // To here.
เมื่อมีการเปลี่ยนแปลงทั้ง 2 อย่างนี้ ตอนนี้เลเยอร์ผู้ให้บริการจะแสดงวิธีกำหนดจำนวนผู้ปฏิบัติงานสูงสุดสำหรับ Isolated Pool เบื้องหลังในลักษณะที่กำหนดค่าฟังก์ชัน Isolated ได้อย่างถูกต้อง
- อัปเดตไฟล์
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 these two lines
label: 'Max worker count', value: workerCount),
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining', value: remaining.formatted),
],
),
),
),
),
),
),
);
}
}
- แก้ไขไฟล์
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),
),
);
}
ถ้าคุณเรียกใช้แอปตอนนี้ คุณจะสามารถแก้ไขจำนวนการแยกพื้นหลังที่สร้างเป็นอินสแตนซ์เพื่อค้นหาคำที่จะใส่ไว้ในครอสเวิร์ดได้
- คลิกที่ไอคอนรูปเฟืองในเพื่อเปิดเมนูตามบริบทที่มีการปรับขนาดของอักษรไขว้ เลือกว่าจะแสดงสถิติเกี่ยวกับอักษรไขว้ที่สร้างขึ้นในปัจจุบันหรือไม่ และตอนนี้แสดงจำนวน Is ที่จะต้องใช้
การเรียกใช้โปรแกรมสร้างปริศนาอักษรไขว้ช่วยลดเวลาประมวลผลสำหรับอักษรไขว้ขนาด 80x44 ลงอย่างมากโดยการใช้หลายแกนพร้อมกัน
9. เปลี่ยนให้เป็นเกม
ส่วนสุดท้ายนี้เป็นรอบพิเศษจริงๆ คุณจะได้นำเทคนิคทั้งหมดที่ได้เรียนรู้ขณะสร้างโปรแกรมสร้างปริศนาอักษรไขว้และใช้เทคนิคเหล่านี้ในการสร้างเกม คุณสามารถใช้โปรแกรมสร้างปริศนาอักษรไขว้เพื่อสร้างปริศนาอักษรไขว้ คุณจะใช้สำนวนเมนูตามบริบทซ้ำเพื่อให้ผู้ใช้เลือกและยกเลิกการเลือกคำที่จะใส่ลงในช่องรูปคำต่างๆ ในตารางได้ โดยมีจุดประสงค์เพื่อไขปริศนาให้สำเร็จ
ฉันไม่ได้จะบอกว่าเกมนี้ดีเลยหรือเล่นจบแล้ว แต่มันยังไม่ใช่เรื่องจริงเลย ปัญหาความสมดุลและความยากที่สามารถแก้ไขได้ด้วยการปรับปรุงการใช้คำทางเลือก ไม่มีบทแนะนําเพื่อดึงดูดผู้ใช้ และภาพเคลื่อนไหวที่ชวนให้คิดอะไรก็ให้อะไรหลายๆ อย่างเป็นที่ต้องการ ฉันจะไม่พูดถึงสิ่งที่ทำได้ง่ายๆ ว่า "คุณชนะ" บนหน้าจอ
ข้อดีข้อเสียก็คือ การขัดเกลาเกมโปรโตนี้ให้เป็นเกมเวอร์ชันเต็มอย่างสมบูรณ์นั้นจะต้องใช้โค้ดมากกว่า มีโค้ดมากกว่าที่ควรจะอยู่ใน Codelab เดียว ดังนั้น จึงเป็นขั้นตอนการทดสอบความเร็วที่ออกแบบมาเพื่อเสริมเทคนิคที่ได้เรียนรู้ใน Codelab นี้โดยการเปลี่ยนตำแหน่งและวิธีการใช้งาน เราหวังว่าข้อมูลนี้จะช่วยเน้นย้ำบทเรียนที่ได้มาก่อนหน้านี้ใน Codelab นี้ หรือคุณจะสร้างประสบการณ์ของคุณเองโดยใช้โค้ดนี้เลยก็ได้ เราอยากเห็นสิ่งที่คุณสร้าง
ในการเริ่มต้น โปรดปฏิบัติตามขั้นตอนต่อไปนี้:
- ลบทุกอย่างในไดเรกทอรี
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_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
const backgroundWorkerCount = 4; // Add this line
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* {
final size = ref.watch(sizeProvider); // Drop the ref.watch(workerCountProvider)
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword =
model.Crossword.crossword(width: size.width, height: size.height);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
// Drop the startTimeProvider and endTimeProvider refs
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: backgroundWorkerCount, // Edit this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
} // Drop the endTimeProvider ref
@riverpod // Add from here to end of file
class Puzzle extends _$Puzzle {
model.CrosswordPuzzleGame _puzzle = model.CrosswordPuzzleGame.from(
crossword: model.Crossword.crossword(width: 0, height: 0),
candidateWords: BuiltSet<String>(),
);
@override
model.CrosswordPuzzleGame build() {
final size = ref.watch(sizeProvider);
final wordList = ref.watch(wordListProvider).value;
final workQueue = ref.watch(workQueueProvider).value;
if (wordList != null &&
workQueue != null &&
workQueue.isCompleted &&
(_puzzle.crossword.height != size.height ||
_puzzle.crossword.width != size.width ||
_puzzle.crossword != workQueue.crossword)) {
compute(_puzzleFromCrosswordTrampoline, (workQueue.crossword, wordList))
.then((puzzle) {
_puzzle = puzzle;
ref.invalidateSelf();
});
}
return _puzzle;
}
Future<void> selectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) async {
final candidate = await compute(
_puzzleSelectWordTrampoline, (_puzzle, location, word, direction));
if (candidate != null) {
_puzzle = candidate;
ref.invalidateSelf();
} else {
debugPrint('Invalid word selection: $word');
}
}
bool canSelectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) {
return _puzzle.canSelectWord(
location: location,
word: word,
direction: direction,
);
}
}
// Trampoline functions to disentangle these Isolate target calls from the
// unsendable reference to the [Puzzle] provider.
Future<model.CrosswordPuzzleGame> _puzzleFromCrosswordTrampoline(
(model.Crossword, BuiltSet<String>) args) async =>
model.CrosswordPuzzleGame.from(crossword: args.$1, candidateWords: args.$2);
model.CrosswordPuzzleGame? _puzzleSelectWordTrampoline(
(
model.CrosswordPuzzleGame,
model.Location,
String,
model.Direction
) args) =>
args.$1.selectWord(location: args.$2, word: args.$3, direction: args.$4);
ส่วนที่น่าสนใจที่สุดของผู้ให้บริการ Puzzle
คือการวางกลยุทธ์เพื่อให้ครอบคลุมค่าใช้จ่ายในการสร้าง CrosswordPuzzleGame
จาก Crossword
และ wordList
รวมถึงค่าใช้จ่ายในการเลือกคำ การดำเนินการทั้ง 2 อย่างนี้เมื่อดำเนินการโดยไม่ใช้ตัวช่วยของ Isolate เบื้องหลังจะทำให้การโต้ตอบกับ UI ล่าช้า การใช้มือเอื้อมมือดันผลลัพธ์ขั้นกลางออกมาขณะประมวลผลผลลัพธ์สุดท้ายในเบื้องหลังจะช่วยให้คุณเตรียมตัวโดยมี UI ที่ปรับเปลี่ยนตามอุปกรณ์ในขณะที่ระบบกำลังคำนวณที่จำเป็นอยู่ในเบื้องหลัง
- ในไดเรกทอรี
lib/widgets
ซึ่งว่างเปล่าอยู่ ให้สร้างไฟล์crossword_puzzle_app.dart
ด้วยเนื้อหาต่อไปนี้
lib/widgets/crossword_puzzle_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_generator_widget.dart';
import 'crossword_puzzle_widget.dart';
import 'puzzle_completed_widget.dart';
class CrosswordPuzzleApp extends StatelessWidget {
const CrosswordPuzzleApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordPuzzleAppMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Puzzle'),
),
body: SafeArea(
child: Consumer(builder: (context, ref, _) {
final workQueueAsync = ref.watch(workQueueProvider);
final puzzleSolved =
ref.watch(puzzleProvider.select((puzzle) => puzzle.solved));
return workQueueAsync.when(
data: (workQueue) {
if (puzzleSolved) {
return PuzzleCompletedWidget();
}
if (workQueue.isCompleted &&
workQueue.crossword.characters.isNotEmpty) {
return CrosswordPuzzleWidget();
}
return CrosswordGeneratorWidget();
},
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(child: Text('$error')),
);
}),
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordPuzzleAppMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
ไฟล์นี้ส่วนใหญ่น่าจะคุ้นเคยดีแล้ว ใช่ จะมีวิดเจ็ตที่ไม่ได้กำหนด ซึ่งคุณจะเริ่มแก้ไขได้ทันที
- สร้างไฟล์
crossword_generator_widget.dart
และเพิ่มเนื้อหาต่อไปนี้ลงในไฟล์
lib/widgets/crossword_generator_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordGeneratorWidget extends ConsumerWidget {
const CrosswordGeneratorWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
);
if (character != null) {
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text('•'), // https://www.compart.com/en/unicode/U+2022
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
ซึ่งควรจะมีความคุ้นเคยพอสมควร ความแตกต่างหลักๆ ก็คือ ตอนนี้คุณจะแสดงอักขระ Unicode แทนการแสดงอักขระที่ไม่ทราบตัวอักขระที่สร้างขึ้น ซึ่งอาจช่วยปรับปรุงความสวยงามได้จริง
- สร้างไฟล์
crossword_puzzle_widget.dart
และเพิ่มเนื้อหาต่อไปนี้ลงในไฟล์
lib/widgets/crossword_puzzle_widget.dart
import 'package:built_collection/built_collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordPuzzleWidget extends ConsumerWidget {
const CrosswordPuzzleWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(puzzleProvider
.select((puzzle) => puzzle.crossword.characters[location]));
final selectedCharacter = ref.watch(puzzleProvider.select((puzzle) =>
puzzle.crosswordFromSelectedWords.characters[location]));
final alternateWords = ref
.watch(puzzleProvider.select((puzzle) => puzzle.alternateWords));
if (character != null) {
final acrossWord = character.acrossWord;
var acrossWords = BuiltList<String>();
if (acrossWord != null) {
acrossWords = acrossWords.rebuild((b) => b
..add(acrossWord.word)
..addAll(alternateWords[acrossWord.location]
?[acrossWord.direction] ??
[])
..sort());
}
final downWord = character.downWord;
var downWords = BuiltList<String>();
if (downWord != null) {
downWords = downWords.rebuild((b) => b
..add(downWord.word)
..addAll(alternateWords[downWord.location]
?[downWord.direction] ??
[])
..sort());
}
return MenuAnchor(
builder: (context, controller, _) {
return GestureDetector(
onTapDown: (details) =>
controller.open(position: details.localPosition),
child: AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
child: Text(selectedCharacter?.character ?? ''),
),
),
),
);
},
menuChildren: [
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Across'),
),
for (final word in acrossWords)
_WordSelectMenuItem(
location: acrossWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.across,
),
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Down'),
),
for (final word in downWords)
_WordSelectMenuItem(
location: downWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.down,
),
],
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
class _WordSelectMenuItem extends ConsumerWidget {
const _WordSelectMenuItem({
required this.location,
required this.word,
required this.selectedCharacter,
required this.direction,
});
final Location location;
final String word;
final CrosswordCharacter? selectedCharacter;
final Direction direction;
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(puzzleProvider.notifier);
return MenuItemButton(
onPressed: ref.watch(puzzleProvider.select((puzzle) =>
puzzle.canSelectWord(
location: location, word: word, direction: direction)))
? () => notifier.selectWord(
location: location, word: word, direction: direction)
: null,
leadingIcon: switch (direction) {
Direction.across => selectedCharacter?.acrossWord?.word == word,
Direction.down => selectedCharacter?.downWord?.word == word,
}
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(word),
);
}
}
วิดเจ็ตนี้มีความหนักแน่นกว่าวิดเจ็ตล่าสุดเล็กน้อย แม้ว่าจะสร้างขึ้นจากชิ้นงานที่คุณเคยเห็นจากที่อื่นๆ ในอดีตก็ตาม ตอนนี้เซลล์ที่มีการป้อนข้อมูลแต่ละเซลล์จะสร้างเมนูตามบริบทเมื่อคลิก ซึ่งจะแสดงคำที่ผู้ใช้เลือกได้ หากมีการเลือกคำแล้ว จะไม่สามารถเลือกคำที่ขัดแย้งได้ หากต้องการยกเลิกการเลือกคํา ผู้ใช้แตะรายการในเมนูสําหรับคํานั้น
สมมติว่าผู้เล่นสามารถเลือกคำเพื่อเติมคำในอักษรไขว้ทั้งหมดได้ คุณจะต้องมีข้อความ "คุณชนะแล้ว!" บนหน้าจอ
- สร้างไฟล์
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,
),
),
);
}
}
ฉันแน่ใจว่าคุณเอาอันนี้ และทำให้น่าสนใจมากขึ้นได้ ดูข้อมูลเพิ่มเติมเกี่ยวกับเครื่องมือสร้างภาพเคลื่อนไหวได้ที่ Codelab ของการสร้าง UI รุ่นใหม่ใน 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(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordPuzzleApp(), // Update this line
),
),
);
}
เมื่อคุณเรียกใช้แอปนี้ คุณจะเห็นภาพเคลื่อนไหวขณะที่โปรแกรมสร้างปริศนาอักษรไขว้สร้างปริศนาของคุณ จากนั้นคุณจะเห็นปริศนาเปล่าให้ไขปริศนา สมมติว่าคุณแก้โจทย์ได้ คุณจะเห็นหน้าจอที่มีลักษณะดังนี้
10. ขอแสดงความยินดี
ยินดีด้วย คุณประสบความสำเร็จในการสร้างเกมไขปัญหากับ Flutter!
คุณได้สร้างโปรแกรมสร้างปริศนาอักษรไขว้ที่กลายเป็นเกมไขปัญหา คุณเชี่ยวชาญการคำนวณพื้นหลังในกลุ่มไฟล์ Isolation คุณใช้โครงสร้างข้อมูลที่เปลี่ยนแปลงไม่ได้เพื่อให้ติดตั้งใช้งานอัลกอริทึมการย้อนกลับได้ง่ายขึ้น และคุณใช้เวลาอันมีค่ากับ TableView
ซึ่งจะเป็นประโยชน์สำหรับครั้งต่อไปที่คุณต้องแสดงข้อมูลแบบตาราง