16 KiB
16 KiB
Flutter 日志系统实现计划
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: 建立前端日志系统,debug 输出到 console,release 输出到本地文件
Architecture:
- 日志模块放在
apps/lib/core/logging/ - 提供
getLogger(module)接口 - Debug 模式:
debugPrint输出到 console - Release 模式:输出到
getApplicationDocumentsDirectory()/logs/app.log
Task 1: 日志模块骨架
Files:
- Create:
apps/lib/core/logging/logger.dart - Create:
apps/lib/core/logging/log_entry.dart - Create:
apps/lib/core/logging/log_config.dart - Create:
apps/lib/core/logging/log_service.dart - Create:
apps/lib/core/logging/log_file_handler.dart
Step 1: Create log_entry.dart
// apps/lib/core/logging/log_entry.dart
enum LogLevel { debug, info, warning, error }
class LogEntry {
final DateTime timestamp;
final LogLevel level;
final String message;
final String module;
final String? funcName;
final int? lineNo;
final String? errorType;
final String? stackTrace;
final Map<String, dynamic>? extra;
LogEntry({
required this.timestamp,
required this.level,
required this.message,
required this.module,
this.funcName,
this.lineNo,
this.errorType,
this.stackTrace,
this.extra,
});
Map<String, dynamic> toJson() => {
'timestamp': timestamp.toIso8601String(),
'level': level.name,
'message': message,
'module': module,
if (funcName != null) 'func_name': funcName,
if (lineNo != null) 'line_no': lineNo,
if (errorType != null) 'error_type': errorType,
if (stackTrace != null) 'stack_trace': stackTrace,
if (extra != null && extra!.isNotEmpty) 'extra': extra,
};
String toConsoleString() {
final ts = timestamp.toIso8601String();
final location = [
if (funcName != null) funcName,
if (lineNo != null) '@$lineNo',
].join('');
final locationStr = location.isNotEmpty ? ' [$location]' : '';
final errorStr = errorType != null ? ' [$errorType]' : '';
final extraStr = extra != null && extra!.isNotEmpty ? ' $extra' : '';
return '$ts ${level.name.toUpperCase().padRight(7)} [$module$locationStr]$errorStr $message$extraStr';
}
String toFileString() {
final sb = StringBuffer();
sb.writeln('[$timestamp] ${level.name.toUpperCase()} [$module]');
if (funcName != null || lineNo != null) {
sb.write(' at $funcName' ?? '');
if (lineNo != null) sb.write(':$lineNo');
sb.writeln();
}
sb.writeln(' $message');
if (errorType != null) {
sb.writeln(' Error: $errorType');
}
if (stackTrace != null) {
sb.writeln(' StackTrace:');
sb.writeln(stackTrace);
}
if (extra != null && extra!.isNotEmpty) {
sb.writeln(' Extra: $extra');
}
return sb.toString();
}
}
Step 2: Create log_config.dart
// apps/lib/core/logging/log_config.dart
enum LogOutput { console, file }
class LogConfig {
final LogLevel minLevel;
final LogOutput output;
final String logFileName;
final String logDir;
const LogConfig({
this.minLevel = LogLevel.debug,
this.output = LogOutput.console,
this.logFileName = 'app.log',
this.logDir = 'logs',
});
static LogConfig forDebug() => const LogConfig(
minLevel: LogLevel.debug,
output: LogOutput.console,
);
static LogConfig forRelease() => const LogConfig(
minLevel: LogLevel.warning,
output: LogOutput.file,
logFileName: 'app.log',
logDir = 'logs',
);
}
Step 3: Create log_file_handler.dart
// apps/lib/core/logging/log_file_handler.dart
import 'dart:io';
import 'package:path_provider/path_provider.dart';
class LogFileHandler {
File? _file;
IOSink? _sink;
Future<void> init(String logDir, String logFileName) async {
final dir = await getApplicationDocumentsDirectory();
final logPath = '${dir.path}/$logDir';
await Directory(logPath).create(recursive: true);
_file = File('$logPath/$logFileName');
_sink = _file!.openWrite(mode: FileMode.append);
}
void write(String content) {
_sink?.writeln(content);
}
Future<void> flush() async {
await _sink?.flush();
}
Future<void> close() async {
await _sink?.close();
_sink = null;
_file = null;
}
Future<List<String>> readAllLines() async {
if (_file == null || !await _file!.exists()) return [];
return await _file!.readAsLines();
}
String? get filePath => _file?.path;
}
Step 4: Create log_service.dart
// apps/lib/core/logging/log_service.dart
import 'package:flutter/foundation.dart';
import 'log_config.dart';
import 'log_entry.dart';
import 'log_file_handler.dart';
class LogService {
final LogConfig _config;
LogFileHandler? _fileHandler;
final _buffer = <String>[];
static const _maxBufferSize = 50;
LogService._({required LogConfig config}) : _config = config;
static Future<LogService> create({LogConfig? config}) async {
final isRelease = kReleaseMode;
final effectiveConfig = config ??
(isRelease ? LogConfig.forRelease() : LogConfig.forDebug());
final service = LogService._(config: effectiveConfig);
if (effectiveConfig.output == LogOutput.file) {
service._fileHandler = LogFileHandler();
await service._fileHandler!.init(
effectiveConfig.logDir,
effectiveConfig.logFileName,
);
}
return service;
}
String? get logFilePath => _fileHandler?.filePath;
void _log(LogEntry entry) {
if (entry.level.index < _config.minLevel.index) return;
if (_config.output == LogOutput.console) {
debugPrint(entry.toConsoleString());
if (entry.stackTrace != null) {
debugPrint(entry.stackTrace!);
}
} else {
_buffer.add(entry.toFileString());
if (_buffer.length >= _maxBufferSize) {
_flushBuffer();
}
}
}
void _flushBuffer() {
for (final line in _buffer) {
_fileHandler?.write(line);
}
_buffer.clear();
_fileHandler?.flush();
}
(String?, int?) _extractLocation(StackTrace stackTrace) {
final frames = stackTrace.toString().split('\n');
for (final frame in frames) {
if (frame.contains('.dart')) {
final match = RegExp(r'#\d+\s+(.+?)\s+\((.+?):(\d+)\)').firstMatch(frame);
if (match != null) {
return (match.group(1), int.tryParse(match.group(3) ?? ''));
}
}
}
return (null, null);
}
void debug({
required String message,
required String module,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(LogEntry(
timestamp: DateTime.now(),
level: LogLevel.debug,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
));
}
void info({
required String message,
required String module,
required Map<String, dynamic> extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(LogEntry(
timestamp: DateTime.now(),
level: LogLevel.info,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
));
}
void warning({
required String message,
required String module,
required Map<String, dynamic> extra,
StackTrace? stackTrace,
}) {
final trace = stackTrace ?? StackTrace.current;
final (funcName, lineNo) = _extractLocation(trace);
_log(LogEntry(
timestamp: DateTime.now(),
level: LogLevel.warning,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
extra: extra,
stackTrace: trace.toString(),
));
}
void error({
required String message,
required Object error,
required StackTrace stackTrace,
required String module,
Map<String, dynamic>? extra,
}) {
final (funcName, lineNo) = _extractLocation(stackTrace);
_log(LogEntry(
timestamp: DateTime.now(),
level: LogLevel.error,
message: message,
module: module,
funcName: funcName,
lineNo: lineNo,
errorType: error.runtimeType.toString(),
stackTrace: stackTrace.toString(),
extra: extra,
));
}
void flush() {
_flushBuffer();
_fileHandler?.flush();
}
Future<List<String>> readLogs() async {
return await _fileHandler?.readAllLines() ?? [];
}
}
Step 5: Create logger.dart
// apps/lib/core/logging/logger.dart
import 'log_service.dart';
LogService? _globalLogService;
void setLogService(LogService service) {
_globalLogService = service;
}
LogService _ensureService() {
return _globalLogService ?? (throw StateError('LogService not initialized'));
}
class Logger {
final String module;
final LogService _service;
Logger(this.module, this._service);
factory Logger.get(String module) {
return Logger(module, _ensureService());
}
void debug({
required String message,
Map<String, dynamic>? extra,
StackTrace? stackTrace,
}) => _service.debug(
message: message,
module: module,
extra: extra ?? {},
stackTrace: stackTrace,
);
void info({
required String message,
required Map<String, dynamic> extra,
StackTrace? stackTrace,
}) => _service.info(
message: message,
module: module,
extra: extra,
stackTrace: stackTrace,
);
void warning({
required String message,
required Map<String, dynamic> extra,
StackTrace? stackTrace,
}) => _service.warning(
message: message,
module: module,
extra: extra,
stackTrace: stackTrace,
);
void error({
required String message,
required Object error,
required StackTrace stackTrace,
Map<String, dynamic>? extra,
}) => _service.error(
message: message,
error: error,
stackTrace: stackTrace,
module: module,
extra: extra,
);
}
Logger getLogger(String module) => Logger.get(module);
Step 6: Commit
git add apps/lib/core/logging/
git commit -m "feat(logging): add logging module skeleton"
Task 2: 全局错误处理器
Files:
- Create:
apps/lib/core/logging/error_handler.dart - Modify:
apps/lib/main.dart
Step 1: Create error_handler.dart
// apps/lib/core/logging/error_handler.dart
class AppErrorHandler {
final Logger _logger = getLogger('flutter.error');
void register() {
FlutterError.onError = (details) {
_logger.error(
message: 'FlutterError: ${details.exceptionAsString()}',
error: details.exceptionAsString(),
stackTrace: details.stack ?? StackTrace.current,
extra: {'context': 'FlutterError.onError'},
);
FlutterError.presentError(details);
};
}
}
Step 2: Modify main.dart
// main.dart
import 'core/logging/logger.dart';
import 'core/logging/log_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final logService = await LogService.create();
Logger.setLogService(logService);
AppErrorHandler().register();
await configureDependencies();
await Env.init();
getLogger('app').info(
message: 'App starting...',
extra: {'version': Env.appVersion, 'platform': Env.platformName},
);
runApp(const LinksyApp());
}
Step 3: Commit
git add apps/lib/core/logging/error_handler.dart apps/lib/main.dart
git commit -m "feat(logging): add global error handler"
Task 3: 添加 path_provider 依赖
Files:
- Modify:
apps/pubspec.yaml
Step 1: Add dependency
dependencies:
path_provider: ^2.1.2
Step 2: Commit
git add apps/pubspec.yaml
git commit -m "feat(logging): add path_provider dependency"
Task 4: 迁移 ChatBloc 示例
Files:
- Modify:
apps/lib/features/chat/presentation/bloc/chat_bloc.dart
Step 1: Add logging to ChatBloc
// chat_bloc.dart
import '../../../../core/logging/logger.dart';
class ChatBloc extends Bloc<ChatBlocEvent, ChatBlocState> {
final _logger = getLogger('features.chat.bloc');
void _onChatSend(ChatBlocSend event) {
_logger.info(
message: 'Sending chat message',
extra: {
'message_length': event.message.length,
'attachments_count': event.attachments.length,
},
);
try {
// ... 业务逻辑
_logger.info(
message: 'Chat message sent successfully',
extra: {'message_id': result.id},
);
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to send chat message',
error: e,
stackTrace: stackTrace,
extra: {'message': event.message},
);
}
}
}
Step 2: Commit
git add apps/lib/features/chat/presentation/bloc/chat_bloc.dart
git commit -m "feat(logging): add logging to ChatBloc"
日志输出示例
Debug 模式 (console)
2026-04-01T10:30:00.123 DEBUG [features.chat.bloc@onChatSend:142] Chat message sent successfully {'message_id': 'abc123'}
#0 ChatBloc._onChatSend (package:social_app/features/chat/presentation/bloc/chat_bloc.dart:142)
...
2026-04-01T10:30:01.456 ERROR [features.chat.bloc@onChatSend:156] Failed to send chat message [SocketException]
#0 ChatBloc._onChatSend (package:social_app/features/chat/presentation/bloc/chat_bloc.dart:156)
...
Release 模式 (file)
[2026-04-01T10:30:00.123] DEBUG [features.chat.bloc]
at onChatSend:142
Chat message sent successfully
Extra: {'message_id': 'abc123'}
[2026-04-01T10:30:01.456] ERROR [features.chat.bloc]
at onChatSend:156
Failed to send chat message
Error: SocketException
StackTrace:
#0 ChatBloc._onChatSend (package:social_app/features/chat/presentation/bloc/chat_bloc.dart:156)
...
Extra: {'message': 'Hello'}
API 使用规范
import 'core/logging/logger.dart';
final logger = getLogger('module.path');
// debug - 可选 extra
logger.debug(message: 'Debug info', extra: {'key': 'value'});
// info - 必须有 extra
logger.info(message: 'Operation completed', extra: {'id': '123'});
// warning - 必须有 extra
logger.warning(message: 'Deprecated API called', extra: {'api': 'old'});
// error - 必须有 error 和 stackTrace
logger.error(
message: 'Operation failed',
error: e,
stackTrace: stackTrace,
);
Task 5: 单元测试
Files:
- Create:
apps/test/core/logging/log_entry_test.dart
Step 1: Write tests
void main() {
group('LogEntry', () {
test('toJson should serialize correctly', () {
final entry = LogEntry(
timestamp: DateTime(2026, 4, 1, 10, 0, 0),
level: LogLevel.error,
message: 'Test error',
module: 'test.module',
funcName: 'testFunc',
lineNo: 42,
errorType: 'TestError',
stackTrace: 'test stack',
extra: {'key': 'value'},
);
final json = entry.toJson();
expect(json['level'], 'error');
expect(json['error_type'], 'TestError');
expect(json['extra'], {'key': 'value'});
});
});
}
Step 2: Commit
git add apps/test/core/logging/
git commit -m "test(logging): add logging tests"
执行方式
两个选择:
-
Subagent-Driven (当前 session) - 我 dispatch 新的 subagent 逐个执行任务,任务间 review,快速迭代
-
Parallel Session (新 session) - 在 worktree 中打开新 session,使用 executing-plans,批量执行带 checkpoint
选择哪个?