最近刚把App里的拉黑界面这一整块做完,从需求评审到UI还原再到接口联调、自测上线,踩了不少坑,也沉淀出一些值得记录的细节。很多团队在排期时会把"拉黑"当做一个普通列表页来估时,实际上它牵扯到的交互状态、数据同步和边界情况比想象中多不少。这篇东西就把我从零到一实现"拉黑界面"的完整过程拆开讲讲,包括产品定位、视觉布局、数据模型、接口设计、状态闭环,以及上线前容易被忽略的测试点,希望能给正在做功能开发的同行一些参考。
如果你手头也接到类似需求,不管是刚起步的初级开发者,还是需要把控进度的前端/客户端负责人,这篇内容都可以直接拿来对照着用。
1. 拉黑界面在App里的产品定位:它不只是"一个列表页"
1.1 为什么拉黑功能比想象中复杂
拉黑,也叫屏蔽、加入黑名单,表面上看就是把一批用户塞进一个列表,点进去还能移除。但产品层面,它是社区安全体系里非常重要的一环,和举报、禁言、敏感词过滤这些机制并列。用户在私信、评论区、群聊里被人骚扰后,最常见的诉求就是"再也不想看到这个人"。拉黑界面承担的不只是展示,它需要让用户明确感知到"我拉黑之后会发生什么"——对方不能再给我发私信、不能在我内容下评论、不能查看我的主页等。
从开发角度,这里马上就分裂出几个子任务:黑名单列表的拉取和展示、搜索黑名单用户、解除拉黑的操作链路、以及拉黑状态在App全链路的联动。如果只做一个静态列表,那上线后一定会被用户吐槽"拉黑了跟没拉黑一样"。所以我在设计界面之前,先花了一上午把所有可能的互动场景捋了一遍,用表格拆出功能点。
| 功能模块 | 说明 | 归属页面 |
|---|---|---|
| 黑名单列表 | 展示所有已拉黑用户,支持分页加载 | 拉黑界面主列表 |
| 搜索 | 在已拉黑用户中按昵称/ID搜索 | 拉黑界面搜索栏 |
| 无打扰开关 | 快捷开关,是否拦截来自黑名单的临时会话通知 | 拉黑界面顶部/设置 |
| 解除拉黑 | 对单个用户解除限制,二次确认 | 拉黑界面列表项 |
| 空状态 | 没有拉黑任何人时的引导说明 | 拉黑界面空页面 |
| 状态联动 | 私信、主页、评论区识别拉黑关系 | 全App相关模块 |
把功能点列出来后,"拉黑界面已经做出来了"这句话才真正有了支撑。它不是一个单页开发任务,而是一个以界面为载体、牵扯到用户关系状态的中型功能模块。
1.2 拉黑关系要分清单向与双向
这里有一个产品决策特别关键:拉黑是单向的还是双向的。绝大多数社交App采用双向屏蔽,即A拉黑B后,A在B的视野中消失,B也无法访问A的主页和内容。但有些App为了降低用户困惑,拉黑仅针对"对方不能打扰我",双方的公开主页和内容仍可见。
这个决策直接影响界面文案和交互反馈。如果选择双向屏蔽,那拉黑界面里还可能要标示"对方已拉黑你"的状态,因为很多App把"我拉黑了对方"和"对方拉黑了我"都归入黑名单页统一展示。我做的时候跟产品确认过:我们采用的是双向屏蔽,并且在拉黑列表里用一个状态标签来区分"已拉黑"和"已把你拉黑"两种关系,这样用户看到列表时心里有数。
如果你也在做这个功能,建议先把这个产品语义定下来,再动手画界面。否则后面接口返回的 relation 字段你都不知道怎么展示,更别说状态同步了。界面表现、文案、接口字段设计全部依赖这个决策。
2. 视觉与交互设计:从草图到像素级还原的决策过程
2.1 信息架构和页面流转
拉黑界面的入口一般藏在"设置—隐私"或"我—设置—账号与安全"这类二级甚至三级页面里,所以页面层级不能太深,用户跳转次数要尽量少。我做的是从"设置—隐私设置—黑名单"进入,进入后页面结构是"搜索栏 + 列表"。点击列表项可以进入用户主页,或者直接在当前页操作"解除拉黑"。
页面流转我这里设计成了三种路径:
- 路径一:设置 → 黑名单列表 → 查看某用户主页 → 返回
- 路径二:设置 → 黑名单列表 → 搜索 → 点击结果 → 查看主页或解除拉黑
- 路径三:设置 → 黑名单列表 → 左滑/长按 → 解除拉黑确认弹窗 → 完成
为了减少用户操作成本,解除拉黑尽量在列表内部完成,不需要跳到用户主页。只有在用户想先看看这个人是谁、再决定是否解除时,才需要进入主页确认。这个交互路径在同类型App里已经很成熟,直接沿用就能保证上手成本低。
2.2 列表项布局与组件设计
拉黑列表项的布局,我最终定的是"左头像 + 右上昵称 + 右下拉黑时间/原因 + 右下操作按钮"的结构。具体拆开是这样的:
// React Native 示例:黑名单列表单项组件 type BlacklistItemProps = { item: BlacklistItem; onRemove: (userId: string) => void; }; export function BlacklistRow({ item, onRemove }: BlacklistItemProps) { return ( <View style={styles.container}> <Avatar uri={item.avatar} size={44} /> <View style={styles.infoBox}> <Text style={styles.nickname} numberOfLines={1}> {item.nickname} </Text> <Text style={styles.meta}> {formatBlockTime(item.blockedAt)} </Text> </View> {item.relation === 'blocked_by_me' ? ( <Pressable style={styles.removeBtn} onPress={() => confirmRemove(item)} hitSlop={8} > <Text style={styles.removeText}>解除</Text> </Pressable> ) : ( <View style={styles.disabledTag}> <Text style={styles.disabledText}>对方已拉黑你</Text> </View> )} </View> ); }这个布局有几个细节值得说:
- 头像和昵称对齐要统一,
numberOfLines={1}防止超长昵称把右侧按钮挤出屏幕。 - 拉黑时间显示在前面,解除按钮放在最后,符合用户从左到右的阅读习惯。
- 如果关系是"对方已拉黑你",那就不提供解除按钮,改用灰色标签,避免用户对"为什么我这边能解除"产生困惑。
- Pressable 的
hitSlop一定要加,按钮太小的话很容易点不中,尤其在真机上。
列表容器我用的 FlatList,它自带的onEndReached可以做分页加载。还有个容易被忽视的点:FlatList 的keyExtractor一定返回稳定且唯一的 ID,不然删除或更新某项时会出现渲染错乱。我的习惯是用userId,而不是用列表索引。
2.3 交互反馈:解除拉黑为什么要二次确认
解除拉黑和拉黑一样,都属于不可逆的敏感操作。拉黑时用户是主动发起的,但解除时很有可能是误触,或者用户只是想看看对方资料,不小心点到了按钮。所以我在解除操作上设了两道防线:
- 第一道:点击"解除"按钮后弹出 Alert 确认框,文案是"确定要解除对「昵称」的拉黑吗?解除后对方可以重新联系你。"
- 第二道:确认后按钮进入 loading 状态,接口返回成功前不允许再次点击,避免重复提交。
这里有个体验细节,确认弹窗里的取消按钮和确定按钮顺序,Android 和 iOS 原生规范不太一样。React Native 的 Alert 在双端默认顺序有差异,如果团队 UI 要求统一,需要自己封装弹窗组件,不能直接用系统 Alert。我是直接用了自定义的 BottomSheet 弹窗,这样双端表现一致,也方便统一样式。
3. 数据结构与本地缓存:让拉黑列表"秒开"的背后设计
3.1 数据模型定义
做界面之前先把数据模型定义好,后面所有逻辑都会基于这套字段来写。这是我在项目里实际使用的 TypeScript 定义:
// 黑名单列表项模型 export type BlacklistRelation = 'blocked_by_me' | 'blocked_me' | 'mutual'; export interface BlacklistItem { // 业务 ID userId: string; // 展示名称 nickname: string; // 头像地址 avatar: string; // 拉黑时间(服务端时间戳,单位秒) blockedAt: number; // 拉黑时的原因分类,如 'harassment' | 'spam' | 'other' reason?: string; // 与当前用户的关系 relation: BlacklistRelation; // 来源页面,比如 'chat' | 'profile' | 'comment' source?: string; } // 分页响应 export interface BlacklistPage { items: BlacklistItem[]; nextCursor: string | null; hasMore: boolean; }字段设计里有几个点可以展开说。blockedAt我用的是服务端时间戳,避免用户手机本地时间不准导致展示错乱。relation是后来加的,因为我们需要区分三种关系状态:我拉黑了他、他拉黑了我、互拉黑。这个字段对界面渲染很重要,直接决定展示解除按钮还是展示状态标签。
3.2 本地缓存:进入页面不能白屏转圈
黑名单列表有个特点:大多数用户的黑名单里其实只有零星几个人,甚至一个都没有。如果每次进入页面都走网络请求,用户看到的就是一个白屏加载,体验很差。所以我把黑名单数据做了本地缓存,进入页面先渲染缓存,再静默拉取最新数据比对更新。
具体方案是:
- 首次进入拉黑界面,检查本地缓存(MMKV 或 AsyncStorage 存储)。
- 有缓存则先渲染,同时发请求刷新;没有缓存则显示 loading。
- 请求返回后,比对
nextCursor或更新时间,更新缓存并刷新界面。 - 缓存设置过期时间,比如 24 小时,避免用户拉黑后又打开时看到旧数据。
缓存结构我用的是一个 JSON 字符串加一个时间戳,key 命名为blacklist_cache_v1。为什么带v1版本号?因为后续如果字段结构调整,可以靠版本号平滑迁移旧数据,直接废弃重拉,不要试图解析旧格式。
// 缓存读写示例 const CACHE_KEY = 'blacklist_cache_v1'; async function readCache(): Promise<BlacklistItem[] | null> { const raw = await MMKV.getItem(CACHE_KEY); if (!raw) return null; try { const parsed = JSON.parse(raw); if (Date.now() - parsed.updatedAt > 24 * 60 * 60 * 1000) { return null; // 过期 } return parsed.items; } catch { return null; } } async function writeCache(items: BlacklistItem[]) { const payload = JSON.stringify({ items, updatedAt: Date.now() }); await MMKV.setItem(CACHE_KEY, payload); }这里有个经验教训:缓存不要存整个分页加载过程的中间态,只存第一页数据就够了。因为用户一旦下拉加载更多,本地缓存和远程数据的一致性就很难保证。我的做法是第一页进缓存,后续的分页只存在内存里,退出页面就释放。
3.3 全局状态管理:拉黑操作后界面要"立刻变"
拉黑不只是在黑名单页面里操作,用户在聊天界面、个人主页、评论区都有可能触发"拉黑"按钮。问题来了:在聊天界面拉黑了一个人,回到黑名单列表时,这个新拉黑的用户应该立刻出现在列表顶部,而不是等下次冷启动才看到。
为了解决这个问题,我引入了一个全局的 blacklist store,用 Zustand 管理,任何页面都可以往里塞一个新的黑名单项,也可以移除一项:
// Zustand 全局黑名单状态 interface BlacklistState { count: number; items: BlacklistItem[]; addItem: (item: BlacklistItem) => void; removeItem: (userId: string) => void; setItems: (items: BlacklistItem[]) => void; } export const useBlacklistStore = create<BlacklistState>((set) => ({ count: 0, items: [], addItem: (item) => set((state) => ({ items: [item, ...state.items.filter((i) => i.userId !== item.userId)], count: state.count + 1, })), removeItem: (userId) => set((state) => ({ items: state.items.filter((i) => i.userId !== userId), count: Math.max(0, state.count - 1), })), setItems: (items) => set({ items, count: items.length }), }));有了这个 store,黑名单列表页只做一件事:订阅 store 的items,然后渲染。任何页面触发的拉黑/解除操作,都会同步更新 store,列表页自动刷新。这也避免了多个页面各自维护数据导致的不一致问题。
4. 服务端接口设计与状态闭环:单端操作如何全端生效
4.1 接口设计:拉取、新增、解除
拉黑功能全部靠服务端接口来维持数据权威性。我们这套接口设计比较通用,不管后端用什么语言实现,语义都很清晰:
| 接口 | 方法 | 说明 | 请求参数 |
|---|---|---|---|
| /api/v1/blacklist | GET | 分页获取黑名单列表 | cursor、pageSize |
| /api/v1/blacklist | POST | 添加拉黑 | userId、reason |
| /api/v1/blacklist/:userId | DELETE | 解除拉黑 | userId |
| /api/v1/blacklist/search | GET | 黑名单内搜索 | keyword |
其中 GET 列表接口我用的是游标分页cursor,而不是传统的page页码。游标分页的好处是,在黑名单列表这种频繁插入、删除的场景里,不会出现因为数据变动而导致页码错乱、重复或遗漏的问题。第一页请求不带 cursor,服务端返回nextCursor和hasMore,客户端据此判断是否还有下一页。
// 获取黑名单列表 export async function fetchBlacklist(cursor?: string) { const res = await request.get('/api/v1/blacklist', { params: cursor ? { cursor } : {}, }); return res.data as BlacklistPage; }POST 接口的语义是"幂等"的:同一个 userId 拉黑两次,服务端应该返回 200 并保持一条记录,而不是抛"重复拉黑"异常。这一点在联调时要跟后端强调,因为用户在聊天界面可能手滑点了两次,或者客户端做了重试机制,服务端不幂等的话会出现脏数据。
4.2 拉黑状态的全链路同步
界面只负责展示数据,真正让拉黑"生效"的,是其他模块对它做出响应。私信模块、评论模块、主页模块都需要在关键操作前检查对方是否已经被拉黑。我把这个检查统一封装成了一个方法isBlocked(userId),底层读 store,避免每个业务模块各写一套查询:
// 通用拉黑关系查询 export function isBlocked(userId: string): boolean { return useBlacklistStore.getState().items.some( (item) => item.userId === userId && item.relation === 'blocked_by_me' ); } // 私信发送前拦截 export function canSendMessage(fromUserId: string, toUserId: string): boolean { if (isBlocked(toUserId)) { return false; // 我把对方拉黑了,不能发消息 } if (isBlockedByOther(fromUserId, toUserId)) { return false; // 对方把我拉黑了,也不能发消息 } return true; }私信场景里,比较细致的一种处理是:如果对方拉黑了我,我进入跟他的聊天会话时,原会话内容可以查看,但输入框要变成"对方已开启好友验证/对方设置了隐私限制"之类的占位提示,不能发送。这个提示文案要和"我拉黑了对方"的提示区分开,因为用户看到不同的提示就知道问题出在自己还是对方身上。
4.3 多端同步与推送事件
如果 App 有 iPad 端、PC 端、手机端同时登录,在一端拉黑了一个用户,另外一端需要尽快知道这个状态。最简单的做法是:每次进入相关页面时重新拉取列表,但这很被动,而且用户可能正在聊天,聊着聊着就被对方发来消息,隔离不及时。
更好的做法是接入服务端推送事件。服务端在黑名单变更时推送一个blacklist.changed事件,客户端收到事件后,重新拉取黑名单列表并更新 store。这个方案对 UI 的响应速度是最好的,代价是要多维护一条推送通道。如果团队资源紧张,也可以做一个折中:App 从后台切回前台、以及每次主动发起"发送私信"之前,调用一次fetchBlacklist增量同步,保证关键路径上状态不过期。
5. 边界场景与体验细节:误操作、重复拉黑、数据分页的坑
5.1 重复拉黑与重复解封的处理
现实操作中用户不会按逻辑来,同一个用户可能被反复拉黑、解除、再拉黑。这里最容易踩的坑是:本地 store 和新接口数据不同步,导致同一个用户出现在列表里两次。要避免这个问题,store 里的 addItem 方法必须做去重,我在前面的代码里用了先过滤相同 userId 再插入的操作,这个顺序很关键。
服务端那边也要做人性的处理:解除拉黑之后再拉黑,不需要额外增加什么限制,但客户端要避免在接口 pending 期间让用户重复点击。我通常在按钮上做了disabled+loading双重控制,请求期间无论点多少次都不会发出第二条请求,接口完成后根据结果再清除状态。
// 解除拉黑的乐观更新与回滚 async function handleRemove(item: BlacklistItem) { setLoadingUserId(item.userId); const previousItems = useBlacklistStore.getState().items; // 乐观更新:先删掉,让界面立刻响应 useBlacklistStore.getState().removeItem(item.userId); try { await request.delete(`/api/v1/blacklist/${item.userId}`); } catch (error) { // 失败回滚,恢复原列表 useBlacklistStore.getState().setItems(previousItems); Toast.show('解除失败,请重试'); } finally { setLoadingUserId(null); } }乐观更新这个技巧经常被提到,但少有人强调"失败回滚"。如果只做乐观更新不处理失败,一旦接口超时或断网,界面就会呈现出已经解除但实际没有解除的假象,用户回头再发消息发现还是发不出去,就会觉得功能坏了。所以乐观更新必须配套回滚逻辑,这是我在踩过几次坑之后才彻底想明白的。
5.2 空状态和搜索的空结果
黑名单列表的空状态不是随便放一句"暂无数据"就完事的。第一次使用隐私设置的用户,可能根本不知道黑名单是干嘛用的。我的空状态文案是"你还没有拉黑任何人",配了一个盾牌图标,下面还有一行小字讲解入口:"在聊天或主页右下角菜单中,可拉黑不受欢迎的用户。"这行引导文案虽然小,但对新用户理解功能起了很大作用。
搜索的空结果同样要注意。在黑名单里搜索一个没拉黑过的人,提示文案不应该是"未找到用户",而应该是"该用户不在你的黑名单中",避免用户误以为"搜不到就代表不存在这个人"。
// 空状态组件示意 if (items.length === 0 && !loading) { return ( <View style={styles.emptyBox}> <Icon name="shield" size={56} color="#C0C4CC" /> <Text style={styles.emptyTitle}>你还没有拉黑任何人</Text> <Text style={styles.emptyDesc}> 在聊天或主页中,可通过右上角菜单拉黑不受欢迎的用户 </Text> </View> ); }5.3 长昵称、特殊字符和头像加载失败
真实用户数据永远比设计稿里的小明小红复杂。我在开发过程中遇到的情况包括:昵称超长、全英文、全数字、包含emoji、包含HTML标签,头像链接失效或加载超时。
针对这些,我的处理经验是:
- 昵称容器必须
numberOfLines={1}+ellipsizeMode="tail",超长截断显示省略号。 - 头像组件需要一个默认兜底图,加载失败时不显示空白占位,而是显示一个默认的灰色人像。
- 有的用户昵称可能包含
<、>这种字符,如果客户端直接使用dangerouslySetInnerHTML或 WebView 渲染就会有问题,必须当作纯文本处理。 - 时间格式化要处理时区问题,最好由服务端返回时间戳,客户端根据本地时区显示,而不是服务端直接返回"2024-01-01 12:00:00"这种字符串。
5.4 FlatList 分页时避免闪烁和跳动
分页加载时如果直接setItems(prev => [...prev, ...newItems]),列表可能会闪一下或者跳到顶部,用户观感很糟。要避免这个问题,FlatList 需要设定好稳定的getItemLayout或者预估行高。黑名单列表项的行高是相对固定的,我直接给getItemLayout返回固定高度ROW_HEIGHT = 72,这样在滚动和加载新数据时,FlatList 不需要动态测量,跳动概率大幅降低。
另一个坑是onEndReached会触发多次。React Native 中onEndReached在下拉加载的临界点上经常连续触发多次,需要自己加一个loadingMore的 ref 锁:
const loadingMoreRef = useRef(false); async function loadMore() { if (loadingMoreRef.current || !hasMoreRef.current) return; loadingMoreRef.current = true; try { const nextCursor = cursorRef.current; const page = await fetchBlacklist(nextCursor); if (page.items.length) { setItems(prev => [...prev, ...page.items]); } cursorRef.current = page.nextCursor ?? null; hasMoreRef.current = page.hasMore; } finally { loadingMoreRef.current = false; } }用 ref 而不是 state 来锁状态,是因为onEndReached触发非常频繁,state 更新是异步的,容易在还没更新前就再次进入回调。ref 的同步读写特性在这里更可靠。
5.5 深色模式与字体缩放适配
黑名单界面如果只适配了浅色模式,深色模式下背景和文字可能糊成一片。我是在项目里利用了系统主题变量,背景、卡片、文字、分割线全部使用语义色(如theme.colors.background、theme.colors.textPrimary),而不是写死的#FFFFFF、#1A1A1A。这样深色模式下联动自动生效,不用单独维护一套样式。
字体缩放的问题更隐蔽。系统开启大字体后,昵称和按钮文字可能会撑大,导致按钮换行甚至溢出。我在按钮文字上限制了maxFontSizeMultiplier,或者把按钮换成绝对定位,让布局在极端缩放下仍然完整。这个细节一般在自测时很难发现,但正式用户里总有需要放大字体的,不做保护就会出现奇怪的排版。
6. 自测与发布:我踩过的兼容性问题与上线前检查清单
6.1 手测用例:不要只测"正常拉黑再解除"
功能做完后,我习惯写一份自查用例清单,照着一条条过。拉黑界面这部分我当时的核心用例有:
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 首次进入 | 无缓存 | 展示 loading,接口返回后渲染列表 |
| 二次进入 | 有缓存 | 先渲染缓存,静默更新,不闪烁 |
| 上拉加载更多 | 滚动到底部 | 加载下一页,不重复,不跳动 |
| 空列表 | 黑名单为空 | 显示空状态引导文案 |
| 搜索无结果 | 输入不存在的昵称 | 显示"不在黑名单中"提示 |
| 解除拉黑 | 点击解除 | 弹窗确认,成功后列表更新 |
| 解除失败 | 断网点击解除 | Toast 提示,列表恢复原状 |
| 拉黑入口联动 | 在聊天页拉黑某人 | 返回黑名单列表后新用户置顶 |
| 特殊字符昵称 | 含 emoji/超长昵称 | 正常截断,不换行,不溢出 |
| 深色模式 | 切换系统深色模式 | 背景/文字/分割线显示正常 |
| 字体放大 | 系统字体调到最大 | 按钮不溢出,文字可读 |
手测时最容易漏的是断网点。我建议切到飞行模式再执行一遍"进入列表—搜索—解除拉黑"这组操作,看有没有异常白屏、崩溃或者错误提示。黑名单列表因为还有本地缓存兜底,断网时进入应该仍然能看数据,只是解除、搜索这些操作要给出友好错误提示。
6.2 自动化测试:核心逻辑一定要有单测
界面部分自动化测试成本高、脆性强,我不建议把全部UI纳入快照测试。但核心状态逻辑,像 store 的 addItem/removeItem、isBlocked 的判断、分页去重逻辑,这些纯函数非常适合做单元测试。我简单写了一个单测示例:
// 黑名单 store 单元测试摘要 describe('blacklist store', () => { it('addItem 应该去重并置顶', () => { const store = useBlacklistStore.getState(); store.setItems([userA]); store.addItem(userB); store.addItem(userA); // 再次添加同一个用户 const items = useBlacklistStore.getState().items; expect(items).toHaveLength(2); expect(items[0].userId).toBe('A'); // 最新操作的置顶 }); it('removeItem 只移除指定用户', () => { const store = useBlacklistStore.getState(); store.setItems([userA, userB]); store.removeItem('B'); const items = useBlacklistStore.getState().items; expect(items.map(i => i.userId)).toEqual(['A']); }); });这些测试在导出 store 后就能稳定跑,不需要起 UI 环境,维护成本很低。每次改到相关逻辑,跑一遍就能立刻发现是不是破坏了旧行为。
6.3 埋点、性能与审核注意的点
黑名单功能上线前不要忘记在几个关键动作上埋点,后续做数据分析才有的放矢。我加的埋点包括:拉黑界面曝光、进入黑名单人数、搜索行为、解除拉黑次数、解除后是否在7天内再次拉黑同一个人。最后这个"解除后短期重复拉黑"指标特别有用,如果数据偏高,说明用户可能误操作或功能入口有歧义。
性能上,黑名单列表的数据量不会太大,正常用户最多也就几百条,FlatList 完全扛得住。真正要关注的是首次缓存读取是否阻塞主线程。如果用同步 MMKV 读一个大 JSON,可能在低端安卓机上造成几十毫秒的卡顿,最好放到异步线程或延迟到首帧渲染之后再做。
最后聊一句上线审核。拉黑类功能涉及用户隐私和社区安全,应用商店审核时一般不会刻意为难,但如果有"拉黑后完全不可见"这类强强调,最好在隐私政策或功能说明里写清楚拉黑后的生效范围,以免出现用户投诉"明明拉黑了还能看到TA的访客记录"。保持功能行为与说明一致,这是上架前比较容易忽略的一点。
我在实际项目中把拉黑界面从"一个普通的列表页"演进到"用户关系状态管理模块",中间最大的收获就是:界面只是表象,数据模型和状态同步才是核心。如果你也在做类似功能,建议先把关系模型、分页策略、乐观更新回滚这几件事理清楚,再去写UI代码,会少走很多弯路。这个界面做完到今天已经稳定跑了一周,线上灰度的数据也很平稳,后面如果再接入拉黑原因分类和智能拦截建议,我相信还能继续优化体验。