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