Tạo câu đố chữ bằng Flutter

1. Trước khi bắt đầu

Hãy tưởng tượng bạn được hỏi liệu có thể tạo ra ô chữ lớn nhất thế giới hay không. Bạn nhớ lại một số kỹ thuật AI mà mình đã học ở trường và tự hỏi liệu bạn có thể sử dụng Flutter để khám phá các lựa chọn về thuật toán nhằm tạo ra giải pháp cho các vấn đề đòi hỏi nhiều tính toán hay không.

Trong lớp học lập trình này, bạn sẽ làm chính xác như vậy. Đến cuối cùng, bạn sẽ tạo một công cụ để chơi trong không gian của các thuật toán xây dựng trò chơi ô chữ. Có nhiều định nghĩa khác nhau về một ô chữ hợp lệ và những kỹ thuật này giúp bạn tạo ra các ô chữ phù hợp với định nghĩa của bạn.

Ảnh động về một ô chữ đang được tạo.

Với công cụ này làm cơ sở, sau đó bạn sẽ tạo một trò chơi ô chữ bằng cách sử dụng trình tạo ô chữ để xây dựng ô chữ cho người dùng giải. Bạn có thể sử dụng câu đố này trên Android, iOS, Windows, macOS và Linux. Sau đây là cách thực hiện trên Android:

Ảnh chụp màn hình một ô chữ đang được giải trên trình mô phỏng Pixel Fold.

Điều kiện tiên quyết

Kiến thức bạn sẽ học được

  • Cách sử dụng các quy trình biệt lập để thực hiện công việc tốn nhiều tài nguyên tính toán mà không làm ảnh hưởng đến vòng lặp kết xuất của Flutter bằng cách kết hợp hàm compute của Flutter và khả năng lưu vào bộ nhớ đệm giá trị của bộ lọc dựng lại select của Riverpod.
  • Cách tận dụng cấu trúc dữ liệu bất biến với built_valuebuilt_collection để triển khai các kỹ thuật AI kiểu cũ (GOFAI) dựa trên tìm kiếm như tìm kiếm theo chiều sâu và quay lui.
  • Cách sử dụng các chức năng của gói two_dimensional_scrollables để hiển thị dữ liệu dạng lưới một cách nhanh chóng và trực quan.

Bạn cần có

  • Flutter SDK.
  • Visual Studio Code (VS Code) có các trình bổ trợ Flutter và Dart.
  • Phần mềm trình biên dịch cho mục tiêu phát triển mà bạn đã chọn. Lớp học lập trình này hoạt động trên mọi nền tảng máy tính, Android và iOS. Bạn cần VS Code để nhắm đến Windows, Xcode để nhắm đến macOS hoặc iOS và Android Studio để nhắm đến Android.

2. Tạo một dự án

Tạo dự án Flutter đầu tiên

  1. Khởi chạy VS Code.
  2. Mở Bảng lệnh (Ctrl+Shift+P trên Windows/Linux, Cmd+Shift+P trên macOS), nhập "flutter new" rồi chọn Flutter: New Project (Flutter: Dự án mới) trong trình đơn.

VS Code với Flutter: Dự án mới xuất hiện trong bảng lệnh mở.

  1. Chọn Empty application (Ứng dụng trống), sau đó chọn một thư mục để tạo dự án. Đây phải là thư mục không yêu cầu đặc quyền nâng cao hoặc có khoảng trắng trong đường dẫn. Ví dụ: thư mục chính hoặc C:\src\.

VS Code có Ứng dụng trống xuất hiện ở trạng thái đã chọn trong quy trình tạo ứng dụng mới

  1. Đặt tên cho dự án generate_crossword. Phần còn lại của lớp học lập trình này giả định rằng bạn đã đặt tên cho ứng dụng của mình là generate_crossword.

VS Code với generate_crossword xuất hiện dưới dạng tên của dự án mới đang được tạo

Giờ đây, Flutter sẽ tạo thư mục dự án và VS Code sẽ mở thư mục đó. Bây giờ, bạn sẽ ghi đè nội dung của 2 tệp bằng một cấu trúc cơ bản của ứng dụng.

Sao chép và dán ứng dụng ban đầu

  1. Trong ngăn bên trái của VS Code, hãy nhấp vào Explorer (Trình khám phá) rồi mở tệp pubspec.yaml.

Ảnh chụp màn hình một phần của VS Code có các mũi tên làm nổi bật vị trí của tệp pubspec.yaml

  1. Thay thế nội dung của tệp này bằng các phần phụ thuộc sau đây cần thiết cho việc tạo ô chữ:

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

Tệp pubspec.yaml chỉ định thông tin cơ bản về ứng dụng của bạn, chẳng hạn như phiên bản hiện tại và các phần phụ thuộc của ứng dụng. Bạn sẽ thấy một tập hợp các phần phụ thuộc không thuộc một ứng dụng Flutter trống thông thường. Bạn sẽ được hưởng lợi từ tất cả các gói này trong các bước tiếp theo.

Tìm hiểu về các phần phụ thuộc

Trước khi đi sâu vào mã, hãy tìm hiểu lý do chúng tôi chọn những gói cụ thể này:

  • built_value: Tạo các đối tượng bất biến dùng chung bộ nhớ một cách hiệu quả, điều này rất quan trọng đối với thuật toán quay lui của chúng tôi
  • Riverpod: Cung cấp tính năng quản lý trạng thái chi tiết bằng select() để giảm thiểu việc tạo lại
  • two_dimensional_scrollables: Xử lý các lưới lớn mà không làm giảm hiệu suất
  1. Mở tệp main.dart trong thư mục lib/.

Ảnh chụp màn hình một phần của VS Code, trong đó có một mũi tên cho biết vị trí của tệp main.dart

  1. Thay thế nội dung của tệp này bằng nội dung sau:

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)),
          ),
        ),
      ),
    ),
  );
}
  1. Chạy mã này để kiểm tra xem mọi thứ có hoạt động không. Thao tác này sẽ hiển thị một cửa sổ mới có cụm từ bắt đầu bắt buộc của mọi dự án mới ở mọi nơi. Có một ProviderScope cho biết ứng dụng này sẽ dùng riverpod để quản lý trạng thái.

Một cửa sổ ứng dụng có dòng chữ "Hello, World!" ở giữa

Điểm dừng: Ứng dụng cơ bản đang chạy

Lúc này, bạn sẽ thấy cửa sổ "Hello, World!". Nếu không:

  • Kiểm tra để đảm bảo bạn đã cài đặt Flutter đúng cách
  • Xác minh rằng ứng dụng chạy bằng flutter run
  • Đảm bảo không có lỗi biên dịch trong thiết bị đầu cuối

3. Thêm từ

Thành phần của trò chơi ô chữ

Về cơ bản, ô chữ là một danh sách từ. Các từ được sắp xếp trong một lưới, một số theo chiều ngang, một số theo chiều dọc, sao cho các từ giao nhau. Giải một từ sẽ gợi ý cho những từ giao với từ đầu tiên đó. Do đó, một khối xây dựng đầu tiên phù hợp là danh sách các từ.

Một nguồn tốt cho những từ này là trang Dữ liệu kho ngữ liệu ngôn ngữ tự nhiên của Peter Norvig. Danh sách SOWPODS là một điểm khởi đầu hữu ích với 267.750 từ.

Trong bước này, bạn sẽ tải một danh sách từ xuống, thêm danh sách đó làm một thành phần vào ứng dụng Flutter và sắp xếp một trình cung cấp Riverpod để tải danh sách vào ứng dụng khi khởi động.

Để bắt đầu, hãy làm theo các bước sau:

  1. Sửa đổi tệp pubspec.yaml của dự án để thêm khai báo tài sản sau cho danh sách từ đã chọn. Danh sách này chỉ cho thấy phần flutter trong cấu hình ứng dụng của bạn, vì phần còn lại vẫn giữ nguyên.

pubspec.yaml

flutter:
  uses-material-design: true
  assets:                                       # Add this line
    - assets/words.txt                          # And this one.

Trình chỉnh sửa của bạn có thể sẽ làm nổi bật dòng cuối cùng này bằng một cảnh báo vì bạn chưa tạo tệp này.

  1. Bằng cách sử dụng trình duyệt và trình chỉnh sửa, hãy tạo một thư mục assets ở cấp cao nhất của dự án và tạo một tệp words.txt trong thư mục đó bằng một trong các danh sách từ được liên kết trước đó.

Mã này được thiết kế dựa trên danh sách SOWPODS đã đề cập trước đó, nhưng sẽ hoạt động với mọi danh sách từ chỉ bao gồm các ký tự A-Z. Việc mở rộng cơ sở mã này để hoạt động với các bộ ký tự khác nhau sẽ được để lại cho người đọc tự thực hiện.

Tải các từ lên

Để viết mã chịu trách nhiệm tải danh sách từ khi khởi động ứng dụng, hãy làm theo các bước sau:

  1. Tạo một tệp providers.dart trong thư mục lib.
  2. Thêm nội dung sau vào tệp:

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)),
      );
}

Đây là provider Riverpod đầu tiên của bạn cho cơ sở mã này.

Cách hoạt động của nhà cung cấp này:

  1. Tải danh sách từ từ các tài sản theo cách không đồng bộ
  2. Lọc các từ để chỉ bao gồm các ký tự a-z dài hơn 2 chữ cái
  3. Trả về một BuiltSet không thể thay đổi để truy cập ngẫu nhiên một cách hiệu quả

Dự án này sử dụng tính năng tạo mã cho nhiều phần phụ thuộc, bao gồm cả Riverpod.

  1. Để bắt đầu tạo mã, hãy chạy lệnh sau:
$ 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)

Quá trình này sẽ tiếp tục chạy trong nền, cập nhật các tệp đã tạo khi bạn thực hiện thay đổi đối với dự án. Sau khi lệnh này tạo mã trong providers.g.dart, trình chỉnh sửa của bạn sẽ hài lòng với mã mà bạn đã thêm vào providers.dart.

Trong Riverpod, các nhà cung cấp như hàm wordList mà bạn đã xác định trước đó thường được khởi tạo một cách trì hoãn. Tuy nhiên, đối với mục đích của ứng dụng này, bạn cần tải danh sách từ một cách háo hức. Tài liệu Riverpod đề xuất phương pháp sau đây để xử lý những nhà cung cấp mà bạn cần tải ngay. Bây giờ, bạn sẽ triển khai tính năng đó.

  1. Tạo tệp crossword_generator_app.dart trong thư mục lib/widgets.
  2. Thêm nội dung sau vào tệp:

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;
  }
}

Tệp này thú vị theo hai hướng riêng biệt. Đầu tiên là tiện ích _EagerInitialization, có nhiệm vụ duy nhất là yêu cầu trình cung cấp wordList mà bạn đã tạo trước đó tải danh sách từ. Tiện ích này đạt được mục tiêu này bằng cách theo dõi nhà cung cấp bằng lệnh gọi ref.watch(). Bạn có thể đọc thêm về kỹ thuật này trong tài liệu Riverpod về Khởi tạo nhà cung cấp một cách chủ động.

Điểm thú vị thứ hai cần lưu ý trong tệp này là cách Riverpod xử lý nội dung không đồng bộ. Như bạn có thể nhớ lại, trình cung cấp wordList được xác định là một hàm không đồng bộ, vì việc tải nội dung từ đĩa diễn ra chậm. Khi xem trình cung cấp danh sách từ trong mã này, bạn sẽ nhận được một AsyncValue<BuiltSet<String>>. Phần AsyncValue của loại này là một bộ chuyển đổi giữa thế giới không đồng bộ của các nhà cung cấp và thế giới đồng bộ của phương thức build của Tiện ích.

Phương thức when của AsyncValue xử lý 3 trạng thái tiềm ẩn mà giá trị trong tương lai có thể ở trong đó. Có thể yêu cầu trong tương lai đã được giải quyết thành công, trong trường hợp đó, lệnh gọi lại data sẽ được gọi, có thể ở trạng thái lỗi, trong trường hợp đó, lệnh gọi lại error sẽ được gọi hoặc cuối cùng, yêu cầu đó vẫn có thể đang tải. Loại dữ liệu trả về của 3 lệnh gọi lại phải có loại dữ liệu trả về tương thích, vì kết quả trả về của lệnh gọi lại được gọi sẽ do phương thức when trả về. Trong trường hợp này, kết quả của phương thức when sẽ xuất hiện dưới dạng body của tiện ích Scaffold.

Tạo một ứng dụng danh sách gần như vô hạn

Để tích hợp tiện ích CrosswordGeneratorApp vào ứng dụng, hãy làm theo các bước sau:

  1. Cập nhật tệp lib/main.dart bằng cách thêm đoạn mã sau:

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
      ),
    ),
  );
}
  1. Khởi động lại ứng dụng. Bạn sẽ thấy một danh sách cuộn qua tất cả hơn 267.750 từ trong từ điển.

Một cửa sổ ứng dụng có tiêu đề &quot;Crossword Generator&quot; (Trình tạo ô chữ) và danh sách các từ

Sản phẩm bạn sẽ tạo ra tiếp theo

Giờ đây, bạn sẽ tạo các cấu trúc dữ liệu cốt lõi cho ô chữ bằng các đối tượng không thể thay đổi. Nền tảng này sẽ cho phép các thuật toán hoạt động hiệu quả và các bản cập nhật giao diện người dùng diễn ra suôn sẻ.

4. Hiển thị các từ trong một lưới

Ở bước này, bạn sẽ tạo một cấu trúc dữ liệu để tạo ô chữ bằng cách sử dụng các gói built_valuebuilt_collection. Hai gói này cho phép tạo cấu trúc dữ liệu dưới dạng các giá trị bất biến, điều này sẽ hữu ích cho cả việc truyền dữ liệu giữa các Isolate và giúp việc triển khai tìm kiếm theo chiều sâu và quay lui trở nên dễ dàng hơn nhiều.

Để bắt đầu, hãy làm theo các bước sau:

  1. Tạo tệp model.dart trong thư mục lib, sau đó thêm nội dung sau vào tệp:

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;

Tệp này mô tả phần đầu của cấu trúc dữ liệu mà bạn sẽ dùng để tạo ô chữ. Về cơ bản, ô chữ là một danh sách các từ theo chiều ngang và chiều dọc được lồng vào nhau trong một lưới ô. Để sử dụng cấu trúc dữ liệu này, bạn tạo một Crossword có kích thước phù hợp bằng hàm khởi tạo có tên Crossword.crossword, sau đó thêm các từ bằng phương thức addWord. Trong quá trình tạo giá trị cuối cùng, một lưới gồm các CrosswordCharacter được tạo bằng phương thức _fillCharacters.

Để sử dụng cấu trúc dữ liệu này, hãy làm theo các bước sau:

  1. Tạo tệp utils trong thư mục lib, sau đó thêm nội dung sau vào tệp:

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));
  }
}

Đây là một tiện ích trên BuiltSet giúp bạn dễ dàng truy xuất một phần tử ngẫu nhiên của tập hợp. Phương thức mở rộng là một cách hay để mở rộng các lớp bằng chức năng bổ sung. Bạn phải đặt tên cho tiện ích để tiện ích có thể hoạt động bên ngoài tệp utils.dart.

  1. Trong tệp lib/providers.dart, hãy thêm các nội dung nhập sau:

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 {

Các thao tác nhập này sẽ hiển thị mô hình đã xác định trước đó cho các nhà cung cấp mà bạn sắp tạo. Dữ liệu nhập dart:math được đưa vào cho Random, dữ liệu nhập flutter/foundation.dart được đưa vào cho debugPrint, model.dart cho mô hình và utils.dart cho tiện ích BuiltSet.

  1. Ở cuối tệp đó, hãy thêm các trình cung cấp sau:

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;
    },
  );
}

Những thay đổi này sẽ thêm 2 trình cung cấp vào ứng dụng của bạn. Trình cung cấp đầu tiên là Size, đây là một biến toàn cục chứa giá trị đã chọn của phép liệt kê CrosswordSize. Điều này sẽ cho phép giao diện người dùng vừa hiển thị vừa đặt kích thước của ô chữ đang được xây dựng. Nhà cung cấp thứ hai, crossword, là một sáng tạo thú vị hơn. Đây là một hàm trả về một chuỗi Crossword. Thư viện này được tạo bằng tính năng hỗ trợ của Dart cho các trình tạo, được đánh dấu bằng async* trên hàm. Điều này có nghĩa là thay vì kết thúc ở một câu lệnh return, nó sẽ tạo ra một chuỗi các Crossword, một cách dễ dàng hơn nhiều để viết một phép tính trả về kết quả trung gian.

Do có một cặp lệnh gọi ref.watch ở đầu hàm của trình cung cấp crossword, nên luồng Crossword sẽ được hệ thống Riverpod khởi động lại mỗi khi kích thước ô chữ đã chọn thay đổi và khi danh sách từ tải xong.

Giờ đây, bạn đã có mã để tạo ô chữ, mặc dù chứa đầy các từ ngẫu nhiên, nhưng sẽ rất hay nếu bạn cho người dùng công cụ xem các từ đó.

  1. Tạo một tệp crossword_widget.dart trong thư mục lib/widgets có nội dung sau:

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,
          ),
        ),
      ),
    );
  }
}

Là một ConsumerWidget, tiện ích này có thể dựa trực tiếp vào trình cung cấp Size để xác định kích thước của lưới nhằm hiển thị các ký tự của Crossword. Lưới này được hiển thị bằng tiện ích TableView trong gói two_dimensional_scrollables.

Điều đáng chú ý là mỗi ô riêng lẻ do các hàm trợ giúp _buildCell kết xuất đều chứa một tiện ích Consumer trong cây Widget được trả về. Đây đóng vai trò là ranh giới làm mới. Mọi thứ bên trong tiện ích Consumer sẽ được tạo lại khi giá trị mà ref.watch trả về thay đổi. Bạn có thể muốn tạo lại toàn bộ cây mỗi khi Crossword thay đổi, tuy nhiên, điều này sẽ gây ra nhiều hoạt động tính toán mà bạn có thể bỏ qua bằng cách sử dụng chế độ thiết lập này.

Nếu xem xét tham số của ref.watch, bạn sẽ thấy có một lớp khác để tránh tính toán lại bố cục bằng cách sử dụng crosswordProvider.select. Điều này có nghĩa là ref.watch sẽ chỉ kích hoạt quá trình tạo lại nội dung của TableViewCell khi ký tự mà ô chịu trách nhiệm kết xuất thay đổi. Việc giảm số lần kết xuất lại là một phần thiết yếu để duy trì khả năng phản hồi của giao diện người dùng.

Để hiển thị trình cung cấp CrosswordWidgetSize cho người dùng, hãy thay đổi tệp crossword_generator_app.dart như sau:

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.
}

Một số điểm đã thay đổi ở đây. Trước tiên, mã chịu trách nhiệm kết xuất wordList dưới dạng ListView đã được thay thế bằng một lệnh gọi đến CrosswordWidget được xác định trong tệp lib/widgets/crossword_widget.dart. Thay đổi lớn khác là việc bắt đầu một trình đơn để thay đổi hành vi của ứng dụng, bắt đầu bằng việc thay đổi kích thước của ô chữ. Sẽ có thêm nhiều MenuItemButton được thêm vào các bước sau. Chạy ứng dụng, bạn sẽ thấy nội dung như sau:

Một cửa sổ ứng dụng có tiêu đề Crossword Generator (Trình tạo ô chữ) và một lưới các ký tự được bố trí dưới dạng các từ chồng lên nhau mà không có lý do hay quy tắc nào

Có các ký tự xuất hiện trong một lưới và một trình đơn cho phép người dùng thay đổi kích thước của lưới. Nhưng các từ không được sắp xếp như trong một ô chữ. Đây là kết quả của việc không áp dụng bất kỳ ràng buộc nào về cách thêm từ vào ô chữ. Nói tóm lại, đó là một mớ hỗn độn. Đây là điều bạn sẽ bắt đầu kiểm soát trong bước tiếp theo!

5. Thực thi các điều kiện ràng buộc

Nội dung thay đổi và lý do

Hiện tại, ô chữ của bạn cho phép các từ chồng lên nhau mà không cần xác thực. Bạn sẽ thêm tính năng kiểm tra ràng buộc để đảm bảo các từ khớp với nhau một cách hợp lý như một trò chơi ô chữ thực sự.

Mục tiêu của bước này là thêm mã vào mô hình để thực thi các ràng buộc của ô chữ. Có nhiều loại ô chữ khác nhau và kiểu ô chữ mà lớp học lập trình này sẽ áp dụng theo truyền thống của ô chữ tiếng Anh. Việc sửa đổi mã này để tạo các kiểu ô chữ khác vẫn là một bài tập dành cho người đọc.

Để bắt đầu, hãy làm theo các bước sau:

  1. Mở tệp model.dart rồi chỉ thay thế mô hình Crossword bằng nội dung sau:

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._();
}

Xin lưu ý rằng những thay đổi mà bạn đang thực hiện đối với các tệp model.dartproviders.dart yêu cầu build_runner đang chạy để cập nhật các tệp model.g.dartproviders.g.dart tương ứng. Nếu các tệp này chưa tự động cập nhật, thì bây giờ là thời điểm thích hợp để bắt đầu lại build_runner bằng dart run build_runner watch -d.

Để tận dụng khả năng mới này trong lớp mô hình, bạn cần cập nhật lớp nhà cung cấp cho phù hợp.

  1. Chỉnh sửa tệp providers.dart như sau:

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;
    },
  );
}
  1. Chạy ứng dụng của bạn. Không có nhiều hoạt động diễn ra trong giao diện người dùng, nhưng có rất nhiều hoạt động diễn ra nếu bạn xem nhật ký.

Cửa sổ ứng dụng Crossword Generator (Trình tạo ô chữ) với các từ được bố trí theo chiều ngang và chiều dọc, giao nhau tại các điểm ngẫu nhiên

Nếu bạn nghĩ về những gì đang xảy ra ở đây, thì chúng ta sẽ thấy một ô chữ xuất hiện một cách ngẫu nhiên. Phương thức addWord trong mô hình Crossword đang từ chối mọi từ được đề xuất không phù hợp với ô chữ hiện tại, vì vậy, thật đáng ngạc nhiên khi chúng ta thấy bất kỳ từ nào xuất hiện.

Tại sao nên chuyển sang xử lý dưới nền?

Bạn có thể nhận thấy giao diện người dùng không phản hồi trong quá trình tạo ô chữ. Điều này xảy ra vì quá trình tạo ô chữ bao gồm hàng nghìn bước kiểm tra xác thực. Các phép tính này chặn vòng lặp kết xuất 60 khung hình/giây của Flutter, vì vậy, bạn sẽ chuyển phép tính phức tạp sang các quy trình riêng biệt ở chế độ nền. Điều này mang lại lợi ích là giao diện người dùng vẫn mượt mà trong khi câu đố được tạo ở chế độ nền

Để chuẩn bị cho việc chọn từ cần thử một cách có phương pháp hơn, bạn nên chuyển phép tính này ra khỏi luồng giao diện người dùng và vào một quy trình riêng ở chế độ nền. Flutter có một trình bao bọc rất hữu ích để lấy một phần công việc và chạy nó trong một quy trình riêng biệt ở chế độ nền – hàm compute.

  1. Trong tệp providers.dart, hãy sửa đổi trình cung cấp ô chữ như sau:

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;
    },
  );
}

Tìm hiểu về các quy định hạn chế đối với tính năng cách ly

Mã này hoạt động nhưng có một vấn đề tiềm ẩn. Các isolate có quy tắc nghiêm ngặt về dữ liệu có thể được truyền giữa chúng, vấn đề là việc đóng "nắm bắt" thông tin tham chiếu nhà cung cấp, thông tin này không thể được chuyển đổi tuần tự và gửi đến một isolate khác.

Bạn sẽ thấy thông báo này khi hệ thống cố gắng gửi dữ liệu không thể chuyển đổi tuần tự:

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)

Đây là kết quả của việc đóng mà compute đang chuyển giao cho quy trình khép kín ở chế độ nền khi đóng một nhà cung cấp, không thể gửi qua SendPort.send(). Một cách khắc phục là đảm bảo không có gì để đóng lại mà không thể gửi được.

Bước đầu tiên là tách các nhà cung cấp khỏi mã Isolate.

  1. Tạo tệp isolates.dart trong thư mục lib, sau đó thêm nội dung sau vào tệp đó:

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');
    }
  }
}

Đoạn mã này có vẻ khá quen thuộc. Đây là cốt lõi của những gì có trong trình cung cấp crossword, nhưng hiện là một hàm trình tạo độc lập. Giờ đây, bạn có thể cập nhật tệp providers.dart để sử dụng hàm mới này nhằm khởi tạo quy trình riêng biệt ở chế độ nền.

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.
    },
  );
}

Giờ đây, bạn đã có một công cụ tạo ô chữ với nhiều kích thước, trong đó compute của việc tìm ra câu đố diễn ra trong một quy trình riêng ở chế độ nền. Giờ đây, nếu mã có thể hiệu quả hơn khi quyết định nên thử thêm từ nào vào ô chữ.

6. Quản lý hàng đợi công việc

Tìm hiểu về chiến lược tìm kiếm

Tính năng tạo ô chữ của bạn sử dụng phương pháp quay lui, một phương pháp thử và sai có hệ thống. Trước tiên, ứng dụng của bạn sẽ cố gắng đặt một từ tại một vị trí, sau đó kiểm tra xem từ đó có phù hợp với các từ hiện có hay không. Nếu có, hãy giữ lại và thử từ tiếp theo. Nếu không, hãy tháo thẻ nhớ và thử ở nơi khác.

Thuật toán quay lui có hiệu quả đối với ô chữ vì mỗi vị trí đặt từ sẽ tạo ra các ràng buộc cho các từ trong tương lai, trong đó các vị trí không hợp lệ sẽ nhanh chóng được phát hiện và loại bỏ. Cấu trúc dữ liệu bất biến giúp việc "huỷ" các thay đổi trở nên hiệu quả.

Một phần của vấn đề với mã như hiện tại là vấn đề đang được giải quyết thực sự là một vấn đề tìm kiếm và giải pháp hiện tại là tìm kiếm mà không có thông tin. Nếu mã tập trung vào việc tìm những từ sẽ gắn vào các từ hiện tại, thay vì cố gắng đặt các từ ở bất kỳ vị trí nào trên lưới, thì hệ thống sẽ tìm ra giải pháp nhanh hơn. Một cách tiếp cận vấn đề này là giới thiệu một hàng đợi công việc gồm các vị trí để cố gắng tìm từ.

Mã này tạo ra các giải pháp đề xuất, kiểm tra xem giải pháp đề xuất có hợp lệ hay không và tuỳ thuộc vào tính hợp lệ, mã này sẽ kết hợp giải pháp đề xuất hoặc loại bỏ giải pháp đó. Đây là một ví dụ về cách triển khai từ họ thuật toán quay lui. Việc triển khai này được giảm bớt rất nhiều nhờ built_valuebuilt_collection, cho phép tạo các giá trị bất biến mới có nguồn gốc và do đó chia sẻ trạng thái chung với giá trị bất biến mà chúng có nguồn gốc. Điều này cho phép khai thác các ứng viên tiềm năng một cách dễ dàng mà không tốn bộ nhớ cần thiết cho việc sao chép sâu.

Để bắt đầu, hãy làm theo các bước sau:

  1. Mở tệp model.dart rồi thêm định nghĩa WorkQueue sau đây vào tệp đó:

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;
  1. Nếu bạn vẫn thấy các đường gạch chân màu đỏ trong tệp này sau khi thêm nội dung mới này trong vài giây, hãy xác nhận rằng build_runner vẫn đang chạy. Nếu không, hãy chạy lệnh dart run build_runner watch -d.

Trong mã, bạn sắp sửa đổi để thêm tính năng ghi nhật ký nhằm cho biết thời gian cần thiết để tạo ô chữ ở nhiều kích thước. Sẽ rất tốt nếu Thời lượng có một số dạng hiển thị được định dạng đẹp. Rất may là với các phương thức mở rộng, chúng ta có thể thêm chính xác phương thức mà mình cần.

  1. Chỉnh sửa tệp utils.dart như sau:

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.

Phương thức mở rộng này tận dụng các biểu thức chuyển đổi và so khớp mẫu trên các bản ghi để chọn cách thích hợp để hiển thị các khoảng thời gian khác nhau, từ giây đến ngày. Để biết thêm thông tin về kiểu mã này, hãy xem lớp học lập trình Khám phá các mẫu và bản ghi của Dart.

  1. Để tích hợp chức năng mới này, hãy thay thế tệp isolates.dart để xác định lại cách xác định hàm exploreCrosswordSolutions như sau:

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}',
  );
}

Khi chạy đoạn mã này, bạn sẽ có một ứng dụng có giao diện giống hệt nhau, nhưng điểm khác biệt là thời gian cần thiết để tìm ra một ô chữ đã hoàn thành. Đây là một ô chữ 80 x 44 được tạo trong 1 phút 29 giây.

Chốt kiểm tra: Thuật toán hoạt động hiệu quả

Giờ đây, quá trình tạo ô chữ của bạn sẽ diễn ra nhanh hơn đáng kể nhờ:

  • Các điểm giao nhau của tiêu chí nhắm mục tiêu theo vị trí từ khoá thông minh
  • Quá trình quay lui hiệu quả khi vị trí không thành công
  • Quản lý hàng đợi công việc để tránh các lượt tìm kiếm dư thừa

Crossword Generator (Công cụ tạo ô chữ) với nhiều từ giao nhau. Khi thu nhỏ, các từ quá nhỏ nên không đọc được.

Câu hỏi hiển nhiên là liệu chúng ta có thể đi nhanh hơn không? Ồ có, chúng tôi có thể.

7. Số liệu thống kê về kênh

Tại sao nên thêm số liệu thống kê?

Để tạo ra một thứ gì đó nhanh chóng, bạn cần biết điều gì đang xảy ra. Số liệu thống kê giúp bạn theo dõi tiến trình và xem hiệu suất của thuật toán theo thời gian thực. Điều này cho phép bạn xác định các điểm tắc nghẽn bằng cách tìm hiểu thời gian thuật toán dành cho việc gì. Nhờ đó, bạn có thể điều chỉnh hiệu suất bằng cách đưa ra quyết định sáng suốt về các phương pháp tối ưu hoá.

Thông tin bạn sẽ hiển thị cần được trích xuất từ WorkQueue và hiển thị trong giao diện người dùng. Bước đầu tiên hữu ích là xác định một lớp mô hình mới chứa thông tin bạn muốn hiển thị.

Để bắt đầu, hãy làm theo các bước sau:

  1. Chỉnh sửa tệp model.dart như sau để thêm lớp 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> {
  1. Ở cuối tệp, hãy thực hiện các thay đổi sau để thêm lớp 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;
  1. Sửa đổi tệp isolates.dart để hiển thị mô hình WorkQueue như sau:

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}',
  );
}

Giờ đây, khi nền tảng cô lập đang hiển thị hàng đợi công việc, vấn đề đặt ra là làm thế nào và ở đâu để lấy số liệu thống kê từ nguồn dữ liệu này.

  1. Thay thế nhà cung cấp ô chữ cũ bằng nhà cung cấp hàng đợi công việc, sau đó thêm nhiều nhà cung cấp khác lấy thông tin từ luồng của nhà cung cấp hàng đợi công việc:

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,
      );
}

Các đối tượng cung cấp mới là sự kết hợp giữa trạng thái chung (dưới dạng thông tin hiển thị có nên được đặt lên trên lưới ô chữ hay không) và dữ liệu phái sinh (chẳng hạn như thời gian chạy của quá trình tạo ô chữ). Tất cả những điều này trở nên phức tạp hơn do thực tế là trình nghe của một số trạng thái này chỉ là tạm thời. Không có gì theo dõi thời gian bắt đầu và kết thúc của quá trình tính toán ô chữ nếu màn hình hiển thị thông tin bị ẩn, nhưng chúng cần được lưu trong bộ nhớ nếu phép tính phải chính xác khi màn hình hiển thị thông tin xuất hiện. Tham số keepAlive của thuộc tính Riverpod rất hữu ích trong trường hợp này.

Khi hiển thị thông tin, có một điểm bất cập nhỏ. Chúng ta muốn có khả năng hiển thị thời gian chạy đã trôi qua, nhưng không có gì ở đây để buộc cập nhật liên tục thời gian đã trôi qua. Quay lại lớp học lập trình Xây dựng giao diện người dùng thế hệ tiếp theo trong Flutter, đây là một tiện ích hữu ích cho chính yêu cầu này.

  1. Tạo tệp ticker_builder.dart trong thư mục lib/widgets, sau đó thêm nội dung sau vào tệp đó:

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);
}

Tiện ích này là một chiếc búa tạ. Nút này sẽ tạo lại nội dung trên mọi khung hình. Điều này thường không được khuyến khích, nhưng so với tải trọng tính toán của việc tìm kiếm ô chữ, tải trọng tính toán của việc vẽ lại thời gian đã trôi qua ở mỗi khung hình có thể sẽ biến mất trong nhiễu. Để tận dụng thông tin mới được suy luận này, bạn cần tạo một tiện ích mới.

  1. Tạo tệp crossword_info_widget.dart trong thư mục lib/widgets, sau đó thêm nội dung sau vào tệp đó:

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),
        ),
      ],
    ),
  );
}

Tiện ích này là một ví dụ điển hình về sức mạnh của các nhà cung cấp Riverpod. Tiện ích này sẽ được đánh dấu để tạo lại khi có bất kỳ nhà cung cấp nào trong số 5 nhà cung cấp cập nhật. Thay đổi bắt buộc cuối cùng trong bước này là tích hợp tiện ích mới này vào giao diện người dùng.

  1. Chỉnh sửa tệp crossword_generator_app.dart như sau:

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),
    ),
  );
}

Hai thay đổi ở đây minh hoạ các phương pháp tích hợp nhà cung cấp khác nhau. Trong phương thức build của CrosswordGeneratorApp, bạn đã giới thiệu một trình tạo Consumer mới để chứa vùng buộc phải tạo lại khi màn hình hiển thị thông tin hiện hoặc ẩn. Mặt khác, toàn bộ trình đơn thả xuống là một ConsumerWidget, sẽ được tạo lại cho dù đó là việc đổi kích thước ô chữ hay việc hiện hoặc ẩn màn hình hiển thị thông tin. Việc chọn cách tiếp cận nào luôn là sự đánh đổi về kỹ thuật giữa sự đơn giản và chi phí tính toán lại bố cục của các cây tiện ích được tạo lại.

Khi chạy ứng dụng, người dùng sẽ hiểu rõ hơn về tiến trình tạo ô chữ. Tuy nhiên, gần cuối quá trình tạo ô chữ, chúng ta thấy có một khoảng thời gian mà các con số thay đổi, nhưng có rất ít thay đổi trong lưới ký tự.

Cửa sổ ứng dụng Crossword Generator (Trình tạo ô chữ), lần này có các từ nhỏ hơn, dễ nhận biết và một lớp phủ nổi ở góc dưới cùng bên phải có số liệu thống kê về lần tạo hiện tại

Bạn nên tìm hiểu thêm thông tin chi tiết về những gì đang xảy ra và lý do.

8. Song song hoá bằng luồng

Lý do hiệu suất giảm

Khi ô chữ sắp hoàn thành, thuật toán sẽ chậm lại vì còn ít lựa chọn hợp lệ để đặt từ. Thuật toán này thử nhiều tổ hợp không hoạt động. Xử lý đơn luồng không thể khám phá hiệu quả nhiều lựa chọn

Trực quan hoá thuật toán

Để hiểu lý do khiến mọi thứ trở nên chậm chạp ở cuối, bạn nên hình dung được những gì thuật toán đang làm. Một phần quan trọng là locationsToTry nổi bật trong WorkQueue. TableView cung cấp cho chúng ta một cách hữu ích để điều tra vấn đề này. Chúng ta có thể thay đổi màu ô dựa trên việc ô đó có nằm trong locationsToTry hay không.

Để bắt đầu, hãy làm theo các bước sau:

  1. Sửa đổi tệp crossword_widget.dart như sau:

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,
          ),
        ),
      ),
    );
  }
}

Khi chạy mã này, bạn sẽ thấy hình ảnh trực quan về những vị trí nổi bật mà thuật toán chưa điều tra.

Crossword Generator (Công cụ tạo ô chữ) cho thấy quá trình tạo đang diễn ra. Một số chữ có văn bản màu trắng trên nền xanh dương đậm, trong khi những chữ khác có văn bản màu xanh dương trên nền trắng.

Điều thú vị khi xem quá trình giải ô chữ là có rất nhiều điểm cần điều tra nhưng không mang lại kết quả hữu ích nào. Có hai lựa chọn ở đây: một là giới hạn quá trình điều tra khi một tỷ lệ phần trăm nhất định của các ô trong ô chữ được điền và hai là điều tra nhiều điểm quan tâm cùng một lúc. Con đường thứ hai nghe có vẻ thú vị hơn, vì vậy, đã đến lúc thực hiện con đường đó.

  1. Chỉnh sửa tệp isolates.dart. Đây gần như là một bản viết lại hoàn chỉnh của mã để chia những gì đang được tính toán trong một quy trình riêng biệt ở chế độ nền thành một nhóm gồm N quy trình riêng biệt ở chế độ nề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);
}

Tìm hiểu về Kiến trúc đa phân lập

Hầu hết mã này đều quen thuộc vì logic nghiệp vụ cốt lõi không thay đổi. Điều đã thay đổi là hiện có 2 lớp lệnh gọi compute. Lớp đầu tiên chịu trách nhiệm phân bổ các vị trí riêng lẻ cho N worker isolate để tìm kiếm, sau đó kết hợp lại các kết quả khi tất cả N worker isolate hoàn tất. Lớp thứ hai bao gồm N worker isolate. Việc điều chỉnh N để đạt được hiệu suất tốt nhất phụ thuộc vào cả máy tính và dữ liệu được đề cập. Lưới càng lớn thì càng có nhiều công nhân có thể làm việc cùng nhau mà không cản trở lẫn nhau.

Một điểm thú vị cần lưu ý là cách mã này hiện xử lý vấn đề về việc các bao đóng nắm bắt những thứ mà chúng không nên nắm bắt. Hiện không có đường nào bị đóng. Các hàm _generate_generateWorker được xác định là các hàm cấp cao nhất, không có môi trường xung quanh để ghi lại. Các đối số và kết quả của cả hai hàm này đều ở dạng bản ghi Dart. Đây là một cách giải quyết vấn đề một giá trị đầu vào, một giá trị đầu ra theo ngữ nghĩa của lệnh gọi compute.

Giờ đây, bạn có thể tạo một nhóm các worker chạy trong nền để tìm kiếm những từ lồng vào nhau trong một lưới nhằm tạo thành một trò chơi ô chữ. Đã đến lúc bạn cung cấp khả năng đó cho phần còn lại của công cụ tạo ô chữ.

  1. Chỉnh sửa tệp providers.dart bằng cách chỉnh sửa trình cung cấp workQueue như sau:

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();
}
  1. Thêm trình cung cấp WorkerCount vào cuối tệp như sau:

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.

Với 2 thay đổi này, lớp nhà cung cấp hiện cung cấp một cách để đặt số lượng worker tối đa cho nhóm riêng biệt ở chế độ nền theo cách định cấu hình chính xác các hàm riêng biệt.

  1. Cập nhật tệp crossword_info_widget.dart bằng cách sửa đổi CrosswordInfoWidget như sau:

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,
                      ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}
  1. Sửa đổi tệp crossword_generator_app.dart bằng cách thêm phần sau vào tiện ích _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),
    ),
  );
}

Nếu chạy ứng dụng ngay bây giờ, bạn sẽ có thể sửa đổi số lượng các thành phần riêng biệt ở chế độ nền được khởi tạo để tìm kiếm các từ cần đưa vào ô chữ.

  1. Nhấp vào biểu tượng bánh răng để mở trình đơn theo bối cảnh chứa kích thước của ô chữ, có hiển thị số liệu thống kê về ô chữ đã tạo hay không và hiện tại là số lượng các ô chữ riêng biệt cần sử dụng.

Cửa sổ Crossword Generator (Trình tạo ô chữ) có các từ và số liệu thống kê

Điểm kiểm tra: Hiệu suất đa luồng

Việc chạy trình tạo ô chữ đã giảm đáng kể thời gian tính toán cho một ô chữ 80x44 bằng cách sử dụng đồng thời nhiều lõi. Bạn sẽ nhận thấy:

  • Tạo ô chữ nhanh hơn với số lượng nhân viên lớn hơn
  • Giao diện người dùng phản hồi mượt mà trong quá trình tạo
  • Số liệu thống kê theo thời gian thực cho biết tiến trình tạo
  • Phản hồi trực quan về các khu vực khám phá thuật toán

9. Biến việc học thành trò chơi

Trò chơi mà chúng tôi đang xây dựng: Trò chơi ô chữ có thể chơi

Phần cuối cùng này thực sự là một vòng thưởng. Bạn sẽ áp dụng tất cả các kỹ thuật đã học trong khi xây dựng trình tạo ô chữ và dùng các kỹ thuật này để tạo một trò chơi. Bạn sẽ:

  1. Tạo ô chữ: Sử dụng trình tạo ô chữ để tạo các ô chữ có thể giải được
  2. Tạo lựa chọn từ: Cung cấp nhiều lựa chọn từ cho mỗi vị trí
  3. Cho phép tương tác: Cho phép người dùng chọn và đặt từ
  4. Xác thực giải pháp: Kiểm tra xem ô chữ đã hoàn thành có chính xác hay không

Bạn sẽ dùng trình tạo ô chữ để tạo một ô chữ. Bạn sẽ sử dụng lại các thành ngữ trong trình đơn theo bối cảnh để cho phép người dùng chọn và bỏ chọn các từ để đặt vào nhiều lỗ có hình dạng từ trong lưới. Tất cả đều nhằm mục đích hoàn thành ô chữ.

Tôi sẽ không nói rằng trò chơi này đã hoàn thiện, vì thực tế là nó còn lâu mới hoàn thiện. Có những vấn đề về sự cân bằng và độ khó mà bạn có thể giải quyết bằng cách cải thiện lựa chọn từ thay thế. Không có hướng dẫn nào để dẫn người dùng vào câu đố. Tôi thậm chí sẽ không đề cập đến màn hình "Bạn đã thắng!" sơ sài.

Điểm đánh đổi ở đây là để tinh chỉnh đúng cách trò chơi nguyên mẫu này thành một trò chơi hoàn chỉnh, bạn sẽ cần nhiều mã hơn đáng kể. Nhiều mã hơn mức cần thiết trong một lớp học lập trình. Vì vậy, thay vào đó, đây là một bước chạy tốc độ được thiết kế để củng cố các kỹ thuật đã học được cho đến nay trong lớp học lập trình này bằng cách thay đổi vị trí và cách sử dụng các kỹ thuật đó. Hy vọng điều này sẽ củng cố những bài học bạn đã học trước đó trong lớp học lập trình này. Ngoài ra, bạn có thể tiếp tục xây dựng trải nghiệm của riêng mình dựa trên mã này. Chúng tôi rất mong được thấy những thành quả mà bạn có thể xây dựng!

Để bắt đầu, hãy làm theo các bước sau:

  1. Xoá mọi nội dung trong thư mục lib/widgets. Bạn sẽ tạo các tiện ích mới bóng bẩy cho trò chơi của mình. Thành phần đó mượn rất nhiều từ các tiện ích cũ.
  1. Chỉnh sửa tệp model.dart để cập nhật phương thức addWord của Crossword như sau:

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;
    }
  }

Việc sửa đổi nhỏ này đối với mô hình Ô chữ cho phép thêm những từ không trùng lặp. Bạn nên cho phép người chơi chơi ở bất kỳ đâu trên bàn cờ và vẫn có thể dùng Crossword làm mô hình cơ sở để lưu trữ các nước đi của người chơi. Đó chỉ là một danh sách các từ ở những vị trí cụ thể được đặt theo một hướng cụ thể.

  1. Thêm lớp mô hình CrosswordPuzzleGame vào cuối tệp 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;

Các nội dung cập nhật cho tệp providers.dart là một loạt thay đổi thú vị. Hầu hết các nhà cung cấp có mặt để hỗ trợ thu thập số liệu thống kê đều đã bị xoá. Khả năng thay đổi số lượng các thành phần riêng biệt ở chế độ nền đã bị xoá và thay thế bằng một hằng số. Ngoài ra, còn có một trình cung cấp mới cho phép truy cập vào mô hình CrosswordPuzzleGame mới mà bạn đã thêm trước đó.

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);

Phần thú vị nhất của nhà cung cấp Puzzle là những mưu mẹo được thực hiện để che giấu chi phí tạo CrosswordPuzzleGame từ CrosswordwordList, cũng như chi phí chọn một từ. Cả hai thao tác này khi được thực hiện mà không có sự trợ giúp của một Isolate nền sẽ gây ra tương tác chậm chạp trên giao diện người dùng. Bằng cách sử dụng một số thủ thuật để đẩy kết quả trung gian ra ngoài trong khi tính toán kết quả cuối cùng ở chế độ nền, bạn sẽ có được một giao diện người dùng phản hồi trong khi các phép tính cần thiết diễn ra ở chế độ nền.

  1. Trong thư mục lib/widgets hiện đang trống, hãy tạo một tệp crossword_puzzle_app.dart có nội dung sau:

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),
    ),
  );
}

Hầu hết nội dung trong tệp này đều khá quen thuộc. Có, sẽ có các tiện ích không xác định mà bạn sẽ bắt đầu khắc phục ngay bây giờ.

  1. Tạo tệp crossword_generator_widget.dart rồi thêm nội dung sau vào tệp đó:

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,
          ),
        ),
      ),
    );
  }
}

Bạn cũng nên làm quen với điều này. Điểm khác biệt chính là thay vì hiển thị các ký tự của những từ đang được tạo, giờ đây, bạn đang hiển thị một ký tự Unicode để biểu thị sự hiện diện của một ký tự không xác định. Cần phải cải thiện tính thẩm mỹ của phần này.

  1. Tạo tệp crossword_puzzle_widget.dart rồi thêm nội dung sau vào tệp đó:

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),
    );
  }
}

Tiện ích này có phần phức tạp hơn tiện ích trước, mặc dù được tạo từ những thành phần mà bạn đã từng thấy ở những nơi khác. Giờ đây, mỗi ô được điền sẵn sẽ tạo ra một trình đơn theo bối cảnh khi người dùng nhấp vào ô đó, trong đó liệt kê những từ mà người dùng có thể chọn. Nếu bạn đã chọn một số từ, thì những từ xung đột sẽ không chọn được. Để bỏ chọn một từ, người dùng nhấn vào mục trong trình đơn cho từ đó.

Giả sử người chơi có thể chọn các từ để điền vào toàn bộ ô chữ, bạn cần có màn hình "Bạn đã thắng!".

  1. Tạo tệp puzzle_completed_widget.dart rồi thêm nội dung sau vào tệp đó:

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),
      ),
    );
  }
}

Tôi tin rằng bạn có thể tiếp thu và làm cho nó thú vị hơn. Để tìm hiểu thêm về các công cụ tạo ảnh động, hãy xem lớp học lập trình Tạo giao diện người dùng thế hệ tiếp theo trong Flutter.

  1. Chỉnh sửa tệp lib/main.dart như sau:

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
      ),
    ),
  );
}

Khi chạy ứng dụng này, bạn sẽ thấy ảnh động khi trình tạo ô chữ tạo ra ô chữ của bạn. Sau đó, bạn sẽ thấy một câu đố trống để giải. Giả sử bạn giải được, bạn sẽ thấy một màn hình như sau:

Cửa sổ ứng dụng Ô chữ hiển thị văn bản &quot;Đã hoàn thành ô chữ!&quot;

10. Xin chúc mừng

Xin chúc mừng! Bạn đã xây dựng thành công một trò chơi đố vui bằng Flutter!

Bạn đã tạo một trình tạo ô chữ và biến nó thành một trò chơi ô chữ. Bạn đã nắm vững cách chạy các phép tính trong nền trong một nhóm các isolate. Bạn đã sử dụng cấu trúc dữ liệu bất biến để dễ dàng triển khai thuật toán quay lui. Và bạn đã dành thời gian chất lượng cho TableView, điều này sẽ hữu ích vào lần tiếp theo bạn cần hiển thị dữ liệu dạng bảng.

Tìm hiểu thêm