前に書いた「名前を信じて、日記の本文を端末のログに流していた — debugPrint はリリース版でも喋る」の続きです。あのとき置いた検査が、何を見ていて何を見ていないかの話になりました。
7月に、保存に失敗したときの例外をそのまま debugPrint へ流していた穴を塞いだ。出ていたのは綴った日記の本文と写真の絶対パスで、debugPrint は名前に反してリリースビルドでも出力される。
そのとき、囲み忘れを検査するのをやめて、呼べる場所を1つに絞るほうを選んだ。kDebugMode の中でだけ debugPrint を呼ぶ debugLog を作り、lib/ からの出力は全部そこを通す形にした。引数は文字列ではなくクロージャ(文字列を作る手順)で受けているので、リリースでは組み立てそのものが走らない。定義と、そう決めるまでの経緯は前の記事にある。
検査も置いた。lib/ を歩いて debugPrint( という文字列を探し、debug_log.dart 以外で見つかったら落ちる。次に debugPrint( と打った瞬間に赤になる。
自分では、これで閉じたと思っていた。
同じ本文が、別の出口から出ていた
今回の観点別 AI レビューで、セキュリティ・プライバシー・UX の3つが別々の症状として同じ場所を挙げてきた。「本文が漏れる」「絶対パスが漏れる」「失敗しても画面が何も言わない」。追いかけたら同じ根だった。
綴る画面には、こういう呼び出しがいくつかある。
// 呼び出し側はボタン: onPressed: () => このメソッド(source)
// ← このメソッドは Future を返すが、誰も受け取らない=ここが「投げっぱなし」
// 「また今度」を外してから、綴り済みとして次へ進む
await ref.read(tsuzuruDecisionServiceProvider).unpassPhoto(source);
if (!mounted) return;
setState(() {
_cursor = _cursor.markWritten(currentId).advance();
_currentMoodKey = '';
});try が無い。そしてこのメソッド自体を呼んでいるのはボタンで、返ってくる Future を受け取らずに捨てている。
この形だと、unpassPhoto が投げた例外は誰にも捕まらない。捕まらない例外は「未処理の非同期エラー」として Dart のランタイムに戻る。
Flutter の既定の受け皿は、例外を toString() して出す。しかもその書き出しに使われているのが debugPrint そのもので、7月に塞いだのと同じ「リリースビルドでも喋る」1本だ。
データベース層が投げる例外は、失敗した SQL 文と渡したパラメータを文字列に含む。そこに写真の絶対パスが入っている。Windows なら C:\Users\<本名>\...\写真\<家族の呼び名>\... の形で。
読める相手は、7月の検査ファイルに自分で書いた集合とまったく同じだった。Android なら adb(PC から端末の中を覗く開発用ツール)で繋いだ相手とバグレポートの提出先、iOS は Console.app と sysdiagnose、macOS は同じユーザーで動く任意のプロセス。
検査に映らなかった理由
検査はこう書いてある。
for (final file in Directory('lib').listSync(recursive: true) ...) {
...
if (!line.contains('debugPrint(')) continue; // 自分のコードに現れる文字を探している
offenders.add('$path:${i + 1} ${line.trim()}');
}数えても永遠にゼロだ。
面白かったのは、この検査ファイルが「自分が見ていない範囲」を一度は考えていたことだ。テストが2本ある。
test('唯一の窓口である debug_log.dart が実在する', () {
// 上のテストは「debugPrint がゼロ」でも緑になる=窓口ごと消えた時に
// 素通りしてしまう。存在をここで別に固定する
//(一方向ラチェット=ゆるむ方向には戻らない仕掛け)。
expect(File(_allowedFile).existsSync(), isTrue, ...); // expect(値, 期待)の形。debug_log.dart が実在するか
});「テンプレートから作り直して窓口ごと消えたら、ゼロ件で緑になってしまう」という抜け道は、ちゃんと想像して塞いである。
塞げていなかったのは、その1つ手前の前提のほうだった。「ログに出るのは、自分が出したものだけ」。
直した形
main() に受け皿を2つ置いた。フレームワークが描画中に拾う例外と、行き場のない非同期エラーの2系統がある。
// 描画中などにフレームワークが拾った例外を、こちらへ回してもらう
FlutterError.onError = (details) => debugLog(() => '$details');
// 誰も待っていない非同期エラーの「最後の受け皿」を、既定のものから差し替える
PlatformDispatcher.instance.onError = (error, stack) {
debugLog(() => '$error\n$stack');
return true; // 「ここで処理した」の意味。既定のコンソール出力へは流さない
};そのうえで、投げっぱなしになっていた呼び出しを catch 付きへ寄せた。ここは新しい形を作らずに済んだ。同じファイルの気分ボタンだけはすでに正しく書けていたので、それに揃えた。
// 既存: 失敗したら楽観更新(先に画面を進めてしまう作り)を巻き戻す。画面には何も出さない
} catch (_) {
if (mounted) { // await を挟んだので、画面が消えていたら setState しない
setState(() {
_cursor = prevCursor; // prevCursor = 進める前に控えておいた位置
_currentMoodKey = prevMoodKey;
});
}
}同じ画面の中で、片方だけ作法が守られている状態だった。片方が抜けたこと自体は、注意力の問題ではないと思う。onPressed: () => 保存する() と書いても、Future を捨てたことについて誰も何も言わない。捨て忘れを警告する lint は Dart 側に用意されているが、このリポでは有効にしていない。だから書けているかどうかが、書いた人の記憶の中にしか残らない。7月に debugPrint で見たのと同じ形が、もう一段手前で起きていた。
検査のほうは「main.dart に受け皿2つが置いてあること」を固定した。消したら赤になる形にして、debugPrint を数えるテストの隣に並べた。
ここで1つ、直したら起きることを見込んでおく。受け皿を置くと、いままで黙って消えていた例外が開発中に出るようになる。うるさく感じるはずだが、それは目的そのものだ。潰す前に「これは本当に想定内の例外か」を1回見る。
学び
「窓口を1つに絞って検査で守る」は正しかったが、守れるのは窓口を通るものだけだった。 規律を人の記憶ではなく置き場所に移す、という考え方自体は今回も効いている。効かなかったのは範囲の見積もりで、「ログに出るもの」の集合を「自分が debugPrint と打ったもの」と等しいと置いていた。
だから今回、検査を足すときにもう1行 doc を書いた。その検査が見ていない範囲を、検査ファイル自身に書く。 「lib/ の文字列しか見ない=フレームワークが吐く経路は映らない」と書いてあれば、緑を見た後日の自分がそこで止まれる。
この記事は2026年8月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。