会员中心是整个 App 里私有数据最集中的一页。我原本以为它最简单------一个列表,几行用户信息,几个设置项。
真正开始做之后,发现它有三个问题,一个比一个隐蔽:
- 页面上要显示的数字,有些接口给总数,有些不给。
- 用户退出登录后,这些数据必须一个字节都不留。
- 改密码这件事,后端和客户端的职责边界很容易搞错。
这篇讲 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 条却还有更多数据,就会提前退出、少算了------那才是问题。所以判断用 < 而不是 ==,在服务端分页语义下是安全的。
小结
会员中心这一页,技术上是最普通的页面,但它暴露了三个容易忽略的点:
- 接口返回什么形式,直接决定了客户端要付多少代价。 点赞数要逐页扫描,根源是裸数组没有 total。
- 私有数据的清理必须穷举。 八行
resetPrivate对应八类数据,漏一类就是一次信息泄露。 - 会造成用户损失的操作要在执行前告知。 "改密码后需要重新登录"必须常驻在表单里,不能事后弹。
第一个点是最有价值的经验:客户端的复杂度,一半来自服务端的返回形式。 契约设计时多考虑一层(比如给点赞列表也加 total),客户端就能少写很多代码。
第二个点则提醒我:"账号隔离"不是一个开关,是一组具体的清理动作。 每新增一类私有数据,都要想清楚它在内存里、在磁盘上、在图片缓存里各有一份没有。
顺带一个观察:这一篇和前面几篇反复出现同一个模式------用命名 key 而不是数组下标来传递结构化数据 (概览的 Map、CachePolicyTable 的枚举家族、错误码到文案的映射)。顺序是一种隐式的契约,一旦有人调整顺序就会静默出错,而编译器不会告诉你。
下一篇讲投稿编辑器------它是全项目唯一涉及"创建 → 编辑 → 提交 → 发布"完整状态机的页面,也是唯一要处理"用户写了很久的东西突然可能丢失"的地方。
延伸阅读
- Dio + Repository:统一响应信封与模型适配
- Refresh Token 旋转与并发 401:安全存储与会话代次
- 会员中心:资料、密码与个人数据为什么不能进公共缓存
- 点赞、收藏与阅读历史:三种互动状态的所有权不同
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
