用设计模式实现用户友好的撤销操作


目录

  1. 问题起源:一条误删的情绪记录引发的思考
  2. 命令模式的核心思想:把操作封装为对象
  3. 设计 Command 接口:execute 与 undo 的契约
  4. 双栈法:undoStack 与 redoStack 的协作机制
  5. 实战一:AddMoodCommand——记录的可撤销新增
  6. 实战二:EditMoodCommand——编辑的完整回滚
  7. 实战三:DeleteMoodCommand——删除的"后悔药"
  8. 命令合并策略:连续输入合并为一个命令
  9. 带 SnackBar 的撤销交互设计
  10. 内存管理:命令栈的大小限制与清理策略
  11. 命令历史的持久化:跨会话撤销的实现
  12. 鸿蒙平台的兼容性说明
  13. 总结

一、问题起源:一条误删的情绪记录引发的思考

E-Brufen 的早期版本中,删除操作是直接调用 MoodStorage.delete(id) 完成的,没有任何撤销机制。用户在时间线页面长按一条情绪记录,弹出确认对话框,点击"删除"按钮之后,数据就永久消失了。

我们在 v1.2 版本的用户反馈中收到了这样一条评价:"不小心把周二那条开心的记录删了,找不回来,哭了。"这虽然是一个操作失误,但作为开发者,我们需要反思:为什么应用没有给用户留一条回头路?

在传统的桌面应用中,Ctrl+Z 早已成为肌肉记忆级别的操作。手机上的 Gmail、微信、备忘录等主流应用也都提供了"已删除,点击撤销"的交互模式。这些设计背后的共同原则是:任何破坏性操作都应该允许用户反悔。

从代码层面看,要实现撤销功能,我们需要解决三个核心问题:

  1. 状态回滚:如何精确地恢复到执行操作之前的状态?直接操作数据库后,原始数据已经丢失了。
  2. 操作粒度:撤销的"一步"究竟包含多少内容?用户连续输入一段文字,撤销时应该一个字一个字退回去,还是一次撤销整段?
  3. 跨页面一致性:情绪记录涉及新增、编辑、删除三种操作,它们的撤销逻辑各不相同,如何用统一的机制管理?

这三个问题指向了同一个设计模式——命令模式(Command Pattern)


二、命令模式的核心思想:把操作封装为对象

命令模式是 GoF(Gang of Four)23 种经典设计模式中的一种行为型模式。它的核心思想可以用一句话概括:将每一个操作请求封装为一个独立的命令对象,这个对象包含了执行该操作所需的所有参数和上下文信息。

在没有命令模式的情况下,我们在 DiaryPage 中直接调用 widget.moodStorage.insert(entry) 来新增记录。这个调用本身不携带"如何撤销"的信息——MoodStorage 不知道是谁调用了它,也不知道调用时的状态是什么。如果要撤销,我们只能依赖数据库级别的 rollback,或者在 UI 层手动记录"刚才做了什么"。

命令模式把这种隐式的调用关系显式化:

传统方式(直接调用):
  UI → MoodStorage.insert(entry)              // 单向操作,无法回滚

命令模式(封装为对象):
  UI → AddMoodCommand(moodStorage, entry)
        ├── execute(): 执行新增操作
        └── undo():    根据 execute 的副作用恢复原状

一个命令对象就像一个"可执行的任务包裹"——它携带了操作的执行逻辑(execute)和撤销逻辑(undo),并且被一个中央调度器(CommandHistory)统一管理。这个调度器维护两个栈:undoStack 存放已执行但未撤销的命令,redoStack 存放已撤销但可以重做的命令。

这种设计带来三个关键好处:

  1. 操作可追溯:每一条命令都是独立的对象,可以遍历、检查、序列化。
  2. 撤销与重做逻辑内聚executeundo 在同一个类中,开发者只需要维护这一对方法就能保证操作的可逆性。
  3. UI 与业务逻辑解耦:UI 只需要创建命令并提交给 CommandHistory,不需要知道命令的内部细节。

三、设计 Command 接口:execute 与 undo 的契约

在 Dart 中,我们用抽象类来定义命令接口。这个接口是整个撤销/重做系统的基石,它的设计直接影响后续所有具体命令类的实现体验:

/// 命令模式的核心抽象:所有可撤销的操作都必须实现这个接口
///
/// 设计原则:
/// 1. execute() 执行操作并保存撤销所需的上下文
/// 2. undo()   基于 execute 保存的上下文恢复原状
/// 3. 两个方法都返回 Future<void>,因为数据操作通常是异步的
abstract class Command {
  /// 执行命令。可能多次调用(重做场景)。
  Future<void> execute();

  /// 撤销命令。将应用状态恢复到 execute 之前。
  /// 假设 execute 已经成功执行过。
  Future<void> undo();

  /// 人类可读的操作描述,用于 SnackBar 提示或日志
  String get description;
}

这个接口看起来很简单,但有几个设计决策值得讨论:

决策一:为什么 execute undo 都返回 Future<void>

因为 E-Brufen 的数据操作全部通过 Hive(hive_ce 版本,兼容鸿蒙)进行,而 Hive 的写入操作是异步的——它返回 Future。如果命令接口方法返回 void,我们就需要在命令内部使用 Future.sync.then() 来处理异步调用,这会破坏代码的线性可读性。直接返回 Future<void> 让调度器可以用 await 等待命令完成。

决策二:为什么需要一个 description 字段?

这看似是一个小细节,但在实际应用中非常重要。当我们需要在 SnackBar 中显示"已删除一条记录,点击撤销"时,这条消息的内容依赖于具体的命令类型。把 description 放在抽象接口中,意味着调度器不需要知道具体命令的类型就能生成提示文案。同时,这个字段在调试和日志记录中也很有用。

决策三:execute 可以被多次调用吗?

是的。在重做(Redo)场景中,一条命令从 redoStack 弹出来,再次调用 execute。这就要求命令对象在第一次 execute 之后保持可用状态,不能在执行后处置自己的内部状态。具体来说,命令对象应该在 execute 中保存"撤销所需的上下文"(比如被删除记录的完整数据),但这个上下文要保留到 undo 真正被调用时使用,而不是在 execute 结束后就丢弃。

决策四:鸿蒙平台的异步特性是否需要特殊处理?

Hive CE(Community Edition)在鸿蒙平台上的异步行为与 Android/iOS 基本一致——都使用 Dart 的 Future 机制。但有一个细微差别:鸿蒙的存储 IO 操作可能因为系统资源管理策略而延迟。在我们的命令系统中,CommandHistory 使用 await 等待每个命令的 execute 完成,这意味着如果某个存储操作被系统延迟,后续的命令执行也会被阻塞。对于情绪记录这种低频操作场景(用户不会在 1 秒内连续执行 10 条命令),这个设计是安全的;但如果将来需要支持高频操作(比如呼吸练习中的实时设置调整),我们就需要考虑把命令执行改为非阻塞的队列消费模式。


四、双栈法:undoStack 与 redoStack 的协作机制

撤销/重做的核心数据结构是双栈——这是整个系统最精妙的部分。让我们先看架构图:

                        CommandHistory
┌─────────────────────────────────────────────────────────────┐
│                                                             │
│   execute(command)                    undo()                │
│   ┌──────────┐                   ┌──────────┐              │
│   │ 执行命令  │                   │ undoStack │              │
│   │ 放入     │────push──────────▶│  [top]    │──pop──▶     │
│   │undoStack │                   │  cmd_3    │  执行undo()  │
│   │ 清空     │                   │  cmd_2    │             │
│   │redoStack │                   │  cmd_1    │  将命令放入  │
│   └──────────┘                   └──────────┘  redoStack   │
│                                        │                    │
│   redo()                              ▼                    │
│   ┌──────────┐                   ┌──────────┐              │
│   │ redoStack │──pop──▶          │ undoStack │              │
│   │  [top]    │  执行execute()   │  ←push──  │              │
│   │  cmd_4    │                  │  cmd_4    │              │
│   └──────────┘                   └──────────┘              │
│                                                             │
│   maxStackSize = 50  (防止内存溢出)                          │
│   staleDuration = 30分钟  (过期清理策略)                     │
└─────────────────────────────────────────────────────────────┘

操作流程示意:
  [初始状态]      undoStack: []         redoStack: []
  ↓ execute(cmd1)
  [执行后]        undoStack: [cmd1]     redoStack: []
  ↓ execute(cmd2)
  [执行后]        undoStack: [cmd2,cmd1]redoStack: []
  ↓ undo()
  [撤销后]        undoStack: [cmd1]     redoStack: [cmd2]
  ↓ execute(cmd3)  ← 新操作会清空 redoStack
  [新操作后]      undoStack: [cmd3,cmd1]redoStack: []  ← 清空了!
  ↓ undo()
  [撤销后]        undoStack: [cmd1]     redoStack: [cmd3]
  ↓ redo()
  [重做后]        undoStack: [cmd3,cmd1]redoStack: []

双栈法的三个核心规则:

规则一:新命令执行时,清空 redoStack。 这是最关键的一条规则。想象这个场景:用户新增了一条记录(cmd1),然后撤销(cmd1 进入 redoStack),又新增了一条不同的记录(cmd2)。如果不清空 redoStack,用户按重做时会执行 cmd1,导致 cmd1 被重新插入——但 cmd2 已经在数据库里了,两条记录并存。这个状态是用户没有预期到的,会造成数据混乱。因此,任何新命令的 execute 都会清空 redoStack,确保重做操作只能在"纯撤销"之后使用。

规则二:undoStack 有大小上限。 如果不加限制,undoStack 会随着用户操作不断增长。一条情绪记录大约 200 字节,50 条命令就是 10KB——不算大,但考虑到命令对象还包含 MoodEntry 的快照数据(可能包含长文本备注),实际内存占用可能达到几百 KB。设置一个合理的上限(比如 50 条)可以防止内存泄漏。

规则三:命令对象保存撤销所需的完整快照。 这不是栈的逻辑,而是命令对象的职责。比如 DeleteMoodCommand 在执行删除之前,必须先把 MoodEntry 的完整数据保存到自己的字段中,这样撤销时才能用这份快照重新 insert。如果等到 undo 时才去查"刚才删了什么",数据已经不存在了。

下面是对应的 Dart 实现:

/// 命令历史管理器:维护 undoStack 和 redoStack
///
/// 单例模式,全局只有一个实例,确保跨 Widget 的命令一致性
class CommandHistory {
  static final CommandHistory _instance = CommandHistory._();
  static CommandHistory get instance => _instance;
  CommandHistory._();

  /// 已执行、可以撤销的命令栈(最后执行的命令在栈顶)
  final List<Command> _undoStack = [];

  /// 已撤销、可以重做的命令栈(最后撤销的命令在栈顶)
  final List<Command> _redoStack = [];

  /// 最大栈深度,超过后移除最旧的命令
  static const int maxStackSize = 50;

  /// 当前是否正在执行命令(防止嵌套调用)
  bool _isExecuting = false;

  // ── 公开状态 ──

  bool get canUndo => _undoStack.isNotEmpty;
  bool get canRedo => _redoStack.isNotEmpty;

  /// 最近一条命令的描述(用于撤销按钮的文案)
  String get lastUndoDescription =>
      _undoStack.isNotEmpty ? _undoStack.last.description : '';

  // ── 核心操作 ──

  /// 执行一条新命令
  Future<void> execute(Command command) async {
    if (_isExecuting) return;
    _isExecuting = true;

    try {
      await command.execute();
      _undoStack.add(command);
      _redoStack.clear(); // 规则一:清空重做栈

      // 规则二:超出上限时移除最旧的命令
      while (_undoStack.length > maxStackSize) {
        _undoStack.removeAt(0);
      }
    } finally {
      _isExecuting = false;
    }
  }

  /// 撤销最近一条命令
  Future<void> undo() async {
    if (!canUndo) return;
    final command = _undoStack.removeLast();
    await command.undo();
    _redoStack.add(command);
  }

  /// 重做最近撤销的命令
  Future<void> redo() async {
    if (!canRedo) return;
    final command = _redoStack.removeLast();
    await command.execute();
    _undoStack.add(command);
  }

  /// 清空所有历史(切换用户或退出登录时调用)
  void clear() {
    _undoStack.clear();
    _redoStack.clear();
  }
}

这里有三个值得展开的细节:

单例的必要性: 在 E-Brufen 中,情绪记录的增删改操作可能发生在 DiaryPage 的任何一个 Tab 中——记录 Tab 有新增按钮,时间线 Tab 有删除和编辑,统计 Tab 理论上也会有快捷操作入口。如果每个 Tab 各自维护一个 CommandHistory,就会出现"在记录页新增了一条记录,但时间线页的撤销按钮无法撤销它"的诡异现象。单例确保了全局只有一个命令历史。

_isExecuting 防重入锁: 这是一个防御性设计。在某些边界情况下(比如用户快速双击按钮,或者代码中连续调用了两次 execute),如果没有防重入保护,第二次调用可能在第一次的 await command.execute() 还没完成时就开始了,导致 _undoStack_redoStack 的状态不一致。虽然在 Dart 的单线程模型中真正的并发写入不会发生,但 await 在微任务队列中的穿插仍然可能导致意外的交错执行。

超出上限时的移除策略: 我们选择移除「最旧的」命令——即 _undoStack 的第一个元素,而不是最后一个。这样,新来的命令总是能保留在栈中,用户最近的 50 步操作都可以撤销。考虑到情绪记录的操作频率(日均 3-5 条),50 步的历史覆盖了大约 10 天的使用量,对于大多数用户来说已经绰绰有余。


五、实战一:AddMoodCommand——记录的可撤销新增

在这里插入图片描述

AddMoodCommand 是最基础的命令实现,但也包含了命令模式最核心的设计要素:在 execute 中保存撤销所需的上下文

对于新增操作,"撤销"意味着删除刚刚插入的那条记录,而"重做"意味着再次插入一条完全相同的记录。注意:第二次插入会获得一个新的 ID,这在情绪记录的场景下是可以接受的——用户看到的是一个"内容相同"的记录,而不是"ID 相同"的记录。

/// 新增情绪记录的命令
class AddMoodCommand implements Command {
  final MoodStorage _storage;
  final MoodType _moodType;
  final String? _note;
  final DateTime _createdAt;

  /// execute 执行后,这里保存数据库分配的 ID,undo 时用它来删除
  int? _insertedId;

  AddMoodCommand({
    required MoodStorage storage,
    required MoodType moodType,
    String? note,
    DateTime? createdAt,
  })  : _storage = storage,
        _moodType = moodType,
        _note = note,
        _createdAt = createdAt ?? DateTime.now();

  
  String get description => '已记录"${_moodType.label}"情绪';

  
  Future<void> execute() async {
    // 1. 构造 MoodEntry 对象
    final entry = MoodEntry(
      moodType: _moodType,
      note: (_note != null && _note!.isNotEmpty) ? _note : null,
      createdAt: _createdAt,
      updatedAt: _createdAt,
    );

    // 2. 执行插入,获取数据库分配的 ID
    _insertedId = await _storage.insert(entry);

    // 3. insertedId 被保存为实例字段——这是 undo 的关键上下文
  }

  
  Future<void> undo() async {
    if (_insertedId == null) return;

    // 使用 execute 中保存的 ID 执行删除
    await _storage.delete(_insertedId!);
    _insertedId = null; // 清空状态,防止重复撤销
  }
}

关键设计:为什么不在 undo 中重新查询数据?

当我们执行撤销时,数据库中的记录仍然存在(通过 _insertedId 可以找到)。理论上,我们可以不在 execute 中保存任何上下文,而在 undo 中查询所有记录、找到最新插入的那条、再删除它。但这种方式有两个致命问题:

  1. 竞态风险:如果用户在新增和撤销之间又通过其他路径新增了记录,"最新插入的那条"就不是我们要删除的了。
  2. 性能浪费MoodStorage.getAll() 需要遍历整个 Hive Box,随着数据增长,这个操作的时间复杂度是 O(n)。

保存 _insertedIdundo 变成了 O(1) 的直接删除操作——精准、高效、无副作用。

使用方式:

// 在 DiaryPage 中,用命令模式替代直接调用 MoodStorage
void _saveWithUndo(MoodType mood, String? note) {
  final command = AddMoodCommand(
    storage: widget.moodStorage,
    moodType: mood,
    note: note,
  );
  CommandHistory.instance.execute(command);

  // 显示带撤销按钮的 SnackBar
  if (mounted) {
    _showUndoSnackBar(context, command.description);
  }
}

六、实战二:EditMoodCommand——编辑的完整回滚

编辑操作的撤销比新增复杂得多。原因在于,撤销编辑意味着将记录恢复到编辑之前的状态,这就要求我们在执行编辑之前保存原始记录的完整快照。

在 E-Brufen 的场景中,一条情绪记录可以被编辑的部分包括:

  • 情绪类型(从"开心"改为"平静")
  • 备注文本(从空改为一段 200 字的心得)
  • 更新时间戳

这三个字段的任何变化都需要在撤销时被回滚。最可靠的方式是在 execute 中保存编辑前完整的 MoodEntry 对象

/// 编辑情绪记录的命令
///
/// 核心策略:execute 执行前先快照原始记录,undo 时用快照覆盖当前记录
class EditMoodCommand implements Command {
  final MoodStorage _storage;
  final int _recordId;

  /// 编辑后的新值
  final MoodType _newMoodType;
  final String? _newNote;

  /// execute 执行前保存的原始快照
  MoodEntry? _originalSnapshot;

  EditMoodCommand({
    required MoodStorage storage,
    required int recordId,
    required MoodType newMoodType,
    String? newNote,
  })  : _storage = storage,
        _recordId = recordId,
        _newMoodType = newMoodType,
        _newNote = newNote;

  
  String get description => '已修改情绪记录';

  
  Future<void> execute() async {
    // 1. 在执行编辑之前,先快照原始记录
    //    注意:这里假设 execute 调用时原始记录还是未修改状态
    final allRecords = _storage.getAll();
    _originalSnapshot = allRecords.cast<MoodEntry?>().firstWhere(
      (e) => e?.id == _recordId,
      orElse: () => null,
    );

    if (_originalSnapshot == null) {
      throw StateError('EditMoodCommand: 找不到 id=$_recordId 的记录');
    }

    // 2. 执行编辑操作
    final updated = _originalSnapshot!.copyWith(
      moodType: _newMoodType,
      note: _newNote,
      updatedAt: DateTime.now(),
    );
    await _storage.update(_recordId, updated);
  }

  
  Future<void> undo() async {
    if (_originalSnapshot == null) return;

    // 用原始快照覆盖当前记录——精确恢复到编辑前的状态
    await _storage.update(_recordId, _originalSnapshot!);

    // 注意:不清空 _originalSnapshot!
    // 原因:用户可能再次 redo(),需要原始快照来重新计算编辑结果
  }
}

为什么 undo 不清空 _originalSnapshot

这是编辑命令和新增命令的一个重要区别。在 AddMoodCommand 中,undo 之后 _insertedId 被清空,再次调用 undo 是 no-op(因为 _insertedId == null)。但在 EditMoodCommand 中,用户可能会执行这样的操作序列:

execute (编辑) → undo (恢复原状) → redo (重新编辑) → undo (再次恢复)

在第二步 redo 时,execute 被再次调用,它会重新读取当前记录(此时已经是原始快照了)并再次应用编辑。这就要求 _originalSnapshot 在整个命令对象生命周期内都保持有效。不清空它确保了 redo 和后续的 undo 都能正确工作。

使用方式:

void _editWithUndo(MoodEntry original, MoodType newType, String? newNote) {
  if (original.id == null) return;

  final command = EditMoodCommand(
    storage: widget.moodStorage,
    recordId: original.id!,
    newMoodType: newType,
    newNote: newNote,
  );
  CommandHistory.instance.execute(command);
}

七、实战三:DeleteMoodCommand——删除的"后悔药"

删除操作是用户最需要"后悔药"的场景——这也是我们收到最多用户反馈的点。DeleteMoodCommand 的设计关键是在执行删除之前先将完整的 MoodEntry 快照保存到命令对象中,这样撤销时可以直接用快照重建记录。

/// 删除情绪记录的命令
///
/// 特殊处理:Hive 的 delete 操作不支持"恢复",因此撤销时用 insert 重建记录
class DeleteMoodCommand implements Command {
  final MoodStorage _storage;
  final int _recordId;

  /// 保存被删除记录的完整快照,undo 时用此快照重建
  MoodEntry? _deletedSnapshot;

  DeleteMoodCommand({
    required MoodStorage storage,
    required int recordId,
  })  : _storage = storage,
        _recordId = recordId;

  
  String get description => '已删除一条情绪记录';

  
  Future<void> execute() async {
    // 1. 在删除之前,保存记录的完整快照
    final allRecords = _storage.getAll();
    _deletedSnapshot = allRecords.cast<MoodEntry?>().firstWhere(
      (e) => e?.id == _recordId,
      orElse: () => null,
    );

    if (_deletedSnapshot == null) {
      throw StateError('DeleteMoodCommand: 找不到 id=$_recordId 的记录');
    }

    // 2. 执行删除
    await _storage.delete(_recordId);
  }

  
  Future<void> undo() async {
    if (_deletedSnapshot == null) return;

    // 用快照重建记录:注意这里会获得一个新的 ID
    // 实际上我们重新构造一个不带 id 的 MoodEntry,让 storage.insert 分配新 ID
    final rebuilt = MoodEntry(
      moodType: _deletedSnapshot!.moodType,
      note: _deletedSnapshot!.note,
      createdAt: _deletedSnapshot!.createdAt,
      updatedAt: _deletedSnapshot!.updatedAt,
    );
    await _storage.insert(rebuilt);

    // 快照保留,以支持 redo
  }
}

重建记录时 ID 会变化——这有问题吗?

当我们的 undo_storage.insert(rebuilt) 重建记录时,数据库会分配一个新的 ID。这意味着恢复后的记录和原始记录的 id 字段不同。对于情绪记录应用来说,这是完全可以接受的。原因有三:

  1. 用户不感知 ID:UI 中从不显示记录 ID,用户看到的只有情绪类型、日期和备注。
  2. 时间线按时间排序:时间线列表根据 createdAt 排序,而不是 ID。恢复的记录时间戳与原始一致,所以在时间线中的位置也一致。
  3. 统计不受影响:周统计和月统计根据 createdAtmoodType 聚合,ID 完全不参与计算。

但这里有一个潜在风险需要标注:如果未来有其他功能依赖记录 ID 进行关联(比如"情绪-呼吸记录联动"),恢复后的新 ID 会导致关联断裂。届时,我们需要在 MoodStorage 中增加一个"指定 ID 插入"的方法,或者改用 UUID 作为记录标识。


八、命令合并策略:连续输入合并为一个命令

在情绪记录的场景中,用户在备注输入框里一段接一段地打字,如果每一个字符都生成一条命令,undoStack 会瞬间被填满——用户打字 50 个字符后,所有可用的撤销历史就被替换成了单个字符的编辑操作。这显然不是用户期望的行为。

解决这个问题的方法是命令合并——将一定时间窗口内的连续同类操作合并为一条命令。

我们先梳理两个核心概念:

合并触发条件(何时合并): 当用户执行的操作与前一条操作类型相同、且发生在指定时间窗口内时,新操作不创建新命令,而是与栈顶命令合并。

合并方式(如何合并): "合并"意味着用新操作更新栈顶命令对象的内部状态,但不改变栈的结构——栈顶仍是那个命令对象,只是它的 execute 结果被更新了。

对于文本编辑场景,最实用的合并策略是基于时间窗口 + 命令类型判断

/// 在 CommandHistory 中增加合并逻辑
class CommandHistory {
  // ... 之前的代码 ...

  /// 合并窗口:500 毫秒内的同类型操作会被合并
  static const Duration _mergeWindow = Duration(milliseconds: 500);

  /// 上一次命令执行的时间戳
  DateTime _lastExecuteTime = DateTime(2000);

  /// 执行命令,支持自动合并
  Future<void> execute(Command command, {bool allowMerge = true}) async {
    if (_isExecuting) return;
    _isExecuting = true;

    try {
      final now = DateTime.now();
      final canMerge = allowMerge &&
          _undoStack.isNotEmpty &&
          command.runtimeType == _undoStack.last.runtimeType &&
          now.difference(_lastExecuteTime) < _mergeWindow;

      if (canMerge) {
        // 合并模式:用新命令替换旧命令
        // 但旧命令的 execute 已经执行过了,我们需要:
        // 1. 先撤销旧命令
        // 2. 执行新命令
        // 3. 将新命令放到旧命令的位置
        final oldCommand = _undoStack.removeLast();
        await command.execute();
        _undoStack.add(command);
        // 注意:这里没有 undo 旧命令,因为"合并"意味着旧操作的结果
        // 应该被新操作覆盖,而不是被撤销
      } else {
        // 标准模式
        await command.execute();
        _undoStack.add(command);
        _redoStack.clear();
        while (_undoStack.length > maxStackSize) {
          _undoStack.removeAt(0);
        }
      }

      _lastExecuteTime = now;
    } finally {
      _isExecuting = false;
    }
  }
}

合并策略的三种实用变体:

合并策略 适用场景 实现方式 用户体验
时间窗口合并 文本输入、滑动调节器 500ms 内同类型命令替换栈顶 撤销=回滚到 500ms 前的状态
语义合并 批量删除、批量标记 多个操作共享同一个 inverse 逻辑 撤销=一次性恢复所有被删除的条目
无合并 单条新增、单条删除 每条操作独立建命令 撤销=一步一步精确回退

对于情绪记录的备注编辑场景,我们推荐使用时间窗口合并。用户在 500 毫秒内的连续输入被视为"一次编辑操作",撤销时会一次性恢复到 500 毫秒窗口开始之前的状态。这个 500 毫秒的阈值是通过实际体验测试得出的——少于 300 毫秒会让快速打字的人产生多次可撤销步骤(不直觉),多于 800 毫秒会让撤销的粒度太粗(一句话删了大半)。

对于批量删除场景(比如一次性删除某一周的所有记录),语义合并更合适。此时所有要删除的记录 ID 被收集到一个列表中,DeleteBatchCommand 持有一个 List<int> deletedIds,撤销时一次性全部重建。


九、带 SnackBar 的撤销交互设计

撤销功能的用户体验不只在于后台的命令管理,更在于用户如何感知它。Material Design 推荐使用 SnackBar 来承载"轻量撤销"操作——一条简短的提示,配一个操作按钮。

E-Brufen 的 SnackBar 撤销交互设计如下:

/// 显示带撤销按钮的 SnackBar
///
/// 特点:
/// 1. 显示时间较长(5 秒),给用户足够的反应时间
/// 2. 撤销按钮使用 Material 3 风格
/// 3. 支持键盘快捷键(Ctrl+Z / Cmd+Z)
/// 4. SnackBar 消失后自动丢弃命令(可选:防止"过期撤销")
void _showUndoSnackBar(BuildContext context, String actionDescription) {
  final messenger = ScaffoldMessenger.of(context);

  // 先清除可能残留的上一个 SnackBar
  messenger.hideCurrentSnackBar();

  final snackBar = SnackBar(
    content: Text(actionDescription),
    duration: const Duration(seconds: 5),
    behavior: SnackBarBehavior.floating,
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
    margin: const EdgeInsets.all(16),
    action: SnackBarAction(
      label: '撤销',
      textColor: AppTheme.accentColor, // E-Brufen 的主题色
      onPressed: () async {
        await CommandHistory.instance.undo();

        // 撤销成功后,显示确认提示
        if (context.mounted) {
          messenger.showSnackBar(
            const SnackBar(
              content: Text('已撤销,情绪记录已恢复'),
              duration: Duration(seconds: 2),
              behavior: SnackBarBehavior.floating,
            ),
          );
        }
      },
    ),
  );

  // SnackBar 关闭后,如果用户没有点"撤销",可以考虑清理命令
  // 但默认不做清理,保留完整的撤销历史
  messenger.showSnackBar(snackBar);
}

SnackBar 交互的四个设计细节:

  1. 显示时长设为 5 秒而非默认的 4 秒。 情绪记录的操作频率不高,用户不太可能因为 1 秒的差异感到厌烦。多出的 1 秒给了用户更多"我是不是要撤销"的思考时间。
  2. 撤销后的二次确认。 当用户点击"撤销"后,我们显示一条新的 SnackBar:“已撤销,情绪记录已恢复”。这个二次确认很重要——它让用户确信撤销操作已经生效。如果没有这个反馈,用户可能不确定是否撤销成功,从而重复点击。
  3. 使用 ScaffoldMessenger 而非旧的 Scaffold.of(context).showSnackBar Flutter 2.0+ 中,ScaffoldMessenger 是管理 SnackBar 的推荐方式。它可以在当前 Scaffold 被替换后继续显示 SnackBar,并且在显示新 SnackBar 前自动关闭旧的。
  4. 键盘快捷键的支持。 对于支持外接键盘的鸿蒙设备(如平板模式),我们额外监听 Ctrl+ZCtrl+Shift+Z 快捷键。这需要在外层 Widget 上包裹一个 ShortcutsActions 组件:
/// 在 DiaryPage 的 build 方法中包裹快捷键支持

Widget build(BuildContext context) {
  return Shortcuts(
    shortcuts: <ShortcutActivator, Intent>{
      const SingleActivator(LogicalKeyboardKey.keyZ, control: true):
          UndoIntent(context),
      const SingleActivator(LogicalKeyboardKey.keyZ,
              control: true, shift: true):
          RedoIntent(context),
    },
    child: Actions(
      actions: <Type, Action<Intent>>{
        UndoIntent: CallbackAction<UndoIntent>(
          onInvoke: (intent) => _handleUndo(intent.context),
        ),
        RedoIntent: CallbackAction<RedoIntent>(
          onInvoke: (intent) => _handleRedo(intent.context),
        ),
      },
      child: Scaffold(
        // ... 原来的 build 内容
      ),
    ),
  );
}

Future<void> _handleUndo(BuildContext context) async {
  await CommandHistory.instance.undo();
  if (context.mounted) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('已撤销 (Ctrl+Z)'), duration: Duration(seconds: 1)),
    );
  }
}

十、内存管理:命令栈的大小限制与清理策略

命令对象的内存占用主要由两部分组成:一是命令类本身的字段(通常很小,几十字节),二是快照数据。在 E-Brufen 的场景中,一条 MoodEntry 快照大约 200-500 字节(包含 JSON 字符串的 Id + 情绪类型 + 备注文本 + 时间戳)。按 50 条上限计算,undoStack 的内存占用大约在 10KB-25KB 之间——对移动设备来说微不足道。

但有两个场景会让内存占用显著增长:

  1. 备注文本极长。 如果用户写了 500 个字符的备注(maxLength: 500),一条快照的 JSON 字符串可能达到 1KB+。50 条就是 50KB+。
  2. 命令对象本身持有额外的资源。 如果未来某个命令需要持有图片数据(比如"删除带图片的情绪记录"),单条命令可能达到 MB 级别。

针对这些场景,我们设计了三级内存管理策略:

/// 命令历史的内存管理配置
class CommandMemoryConfig {
  /// 最大命令数(硬限制,超出后移除最旧的)
  static const int maxCommandCount = 50;

  /// 单条快照的最大字符数(超过后截断备注文本)
  static const int maxSnapshotNoteLength = 200;

  /// 命令的最大生命周期(超过后自动从栈中移除)
  static const Duration maxCommandAge = Duration(minutes: 30);

  /// 总快照数据的最大内存估算(字节),超过后清空最旧的命令直到低于阈值
  static const int maxTotalSnapshotBytes = 256 * 1024; // 256KB
}

对应的清理逻辑集成在 CommandHistory 中:

/// 在 execute 之后调用,检查并执行清理策略
void _enforceMemoryLimit() {
  // 策略一:数量限制
  while (_undoStack.length > CommandMemoryConfig.maxCommandCount) {
    _undoStack.removeAt(0);
  }

  // 策略二:生命周期限制
  final cutoff = DateTime.now().subtract(CommandMemoryConfig.maxCommandAge);
  _undoStack.removeWhere((cmd) {
    // 需要命令对象暴露时间戳
    if (cmd is TimestampedCommand) {
      return cmd.executedAt.isBefore(cutoff);
    }
    return false;
  });

  // 策略三:总内存估算限制
  int estimatedBytes = _estimateTotalSnapshotBytes();
  while (estimatedBytes > CommandMemoryConfig.maxTotalSnapshotBytes &&
         _undoStack.isNotEmpty) {
    _undoStack.removeAt(0);
    estimatedBytes = _estimateTotalSnapshotBytes();
  }
}

int _estimateTotalSnapshotBytes() {
  // 粗略估算:每个命令对象 + 快照数据
  int total = 0;
  for (final cmd in _undoStack) {
    total += 256; // 命令对象本身的基础开销
    if (cmd is DeleteMoodCommand) {
      total += cmd.estimatedSnapshotSize;
    } else if (cmd is EditMoodCommand) {
      total += cmd.estimatedSnapshotSize;
    }
  }
  return total;
}

鸿蒙平台的一个特殊考量: 鸿蒙系统对后台应用的内存限制比 Android 更严格。在 OpenHarmony 3.x 设备上,后台应用被分配的内存预算通常是 200-400MB(含 Flutter 引擎本身)。虽然命令快照的 256KB 在这个预算面前微乎其微,但如果应用同时在处理其他内存密集型任务(比如呼吸球动画的 Canvas 绘制缓冲),每一步内存管理都是有意义的。因此我们在 maxTotalSnapshotBytes 上设了一个相对保守的 256KB 阈值。


十一、命令历史的持久化:跨会话撤销的实现

前面的所有讨论都基于一个前提:undoStackredoStack 只存在于内存中,应用关闭后即丢失。对于情绪记录应用来说,这意味着用户关掉应用再打开后,之前的历史命令就消失了——他无法撤销上一次会话中的操作。

是否需要跨会话撤销?这取决于具体的产品场景。对于 E-Brufen,我们选择有条件地持久化:只持久化最近 5 条命令(而不是全部 50 条),以适应"用户关闭应用后不久又打开,发现刚才不小心删了什么"的场景。5 条命令的快照数据总量大约是 2.5KB,Hive 完全能胜任。

持久化的技术方案是将命令对象序列化为 JSON 存入 Hive:

/// 可序列化的命令基类,扩展了 Command 接口
abstract class SerializableCommand extends Command {
  /// 将命令序列化为可存储的 JSON
  Map<String, dynamic> toJson();

  /// 将命令的类型标识(用于反序列化时的工厂分发)
  String get commandType;
}

/// 命令历史持久化管理器
class CommandHistoryPersistence {
  static const String _boxName = 'command_history';
  static const String _key = 'persisted_commands';
  static const int _maxPersistedCommands = 5;

  static Future<void> save(CommandHistory history) async {
    final box = await Hive.openBox(_boxName);

    final toSave = <Map<String, dynamic>>[];
    // 只保存最近 5 条 undoStack 中的命令
    final commandsToSave = history._undoStack
        .whereType<SerializableCommand>()
        .take(_maxPersistedCommands);

    for (final cmd in commandsToSave) {
      toSave.add({
        'type': cmd.commandType,
        'data': cmd.toJson(),
      });
    }

    await box.put(_key, jsonEncode(toSave));
  }

  static Future<void> restore(CommandHistory history) async {
    final box = await Hive.openBox(_boxName);
    final raw = box.get(_key);

    if (raw == null || (raw is String && raw.isEmpty)) return;

    final List<dynamic> saved = jsonDecode(raw is String ? raw : raw.toString());
    for (final item in saved.reversed) {
      final type = item['type'] as String;
      final data = item['data'] as Map<String, dynamic>;

      final command = _deserializeCommand(type, data);
      if (command != null) {
        // 恢复时不重新执行命令——它们已经在之前的会话中执行过了
        history._undoStack.add(command);
      }
    }
  }

  static SerializableCommand? _deserializeCommand(
      String type, Map<String, dynamic> data) {
    // 注意:反序列化时需要注入 MoodStorage 实例
    // 这意味着持久化的命令对象不包含 storage 引用,
    // 需要在恢复时从全局 DI 容器中获取
    switch (type) {
      case 'AddMoodCommand':
        return AddMoodCommand.fromJson(data);
      case 'EditMoodCommand':
        return EditMoodCommand.fromJson(data);
      case 'DeleteMoodCommand':
        return DeleteMoodCommand.fromJson(data);
      default:
        return null;
    }
  }
}

持久化恢复的关键问题:MoodStorage 依赖注入。 命令对象在持久化时不能将 MoodStorage 引用一起序列化(ChangeNotifier 不可序列化)。因此在恢复时,需要通过全局的依赖注入容器(比如一个简单的 ServiceLocator)重新获取 MoodStorage 实例。这也是为什么命令对象的 fromJson 工厂方法不会在构造函数中接收 storage——而是在恢复后通过一个 bindStorage 方法注入:

// 恢复流程的完整代码
Future<void> _restoreCommandHistory() async {
  await CommandHistoryPersistence.restore(CommandHistory.instance);

  // 为恢复的命令绑定 MoodStorage 实例
  final storage = ServiceLocator.instance.get<MoodStorage>();
  for (final cmd in CommandHistory.instance._undoStack) {
    if (cmd is BindableCommand) {
      (cmd as BindableCommand).bindStorage(storage);
    }
  }
}

跨会话撤销的边界条件:

场景 行为 原因
用户关闭应用后 10 分钟重新打开 可以撤销上次操作的 5 条命令 持久化覆盖了这个时间窗口
用户关闭应用后 3 天重新打开 命令不恢复 超过 maxCommandAge(30 分钟),恢复时被过滤
用户切换了 Hive 存储路径 命令不恢复 Box 路径变化,持久化数据找不到
用户在恢复后又执行了新操作 新操作正常加入栈,旧命令从底部被挤出 数量上限仍然生效

十二、鸿蒙平台的兼容性说明

命令模式是纯 Dart 代码实现的设计模式,不依赖任何平台特定 API,因此在鸿蒙平台上不需要任何特殊适配。以下是在鸿蒙环境下测试验证的几个关键点:

Hive CE 的兼容性确认。 命令持久化使用 Hive CE(Community Edition),该版本是 Hive 的社区维护分支,明确支持 OpenHarmony。在 pubspec.yaml 中配置如下:

dependencies:
  hive_ce: ^1.0.0

注意不要混用 hivehive_ce——前者不支持鸿蒙,后者是专门的社区版。如果在项目中同时引入了两个包(比如某个第三方库间接依赖了 hive),会导致运行时的 MissingPluginException

异步操作的平台差异。 鸿蒙的 IO 操作延迟在大多数场景下与 Android 持平。但在低端鸿蒙设备(如搭载 OpenHarmony 3.x 的入门级设备)上,Hive 的 put 操作偶尔会出现 50-100ms 的额外延迟。这是因为鸿蒙的分布式文件系统在后台执行同步检查。对于命令模式的 execute/undo 来说,这种延迟不会影响正确性——CommandHistory 使用 await 等待每次操作完成,保证了操作顺序。

内存限制差异。 前文提到的 256KB 快照内存限制是基于鸿蒙设备通常的内存配置(2GB-4GB)设定的。如果你在更高配置的设备上运行(如 8GB 以上的鸿蒙平板),可以将 maxTotalSnapshotBytes 提高到 512KB,以容纳更多历史命令。

键盘快捷键的鸿蒙特有行为。 鸿蒙平板模式下,外接键盘的 Ctrl+Z 映射与 Android 一致,使用的都是 Flutter 的 LogicalKeyboardKey 抽象层。不需要针对鸿蒙做特殊按键映射。

下面是一个完整的鸿蒙兼容性测试清单:

测试项 鸿蒙手机 鸿蒙平板 备注
AddMoodCommand 执行与撤销 通过 通过 Hive CE 读写正常
EditMoodCommand 快照与回滚 通过 通过 长备注(500 字)序列化正常
DeleteMoodCommand 删除与恢复 通过 通过 恢复后的 ID 变化不影响 UI
命令合并(500ms 窗口) 通过 通过 计时器精度 10ms+
持久化保存与恢复 通过 通过 JSON 序列化跨平台一致
Ctrl+Z / Ctrl+Shift+Z 快捷键 N/A 通过 手机无外接键盘场景
50 条命令上限清理 通过 通过 无内存异常
应用挂起后恢复(生命周期) 通过 通过 Hive Box 正常 reopen

十三、总结

命令模式在 E-Brufen 情绪记录模块中的应用,解决了一个看似简单实则棘手的用户体验问题:“用户不小心删了一条记录,怎么办?”

我们从三个维度给出了完整的解决方案:

设计维度: 采用 GoF 命令模式,将每一个用户操作封装为独立的命令对象。通过 Command 抽象接口(execute + undo + description)统一了新增、编辑、删除三种操作的撤销逻辑。

架构维度: 引入 CommandHistory 单例作为中央调度器,用双栈法(undoStack + redoStack)管理命令的生命周期。定义了三条核心规则——新操作清空 redoStack、栈大小上限、快照数据由命令对象自管理——确保了多入口操作下的状态一致性。

工程维度: 实现了命令合并(500ms 时间窗口)、带 SnackBar 的撤销交互(5 秒显示 + 二次确认反馈)、三级内存管理(数量/时间/大小限制)、以及通过 Hive 的条件持久化(最近 5 条命令)支持跨会话撤销。

对于 Flutter/鸿蒙开发者来说,命令模式是一个值得加入工具箱的设计模式。它不仅适用于情绪记录,还可以扩展到以下场景:

  • 文本编辑器(Undo/Redo 是文本编辑器的标配)
  • 表单填写(回退到填写前的状态)
  • 列表拖拽排序(撤销排序操作)
  • 设置页面的批量修改(一键恢复默认设置)

在实现命令模式时,务必记住两个核心原则:在 execute 中保存撤销所需的完整上下文,以及永远在 execute 之前做快照。这两条原则是命令模式正确性的基石,任何违反它们的实现都会在某个边界条件下暴露出 Bug。


作者简介

E-Brufen Dev,全栈开发者,专注于 Flutter 跨平台与鸿蒙 HarmonyOS 生态。独立开发了 E-Brufen(情绪健康助手)应用,从零到一完成设计、开发、测试与 AppGallery 上架。擅长用设计模式解决实际工程问题,信奉"代码的可维护性 = 团队的生产力"。技术博客持续更新于 AtomGit。
项目源码https://gitcode.com/PengXiansheng/E-Brufen

博客声明: 本文为原创内容,未经许可禁止转载。文中代码基于 E-Brufen 项目的实际架构,部分细节为教学目的做了简化处理。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐