成为全栈·Flutter App 篇·会员中心:资料、密码、通知与私有数据缓存

会员中心是整个 App 里私有数据最集中的一页。我原本以为它最简单------一个列表,几行用户信息,几个设置项。

真正开始做之后,发现它有三个问题,一个比一个隐蔽:

  1. 页面上要显示的数字,有些接口给总数,有些不给。
  2. 用户退出登录后,这些数据必须一个字节都不留。
  3. 改密码这件事,后端和客户端的职责边界很容易搞错。

这篇讲 features/data/member_overview.dart 那七十四行,以及它逼我做出的几个决定。

为了四个数字,发了四个请求

会员中心顶部要显示四个数字:收藏数、点赞数、阅读历史数、稿件数。

第一版我以为可以一个接口全返回。实际不行------这四个数据分别对应四个不同的列表端点:

dart 复制代码
final results = Map.fromEntries(
  await Future.wait(
    {
      'favorites': Endpoints.meFavorites,
      'history': Endpoints.meHistory,
      'articles': Endpoints.meArticles,
    }.entries.map(
      (entry) async => MapEntry(
        entry.key,
        await read(entry.value, query: {'page': 1, 'pageSize': 3}),
      ),
    ),
  ),
);

三个接口并发发(Future.wait),每个只取 3 条------因为这里只需要总数,不需要内容。

用 Map 加命名而不是 Future.wait 返回的 List,是为了让后面取值时不用靠下标:

dart 复制代码
'favorites': results['favorites']['pagination']['total'],

如果用 List,results[0] 和 results[1] 哪个是哪个,全靠记------改一次顺序就错,而且不会报错。 这就是注释里说的"避免界面与接口靠数组顺序耦合"。

用命名 key 的话,加一个端点就只是多一行 Map 条目,其他代码不用动。

注意这三个请求传的是 pageSize: 3 而不是 1。因为有的接口会按 pageSize 算什么吗?不会------但返回 3 条和 1 条的响应体积差不了多少 ,而万一某个接口的 total 是在截断之前算的(这是合理的实现),取 1 条也没问题。取 3 条纯粹是为了给一点余量。

那个必须逐页扫描的接口

现在说第四个数字:点赞数。

前三个都能从 pagination.total 直接拿到,点赞不行。

dart 复制代码
// 点赞接口没有总数,必须逐页扫描,不能把首屏长度当成累计数量。
int likes = 0, page = 1;
while (true) {
  final batch = await read(
    Endpoints.meLikes,
    query: {'page': page, 'pageSize': 100},
  ) as List;
  likes += batch.length;
  if (batch.length < 100) break;
  page++;
}

这是一个 while 循环,一直翻页直到某次拿到的条数少于 100。

为什么会这样? 因为点赞列表在契约里就是裸数组 ------M4-07 讲过,四种响应形状之一。它没有 pagination 包装,因为产品上"我点赞过的文章"是一个有限的集合,不需要分页体验。

但"不需要分页体验"和"不需要总数"是两回事。服务端可以直接返回数组长度,但那需要额外一次 count 查询;而返回裸数组本身是零成本的。

于是客户端就得自己数。数的方式就是把所有页翻一遍。

这个问题让我重新想了一遍"服务端返回什么"。 一开始我觉得裸数组是合理设计,现在意识到它有个隐藏成本:客户端想显示总数时,必须付出 N 次请求。

如果点赞记录超过一万条,这个循环会发一百次请求------在用户点进会员中心的那一刻。

现在的应对:

措施 做法
页大小拉到最大 pageSize: 100(契约允许的上限)
结果缓存 整个概览缓存 1 分钟新鲜、5 分钟过期
只驻留内存 不落盘,避免下次启动又扫一遍

缓存这条尤其重要------用户从会员中心返回再进去,不会重新扫。 而这个缓存是私有的,键里带账号和会话代次(M4-09),所以不会串号。

我知道这个循环不优雅。更理想的做法是服务端加一个 count 端点,或者给点赞列表也套上分页。 但那是契约变更,牵动后端和七个端,得单独排期。现在先把这个已知的性能债记下来,而不是假装它不存在。

而那句注释------"不能把首屏长度当成累计数量"------是我踩过的坑。当时想省事,第一屏返回 3 条,我就用 batch.length 当点赞数。结果用户有 47 个赞,页面显示 3。"看起来有个数"比"没有数"更危险,因为用户不会怀疑它,只会以为系统统计错了。

概览的缓存策略:不落盘

dart 复制代码
// 概览只驻留内存,键包含账号与会话代际。
final k = key(CacheKeys.overview, const {}, private: true);
...
const CachePolicy(Duration(minutes: 1), Duration(minutes: 5)),

新鲜 1 分钟、过期 5 分钟,而且不落盘。

新鲜期短是因为那几个数字本身在变(用户可能刚在别处收藏了)。过期 5 分钟是因为------超过 5 分钟,用户早就离开这个页面了,再精确也没有意义。

不落盘的理由和 M4-14 的阅读历史一样 :私有数据落盘要处理"用户卸载前的数据会不会残留",成本高于收益。而且概览数据可以重新拉(虽然贵),但留在磁盘上的用户数据是不该有的。

而 tags: {'private', 'overview'} 那一行是有用的:它让别处的写操作能精准失效这个缓存。

dart 复制代码
ResourceFamily.reaction => {
  'reactions:${Endpoints.articleId(path)}',
  Endpoints.meLikes,
  'overview',          // ← 点赞一下,会员中心的数字要变
},

用户在这篇文章页点了赞,切到会员中心,那个数字应该已经更新了。 overview 标签让这次失效自动发生,不需要手动刷新。

退出登录:什么都要清

现在讲第二个问题,也是一个安全问题。

会员中心的数据全是私有的:收藏、点赞、历史、稿件。用户退出登录后,这些必须全部消失。

dart 复制代码
// 会话隔离清理不影响公开资源,也不触及按账号保存的投稿草稿。
void resetPrivate() {
  CodeCache.clear();          // 代码高亮缓存
  images.resetPrivate();      // 私有图片
  favoriteIndex.resetFavoriteIndex();
  reactions.clear();
  favoriteIndex.overrides.clear();
  snapshots.removeWhere((_, s) => s.tags.contains('private'));
  cache.invalidate({'private'});
  reactionStore.events.add(-1);   // ← 这个数字是信号
}

八行代码,逐个对应一类私有数据。但有两个细节值得说。

一、公开数据不清。 cache.invalidate({'private'}) 只清带 private 标签的。文章正文、分类列表这些公开缓存留着------退出登录不该让用户重新下载一遍公开内容。

但有个例外要注意:文章正文缓存里可能含用户相关信息。 比如"收藏"按钮的状态、meLikes 里的文章条目。

images.resetPrivate() 和 CodeCache.clear() 就是处理这类"混在公开结构里的私有数据"的。M4-13 讲过 public 标志的作用------私有预览的内容不进共享缓存。这些方法是对那个设计的清理侧。

二、reactionStore.events.add(-1) 里的 -1 不是文章 id。

这是个约定:负数表示"全局失效,所有订阅者都应该重新读一次"。

为什么不直接发事件?因为清缓存之后,订阅的页面需要知道"你之前拿到的数据全作废了"。但它不知道该监听哪个 id------收藏、点赞、稿件各自的 id 列表都不一样,发四个事件不如发一个"全作废"。

而订阅方处理负数的逻辑,就是"忽略参数,重新从 store 读"。这个约定的价值在于:它把"清理"和"通知"合成了一步,不会漏掉通知。

改密码:客户端不知道该做什么

第三个问题,关于改密码。

第一版我在提交后直接提示"修改成功",然后发现用户改完密码还能继续用旧会话操作。

原因在协议层:改密码之后服务端应该让当前会话失效。 用户得重新登录。

dart 复制代码
class _PasswordFields extends StatelessWidget {
  ...
  return Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      TextFormField(
        controller: oldPassword,
        obscureText: true,
        decoration: const InputDecoration(labelText: '当前密码'),
        validator: (v) => v!.length < 8 ? '至少 8 个字符' : null,
      ),
      const SizedBox(height: 16),
      TextFormField(
        controller: newPassword,
        obscureText: true,
        decoration: const InputDecoration(labelText: '新密码'),
        validator: (v) => v!.length < 8 ? '至少 8 个字符' : null,
      ),
      const SizedBox(height: 16),
      const Text('修改后需要重新登录。'),
    ],
  );
}

注意最后那句提示:"修改后需要重新登录"是常驻在表单里的,不是提交后才弹的。

这不是文案细节。改密码是个有副作用的操作 ------它会让用户的当前登录态失效,可能让他正在做的事(比如一篇没保存的稿件)丢失。所以必须在用户点提交之前就告诉他,而不是事后才说。

这个判断的通用形式是:如果一个操作会导致用户失去某些东西,就要在执行前告知,而不是执行后补救。

还有一点:这个组件不执行修改请求 ,它只是表单和校验。真正的提交在 profile_page.dart 里。

为什么要拆开?因为校验规则和请求逻辑的变化频率不同 。密码规则(M1 定的 bcrypt + 最小长度)是稳定的产品决策,将来可能加复杂度要求;请求逻辑(带 token、错误处理、成功后清会话)是技术实现。混在一起的话,改其中一个要动整个组件。

通知:那个红点其实是个独立请求

会员中心还有一个通知图标,带未读数红点。

这个红点的实现有个选择:是每次进会员中心时请求一次 ,还是全局常驻?

现在是后者,因为用户可能在任何页面点通知:

dart 复制代码
IconButton(
  tooltip: '通知',
  onPressed: () => context.push('/member/notifications'),
  icon: const UnreadIcon(Icons.notifications_none),
)

UnreadIcon 自己去读未读数,走的是 Endpoints.unreadCount 这个独立端点。

为什么不把它塞进概览接口? 试过。因为通知在每一个页面都要显示,而概览只在会员中心用。如果塞进去,就要么让每个页面都请求概览(浪费),要么让概览缓存一直留着(数据会旧)。

一个独立的小端点反而更合理------它响应体极小(就一个数字),而且可以在布局阶段就拿到,不阻塞页面渲染。

而在缓存策略表里,它是个特例:

dart 复制代码
void validate(String path, dynamic value) {
  final shape = switch (path) {
    Endpoints.siteSettings || Endpoints.unreadCount => _Shape.object,
    _ => _rules[family(path)]?.shape ?? _Shape.object,
  };

未读数没有在策略表里登记 ,所以要在这里特判。为什么不登记?因为它需要极短的新鲜期(5 秒级别),而其他私有没有一个适合的:

dart 复制代码
ResourceFamily.notifications: _ReadRule(
  CachePolicy(Duration(seconds: 15), Duration(minutes: 1)),
  _Shape.page,
  tags: {'private', 'notifications'},
),

通知列表本身(meNotifications)缓存 15 秒是因为用户要翻阅它 ;未读数只是个角标,缓存 15 秒的话,用户点了三条通知回来红点还亮着------角标失灵比通知列表不新更让人恼火。

这一页的数据清单

汇总一下会员中心涉及的数据:

数据 来源 缓存 私有 落盘
收藏/历史/稿件数 三个列表的 total 5 分钟 是 否
点赞数 逐页扫描裸数组 5 分钟 是 否
未读数 独立小端点 极短 是 否
用户资料 me 端点 较长 是 否
头像 图片缓存 较长 是 否

五类数据,五种策略。 而真正需要特殊处理的只有一个------点赞数,因为它没有总数。

这个观察挺有意思:大部分"看起来麻烦"的需求,麻烦的原因是服务端返回的形式,而不是业务逻辑本身。 如果点赞接口返回分页对象,这一页会简单一大半。

一条我一开始想省掉的事

点赞数那个 while (true) 循环,我一开始加了个请求数上限,怕无限循环:

dart 复制代码
int likes = 0, page = 1;
while (page <= 100) {          // 最多翻 100 页
  ...
}

后来去掉了,因为它掩盖了问题 :如果有 bug 导致接口总是返回满 100 条,这个循环会跑 100 次然后静默停在一个错误的数上,而没有任何提示。

去掉上限之后,如果接口真有问题,循环会一直跑------用户看到的是一个永远转圈的加载条 。这确实比显示一个错数字更糟,但它是诚实的失败。

现在的判断是:去掉上限,因为这个循环的退出条件是可靠的(batch.length < 100),而依赖它的那个上限只是防我自己的 bug 。如果真要防,更好的做法是加一个大的上限(比如 1000 页)并且在触顶时抛异常,而不是静默返回。

顺带说,batch.length < 100 这个判断依赖"最后一次一定不足页"这个假设。如果服务端某次恰好返回了正好 100 条(而实际已经没有了),就会多发一次请求,拿到空数组,然后正确退出。多一次请求,无害。 反过来如果返回了少于 100 条却还有更多数据,就会提前退出、少算了------那才是问题。所以判断用 < 而不是 ==,在服务端分页语义下是安全的。

小结

会员中心这一页,技术上是最普通的页面,但它暴露了三个容易忽略的点:

  1. 接口返回什么形式,直接决定了客户端要付多少代价。 点赞数要逐页扫描,根源是裸数组没有 total。
  2. 私有数据的清理必须穷举。 八行 resetPrivate 对应八类数据,漏一类就是一次信息泄露。
  3. 会造成用户损失的操作要在执行前告知。 "改密码后需要重新登录"必须常驻在表单里,不能事后弹。

第一个点是最有价值的经验:客户端的复杂度,一半来自服务端的返回形式。 契约设计时多考虑一层(比如给点赞列表也加 total),客户端就能少写很多代码。

第二个点则提醒我:"账号隔离"不是一个开关,是一组具体的清理动作。 每新增一类私有数据,都要想清楚它在内存里、在磁盘上、在图片缓存里各有一份没有。

顺带一个观察:这一篇和前面几篇反复出现同一个模式------用命名 key 而不是数组下标来传递结构化数据 (概览的 Map、CachePolicyTable 的枚举家族、错误码到文案的映射)。顺序是一种隐式的契约,一旦有人调整顺序就会静默出错,而编译器不会告诉你。

下一篇讲投稿编辑器------它是全项目唯一涉及"创建 → 编辑 → 提交 → 发布"完整状态机的页面,也是唯一要处理"用户写了很久的东西突然可能丢失"的地方。

延伸阅读


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

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

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

相关推荐
穆梓兰煊19 小时前
Flutter端云协同推理架构解析
flutter·github
FungLeo1 天前
成为全栈·Flutter App 篇·内置 WebView:网页历史、App 返回与外链安全
flutter·网络安全·移动开发·webview·路由管理·成为全栈
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·鸿蒙
FungLeo1 天前
成为全栈·Flutter App 篇·服务端目录与文章辅助阅读:锚点、上下篇、浏览量和阅读历史
flutter·锚点·阅读体验·成为全栈·markdown目录·api契约
恋猫de小郭1 天前
Flutter Android 改动:APK 不再允许通过 Intent 动态修改 Engine 参数
android·前端·flutter
安当加密03011 天前
动态口令认证如何选型
身份认证·mfa·双因素认证·动态口令认证如何选型·动态口令认证
安当加密-焱垚1 天前
身份认证凭据管理系统
身份认证·凭据管理·身份认证凭据管理系统·登录认证中如何应用凭据管理系统·身份认证中如何应用凭据管理系统