ข้ามไปยังเนื้อหา

Production Concerns

ใน production คุณต้องการรู้เรื่อง crash ก่อนที่ผู้ใช้จะบ่น นี่คือหนึ่งในจุดที่คำแนะนำอย่างเป็นทางการของ Flutter เปลี่ยนไปจริง ๆ และการเข้าใจคำแนะนำปัจจุบันให้ถูก (ไม่ใช่ตัวที่เคยเป็นเมื่อสองสามปีก่อน) สำคัญ

lib/error_demo.dart
import 'package:flutter/material.dart';
class ThrowsInBuild extends StatelessWidget {
const ThrowsInBuild({super.key});
@override
Widget build(BuildContext context) {
throw StateError('build error');
}
}
test/error_demo_test.dart
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/tooling-and-production__production-concerns/error_demo.dart';
void main() {
testWidgets(
'FlutterError.onError catches a build() error; PlatformDispatcher.onError does not',
(tester) async {
FlutterErrorDetails? caughtByFlutterError;
Object? caughtByPlatformDispatcher;
final originalFlutterOnError = FlutterError.onError;
final originalPlatformOnError = PlatformDispatcher.instance.onError;
FlutterError.onError = (details) => caughtByFlutterError = details;
PlatformDispatcher.instance.onError = (error, stack) {
caughtByPlatformDispatcher = error;
return true;
};
await tester.pumpWidget(const MaterialApp(home: ThrowsInBuild()));
FlutterError.onError = originalFlutterOnError;
PlatformDispatcher.instance.onError = originalPlatformOnError;
expect(caughtByFlutterError?.exception, isA<StateError>());
expect(caughtByPlatformDispatcher, isNull); // genuinely NOT forwarded here
expect(tester.takeException(), isNull); // FlutterError.onError handled it, nothing escaped
});
}
00:00 +0: FlutterError.onError catches a build() error; PlatformDispatcher.onError does not
00:00 +1: All tests passed!

พิสูจน์แล้วตรง ๆ: error จาก build/layout/paint ไปที่ FlutterError.onError และอยู่ที่นั่น — PlatformDispatcher.instance.onError ไม่เห็นมันเลย และ tester.takeException() คืน null เพราะ FlutterError.onError ดูดมันไว้เต็มที่ (ความต่างแบบ “ถูกดูดไว้ vs หลุดออกมา” เดียวกับที่พิสูจน์แล้วสำหรับ FutureBuilder ในบทเรียน futures-streams-in-ui)

PlatformDispatcher.instance.onError: คำตอบปัจจุบันสำหรับทุกอย่างที่เหลือ

หัวข้อที่มีชื่อว่า “PlatformDispatcher.instance.onError: คำตอบปัจจุบันสำหรับทุกอย่างที่เหลือ”

หลายปีที่ผ่านมา คำแนะนำมาตรฐาน (และบทเรียนนี้เวอร์ชันก่อนหน้าเอง) คือการจับคู่ FlutterError.onError กับ runZonedGuarded ที่เขียนเองรอบ runApp() เพื่อจับ async error คำแนะนำนั้นล้าสมัยแล้ว ตรง ๆ จาก release notes 3.3 ของ Flutter เอง อ้างอิงตรง ๆ: “In previous releases, you had to manually configure a custom Zone in order to catch all of an application’s exceptions and errors. However, custom Zones were detrimental to a number of optimizations in Dart’s core libraries, which slowed down application start-up time. In this release, instead of using a custom Zone, you should catch all errors and exceptions by setting the PlatformDispatcher.onError callback.” คู่มือ error-handling ของ docs.flutter.dev ยืนยันช่องว่างเจาะจงที่ PlatformDispatcher.instance.onError ปิด: “Consider an onPressed callback that invokes an asynchronous function… If invokeMethod throws an error, it won’t be forwarded to FlutterError.onError. Instead, it’s forwarded to the PlatformDispatcher.”

void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
reportToCrashService(details.exception, details.stack);
};
PlatformDispatcher.instance.onError = (error, stack) {
reportToCrashService(error, stack); // the current, official replacement for runZonedGuarded
return true; // tells the engine the error was handled
};
runApp(const MyApp());
}

ช่องว่างที่ตรงไปตรงมาและทดสอบแล้วใน environment ของคอร์สนี้เอง: การพิสูจน์ว่า PlatformDispatcher.instance.onError ทำงานจริงสำหรับ async error ที่ไม่ถูกจับจริง ๆ (เช่น Future ที่ไม่ await แล้ว throw) ทำซ้ำไม่ได้ภายใต้ flutter test — ความพยายามรันจริงที่นี่แสดงว่า test zone ของ package:test เอง (และภายใต้ testWidgets คือ binding ที่ใช้ FakeAsync) ดัก async error ที่ไม่ถูกจับไว้เพื่อรายงาน test failure ของตัวเองก่อนที่มันจะไปถึง PlatformDispatcher.instance.onError ที่กำหนดเองเลยด้วยซ้ำ ทั้งใน test() ธรรมดาและ testWidgets() นี่คือข้อจำกัดของ tooling ใน test environment จริง ๆ ไม่ใช่ข้อกล่าวอ้างเกี่ยวกับพฤติกรรมของแอปจริง — docs ทางการที่อ้างอิงด้านบนชัดเจนว่า PlatformDispatcher.instance.onError คือสิ่งที่แอปจริงที่กำลังรันอยู่ (นอก test harness ใด ๆ) ได้รับสำหรับ error class นี้ runZonedGuarded เองยังคงทำงานตามที่พิสูจน์แล้วในบทเรียน Async in Dartเป๊ะ — มันไม่ได้ deprecated หรือพัง — มันแค่ไม่ใช่กลไกที่แนะนำสำหรับงานนี้เจาะจงอีกต่อไป ด้วยเหตุผลด้าน startup performance ที่อ้างอิงไว้ด้านบน

ต่อ handler ทั้งสองด้านบนเข้ากับ service — Sentry, Firebase Crashlytics — เพื่อให้ crash จากอุปกรณ์จริงกลับมาพร้อม stack trace, ข้อมูลอุปกรณ์ และ breadcrumb:

// Code shape (Sentry), cited from sentry.io's Flutter docs — not
// independently run here, no external network/service in this environment.
PlatformDispatcher.instance.onError = (error, stack) {
Sentry.captureException(error, stackTrace: stack);
return true;
};

สองกฎที่สำคัญไม่ว่าจะใช้ provider ไหน:

  • Upload symbol/debug info สำหรับ release build — AOT ตัด symbol ออก และถ้าใช้ --obfuscate identifier ก็ถูกเปลี่ยนชื่อด้วย; ถ้าไม่มีไฟล์ symbol ที่ตรงกันเป๊ะ stack trace จะอ่านไม่ออก (พิสูจน์เต็มรูปแบบ พร้อม --split-debug-info build จริงและไฟล์ symbol จริง ในบทเรียน build-and-release)
  • Log อย่างตั้งใจ และรู้ว่าอะไรรอดจริง ๆ ใน release — บทเรียนนี้เวอร์ชันก่อนหน้าอ้างว่า print “stripped/ignored in release” การอ่าน source ของ SDK ที่ติดตั้งจริงตรง ๆ (~/flutter/packages/flutter/lib/src/foundation/print.dart) แก้ข้อนั้น: ทั้ง print และ debugPrint ไม่ถูกตัดใน release เลย doc comment ของ debugPrint เองระบุตรง ๆ: “The debugPrint function logs to console even in release mode. As per convention, calls to debugPrint should be within a debug mode check or an assert.” เหตุผลจริงที่ควรใช้ debugPrint แทน print เปล่า ๆ ไม่ใช่เรื่องการตัดทิ้งใน release — แต่คือการปิดเสียง (throttling): implementation default ของ debugPrint คือ debugPrintThrottled เจาะจงเพราะ ตาม doc comment ของมันเอง การพิมพ์เร็ว ๆ โดยไม่ปิดเสียงอาจทำให้เกิดข้อมูลหายบน Android (buffer log ของ platform ทิ้งบรรทัดภายใต้ burst ปริมาณสูง) — debugPrintSynchronously มี signature เดียวกันแต่ไม่ปิดเสียง สำหรับตอนที่ลำดับการสลับกันสำคัญกว่าการหลีกเลี่ยงข้อมูลหาย ฟังก์ชัน log() ของ dart:developer คือตัวเลือกจริงอีกตัวที่ต่างจากทั้งสอง: มันเขียน log record แบบมีโครงสร้าง (มี name, level, error, stackTrace) ที่มุมมอง logging ของ DevTools เองกรองได้ แทนที่จะเป็น string แบนราบ

assert(...) (และ block ที่ถูก gate ด้วย kDebugMode ใด ๆ เพราะ kDebugMode เองถูกนิยามด้วยกลไกเดียวกัน) ไม่ทำงานใน release build — และเหตุผลจริงพิสูจน์ได้ตรง ๆ: การรัน assert ถูก gate ด้วย flag --enable-asserts ของ Dart VM/compiler ซึ่ง tooling ของ Flutter เองส่งให้อัตโนมัติสำหรับการรัน debug (flutter run, flutter test) และไม่เคยส่งให้ flutter build ... --release environment ของคอร์สนี้รัน output ของ flutter build web --release ที่ compile แล้วไม่ได้ (ไม่มี browser) ดังนั้นกลไกเดียวกันเป๊ะจึงพิสูจน์ตรง ๆ ด้วย dart compile exe ธรรมดา — backend AOT ของ Dart ตัวเดียวกับที่ flutter build ใช้ข้างใต้ ควบคุมด้วย flag ตัวเดียวกันเป๊ะ:

// assert_check.dart -- plain Dart, run in scratch (not the shared probe)
void main() {
var assertsRan = false;
assert(() {
assertsRan = true;
return true;
}());
print(assertsRan ? 'ASSERTS RAN (enabled)' : 'ASSERTS DID NOT RUN (stripped)');
}
$ dart run assert_check.dart # JIT, no flag
ASSERTS DID NOT RUN (stripped)
$ dart run --enable-asserts assert_check.dart # JIT, matches `flutter run`/`flutter test` debug mode
ASSERTS RAN (enabled)
$ dart compile exe assert_check.dart -o release_build # AOT, no flag -- matches `flutter build ... --release`
$ ./release_build
ASSERTS DID NOT RUN (stripped)
$ dart compile exe --enable-asserts assert_check.dart -o release_build_forced # AOT, flag forced on
$ ./release_build_forced
ASSERTS RAN (enabled)

การรันครั้งที่สี่คือตัวสำคัญ: binary ที่ compile แบบ AOT รัน assert ได้จริง ๆ ถ้า --enable-asserts ถูกส่งให้ — พิสูจน์ว่า assert ถูก gate ด้วย flag ตัวเดียวนี้ ไม่ใช่อะไรที่เป็นไปไม่ได้เชิงโครงสร้างใน AOT/release code flutter build ... --release แค่ไม่เคยส่ง flag นั้นเท่านั้นเอง ซึ่งคือกลไกทั้งหมดที่พิสูจน์ได้เบื้องหลัง “assert ไม่ทำงานใน release” ไม่ใช่ pass การตัดทิ้งแยกต่างหากพิเศษ

lib/a11y_demo.dart
import 'package:flutter/material.dart';
class UnlabeledIconButton extends StatelessWidget {
const UnlabeledIconButton({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {},
// no tooltip / no Semantics label
),
);
}
}
class LabeledIconButton extends StatelessWidget {
const LabeledIconButton({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Semantics(
label: 'Delete item',
child: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {},
tooltip: 'Delete item',
),
),
);
}
}
test/a11y_demo_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/tooling-and-production__production-concerns/a11y_demo.dart';
void main() {
testWidgets('a labeled icon button meets the labeled-tap-target guideline',
(tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(const MaterialApp(home: LabeledIconButton()));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
handle.dispose();
});
testWidgets('an unlabeled icon button FAILS the labeled-tap-target guideline',
(tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(const MaterialApp(home: UnlabeledIconButton()));
await expectLater(
tester,
doesNotMeetGuideline(labeledTapTargetGuideline),
);
handle.dispose();
});
}
00:00 +0: a labeled icon button meets the labeled-tap-target guideline
00:00 +1: an unlabeled icon button FAILS the labeled-tap-target guideline
00:00 +2: All tests passed!

ข้อความ failure จริงที่จับได้บนปุ่มที่ไม่มี label (จากกรณีลบ รันตรงกับ meetsGuideline ก่อนที่จะพลิกเป็นรูปแบบ doesNotMeetGuideline ที่ผ่านซึ่งใช้ใน fence ด้านบน):

Expected: Tappable widgets should have a semantic label
Actual: <Instance of 'WidgetTester'>
Which: SemanticsNode#4(Rect.fromLTRB(0.0, 0.0, 48.0, 48.0), actions: [focus, tap], flags: [isButton, hasEnabledState, isEnabled, isFocusable]): expected tappable node to have semantic label, but none was found.

meetsGuideline/doesNotMeetGuideline รับ AccessibilityGuideline ที่มีมาให้ของ Flutter ตัวใดก็ได้ (labeledTapTargetGuideline, textContrastGuideline, androidTapTargetGuideline, iOSTapTargetGuideline) แล้วรันการเช็คเชิงกลไกจริงกับ semantics tree ที่ render แล้ว — tester.ensureSemantics() ต้องเรียกก่อน (มันสร้าง semantics tree ที่ test binding ข้ามไปเพื่อ performance ปกติ เพราะ widget test ส่วนใหญ่ไม่ต้องการมัน) และต้อง .dispose() หลังจากนั้น

MaterialApp(showPerformanceOverlay: true) วาดกราฟ real-time สองอันทับบนแอปที่กำลังรันตรง ๆ: เวลาต่อเฟรมของ UI thread (build/layout/paint) และเวลาต่อเฟรมของ raster thread แต่ละแท่งแสดงสีแดงเมื่อเฟรมเกินงบ มันคือวิธีที่เร็วที่สุดในการเห็นจังหวะสะดุดขณะโต้ตอบกับแอปสด ๆ เสริมกับ frame timeline ของ DevTools เอง (ครอบคลุมพร้อม workflow จริงในบทเรียน rebuilds-and-performance) environment ของคอร์สนี้ไม่มีอุปกรณ์/emulator ให้ render และ screenshot overlay จริง ๆ ดังนั้นนี่คือการอ้างอิงจากคู่มือ performance overlay ของ docs.flutter.dev แทนที่จะสาธิต

กลไก --dart-define/--dart-define-from-file เดียวกับที่พิสูจน์แล้วในบทเรียน build-and-release (test จริงที่นั่นยืนยันว่าค่าที่กำหนดถูกอ่านเป็น compile-time constant จริง ๆ) คือวิธีมาตรฐานในการ inject config ต่อ environment (API base URL, feature flag) โดยไม่ต้อง commit secret ลง source หรือ branch โค้ดต่อ environment — รวมกับ flavor สำหรับอะไรก็ตามที่ต้องการ identity ของแอปที่ต่างกันจริง ๆ (bundle ID, ไอคอน) ไม่ใช่แค่ค่า config ที่ต่างกัน

การจัดการ error สำหรับ production ที่ AI generate มักหยิบ pattern runZonedGuarded แบบเก่า (มันคือสิ่งที่ training data และ tutorial ส่วนใหญ่ยังแสดงอยู่) และพลาดความต่างที่บทนี้พิสูจน์ว่าแต่ละ handler ได้รับอะไรจริง ๆ

// AI-generated: "set up crash reporting"
void main() {
runZonedGuarded(() {
runApp(const MyApp());
}, (error, stack) {
reportToCrashService(error, stack);
});
}

ก่อนจะเชื่อ setup การจัดการ error ที่ generate มาแบบนี้ ให้เช็ค:

  • ไม่มี FlutterError.onError เลย — พิสูจน์แล้วด้านบนว่า zone ของ runZonedGuarded ไม่ได้รับ error จาก build/layout/paint ของ framework; สิ่งเหล่านั้นต้องการ handler ของตัวเอง หรือไม่งั้นก็แสดงแค่หน้าจอ error สีแดง default เท่านั้น
  • runZonedGuarded ที่ใช้เป็นกลไกเดียวสำหรับ async error โดยไม่มี PlatformDispatcher.instance.onError — พิสูจน์แล้วด้านบนจาก docs ทางการว่านี่คือ pattern ที่ล้าสมัย; ช่องว่างเจาะจงที่มันทิ้งไว้ (async error จาก plugin/MethodChannel ที่ไม่ถูกส่งไปยัง FlutterError.onError) คือสิ่งที่ PlatformDispatcher.instance.onError มีเอกสารระบุว่าปิดพอดี
  • statement print/debugPrint ที่ทิ้งไว้เพื่อ “production logging” โดยสมมติว่ามันหายไปใน release — พิสูจน์แล้วด้านบนจาก source ว่าไม่จริง อะไรที่ sensitive ที่ log แบบนี้ไปถึง device log ของผู้ใช้จริง
  • assert ที่ใช้ guard อะไรบางอย่างที่ต้องเกิดขึ้นจริงใน production (validation จริง ไม่ใช่แค่ sanity check สำหรับ debug) — พิสูจน์แล้วด้านบนว่า assert ถูก gate ด้วย compile flag ที่ release build ของ Flutter ไม่เคยส่ง โค้ดข้างในมันไม่ทำงานเลยสำหรับผู้ใช้จริง

🐛 หา bug ในโค้ดนี้

void main() {
runZonedGuarded(() {
FlutterError.onError = (details) {
reportToCrashService(details.exception, details.stack);
};
runApp(const MyApp());
}, (error, stack) {
reportToCrashService(error, stack);
});
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key, required this.userId});
final String userId;
@override
Widget build(BuildContext context) {
fetchProfile(userId); // fire-and-forget, not awaited
return const CircularProgressIndicator();
}
}
เฉลย

มีปัญหาสองอย่างที่ซ้อนกัน ทั้งคู่พิสูจน์แล้วด้านบน อย่างแรก setup นี้ใช้ pattern แบบเก่าที่พึ่ง runZonedGuarded อย่างเดียวสำหรับ async error แทนที่จะเป็น PlatformDispatcher.instance.onError ที่มีเอกสารปัจจุบัน — ช่องว่างจริงที่ docs ทางการระบุตรง ๆ: async error จาก plugin call ถูก “forwarded to the PlatformDispatcher” ไม่ถูกจับโดย zone นี้หรือโดย FlutterError.onError อย่างที่สอง fetchProfile(userId) ข้างใน build คือการเรียกแบบ fire-and-forget (บทเรียน Async in Dart พิสูจน์ว่า pattern นี้เป๊ะ ๆ ทำให้ error ไม่ถูกจับโดย try/catch ใด ๆ ที่ call site จริง ๆ) — ดังนั้นถ้ามัน throw setup ทั้งหมดนี้พนันทุกอย่างไว้กับ handler runZonedGuarded ตัวเดียวที่ถูกแทนที่ไปแล้วให้จับมัน โดยไม่มี PlatformDispatcher.instance.onError เป็น backstop สำหรับ async path ที่ zone ของมันเองอาจไปไม่ถึง

ทุกกลไกในบทนี้ตอบคำถามเดียวกับเนื้อหา error-handling ก่อนหน้าในคอร์ส ที่ชั้น production: เมื่อ error เกิดขึ้น มีอะไรข้างล่างรู้เรื่องไหม และความจริงรอดไปถึงที่นั่นมากแค่ไหน? FlutterError.onError เทียบกับ PlatformDispatcher.instance.onError คือ “error มาจากชั้นไหน” (build ของ framework เทียบกับทุกอย่างที่เหลือ) — พิสูจน์แล้วด้านบนว่าเป็นการแบ่งที่แยกจากกันจริง ไม่ใช่ coverage ที่ซ้อนกัน assert/debugPrint ทั้งคู่เกี่ยวกับ “อะไรรอดจากการเดินทางจาก editor ของคุณไปถึงอุปกรณ์ของผู้ใช้จริง” — พิสูจน์แล้วด้านบนว่าตัวหนึ่งถูกตัดทิ้งเต็มที่ผ่านการไม่ส่ง flag ส่วนอีกตัวไม่ถูกตัดจริง ๆ (แค่ปิดเสียง) และ obfuscation + ไฟล์ symbol (จากบทเรียน build-and-release) คือคำถามเดียวกันที่ชั้น binary: stack trace ที่ crash service ได้รับอ่านได้ดีแค่ไหนขึ้นอยู่กับไฟล์ symbol เป๊ะที่คุณเก็บไว้คู่กับ build นั้นเท่านั้น

ตัวเลือกข้อดีต้นทุน
PlatformDispatcher.instance.onError (ปัจจุบัน)พิสูจน์แล้วด้านบนจาก docs ทางการ: จับสิ่งที่ FlutterError.onError จับไม่ได้ โดยไม่มีต้นทุน startup ของ custom Zoneพิสูจน์แล้วด้านบน: unit test ตรง ๆ ยากจริง ๆ (tooling ของ test ดักไว้ก่อน) — เชื่อ docs ยืนยันใน crash-reporting integration จริง
runZonedGuarded (legacy แต่ยังทำงานได้)พิสูจน์แล้วด้านบน (บทเรียนที่ลิงก์ไว้): ยังทำงานได้จริง ให้เหตุผลง่ายในฐานะ zone handler ธรรมดาพิสูจน์แล้วด้านบนจาก docs ทางการ: ต้นทุน startup performance เป๊ะที่เป็นเหตุผลให้ย้ายออกจากมัน
debugPrint แทน printพิสูจน์แล้วด้านบน: ปิดเสียง หลีกเลี่ยงข้อมูลหายจริงบน Androidพิสูจน์แล้วด้านบน: ทั้งคู่ไม่ถูกตัดใน release — ยังต้องการ guard kDebugMode/logger จริงสำหรับอะไรที่ sensitive
assert สำหรับ invariant ภายในต้นทุนเป็นศูนย์ใน release — พิสูจน์แล้วด้านบนว่า flag ที่รันมันไม่เคยถูกส่งโดย flutter build --releaseพิสูจน์แล้วด้านบน: พึ่งพาไม่ได้กับอะไรที่ต้อง execute จริงสำหรับผู้ใช้จริง
Semantics/meetsGuideline ใน testพิสูจน์แล้วด้านบน: จับช่องว่าง accessibility จริงที่เจาะจงเชิงกลไก ใน CI โดยไม่มีอุปกรณ์ครอบคลุมแค่สิ่งที่ guideline ที่มีมาให้เช็ค — ไม่ใช่ตัวแทนของการ test screen-reader จริง
  • “FlutterError.onError + runZonedGuarded ยังคงเป็นคู่ที่แนะนำ” พิสูจน์แล้วด้านบนว่าไม่จริง ด้วยการอ้างอิง release notes 3.3 ของ Flutter เองตรง ๆ: PlatformDispatcher.instance.onError คือตัวแทนที่มีเอกสารระบุแล้วสำหรับครึ่งของ runZonedGuarded เจาะจงด้วยเหตุผลด้าน startup performance
  • “print/debugPrint ถูกเอาออกอัตโนมัติใน release build” พิสูจน์แล้วด้านบนว่าไม่จริง จากการอ่าน source ของ SDK ที่ติดตั้งจริงตรง ๆ: doc comment ของ debugPrint เองระบุว่ามัน log “even in release mode”
  • “debugPrint มีอยู่หลัก ๆ เพื่อไม่ให้ logging ship ไป production” พิสูจน์แล้วด้านบนว่าไม่จริง: doc comment ของมันเองให้เหตุผลจริง — การปิดเสียง เพื่อหลีกเลี่ยงข้อมูลหายจริงบน log buffer ของ Android ภายใต้ output ปริมาณสูง
  • “โค้ดข้างใน assert(...) อาจยังทำงานใน release ถ้า compiler ตัดสินว่ามันถูกพอ” พิสูจน์แล้วด้านบนว่าไม่จริง ด้วยตัวอย่างโต้แย้งที่บังคับจริง: AOT binary ที่ compile ด้วย --enable-asserts รัน assert ของมันจริง ๆ — กลไกคือ flag ที่ release build ของ Flutter ไม่เคยส่ง ไม่ใช่ข้อจำกัดเชิงโครงสร้างของ AOT
พิสูจน์แล้วด้านบนด้วย test จริง การแบ่งที่แยกจากกันจริงระหว่าง FlutterError.onError กับ PlatformDispatcher.instance.onError คืออะไร?
พิสูจน์แล้วด้านบนด้วยการอ้างอิง release notes 3.3 ของ Flutter เองตรง ๆ ตัวแทนที่แนะนำปัจจุบันสำหรับการห่อ runApp() ด้วย runZonedGuarded คืออะไร?
ทำไมบทเรียนนี้ถึงพิสูจน์ไม่ได้ตรง ๆ ว่า PlatformDispatcher.instance.onError ทำงานสำหรับ async error ที่ไม่ถูกจับภายใต้ flutter test และทำอะไรแทน?
พิสูจน์แล้วด้านบนด้วยการอ่าน source ของ SDK ที่ติดตั้งจริง เกิดอะไรขึ้นกับการเรียก print()/debugPrint() ใน release build?
พิสูจน์แล้วด้านบนด้วยตัวอย่างโต้แย้งที่บังคับจริง อะไรที่ gate ว่า assert(...) จะทำงานหรือไม่จริง ๆ?
พิสูจน์แล้วด้านบนด้วย meetsGuideline ที่พังจริง IconButton ที่ไม่มี label พังอะไร และต้องมีอะไรก่อนรันการเช็คแบบนั้น?