概要
Flutterで箱入り娘を作成し、iOS と Android のアプリとして販売する
このシリーズでは、作り始めから販売までを順番に書いていく
第4回では、次のことをする
遊んだ記録を端末に残す
一覧の行に、クリアの印と自分の手数を出す
途中でやめた盤面から続けられるようにする
実行環境
Flutter
$ flutter --version
Flutter 3.47.9 • channel stable • https://github.com/flutter/flutter.git
Framework • revision c197ca5199 (4 days ago) • 2026-10-29 10:10:20 -0400
Engine • hash 9e025265bef30b838c0c1b68cad19decf97048f2 (revision 89c39edfdf) (6 days ago) • 2026-10-27 19:51:20.000Z
Tools • Dart 3.13.7 • DevTools 2.60.0
手順
パッケージを入れる
flutter pub add shared_preferences
flutter pub add dev:shared_preferences_platform_interface
2つ目はテストだけで使う(後述)。
ファイルの構成
lib/
main.dart … 起動のときに記録を読み込む
model/
progress.dart … 遊んだ記録
screen/
stage_list_screen.dart … ステージの一覧(前回から)
game_screen.dart … 遊ぶ画面(前回から)
test/
memory_store.dart … テスト用の、メモリの中だけの置き場
model/progress_test.dart
screen/progress_screens_test.dart
以下のコードは主要な部分の抜粋(... は省略)。
何を残すか
残すのは、次の3つ。
| 残すもの | 使うところ |
|---|---|
| 配置ごとの、クリアしたうちでいちばん少ない手数 | 一覧の印と「自分 N 手」 |
| 配置ごとの、途中の盤面と手数 | 途中から続ける |
| 最後に開いた配置 | 一覧のいちばん上の「続きから」 |
一覧の印は4通りにする。
| 印 | いつ |
|---|---|
| 王冠 | 最短手数でクリアした |
| ✓ | クリアした(最短より多い手数) |
| 途中 | クリアしたことがなく、途中の盤面がある |
| なし | まだ遊んでいない |
- 行を押すと、途中の盤面があればそこから続ける。遊ぶ画面の「はじめから」で、途中の盤面を捨てる
- 一度クリアした配置は、また途中でやめても、クリアの印のままにする(クリアしたことは消えないため)
- 「続きから」は、最後に開いた配置に途中の盤面があるときだけ出す。クリアしたら消える
保存のパッケージを選ぶ
shared_preferences を使う。
Flutter のチームが作っていて、iOS・Android のほか Web でも動く。
説明には、次のように書いてある。
there is no guarantee that writes will be persisted to disk after returning, so this plugin must not be used for storing critical data.
書いたものが必ず端末に残るとは限らないので、大事なデータには使わない。
残すのは、消えても遊び直せば戻るものだけにする(今回の3つはどれもそう)。
使い方は3通りある。
古い SharedPreferences は、いずれ使わなくなる予定なので、新しく書くコードでは SharedPreferencesWithCache か SharedPreferencesAsync を使う。
SharedPreferencesWithCache は、起動のときに一度だけ端末から読み込み、メモリに写しを持つ。
そのあとの読み出しは await なしで写しから読めるので、画面を作る build の中でそのまま使える。
記録のクラス
画面からは shared_preferences を直接触らず、Progress のクラスを通す。
- 「どの文字の名前で、どんな形の値を置くか」を1か所にまとめる
- テストでは、置き場をメモリの中のものに差しかえられる(後述)
/// 遊んだ記録。端末に残す。
class Progress {
Progress._(this._prefs);
/// 端末から記録を読み込む。
static Future<Progress> load() async {
...
await SharedPreferencesWithCache.create(
cacheOptions: SharedPreferencesWithCacheOptions(allowList: _keys),
),
...
}
final SharedPreferencesWithCache? _prefs;
// 配置は名前で覚える(並び順を変えても記録がずれないように)
static String _bestKey(NamedLayout layout) => 'best:${layout.name}';
static String _unfinishedKey(NamedLayout layout) =>
'unfinished:${layout.name}';
static const _lastKey = 'last';
static final _keys = {
_lastKey,
for (final layout in namedLayouts) ...[
_bestKey(layout),
_unfinishedKey(layout),
],
};
...
}
- 配置は、リストの番号ではなく名前で覚える。あとで配置を足したり並べ直したりしても、記録が別の配置にずれない
-
allowListには、使う名前を全部渡す。ほかのパッケージが同じ置き場に書いた値を、写しに読み込まない
読み出し
/// クリアしたうちで、いちばん少ない手数。クリアしていなければ null。
int? best(NamedLayout layout) {
final value = _prefs?.get(_bestKey(layout));
// 最短より少ない手数は、ありえないので読めない値とみなす
return value is int && value >= layout.moves ? value : null;
}
置き場の値は、アプリの外から書きかえられることもある。
型と中身を確かめて、おかしな値は「記録なし」として扱う。
途中の盤面を文字にする
途中の盤面は、1つの文字にして置く。
手数;駒,駒,… (例:3;s00,s10,d20,h01,…)
駒は、種類の頭文字(d = 娘、v = 縦長、h = 横長、s = 小さい駒)と、左上の x と y。
static String _encode(Board board, int moves) =>
'$moves;${board.pieces.map((p) => '${_letters[p.type]}${p.x}${p.y}').join(',')}';
/// 読めない値・[layout] と駒の数がちがう盤面・クリアした盤面は null。
static Unfinished? _decode(NamedLayout layout, String text) {
final match = RegExp(r'^([1-9]\d{0,8});((?:[dvhs]\d\d,)*[dvhs]\d\d)$')
.firstMatch(text);
if (match == null) return null;
...
if (!_sameKinds(pieces, layout.board.pieces)) return null;
final Board board;
try {
board = Board(pieces);
} on ArgumentError {
return null;
}
if (board.isSolved) return null;
return Unfinished(board, int.parse(match[1]!));
}
- 駒の並び順は、盤面のままにする。読み戻したときに、遊ぶ画面が「何番目の駒をなぞったか」で動かせるように
- 読み戻すときは、文字の形 → 駒の種類ごとの数 → 盤からはみ出さないか・重ならないか(第1回の
Boardが確かめる)→ クリアした盤面でないか、の順に確かめる
クリアを書く
/// [moves] 手でクリアしたことを覚え、途中の盤面を忘れる。前より少ない手数のときだけ、いちばん少ない手数を書きかえる。
/// 読み出しに使う写しへは、待たずにすぐ入る(端末へ書き終わるのはあと)。
Future<void> recordClear(NamedLayout layout, int moves) {
final best = this.best(layout);
return Future.wait([
if (_prefs != null && (best == null || moves < best))
_prefs.setInt(_bestKey(layout), moves),
clearUnfinished(layout),
]);
}
SharedPreferencesWithCache の setInt などは、呼んだときにメモリの写しを書きかえ、端末へはそのあと書く。
なので、書いた直後に読み出すと、もう新しい値になっている。
画面につなぐ
起動のときに読み込む
Future<void> main() async {
// 記録を読み込むには、アプリの画面より先に端末とのつなぎを用意しておく
WidgetsFlutterBinding.ensureInitialized();
runApp(KlotskiApp(progress: await Progress.load()));
}
runApp より前に端末の機能を使うときは、先に WidgetsFlutterBinding.ensureInitialized() を呼ぶ。
遊ぶ画面で、1手ごとに書く
@override
void initState() {
super.initState();
final unfinished = _progress.unfinished(_layout);
_board = unfinished?.board ?? _layout.board;
_moves = unfinished?.moves ?? 0;
// 書き終わるのは待たない(画面は先に出す)。以下の書き込みも同じ
unawaited(_progress.open(_layout));
}
void _move(int index, Piece to) {
...
unawaited(
_board.isSolved
? _progress.recordClear(_layout, _moves)
: _progress.saveUnfinished(_layout, _board, _moves),
);
}
- 書き込みは待たない。指を離すたびに書き終わるのを待つと、次の手が遅れるため
- 待たないことをはっきりさせるため、
unawaited(dart:async)で包む
一覧で、印と「続きから」を出す
final best = _progress.best(layout);
// 印:クリアしたことがあればクリアの印(最短なら王冠)。無ければ、途中の盤面があるとき途中の印
final (IconData, String)? mark = best == layout.moves
? (Icons.emoji_events, '最短でクリア')
: best != null
? (Icons.check_circle, 'クリア')
: _progress.unfinished(layout) != null
? (Icons.timelapse, '途中')
: null;
...
subtitle: Row(
children: [
if (mark != null) ...[
Icon(
mark.$1,
size: 16,
color: colors.primary,
semanticLabel: mark.$2,
),
const SizedBox(width: 4),
],
Flexible(
child: Text(
best == null
? '最短 ${layout.moves} 手'
: '最短 ${layout.moves} 手 自分 $best 手',
...
- 印と読み上げの言葉を、レコード
(IconData, String)でまとめて持つ - 印は形だけで分かるように、王冠・✓・時計の形を変える(色だけで分けない)
-
semanticLabelで、画面の読み上げ機能でも「最短でクリア」などと読ませる
遊ぶ画面から戻ったら、一覧を新しくする
一覧の画面は StatefulWidget にして、遊ぶ画面を閉じたら setState で作り直す。
/// 遊ぶ画面を開き、戻ってきたら記録の印を出し直す。
Future<void> _open(NamedLayout layout) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => GameScreen(layout: layout, progress: _progress),
),
);
if (mounted) setState(() {});
}
-
Navigator.pushのFutureは、その画面を閉じたときに終わる。awaitのあとが「戻ってきたとき」になる -
awaitのあいだに一覧の画面が消えていることもあるので、mountedを確かめてからsetStateする
記録が壊れたとき
端末によっては、記録のファイルが壊れていると、SharedPreferencesWithCache.create がエラーになる(壊れ方によって FormatException も TypeError も出る)。
main で読み込みを待ってから runApp しているので、そのままだと画面が出ないまま止まる。
読み込みでエラーが出たら、記録なしで始める。
static Future<Progress> load() async {
try {
return Progress._(
await SharedPreferencesWithCache.create(
cacheOptions: SharedPreferencesWithCacheOptions(allowList: _keys),
),
);
} catch (e, stack) {
// 壊れ方によって、FormatException も TypeError(Exception でない)も出るので、全部受け止める
debugPrint('記録を読み込めなかったので、記録なしで始める: $e\n$stack');
return Progress._(null);
}
}
-
on Exceptionだと、ExceptionでないTypeErrorを受け止められない - 置き場が
nullのときは、読み出しは全部「記録なし」、書き込みは何もしない(_prefs?.get(...)など) - 遊べないよりは、記録なしで遊べる方がよい。壊れた記録は、どのみち読めない
テスト
置き場をメモリの中のものにする
テストでは、端末の置き場を InMemorySharedPreferencesAsync に差しかえる。
shared_preferences 自身のテストでも使われているもので、shared_preferences_platform_interface に入っている。
/// 端末の置き場を、メモリの中だけのものにする([data] は始めから入っている値)。
void useMemoryStore([Map<String, Object> data = const {}]) {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({...data});
}
/// 端末の置き場を、読み込むと [error] を投げるもの(記録のファイルが壊れたとき)にする。
void useBrokenStore([Object error = const FormatException('壊れた記録')]) {
SharedPreferencesAsyncPlatform.instance = _BrokenStore(error);
}
base class _BrokenStore extends InMemorySharedPreferencesAsync {
...
@override
Future<Map<String, Object>> getPreferences(
GetPreferencesParameters parameters,
SharedPreferencesOptions options,
) => throw error;
}
withData で始めから値を入れておけるので、「壊れた値が入っていたら」も書ける。
for (final (why, value) in [
('空', ''),
('手数が 0', good.replaceFirst('1;', '0;')),
('知らない駒', good.replaceFirst('d', 'x')),
('駒が重なる', good.replaceFirst(RegExp(r'd\d\d'), 's00')),
('盤からはみ出す', good.replaceFirst(RegExp(r'd\d\d'), 'd33')),
('クリアした盤面', solvedCode),
...
]) {
test(why, () async {
useMemoryStore({key: value});
expect((await Progress.load()).unfinished(first), isNull);
});
}
- 「読める見本」のテストも1つ置く。見本が読めないと、下の「読めない」のテストが全部、別の理由で通ってしまうため
起動のテスト
置き場を壊れたものにして、本物の main を呼ぶ。
testWidgets('記録のファイルが壊れて読み込めなくても、アプリは立ち上がって一覧を出す', (tester) async {
useBrokenStore();
await app.main();
await tester.pump();
expect(find.text('1. 前呼後擁'), findsOneWidget);
});
flutter test
アプリの実行
アプリを起動
flutter run
実行結果
次回
詰まったときに、今の盤面からの解き方を見せる。
