主要变更: 1. 全局修复类型转换问题,将多处`as int?`改为`(num?)?.toInt()`兼容浮点/字符串类型的数字字段 2. 移除废弃的nearby_p2p配对方式和对应的依赖包 3. 优化鸿蒙端快捷方式、引导页、路由导航的稳定性 4. 合并日志输出避免鸿蒙端IDE卡顿 5. 修复安卓端蓝牙权限冗余声明
537 lines
17 KiB
Dart
537 lines
17 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 日志工具
|
||
/// 创建时间: 2026-04-20
|
||
/// 更新时间: 2026-06-06
|
||
/// 作用: 统一日志封装,支持分级与格式化 + 日志查看器 + 日志导出 + 按模块分类控制
|
||
/// 上次更新: 优化日志输出:简洁Printer模式 + 提高高频分类默认日志级别
|
||
/// ============================================================
|
||
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||
import 'package:logger/logger.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:share_plus/share_plus.dart';
|
||
|
||
/// 全局日志器(release模式仅输出error级别,避免敏感信息泄露)
|
||
/// 简洁模式:减少调用栈、关闭颜色和emoji、缩短行宽
|
||
final appLogger = Logger(
|
||
printer: PrettyPrinter(
|
||
methodCount: 0, // 不打印调用栈
|
||
errorMethodCount: 5, // 错误时只打印5层调用栈
|
||
lineLength: 80, // 缩短行宽
|
||
// colors: true, // 颜色(减少ANSI转义序列)
|
||
printEmojis: false, // 关闭emoji(减少输出)
|
||
dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, // 只显示时间
|
||
),
|
||
level: _isDebugMode ? Level.debug : Level.error,
|
||
);
|
||
|
||
/// 是否为调试模式(编译期常量,release模式下为false)
|
||
bool get _isDebugMode {
|
||
bool? val;
|
||
assert(() {
|
||
val = true;
|
||
return true;
|
||
}());
|
||
return val ?? false;
|
||
}
|
||
|
||
/// 日志级别
|
||
enum LogLevel {
|
||
all('全部', 0),
|
||
verbose('详细', 1),
|
||
debug('调试', 2),
|
||
info('信息', 3),
|
||
warning('警告', 4),
|
||
error('错误', 5);
|
||
|
||
const LogLevel(this.label, this.value);
|
||
|
||
/// 级别中文名
|
||
final String label;
|
||
|
||
/// 级别数值(越大越严格)
|
||
final int value;
|
||
}
|
||
|
||
/// ============================================================
|
||
/// 日志分类 — 按模块控制日志级别
|
||
/// 创建时间: 2026-06-05
|
||
/// 作用: 支持按模块设置日志级别,调试时可只关注特定模块
|
||
/// ============================================================
|
||
enum LogCategory {
|
||
ui('UI', LogLevel.warning), // UI渲染、布局(减少高频UI日志)
|
||
network('网络', LogLevel.warning), // API请求、响应(减少网络日志噪音)
|
||
router('路由', LogLevel.warning), // 页面导航(减少路由日志)
|
||
storage('存储', LogLevel.warning), // 数据库、KV存储(减少存储日志)
|
||
device('设备', LogLevel.warning), // 设备信息、传感器(减少设备日志)
|
||
auth('认证', LogLevel.info), // 登录、鉴权(保留info级别,关注认证流程)
|
||
transfer('传输', LogLevel.info), // 文件传输(保留info级别)
|
||
search('搜索', LogLevel.warning), // 搜索功能(减少搜索日志)
|
||
chart('图表', LogLevel.error), // Syncfusion图表(仅输出错误)
|
||
haptic('触觉', LogLevel.error), // 震动反馈(仅输出错误,极高频调用)
|
||
provider('状态', LogLevel.warning), // Riverpod Provider(减少状态日志)
|
||
service('服务', LogLevel.info), // 后台服务(保留info级别)
|
||
sync('同步', LogLevel.info), // 数据同步(保留info级别)
|
||
offline('离线', LogLevel.info), // 离线模式(保留info级别)
|
||
onboarding('引导', LogLevel.warning), // 引导页(减少引导日志)
|
||
push('推送', LogLevel.error), // 推送通知(仅输出错误)
|
||
general('通用', LogLevel.warning); // 其他(减少通用日志)
|
||
|
||
const LogCategory(this.label, this.defaultLevel);
|
||
|
||
/// 分类中文名
|
||
final String label;
|
||
|
||
/// 默认日志级别
|
||
final LogLevel defaultLevel;
|
||
|
||
/// 自定义级别映射(可动态调整)
|
||
static final Map<LogCategory, LogLevel> _customLevels = {};
|
||
|
||
/// 获取当前级别(优先使用自定义级别,否则使用默认级别)
|
||
LogLevel get currentLevel => _customLevels[this] ?? defaultLevel;
|
||
|
||
/// 分类标签(用于日志输出,如 [UI], [网络])
|
||
String get tag => '[$label]';
|
||
|
||
/// 设置单个分类级别
|
||
static void setLevel(LogCategory category, LogLevel level) {
|
||
_customLevels[category] = level;
|
||
}
|
||
|
||
/// 重置所有分类为默认级别
|
||
static void resetLevels() {
|
||
_customLevels.clear();
|
||
}
|
||
|
||
/// 设置所有分类为同一级别
|
||
static void setAllLevels(LogLevel level) {
|
||
for (final cat in LogCategory.values) {
|
||
_customLevels[cat] = level;
|
||
}
|
||
}
|
||
|
||
/// 获取所有分类的当前级别(用于UI展示)
|
||
static Map<LogCategory, LogLevel> get allCurrentLevels {
|
||
return {for (final cat in LogCategory.values) cat: cat.currentLevel};
|
||
}
|
||
|
||
/// 获取所有自定义级别(用于持久化)
|
||
static Map<String, String> exportCustomLevels() {
|
||
return _customLevels.map((k, v) => MapEntry(k.name, v.name));
|
||
}
|
||
|
||
/// 从持久化数据恢复自定义级别
|
||
static void importCustomLevels(Map<String, String> data) {
|
||
_customLevels.clear();
|
||
for (final entry in data.entries) {
|
||
final cat = LogCategory.values
|
||
.where((c) => c.name == entry.key)
|
||
.firstOrNull;
|
||
final level = LogLevel.values
|
||
.where((l) => l.name == entry.value)
|
||
.firstOrNull;
|
||
if (cat != null && level != null) {
|
||
_customLevels[cat] = level;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 日志条目
|
||
class LogEntry {
|
||
const LogEntry({
|
||
required this.level,
|
||
required this.message,
|
||
required this.time,
|
||
required this.category,
|
||
this.error,
|
||
this.stackTrace,
|
||
});
|
||
|
||
final LogLevel level;
|
||
final String message;
|
||
final DateTime time;
|
||
|
||
/// 日志分类
|
||
final LogCategory category;
|
||
final dynamic error;
|
||
final StackTrace? stackTrace;
|
||
}
|
||
|
||
/// 日志工具 — 静态便捷方法 + 内存缓冲 + 分类级别控制
|
||
class Log {
|
||
Log._();
|
||
|
||
/// 内存缓冲区最大条目数(从500减少到200,降低内存占用)
|
||
static const _maxEntries = 200;
|
||
static final List<LogEntry> _entries = [];
|
||
|
||
/// Log.i 节流:同一消息5秒内不重复输出到控制台
|
||
static final Map<String, DateTime> _infoThrottleMap = {};
|
||
static const _infoThrottleDuration = Duration(seconds: 5);
|
||
|
||
/// 获取所有日志条目
|
||
static List<LogEntry> get entries => List.unmodifiable(_entries);
|
||
|
||
/// 日志条目数量
|
||
static int get entryCount => _entries.length;
|
||
|
||
/// 清空日志
|
||
static void clearEntries() => _entries.clear();
|
||
|
||
/// 删除单条日志
|
||
static void removeEntry(LogEntry entry) => _entries.remove(entry);
|
||
|
||
/// 添加日志条目(内部方法)
|
||
static void _addEntry(
|
||
LogLevel level,
|
||
dynamic message, [
|
||
dynamic error,
|
||
StackTrace? stackTrace,
|
||
LogCategory category = LogCategory.general,
|
||
]) {
|
||
_entries.add(
|
||
LogEntry(
|
||
level: level,
|
||
message: message?.toString() ?? '',
|
||
time: DateTime.now(),
|
||
category: category,
|
||
error: error,
|
||
stackTrace: stackTrace,
|
||
),
|
||
);
|
||
if (_entries.length > _maxEntries) {
|
||
_entries.removeRange(0, _entries.length - _maxEntries);
|
||
}
|
||
}
|
||
|
||
/// 判断分类是否应该输出日志
|
||
/// 如果日志级别 >= 分类的当前级别,则输出
|
||
static bool _shouldLog(LogLevel messageLevel, LogCategory category) {
|
||
return messageLevel.value >= category.currentLevel.value;
|
||
}
|
||
|
||
/// 格式化带分类标签的消息
|
||
static String _formatMessage(dynamic message, LogCategory category) {
|
||
if (category == LogCategory.general) {
|
||
return message?.toString() ?? '';
|
||
}
|
||
return '${category.tag} ${message?.toString() ?? ''}';
|
||
}
|
||
|
||
/// 调试日志(release模式下不输出)
|
||
/// [category] 日志分类,默认为 LogCategory.general
|
||
static void d(
|
||
dynamic message, [
|
||
dynamic error,
|
||
StackTrace? stackTrace,
|
||
LogCategory? category,
|
||
]) {
|
||
if (!_isDebugMode) return;
|
||
final cat = category ?? LogCategory.general;
|
||
if (!_shouldLog(LogLevel.debug, cat)) return;
|
||
appLogger.d(
|
||
_formatMessage(message, cat),
|
||
error: error,
|
||
stackTrace: stackTrace,
|
||
);
|
||
_addEntry(LogLevel.debug, message, error, stackTrace, cat);
|
||
}
|
||
|
||
/// 信息日志(release模式下不输出)
|
||
/// 添加节流机制:同一消息5秒内不重复输出到控制台
|
||
/// [category] 日志分类,默认为 LogCategory.general
|
||
static void i(
|
||
dynamic message, [
|
||
dynamic error,
|
||
StackTrace? stackTrace,
|
||
LogCategory? category,
|
||
]) {
|
||
if (!_isDebugMode) return;
|
||
final cat = category ?? LogCategory.general;
|
||
if (!_shouldLog(LogLevel.info, cat)) {
|
||
// 即使不输出到控制台,仍然记录到内存缓冲区
|
||
_addEntry(LogLevel.info, message, error, stackTrace, cat);
|
||
return;
|
||
}
|
||
// 节流检查:同一消息5秒内不重复输出到控制台
|
||
final msgStr = message?.toString() ?? '';
|
||
final now = DateTime.now();
|
||
final lastTime = _infoThrottleMap[msgStr];
|
||
if (lastTime == null || now.difference(lastTime) > _infoThrottleDuration) {
|
||
appLogger.i(
|
||
_formatMessage(message, cat),
|
||
error: error,
|
||
stackTrace: stackTrace,
|
||
);
|
||
_infoThrottleMap[msgStr] = now;
|
||
// 清理过期的节流记录,避免内存泄漏
|
||
_infoThrottleMap.removeWhere(
|
||
(_, time) => now.difference(time) > _infoThrottleDuration,
|
||
);
|
||
}
|
||
_addEntry(LogLevel.info, message, error, stackTrace, cat);
|
||
}
|
||
|
||
/// 警告日志(release模式下不输出)
|
||
/// [category] 日志分类,默认为 LogCategory.general
|
||
static void w(
|
||
dynamic message, [
|
||
dynamic error,
|
||
StackTrace? stackTrace,
|
||
LogCategory? category,
|
||
]) {
|
||
if (!_isDebugMode) return;
|
||
final cat = category ?? LogCategory.general;
|
||
if (!_shouldLog(LogLevel.warning, cat)) {
|
||
_addEntry(LogLevel.warning, message, error, stackTrace, cat);
|
||
return;
|
||
}
|
||
appLogger.w(
|
||
_formatMessage(message, cat),
|
||
error: error,
|
||
stackTrace: stackTrace,
|
||
);
|
||
_addEntry(LogLevel.warning, message, error, stackTrace, cat);
|
||
}
|
||
|
||
/// 错误日志(始终输出,错误必须记录)
|
||
/// [category] 日志分类,默认为 LogCategory.general
|
||
static void e(
|
||
dynamic message, [
|
||
dynamic error,
|
||
StackTrace? stackTrace,
|
||
LogCategory? category,
|
||
]) {
|
||
final cat = category ?? LogCategory.general;
|
||
appLogger.e(
|
||
_formatMessage(message, cat),
|
||
error: error,
|
||
stackTrace: stackTrace,
|
||
);
|
||
_addEntry(LogLevel.error, message, error, stackTrace, cat);
|
||
}
|
||
|
||
/// 致命错误日志(始终输出,错误必须记录)
|
||
/// [category] 日志分类,默认为 LogCategory.general
|
||
static void f(
|
||
dynamic message, [
|
||
dynamic error,
|
||
StackTrace? stackTrace,
|
||
LogCategory? category,
|
||
]) {
|
||
final cat = category ?? LogCategory.general;
|
||
appLogger.f(
|
||
_formatMessage(message, cat),
|
||
error: error,
|
||
stackTrace: stackTrace,
|
||
);
|
||
_addEntry(LogLevel.error, message, error, stackTrace, cat);
|
||
}
|
||
|
||
/// 按级别过滤日志
|
||
static List<LogEntry> filterByLevel(LogLevel level) {
|
||
if (level == LogLevel.all) return entries;
|
||
return _entries.where((e) => e.level.value >= level.value).toList();
|
||
}
|
||
|
||
/// 按分类过滤日志
|
||
static List<LogEntry> filterByCategory(LogCategory? category) {
|
||
if (category == null) return entries;
|
||
return _entries.where((e) => e.category == category).toList();
|
||
}
|
||
|
||
/// 按时间范围筛选
|
||
static List<LogEntry> filterByTimeRange(DateTime? start, DateTime? end) {
|
||
return _entries.where((e) {
|
||
if (start != null && e.time.isBefore(start)) return false;
|
||
if (end != null && e.time.isAfter(end)) return false;
|
||
return true;
|
||
}).toList();
|
||
}
|
||
|
||
/// 按关键词搜索
|
||
static List<LogEntry> searchByKeyword(String keyword) {
|
||
if (keyword.isEmpty) return entries;
|
||
final lower = keyword.toLowerCase();
|
||
return _entries.where((e) {
|
||
if (e.message.toLowerCase().contains(lower)) return true;
|
||
if (e.error != null && e.error.toString().toLowerCase().contains(lower)) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}).toList();
|
||
}
|
||
|
||
/// 综合筛选(支持分类过滤)
|
||
static List<LogEntry> filter({
|
||
LogLevel level = LogLevel.all,
|
||
LogCategory? category,
|
||
DateTime? startTime,
|
||
DateTime? endTime,
|
||
String keyword = '',
|
||
}) {
|
||
return _entries.where((e) {
|
||
if (level != LogLevel.all && e.level.value < level.value) return false;
|
||
if (category != null && e.category != category) return false;
|
||
if (startTime != null && e.time.isBefore(startTime)) return false;
|
||
if (endTime != null && e.time.isAfter(endTime)) return false;
|
||
if (keyword.isNotEmpty) {
|
||
final lower = keyword.toLowerCase();
|
||
if (!e.message.toLowerCase().contains(lower) &&
|
||
!(e.error != null &&
|
||
e.error.toString().toLowerCase().contains(lower))) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}).toList();
|
||
}
|
||
|
||
/// 按级别统计
|
||
static Map<LogLevel, int> countByLevel() {
|
||
final counts = <LogLevel, int>{};
|
||
for (final level in LogLevel.values) {
|
||
if (level == LogLevel.all) continue;
|
||
counts[level] = _entries.where((e) => e.level == level).length;
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
/// 按分类统计
|
||
static Map<LogCategory, int> countByCategory() {
|
||
final counts = <LogCategory, int>{};
|
||
for (final cat in LogCategory.values) {
|
||
counts[cat] = _entries.where((e) => e.category == cat).length;
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
/// 导出日志为 JSON 字符串
|
||
static String exportToJson({LogLevel level = LogLevel.all}) {
|
||
final filtered = filterByLevel(level);
|
||
final data = filtered
|
||
.map(
|
||
(e) => {
|
||
'time': e.time.toIso8601String(),
|
||
'level': e.level.label,
|
||
'category': e.category.label,
|
||
'message': e.message,
|
||
if (e.error != null) 'error': e.error.toString(),
|
||
if (e.stackTrace != null) 'stackTrace': e.stackTrace.toString(),
|
||
},
|
||
)
|
||
.toList();
|
||
|
||
return const JsonEncoder.withIndent(' ').convert({
|
||
'export_time': DateTime.now().toIso8601String(),
|
||
'app_name': '闲言',
|
||
'log_count': data.length,
|
||
'filter_level': level.label,
|
||
'entries': data,
|
||
});
|
||
}
|
||
|
||
/// 导出日志为纯文本
|
||
static String exportToText({LogLevel level = LogLevel.all}) {
|
||
final filtered = filterByLevel(level);
|
||
final buffer = StringBuffer();
|
||
buffer.writeln('闲言APP 日志导出');
|
||
buffer.writeln('导出时间: ${DateTime.now().toIso8601String()}');
|
||
buffer.writeln('过滤级别: ${level.label}');
|
||
buffer.writeln('条目数量: ${filtered.length}');
|
||
buffer.writeln('=' * 60);
|
||
|
||
for (final e in filtered) {
|
||
buffer.writeln(
|
||
'[${e.time.toString().substring(0, 19)}] [${e.level.label}] [${e.category.label}] ${e.message}',
|
||
);
|
||
if (e.error != null) buffer.writeln(' Error: ${e.error}');
|
||
if (e.stackTrace != null) buffer.writeln(' Stack: ${e.stackTrace}');
|
||
}
|
||
|
||
return buffer.toString();
|
||
}
|
||
|
||
/// 导出日志为 CSV 格式
|
||
static String exportToCsv({LogLevel level = LogLevel.all}) {
|
||
final filtered = filterByLevel(level);
|
||
final buffer = StringBuffer();
|
||
buffer.writeln('时间,级别,分类,消息,错误,堆栈');
|
||
|
||
for (final e in filtered) {
|
||
final time = e.time.toString().substring(0, 19);
|
||
final levelLabel = e.level.label;
|
||
final categoryLabel = e.category.label;
|
||
final message = _escapeCsv(e.message);
|
||
final error = e.error != null ? _escapeCsv(e.error.toString()) : '';
|
||
final stack = e.stackTrace != null
|
||
? _escapeCsv(e.stackTrace.toString())
|
||
: '';
|
||
buffer.writeln('$time,$levelLabel,$categoryLabel,$message,$error,$stack');
|
||
}
|
||
|
||
return buffer.toString();
|
||
}
|
||
|
||
static String _escapeCsv(String value) {
|
||
if (value.contains(',') || value.contains('"') || value.contains('\n')) {
|
||
return '"${value.replaceAll('"', '""')}"';
|
||
}
|
||
return value;
|
||
}
|
||
|
||
/// 导出日志到文件(Web端不可用)
|
||
static Future<String> exportToFile({
|
||
LogLevel level = LogLevel.all,
|
||
bool asJson = true,
|
||
bool asCsv = false,
|
||
}) async {
|
||
if (kIsWeb) throw UnsupportedError('日志导出在Web端不可用');
|
||
try {
|
||
final String content;
|
||
final String ext;
|
||
if (asCsv) {
|
||
content = exportToCsv(level: level);
|
||
ext = 'csv';
|
||
} else if (asJson) {
|
||
content = exportToJson(level: level);
|
||
ext = 'json';
|
||
} else {
|
||
content = exportToText(level: level);
|
||
ext = 'log';
|
||
}
|
||
Directory dir;
|
||
try {
|
||
dir = await getTemporaryDirectory();
|
||
} catch (_) {
|
||
dir = Directory.systemTemp;
|
||
}
|
||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||
final file = File('${dir.path}/xianyan_logs_$timestamp.$ext');
|
||
await file.writeAsString(content);
|
||
return file.path;
|
||
} catch (e) {
|
||
appLogger.e('日志导出失败', error: e);
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// 分享日志文件(Web端不可用)
|
||
static Future<void> shareLogs({LogLevel level = LogLevel.all}) async {
|
||
if (kIsWeb) return;
|
||
try {
|
||
final path = await exportToFile(level: level);
|
||
await SharePlus.instance.share(
|
||
ShareParams(files: [XFile(path)], text: '闲言APP 日志文件'),
|
||
);
|
||
} catch (e) {
|
||
appLogger.e('日志分享失败', error: e);
|
||
}
|
||
}
|
||
}
|