状态管理重构:从混乱到统一

December 24, 2025
9 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

背景

问题现象

我在使用过程中发现多个状态相关的问题:

  1. 聊天页显示”未解锁”:进入聊天页时显示空间未解锁,但点击刷新后变成离线状态
  2. 发送页显示”离线模式”:刚进入 app,进入发送页显示离线模式,需要手动刷新才能恢复
  3. 状态不一致:只有文件列表页刷新后,其他页面的状态才能正确

根本原因分析

经过深入分析,发现问题的根源在于:

  1. 离线状态设置点过多_isOffline 在代码中有 17 处设置点,分散在各个方法中,逻辑混乱
  2. 初始化时序问题AppState 构造函数中的 _initCoreAndNetwork() 是异步的,但 UI 不等待它完成就开始渲染
  3. 各页面独立刷新:每个页面在 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(),添加视频缩略图生成

关键设计决策

  1. 统一状态管理入口_isOffline 只由 refreshHealthStatus() 设置
  2. 等待初始化完成:通过 isReady 标志,确保 UI 渲染时状态已正确
  3. 区分离线类型:从未连接 vs 已连接但离线,UI 表现不同
  4. 代码复用:视频缩略图使用统一的 VideoThumbnailService

使用体验改进

场景改动前改动后
启动 app直接进入,状态可能错误显示 loading,等待初始化
进入发送页可能显示”离线模式”状态正确
离线时有缓存全屏错误提示正常显示 + 红色云朵
聊天上传视频无缩略图自动生成缩略图

技术债务清理

  • _isOffline 设置点:17 → 5 处
  • forceRefresh() 代码量:60+ → 20 行
  • 页面重复刷新逻辑:移除

被拒绝的方案和我这边用的时候发现

原话记录

  1. 关于 isLocalFile 字段

    “我拒绝了你的修改,isLocalFile 是因为我们将来还要支持从云盘中选择文件发送,发送到对方后,对方可以选择下载到本地,这个时候再点击,就直接从本地缓存打开。同时,发送失败的消息,点击可以重新发送,此时就需要用到 isLocalFile 指向的路径”

  2. 关于重构方向

    “好了,别再垃圾上雕花了。重新设置事件总线,结合我们的业务逻辑,设计一个优雅的简洁统一的状态检测。完全重构取代之前的混乱逻辑”

  3. 关于 UI 显示策略

    “我们只想在还没有成功连接到 S3 过时,才显示整个界面被替换的那种大的提示,连接不到 S3 什么什么的。而只要连接过,离线或者 S3 连接不上,就只是云朵变成红色而已”

  4. 关于移除手动刷新逻辑的质疑

    “为什么要移除移除手动刷新逻辑?“

被拒绝的方案

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');
+   }
+ }