Files
flutter-template/lib/core/crash/crash_reporter.dart
T
SkyJourney 61017f1c39 chore: 初始化脚手架 — sunny_mochi 企业级 Flutter 模板
Core 基础设施:Flavor 三包体系、Drift+SQLCipher 加密数据库
7 拦截器 Dio 网络栈、CrashReporter 崩溃日志、Sealed Failure 错误体系
5 色板主题系统、Auth 认证骨架(Clean Architecture)、Dev Panel、Mock Adapter

通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB
2026-05-14 12:51:05 +08:00

130 lines
4.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sunny_mochi/core/crash/pending_crash_report.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'crash_reporter.g.dart';
/// Riverpod provider:崩溃报告在 main.dart 通过 overrideWithValue 注入。
/// 默认值为 null(正常启动时无崩溃)。
@Riverpod(keepAlive: true)
PendingCrashReport? pendingCrash(Ref ref) => null;
/// 全局崩溃日志服务(纯静态,不依赖 Riverpod / Flutter framework)。
///
/// **生命周期**
/// 1. `preInit()` — main() 最早期调用,异步缓存路径 + 设备信息
/// 2. `consumePending()` — 读取并删除上次崩溃文件(一次性消费)
/// 3. `installHooks()` — 在 SentrySetup.init appRunner 内调用,链式挂载钩子
///
/// **崩溃写入**:仅用 `File.writeAsStringSync(flush: true)`,绝对不能 await。
abstract class CrashReporter {
static String? _crashFilePath;
static String _deviceModel = 'unknown';
static String _osVersion = 'unknown';
static String _appVersion = 'unknown';
static String _appBuild = '0';
/// 步骤 1:异步预初始化(收集路径 + 设备信息)。
static Future<void> preInit() async {
try {
final dir = await getApplicationDocumentsDirectory();
_crashFilePath = p.join(dir.path, 'crash_report.json');
final pkg = await PackageInfo.fromPlatform();
_appVersion = pkg.version;
_appBuild = pkg.buildNumber;
if (kIsWeb) return;
if (Platform.isAndroid) {
final info = await DeviceInfoPlugin().androidInfo;
_deviceModel = '${info.manufacturer} ${info.model}'.trim();
_osVersion = 'Android ${info.version.release}';
} else if (Platform.isIOS) {
final info = await DeviceInfoPlugin().iosInfo;
_deviceModel = info.utsname.machine;
_osVersion = '${info.systemName} ${info.systemVersion}';
} else {
_deviceModel = Platform.operatingSystem;
_osVersion = Platform.operatingSystemVersion;
}
} catch (_) {
// preInit 失败不能阻断启动流程
}
}
/// 步骤 2:一次性消费崩溃文件(读取 + 立即删除)。
/// 返回 null 表示本次是正常启动。
static PendingCrashReport? consumePending() {
final path = _crashFilePath;
if (path == null) return null;
final file = File(path);
if (!file.existsSync()) return null;
try {
final raw = file.readAsStringSync();
file.deleteSync();
return PendingCrashReport.fromRawJson(raw);
} catch (_) {
try {
file.deleteSync();
} catch (_) {}
return null;
}
}
/// 步骤 3:安装全局崩溃钩子(必须在 SentrySetup appRunner 内调用)。
///
/// 采用链式挂载:先读出 Sentry 已设置的 handler,我们包一层写回,
/// 确保 Sentry 依然能接到所有错误。
static void installHooks() {
final previousOnError = PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = (error, stack) {
_writeCrashSync(kind: 'dart', error: error, stackTrace: stack);
return previousOnError?.call(error, stack) ?? false;
};
}
/// 同步写入崩溃文件(crash hook 专用,不能有任何 await)。
static void _writeCrashSync({
required String kind,
required Object error,
required StackTrace stackTrace,
}) {
final path = _crashFilePath;
if (path == null) return;
try {
final stackStr = stackTrace.toString();
final truncated = stackStr.length > 4096
? '${stackStr.substring(0, 4096)}\n...(truncated)'
: stackStr;
final report = PendingCrashReport(
kind: kind,
error: error.toString(),
stackTrace: truncated,
occurredAt: DateTime.now().toUtc().toIso8601String(),
deviceModel: _deviceModel,
osVersion: _osVersion,
appVersion: _appVersion,
appBuild: _appBuild,
);
File(path).writeAsStringSync(
report.toRawJson(),
flush: true,
);
} catch (_) {
// crash hook 内绝对不能抛出异常
}
}
}