成为全栈·Flutter App 篇·Markdown 编辑器:编辑、预览与离开保护

成为全栈·Flutter App 篇·Markdown 编辑器:编辑、预览与离开保护

编辑器有两个按钮:编辑、预览。切一下。就这样。

我最初的实现就是切一下------然后发现用户抱怨"预览之后回来,我的东西没了"。

原因很蠢:我在切换时重建了 TextEditingController,而新 controller 是空的。文本还在 TextField 的渲染里,但数据源已经被换掉了。

这个 bug 让我意识到一件事------编辑器里所有的状态必须活在同一个生命周期里 ,而不是各自为政。M4-19 讲了投稿状态机,这一篇讲的是编辑器界面这一层。

编辑/预览切换:Controller 不能重建

现在看正确的做法。切换按钮只是个开关:

dart 复制代码
/// 编辑/预览切换保留同一组文本控制器,不丢失未保存内容。
class _EditorModes extends StatelessWidget {
  const _EditorModes({
    required this.preview,
    required this.length,
    required this.onMode,
  });
  final bool preview;
  final int length;
  final ValueChanged<bool> onMode;
  @override
  Widget build(BuildContext context) => Row(
    children: [
      for (final mode in [false, true])
        OutlinedButton(
          style: OutlinedButton.styleFrom(
            backgroundColor: preview == mode
                ? context.colors.brandSubtle
                : context.colors.surface,
            shape: RoundedRectangleBorder(borderRadius: AppRadius.rSm),
          ),
          onPressed: () => onMode(mode),
          child: Text(mode ? '预览' : '编辑'),
        ),
      const Spacer(),
      Text('$length / 65535', style: context.text.labelSmall),
    ],
  );
}

for (var mode in [false, true]) 这个循环同时生成了两个按钮,preview == mode 决定哪个高亮。两个按钮共享同一个 onMode 回调,状态只有一份。

而真正关键的变化在内容的组合:

dart 复制代码
AbsorbPointer(
  absorbing: busy,
  child: preview ? previewBody : form,
)

form 是常驻的,不是切换时新建的。 previewBody 也是同一个 Widget 实例的两个分支,而 TextEditingController 在编辑器页面 State 里,从 initState 活到 dispose。

所以切换只改变渲染哪一棵子树,数据源完全不动。

这个思路推广开来就是:编辑器页面里所有的状态都活在同一个生命周期里。 标题、摘要、正文、封面、分类、标签------全部是 editor.dart 那个 State 的字段。切换模式、切分类、失败重试,都没有一样东西被重建。

那个长度计数器

右上角那个 1234 / 65535 看着不起眼,它有两个作用:

一、告诉用户还剩多少。 到上限之后不能继续输入------这不是拒绝服务,而是服务端契约的限制 (content 字段最大 65535)。如果让用户写到 60000 字然后提交失败,那个报错毫无意义。

二、它是一个免费的"内容确实被输入了"的信号。 因为 length 来自 controller,所以它变了一定意味着 controller 变过。这一点在判断"内容有没有改"时可以直接用。

而 65535 这个数字应该从契约来 ,而不是写死在界面里。如果哪天服务端改成 65536,这里得跟着改。现在的做法是它写在设计稿里,改的时候记得两处一起改------这是一个已知的不足,不是完美的方案。

忙碌期为什么要锁住整个表单

AbsorbPointer(absorbing: busy) 那一行,我一开始觉得没必要。

理由很朴素:保存的时候,用户如果继续打字,那次保存存的是哪一版?

时序:用户点保存 → 请求发出(携带当时的内容)→ 请求在飞 → 用户继续打字 → 请求回来 → 界面显示"保存成功" → 但用户刚打的那段字没保存,而且他不知道。

更糟的情况是第二篇讲的那个:他在保存成功之后继续打字,然后触发离开保护 ------因为 dirty 判断是基于"和服务端的差异",而服务端刚更新过,所以这一版被判成"未修改",直接放他离开。用户刚打的字没了。

AbsorbPointer 解决的就是这个:请求期间整个表单不接受输入。

它比 IgnorePointer 更合适的地方在于:AbsorbPointer 不仅屏蔽了触摸,还让被包裹的子树不参与焦点和语义 ,对屏幕阅读器来说内容也是"不可交互"的。而 IgnorePointer 只挡触摸,可访问性上不完全。

而"忙碌"这个状态需要覆盖的不止保存 。上传图片、提交审核、切回预览重新渲染------凡是会改内容的异步操作,都算 busy。理由统一:如果用户能在请求期间改内容,那这次请求的结果和用户看到的内容就不对应。

保存按钮的三个细节

底部那条:

dart 复制代码
/// 保存和送审共享忙碌状态,避免重复写入。
class _EditorSaveBar extends StatelessWidget {
  ...
  child: Row(
    children: [
      Expanded(
        child: OutlinedButton(
          onPressed: busy ? null : () => save(false),
          child: Text(status == 'draft' ? '保存草稿' : '保存修改'),
        ),
      ),
      if (status == 'draft') ...[
        const SizedBox(width: 12),
        Expanded(
          child: SubmitButton(
            label: '提交审核',
            busy: busy,
            onPressed: () => save(true),
          ),
        ),
      ],
      if (status != 'draft' && busy)
        const Padding(
          padding: AppInsets.small,
          child: CircularProgressIndicator(),
        ),
    ],
  ),

三个细节:

一、按钮文案跟着状态变。 status == 'draft' 时是"保存草稿",其他状态是"保存修改"。因为这两种保存的语义不一样:前者是"存在草稿箱里",后者是"更新我的稿件"。

二、"提交审核"按钮只在草稿状态存在。 这和 M4-19 的 actionsFor 是同一套规则,只是落在界面层。待审核和已发布状态不显示它,因为不能重复提交。

而"保存修改"在任何非草稿状态都显示------待审核能改,已发布也能改(M4-19 讲过,改已发布的会重新进审核)。

三、busy 时显示转圈的位置不一样。 草稿状态有两个按钮,SubmitButton(busy: busy) 自带转圈;非草稿状态只有一个按钮,所以单独显示一个 CircularProgressIndicator。

这个差异是外观问题,但它反映了一个真实区别:草稿状态的保存栏有两个可能触发的动作,需要在两个按钮上都表达忙碌;非草稿状态只有一个按钮需要。

而 onPressed: busy ? null : ... 这个写法保证了两个按钮共享同一个 busy 状态,不可能一个在转圈另一个还能点。注释说的就是这个------避免重复写入。

离开保护:什么时候该拦,什么时候该放

M4-19 提过 PopScope 的三个条件,这里补上"什么时候算脏"的判断标准。

dart 复制代码
canPop: allowLeave || (!dirty && !busy),

dirty 不是"用户碰过键盘",而是当前内容和服务端内容实际有差异。

这个区别很重要。设想用户打开编辑器、随手点了几下又全部撤销------内容和服务端一样,不该拦。 如果用"碰过就脏"的判断,用户会频繁看到"确定离开吗",然后学会无脑点确定------拦截提示一旦被用户无视,就等于没有。

而比较的对象是服务端那份,不是进页面时的那份。因为服务端可能在用户编辑期间变了(M4-19 讲的并发场景)。

allowLeave 那一条更微妙:保存成功后要给一条出路。

用户点保存 → 成功 → 服务端内容更新 → dirty 自动变成 false → 可以直接退。

但如果保存过程中状态更新有延迟(比如 allowLeave 是在 finally 里清的),就会有一个瞬间:请求成功了、内容显示"已保存"、但 dirty 还没重算。这时候用户退出,会被一个不该出现的弹窗拦住。

所以 allowLeave 是个显式标志位,在保存成功的分支里设置。它和 dirty 分工不同:

含义 谁来清
dirty 内容和服务端有差异 每次内容变化时重算
allowLeave 刚刚保存成功,这次可以走 保存成功时置位,下次内容变化时清零

为什么要分开? 因为"内容没差异"和"用户刚保存过"是两种不同的状态。前者是事实,后者是意图。而离开保护拦的是意图------用户在保存成功后还想走,这本身没有问题,不该被拦。

上传失败:正文必须留着

编辑器里有个图片上传。失败的时候:

dart 复制代码
if (uploadFailed)
  ListTile(
    title: const Text('图片上传失败,正文已保留'),
    trailing: TextButton(
      onPressed: busy ? null : retryUpload,
      child: const Text('重试'),
    ),
  ),

那条 ListTile 插在表单和保存栏之间,不占内容的空间,但足够显眼。

"正文已保留"这四个字是刻意加的。

因为用户遇到上传失败时的第一反应是"完了,白写了"。如果不告诉他正文还在,他可能会选择放弃------重新读一遍自己刚写的东西,比多点一下重试麻烦得多。

而这个提示还带了一个"重试"按钮。因为上传失败通常是可以重试的(网络抖动、图片太大被拒),而不是必须重新操作整个流程。

顺带说图片上传的客户端校验:

dart 复制代码
final ext = file.name.split('.').last.toLowerCase();
const types = {
  'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
  'webp': 'image/webp', 'gif': 'image/gif',
};
if (!types.containsKey(ext)) {
  throw const ApiFailure('请选择 PNG、JPG、GIF 或 WebP 图片');
}
if (await file.length() > 10 * 1024 * 1024) {
  throw const ApiFailure('图片不能超过 10MB');
}

在发请求之前就拦住。 三个点:

  • 扩展名白名单,同时用于推导 MIME type------不信任客户端给的头,用文件名推断
  • toLowerCase() :.PNG 和 .png 是同一个文件,用户不该因为大小写被拒
  • 大小限制在本地做,不让用户传完 20MB 上传完才发现超限

而抛出的是 ApiFailure------复用了 M4-08 那个错误类型。所以它会自动走到统一的错误展示路径,不需要单独处理。

这个页面的组件拆分

看这几段代码,它们都是 part of '../editor.dart':

dart 复制代码
part 'editor/editor_save_bar.dart';
part 'editor/editor_modes.dart';
part 'editor/editor_body.dart';
part 'editor/recover_draft.dart';

用 part 而不是独立文件,是因为它们都要访问 editor 那个 State 里的私有字段------status、busy、preview、form、previewBody。

M4-17 讲过这个取舍:part 文件不能自己 import,也不能独立测试。 但这里四个组件共享同一批状态参数,拆成独立文件的话每个 build 都要传七八个参数,签名比代码还长。

我接受的判断标准是:当一个组件的状态来源就是父类的私有字段时,用 part。 因为那些字段本来就不该被外部访问,用 part 恰好保持了这种私有性。

而 editor_body.dart 那个 AbsorbPointer 的位置------包住内容和表单,但不包括保存栏。这也是有意的:

dart 复制代码
Expanded(
  child: SingleChildScrollView(
    padding: AppInsets.page,
    child: AbsorbPointer(absorbing: busy, child: preview ? previewBody : form),
  ),
),
saveBar,

保存栏在外面,所以它自己的 onPressed: busy ? null 生效------忙碌时按钮禁用,但表单区域的输入也被锁。两个层次都挡住了。

验收该看什么

编辑器是交互最重的页面,测试重点在"状态转换"而不是"渲染":

场景 该验证
编辑 → 预览 → 编辑 内容完全一致
预览状态下保存 保存的是编辑的内容
保存中尝试输入 输入被忽略
保存成功后立刻退出 不弹离开确认
改了又撤销 不算 dirty
草稿状态 只显示"提交审核"
待审核状态 不显示"提交审核"
上传失败 正文还在,有重试按钮
内容超过上限 拦在客户端

第一行"编辑 → 预览 → 编辑"就是这次修的那个 bug 的回归测试。它现在是一个显式用例,而不是靠手动点两下确认。

一条我一开始想省掉的事

dirty 的计算,我最初写成"只要用户输入过就标记为脏"。

结果测试时发现一个问题:我打开编辑器,随便按了个空格然后删掉,测试失败了------因为它被标记成 dirty。

修的时候顺手想明白了:这个标记的用途是"决定要不要拦用户",那么它的准确度就比速度重要。 多拦一次,用户就会开始无脑忽略;少拦一次,用户丢一次内容。少拦的代价大得多,但也不能多拦。

所以现在的判断是逐字段比较当前值和基线值。慢一点(几个字符串比较),但准确。

顺带一个相关的判断:基线值什么时候更新? 每次保存成功后更新,而不是每次加载时更新。因为如果保存成功后用户又改了一版,然后又保存------第二次保存失败,那么基线应该是"上次成功保存的内容",而不是"第一次保存的内容"。

allowLeave 这个标志位的逻辑也是这样:它在内容变化时必须被清掉。 如果用户在保存成功后立刻改了点什么然后尝试退出,allowLeave 如果还在,就放他走了------而这次修改没保存。

现在它在 controller 的监听回调里被重置。这个位置不好找,是我在 review 的时候被提醒的:"这个标志在哪清?" 答案是"在任何非程序化的内容变化时"。

小结

编辑器这一页的技术点不多,但它们都在"状态生命周期"这一个主题上:

  1. 切换模式不重建状态 ------ Controller 常驻,只切换渲染哪棵子树。
  2. 忙碌期锁输入 ------ 否则请求期间的操作会导致"保存的版本和看到的版本不一致"。
  3. 离开保护用差异判断,不用意图判断 ------ 因为拦截提示一旦被无视就等于没有。
  4. 失败提示要告诉用户"什么都没丢" ------ "正文已保留"四个字比一个错误图标有用得多。

第二条(忙碌期锁输入)是我认为最重要的一条,因为它防的不是崩溃,而是一种静默的数据丢失:用户继续打字 → 保存成功 → 界面显示已保存 → 那些字其实没保存。

这和 M4-19 的 draftWrite 串行链是同一个问题的两面:在编辑器里,任何"用户以为保存了但其实没有"的情况,比任何报错都严重。

下一篇讲图片上传------它是编辑器里最容易出问题的部分,涉及原生文件选择、大小校验、进度回调和失败恢复。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
传奇开心果编程2 小时前
【Compose Multiplatform跨端声明式UI实用技术】第3课:Compose Hot Reload 与开发效率工具链
移动开发·编程语言理论·热重载·开发范式·编程框架·声明式ui·效率工具链
FungLeo2 小时前
成为全栈·Flutter App 篇·会员中心:资料、密码、通知与私有数据缓存
flutter·dart·身份认证·数据隔离·成为全栈·会员中心
穆梓兰煊20 小时前
Flutter端云协同推理架构解析
flutter·github
FungLeo1 天前
成为全栈·Flutter App 篇·内置 WebView:网页历史、App 返回与外链安全
flutter·网络安全·移动开发·webview·路由管理·成为全栈
传奇开心果编程1 天前
【Compose Multiplatform跨端声明式UI实用技术】第4课:Composable 函数与声明式 UI 思维
移动开发·编程语言理论·开发范式·编程框架·函数式思维·声明式ui·声明式ui思维
m0_738185821 天前
Flutter 鸿蒙化实战:volume_controller 适配 OpenHarmony,系统音量控制
flutter·华为·harmonyos·鸿蒙
FungLeo1 天前
成为全栈·Flutter App 篇·Flutter Markdown 阅读器:支持范围与渲染边界
flutter·markdown·dart·代码高亮·成为全栈·移动阅读
m0_738185821 天前
Flutter 鸿蒙化实战:webview_cookie_manager 适配 OpenHarmony,WebView Cookie 管理
flutter·华为·harmonyos·鸿蒙