背景
问题现象
我在使用过程中发现多个状态相关的问题:
- 聊天页显示”未解锁”:进入聊天页时显示空间未解锁,但点击刷新后变成离线状态
- 发送页显示”离线模式”:刚进入 app,进入发送页显示离线模式,需要手动刷新才能恢复
- 状态不一致:只有文件列表页刷新后,其他页面的状态才能正确
根本原因分析
经过深入分析,发现问题的根源在于:
- 离线状态设置点过多:
_isOffline在代码中有 17 处设置点,分散在各个方法中,逻辑混乱 - 初始化时序问题:
AppState构造函数中的_initCoreAndNetwork()是异步的,但 UI 不等待它完成就开始渲染 - 各页面独立刷新:每个页面在
initState中各自调用刷新方法,导致状态不同步
问题时序图:
┌─────────────────────────────────────────────────────────────┐│ 旧的有问题的流程 │├─────────────────────────────────────────────────────────────┤│ ││ AppState 构造函数 ││ │ ││ ├── _initCoreAndNetwork() ──┐ (异步,不阻塞) ││ │ │ ││ ▼ (立即返回) │ ││ MaterialApp 开始渲染 │ ││ │ │ ││ ▼ │ ││ 进入发送页 │ (初始化还在进行中) ││ │ │ ││ ▼ │ ││ 页面读取 isOffline = true ←────────┘ (默认值/未初始化完成) ││ │ ││ ▼ ││ 显示"离线模式" ← 错误! ││ │└─────────────────────────────────────────────────────────────┘第一阶段:离线状态统一管理
方案设计
核心思路:将 _isOffline 的设置点从 17 处减少到 5 处,统一由 refreshHealthStatus() 管理。
改动前
// 分散在各处的 _isOffline 设置(17 处)
Future<void> _loadMetadata() async {
// ...
_isOffline = true; // 这里设置
// ...
_isOffline = false; // 那里又设置
}
Future<void> refreshFiles() async {
// ...
_isOffline = true; // 这里也设置
}
Future<void> forceRefresh() async {
// 60+ 行代码,多处设置 _isOffline
}改动后
/// 刷新健康状态(统一的离线状态管理入口)
Future<void> refreshHealthStatus() async {
try {
final coreResult = await api.checkCoreHealth();
final storageResult = await api.checkStorageHealth();
// 统一设置离线状态
_isOffline = !coreResult.isSuccess || !storageResult.isSuccess;
// 根据错误类型设置错误码
if (!coreResult.isSuccess) {
_healthErrorCode = 'core_error';
} else if (!storageResult.isSuccess) {
_healthErrorCode = 's3_error';
} else {
_healthErrorCode = null;
}
notifyListeners();
} catch (e) {
_isOffline = true;
notifyListeners();
}
}
/// 加载元数据(不改变离线状态,由 refreshHealthStatus 统一管理)
Future<bool> _loadMetadata() async {
try {
final result = await api.getMetadata();
if (result.isSuccess && result.data != null) {
_files = result.data!.files.values.toList();
_markSystemFolders();
await _syncToLocalDb(result.data!);
return true; // 只返回成功/失败,不设置 _isOffline
} else {
await _loadFromLocalDb();
return false;
}
} catch (e) {
await _loadFromLocalDb();
return false;
}
}
/// 强制刷新(简化后:从 60+ 行减少到 20 行)
Future<void> forceRefresh() async {
_isLoading = true;
notifyListeners();
// 1. 先刷新健康状态(统一的离线状态管理入口)
await refreshHealthStatus();
if (!_isOffline) {
// 2. 在线时:先尝试重新认证
await _tryReauthenticate();
// 3. 加载最新数据
await _loadMetadata();
} else {
// 4. 离线时:加载本地缓存
await _loadFromLocalDb();
}
_isLoading = false;
notifyListeners();
}效果
_isOffline设置点:17 处 → 5 处forceRefresh()代码量:60+ 行 → 20 行- 逻辑更清晰,状态变更可追踪
第二阶段:UI 显示逻辑优化
需求
我当时明确提到:
“我们只想在还没有成功连接到 S3 过时,才显示整个界面被替换的那种大的提示。而只要连接过,离线或者 S3 连接不上,就只是云朵变成红色而已。“
方案设计
区分两种状态:
| 状态 | 判断条件 | UI 表现 |
|---|---|---|
| 从未连接过 | !isUnlocked && sessions.isEmpty | 全屏锁定提示 |
| 已连接但离线 | isOffline && sessions.isNotEmpty | 正常显示列表 + 红色云朵 |
实现
body: Consumer<AppState>(
builder: (context, appState, _) {
// 只有在"从未连接过"(未解锁且没有缓存)时才显示大的锁定提示
// 一旦有缓存,即使离线/未解锁,也正常显示列表(只用红色云朵提示)
final neverConnected = !appState.isUnlocked && _sessions.isEmpty && !_loading;
if (neverConnected) {
return _buildLockedState(isDark); // 全屏提示
}
// 有缓存时正常显示,通过 AppBar 的红色云朵提示离线状态
return _buildSessionList(isDark);
},
),第三阶段:初始化时序问题解决(核心重构)
问题本质
class AppState extends ChangeNotifier {
AppState() {
_initCoreAndNetwork(); // 异步!不阻塞构造函数
}
Future<void> _initCoreAndNetwork() async {
await _loadPreferences();
await _ensureCoreModeLoaded();
await _maybeStartEmbeddedCore();
await _checkInitialNetworkStatus(); // 这里才设置正确的离线状态
}
}问题:构造函数立即返回,UI 开始渲染时,_initCoreAndNetwork() 还没完成。
方案对比
| 方案 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| A. 各页面手动刷新 | 每个页面 initState 调用 forceRefresh() | 简单 | 重复代码、多次请求、状态不一致 |
| B. 添加 isReady 标志 | 初始化完成前显示启动页 | 统一、优雅 | 需要修改入口 |
| C. 同步初始化 | 阻塞构造函数直到完成 | 保证状态正确 | 阻塞 UI、体验差 |
选择方案 B:添加 isReady 标志,优雅且不阻塞。
实现
1. AppState 添加 isReady 标志
class AppState extends ChangeNotifier {
bool _isReady = false; // 新增
bool get isReady => _isReady; // getter
Future<void> _initCoreAndNetwork() async {
await _loadPreferences();
await _ensureCoreModeLoaded();
await _maybeStartEmbeddedCore();
await _checkInitialNetworkStatus();
_cleanupFilePickerCache();
// 标记初始化完成
_isReady = true;
notifyListeners();
}
}2. main.dart 等待初始化完成
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<AppState>(
builder: (context, appState, _) {
return MaterialApp(
home: _buildHome(appState, appPinRequired),
);
},
);
}
Widget _buildHome(AppState appState, bool appPinRequired) {
// 未初始化完成时显示启动页
if (!appState.isReady) {
return const _SplashScreen();
}
// PIN 锁屏
if (appPinRequired) {
return const AppPinLockPage();
}
return const HomePage();
}
}
/// 启动页(初始化中)
class _SplashScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_outlined,
size: 64,
color: isDark ? Colors.white70 : Colors.grey[600],
),
const SizedBox(height: 24),
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
isDark ? Colors.white70 : Colors.grey[600]!,
),
),
),
],
),
),
);
}
}3. 简化各页面的加载逻辑
由于进入页面时 AppState 已经初始化完成,不再需要在 initState 中调用 forceRefresh():
// 之前(send_page.dart)
Future<void> _loadSessions() async {
// 0. 先强制刷新应用状态,确保 S3 配置和解锁状态已同步
if (_sessions.isEmpty) {
setState(() => _loading = true);
}
await appState.forceRefresh(); // 多余的!
// ... 后续逻辑
}
// 之后
Future<void> _loadSessions() async {
final appState = context.read<AppState>();
final s3ConfigId = appState.activeS3ConfigId;
// 显示加载状态(但不清空现有数据)
if (_sessions.isEmpty) {
setState(() {
_loading = true;
_error = null;
});
}
// 直接使用 appState 的状态,因为它已经初始化完成了
// ... 后续逻辑
}新的流程图
┌─────────────────────────────────────────────────────────────┐│ 新的正确流程 │├─────────────────────────────────────────────────────────────┤│ ││ AppState 构造函数 ││ │ ││ ├── _initCoreAndNetwork() ──┐ (异步) ││ │ │ ││ ▼ │ ││ MaterialApp 开始渲染 │ ││ │ │ ││ ▼ │ ││ 检查 appState.isReady │ ││ │ │ ││ ▼ (isReady = false) │ ││ 显示 SplashScreen (loading) │ ││ │ │ ││ │ ←────────────────────────┘ (初始化完成) ││ │ _isReady = true ││ │ notifyListeners() ││ │ ││ ▼ (isReady = true) ││ 显示 HomePage ││ │ ││ ▼ ││ 进入发送页 ││ │ ││ ▼ ││ 页面读取 isOffline (已正确初始化) ← 正确! ││ │└─────────────────────────────────────────────────────────────┘第四阶段:聊天视频缩略图修复
问题
我这边用的时候发现聊天界面上传视频无法生成缩略图。
原因分析
对比文件列表页和聊天页的上传逻辑:
| 功能 | 文件列表页 | 聊天页 |
|---|---|---|
| 上传方法 | _startUploadJob() | _sendFileMessage() |
| 缩略图生成 | ✅ 调用 _generateAndUploadVideoThumbnail() | ❌ 没有调用 |
修复
在 chat_page.dart 的 _sendFileMessage() 方法中添加视频缩略图生成逻辑:
// 添加导入
import '../core/services/video_thumbnail_service.dart';
// 在上传成功后添加缩略图生成
if (result.isSuccess && result.data != null) {
// 成功:替换临时消息为真实消息
setState(() {
// ... 更新消息列表
});
// 更新本地数据库
await appState.db.deleteSendMessage(tempId);
await appState.db.upsertSendMessage(result.data!);
// 视频文件上传成功后,生成并上传缩略图
if (isVideoFile(safeFileName)) {
_generateAndUploadVideoThumbnail(appState, filePath, uploadedFileId);
}
}
/// 视频上传后生成并上传缩略图(异步,不阻塞上传流程)
Future<void> _generateAndUploadVideoThumbnail(
AppState appState,
String videoPath,
String fileId,
) async {
try {
// 使用统一的缩略图服务从本地文件生成
final thumbData = await VideoThumbnailService().generateFromFile(videoPath);
if (thumbData != null) {
await appState.api.uploadThumbnail(fileId, thumbData);
debugPrint('[ChatPage] Video thumbnail uploaded for $fileId');
}
} catch (e) {
// 缩略图生成失败不影响上传流程
debugPrint('[ChatPage] Failed to generate video thumbnail: $e');
}
}总结
改动文件清单
| 文件 | 改动类型 | 说明 |
|---|---|---|
app_state.dart | 重构 | 添加 isReady 标志,统一离线状态管理 |
main.dart | 新增 | 添加 _SplashScreen,等待初始化完成 |
send_page.dart | 简化 | 移除 forceRefresh() 调用 |
chat_page.dart | 简化 + 修复 | 移除 refreshHealthStatus(),添加视频缩略图生成 |
关键设计决策
- 统一状态管理入口:
_isOffline只由refreshHealthStatus()设置 - 等待初始化完成:通过
isReady标志,确保 UI 渲染时状态已正确 - 区分离线类型:从未连接 vs 已连接但离线,UI 表现不同
- 代码复用:视频缩略图使用统一的
VideoThumbnailService
使用体验改进
| 场景 | 改动前 | 改动后 |
|---|---|---|
| 启动 app | 直接进入,状态可能错误 | 显示 loading,等待初始化 |
| 进入发送页 | 可能显示”离线模式” | 状态正确 |
| 离线时有缓存 | 全屏错误提示 | 正常显示 + 红色云朵 |
| 聊天上传视频 | 无缩略图 | 自动生成缩略图 |
技术债务清理
_isOffline设置点:17 → 5 处forceRefresh()代码量:60+ → 20 行- 页面重复刷新逻辑:移除
被拒绝的方案和我这边用的时候发现
原话记录
-
关于 isLocalFile 字段:
“我拒绝了你的修改,isLocalFile 是因为我们将来还要支持从云盘中选择文件发送,发送到对方后,对方可以选择下载到本地,这个时候再点击,就直接从本地缓存打开。同时,发送失败的消息,点击可以重新发送,此时就需要用到 isLocalFile 指向的路径”
-
关于重构方向:
“好了,别再垃圾上雕花了。重新设置事件总线,结合我们的业务逻辑,设计一个优雅的简洁统一的状态检测。完全重构取代之前的混乱逻辑”
-
关于 UI 显示策略:
“我们只想在还没有成功连接到 S3 过时,才显示整个界面被替换的那种大的提示,连接不到 S3 什么什么的。而只要连接过,离线或者 S3 连接不上,就只是云朵变成红色而已”
-
关于移除手动刷新逻辑的质疑:
“为什么要移除移除手动刷新逻辑?“
被拒绝的方案
1. 删除 isLocalFile 字段
提议:删除 SendMessage.isLocalFile 字段,因为看起来是冗余的。
被拒绝原因:
- 业务扩展需求:将来支持从云盘选择文件发送
- 本地缓存功能:接收方下载后可直接从本地打开
- 失败重试:发送失败的消息需要用本地路径重新上传
教训:在删除字段之前,必须了解完整的业务场景和未来规划。
2. 在各页面 initState 中调用 forceRefresh()
提议:每个页面进入时调用 appState.forceRefresh() 确保状态同步。
问题:
- 重复请求:每进入一个页面就发起网络请求
- 状态闪烁:先显示错误状态,请求完成后才正确
- 使用体验差:需要手动刷新
最终方案:使用 isReady 标志,等待初始化完成后再渲染。
相关的历史决策
存储路径设计
聊天数据的存储位置经过确认:
- 元数据:
.e2eepan/send/sessions.enc和.e2eepan/send/messages/ - 个人文件:
/sender/目录
后端代码确认:
const (
sendSessionsKey = ".e2eepan/send/sessions.enc"
sendMessagesDir = ".e2eepan/send/messages/"
)客户端上传:
final taskResult = await appState.api.uploadByPath(
filePath: filePath,
fileName: safeFileName,
remotePath: '/sender/', // 聊天文件存储在这里
);大文件上传流式处理
之前修复了聊天上传大文件导致 OOM 的问题:
- 使用
uploadByPath替代uploadFile - Go 核心直接读取文件流,不将文件内容全部加载到内存
// 之前(会 OOM)
final fileData = await File(filePath).readAsBytes();
await api.uploadFile(fileData, fileName);
// 之后(流式处理)
await api.uploadByPath(
filePath: filePath,
fileName: fileName,
remotePath: '/sender/',
);测试验证
测试场景
| 场景 | 预期行为 | 验证状态 |
|---|---|---|
| 冷启动 | 显示 loading,等待初始化 | 待测试 |
| 进入发送页 | 直接显示正确状态 | 待测试 |
| 进入聊天页 | 直接显示正确状态 | 待测试 |
| 离线且有缓存 | 显示缓存数据 + 红色云朵 | 待测试 |
| 从未连接过 | 显示全屏提示 | 待测试 |
| 上传视频 | 自动生成缩略图 | 待测试 |
构建验证
# Flutter 代码分析
flutter analyze
# 结果:3 issues found(都是 info 级别的 use_build_context_synchronously)
# APK 构建
.\scripts\bat\build_android.bat
# 结果:成功,133.6MB
# 安装到设备
adb install -r app-release.apk
# 结果:成功附录:完整代码差异
app_state.dart
bool _isInitialized = false;
bool _isUnlocked = false;
bool _isLoading = false;
bool _isOffline = false;
+ bool _isReady = false; // 应用状态是否初始化完成
// Getters
+ bool get isReady => _isReady;
bool get isInitialized => _isInitialized;
bool get isUnlocked => _isUnlocked;
Future<void> _initCoreAndNetwork() async {
await _loadPreferences();
await _ensureCoreModeLoaded();
await _maybeStartEmbeddedCore();
await _checkInitialNetworkStatus();
_cleanupFilePickerCache();
+
+ // 标记初始化完成
+ _isReady = true;
+ notifyListeners();
}main.dart
Widget build(BuildContext context) {
return Consumer<AppState>(
builder: (context, appState, _) {
return MaterialApp(
- home: appPinRequired ? const AppPinLockPage() : const HomePage(),
+ home: _buildHome(appState, appPinRequired),
);
},
);
}
+ Widget _buildHome(AppState appState, bool appPinRequired) {
+ if (!appState.isReady) {
+ return const _SplashScreen();
+ }
+ if (appPinRequired) {
+ return const AppPinLockPage();
+ }
+ return const HomePage();
+ }chat_page.dart
+ import '../core/services/video_thumbnail_service.dart';
if (result.isSuccess && result.data != null) {
// ... 更新消息
+ // 视频文件上传成功后,生成并上传缩略图
+ if (isVideoFile(safeFileName)) {
+ _generateAndUploadVideoThumbnail(appState, filePath, uploadedFileId);
+ }
}
+ Future<void> _generateAndUploadVideoThumbnail(
+ AppState appState,
+ String videoPath,
+ String fileId,
+ ) async {
+ try {
+ final thumbData = await VideoThumbnailService().generateFromFile(videoPath);
+ if (thumbData != null) {
+ await appState.api.uploadThumbnail(fileId, thumbData);
+ }
+ } catch (e) {
+ debugPrint('[ChatPage] Failed to generate video thumbnail: $e');
+ }
+ }