Układaj słowne łamigłówki w aplikacji Flutter

1. Zanim zaczniesz

Wyobraź sobie, że ktoś pyta Cię, czy można stworzyć największą na świecie krzyżówkę. Przypominasz sobie techniki AI, których uczyłeś się w szkole, i zastanawiasz się, czy możesz użyć Fluttera do zbadania opcji algorytmicznych, aby tworzyć rozwiązania problemów wymagających dużej mocy obliczeniowej.

W tym ćwiczeniu zrobisz dokładnie to. Na koniec stworzysz narzędzie do tworzenia łamigłówek z siatką słów. Istnieje wiele różnych definicji tego, co jest prawidłową krzyżówką, a te techniki pomagają tworzyć krzyżówki, które pasują do Twojej definicji.

Animacja przedstawiająca generowanie krzyżówki.

Na podstawie tego narzędzia możesz następnie utworzyć krzyżówkę, która będzie zawierać wygenerowane przez nie hasła. Ta łamigłówka jest dostępna na urządzeniach z Androidem, iOS, Windows, macOS i Linux. Oto jak to wygląda na Androidzie:

Zrzut ekranu z krzyżówką rozwiązywaną na emulatorze Pixela Fold.

Wymagania wstępne

Czego się dowiesz

  • Jak używać izolowanych procesów do wykonywania wymagających obliczeniowo zadań bez zakłócania pętli renderowania Fluttera za pomocą kombinacji funkcji compute Fluttera i możliwości buforowania wartości filtra przebudowy Riverpoda select.
  • Jak wykorzystać niezmienne struktury danych za pomocą built_valuebuilt_collection do wdrożenia opartych na wyszukiwaniu tradycyjnych technik AI (GOFAI), takich jak przeszukiwanie w głąb i wycofywanie.
  • Jak korzystać z funkcji pakietu two_dimensional_scrollables, aby szybko i intuicyjnie wyświetlać dane w siatce.

Wymagania

  • Pakiet SDK Flutter.
  • Visual Studio Code (VS Code) z wtyczkami Flutter i Dart.
  • Oprogramowanie kompilatora dla wybranego środowiska docelowego. Te warsztaty działają na wszystkich platformach komputerowych, Androidzie i iOS. Aby kierować reklamy na Windowsa, musisz używać VS Code, aby kierować reklamy na macOS lub iOS – Xcode, a aby kierować reklamy na Androida – Android Studio.

2. Utwórz projekt

Tworzenie pierwszego projektu Flutter

  1. Uruchom VS Code.
  2. Otwórz paletę poleceń (Ctrl+Shift+P w systemie Windows/Linux, Cmd+Shift+P w systemie macOS), wpisz „flutter new”, a następnie w menu wybierz Flutter: New Project (Flutter: nowy projekt).

VS Code z Flutterem: w otwartej palecie poleceń widoczna opcja Nowy projekt.

  1. Wybierz Pusta aplikacja, a potem wybierz katalog, w którym chcesz utworzyć projekt. Powinien to być dowolny katalog, który nie wymaga podwyższonych uprawnień ani nie zawiera spacji w ścieżce. Może to być na przykład katalog domowy lub C:\src\.

VS Code z wybraną opcją Pusta aplikacja w ramach nowego procesu tworzenia aplikacji

  1. Nadaj nazwę projektowigenerate_crossword. W dalszej części tego ćwiczenia zakłada się, że aplikacja ma nazwę generate_crossword.

VS Code z nazwą generate_crossword jako nazwą nowego projektu

Flutter utworzy teraz folder projektu, a VS Code go otworzy. Teraz zastąpisz zawartość 2 plików podstawowym szkieletem aplikacji.

Kopiowanie i wklejanie początkowej aplikacji

  1. W lewym okienku VS Code kliknij Eksplorator i otwórz plik pubspec.yaml.

Częściowy zrzut ekranu VS Code ze strzałkami wskazującymi lokalizację pliku pubspec.yaml

  1. Zastąp zawartość tego pliku tymi zależnościami potrzebnymi do generowania krzyżówek:

pubspec.yaml

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

environment:
  sdk: ^3.9.0

dependencies:
  flutter:
    sdk: flutter
  built_collection: ^5.1.1
  built_value: ^8.10.1
  characters: ^1.4.0
  flutter_riverpod: ^2.6.1
  intl: ^0.20.2
  riverpod: ^2.6.1
  riverpod_annotation: ^2.6.1
  two_dimensional_scrollables: ^0.3.7

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0
  build_runner: ^2.5.4
  built_value_generator: ^8.10.1
  custom_lint: ^0.7.6
  riverpod_generator: ^2.6.5
  riverpod_lint: ^2.6.5

flutter:
  uses-material-design: true

Plik pubspec.yaml zawiera podstawowe informacje o aplikacji, takie jak jej aktualna wersja i zależności. Widzisz zbiór zależności, które nie są częścią normalnej pustej aplikacji Flutter. W kolejnych krokach skorzystasz ze wszystkich tych pakietów.

Zrozumienie zależności

Zanim przejdziemy do kodu, dowiedzmy się, dlaczego wybrano te konkretne pakiety:

  • built_value: tworzy niezmienne obiekty, które efektywnie współdzielą pamięć, co ma kluczowe znaczenie dla naszego algorytmu wycofywania.
  • Riverpod: zapewnia precyzyjne zarządzanie stanem za pomocą select(), aby zminimalizować przebudowy.
  • two_dimensional_scrollables: obsługuje duże siatki bez obniżania wydajności.
  1. Otwórz plik main.dart w katalogu lib/.

Częściowy zrzut ekranu VS Code ze strzałką wskazującą lokalizację pliku main.dart

  1. Zastąp zawartość tego pliku tymi wierszami:

lib/main.dart

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

void main() {
  runApp(
    ProviderScope(
      child: MaterialApp(
        title: 'Crossword Builder',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: Scaffold(
          body: Center(
            child: Text('Hello, World!', style: TextStyle(fontSize: 24)),
          ),
        ),
      ),
    ),
  );
}
  1. Uruchom ten kod, aby sprawdzić, czy wszystko działa. Powinno się w nim wyświetlać nowe okno z obowiązkowym zwrotem początkowym każdego nowego projektu. Obok nazwy aplikacji znajduje się ikona ProviderScope, która wskazuje, że aplikacja będzie używać riverpod do zarządzania stanem.

Okno aplikacji z napisem „Hello, World!” na środku.

Punkt kontrolny: podstawowa aplikacja działa

W tym momencie powinno się pojawić okno „Hello, World!”. Jeśli nie:

  • Sprawdź, czy Flutter jest prawidłowo zainstalowany.
  • Sprawdź, czy aplikacja działa z flutter run
  • Sprawdź, czy w terminalu nie ma błędów kompilacji.

3. Dodawanie słów

Elementy składowe krzyżówki

Krzyżówka to w zasadzie lista słów. Słowa są ułożone w siatce, niektóre w poziomie, inne w pionie, tak aby się ze sobą przeplatały. Rozwiązanie jednego słowa daje wskazówki dotyczące słów, które się z nim krzyżują. Dlatego dobrym pierwszym elementem jest lista słów.

Dobrym źródłem tych słów jest strona Natural Language Corpus Data Petera Norviga. Lista SOWPODS to przydatny punkt wyjścia, ponieważ zawiera 267 750 słów.

W tym kroku pobierzesz listę słów, dodasz ją jako zasób do aplikacji Flutter i skonfigurujesz dostawcę Riverpod, aby wczytywał listę do aplikacji podczas uruchamiania.

Aby rozpocząć, wykonaj następujące czynności:

  1. Zmodyfikuj plik pubspec.yaml projektu, aby dodać deklarację komponentu dla wybranej listy słów. Ta lista zawiera tylko sekcję Flutter konfiguracji aplikacji, ponieważ reszta pozostała bez zmian.

pubspec.yaml

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

Edytor prawdopodobnie wyróżni ten ostatni wiersz ostrzeżeniem, ponieważ nie masz jeszcze tego pliku.

  1. W przeglądarce i edytorze utwórz katalog assets na najwyższym poziomie projektu i utwórz w nim plik words.txt z jedną z list słów, do których linki podaliśmy wcześniej.

Ten kod został zaprojektowany z użyciem wspomnianej wcześniej listy SOWPODS, ale powinien działać z każdą listą słów, która zawiera tylko znaki z zakresu A–Z. Rozszerzenie tego kodu, aby działał z różnymi zestawami znaków, pozostawiamy czytelnikowi.

Wczytaj słowa

Aby napisać kod odpowiedzialny za wczytywanie listy słów podczas uruchamiania aplikacji, wykonaj te czynności:

  1. Utwórz plik providers.dart w katalogu lib.
  2. Dodaj do pliku te elementy:

lib/providers.dart

import 'dart:convert';

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

part 'providers.g.dart';

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

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

To Twój pierwszy dostawca Riverpod w tym kodzie.

Jak działa ten dostawca:

  1. Asynchroniczne wczytywanie listy słów z zasobów
  2. Filtruje słowa, aby uwzględniać tylko znaki a–z dłuższe niż 2 litery.
  3. Zwraca niezmienny obiekt BuiltSet, który umożliwia wydajny dostęp losowy.

Ten projekt korzysta z generowania kodu w przypadku wielu zależności, w tym Riverpod.

  1. Aby rozpocząć generowanie kodu, uruchom to polecenie:
$ 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)

Będzie on nadal działać w tle i aktualizować wygenerowane pliki w miarę wprowadzania zmian w projekcie. Gdy to polecenie wygeneruje kod w providers.g.dart, edytor powinien zaakceptować kod dodany do providers.dart.

W Riverpod dostawcy, tacy jak zdefiniowana wcześniej funkcja wordList, są zwykle tworzeni na żądanie. W przypadku tej aplikacji lista słów musi być jednak wczytywana od razu. Dokumentacja Riverpod sugeruje następujące podejście do obsługi dostawców, którzy muszą być wczytywani od razu. Teraz to zrobisz.

  1. Utwórz plik crossword_generator_app.dart w katalogu lib/widgets.
  2. Dodaj do pliku te elementy:

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

Ten plik jest interesujący z dwóch różnych powodów. Pierwszy to widżet _EagerInitialization, którego jedynym zadaniem jest wymaganie od utworzonego wcześniej dostawcy wordList wczytania listy słów. Ten widżet realizuje ten cel, nasłuchując dostawcy za pomocą wywołania ref.watch(). Więcej informacji o tej technice znajdziesz w dokumentacji Riverpod w sekcji Eager initialization of providers (Inicjowanie dostawców z wyprzedzeniem).

Drugą ciekawą rzeczą, na którą warto zwrócić uwagę w tym pliku, jest sposób, w jaki Riverpod obsługuje treści asynchroniczne. Jak pamiętasz, wordList dostawca jest zdefiniowany jako funkcja asynchroniczna, ponieważ wczytywanie treści z dysku jest powolne. Jeśli w tym kodzie obserwujesz dostawcę listy słów, otrzymujesz AsyncValue<BuiltSet<String>>. Część AsyncValue tego typu to adapter między asynchronicznym światem dostawców a synchronicznym światem metody build widżetu.

Metoda AsyncValue when obsługuje 3 potencjalne stany, w których może znajdować się wartość przyszła. Przyszłość mogła zostać rozwiązana pomyślnie, w którym to przypadku wywoływane jest wywołanie zwrotne data. Może też być w stanie błędu, w którym to przypadku wywoływane jest wywołanie zwrotne error, lub może być nadal w trakcie ładowania. Typy zwracane przez 3 wywołania zwrotne muszą być zgodne, ponieważ wartość zwracana przez wywołane wywołanie zwrotne jest zwracana przez metodę when. W tym przypadku wynik metody when jest wyświetlany jako body widżetu Scaffold.

Tworzenie aplikacji z niemal nieskończoną listą

Aby zintegrować widżet CrosswordGeneratorApp z aplikacją, wykonaj te czynności:

  1. Zaktualizuj plik lib/main.dart, dodając ten kod:

lib/main.dart

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

import 'widgets/crossword_generator_app.dart';             // Add this import

void main() {
  runApp(
    ProviderScope(
      child: MaterialApp(
        title: 'Crossword Builder',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: CrosswordGeneratorApp(),                     // Remove what was here and replace
      ),
    ),
  );
}
  1. Uruchom ponownie aplikację. Powinna się wyświetlić przewijana lista zawierająca ponad 267 750 słów ze słownika.

Okno aplikacji z tytułem „Generator krzyżówek” i listą słów

Co utworzysz

Teraz utworzysz podstawowe struktury danych dla krzyżówki za pomocą obiektów niezmiennych. Te podstawy umożliwią wydajne działanie algorytmów i płynne aktualizacje interfejsu.

4. Wyświetlanie słów w siatce

W tym kroku utworzysz strukturę danych do tworzenia krzyżówki za pomocą pakietów built_valuebuilt_collection. Te 2 pakiety umożliwiają tworzenie struktur danych jako wartości niezmiennych, co będzie przydatne zarówno do przekazywania danych między izolowanymi procesami, jak i do znacznie łatwiejszego wdrażania przeszukiwania w głąb i wycofywania.

Aby rozpocząć, wykonaj następujące czynności:

  1. Utwórz plik model.dart w katalogu lib, a następnie dodaj do niego ten kod:

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;

Ten plik opisuje początek struktury danych, której będziesz używać do tworzenia krzyżówek. Krzyżówka to lista słów ułożonych poziomo i pionowo, które są ze sobą powiązane w siatce. Aby użyć tej struktury danych, utwórz Crossword o odpowiednim rozmiarze za pomocą konstruktora o nazwie Crossword.crossword, a następnie dodaj słowa za pomocą metody addWord. W ramach tworzenia ostatecznej wartości metoda _fillCharacters tworzy siatkę CrosswordCharacter.

Aby użyć tej struktury danych, wykonaj te czynności:

  1. Utwórz plik utils w katalogu lib, a następnie dodaj do niego ten kod:

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

Jest to rozszerzenie funkcji BuiltSet, które ułatwia pobieranie losowego elementu zbioru. Metody rozszerzające to dobry sposób na rozszerzenie klas o dodatkowe funkcje. Nazwanie rozszerzenia jest wymagane, aby było ono dostępne poza plikiem utils.dart.

  1. Dodaj do pliku lib/providers.dart te instrukcje importu:

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 {

Te importy udostępniają zdefiniowany wcześniej model dostawcom, których zamierzasz utworzyć. Import dart:math jest uwzględniony w przypadku Random, import flutter/foundation.dart – w przypadku debugPrint, model.dart – w przypadku modelu, a utils.dart – w przypadku rozszerzenia BuiltSet.

  1. Na końcu tego samego pliku dodaj tych dostawców:

lib/providers.dart

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

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

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

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

  @override
  CrosswordSize build() => _size;

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

final _random = Random();

@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);

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

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

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

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

Te zmiany dodają do aplikacji 2 dostawców. Pierwszy to Size, czyli zmienna globalna zawierająca wybraną wartość wyliczenia CrosswordSize. Dzięki temu interfejs będzie mógł wyświetlać i ustawiać rozmiar tworzonego krzyżówki. Drugi dostawca, crossword, to ciekawsza kreacja. Jest to funkcja, która zwraca serię znaków Crossword. Jest ona tworzona przy użyciu obsługi generatorów w Dart, co jest oznaczone symbolem async* przy funkcji. Oznacza to, że zamiast kończyć się na zwróceniu wartości, daje serię Crossword, co znacznie ułatwia pisanie obliczeń zwracających wyniki pośrednie.

Ze względu na obecność pary wywołań ref.watch na początku funkcji dostawcy crossword strumień Crossword zostanie ponownie uruchomiony przez system Riverpod za każdym razem, gdy zmieni się wybrany rozmiar krzyżówki i gdy lista słów zakończy wczytywanie.

Masz już kod do generowania krzyżówek, choć pełnych losowych słów. Warto byłoby pokazać je użytkownikowi narzędzia.

  1. Utwórz w katalogu lib/widgets plik crossword_widget.dart z tą zawartością:

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

Ten widżet, będący elementem ConsumerWidget, może bezpośrednio korzystać z usługi Size, aby określić rozmiar siatki do wyświetlania znaków Crossword. Wyświetlanie tej siatki jest możliwe dzięki widżetowi TableView z pakietu two_dimensional_scrollables.

Warto zauważyć, że poszczególne komórki renderowane przez funkcje pomocnicze _buildCell zawierają w zwracanym drzewie Widget widżet Consumer. Działa to jako granica odświeżania. Wszystko w Consumer widżecie jest odtwarzane, gdy zmieni się zwrócona wartość ref.watch. Może się wydawać, że za każdym razem, gdy zmienia się Crossword, trzeba odtworzyć całe drzewo, ale takie podejście wymaga wielu obliczeń, których można uniknąć, stosując tę konfigurację.

Jeśli przyjrzysz się parametrowi ref.watch, zobaczysz kolejną warstwę unikania ponownego obliczania układów za pomocą crosswordProvider.select. Oznacza to, że ref.watch spowoduje ponowne utworzenie zawartości TableViewCell tylko wtedy, gdy zmieni się znak, za którego renderowanie odpowiada komórka. Ograniczenie ponownego renderowania jest niezbędne, aby interfejs użytkownika był responsywny.

Aby udostępnić użytkownikowi dostawców CrosswordWidgetSize, zmień plik crossword_generator_app.dart w ten sposób:

lib/widgets/crossword_generator_app.dart

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

import '../providers.dart';
import 'crossword_widget.dart';                               // Add this import

class CrosswordGeneratorApp extends StatelessWidget {
  const CrosswordGeneratorApp({super.key});

  @override
  Widget build(BuildContext context) {
    return _EagerInitialization(
      child: Scaffold(
        appBar: AppBar(
          actions: [_CrosswordGeneratorMenu()],               // Add this line
          titleTextStyle: TextStyle(
            color: Theme.of(context).colorScheme.primary,
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
          title: Text('Crossword Generator'),
        ),
        body: SafeArea(child: CrosswordWidget()),             // Replace what was here before
      ),
    );
  }
}

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

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

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

Zmieniło się tu kilka rzeczy. Po pierwsze, kod odpowiedzialny za renderowanie wordList jako ListView został zastąpiony wywołaniem funkcji CrosswordWidget zdefiniowanej w pliku lib/widgets/crossword_widget.dart. Kolejną ważną zmianą jest wprowadzenie menu, które umożliwia zmianę działania aplikacji, począwszy od zmiany rozmiaru krzyżówki. W kolejnych krokach dodamy więcej MenuItemButton. Uruchom aplikację. Zobaczysz coś takiego:

Okno aplikacji z tytułem Generator krzyżówek i siatką znaków ułożonych w nakładające się na siebie słowa bez rymu i sensu

Znaki są wyświetlane w siatce, a menu umożliwia zmianę jej rozmiaru. Słowa nie są jednak ułożone jak w krzyżówce. Wynika to z braku ograniczeń dotyczących sposobu dodawania słów do krzyżówki. Krótko mówiąc, to bałagan. W następnym kroku zaczniesz nad tym panować.

5. Egzekwowanie ograniczeń

Co się zmienia i dlaczego

Obecnie Twoja krzyżówka umożliwia nakładanie się słów bez weryfikacji. Dodasz sprawdzanie ograniczeń, aby upewnić się, że słowa prawidłowo się ze sobą łączą, jak w prawdziwej krzyżówce.

Celem tego kroku jest dodanie do modelu kodu, który będzie wymuszać ograniczenia krzyżówki. Istnieje wiele różnych rodzajów krzyżówek, a styl, który będzie obowiązywał w tym laboratorium, jest zgodny z tradycją angielskich krzyżówek. Zmiana tego kodu w celu generowania innych stylów krzyżówek pozostaje, jak zawsze, zadaniem dla czytelnika.

Aby rozpocząć, wykonaj następujące czynności:

  1. Otwórz plik model.dart i zastąp tylko model Crossword tym kodem:

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

Przypominamy, że zmiany wprowadzane w plikach model.dartproviders.dart wymagają uruchomienia build_runner, aby zaktualizować odpowiednie pliki model.g.dartproviders.g.dart. Jeśli te pliki nie zostały automatycznie zaktualizowane, teraz jest dobry moment, aby ponownie rozpocząć build_runner od dart run build_runner watch -d.

Aby korzystać z tej nowej funkcji w warstwie modelu, musisz zaktualizować warstwę dostawcy.

  1. Zmodyfikuj plik providers.dart w ten sposób:

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. Uruchom aplikację. W interfejsie nie dzieje się zbyt wiele, ale w logach widać, że wiele się dzieje.

Okno aplikacji Crossword Generator z wyrazami ułożonymi w pionie i poziomie, które przecinają się w losowych punktach

Jeśli zastanowisz się nad tym, co się tu dzieje, zobaczysz, że krzyżówka pojawia się losowo. Metoda addWord w modelu Crossword odrzuca każde proponowane słowo, które nie pasuje do obecnej krzyżówki, więc to niesamowite, że w ogóle coś się pojawia.

Dlaczego warto przejść na przetwarzanie w tle?

Podczas generowania krzyżówki interfejs może przestać odpowiadać. Dzieje się tak, ponieważ generowanie krzyżówek obejmuje tysiące kontroli weryfikacyjnych. Te obliczenia blokują pętlę renderowania Fluttera z częstotliwością 60 klatek na sekundę, więc przenieś wymagające obliczenia do izolowanych procesów w tle. Dzięki temu interfejs użytkownika działa płynnie, a łamigłówka generuje się w tle.

Aby bardziej metodycznie wybierać słowa do wypróbowania, warto przenieść to obliczenie z wątku interfejsu do izolatu w tle. Flutter ma bardzo przydatną funkcję opakowującą, która umożliwia wykonanie części pracy w izolowanym procesie w tle – jest to funkcja compute.

  1. W pliku providers.dart zmodyfikuj dostawcę krzyżówki w ten sposób:

lib/providers.dart

@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
  final size = ref.watch(sizeProvider);
  final wordListAsync = ref.watch(wordListProvider);

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

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

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

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

Informacje o ograniczeniach izolacji

Ten kod działa, ale ma ukryty problem. Izolaty mają ścisłe reguły dotyczące tego, jakie dane można między nimi przekazywać. Problem polega na tym, że zamknięcie „przechwytuje” odwołanie do dostawcy, którego nie można serializować i wysyłać do innego izolatu.

Ten komunikat pojawi się, gdy system spróbuje wysłać dane, których nie można serializować:

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)

Jest to spowodowane zamknięciem, które compute przekazuje do izolatu w tle, zamykając dostawcę, którego nie można wysłać przez SendPort.send(). Jednym z rozwiązań jest upewnienie się, że w zamknięciu nie ma niczego, co nie może zostać wysłane.

Pierwszym krokiem jest oddzielenie dostawców od kodu Isolate.

  1. Utwórz plik isolates.dart w katalogu lib i dodaj do niego tę treść:

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

Ten kod powinien być dość znajomy. Jest to rdzeń tego, co było w crossword dostawcy, ale teraz jako samodzielna funkcja generatora. Teraz możesz zaktualizować plik providers.dart, aby użyć tej nowej funkcji do utworzenia instancji izolatu w tle.

lib/providers.dart

// Drop the dart:math import, the _random instance moved to isolates.dart
import 'dart:convert';

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

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

part 'providers.g.dart';

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

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

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

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

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

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

  @override
  CrosswordSize build() => _size;

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

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

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

Dzięki temu masz teraz narzędzie, które tworzy krzyżówki o różnych rozmiarach, a compute rozwiązywania krzyżówki odbywa się w izolowanym tle. Teraz tylko kod mógłby być bardziej wydajny w decyzji, jakie słowa spróbować dodać do krzyżówki.

6. Zarządzanie kolejką zadań

Omówienie strategii wyszukiwania

Do generowania krzyżówek używamy wstecznego śledzenia, czyli systematycznej metody prób i błędów. Najpierw aplikacja próbuje umieścić słowo w określonym miejscu, a potem sprawdza, czy pasuje ono do istniejących słów. Jeśli tak, zachowaj je i spróbuj z następnym słowem. Jeśli nie, wyjmij go i spróbuj w innym miejscu.

W przypadku krzyżówek metoda ta działa, ponieważ umieszczenie każdego słowa tworzy ograniczenia dla kolejnych słów, a nieprawidłowe umieszczenia są szybko wykrywane i porzucane. Niezmienne struktury danych umożliwiają wydajne „cofanie” zmian.

Problem z obecną wersją kodu polega na tym, że rozwiązywany problem jest w zasadzie problemem wyszukiwania, a obecne rozwiązanie jest wyszukiwaniem w ciemno. Jeśli kod skupi się na znajdowaniu słów, które pasują do obecnych słów, zamiast losowo umieszczać słowa w dowolnym miejscu siatki, system będzie szybciej znajdować rozwiązania. Jednym ze sposobów jest wprowadzenie kolejki zadań z lokalizacjami, dla których należy znaleźć słowa.

Kod tworzy proponowane rozwiązania, sprawdza, czy są one prawidłowe, a następnie w zależności od wyniku weryfikacji uwzględnia je lub odrzuca. To przykład implementacji algorytmu z rodziny algorytmów z backtrackingiem. To wdrożenie jest znacznie ułatwione przez funkcje built_valuebuilt_collection, które umożliwiają tworzenie nowych niezmiennych wartości, które pochodzą z niezmiennej wartości, z której zostały utworzone, i dlatego współdzielą z nią wspólny stan. Umożliwia to tanie wykorzystanie potencjalnych kandydatów bezpłatnie pamięci wymaganych do głębokiego kopiowania.

Aby rozpocząć, wykonaj następujące czynności:

  1. Otwórz plik model.dart i dodaj do niego tę definicję WorkQueue:

lib/model.dart

  /// Constructor for [Crossword].
  /// Use [Crossword.crossword] instead.
  factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
  Crossword._();
}
                                                           // Add from here
/// A work queue for a worker to process. The work queue contains a crossword
/// and a list of locations to try, along with candidate words to add to the
/// crossword.
abstract class WorkQueue implements Built<WorkQueue, WorkQueueBuilder> {
  static Serializer<WorkQueue> get serializer => _$workQueueSerializer;

  /// The crossword the worker is working on.
  Crossword get crossword;

  /// The outstanding queue of locations to try.
  BuiltMap<Location, Direction> get locationsToTry;

  /// Known bad locations.
  BuiltSet<Location> get badLocations;

  /// The list of unused candidate words that can be added to this crossword.
  BuiltSet<String> get candidateWords;

  /// Returns true if the work queue is complete.
  bool get isCompleted => locationsToTry.isEmpty || candidateWords.isEmpty;

  /// Create a work queue from a crossword.
  static WorkQueue from({
    required Crossword crossword,
    required Iterable<String> candidateWords,
    required Location startLocation,
  }) => WorkQueue((b) {
    if (crossword.words.isEmpty) {
      // Strip candidate words too long to fit in the crossword
      b.candidateWords.addAll(
        candidateWords.where(
          (word) => word.characters.length <= crossword.width,
        ),
      );

      b.crossword.replace(crossword);

      b.locationsToTry.addAll({startLocation: Direction.across});
    } else {
      // Assuming words have already been stripped to length
      b.candidateWords.addAll(
        candidateWords.toBuiltSet().rebuild(
          (b) => b.removeAll(crossword.words.map((word) => word.word)),
        ),
      );
      b.crossword.replace(crossword);
      crossword.characters
          .rebuild(
            (b) => b.removeWhere((location, character) {
              if (character.acrossWord != null && character.downWord != null) {
                return true;
              }
              final left = crossword.characters[location.left];
              if (left != null && left.downWord != null) return true;
              final right = crossword.characters[location.right];
              if (right != null && right.downWord != null) return true;
              final up = crossword.characters[location.up];
              if (up != null && up.acrossWord != null) return true;
              final down = crossword.characters[location.down];
              if (down != null && down.acrossWord != null) return true;
              return false;
            }),
          )
          .forEach((location, character) {
            b.locationsToTry.addAll({
              location: switch ((character.acrossWord, character.downWord)) {
                (null, null) => throw StateError(
                  'Character is not part of a word',
                ),
                (null, _) => Direction.across,
                (_, null) => Direction.down,
                (_, _) => throw StateError('Character is part of two words'),
              },
            });
          });
    }
  });

  WorkQueue remove(Location location) => rebuild(
    (b) => b
      ..locationsToTry.remove(location)
      ..badLocations.add(location),
  );

  /// Update the work queue from a crossword derived from the current crossword
  /// that this work queue is built from.
  WorkQueue updateFrom(final Crossword crossword) =>
      WorkQueue.from(
        crossword: crossword,
        candidateWords: candidateWords,
        startLocation: locationsToTry.isNotEmpty
            ? locationsToTry.keys.first
            : Location.at(0, 0),
      ).rebuild(
        (b) => b
          ..badLocations.addAll(badLocations)
          ..locationsToTry.removeWhere(
            (location, _) => badLocations.contains(location),
          ),
      );

  /// Factory constructor for [WorkQueue]
  factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;

  WorkQueue._();
}                                                          // To here.

/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
  Location,
  Crossword,
  CrosswordWord,
  CrosswordCharacter,
  WorkQueue,                                               // Add this line
])
final Serializers serializers = _$serializers;
  1. Jeśli po dodaniu nowych treści do tego pliku czerwone zygzaki nadal się w nim pojawiają przez kilka sekund, sprawdź, czy build_runner nadal działa. Jeśli nie, uruchom polecenie dart run build_runner watch -d.

W kodzie, do którego za chwilę dodasz rejestrowanie, zobaczysz, ile czasu zajmuje tworzenie krzyżówek o różnych rozmiarach. Dobrze byłoby, gdyby czas trwania był wyświetlany w ładnie sformatowanej formie. Na szczęście dzięki metodom rozszerzającym możemy dodać dokładnie taką metodę, jakiej potrzebujemy.

  1. Zmodyfikuj plik utils.dart w ten sposób:

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.

Ta metoda rozszerzania wykorzystuje wyrażenia switch i dopasowywanie wzorców do rekordów, aby wybrać odpowiedni sposób wyświetlania różnych czasów trwania, od sekund po dni. Więcej informacji o tym stylu kodu znajdziesz w samouczku Dive into Dart's patterns and records (po angielsku).

  1. Aby zintegrować tę nową funkcję, zastąp plik isolates.dart, aby ponownie zdefiniować funkcję exploreCrosswordSolutions w ten sposób:

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

Uruchomienie tego kodu spowoduje powstanie aplikacji, która na pierwszy rzut oka będzie wyglądać identycznie, ale różnica będzie polegać na tym, jak długo zajmie znalezienie gotowej krzyżówki. Oto krzyżówka o wymiarach 80 × 44 wygenerowana w 1 minutę i 29 sekund.

Punkt kontrolny: działanie wydajnego algorytmu

Generowanie krzyżówek powinno być teraz znacznie szybsze dzięki:

  • Punkty przecięcia inteligentnego kierowania na miejsca docelowe
  • Skuteczne wycofywanie się, gdy miejsca docelowe nie działają
  • Zarządzanie kolejką zadań, aby uniknąć zbędnych wyszukiwań

Generator krzyżówek z wieloma przecinającymi się słowami. Po oddaleniu słowa są zbyt małe, aby można je było odczytać.

Oczywiste pytanie brzmi: czy możemy przyspieszyć? Owszem, tak.

7. Statystyki interfejsu

Dlaczego warto dodawać statystyki?

Aby coś przyspieszyć, warto wiedzieć, co się dzieje. Statystyki pomagają monitorować postępy i sprawdzać skuteczność algorytmu w czasie rzeczywistym. Umożliwia identyfikowanie wąskich gardeł przez sprawdzenie, na co algorytm poświęca najwięcej czasu. Dzięki temu możesz dostosowywać skuteczność kampanii, podejmując przemyślane decyzje dotyczące metod optymalizacji.

Wyświetlane informacje muszą być wyodrębnione z kolejki WorkQueue i wyświetlane w interfejsie. Przydatnym pierwszym krokiem jest zdefiniowanie nowej klasy modelu, która zawiera informacje, które chcesz wyświetlić.

Aby rozpocząć, wykonaj następujące czynności:

  1. Aby dodać klasę DisplayInfo, zmodyfikuj plik model.dart w ten sposób:

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. Na końcu pliku wprowadź te zmiany, aby dodać klasę 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. Zmodyfikuj plik isolates.dart, aby udostępnić model WorkQueue w ten sposób:

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

Teraz, gdy izolacja w tle udostępnia kolejkę zadań, pozostaje kwestia, jak i gdzie uzyskać statystyki z tego źródła danych.

  1. Zastąp starego dostawcę krzyżówek dostawcą kolejki zadań, a potem dodaj kolejnych dostawców, którzy będą pobierać informacje ze strumienia dostawcy kolejki zadań:

lib/providers.dart

import 'dart:convert';
import 'dart:math';                                        // Add this import

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

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

part 'providers.g.dart';

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

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

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

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

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

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

  @override
  CrosswordSize build() => _size;

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

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

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

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

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

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

  DateTime? _start;

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

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

  DateTime? _end;

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

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

const _estimatedTotalCoverage = 0.54;

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

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

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

  @override
  bool build() => _display;

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

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

Nowi dostawcy to połączenie stanu globalnego, np. informacji o tym, czy wyświetlacz informacji powinien być nakładany na siatkę krzyżówki, oraz danych pochodnych, takich jak czas generowania krzyżówki. Wszystko to komplikuje fakt, że odbiorcy niektórych z tych stanów są przejściowi. Jeśli wyświetlanie informacji jest ukryte, nic nie nasłuchuje czasu rozpoczęcia i zakończenia obliczeń krzyżówki, ale te informacje muszą pozostać w pamięci, aby obliczenia były dokładne, gdy wyświetlanie informacji jest widoczne. W tym przypadku bardzo przydatny jest parametr keepAlive atrybutu Riverpod.

Wyświetlanie informacji ma jednak pewien mankament. Chcemy mieć możliwość wyświetlania czasu trwania, ale nie ma tu niczego, co wymuszałoby ciągłą aktualizację czasu trwania. W samouczku dotyczącym tworzenia interfejsów nowej generacji w Flutterze znajdziesz przydatny widżet, który spełnia to wymaganie.

  1. Utwórz w katalogu lib/widgets plik ticker_builder.dart, a następnie dodaj do niego tę treść:

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

Ten widżet to młot. Odtwarza treści w każdej klatce. Zazwyczaj jest to odradzane, ale w porównaniu z obciążeniem obliczeniowym związanym z wyszukiwaniem krzyżówek obciążenie obliczeniowe związane z odświeżaniem upływającego czasu w każdej klatce prawdopodobnie zniknie w szumie. Aby wykorzystać te nowo uzyskane informacje, utwórz nowy widżet.

  1. Utwórz plik crossword_info_widget.dart w katalogu lib/widgets i dodaj do niego ten kod:

lib/widgets/crossword_info_widget.dart

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

import '../providers.dart';
import '../utils.dart';
import 'ticker_builder.dart';

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

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

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

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

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

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

Ten widżet jest doskonałym przykładem możliwości dostawców Riverpod. Gdy którykolwiek z 5 dostawców zaktualizuje dane, widżet zostanie oznaczony do ponownego utworzenia. Ostatnią wymaganą zmianą w tym kroku jest zintegrowanie nowego widżetu z interfejsem.

  1. Zmodyfikuj plik crossword_generator_app.dart w ten sposób:

lib/widgets/crossword_generator_app.dart

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

import '../providers.dart';
import 'crossword_info_widget.dart';                       // Add this import
import 'crossword_widget.dart';

class CrosswordGeneratorApp extends StatelessWidget {
  const CrosswordGeneratorApp({super.key});

  @override
  Widget build(BuildContext context) {
    return _EagerInitialization(
      child: Scaffold(
        appBar: AppBar(
          actions: [_CrosswordGeneratorMenu()],
          titleTextStyle: TextStyle(
            color: Theme.of(context).colorScheme.primary,
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
          title: Text('Crossword Generator'),
        ),
        body: SafeArea(
          child: Consumer(                                 // Modify from here
            builder: (context, ref, child) {
              return Stack(
                children: [
                  Positioned.fill(child: CrosswordWidget()),
                  if (ref.watch(showDisplayInfoProvider)) CrosswordInfoWidget(),
                ],
              );
            },
          ),                                               // To here.
        ),
      ),
    );
  }
}

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

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

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

Te 2 zmiany pokazują różne podejścia do integracji dostawców. W metodzie CrosswordGeneratorAppbuild wprowadziliśmy nowy Consumer, który zawiera obszar wymuszający ponowne renderowanie, gdy wyświetlacz informacji jest widoczny lub ukryty. Z drugiej strony całe menu to jeden ConsumerWidget, który zostanie przebudowany niezależnie od tego, czy zmieniasz rozmiar krzyżówki, czy wyświetlasz lub ukrywasz informacje. Wybór podejścia zawsze wiąże się z kompromisem między prostotą a kosztem ponownego obliczania układów przebudowanych drzew widżetów.

Uruchomienie aplikacji daje użytkownikowi więcej informacji o postępach w generowaniu krzyżówki. Pod koniec generowania krzyżówki widzimy jednak okres, w którym liczby się zmieniają, ale w siatce znaków zachodzą bardzo niewielkie zmiany.

Okno aplikacji Crossword Generator, tym razem mniejsze, z rozpoznawalnymi słowami i pływającą nakładką w prawym dolnym rogu ze statystykami bieżącego generowania.

Przydatne byłoby uzyskanie dodatkowych informacji o tym, co się dzieje i dlaczego.

8. Równoległe przetwarzanie za pomocą wątków

Dlaczego wydajność się pogarsza

Gdy krzyżówka jest prawie gotowa, algorytm zwalnia, ponieważ pozostaje mniej prawidłowych opcji umieszczenia słów. Algorytm wypróbowuje wiele kombinacji, które nie działają. Przetwarzanie jednowątkowe nie może skutecznie sprawdzać wielu opcji.

Wizualizacja algorytmu

Aby zrozumieć, dlaczego pod koniec proces zwalnia, warto zobaczyć, co robi algorytm. Kluczowym elementem jest znakomity locationsToTryWorkQueue. Widok tabeli to przydatny sposób na zbadanie tego problemu. Możemy zmienić kolor komórki w zależności od tego, czy znajduje się ona w locationsToTry.

Aby rozpocząć, wykonaj następujące czynności:

  1. Zmodyfikuj plik crossword_widget.dart w ten sposób:

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

Po uruchomieniu tego kodu zobaczysz wizualizację nieprzebadanych jeszcze lokalizacji.

Generator krzyżówek w trakcie generowania. Niektóre litery mają biały tekst na ciemnoniebieskim tle, a inne – niebieski tekst na białym tle.

Ciekawą rzeczą w obserwowaniu postępów w rozwiązywaniu krzyżówki jest to, że pozostaje wiele punktów do zbadania, które nie przyniosą niczego przydatnego. Możesz to zrobić na 2 sposoby: ograniczyć sprawdzanie do momentu, w którym zostanie wypełniony określony odsetek pól krzyżówki, lub sprawdzać jednocześnie kilka punktów. Druga ścieżka wydaje się ciekawsza, więc czas ją wybrać.

  1. Edytuj plik isolates.dart. Jest to niemal całkowite przepisanie kodu, aby podzielić to, co było obliczane w jednym izolacie w tle, na pulę N izolatów w tle.

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

Omówienie architektury z wieloma izolacjami

Większość tego kodu powinna być Ci znana, ponieważ podstawowa logika biznesowa nie uległa zmianie. Zmieniło się to, że są teraz 2 warstwy połączeń compute. Pierwsza warstwa odpowiada za przekazywanie poszczególnych pozycji do wyszukiwania do N izolatów roboczych, a następnie za ponowne łączenie wyników po zakończeniu pracy wszystkich N izolatów roboczych. Druga warstwa składa się z N izolatów instancji roboczych. Dostosowanie wartości N w celu uzyskania najlepszej wydajności zależy zarówno od komputera, jak i od danych. Im większa siatka, tym więcej pracowników może pracować razem, nie przeszkadzając sobie nawzajem.

Ciekawostką jest to, jak ten kod radzi sobie teraz z problemem zamykania funkcji, które przechwytują elementy, których nie powinny. Obecnie nie ma żadnych zamknięć. Funkcje _generate_generateWorker są zdefiniowane jako funkcje najwyższego poziomu, które nie mają otoczenia, z którego można by pobrać dane. Argumenty i wyniki obu tych funkcji mają postać rekordów Dart. Pozwala to obejść semantykę wywołania compute, która polega na tym, że jedna wartość wejściowa daje jedną wartość wyjściową.

Teraz, gdy masz możliwość utworzenia puli pracowników w tle, którzy będą wyszukiwać słowa pasujące do siebie w siatce, aby utworzyć krzyżówkę, możesz udostępnić tę funkcję reszcie narzędzia do generowania krzyżówek.

  1. Zmodyfikuj plik providers.dart, edytując dostawcę workQueue w ten sposób:

lib/providers.dart

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

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

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

  ref.read(endTimeProvider.notifier).end();
}
  1. Dodaj dostawcę WorkerCount na końcu pliku w ten sposób:

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.

Dzięki tym 2 zmianom warstwa dostawcy udostępnia teraz sposób ustawiania maksymalnej liczby procesów roboczych w puli izolowanych procesów w tle, tak aby funkcje izolowane były prawidłowo skonfigurowane.

  1. Zaktualizuj plik crossword_info_widget.dart, modyfikując CrosswordInfoWidget w ten sposób:

lib/widgets/crossword_info_widget.dart

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final size = ref.watch(sizeProvider);
    final displayInfo = ref.watch(displayInfoProvider);
    final workerCount = ref.watch(workerCountProvider).label;  // Add this line
    final startTime = ref.watch(startTimeProvider);
    final endTime = ref.watch(endTimeProvider);
    final remaining = ref.watch(expectedRemainingTimeProvider);

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

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

Jeśli teraz uruchomisz aplikację, będziesz mieć możliwość modyfikowania liczby izolowanych procesów w tle, które są tworzone w celu wyszukiwania słów pasujących do krzyżówki.

  1. Kliknij ikonę koła zębatego, aby otworzyć menu kontekstowe zawierające opcje rozmiaru krzyżówki, wyświetlania statystyk wygenerowanej krzyżówki oraz liczbę izolowanych słów do użycia.

Okno generatora krzyżówek ze słowami i statystykami

Punkt kontrolny: wydajność wielowątkowa

Uruchomienie generatora krzyżówek znacznie skróciło czas obliczeń w przypadku krzyżówki o wymiarach 80 x 44 – dzięki jednoczesnemu wykorzystaniu wielu rdzeni. Zauważysz, że:

  • Szybsze generowanie krzyżówek dzięki większej liczbie pracowników
  • Płynna responsywność interfejsu użytkownika podczas generowania
  • Statystyki w czasie rzeczywistym pokazujące postępy w generowaniu
  • Wizualne potwierdzenie obszarów eksploracji algorytmu

9. Zamień to w grę

Co tworzymy: interaktywna gra w krzyżówkę

Ostatnia sekcja to tak naprawdę runda dodatkowa. Wykorzystasz wszystkie techniki, których nauczyłeś się podczas tworzenia generatora krzyżówek, aby zbudować grę. W ramach ćwiczenia:

  1. Generowanie łamigłówek: użyj generatora krzyżówek, aby tworzyć łamigłówki, które można rozwiązać.
  2. Tworzenie opcji słów: podaj kilka opcji słów dla każdego miejsca.
  3. Włącz interakcję: umożliwia użytkownikom wybieranie i umieszczanie słów.
  4. Sprawdź rozwiązania: sprawdź, czy wypełniona krzyżówka jest prawidłowa.

Użyjesz generatora krzyżówek do utworzenia krzyżówki. Użyjesz idiomów menu kontekstowego, aby umożliwić użytkownikowi wybieranie i odznaczanie słów, które mają być umieszczane w różnych otworach w siatce. Wszystko po to, aby rozwiązać krzyżówkę.

Nie powiem, że ta gra jest dopracowana lub ukończona, bo w rzeczywistości jest daleka od tego. Występują problemy z równowagą i trudnością, które można rozwiązać, poprawiając wybór alternatywnych słów. Nie ma samouczka, który wprowadzałby użytkowników w łamigłówkę. Nie wspomnę nawet o minimalistycznym ekranie „Wygrałeś(-aś)!”.

W tym przypadku jednak, aby przekształcić tę prototypową grę w pełną wersję, trzeba będzie napisać znacznie więcej kodu. Więcej kodu niż powinno być w jednym laboratorium. Jest to więc krok, który ma na celu utrwalenie technik poznanych do tej pory w tym laboratorium, poprzez zmianę miejsca i sposobu ich użycia. Mam nadzieję, że utrwali to wiedzę zdobytą wcześniej w tym laboratorium. Możesz też na podstawie tego kodu tworzyć własne rozwiązania. Chętnie zobaczymy, co udało Ci się stworzyć.

Aby rozpocząć, wykonaj następujące czynności:

  1. Usuń wszystko z katalogu lib/widgets. Będziesz tworzyć nowe widżety do swojej gry. który w dużej mierze korzysta ze starych widżetów.
  1. Edytuj plik model.dart, aby zaktualizować metodę addWord Crossword w ten sposób:

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

Ta niewielka modyfikacja modelu krzyżówki umożliwia dodawanie słów, które się nie pokrywają. Pozwala to graczom grać w dowolnym miejscu na planszy i nadal używać Crossword jako modelu bazowego do przechowywania ruchów gracza. To tylko lista słów w określonych miejscach, umieszczonych w określonym kierunku.

  1. Dodaj klasę modelu CrosswordPuzzleGame na końcu pliku 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;

Aktualizacje pliku providers.dart to ciekawy zbiór zmian. Większość dostawców, którzy byli obecni, aby wspierać zbieranie statystyk, została usunięta. Możliwość zmiany liczby izolowanych elementów tła została usunięta i zastąpiona stałą. Jest też nowy dostawca, który daje dostęp do nowego modelu CrosswordPuzzleGame dodanego wcześniej.

lib/providers.dart

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

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

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

part 'providers.g.dart';

const backgroundWorkerCount = 4;                           // Add this line

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

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

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

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

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

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

  @override
  CrosswordSize build() => _size;

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

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

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

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

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

    return _puzzle;
  }

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

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

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

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

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

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

Najciekawsze aspekty Puzzle to strategie, które mają na celu zatuszowanie kosztów utworzenia CrosswordPuzzleGameCrosswordwordList oraz kosztów wyboru słowa. Obie te czynności wykonywane bez pomocy funkcji izolowania tła mogą powodować powolne działanie interfejsu. Dzięki zastosowaniu pewnego triku polegającego na wyświetleniu wyniku pośredniego podczas obliczania wyniku końcowego w tle uzyskasz responsywny interfejs, a wymagane obliczenia będą wykonywane w tle.

  1. W pustym katalogu lib/widgets utwórz plik crossword_puzzle_app.dart z tą zawartością:

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

Większość tego pliku powinna być już znana. Tak, będą nieokreślone widżety, które teraz zaczniesz naprawiać.

  1. Utwórz plik crossword_generator_widget.dart i dodaj do niego tę treść:

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

Powinno to być dość oczywiste. Główna różnica polega na tym, że zamiast wyświetlać znaki generowanych słów, wyświetlasz teraz znak Unicode, który oznacza obecność nieznanego znaku. Warto popracować nad estetyką.

  1. Utwórz plik crossword_puzzle_widget.dart i dodaj do niego tę treść:

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

Ten widżet jest nieco bardziej złożony niż poprzedni, mimo że został utworzony z elementów, które były już używane w innych miejscach. Teraz po kliknięciu każdej wypełnionej komórki wyświetla się menu kontekstowe z listą słów, które użytkownik może wybrać. Jeśli słowa zostały wybrane, nie można wybrać słów, które z nimi kolidują. Aby odznaczyć słowo, użytkownik klika element menu odpowiadający temu słowu.

Zakładając, że gracz może wybrać słowa, aby wypełnić całą krzyżówkę, musisz mieć ekran „Gratulacje!”.

  1. Utwórz plik puzzle_completed_widget.dart i dodaj do niego tę treść:

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

Jestem pewien, że możesz to wykorzystać i uczynić bardziej interesującym. Więcej informacji o narzędziach do animacji znajdziesz w samouczku Building next generation UIs in Flutter.

  1. Zmodyfikuj plik lib/main.dart w ten sposób:

lib/main.dart

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

import 'widgets/crossword_puzzle_app.dart';                 // Update this line

void main() {
  runApp(
    ProviderScope(
      child: MaterialApp(
        title: 'Crossword Puzzle',                          // Update this line
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          colorSchemeSeed: Colors.blueGrey,
          brightness: Brightness.light,
        ),
        home: CrosswordPuzzleApp(),                         // Update this line
      ),
    ),
  );
}

Po uruchomieniu tej aplikacji zobaczysz animację, gdy generator krzyżówek będzie tworzyć Twoją łamigłówkę. Następnie wyświetli się pusta łamigłówka do rozwiązania. Po rozwiązaniu zadania powinien wyświetlić się ekran podobny do tego:

Okno aplikacji Crossword Puzzle z tekstem „Puzzle completed!” (Puzzle completed!).

10. Gratulacje

Gratulacje! Udało Ci się stworzyć grę logiczną za pomocą Fluttera.

Stworzyłeś generator krzyżówek, który stał się grą logiczną. Masz już opanowane uruchamianie obliczeń w tle w puli izolowanych środowisk. Używasz niezmiennych struktur danych, aby ułatwić wdrożenie algorytmu wycofywania. Poświęciłeś(-aś) czas na naukę TableView, co przyda Ci się przy następnym wyświetlaniu danych tabelarycznych.

Więcej informacji