이 Codelab 정보
1. 시작하기 전에
세계에서 가장 큰 크로스워드 퍼즐을 만들 수 있는지 묻는다고 가정해 보세요. 학교에서 공부한 AI 기법을 떠올리며 Flutter를 사용하여 계산 집약적인 문제에 대한 해결책을 만들기 위한 알고리즘 옵션을 탐색할 수 있는지 궁금해합니다.
이 Codelab에서는 바로 이 작업을 수행합니다. 결국 단어 그리드 퍼즐을 구성하는 알고리즘의 공간에서 플레이하는 도구를 빌드하게 됩니다. 유효한 크로스워드 퍼즐에 대한 정의는 다양하며 이러한 기법을 사용하면 내 정의에 맞는 퍼즐을 만들 수 있습니다.
이 도구를 기반으로 크로스워드 생성기를 사용하여 사용자가 풀 수 있는 크로스워드를 만듭니다. 이 퍼즐은 Android, iOS, Windows, macOS, Linux에서 사용할 수 있습니다. Android에서는 다음과 같이 표시됩니다.
기본 요건
- 첫 번째 Flutter 앱 Codelab 완료
학습 내용
- Flutter의
compute
함수와 Riverpod의select
다시 빌드 필터의 값 캐싱 기능을 조합하여 Flutter의 렌더링 루프를 방해하지 않고 계산 비용이 많이 드는 작업을 실행하는 방법 built_value
및built_collection
로 불변 데이터 구조를 활용하여 깊이 우선 검색 및 백트래킹과 같은 검색 기반의 고전적인 AI (GOFAI) 기법을 구현하는 방법two_dimensional_scrollables
패키지의 기능을 사용하여 그리드 데이터를 빠르고 직관적인 방식으로 표시하는 방법
필요한 항목
- Flutter SDK
- Flutter 및 Dart 플러그인이 있는 Visual Studio Code (VS Code)
- 선택한 개발 타겟의 컴파일러 소프트웨어 이 Codelab은 모든 데스크톱 플랫폼, Android, iOS에서 작동합니다. Windows를 타겟팅하려면 VS Code가 필요하고, macOS 또는 iOS를 타겟팅하려면 Xcode가 필요하며, Android를 타겟팅하려면 Android 스튜디오가 필요합니다.
2. 프로젝트 만들기
첫 번째 Flutter 프로젝트 만들기
- VS Code를 실행합니다.
- 명령어 팔레트 (Windows/Linux의 경우 Ctrl+Shift+P, macOS의 경우 Cmd+Shift+P)를 열고 'flutter new'를 입력한 다음 메뉴에서 Flutter: New Project를 선택합니다.
- 빈 애플리케이션을 선택하고 프로젝트를 만들 디렉터리를 선택합니다. 권한 상승이 필요하지 않거나 경로에 공백이 없는 디렉터리여야 합니다. 예를 들어 홈 디렉터리나
C:\src\
가 있습니다.
- 프로젝트 이름을
generate_crossword
로 지정합니다. 이 Codelab의 나머지 부분에서는 앱 이름을generate_crossword
라고 가정합니다.
이제 Flutter에서 프로젝트 폴더를 생성하고 VS Code에서 이 폴더를 엽니다. 이제 앱의 기본 스캐폴드로 두 파일의 콘텐츠를 덮어씁니다.
초기 앱 복사 및 붙여넣기
- VS Code의 왼쪽 창에서 Explorer를 클릭하고
pubspec.yaml
파일을 엽니다.
- 이 파일의 콘텐츠를 크로스워드 생성에 필요한 다음 종속 항목으로 바꿉니다.
pubspec.yaml
name: generate_crossword
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: ^3.9.0
dependencies:
flutter:
sdk: flutter
built_collection: ^5.1.1
built_value: ^8.10.1
characters: ^1.4.0
flutter_riverpod: ^2.6.1
intl: ^0.20.2
riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
two_dimensional_scrollables: ^0.3.7
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
build_runner: ^2.5.4
built_value_generator: ^8.10.1
custom_lint: ^0.7.6
riverpod_generator: ^2.6.5
riverpod_lint: ^2.6.5
flutter:
uses-material-design: true
pubspec.yaml
파일은 앱에 관한 기본 정보(예: 현재 버전, 종속 항목)를 지정합니다. 일반적인 빈 Flutter 앱에 포함되지 않는 종속 항목 모음이 표시됩니다. 다음 단계에서 이러한 모든 패키지를 활용합니다.
종속 항목 이해
코드를 살펴보기 전에 이러한 특정 패키지가 선택된 이유를 알아보겠습니다.
- built_value: 메모리를 효율적으로 공유하는 변경 불가능한 객체를 생성합니다. 이는 백트래킹 알고리즘에 중요합니다.
- Riverpod:
select()
를 사용하여 세분화된 상태 관리를 제공하여 리빌드를 최소화합니다. - two_dimensional_scrollables: 성능 저하 없이 큰 그리드를 처리합니다.
lib/
디렉터리에서main.dart
파일을 엽니다.
- 이 파일의 콘텐츠를 다음으로 바꿉니다.
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)),
),
),
),
),
);
}
- 이 코드를 실행하여 모든 것이 제대로 작동하는지 확인합니다. 모든 새 프로젝트의 필수 시작 문구가 포함된 새 창이 어디에나 표시되어야 합니다. 이 앱이 상태 관리에
riverpod
를 사용함을 나타내는ProviderScope
가 있습니다.
체크포인트: 기본 앱 실행
이제 'Hello, World!' 창이 표시됩니다. 그렇지 않은 경우 다음 단계를 따르세요.
- Flutter가 올바르게 설치되었는지 확인
flutter run
를 사용하여 앱이 실행되는지 확인합니다.- 터미널에 컴파일 오류가 없는지 확인합니다.
3. 단어 추가
십자말풀이의 구성요소
크로스워드는 기본적으로 단어 목록입니다. 단어는 격자 모양으로 배열되어 있으며, 일부는 가로로, 일부는 세로로 배열되어 단어가 서로 맞물립니다. 한 단어를 풀면 그 단어와 교차하는 단서가 제공됩니다. 따라서 첫 번째로 적합한 구성요소는 단어 목록입니다.
이러한 단어의 좋은 소스는 Peter Norvig의 자연어 코퍼스 데이터 페이지입니다. SOWPODS 목록은 267,750개의 단어가 포함되어 있어 유용한 시작점입니다.
이 단계에서는 단어 목록을 다운로드하고, Flutter 앱에 애셋으로 추가하고, 시작 시 목록을 앱에 로드하도록 Riverpod 제공자를 정렬합니다.
시작하려면 다음 단계를 따릅니다.
- 선택한 단어 목록에 다음 애셋 선언을 추가하도록 프로젝트의
pubspec.yaml
파일을 수정합니다. 나머지는 동일하게 유지되므로 이 목록에는 앱 구성의 Flutter 스탠자만 표시됩니다.
pubspec.yaml
flutter:
uses-material-design: true
assets: # Add this line
- assets/words.txt # And this one.
이 파일을 아직 만들지 않았으므로 편집기에서 이 마지막 줄을 경고와 함께 강조 표시할 수 있습니다.
- 브라우저와 편집기를 사용하여 프로젝트의 최상위 수준에
assets
디렉터리를 만들고 이 디렉터리에 이전에 연결된 단어 목록 중 하나를 사용하여words.txt
파일을 만듭니다.
이 코드는 앞에서 언급한 SOWPODS 목록을 사용하여 설계되었지만 A~Z 문자로만 구성된 단어 목록과도 호환됩니다. 이 코드베이스를 확장하여 다양한 문자 집합과 작동하도록 하는 것은 독자의 연습으로 남겨둡니다.
단어 로드
앱 시작 시 단어 목록을 로드하는 코드를 작성하려면 다음 단계를 따르세요.
lib
디렉터리에providers.dart
파일을 만듭니다.- 파일에 다음을 추가합니다.
lib/providers.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
이 코드베이스의 첫 번째 Riverpod 제공자입니다.
이 제공업체의 작동 방식:
- 애셋에서 단어 목록을 비동기적으로 로드합니다.
- 2자(영문 기준)보다 긴 a~z 문자만 포함하도록 단어를 필터링합니다.
- 효율적인 임의 액세스를 위해 변경 불가능한
BuiltSet
을 반환합니다.
이 프로젝트는 Riverpod을 비롯한 여러 종속 항목에 코드 생성을 사용합니다.
- 코드 생성을 시작하려면 다음 명령어를 실행합니다.
$ dart run build_runner watch -d [INFO] Generating build script completed, took 174ms [INFO] Setting up file watchers completed, took 5ms [INFO] Waiting for all file watchers to be ready completed, took 202ms [INFO] Reading cached asset graph completed, took 65ms [INFO] Checking for updates since last build completed, took 680ms [INFO] Running build completed, took 2.3s [INFO] Caching finalized dependency graph completed, took 42ms [INFO] Succeeded after 2.3s with 122 outputs (243 actions)
백그라운드에서 계속 실행되며 프로젝트를 변경할 때마다 생성된 파일을 업데이트합니다. 이 명령어가 providers.g.dart
에 코드를 생성하면 편집기에서 providers.dart
에 추가한 코드를 인식할 수 있습니다.
Riverpod에서 이전에 정의한 wordList
함수와 같은 제공자는 일반적으로 지연 인스턴스화됩니다. 하지만 이 앱에서는 단어 목록을 즉시 로드해야 합니다. Riverpod 문서에서는 즉시 로드해야 하는 제공자를 처리하는 다음 방법을 제안합니다. 이제 이를 구현합니다.
lib/widgets
디렉터리에crossword_generator_app.dart
파일을 만듭니다.- 파일에 다음을 추가합니다.
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
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;
}
}
이 파일은 두 가지 방향에서 흥미롭습니다. 첫 번째는 _EagerInitialization
위젯으로, 이전에 만든 wordList
제공자가 단어 목록을 로드하도록 요구하는 것이 유일한 임무입니다. 이 위젯은 ref.watch()
호출을 사용하여 제공자를 수신 대기하여 이 목표를 달성합니다. 이 기법에 대한 자세한 내용은 Riverpod 문서의 Eager initialization of providers를 참고하세요.
이 파일에서 주목할 만한 두 번째 흥미로운 점은 Riverpod이 비동기 콘텐츠를 처리하는 방식입니다. 디스크에서 콘텐츠를 로드하는 속도가 느리므로 wordList
제공자는 비동기 함수로 정의됩니다. 이 코드에서 단어 목록 제공자를 모니터링하면 AsyncValue<BuiltSet<String>>
가 수신됩니다. 이 유형의 AsyncValue
부분은 제공자의 비동기 세계와 위젯의 build
메서드의 동기 세계 간 어댑터입니다.
AsyncValue
의 when
메서드는 미래 값이 있을 수 있는 세 가지 잠재적 상태를 처리합니다. Future가 성공적으로 해결되어 data
콜백이 호출되었을 수도 있고, 오류 상태에 있어 error
콜백이 호출되었을 수도 있으며, 아직 로드 중일 수도 있습니다. 호출된 콜백의 반환이 when
메서드에 의해 반환되므로 세 콜백의 반환 유형은 호환되는 반환 유형을 가져야 합니다. 이 경우 when 메서드의 결과가 Scaffold
위젯의 body
로 표시됩니다.
거의 무한한 목록 앱 만들기
CrosswordGeneratorApp
위젯을 앱에 통합하려면 다음 단계를 따르세요.
- 다음 코드를 추가하여
lib/main.dart
파일을 업데이트합니다.
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_generator_app.dart'; // Add this import
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordGeneratorApp(), // Remove what was here and replace
),
),
);
}
- 앱을 다시 시작합니다. 사전의 267,750개 이상의 단어를 모두 스크롤하는 목록이 표시됩니다.
다음으로 빌드할 항목
이제 불변 객체를 사용하여 크로스워드 퍼즐의 핵심 데이터 구조를 만듭니다. 이 기반을 통해 효율적인 알고리즘과 원활한 UI 업데이트가 가능합니다.
4. 단어를 그리드에 표시
이 단계에서는 built_value
및 built_collection
패키지를 사용하여 크로스워드 퍼즐을 만들기 위한 데이터 구조를 만듭니다. 이 두 패키지를 사용하면 데이터 구조를 불변 값으로 구성할 수 있으므로, 아이솔레이트 간에 데이터를 전달하고 깊이 우선 검색과 백트래킹을 훨씬 쉽게 구현할 수 있습니다.
시작하려면 다음 단계를 따릅니다.
lib
디렉터리에model.dart
파일을 만든 후 파일에 다음 콘텐츠를 추가합니다.
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
static Serializer<Location> get serializer => _$locationSerializer;
/// The horizontal part of the location. The location is 0 based.
int get x;
/// The vertical part of the location. The location is 0 based.
int get y;
/// Returns a new location that is one step to the left of this location.
Location get left => rebuild((b) => b.x = x - 1);
/// Returns a new location that is one step to the right of this location.
Location get right => rebuild((b) => b.x = x + 1);
/// Returns a new location that is one step up from this location.
Location get up => rebuild((b) => b.y = y - 1);
/// Returns a new location that is one step down from this location.
Location get down => rebuild((b) => b.y = y + 1);
/// Returns a new location that is [offset] steps to the left of this location.
Location leftOffset(int offset) => rebuild((b) => b.x = x - offset);
/// Returns a new location that is [offset] steps to the right of this location.
Location rightOffset(int offset) => rebuild((b) => b.x = x + offset);
/// Returns a new location that is [offset] steps up from this location.
Location upOffset(int offset) => rebuild((b) => b.y = y - offset);
/// Returns a new location that is [offset] steps down from this location.
Location downOffset(int offset) => rebuild((b) => b.y = y + offset);
/// Pretty print a location as a (x,y) coordinate.
String prettyPrint() => '($x,$y)';
/// Returns a new location built from [updates]. Both [x] and [y] are
/// required to be non-null.
factory Location([void Function(LocationBuilder)? updates]) = _$Location;
Location._();
/// Returns a location at the given coordinates.
factory Location.at(int x, int y) {
return Location((b) {
b
..x = x
..y = y;
});
}
}
/// The direction of a word in a crossword.
enum Direction {
across,
down;
@override
String toString() => name;
}
/// A word in a crossword. This is a word at a location in a crossword, in either
/// the across or down direction.
abstract class CrosswordWord
implements Built<CrosswordWord, CrosswordWordBuilder> {
static Serializer<CrosswordWord> get serializer => _$crosswordWordSerializer;
/// The word itself.
String get word;
/// The location of this word in the crossword.
Location get location;
/// The direction of this word in the crossword.
Direction get direction;
/// Compare two CrosswordWord by coordinates, x then y.
static int locationComparator(CrosswordWord a, CrosswordWord b) {
final compareRows = a.location.y.compareTo(b.location.y);
final compareColumns = a.location.x.compareTo(b.location.x);
return switch (compareColumns) {
0 => compareRows,
_ => compareColumns,
};
}
/// Constructor for [CrosswordWord].
factory CrosswordWord.word({
required String word,
required Location location,
required Direction direction,
}) {
return CrosswordWord(
(b) => b
..word = word
..direction = direction
..location.replace(location),
);
}
/// Constructor for [CrosswordWord].
/// Use [CrosswordWord.word] instead.
factory CrosswordWord([void Function(CrosswordWordBuilder)? updates]) =
_$CrosswordWord;
CrosswordWord._();
}
/// A character in a crossword. This is a single character at a location in a
/// crossword. It may be part of an across word, a down word, both, but not
/// neither. The neither constraint is enforced elsewhere.
abstract class CrosswordCharacter
implements Built<CrosswordCharacter, CrosswordCharacterBuilder> {
static Serializer<CrosswordCharacter> get serializer =>
_$crosswordCharacterSerializer;
/// The character at this location.
String get character;
/// The across word that this character is a part of.
CrosswordWord? get acrossWord;
/// The down word that this character is a part of.
CrosswordWord? get downWord;
/// Constructor for [CrosswordCharacter].
/// [acrossWord] and [downWord] are optional.
factory CrosswordCharacter.character({
required String character,
CrosswordWord? acrossWord,
CrosswordWord? downWord,
}) {
return CrosswordCharacter((b) {
b.character = character;
if (acrossWord != null) {
b.acrossWord.replace(acrossWord);
}
if (downWord != null) {
b.downWord.replace(downWord);
}
});
}
/// Constructor for [CrosswordCharacter].
/// Use [CrosswordCharacter.character] instead.
factory CrosswordCharacter([
void Function(CrosswordCharacterBuilder)? updates,
]) = _$CrosswordCharacter;
CrosswordCharacter._();
}
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Add a word to the crossword at the given location and direction.
Crossword addWord({
required Location location,
required String word,
required Direction direction,
}) {
return rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width,
(_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([Location, Crossword, CrosswordWord, CrosswordCharacter])
final Serializers serializers = _$serializers;
이 파일은 크로스워드를 만드는 데 사용할 데이터 구조의 시작을 설명합니다. 크로스워드 퍼즐은 그리드에 서로 맞물려 있는 가로 및 세로 단어 목록입니다. 이 데이터 구조를 사용하려면 Crossword.crossword
이름이 지정된 생성자로 적절한 크기의 Crossword
를 구성한 다음 addWord
메서드를 사용하여 단어를 추가합니다. 최종 값을 구성하는 과정에서 _fillCharacters
메서드로 CrosswordCharacter
그리드가 생성됩니다.
이 데이터 구조를 사용하려면 다음 단계를 따르세요.
lib
디렉터리에utils
파일을 만든 후 파일에 다음 콘텐츠를 추가합니다.
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
이는 집합의 임의 요소를 쉽게 가져올 수 있도록 하는 BuiltSet
의 확장 프로그램입니다. 확장 메서드는 추가 기능으로 클래스를 확장하는 좋은 방법입니다. utils.dart
파일 외부에서 확장 프로그램을 사용할 수 있도록 하려면 확장 프로그램의 이름을 지정해야 합니다.
lib/providers.dart
파일에 다음 가져오기를 추가합니다.
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart'; // Add this import
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model; // And this import
import 'utils.dart'; // And this one
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
이러한 가져오기는 이전에 정의된 모델을 곧 만들 제공자에게 노출합니다. dart:math
가져오기는 Random
에 포함되고, flutter/foundation.dart
가져오기는 debugPrint
에 포함되며, model.dart
은 모델에, utils.dart
은 BuiltSet
확장 프로그램에 포함됩니다.
- 동일한 파일의 끝에 다음 제공자를 추가합니다.
lib/providers.dart
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool()
? model.Direction.across
: model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width),
_random.nextInt(size.height),
);
crossword = crossword.addWord(
word: word,
direction: direction,
location: location,
);
yield crossword;
await Future.delayed(Duration(milliseconds: 100));
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
이러한 변경사항은 앱에 두 개의 제공자를 추가합니다. 첫 번째는 Size
로, CrosswordSize
열거형의 선택된 값을 포함하는 전역 변수입니다. 이렇게 하면 UI에서 제작 중인 크로스워드의 크기를 표시하고 설정할 수 있습니다. 두 번째 제공업체인 crossword
는 더 흥미로운 생성입니다. Crossword
의 계열을 반환하는 함수입니다. 함수에 async*
로 표시된 대로 Dart의 생성기 지원을 사용하여 빌드됩니다. 즉, 반환으로 끝나는 대신 일련의 Crossword
를 생성하므로 중간 결과를 반환하는 계산을 훨씬 쉽게 작성할 수 있습니다.
crossword
제공자 함수 시작 부분에 ref.watch
호출 쌍이 있으므로 크로스워드의 선택된 크기가 변경될 때마다 그리고 단어 목록이 로드 완료될 때마다 Crossword
스트림이 Riverpod 시스템에 의해 다시 시작됩니다.
이제 무작위 단어로 가득하지만 크로스워드를 생성하는 코드가 있으므로 도구 사용자에게 이를 표시하는 것이 좋습니다.
lib/widgets
디렉터리에 다음 콘텐츠가 포함된crossword_widget.dart
파일을 만듭니다.
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
crosswordProvider.select(
(crosswordAsync) => crosswordAsync.when(
data: (crossword) => crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
if (character != null) {
return Container(
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: Text(
character.character,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
이 위젯은 ConsumerWidget
이므로 Size
제공자를 직접 사용하여 Crossword
의 문자를 표시할 그리드의 크기를 결정할 수 있습니다. 이 그리드의 표시는 two_dimensional_scrollables
패키지의 TableView
위젯으로 이루어집니다.
_buildCell
도우미 함수로 렌더링된 개별 셀에는 반환된 Widget
트리에 Consumer
위젯이 각각 포함되어 있습니다. 이는 새로고침 경계 역할을 합니다. Consumer
위젯 내의 모든 항목은 ref.watch
의 반환 값이 변경될 때 다시 생성됩니다. Crossword
가 변경될 때마다 전체 트리를 다시 만드는 것이 좋지만 이 설정으로 건너뛸 수 있는 계산이 많이 발생합니다.
ref.watch
의 매개변수를 살펴보면 crosswordProvider.select
를 사용하여 레이아웃을 다시 계산하지 않도록 하는 또 다른 레이어가 있습니다. 즉, ref.watch
는 셀이 렌더링을 담당하는 문자가 변경될 때만 TableViewCell
의 콘텐츠를 다시 빌드하도록 트리거합니다. 다시 렌더링이 줄어드는 것은 UI의 응답성을 유지하는 데 필수적입니다.
사용자에게 CrosswordWidget
및 Size
제공자를 노출하려면 crossword_generator_app.dart
파일을 다음과 같이 변경합니다.
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_widget.dart'; // Add this import
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()], // Add this line
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(child: CrosswordWidget()), // Replace what was here before
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget { // Add from here
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
); // To here.
}
여기에서 몇 가지 사항이 변경되었습니다. 먼저 wordList
을 ListView
로 렌더링하는 코드가 lib/widgets/crossword_widget.dart
파일에 정의된 CrosswordWidget
호출로 대체되었습니다. 또 다른 주요 변경사항은 크로스워드 크기 변경부터 시작하여 앱의 동작을 변경하는 메뉴가 시작된다는 것입니다. 향후 단계에서 더 많은 MenuItemButton
가 추가될 예정입니다. 앱을 실행하면 다음과 같이 표시됩니다.
그리드에 표시된 문자와 사용자가 그리드 크기를 변경할 수 있는 메뉴가 있습니다. 하지만 단어가 십자말풀이처럼 배치되지는 않습니다. 이는 단어가 크로스워드에 추가되는 방식에 제약이 적용되지 않기 때문입니다. 간단히 말해 엉망입니다. 다음 단계에서 제어하기 시작할 수 있습니다.
5. 제약 조건 적용
변경사항 및 이유
현재 크로스워드에서는 검증 없이 단어가 겹쳐도 됩니다. 단어가 실제 크로스워드 퍼즐처럼 제대로 맞물리도록 제약 조건 검사를 추가합니다.
이 단계의 목표는 크로스워드 제약 조건을 적용하기 위해 모델에 코드를 추가하는 것입니다. 다양한 유형의 크로스워드 퍼즐이 있으며 이 Codelab에서 적용할 스타일은 영어 크로스워드 퍼즐의 전통을 따릅니다. 이 코드를 수정하여 다른 스타일의 크로스워드 퍼즐을 생성하는 것은 독자의 연습으로 남겨둡니다.
시작하려면 다음 단계를 따릅니다.
model.dart
파일을 열고Crossword
모델만 다음으로 바꿉니다.
lib/model.dart
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword,
/// or checking the proposed solution.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Checks if this crossword is valid.
bool get valid {
// Check that there are no duplicate words.
final wordSet = words.map((word) => word.word).toBuiltSet();
if (wordSet.length != words.length) {
return false;
}
for (final MapEntry(key: location, value: character)
in characters.entries) {
// All characters must be a part of an across or down word.
if (character.acrossWord == null && character.downWord == null) {
return false;
}
// All characters must be within the crossword puzzle.
// No drawing outside the lines.
if (location.x < 0 ||
location.y < 0 ||
location.x >= width ||
location.y >= height) {
return false;
}
// Characters above and below this character must be related
// by a vertical word
if (characters[location.up] case final up?) {
if (character.downWord == null) {
return false;
}
if (up.downWord != character.downWord) {
return false;
}
}
if (characters[location.down] case final down?) {
if (character.downWord == null) {
return false;
}
if (down.downWord != character.downWord) {
return false;
}
}
// Characters to the left and right of this character must be
// related by a horizontal word
final left = characters[location.left];
if (left != null) {
if (character.acrossWord == null) {
return false;
}
if (left.acrossWord != character.acrossWord) {
return false;
}
}
final right = characters[location.right];
if (right != null) {
if (character.acrossWord == null) {
return false;
}
if (right.acrossWord != character.acrossWord) {
return false;
}
}
}
return true;
}
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
if (words.isNotEmpty && !overlap) {
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width,
(_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
간단히 말씀드리면 model.dart
및 providers.dart
파일을 변경하려면 build_runner
가 실행되어야 관련 model.g.dart
및 providers.g.dart
파일이 업데이트됩니다. 이러한 파일이 자동으로 업데이트되지 않았다면 지금 dart run build_runner watch -d
로 build_runner
를 다시 시작하는 것이 좋습니다.
모델 레이어에서 이 새로운 기능을 활용하려면 이에 맞게 제공자 레이어를 업데이트해야 합니다.
- 다음과 같이
providers.dart
파일을 수정합니다.
lib/providers.dart
import 'dart:convert';
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model;
import 'utils.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
var candidate = crossword.addWord( // Edit from here
word: word, direction: direction, location: location);
await Future.delayed(Duration(milliseconds: 10));
if (candidate != null) {
debugPrint('Added word: $word');
crossword = candidate;
yield crossword;
} else {
debugPrint('Failed to add word: $word');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
- 앱을 실행합니다. UI에서는 많은 일이 일어나지 않지만 로그를 살펴보면 많은 일이 일어나고 있습니다.
여기에서 어떤 일이 일어나고 있는지 생각해 보면 무작위로 십자말풀이가 표시되는 것을 알 수 있습니다. Crossword
모델의 addWord
메서드는 현재 크로스워드에 맞지 않는 제안된 단어를 거부하므로 단어가 표시되는 것 자체가 놀라운 일입니다.
백그라운드 처리로 전환해야 하는 이유
크로스워드 생성 중에 UI가 응답하지 않는 것을 확인할 수 있습니다. 이는 크로스워드 생성에 수천 건의 유효성 검사가 포함되기 때문입니다. 이러한 계산은 Flutter의 60fps 렌더링 루프를 차단하므로 무거운 계산을 백그라운드 격리로 이동합니다. 이렇게 하면 퍼즐이 백그라운드에서 생성되는 동안 UI가 원활하게 유지됩니다.
어떤 단어를 어디에서 시도할지 더 체계적으로 선택하기 위해 이 계산을 UI 스레드에서 백그라운드 격리로 이동하는 것이 매우 유용합니다. Flutter에는 작업을 가져와 백그라운드 격리에서 실행하는 데 매우 유용한 래퍼인 compute
함수가 있습니다.
providers.dart
파일에서 다음과 같이 크로스워드 제공자를 수정합니다.
lib/providers.dart
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool()
? model.Direction.across
: model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width),
_random.nextInt(size.height),
);
try { // Edit from here
var candidate = await compute((
(String, model.Direction, model.Location) wordToAdd,
) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word,
direction: direction,
location: location,
);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
격리 제한사항 이해하기
이 코드는 작동하지만 숨겨진 문제가 있습니다. 아이솔레이트 간에 전달할 수 있는 데이터에 관한 엄격한 규칙이 있으며, 클로저가 직렬화하여 다른 아이솔레이트로 전송할 수 없는 제공자 참조를 '캡처'하는 것이 문제입니다.
시스템에서 직렬화할 수 없는 데이터를 전송하려고 하면 다음이 표시됩니다.
flutter: Error running isolate: Invalid argument(s): Illegal argument in isolate message: object is unsendable - Library:'dart:async' Class: _Future@4048458 (see restrictions listed at `SendPort.send()` documentation for more information) flutter: <- Instance of 'AutoDisposeStreamProviderElement<Crossword>' (from package:riverpod/src/stream_provider.dart) flutter: <- Context num_variables: 2 <- Context num_variables: 1 parent:{ Context num_variables: 2 } flutter: <- Context num_variables: 1 parent:{ Context num_variables: 1 parent:{ Context num_variables: 2 } } flutter: <- Closure: () => Crossword? (from package:generate_crossword/providers.dart)
이는 compute
가 SendPort.send()
을 통해 전송할 수 없는 제공자를 통해 닫히는 백그라운드 격리에 핸드오프되는 클로저의 결과입니다. 이 문제를 해결하는 한 가지 방법은 클로저가 전송할 수 없는 항목을 닫지 않도록 하는 것입니다.
첫 번째 단계는 격리 코드에서 제공자를 분리하는 것입니다.
lib
디렉터리에isolates.dart
파일을 만들고 다음 콘텐츠를 추가합니다.
lib/isolates.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
final _random = Random();
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
while (crossword.characters.length <
crossword.width * crossword.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool() ? Direction.across : Direction.down;
final location = Location.at(
_random.nextInt(crossword.width),
_random.nextInt(crossword.height),
);
try {
var candidate = await compute(((String, Direction, Location) wordToAdd) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word,
direction: direction,
location: location,
);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
}
이 코드는 꽤 익숙할 것입니다. crossword
제공자에 있던 내용의 핵심이지만 이제 독립형 생성기 함수로 제공됩니다. 이제 이 새 함수를 사용하여 백그라운드 격리를 인스턴스화하도록 providers.dart
파일을 업데이트할 수 있습니다.
lib/providers.dart
// Drop the dart:math import, the _random instance moved to isolates.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart'; // Add this import
import 'model.dart' as model;
// Drop the utils.dart import
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
// Drop the _random instance
@riverpod
Stream<model.Crossword> crossword(Ref ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword( // Edit from here
width: size.width,
height: size.height,
);
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyCrossword;
},
loading: () async* {
yield emptyCrossword; // To here.
},
);
}
이제 퍼즐을 푸는 compute
가 백그라운드 격리에서 발생하는 다양한 크기의 크로스워드 퍼즐을 만드는 도구가 있습니다. 이제 크로스워드 퍼즐에 추가할 단어를 결정할 때 코드가 더 효율적일 수 있습니다.
6. 작업 대기열 관리
검색 전략 이해하기
크로스워드 생성에는 체계적인 시행착오 접근 방식인 백트래킹이 사용됩니다. 먼저 앱이 특정 위치에 단어를 배치하려고 시도한 다음 기존 단어와 맞는지 확인합니다. 일치하면 유지하고 다음 단어를 시도합니다. 그렇지 않으면 다른 곳에 설치해 보세요.
백트래킹은 각 단어 배치가 향후 단어에 제약 조건을 생성하여 잘못된 배치가 빠르게 감지되고 포기되는 크로스워드에 적합합니다. 불변 데이터 구조를 사용하면 변경사항을 효율적으로 '되돌릴' 수 있습니다.
현재 코드의 문제 중 하나는 해결해야 하는 문제가 사실상 검색 문제인데 현재 솔루션은 맹목적으로 검색한다는 것입니다. 코드가 그리드의 아무 곳에나 단어를 무작위로 배치하려고 하는 대신 현재 단어에 연결될 단어를 찾는 데 집중하면 시스템에서 더 빠르게 해결책을 찾을 수 있습니다. 이 문제를 해결하는 한 가지 방법은 단어를 찾으려고 시도하는 위치의 작업 대기열을 도입하는 것입니다.
이 코드는 후보 솔루션을 빌드하고, 후보 솔루션이 유효한지 확인하며, 유효성에 따라 후보를 통합하거나 삭제합니다. 이는 백트래킹 알고리즘 계열의 구현 예입니다. 이 구현은 built_value
및 built_collection
에 의해 크게 간소화됩니다. 이러한 구현을 사용하면 파생된 변경 불가능한 값과 공통 상태를 공유하는 새로운 변경 불가능한 값을 만들 수 있기 때문입니다. 이렇게 하면 딥 복사에 필요한 메모리 비용 없이 잠재적 후보를 저렴하게 활용할 수 있습니다.
시작하려면 다음 단계를 따릅니다.
model.dart
파일을 열고 다음WorkQueue
정의를 추가합니다.
lib/model.dart
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
// Add from here
/// A work queue for a worker to process. The work queue contains a crossword
/// and a list of locations to try, along with candidate words to add to the
/// crossword.
abstract class WorkQueue implements Built<WorkQueue, WorkQueueBuilder> {
static Serializer<WorkQueue> get serializer => _$workQueueSerializer;
/// The crossword the worker is working on.
Crossword get crossword;
/// The outstanding queue of locations to try.
BuiltMap<Location, Direction> get locationsToTry;
/// Known bad locations.
BuiltSet<Location> get badLocations;
/// The list of unused candidate words that can be added to this crossword.
BuiltSet<String> get candidateWords;
/// Returns true if the work queue is complete.
bool get isCompleted => locationsToTry.isEmpty || candidateWords.isEmpty;
/// Create a work queue from a crossword.
static WorkQueue from({
required Crossword crossword,
required Iterable<String> candidateWords,
required Location startLocation,
}) => WorkQueue((b) {
if (crossword.words.isEmpty) {
// Strip candidate words too long to fit in the crossword
b.candidateWords.addAll(
candidateWords.where(
(word) => word.characters.length <= crossword.width,
),
);
b.crossword.replace(crossword);
b.locationsToTry.addAll({startLocation: Direction.across});
} else {
// Assuming words have already been stripped to length
b.candidateWords.addAll(
candidateWords.toBuiltSet().rebuild(
(b) => b.removeAll(crossword.words.map((word) => word.word)),
),
);
b.crossword.replace(crossword);
crossword.characters
.rebuild(
(b) => b.removeWhere((location, character) {
if (character.acrossWord != null && character.downWord != null) {
return true;
}
final left = crossword.characters[location.left];
if (left != null && left.downWord != null) return true;
final right = crossword.characters[location.right];
if (right != null && right.downWord != null) return true;
final up = crossword.characters[location.up];
if (up != null && up.acrossWord != null) return true;
final down = crossword.characters[location.down];
if (down != null && down.acrossWord != null) return true;
return false;
}),
)
.forEach((location, character) {
b.locationsToTry.addAll({
location: switch ((character.acrossWord, character.downWord)) {
(null, null) => throw StateError(
'Character is not part of a word',
),
(null, _) => Direction.across,
(_, null) => Direction.down,
(_, _) => throw StateError('Character is part of two words'),
},
});
});
}
});
WorkQueue remove(Location location) => rebuild(
(b) => b
..locationsToTry.remove(location)
..badLocations.add(location),
);
/// Update the work queue from a crossword derived from the current crossword
/// that this work queue is built from.
WorkQueue updateFrom(final Crossword crossword) =>
WorkQueue.from(
crossword: crossword,
candidateWords: candidateWords,
startLocation: locationsToTry.isNotEmpty
? locationsToTry.keys.first
: Location.at(0, 0),
).rebuild(
(b) => b
..badLocations.addAll(badLocations)
..locationsToTry.removeWhere(
(location, _) => badLocations.contains(location),
),
);
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue, // Add this line
])
final Serializers serializers = _$serializers;
- 새 콘텐츠를 추가한 후 몇 초 이상 이 파일에 빨간색 물결선이 남아 있으면
build_runner
가 계속 실행 중인지 확인합니다. 그렇지 않으면dart run build_runner watch -d
명령어를 실행합니다.
이제 다양한 크기의 크로스워드를 만드는 데 걸리는 시간을 보여주는 로깅을 도입할 것입니다. 기간에 형식이 잘 지정된 표시가 있으면 좋을 것 같습니다. 다행히 확장 메서드를 사용하면 필요한 정확한 메서드를 추가할 수 있습니다.
- 다음과 같이
utils.dart
파일을 수정합니다.
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
// Add from here
/// An extension on [Duration] that adds a method to format the duration.
extension DurationFormat on Duration {
/// A human-readable string representation of the duration.
/// This format is tuned for durations in the seconds to days range.
String get formatted {
final hours = inHours.remainder(24).toString().padLeft(2, '0');
final minutes = inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = inSeconds.remainder(60).toString().padLeft(2, '0');
return switch ((inDays, inHours, inMinutes, inSeconds)) {
(0, 0, 0, _) => '${inSeconds}s',
(0, 0, _, _) => '$inMinutes:$seconds',
(0, _, _, _) => '$inHours:$minutes:$seconds',
_ => '$inDays days, $hours:$minutes:$seconds',
};
}
} // To here.
이 확장 프로그램 메서드는 레코드에 대한 switch 표현식과 패턴 일치를 활용하여 초에서 일에 이르는 다양한 기간을 표시하는 적절한 방법을 선택합니다. 이 스타일의 코드에 관한 자세한 내용은 Dart의 패턴과 레코드 살펴보기 Codelab을 참고하세요.
- 이 새로운 기능을 통합하려면
isolates.dart
파일을 바꿔exploreCrosswordSolutions
함수가 정의되는 방식을 다음과 같이 재정의하세요.
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild(
(b) => b
..where((b) => b.characters.contains(target.character))
..shuffle(),
);
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword);
yield crossword;
} else {
workQueue = workQueue.remove(location);
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint(
'${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}',
);
}
이 코드를 실행하면 겉으로 보기에는 동일한 앱이 표시되지만, 완성된 크로스워드 퍼즐을 찾는 데 걸리는 시간은 다릅니다. 다음은 1분 29초 만에 생성된 80x44 크로스워드 퍼즐입니다.
체크포인트: 효율적인 알고리즘 작동
이제 다음과 같은 이유로 크로스워드 생성 속도가 크게 빨라집니다.
- 지능형 단어 배치 타겟팅 교차점
- 게재위치 실패 시 효율적인 백트래킹
- 중복 검색을 방지하기 위한 작업 대기열 관리
여기서 당연히 드는 질문은 더 빠르게 갈 수 있느냐입니다. 네, 가능합니다.
7. 표면 통계
통계를 추가해야 하는 이유
빠르게 처리하려면 어떤 일이 일어나고 있는지 확인하는 것이 좋습니다. 통계를 사용하면 진행 상황을 모니터링하고 알고리즘이 실시간으로 어떻게 작동하는지 확인할 수 있습니다. 알고리즘이 시간을 어디에 사용하는지 파악하여 병목 현상을 식별할 수 있습니다. 이를 통해 최적화 접근 방식에 대해 충분한 정보를 바탕으로 결정을 내려 실적을 조정할 수 있습니다.
표시할 정보는 WorkQueue에서 추출하여 UI에 표시해야 합니다. 유용한 첫 번째 단계는 표시하려는 정보가 포함된 새 모델 클래스를 정의하는 것입니다.
시작하려면 다음 단계를 따릅니다.
- 다음과 같이
model.dart
파일을 수정하여DisplayInfo
클래스를 추가합니다.
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
import 'package:intl/intl.dart'; // Add this import
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
- 파일 끝에서 다음 코드를 추가하여
DisplayInfo
클래스를 추가합니다.
lib/model.dart
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
}
// Add from here.
/// Display information for the current state of the crossword solve.
abstract class DisplayInfo implements Built<DisplayInfo, DisplayInfoBuilder> {
static Serializer<DisplayInfo> get serializer => _$displayInfoSerializer;
/// The number of words in the grid.
String get wordsInGridCount;
/// The number of candidate words.
String get candidateWordsCount;
/// The number of locations to explore.
String get locationsToExploreCount;
/// The number of known bad locations.
String get knownBadLocationsCount;
/// The percentage of the grid filled.
String get gridFilledPercentage;
/// Construct a [DisplayInfo] instance from a [WorkQueue].
factory DisplayInfo.from({required WorkQueue workQueue}) {
final gridFilled =
(workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height));
final fmt = NumberFormat.decimalPattern();
return DisplayInfo(
(b) => b
..wordsInGridCount = fmt.format(workQueue.crossword.words.length)
..candidateWordsCount = fmt.format(workQueue.candidateWords.length)
..locationsToExploreCount = fmt.format(workQueue.locationsToTry.length)
..knownBadLocationsCount = fmt.format(workQueue.badLocations.length)
..gridFilledPercentage = '${(gridFilled * 100).toStringAsFixed(2)}%',
);
}
/// An empty [DisplayInfo] instance.
static DisplayInfo get empty => DisplayInfo(
(b) => b
..wordsInGridCount = '0'
..candidateWordsCount = '0'
..locationsToExploreCount = '0'
..knownBadLocationsCount = '0'
..gridFilledPercentage = '0%',
);
factory DisplayInfo([void Function(DisplayInfoBuilder)? updates]) =
_$DisplayInfo;
DisplayInfo._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo, // Add this line.
])
final Serializers serializers = _$serializers;
- 다음과 같이
WorkQueue
모델을 노출하도록isolates.dart
파일을 수정합니다.
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({ // Modify this line
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild(
(b) => b
..where((b) => b.characters.contains(target.character))
..shuffle(),
);
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword); // Drop the yield crossword;
} else {
workQueue = workQueue.remove(location);
}
yield workQueue; // Add this line.
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint(
'${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}',
);
}
이제 백그라운드 격리에서 작업 대기열을 노출하므로 이 데이터 소스에서 통계를 파생하는 방법과 위치가 문제입니다.
- 이전 크로스워드 제공업체를 작업 대기열 제공업체로 바꾼 다음 작업 대기열 제공업체의 스트림에서 정보를 파생하는 제공업체를 추가합니다.
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* { // Modify this provider
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
} // To here.
@Riverpod(keepAlive: true) // Add from here to end of file
class StartTime extends _$StartTime {
@override
DateTime? build() => _start;
DateTime? _start;
void start() {
_start = DateTime.now();
ref.invalidateSelf();
}
}
@Riverpod(keepAlive: true)
class EndTime extends _$EndTime {
@override
DateTime? build() => _end;
DateTime? _end;
void clear() {
_end = null;
ref.invalidateSelf();
}
void end() {
_end = DateTime.now();
ref.invalidateSelf();
}
}
const _estimatedTotalCoverage = 0.54;
@riverpod
Duration expectedRemainingTime(Ref ref) {
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final workQueueAsync = ref.watch(workQueueProvider);
return workQueueAsync.when(
data: (workQueue) {
if (startTime == null || endTime != null || workQueue.isCompleted) {
return Duration.zero;
}
try {
final soFar = DateTime.now().difference(startTime);
final completedPercentage = min(
0.99,
(workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height) /
_estimatedTotalCoverage),
);
final expectedTotal = soFar.inSeconds / completedPercentage;
final expectedRemaining = expectedTotal - soFar.inSeconds;
return Duration(seconds: expectedRemaining.toInt());
} catch (e) {
return Duration.zero;
}
},
error: (error, stackTrace) => Duration.zero,
loading: () => Duration.zero,
);
}
/// A provider that holds whether to display info.
@Riverpod(keepAlive: true)
class ShowDisplayInfo extends _$ShowDisplayInfo {
var _display = true;
@override
bool build() => _display;
void toggle() {
_display = !_display;
ref.invalidateSelf();
}
}
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref
.watch(workQueueProvider)
.when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
새로운 제공자는 정보 표시가 크로스워드 그리드 위에 오버레이되어야 하는지 여부 형태의 전역 상태와 크로스워드 생성의 실행 시간과 같은 파생 데이터가 혼합되어 있습니다. 이러한 상태의 리스너 중 일부가 일시적이라는 사실로 인해 모든 것이 복잡해집니다. 정보 표시가 숨겨져 있으면 크로스워드 계산의 시작 시간과 종료 시간을 수신하는 항목이 없지만 정보 표시가 표시될 때 계산이 정확하려면 메모리에 유지되어야 합니다. 이 경우 Riverpod
속성의 keepAlive
매개변수가 매우 유용합니다.
정보 디스플레이를 표시할 때 약간의 문제가 있습니다. 경과된 실행 시간을 표시할 수 있어야 하지만 경과된 시간의 지속적인 업데이트를 강제하는 것은 없습니다. Flutter에서 차세대 UI 빌드 Codelab으로 돌아가면 이 요구사항에 적합한 유용한 위젯이 있습니다.
lib/widgets
디렉터리에ticker_builder.dart
파일을 만들고 다음 콘텐츠를 추가합니다.
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);
}
이 위젯은 슬레지해머입니다. 모든 프레임에서 콘텐츠를 다시 빌드합니다. 일반적으로는 권장되지 않지만, 크로스워드 퍼즐을 검색하는 계산 부하와 비교하면 프레임마다 경과 시간을 다시 그리는 계산 부하는 노이즈로 사라질 것입니다. 새로 파생된 정보를 활용하려면 새 위젯을 만들어야 합니다.
lib/widgets
디렉터리에crossword_info_widget.dart
파일을 만들고 다음 콘텐츠를 추가합니다.
lib/widgets/crossword_info_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import '../utils.dart';
import 'ticker_builder.dart';
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(right: 32.0, bottom: 32.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}',
),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount,
),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount,
),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount,
),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount,
),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage,
),
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted,
),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining',
value: remaining.formatted,
),
],
),
),
),
),
),
),
);
}
}
class _CrosswordInfoRichText extends StatelessWidget {
final String label;
final String value;
const _CrosswordInfoRichText({required this.label, required this.value});
@override
Widget build(BuildContext context) => RichText(
text: TextSpan(
children: [
TextSpan(text: '$label ', style: DefaultTextStyle.of(context).style),
TextSpan(
text: value,
style: DefaultTextStyle.of(
context,
).style.copyWith(fontWeight: FontWeight.bold),
),
],
),
);
}
이 위젯은 Riverpod 제공자의 강력한 기능을 보여주는 대표적인 예입니다. 5개 제공자 중 하나가 업데이트되면 이 위젯은 다시 빌드되도록 표시됩니다. 이 단계에서 마지막으로 필요한 변경사항은 이 새 위젯을 UI에 통합하는 것입니다.
- 다음과 같이
crossword_generator_app.dart
파일을 수정합니다.
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_info_widget.dart'; // Add this import
import 'crossword_widget.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer( // Modify from here
builder: (context, ref, child) {
return Stack(
children: [
Positioned.fill(child: CrosswordWidget()),
if (ref.watch(showDisplayInfoProvider)) CrosswordInfoWidget(),
],
);
},
), // To here.
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton( // Add from here
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () => ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
), // To here.
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
여기서 두 가지 변경사항은 공급자를 통합하는 다양한 접근 방식을 보여줍니다. CrosswordGeneratorApp
의 build
메서드에서 정보 표시가 표시되거나 숨겨질 때 강제로 다시 빌드되는 영역을 포함하는 새로운 Consumer
빌더를 도입했습니다. 반면 전체 드롭다운 메뉴는 하나의 ConsumerWidget
이며, 크로스워드 퍼즐의 크기를 조정하든 정보 표시를 표시하거나 숨기든 다시 빌드됩니다. 어떤 접근 방식을 취할지는 항상 단순성과 재빌드된 위젯 트리의 레이아웃을 다시 계산하는 비용 간의 엔지니어링 트레이드오프입니다.
이제 앱을 실행하면 사용자가 크로스워드 생성 진행 상황을 더 자세히 파악할 수 있습니다. 하지만 크로스워드 생성의 끝부분에 가까워지면 숫자는 바뀌지만 문자 그리드에는 거의 변화가 없는 기간이 있습니다.
무슨 일이 일어나고 있는지, 그 이유는 무엇인지에 관한 추가 정보를 얻는 것이 유용할 수 있습니다.
8. 스레드로 병렬화
성능 저하의 원인
크로스워드가 거의 완성되면 유효한 단어 배치 옵션이 적게 남아 알고리즘이 느려집니다. 알고리즘이 작동하지 않는 조합을 많이 시도합니다. 단일 스레드 처리로는 여러 옵션을 효율적으로 탐색할 수 없습니다.
알고리즘 시각화
마지막에 느려지는 이유를 이해하려면 알고리즘이 무엇을 하고 있는지 시각화할 수 있는 것이 유용합니다. 핵심 부분은 WorkQueue
의 미결제 locationsToTry
입니다. TableView를 사용하면 이 문제를 유용하게 조사할 수 있습니다. locationsToTry
에 있는지에 따라 셀 색상을 변경할 수 있습니다.
시작하려면 다음 단계를 따릅니다.
- 다음과 같이
crossword_widget.dart
파일을 수정합니다.
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch( // Add from here
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
); // To here.
if (character != null) { // Modify from here
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text(character.character),
), // To here.
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
이 코드를 실행하면 알고리즘에서 아직 조사하지 않은 미해결 위치가 시각화됩니다.
크로스워드가 완성되어 가는 과정을 지켜보면 유용하지 않은 다양한 지점이 조사 대상으로 남아 있다는 점이 흥미롭습니다. 여기에는 두 가지 옵션이 있습니다. 하나는 특정 비율의 크로스워드 퍼즐 셀이 채워지면 조사를 중단하는 것이고, 두 번째는 한 번에 여러 관심 포인트를 조사하는 것입니다. 두 번째 경로가 더 재미있을 것 같으니 이제 두 번째 경로를 따라가 보겠습니다.
isolates.dart
파일을 수정합니다. 이는 하나의 백그라운드 격리에서 계산된 항목을 N개의 백그라운드 격리 풀로 분할하기 위해 코드를 거의 완전히 다시 작성한 것입니다.
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
required int maxWorkerCount,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
try {
workQueue = await compute(_generate, (workQueue, maxWorkerCount));
yield workQueue;
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint(
'Generated ${workQueue.crossword.width} x '
'${workQueue.crossword.height} crossword in '
'${DateTime.now().difference(start).formatted} '
'with $maxWorkerCount workers.',
);
}
Future<WorkQueue> _generate((WorkQueue, int) workMessage) async {
var (workQueue, maxWorkerCount) = workMessage;
final candidateGeneratorFutures = <Future<(Location, Direction, String?)>>[];
final locations = workQueue.locationsToTry.keys.toBuiltList().rebuild(
(b) => b
..shuffle()
..take(maxWorkerCount),
);
for (final location in locations) {
final direction = workQueue.locationsToTry[location]!;
candidateGeneratorFutures.add(
compute(_generateCandidate, (
workQueue.crossword,
workQueue.candidateWords,
location,
direction,
)),
);
}
try {
final results = await candidateGeneratorFutures.wait;
var crossword = workQueue.crossword;
for (final (location, direction, word) in results) {
if (word != null) {
final candidate = crossword.addWord(
location: location,
word: word,
direction: direction,
);
if (candidate != null) {
crossword = candidate;
}
} else {
workQueue = workQueue.remove(location);
}
}
workQueue = workQueue.updateFrom(crossword);
} catch (e) {
debugPrint('$e');
}
return workQueue;
}
(Location, Direction, String?) _generateCandidate(
(Crossword, BuiltSet<String>, Location, Direction) searchDetailMessage,
) {
final (crossword, candidateWords, location, direction) = searchDetailMessage;
final target = crossword.characters[location];
if (target == null) {
return (location, direction, candidateWords.randomElement());
}
// Filter down the candidate word list to those that contain the letter
// at the current location
final words = candidateWords.toBuiltList().rebuild(
(b) => b
..where((b) => b.characters.contains(target.character))
..shuffle(),
);
int tryCount = 0;
final start = DateTime.now();
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return switch (direction) {
Direction.across => (location.leftOffset(index), direction, word),
Direction.down => (location.upOffset(index), direction, word),
};
}
final deltaTime = DateTime.now().difference(start);
if (tryCount >= 1000 || deltaTime > Duration(seconds: 10)) {
return (location, direction, null);
}
}
}
return (location, direction, null);
}
다중 격리 아키텍처 이해
핵심 비즈니스 로직은 변경되지 않았으므로 이 코드의 대부분은 익숙할 것입니다. 이제 compute
호출이 두 레이어로 구성됩니다. 첫 번째 레이어는 검색할 개별 위치를 N개의 작업자 격리에 할당한 다음 N개의 작업자 격리가 모두 완료되면 결과를 다시 결합합니다. 두 번째 레이어는 N개의 작업자 격리로 구성됩니다. 최적의 성능을 얻기 위해 N을 조정하는 것은 컴퓨터와 해당 데이터에 따라 다릅니다. 그리드가 클수록 서로 방해하지 않고 함께 작업할 수 있는 작업자가 많아집니다.
한 가지 흥미로운 점은 이 코드가 이제 클로저가 캡처해서는 안 되는 항목을 캡처하는 문제를 처리하는 방식입니다. 이제 폐쇄된 항목이 없습니다. _generate
및 _generateWorker
함수는 캡처할 주변 환경이 없는 최상위 함수로 정의됩니다. 이 두 함수의 인수와 결과는 Dart 레코드 형식입니다. 이는 compute
호출의 하나의 값 입력, 하나의 값 출력 의미 체계를 해결하는 방법입니다.
이제 그리드에서 서로 맞물려 크로스워드 퍼즐을 형성하는 단어를 검색하는 백그라운드 작업자 풀을 만들 수 있으므로 이 기능을 나머지 크로스워드 생성기 도구에 노출할 차례입니다.
- 다음과 같이 workQueue 제공자를 수정하여
providers.dart
파일을 수정합니다.
lib/providers.dart
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* {
final workers = ref.watch(workerCountProvider); // Add this line
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: workers.count, // Add this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
}
- 다음과 같이 파일 끝에
WorkerCount
제공자를 추가합니다.
lib/providers.dart
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref
.watch(workQueueProvider)
.when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
enum BackgroundWorkers { // Add from here
one(1),
two(2),
four(4),
eight(8),
sixteen(16),
thirtyTwo(32),
sixtyFour(64),
oneTwentyEight(128);
const BackgroundWorkers(this.count);
final int count;
String get label => count.toString();
}
/// A provider that holds the current number of background workers to use.
@Riverpod(keepAlive: true)
class WorkerCount extends _$WorkerCount {
var _count = BackgroundWorkers.four;
@override
BackgroundWorkers build() => _count;
void setCount(BackgroundWorkers count) {
_count = count;
ref.invalidateSelf();
}
} // To here.
이 두 가지 변경사항을 통해 이제 프로바이더 레이어는 격리 함수가 올바르게 구성되도록 배경 격리 풀의 최대 작업자 수를 설정하는 방법을 노출합니다.
- 다음과 같이
CrosswordInfoWidget
를 수정하여crossword_info_widget.dart
파일을 업데이트합니다.
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,
),
],
),
),
),
),
),
),
);
}
}
_CrosswordGeneratorMenu
위젯에 다음 섹션을 추가하여crossword_generator_app.dart
파일을 수정합니다.
lib/widgets/crossword_generator_app.dart
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton(
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () => ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
),
for (final count in BackgroundWorkers.values) // Add from here
MenuItemButton(
leadingIcon: count == ref.watch(workerCountProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
onPressed: () =>
ref.read(workerCountProvider.notifier).setCount(count),
child: Text(count.label), // To here.
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
이제 앱을 실행하면 크로스워드에 들어갈 단어를 검색하기 위해 인스턴스화되는 백그라운드 격리 요소의 수를 수정할 수 있습니다.
- 톱니바퀴 아이콘을 클릭하여 크로스워드 퍼즐의 크기, 생성된 크로스워드 퍼즐에 통계를 표시할지 여부, 사용할 격리된 단어 수를 포함하는 컨텍스트 메뉴를 엽니다.
체크포인트: 멀티 스레드 성능
크로스워드 생성기를 실행하면 여러 코어를 동시에 사용하여 80x44 크로스워드의 컴퓨팅 시간이 크게 줄어듭니다. 다음과 같은 사항을 확인할 수 있습니다.
- 작업자 수가 많을수록 더 빠른 크로스워드 생성
- 생성 중 원활한 UI 응답성
- 생성 진행 상황을 보여주는 실시간 통계
- 알고리즘 탐색 영역의 시각적 피드백
9. 게임으로 만들기
Google에서 개발 중인 게임: 게임 룸 크로스워드 게임
마지막 섹션은 보너스 라운드입니다. 십자말풀이 생성기를 구성하면서 배운 모든 기법을 사용하여 게임을 빌드합니다. 실습할 내용은 다음과 같습니다.
- 퍼즐 생성: 크로스워드 생성기를 사용하여 풀 수 있는 퍼즐을 만듭니다.
- 단어 선택지 만들기: 각 위치에 여러 단어 옵션 제공
- 상호작용 사용 설정: 사용자가 단어를 선택하고 배치할 수 있도록 허용
- 솔루션 검증: 완성된 크로스워드가 올바른지 확인합니다.
크로스워드 생성기를 사용하여 크로스워드 퍼즐을 만듭니다. 컨텍스트 메뉴 관용구를 재사용하여 사용자가 그리드의 다양한 단어 모양 구멍에 넣을 단어를 선택하고 선택 해제할 수 있도록 합니다. 모두 크로스워드를 완성하기 위한 것입니다.
이 게임이 세련되거나 완성되었다고 말할 수는 없습니다. 사실과는 거리가 멉니다. 대체 단어 선택을 개선하면 해결할 수 있는 균형 및 난이도 문제가 있습니다. 사용자를 퍼즐로 안내하는 튜토리얼이 없습니다. 최소한의 '당첨되었습니다' 화면은 언급하지 않겠습니다.
여기서의 트레이드오프는 이 프로토 게임을 정식 게임으로 제대로 다듬으려면 코드가 훨씬 더 많이 필요하다는 것입니다. 단일 Codelab에 포함하기에는 너무 많은 코드 따라서 이 단계는 사용 위치와 방법을 변경하여 이 Codelab에서 지금까지 배운 기법을 강화하도록 설계된 스피드런 단계입니다. 이로써 이 Codelab의 앞부분에서 배운 내용을 강화할 수 있기를 바랍니다. 또는 이 코드를 기반으로 자체 환경을 빌드할 수도 있습니다. 여러분이 빌드한 결과물을 기대하겠습니다.
시작하려면 다음 단계를 따릅니다.
lib/widgets
디렉터리의 모든 항목을 삭제합니다. 게임에 사용할 멋진 새 위젯을 만들게 됩니다. 이 위젯은 이전 위젯에서 많은 부분을 차용합니다.
model.dart
파일을 수정하여Crossword
의addWord
메서드를 다음과 같이 업데이트합니다.
lib/model.dart
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
bool requireOverlap = true, // Add this parameter
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
// Edit from here
// If overlap is required, make sure that the word overlaps with an existing
// word. Skip this test if the crossword is empty.
if (words.isNotEmpty && !overlap && requireOverlap) { // To here.
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
이러한 사소한 수정으로 인해 겹치지 않는 단어를 추가할 수 있습니다. 플레이어가 보드 어디에서나 플레이할 수 있으면서도 플레이어의 이동을 저장하는 기본 모델로 Crossword
를 사용할 수 있도록 하는 것이 유용합니다. 특정 방향으로 배치된 특정 위치의 단어 목록일 뿐입니다.
CrosswordPuzzleGame
모델 클래스를model.dart
파일 끝에 추가합니다.
lib/model.dart
/// Creates a puzzle from a crossword and a set of candidate words.
abstract class CrosswordPuzzleGame
implements Built<CrosswordPuzzleGame, CrosswordPuzzleGameBuilder> {
static Serializer<CrosswordPuzzleGame> get serializer =>
_$crosswordPuzzleGameSerializer;
/// The [Crossword] that this puzzle is based on.
Crossword get crossword;
/// The alternate words for each [CrosswordWord] in the crossword.
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>> get alternateWords;
/// The player's selected words.
BuiltList<CrosswordWord> get selectedWords;
bool canSelectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return true;
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild(
(b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
),
);
}
return null !=
puzzle.crosswordFromSelectedWords.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false,
);
}
CrosswordPuzzleGame? selectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return rebuild((b) => b.selectedWords.remove(crosswordWord));
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild(
(b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
),
);
}
// Check if the selected word meshes with the already selected words.
// Note this version of the crossword does not enforce overlap to
// allow the player to select words anywhere on the grid. Enforcing words
// to be solved in order is a possible alternative.
final updatedSelectedWordsCrossword = puzzle.crosswordFromSelectedWords
.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false,
);
// Make sure the selected word is in the crossword or is an alternate word.
if (updatedSelectedWordsCrossword != null) {
if (puzzle.crossword.words.contains(crosswordWord) ||
puzzle.alternateWords[location]?[direction]?.contains(word) == true) {
return puzzle.rebuild(
(b) => b
..selectedWords.add(
CrosswordWord.word(
word: word,
location: location,
direction: direction,
),
),
);
}
}
return null;
}
/// The crossword from the selected words.
Crossword get crosswordFromSelectedWords => Crossword.crossword(
width: crossword.width,
height: crossword.height,
words: selectedWords,
);
/// Test if the puzzle is solved. Note, this allows for the possibility of
/// multiple solutions.
bool get solved =>
crosswordFromSelectedWords.valid &&
crosswordFromSelectedWords.words.length == crossword.words.length &&
crossword.words.isNotEmpty;
/// Create a crossword puzzle game from a crossword and a set of candidate
/// words.
factory CrosswordPuzzleGame.from({
required Crossword crossword,
required BuiltSet<String> candidateWords,
}) {
// Remove all of the currently used words from the list of candidates
candidateWords = candidateWords.rebuild(
(p0) => p0.removeAll(crossword.words.map((p1) => p1.word)),
);
// This is the list of alternate words for each word in the crossword
var alternates =
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>>();
// Build the alternate words for each word in the crossword
for (final crosswordWord in crossword.words) {
final alternateWords = candidateWords.toBuiltList().rebuild(
(b) => b
..where((b) => b.length == crosswordWord.word.length)
..shuffle()
..take(4)
..sort(),
);
candidateWords = candidateWords.rebuild(
(b) => b.removeAll(alternateWords),
);
alternates = alternates.rebuild(
(b) => b.updateValue(
crosswordWord.location,
(b) => b.rebuild(
(b) => b.updateValue(
crosswordWord.direction,
(b) => b.rebuild((b) => b.replace(alternateWords)),
ifAbsent: () => alternateWords,
),
),
ifAbsent: () => {crosswordWord.direction: alternateWords}.build(),
),
);
}
return CrosswordPuzzleGame((b) {
b
..crossword.replace(crossword)
..alternateWords.replace(alternates);
});
}
factory CrosswordPuzzleGame([
void Function(CrosswordPuzzleGameBuilder)? updates,
]) = _$CrosswordPuzzleGame;
CrosswordPuzzleGame._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo,
CrosswordPuzzleGame, // Add this line
])
final Serializers serializers = _$serializers;
providers.dart
파일의 업데이트는 흥미로운 변경사항 모음입니다. 통계 수집을 지원하는 대부분의 제공자가 삭제되었습니다. 백그라운드 격리 항목 수를 변경하는 기능이 삭제되고 상수로 대체되었습니다. 이전에 추가한 새 CrosswordPuzzleGame
모델에 액세스할 수 있는 새로운 제공자도 있습니다.
lib/providers.dart
import 'dart:convert';
// Drop the dart:math import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
const backgroundWorkerCount = 4; // Add this line
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(Ref ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter()
.convert(words)
.toBuiltSet()
.rebuild(
(b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)),
);
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({required this.width, required this.height});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(Ref ref) async* {
final size = ref.watch(sizeProvider); // Drop the ref.watch(workerCountProvider)
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = model.Crossword.crossword(
width: size.width,
height: size.height,
);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
// Drop the startTimeProvider and endTimeProvider refs
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: backgroundWorkerCount, // Edit this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
} // Drop the endTimeProvider ref
@riverpod // Add from here to end of file
class Puzzle extends _$Puzzle {
model.CrosswordPuzzleGame _puzzle = model.CrosswordPuzzleGame.from(
crossword: model.Crossword.crossword(width: 0, height: 0),
candidateWords: BuiltSet<String>(),
);
@override
model.CrosswordPuzzleGame build() {
final size = ref.watch(sizeProvider);
final wordList = ref.watch(wordListProvider).value;
final workQueue = ref.watch(workQueueProvider).value;
if (wordList != null &&
workQueue != null &&
workQueue.isCompleted &&
(_puzzle.crossword.height != size.height ||
_puzzle.crossword.width != size.width ||
_puzzle.crossword != workQueue.crossword)) {
compute(_puzzleFromCrosswordTrampoline, (
workQueue.crossword,
wordList,
)).then((puzzle) {
_puzzle = puzzle;
ref.invalidateSelf();
});
}
return _puzzle;
}
Future<void> selectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) async {
final candidate = await compute(_puzzleSelectWordTrampoline, (
_puzzle,
location,
word,
direction,
));
if (candidate != null) {
_puzzle = candidate;
ref.invalidateSelf();
} else {
debugPrint('Invalid word selection: $word');
}
}
bool canSelectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) {
return _puzzle.canSelectWord(
location: location,
word: word,
direction: direction,
);
}
}
// Trampoline functions to disentangle these Isolate target calls from the
// unsendable reference to the [Puzzle] provider.
Future<model.CrosswordPuzzleGame> _puzzleFromCrosswordTrampoline(
(model.Crossword, BuiltSet<String>) args,
) async =>
model.CrosswordPuzzleGame.from(crossword: args.$1, candidateWords: args.$2);
model.CrosswordPuzzleGame? _puzzleSelectWordTrampoline(
(model.CrosswordPuzzleGame, model.Location, String, model.Direction) args,
) => args.$1.selectWord(location: args.$2, word: args.$3, direction: args.$4);
Puzzle
제공자의 가장 흥미로운 부분은 Crossword
및 wordList
에서 CrosswordPuzzleGame
를 만드는 비용과 단어를 선택하는 비용을 숨기기 위해 취한 전략입니다. 백그라운드 격리의 도움 없이 이러한 작업을 수행하면 UI 상호작용이 느려집니다. 일종의 속임수를 사용하여 백그라운드에서 최종 결과를 계산하는 동안 중간 결과를 푸시하면 필요한 계산이 백그라운드에서 실행되는 동안 응답성이 뛰어난 UI를 얻을 수 있습니다.
- 이제 비어 있는
lib/widgets
디렉터리에 다음 콘텐츠가 포함된crossword_puzzle_app.dart
파일을 만듭니다.
lib/widgets/crossword_puzzle_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_generator_widget.dart';
import 'crossword_puzzle_widget.dart';
import 'puzzle_completed_widget.dart';
class CrosswordPuzzleApp extends StatelessWidget {
const CrosswordPuzzleApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordPuzzleAppMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Puzzle'),
),
body: SafeArea(
child: Consumer(
builder: (context, ref, _) {
final workQueueAsync = ref.watch(workQueueProvider);
final puzzleSolved = ref.watch(
puzzleProvider.select((puzzle) => puzzle.solved),
);
return workQueueAsync.when(
data: (workQueue) {
if (puzzleSolved) {
return PuzzleCompletedWidget();
}
if (workQueue.isCompleted &&
workQueue.crossword.characters.isNotEmpty) {
return CrosswordPuzzleWidget();
}
return CrosswordGeneratorWidget();
},
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(child: Text('$error')),
);
},
),
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordPuzzleAppMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
이제 이 파일의 대부분이 꽤 익숙할 것입니다. 예, 정의되지 않은 위젯이 있습니다. 이제 이를 수정하세요.
crossword_generator_widget.dart
파일을 만들고 다음 콘텐츠를 추가합니다.
lib/widgets/crossword_generator_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordGeneratorWidget extends ConsumerWidget {
const CrosswordGeneratorWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
);
if (character != null) {
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text('•'), // https://www.compart.com/en/unicode/U+2022
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
이것도 어느 정도 익숙할 것입니다. 주요 차이점은 생성되는 단어의 문자를 표시하는 대신 알 수 없는 문자의 존재를 나타내는 유니코드 문자를 표시한다는 것입니다. 미적 감각을 개선하기 위해 작업이 필요합니다.
crossword_puzzle_widget.dart
파일을 만들고 다음 콘텐츠를 추가합니다.
lib/widgets/crossword_puzzle_widget.dart
import 'package:built_collection/built_collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordPuzzleWidget extends ConsumerWidget {
const CrosswordPuzzleWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
puzzleProvider.select(
(puzzle) => puzzle.crossword.characters[location],
),
);
final selectedCharacter = ref.watch(
puzzleProvider.select(
(puzzle) =>
puzzle.crosswordFromSelectedWords.characters[location],
),
);
final alternateWords = ref.watch(
puzzleProvider.select((puzzle) => puzzle.alternateWords),
);
if (character != null) {
final acrossWord = character.acrossWord;
var acrossWords = BuiltList<String>();
if (acrossWord != null) {
acrossWords = acrossWords.rebuild(
(b) => b
..add(acrossWord.word)
..addAll(
alternateWords[acrossWord.location]?[acrossWord
.direction] ??
[],
)
..sort(),
);
}
final downWord = character.downWord;
var downWords = BuiltList<String>();
if (downWord != null) {
downWords = downWords.rebuild(
(b) => b
..add(downWord.word)
..addAll(
alternateWords[downWord.location]?[downWord.direction] ??
[],
)
..sort(),
);
}
return MenuAnchor(
builder: (context, controller, _) {
return GestureDetector(
onTapDown: (details) =>
controller.open(position: details.localPosition),
child: AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
child: Text(selectedCharacter?.character ?? ''),
),
),
),
);
},
menuChildren: [
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Across'),
),
for (final word in acrossWords)
_WordSelectMenuItem(
location: acrossWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.across,
),
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Down'),
),
for (final word in downWords)
_WordSelectMenuItem(
location: downWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.down,
),
],
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
class _WordSelectMenuItem extends ConsumerWidget {
const _WordSelectMenuItem({
required this.location,
required this.word,
required this.selectedCharacter,
required this.direction,
});
final Location location;
final String word;
final CrosswordCharacter? selectedCharacter;
final Direction direction;
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(puzzleProvider.notifier);
return MenuItemButton(
onPressed:
ref.watch(
puzzleProvider.select(
(puzzle) => puzzle.canSelectWord(
location: location,
word: word,
direction: direction,
),
),
)
? () => notifier.selectWord(
location: location,
word: word,
direction: direction,
)
: null,
leadingIcon:
switch (direction) {
Direction.across => selectedCharacter?.acrossWord?.word == word,
Direction.down => selectedCharacter?.downWord?.word == word,
}
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(word),
);
}
}
이 위젯은 이전에 다른 곳에서 사용된 적이 있는 조각으로 구성되었지만 이전 위젯보다 조금 더 강렬합니다. 이제 채워진 각 셀을 클릭하면 사용자가 선택할 수 있는 단어가 나열된 컨텍스트 메뉴가 표시됩니다. 단어가 선택된 경우 충돌하는 단어는 선택할 수 없습니다. 단어를 선택 해제하려면 사용자가 해당 단어의 메뉴 항목을 탭합니다.
플레이어가 단어를 선택하여 전체 크로스워드를 채울 수 있다고 가정하면 '승리하셨습니다!' 화면이 필요합니다.
puzzle_completed_widget.dart
파일을 만들고 다음 콘텐츠를 추가합니다.
lib/widgets/puzzle_completed_widget.dart
import 'package:flutter/material.dart';
class PuzzleCompletedWidget extends StatelessWidget {
const PuzzleCompletedWidget({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Text(
'Puzzle Completed!',
style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
),
);
}
}
이 내용을 바탕으로 더 흥미로운 이야기를 만들어 주실 수 있을 거예요. 애니메이션 도구에 관해 자세히 알아보려면 Flutter에서 차세대 UI 빌드 Codelab을 참고하세요.
- 다음과 같이
lib/main.dart
파일을 수정합니다.
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_puzzle_app.dart'; // Update this line
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Puzzle', // Update this line
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordPuzzleApp(), // Update this line
),
),
);
}
이 앱을 실행하면 크로스워드 생성기가 퍼즐을 생성할 때 애니메이션이 표시됩니다. 그러면 풀어야 할 빈 퍼즐이 표시됩니다. 문제를 해결하면 다음과 같은 화면이 표시됩니다.
10. 축하합니다
축하합니다. Flutter로 퍼즐 게임을 빌드하는 데 성공했습니다.
크로스워드 생성기를 만들어 퍼즐 게임이 되었습니다. 분리된 풀에서 백그라운드 계산을 실행하는 방법을 익혔습니다. 백트래킹 알고리즘의 구현을 용이하게 하기 위해 불변 데이터 구조를 사용했습니다. 또한 TableView
와 함께 유익한 시간을 보냈으므로 다음에 표 형식 데이터를 표시해야 할 때 유용할 것입니다.