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

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

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

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

Ảnh động minh hoạ một câu đố ô chữ.

Với công cụ này làm cơ sở, bạn sẽ tạo ra một câu đố ô chữ sử dụng máy tạo ô chữ để tạo câu đố để người dùng giải. Bạn có thể dùng câu đố này trên Android, iOS, Windows, macOS và Linux. Dưới đây là nội dung trên Android:

Ảnh chụp màn hình một trò chơi giải ô chữ 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 vùng cách ly để thực hiện công việc tính toán tốn kém mà không cản trở vòng lặp kết xuất của Flutter nhờ sự kết hợp giữa hàm compute của Flutter và bộ lọc tạo lại select của Riverpod.
  • Cách tận dụng cấu trúc dữ liệu bất biến bằng built_valuebuilt_collection để dễ dàng triển khai các kỹ thuật Good Old Fashioned AI (GOFAI) dựa trên kết quả tìm kiếm, chẳng hạn như tìm kiếm theo chiều sâu và theo dõi ngược.
  • Cách sử dụng các tính năng của gói two_dimensional_scrollables để hiển thị dữ liệu lưới theo cách nhanh chóng và trực quan.

Bạn cần có

  • SDK Flutter.
  • Visual Studio Code (Mã VS) với các trình bổ trợ Flutter và Dart.
  • Phần mềm 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 dành cho tất cả các nền tảng máy tính, Android và iOS. Bạn cần VS Code để nhắm mục tiêu Windows, Xcode để nhắm mục tiêu đến macOS hoặc iOS và Android Studio để nhắm mục tiêu Android.

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

Tạo dự án Flutter đầu tiên của bạn

  1. Chạy mã VS.
  2. Trong dòng lệnh, hãy nhập flutter new rồi chọn Flutter: New Project (Flutter: Dự án mới) trong trình đơn.

Ảnh chụp màn hình của Mã VS với

  1. Chọn Empty application (Ứng dụng trống) rồi chọn một thư mục để tạo dự án. Đây phải là bất kỳ thư mục nào không yêu cầu đặc quyền cấp cao hoặc có khoảng trắng trong đường dẫn. Các ví dụ bao gồm thư mục gốc hoặc C:\src\ của bạn.

Ảnh chụp màn hình Mã VS có Ứng dụng trống (Empty Application) hiển thị là được chọn trong quy trình đăng ký mới

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

Ảnh chụp màn hình Mã VS với

Bây giờ, Flutter sẽ tạo thư mục dự án của bạ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 scaffold 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 Mã VS, hãy nhấp vào Explorer rồi mở tệp pubspec.yaml.

Ảnh chụp một phần màn hình của Mã VS với 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:

pubspec.yaml

name: generate_crossword
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: '>=3.3.3 <4.0.0'

dependencies:
  built_collection: ^5.1.1
  built_value: ^8.9.2
  characters: ^1.3.0
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.5.1
  intl: ^0.19.0
  riverpod: ^2.5.1
  riverpod_annotation: ^2.3.5
  two_dimensional_scrollables: ^0.2.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.0
  build_runner: ^2.4.9
  built_value_generator: ^8.9.2
  custom_lint: ^0.6.4
  riverpod_generator: ^2.4.0
  riverpod_lint: ^2.3.10

flutter:
  uses-material-design: true

Tệp pubspec.yaml chỉ định thông tin cơ bản về ứng dụng, 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 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ẽ nhận được lợi ích từ tất cả các gói này trong những bước tiếp theo.

  1. Mở tệp main.dart trong thư mục lib/.

Ảnh chụp một phần màn hình của Mã VS có mũi tên cho thấy vị trí của tệp main.dart

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

lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(
    ProviderScope(
      child: MaterialApp(
        title: 'Crossword Builder',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          useMaterial3: true,
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: Scaffold(
          body: Center(
            child: Text(
              'Hello, World!',
              style: TextStyle(fontSize: 24),
            ),
          ),
        ),
      ),
    ),
  );
}
  1. Chạy mã này để kiểm tra xem mọi thứ đều hoạt động. Lớp này sẽ hiển thị một cửa sổ mới với cụm từ 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ẽ sử dụng riverpod để quản lý trạng thái.

Cửa sổ ứng dụng có dòng chữ &quot;Hello, World!&quot; (Xin chào thế giới!) ở giữa

3. Thêm từ

Các thành phần để giải đố ô chữ

Giải ô chữ chính là một danh sách các từ. Các từ được sắp xếp trong một lưới, một số nằm ngang, một số nằm xuống dưới, sao cho các từ khớp vào nhau. Việc giải một từ sẽ giúp bạn gợi ý về những từ vượt qua từ đầu tiên đó. Do đó, thành phần đầu tiên phải là một danh sách các từ.

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

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

Để bắt đầu, hãy thực hiện theo các bước sau:

  1. Sửa đổi tệp pubspec.yaml của dự án để thêm phần khai báo thành phần sau cho danh sách từ bạn đã chọn. Trang thông tin này chỉ cho thấy phần văn bản rung trong cấu hình của ứng dụng, 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ẽ đánh dấu dòng cuối cùng này kèm theo cảnh báo vì bạn chưa tạo tệp này.

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

Mã này được thiết kế dựa trên danh sách SOWPODS nêu trên, nhưng sẽ dùng được với mọi danh sách từ chỉ chứa ký tự A-Z. Việc mở rộng cơ sở mã này để làm việc với nhiều bộ ký tự khác nhau chỉ là một bài tập dành cho trình đọc.

Tải các từ

Để 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 tệp providers.dart trong thư mục lib.
  2. Thêm phần 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_annotation/riverpod_annotation.dart';

part 'providers.g.dart';

/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
  // This codebase requires that all words consist of lowercase characters
  // in the range 'a'-'z'. Words containing uppercase letters will be
  // lowercased, and words containing runes outside this range will
  // be removed.

  final re = RegExp(r'^[a-z]+$');
  final words = await rootBundle.loadString('assets/words.txt');
  return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
    ..map((word) => word.toLowerCase().trim())
    ..where((word) => word.length > 2)
    ..where((word) => re.hasMatch(word)));
}

Đây là nhà cung cấp Riverpod đầu tiên của bạn cho cơ sở mã này. Bạn sẽ nhận thấy rằng có một số khu vực mà trình chỉnh sửa của bạn sẽ phàn nàn vì lớp không xác định hoặc mục tiêu chưa được tạo. Dự án này sử dụng việc tạo mã cho nhiều phần phụ thuộc, bao gồm cả Riverpod, vì vậy, lỗi lớp không xác định có thể xảy ra.

  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)

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

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

  1. Tạo tệp crossword_generator_app.dart trong thư mục lib/widgets.
  2. Thêm phần 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ị khi xem ở hai hướng riêng biệt. Đầu tiên là tiện ích _EagerInitialization. Nhiệm vụ duy nhất là yêu cầu nhà cung cấp wordList mà bạn đã tạo ở trên tải danh sách từ. Tiện ích này giúp đạt được mục tiêu này bằng cách lắng nghe nhà cung cấp thông qua lệnh gọi ref.watch(). Bạn có thể đọc thêm về kỹ thuật này trong tài liệu về Riverpod về Hoạt động khởi tạo nhà cung cấp Eager.

Đ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 đã biết, trình cung cấp wordList được định nghĩa là một hàm không đồng bộ, vì việc tải nội dung từ ổ đĩa bị 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 đó là bộ chuyển đổi giữa thế giới nhà cung cấp không đồng bộ 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ý ba trạng thái tiềm năng có thể chứa giá trị trong tương lai. Tương lai có thể đã được giải quyết thành công. Trong trường hợp đó, lệnh gọi lại data đượ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 lệnh gọi lại có thể vẫn đang tải. Loại dữ liệu trả về của 3 lệnh gọi lại phải có kiểu dữ liệu trả về tương thích, vì phương thức when trả về lệnh gọi lại đã gọi. Trong trường hợp này, kết quả của phương thức when được hiển thị 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(
          useMaterial3: true,
          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 gần như vĩnh viễn.

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

4. Hiển thị các từ ở dạng lưới

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

Để bắt đầu, hãy thực hiện theo các bước sau:

  1. Tạo một tệp model.dart trong thư mục lib rồi thêm nội dung sau đây 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ẽ sử dụng để tạo ô chữ. Về cơ bản, giải đố ô chữ là một danh sách các từ theo chiều ngang và chiều dọ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 Crossword có kích thước phù hợp với hàm khởi tạo có tên Crossword.crossword, sau đó thêm từ bằng phương thức addWord. Trong quá trình xây dựng giá trị cuối cùng, một lưới gồm các CrosswordCharacter sẽ đượ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 một tệp utils trong thư mục lib rồi thêm nội dung sau đây 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. Các phương thức mở rộng giúp bạn dễ dàng 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 đó hoạt động bên ngoài tệp utils.dart.

  1. Trong tệp lib/providers.dart, hãy thêm các lệnh 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 lệnh nhập này sẽ hiển thị mô hình được xác định ở trên cho các trình cung cấp mà bạn sắp tạo. Dữ liệu nhập dart:math được bao gồm cho Random, dữ liệu nhập flutter/foundation.dart được bao gồm 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 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(CrosswordRef ref) async* {
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);

  var crossword =
      model.Crossword.crossword(width: size.width, height: size.height);

  yield* wordListAsync.when(
    data: (wordList) async* {
      while (crossword.characters.length < size.width * size.height * 0.8) {
        final word = wordList.randomElement();
        final direction =
            _random.nextBool() ? model.Direction.across : model.Direction.down;
        final location = model.Location.at(
            _random.nextInt(size.width), _random.nextInt(size.height));

        crossword = crossword.addWord(
            word: word, direction: direction, location: location);
        yield crossword;
        await Future.delayed(Duration(milliseconds: 100));
      }

      yield crossword;
    },
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield crossword;
    },
    loading: () async* {
      yield crossword;
    },
  );
}

Những thay đổi này sẽ thêm 2 nhà cung cấp vào ứng dụng của bạn. Phương thức đầu tiên là Size. Đây là một biến toàn cục chứa giá trị hiện được chọn của giá trị 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. Trình cung cấp thứ hai là 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. Công cụ này được xây dựng bằng cách sử dụng tính năng hỗ trợ của Dart dành cho trình tạo, như được đánh dấu bằng async* trên hàm. Tức là thay vì kết thúc ở kết quả trả về, phương thức này sẽ cho ra một chuỗi 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 sự hiện diện của một cặp lệnh gọi ref.watch ở đầu hàm nhà cung cấp crossword, luồng Crossword sẽ được hệ thống Riverpod khởi động lại mỗi khi kích thước đã chọn của ô chữ thay đổi và khi danh sách từ tải xong.

Bây giờ, bạn đã có mã để tạo Ô chữ, mặc dù có đầy đủ các từ ngẫu nhiên, nhưng sẽ rất hữu ích nếu hiển thị chúng cho người dùng công cụ này.

  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ể trực tiếp dựa vào trình cung cấp Size để xác định kích thước của lưới nhằm hiện các ký tự của Crossword. Việc hiển thị lưới này được thực hiện bằng tiện ích TableView trong gói two_dimensional_scrollables.

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

Nếu nhìn vào tham số của ref.watch, bạn sẽ thấy có một lớp khác để tránh việc tính toán lại bố cục, bằng cách sử dụng crosswordProvider.select. Tức là ref.watch sẽ chỉ kích hoạt việc tạo lại nội dung của TableViewCell khi ký tự mà ô chịu trách nhiệm kết xuất các thay đổi. Việc giảm số lượt kết xuất lại là một phần quan trọng trong việc 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(),                           // Replaces everything that was here before
        ),
      ),
    );
  }
}

class _EagerInitialization extends ConsumerWidget {
  const _EagerInitialization({required this.child});
  final Widget child;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.watch(wordListProvider);
    return child;
  }
}

class _CrosswordGeneratorMenu extends ConsumerWidget {        // Add from here
  @override
  Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
        menuChildren: [
          for (final entry in CrosswordSize.values)
            MenuItemButton(
              onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
              leadingIcon: entry == ref.watch(sizeProvider)
                  ? Icon(Icons.radio_button_checked_outlined)
                  : Icon(Icons.radio_button_unchecked_outlined),
              child: Text(entry.label),
            ),
        ],
        builder: (context, controller, child) => IconButton(
          onPressed: () => controller.open(),
          icon: Icon(Icons.settings),
        ),
      );                                                      // To here.
}

Có một vài thứ đã 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 lệnh gọi đến CrosswordWidget được xác định trong tệp trước. Thay đổi quan trọng khác là việc bắt đầu 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 MenuItemButton được thêm trong các bước trong tương lai. Khi chạy ứng dụng, bạn sẽ thấy như sau:

Cửa sổ ứng dụng có tiêu đề Trình tạo ô chữ và một lưới ký tự được sắp xếp dưới dạng các từ chồng lên nhau mà không có vần điệu hoặc lý do

Có các ký tự hiển thị 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. Tuy nhiên, các từ không được sắp xếp như một câu đố ô chữ. Đây là kết quả của việc không thực thi bất kỳ quy tắc ràng buộc nào về cách thêm từ vào ô chữ. Tóm lại, đây là một đám rối. Điều gì đó mà bạn sẽ bắt đầu kiểm soát trong bước tiếp theo!

5. Thực thi các quy tắc ràng buộc

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

Để bắt đầu, hãy thực hiện theo các bước sau:

  1. Mở tệp model.dart rồi thay thế mô hình Crossword bằng mẫu 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 bạn đang thực hiện đối với các tệp model.dartproviders.dart yêu cầu build_runner phải 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ự cập nhật tự động, thì giờ là lúc thích hợp để bắt đầu lại build_runner bằng dart run build_runner watch -d.

Để tận dụng chức 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. Không có gì nhiều xảy ra trong giao diện người dùng, nhưng có rất nhiều điều xảy ra nếu bạn xem nhật ký.

Cửa sổ ứng dụng Trình tạo ô chữ có các từ được bố trí dọc và xuống, 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 đang thấy một giải ô chữ xuất hiện tình cờ. Phương thức addWord trong mô hình Crossword đang từ chối mọi từ đề xuất không phù hợp với ô chữ hiện tại. Vì vậy, thật đáng kinh ngạc khi chúng ta thấy có một từ nào đó xuất hiện.

Để chuẩn bị có phương pháp hơn về việc chọn từ cần thử ở đâu, bạn nên di chuyển phép tính này ra khỏi luồng giao diện người dùng và tách riêng trong nền. Flutter có một trình bao bọc rất hữu ích để xử lý một phần công việc và chạy công việc đó ở chế độ nền, tức là 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(CrosswordRef ref) async* {
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);

  var crossword =
      model.Crossword.crossword(width: size.width, height: size.height);

  yield* wordListAsync.when(
    data: (wordList) async* {
      while (crossword.characters.length < size.width * size.height * 0.8) {
        final word = wordList.randomElement();
        final direction =
            _random.nextBool() ? model.Direction.across : model.Direction.down;
        final location = model.Location.at(
            _random.nextInt(size.width), _random.nextInt(size.height));
        try {
          var candidate = await compute(                   // Edit from here.
              ((String, model.Direction, model.Location) wordToAdd) {
            final (word, direction, location) = wordToAdd;
            return crossword.addWord(
                word: word, direction: direction, location: location);
          }, (word, direction, location));

          if (candidate != null) {
            crossword = candidate;
            yield crossword;
          }
        } catch (e) {
          debugPrint('Error running isolate: $e');
        }                                                  // To here.
      }

      yield crossword;
    },
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield crossword;
    },
    loading: () async* {
      yield crossword;
    },
  );
}

Mã này hoạt động được. Tuy nhiên, từ này có một cái bẫy. Nếu tiếp tục đi theo cách này, bạn sẽ gặp một lỗi được ghi lại như sau:

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 sang chế độ tách biệt trong nền qua một trình cung cấp, không thể gửi qua SendPort.send(). Một cách khắc phục cho vấn đề này là đảm bảo không có gì để đóng và không thể gửi được.

Bước đầu tiên là tách riêng các trình cung cấp khỏi mã Tách biệt.

  1. Tạo một tệp isolates.dart trong thư mục lib rồi 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');
    }
  }
}

Mã này trông khá quen thuộc. Đây là thành phần cốt lõi của trình cung cấp crossword, nhưng hiện đóng vai trò là một hàm trình tạo độc lập. Bây giờ, bạn có thể cập nhật tệp providers.dart để dùng hàm mới này nhằm tạo thực thể cho thao tác tách biệt trong 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_annotation/riverpod_annotation.dart';

import 'isolates.dart';                                    // Add this import
import 'model.dart' as model;
                                                           // Drop the utils.dart import

part 'providers.g.dart';

/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
  // This codebase requires that all words consist of lowercase characters
  // in the range 'a'-'z'. Words containing uppercase letters will be
  // lowercased, and words containing runes outside this range will
  // be removed.

  final re = RegExp(r'^[a-z]+$');
  final words = await rootBundle.loadString('assets/words.txt');
  return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
    ..map((word) => word.toLowerCase().trim())
    ..where((word) => word.length > 2)
    ..where((word) => re.hasMatch(word)));
}

/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
  small(width: 20, height: 11),
  medium(width: 40, height: 22),
  large(width: 80, height: 44),
  xlarge(width: 160, height: 88),
  xxlarge(width: 500, height: 500);

  const CrosswordSize({
    required this.width,
    required this.height,
  });

  final int width;
  final int height;
  String get label => '$width x $height';
}

/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
  var _size = CrosswordSize.medium;

  @override
  CrosswordSize build() => _size;

  void setSize(CrosswordSize size) {
    _size = size;
    ref.invalidateSelf();
  }
}
                                                           // Drop the _random instance
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);

  final emptyCrossword =                                   // Edit from here
      model.Crossword.crossword(width: size.width, height: size.height);

  yield* wordListAsync.when(
    data: (wordList) => exploreCrosswordSolutions(
      crossword: emptyCrossword,
      wordList: wordList,
    ),
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield emptyCrossword;
    },
    loading: () async* {
      yield emptyCrossword;                                // To here.
    },
  );
}

Nhờ vậy, giờ đây bạn đã có một công cụ để tạo các câu đố ô chữ ở nhiều kích thước, trong đó có compute nhằm tìm ra câu đố diễn ra trong một nền tách biệt. Giờ đây, nếu chỉ có đoạn mã có thể hiệu quả hơn khi quyết định những từ cần thêm vào giải đố ô chữ.

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

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ù. Nếu mã tập trung vào việc tìm các từ sẽ đính kèm vào các từ hiện tại, thay vì cố gắng đặt các từ một cách ngẫu nhiên ở bất kỳ vị trí nào trên lưới thì hệ thống sẽ tìm ra đáp án 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 hiện tạo 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. 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ụ triển khai trong nhóm thuật toán theo dõi ngược. Cách triển khai này được built_valuebuilt_collection dễ dàng triển khai hơn rất nhiều, cho phép tạo các giá trị bất biến mới xuất phát từ đó và chia sẻ trạng thái chung với giá trị bất biến bắt nguồn từ các giá trị đó. Điều này cho phép khai thác các ứng viên tiềm năng với chi phí thấp mà không cần chi phí bộ nhớ cho việc sao chép sâu.

Để bắt đầu, hãy thực hiện 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 còn các sọc màu đỏ trong tệp này sau khi thêm nội dung mới này trong hơn vài giây, hãy xác nhận rằng build_runner của bạn 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 giới thiệu tính năng ghi nhật ký để cho biết thời gian cần thiết để tạo ô chữ ở nhiều kích thước. Sẽ rất gọn gàng nếu Thời lượng có một số hình thức hiển thị được định dạng độc đáo. 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à chúng ta 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 biểu thức chuyển đổi và so khớp mẫu trên các bản ghi nhằm chọn cách thích hợp để hiển thị các thời lượng khác nhau trong khoảng 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 Tìm hiểu kỹ hơn về 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 định nghĩa 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}');
}

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

Trình tạo ô chữ, có nhiều từ cắt nhau. Đã thu nhỏ, các từ quá nhỏ nên không đọc được.

Câu hỏi hiển nhiên là tất nhiên, chúng ta có thể tiến hành nhanh hơn không? Ồ, được rồi, chúng tôi có thể.

7. Số liệu thống kê về nền tảng

Khi tạo ra một điều gì đó nhanh chóng, bạn sẽ biết được điều gì đang diễn ra. Một điều hữu ích trong việc này là hiển thị thông tin về quy trình trong quá trình thực hiện. Bây giờ, bạn nên thêm khả năng đo lường và hiển thị thông tin đó dưới dạng bảng thông tin di chuột.

Thông tin bạn 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 hữu ích đầu tiên là xác định một lớp mô hình mới có chứa thông tin bạn muốn hiển thị.

Để bắt đầu, hãy thực hiện 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 tính năng tách biệt nền đang hiển thị hàng đợi công việc, vấn đề bây giờ là một câu hỏi về cách thức và nơi để lấy số liệu thống kê từ nguồn dữ liệu này.

  1. Thay thế trình cung cấp ô chữ cũ bằng trình cung cấp hàng đợi công việc rồi thêm các trình cung cấp khác lấy thông tin từ luồng của trì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_annotation/riverpod_annotation.dart';

import 'isolates.dart';
import 'model.dart' as model;

part 'providers.g.dart';

/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
  // This codebase requires that all words consist of lowercase characters
  // in the range 'a'-'z'. Words containing uppercase letters will be
  // lowercased, and words containing runes outside this range will
  // be removed.

  final re = RegExp(r'^[a-z]+$');
  final words = await rootBundle.loadString('assets/words.txt');
  return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
    ..map((word) => word.toLowerCase().trim())
    ..where((word) => word.length > 2)
    ..where((word) => re.hasMatch(word)));
}

/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
  small(width: 20, height: 11),
  medium(width: 40, height: 22),
  large(width: 80, height: 44),
  xlarge(width: 160, height: 88),
  xxlarge(width: 500, height: 500);

  const CrosswordSize({
    required this.width,
    required this.height,
  });

  final int width;
  final int height;
  String get label => '$width x $height';
}

/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
  var _size = CrosswordSize.medium;

  @override
  CrosswordSize build() => _size;

  void setSize(CrosswordSize size) {
    _size = size;
    ref.invalidateSelf();
  }
}

@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* { // Modify this provider
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);
  final emptyCrossword =
      model.Crossword.crossword(width: size.width, height: size.height);
  final emptyWorkQueue = model.WorkQueue.from(
    crossword: emptyCrossword,
    candidateWords: BuiltSet<String>(),
    startLocation: model.Location.at(0, 0),
  );

  ref.read(startTimeProvider.notifier).start();
  ref.read(endTimeProvider.notifier).clear();

  yield* wordListAsync.when(
    data: (wordList) => exploreCrosswordSolutions(
      crossword: emptyCrossword,
      wordList: wordList,
    ),
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield emptyWorkQueue;
    },
    loading: () async* {
      yield emptyWorkQueue;
    },
  );

  ref.read(endTimeProvider.notifier).end();
}                                                          // To here.

@Riverpod(keepAlive: true)                                 // Add from here to end of file
class StartTime extends _$StartTime {
  @override
  DateTime? build() => _start;

  DateTime? _start;

  void start() {
    _start = DateTime.now();
    ref.invalidateSelf();
  }
}

@Riverpod(keepAlive: true)
class EndTime extends _$EndTime {
  @override
  DateTime? build() => _end;

  DateTime? _end;

  void clear() {
    _end = null;
    ref.invalidateSelf();
  }

  void end() {
    _end = DateTime.now();
    ref.invalidateSelf();
  }
}

const _estimatedTotalCoverage = 0.54;

@riverpod
Duration expectedRemainingTime(ExpectedRemainingTimeRef ref) {
  final startTime = ref.watch(startTimeProvider);
  final endTime = ref.watch(endTimeProvider);
  final workQueueAsync = ref.watch(workQueueProvider);

  return workQueueAsync.when(
    data: (workQueue) {
      if (startTime == null || endTime != null || workQueue.isCompleted) {
        return Duration.zero;
      }
      try {
        final soFar = DateTime.now().difference(startTime);
        final completedPercentage = min(
            0.99,
            (workQueue.crossword.characters.length /
                (workQueue.crossword.width * workQueue.crossword.height) /
                _estimatedTotalCoverage));
        final expectedTotal = soFar.inSeconds / completedPercentage;
        final expectedRemaining = expectedTotal - soFar.inSeconds;
        return Duration(seconds: expectedRemaining.toInt());
      } catch (e) {
        return Duration.zero;
      }
    },
    error: (error, stackTrace) => Duration.zero,
    loading: () => Duration.zero,
  );
}

/// A provider that holds whether to display info.
@Riverpod(keepAlive: true)
class ShowDisplayInfo extends _$ShowDisplayInfo {
  var _display = true;

  @override
  bool build() => _display;

  void toggle() {
    _display = !_display;
    ref.invalidateSelf();
  }
}

/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
  @override
  model.DisplayInfo build() => ref.watch(workQueueProvider).when(
        data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
        error: (error, stackTrace) => model.DisplayInfo.empty,
        loading: () => model.DisplayInfo.empty,
      );
}

Các nhà cung cấp mới này kết hợp giữa các trạng thái trên toàn cầu, ở dạng là liệu thông tin có nên được phủ lên trên lưới ô chữ hay không và dữ liệu phái sinh như thời gian chạy của quá trình tạo ô chữ. Tất cả những điều này đều phức tạp vì trên thực tế, trình nghe một số trạng thái này chỉ mang tính tạm thời. Không có gì đang nghe 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 thông tin bị ẩn, nhưng chúng cần nằm trong bộ nhớ nếu kết quả tính toán chính xác khi màn hình 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.

Màn hình thông tin xuất hiện một vết nhăn nhẹ. Chúng ta muốn khả năng hiển thị thời gian chạy hiện đã trôi qua, nhưng không có gì ở đây để dễ dàng buộc cập nhật liên tục thời gian hiện tại đã 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ệ mới trong Flutter, đây là một tiện ích hữu ích dành riêng cho yêu cầu này.

  1. Tạo một tệp ticker_builder.dart trong thư mục lib/widgets rồi 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ạ. Công cụ này xây dựng lại nội dung trên mọi khung hình. Thông thường, chúng ta sẽ không tán thành điều này, nhưng so với khối lượng tính toán khi tìm kiếm các câu đố giải ô chữ, khối lượng tính toán khi vẽ lại thời gian đã trôi qua của mỗi khung hình có thể sẽ biến mất khỏi tạp âm. Để tận dụng thông tin mới bắt nguồn này, đã đến lúc tạo tiện ích mới.

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

lib/widgets/crossword_info_widget.dart

class CrosswordInfoWidget extends ConsumerWidget {
  const CrosswordInfoWidget({
    super.key,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final size = ref.watch(sizeProvider);
    final displayInfo = ref.watch(displayInfoProvider);
    final startTime = ref.watch(startTimeProvider);
    final endTime = ref.watch(endTimeProvider);
    final remaining = ref.watch(expectedRemainingTimeProvider);

    return Align(
      alignment: Alignment.bottomRight,
      child: Padding(
        padding: const EdgeInsets.only(
          right: 32.0,
          bottom: 32.0,
        ),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(8),
          child: ColoredBox(
            color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
            child: Padding(
              padding: const EdgeInsets.symmetric(
                horizontal: 12,
                vertical: 8,
              ),
              child: DefaultTextStyle(
                style: TextStyle(
                    fontSize: 16, color: Theme.of(context).colorScheme.primary),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    _CrosswordInfoRichText(
                        label: 'Grid Size',
                        value: '${size.width} x ${size.height}'),
                    _CrosswordInfoRichText(
                        label: 'Words in grid',
                        value: displayInfo.wordsInGridCount),
                    _CrosswordInfoRichText(
                        label: 'Candidate words',
                        value: displayInfo.candidateWordsCount),
                    _CrosswordInfoRichText(
                        label: 'Locations to explore',
                        value: displayInfo.locationsToExploreCount),
                    _CrosswordInfoRichText(
                        label: 'Known bad locations',
                        value: displayInfo.knownBadLocationsCount),
                    _CrosswordInfoRichText(
                        label: 'Grid filled',
                        value: displayInfo.gridFilledPercentage),
                    switch ((startTime, endTime)) {
                      (null, _) => _CrosswordInfoRichText(
                          label: 'Time elapsed',
                          value: 'Not started yet',
                        ),
                      (DateTime start, null) => TickerBuilder(
                          builder: (context) => _CrosswordInfoRichText(
                            label: 'Time elapsed',
                            value: DateTime.now().difference(start).formatted,
                          ),
                        ),
                      (DateTime start, DateTime end) => _CrosswordInfoRichText(
                          label: 'Completed in',
                          value: end.difference(start).formatted),
                    },
                    if (startTime != null && endTime == null)
                      _CrosswordInfoRichText(
                          label: 'Est. remaining', value: remaining.formatted),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _CrosswordInfoRichText extends StatelessWidget {
  final String label;
  final String value;

  const _CrosswordInfoRichText({required this.label, required this.value});

  @override
  Widget build(BuildContext context) => RichText(
        text: TextSpan(
          children: [
            TextSpan(
              text: '$label ',
              style: DefaultTextStyle.of(context).style,
            ),
            TextSpan(
              text: value,
              style: DefaultTextStyle.of(context)
                  .style
                  .copyWith(fontWeight: FontWeight.bold),
            ),
          ],
        ),
      );
}

Tiện ích này là một ví dụ tiêu biểu về sức mạnh của các nhà cung cấp của Riverpod. Tiện ích này sẽ được đánh dấu để tạo lại khi bất kỳ nhà cung cấp nào trong số năm 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(
        menu Children: [
          for (final entry in CrosswordSize.values)
            MenuItemButton(
              onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
              leadingIcon: entry == ref.watch(sizeProvider)
                  ? Icon(Icons.radio_button_checked_outlined)
                  : Icon(Icons.radio_button_unchecked_outlined),
              child: Text(entry.label),
            ),
          MenuItemButton(                                  // Add from here
            leadingIcon: ref.watch(showDisplayInfoProvider)
                ? Icon(Icons.check_box_outlined)
                : Icon(Icons.check_box_outline_blank_outlined),
            onPressed: () =>
                ref.read(showDisplayInfoProvider.notifier).toggle(),
            child: Text('Display Info'),
          ),                                               // To here.
        ],
        builder: (context, controller, child) => IconButton(
          onPressed: () => controller.open(),
          icon: Icon(Icons.settings),
        ),
      );
}

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 khu vực buộc phải xây dựng lại khi màn hình thông tin hiện hoặc ẩn đi. Mặt khác, toàn bộ trình đơn thả xuống là một ConsumerWidget. Lớp này sẽ được tạo lại từ việc đổi kích thước ô chữ, hiện/ẩn màn hình thông tin. Phương pháp nào cần thực hiện luôn là sự đánh đổi về mặt kỹ thuật giữa tính đơn giản so với chi phí tính toán lại bố cục của cây tiện ích được xây dựng lại.

Việc chạy ứng dụng ngay bây giờ sẽ cung cấp cho người dùng thông tin chi tiết 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 các con số thay đổi nhưng lưới ký tự lại rất ít thay đổi.

Cửa sổ ứng dụng Trình tạo ô chữ, lần này là 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 chứa số liệu thống kê về lần chạy chiến dịch thế hệ hiện tại

Sẽ rất hữu ích nếu bạn có thêm thông tin chi tiết về điều gì đang xảy ra và lý do.

8. Song song hoá với các luồng

Để hiểu lý do tại sao mọi thứ bị chậm ở cuối, bạn có thể hình dung hoạt động của thuật toán. 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 của ô dựa trên việc ô đó có nằm trong locationsToTry hay không.

Để bắt đầu, hãy thực hiện 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 của các vị trí nổi bật mà thuật toán chưa điều tra.

Trình tạo ô chữ cho thấy quá trình tạo một phần. Một số chữ cái có văn bản màu trắng trên nền xanh dương đậm, trong khi một số khác có văn bản màu xanh dương trên nền trắng.

Điều thú vị là việc xem xét vấn đề này khi ô chữ tiến triển dần đến khi hoàn thành là vẫn còn một mảng điểm cần được điều tra nhưng sẽ không mang lại bất kỳ kết quả nào hữu ích. Có một số lựa chọn ở đây; phương pháp thứ nhất là giới hạn cuộc điều tra sau khi đã đạt được một tỷ lệ phần trăm nhất định ô chữ và phương pháp thứ hai là kiểm tra nhiều địa điểm yêu thích cùng một lúc. Lộ trình thứ hai có vẻ thú vị hơn, vì vậy hãy thực hiện điều đó.

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

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

Một vấn đề đáng chú ý là cần lưu ý cách mã này hiện xử lý vấn đề đóng cửa, ghi lại những nội dung mà chúng không nên chụp. Hiện không có trường hợp đóng cửa nào. Các hàm _generate_generateWorker được định nghĩa là hàm cấp cao nhất, không có môi trường xung quanh để thu thập. Các đối số được đưa vào 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 dễ dàng để xử lý một giá trị trong đó, một giá trị ngoài ngữ nghĩa của lệnh gọi compute.

Giờ đây, bạn có thể tạo một nhóm worker chạy ở chế độ nền để tìm kiếm các từ khớp với nhau trong một lưới nhằm tạo thành trò chơi giải ô chữ. Đã đến lúc thể hiện 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(WorkQueueRef ref) async* {
  final workers = ref.watch(workerCountProvider);          // Add this line
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);
  final emptyCrossword =
      model.Crossword.crossword(width: size.width, height: size.height);
  final emptyWorkQueue = model.WorkQueue.from(
    crossword: emptyCrossword,
    candidateWords: BuiltSet<String>(),
    startLocation: model.Location.at(0, 0),
  );

  ref.read(startTimeProvider.notifier).start();
  ref.read(endTimeProvider.notifier).clear();

  yield* wordListAsync.when(
    data: (wordList) => exploreCrosswordSolutions(
      crossword: emptyCrossword,
      wordList: wordList,
      maxWorkerCount: workers.count,                       // Add this line
    ),
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield emptyWorkQueue;
    },
    loading: () async* {
      yield emptyWorkQueue;
    },
  );

  ref.read(endTimeProvider.notifier).end();
}
  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 cho thấy cách đặt số lượng worker tối đa cho nhóm tách biệt ở chế độ nền theo cách các hàm tách biệt được định cấu hình chính xác.

  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 these two lines
                        label: 'Max worker count', value: workerCount),
                    switch ((startTime, endTime)) {
                      (null, _) => _CrosswordInfoRichText(
                          label: 'Time elapsed',
                          value: 'Not started yet',
                        ),
                      (DateTime start, null) => TickerBuilder(
                          builder: (context) => _CrosswordInfoRichText(
                            label: 'Time elapsed',
                            value: DateTime.now().difference(start).formatted,
                          ),
                        ),
                      (DateTime start, DateTime end) => _CrosswordInfoRichText(
                          label: 'Completed in',
                          value: end.difference(start).formatted),
                    },
                    if (startTime != null && endTime == null)
                      _CrosswordInfoRichText(
                          label: 'Est. remaining', value: remaining.formatted),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}
  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ể chỉnh sửa số lượng thành phần tách biệt trong nền được tạo thực thể để tìm các từ có thể đưa vào ô chữ.

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

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

Việc chạy trình tạo ô chữ đã làm 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.

9. Biến nó thành trò 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à sử dụng các kỹ thuật này để xây dựng trò chơi. Bạn sẽ sử dụng trình tạo ô chữ để tạo một câu đố ô chữ. Bạn sẽ sử dụng lại thành ngữ trình đơn theo ngữ cảnh để cho phép người dùng chọn và bỏ chọn các từ cần đặt vào các lỗ hình từ khác nhau trong lưới. Tất cả đều nhằm hoàn thành giải ô chữ.

Tôi sẽ không nói rằng trò chơi này chỉ trau chuốt hay đã hoàn thiện, thật ra nó không còn như thế. Có một số vấn đề về sự cân bằng và độ khó có thể được 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 để dẫn dắt người dùng và ảnh động tư duy khiến người dùng có rất nhiều yêu cầu. Tôi thậm chí sẽ không đề cập đến chuyện "Bạn đã thắng!" màn hình.

Sự đánh đổi ở đây là việc hoàn thiện trò chơi proto này thành một trò chơi hoàn chỉnh sẽ tốn nhiều mã hơn đáng kể. Nhiều mã hơn nên có trong một lớp học lập trình. Thay vào đó, đây là một bước chạy nhanh được thiết kế để củng cố các kỹ thuật đã học trong lớp học lập trình này bằng cách thay đổi vị trí và cách thức sử dụng những kỹ thuật đó. Hy vọng những nội dung này sẽ củng cố những kiến thức đã học trước đây trong lớp học lập trình này. Ngoài ra, bạn có thể tiếp tục và 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 muốn xem thành quả của bạn!

Để bắt đầu, hãy thực hiện theo các bước sau:

  1. Xoá mọi thứ trong thư mục lib/widgets. Bạn sẽ tạo các tiện ích mới sáng bóng cho trò chơi của mình. Hình thức này cũng giống như việc mượn rất nhiều từ các tiện ích cũ.
  2. 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;
    }
  }

Sửa đổi nhỏ này đối với mô hình Giải ô chữ của bạn cho phép bạn thêm các từ mà không chồng chéo lên nhau. Rất hữu ích khi cho phép người chơi chơi ở bất kỳ vị trí nào trên một bảng mà vẫn có thể sử dụng Crossword làm mô hình cơ sở để lưu trữ nước đi của người chơi. Đó chỉ là một danh sách các từ tại 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;

Nội dung cập nhật cho tệp providers.dart là một túi tổng hợp các thay đổi thú vị. Hầu hết các nhà cung cấp hiện có để hỗ trợ thu thập số liệu thống kê đã bị xóa. Đã xoá khả năng thay đổi số lượng các khoảng cách ly của nền và thay thế bằng hằng số. Ngoài ra, còn có một nhà cung cấp mới cấp quyền truy cập vào mô hình CrosswordPuzzleGame mới mà bạn vừa thêm ở trên.

lib/providers.dart

import 'dart:convert';
                                                           // Drop the dart:math import

import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import 'isolates.dart';
import 'model.dart' as model;

part 'providers.g.dart';

const backgroundWorkerCount = 4;                           // Add this line

/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
  // This codebase requires that all words consist of lowercase characters
  // in the range 'a'-'z'. Words containing uppercase letters will be
  // lowercased, and words containing runes outside this range will
  // be removed.

  final re = RegExp(r'^[a-z]+$');
  final words = await rootBundle.loadString('assets/words.txt');
  return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
    ..map((word) => word.toLowerCase().trim())
    ..where((word) => word.length > 2)
    ..where((word) => re.hasMatch(word)));
}

/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
  small(width: 20, height: 11),
  medium(width: 40, height: 22),
  large(width: 80, height: 44),
  xlarge(width: 160, height: 88),
  xxlarge(width: 500, height: 500);

  const CrosswordSize({
    required this.width,
    required this.height,
  });

  final int width;
  final int height;
  String get label => '$width x $height';
}

/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
  var _size = CrosswordSize.medium;

  @override
  CrosswordSize build() => _size;

  void setSize(CrosswordSize size) {
    _size = size;
    ref.invalidateSelf();
  }
}

@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* {
  final size = ref.watch(sizeProvider);                   // Drop the ref.watch(workerCountProvider)
  final wordListAsync = ref.watch(wordListProvider);
  final emptyCrossword =
      model.Crossword.crossword(width: size.width, height: size.height);
  final emptyWorkQueue = model.WorkQueue.from(
    crossword: emptyCrossword,
    candidateWords: BuiltSet<String>(),
    startLocation: model.Location.at(0, 0),
  );
                                                          // Drop the startTimeProvider and endTimeProvider refs
  yield* wordListAsync.when(
    data: (wordList) => exploreCrosswordSolutions(
      crossword: emptyCrossword,
      wordList: wordList,
      maxWorkerCount: backgroundWorkerCount,              // Edit this line
    ),
    error: (error, stackTrace) async* {
      debugPrint('Error loading word list: $error');
      yield emptyWorkQueue;
    },
    loading: () async* {
      yield emptyWorkQueue;
    },
  );
}                                                         // Drop the endTimeProvider ref

@riverpod                                                 // Add from here to end of file
class Puzzle extends _$Puzzle {
  model.CrosswordPuzzleGame _puzzle = model.CrosswordPuzzleGame.from(
    crossword: model.Crossword.crossword(width: 0, height: 0),
    candidateWords: BuiltSet<String>(),
  );

  @override
  model.CrosswordPuzzleGame build() {
    final size = ref.watch(sizeProvider);
    final wordList = ref.watch(wordListProvider).value;
    final workQueue = ref.watch(workQueueProvider).value;

    if (wordList != null &&
        workQueue != null &&
        workQueue.isCompleted &&
        (_puzzle.crossword.height != size.height ||
            _puzzle.crossword.width != size.width ||
            _puzzle.crossword != workQueue.crossword)) {
      compute(_puzzleFromCrosswordTrampoline, (workQueue.crossword, wordList))
          .then((puzzle) {
        _puzzle = puzzle;
        ref.invalidateSelf();
      });
    }

    return _puzzle;
  }

  Future<void> selectWord({
    required model.Location location,
    required String word,
    required model.Direction direction,
  }) async {
    final candidate = await compute(
        _puzzleSelectWordTrampoline, (_puzzle, location, word, direction));

    if (candidate != null) {
      _puzzle = candidate;
      ref.invalidateSelf();
    } else {
      debugPrint('Invalid word selection: $word');
    }
  }

  bool canSelectWord({
    required model.Location location,
    required String word,
    required model.Direction direction,
  }) {
    return _puzzle.canSelectWord(
      location: location,
      word: word,
      direction: direction,
    );
  }
}

// Trampoline functions to disentangle these Isolate target calls from the
// unsendable reference to the [Puzzle] provider.

Future<model.CrosswordPuzzleGame> _puzzleFromCrosswordTrampoline(
        (model.Crossword, BuiltSet<String>) args) async =>
    model.CrosswordPuzzleGame.from(crossword: args.$1, candidateWords: args.$2);

model.CrosswordPuzzleGame? _puzzleSelectWordTrampoline(
        (
          model.CrosswordPuzzleGame,
          model.Location,
          String,
          model.Direction
        ) args) =>
    args.$1.selectWord(location: args.$2, word: args.$3, direction: args.$4);

Phần thú vị nhất của trình cung cấp Puzzle là các chiến thuật được thực hiện để giải thích chi phí tạo CrosswordPuzzleGame từ CrosswordwordList cũng như chi phí chọn từ. Cả hai thao tác này khi được thực hiện mà không có sự hỗ trợ của tính năng tách biệt nền đều khiến giao diện người dùng tương tác chậm. Bằng cách sử dụng một chút lực để đưa ra kết quả trung gian trong khi tính toán kết quả cuối cùng ở chế độ nền, bạn sẽ có được giao diện người dùng thích ứng trong khi các phép tính bắt buộc đang 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 các tệp này hiện đã khá quen thuộc. Có, sẽ có những tiện ích không xác định mà bây giờ bạn sẽ bắt đầu khắc phục.

  1. Tạo một 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),
        ),
      ),
    );
  }
}

Tính năng này cũng khá quen thuộc. Điểm khác biệt chính là thay vì hiển thị ký tự của những từ được tạo, giờ đây, bạn sẽ 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. Điều này thực sự có thể phải mất một số công sức để cải thiện tính thẩm mỹ.

  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ó độ nét cao hơn một chút so với tiện ích trước, mặc dù nó được xây dựng từ các thành phần mà bạn đã thấy sử dụng ở những nơi khác trong quá khứ. Giờ đây, mỗi ô được điền sẵn sẽ tạo ra một trình đơn theo bối cảnh khi được nhấp vào. Trình đơn này liệt kê các từ mà người dùng có thể chọn. Nếu các từ đã được chọn, bạn sẽ không thể chọn những từ gây xung đột. Để 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 toàn bộ ô chữ, thì bạn cần có câu "Bạn đã thắng!" màn hình.

  1. Tạo một 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 chắc chắn bạn sẽ chấp nhận và khiến nội dung trở nên thú vị hơn. Để tìm hiểu thêm về các công cụ ảnh động, hãy xem lớp học lập trình Xây dựng giao diện người dùng thế hệ mới 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(
          useMaterial3: true,
          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 công cụ tạo ô chữ tạo ra câu đố của bạn. Sau đó, bạn sẽ thấy một câu đố trống cần giải. Giả sử bạn giải quyết xong bài toán, bạn sẽ thấy một màn hình như sau:

Cửa sổ ứng dụng Giải ô chữ hiển thị dòng chữ &quot;Đã hoàn thành câu đố!&quot;

10. Xin chúc mừng

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

Bạn đã tạo một máy tạo ô chữ rồi trở thành một trò chơi giải đố. Bạn đã thành thạo việc chạy các phép tính ở chế độ nền trong một nhóm các vùng cách ly. 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 theo dõi ngược. Và bạn đã dành thời gian chất lượng với TableView, điều này sẽ hữu ích khi bạn cần hiển thị dữ liệu dạng bảng vào lần tới.

Tìm hiểu thêm