拉黑系统 --- 屏蔽与全局过滤
1. 拉黑系统在社交App中的安全价值
在社交产品的生态中,拉黑功能远不止一个简单的"屏蔽按钮",它是用户安全体系中最基础也最刚性的防线。任何一款以"人与人连接"为核心的产品,都无法回避一个根本矛盾:连接的便利性与连接的安全性之间存在天然张力。产品设计的初衷是让用户更方便地认识新朋友、发现附近的人、参与社交互动;然而,社交的开放性同时也为骚扰、冒犯、跟踪、欺诈等负面行为提供了通道。拉黑功能,就是在开放连接与安全保护之间划出的一道"用户可控的边界线"。
从用户心理的角度分析,拉黑需求通常产生于以下场景:第一,持续性骚扰------某用户反复发送不当消息,无视已读不回或委婉拒绝的暗示,用户需要一种"硬性阻断"来终止骚扰;第二,冒犯性互动------在聊天或游戏过程中,某用户的言论严重冒犯了自己,用户希望立即切断与该用户的所有联系;第三,隐私保护------用户发现某人是熟人或不想让其看到自己位置信息的人,需要阻止对方发现自己的在线状态与位置;第四,社交净化------用户对某些人的价值观或行为方式感到不适,虽然对方并未直接骚扰,但用户不希望在社交体验中遇到这些人。
这些场景的共同特征是:用户的需求是即时性的、全局性的、不可妥协的。即时性意味着拉黑操作一旦执行,效果必须立即生效------不能有"需要等待30秒"或"刷新后生效"的中间状态;全局性意味着被拉黑者必须从产品的每一个角落消失------不能只在附近列表消失却仍在通知中出现;不可妥协性意味着拉黑是一个"全有或全无"的操作------不存在"半拉黑"或"部分屏蔽"的中间态。这三个特征共同定义了拉黑功能的安全价值:它不仅是一个功能,更是一种安全承诺------用户拉黑某人后,系统保证被拉黑者无法通过任何渠道再次接触用户。
在社交产品的安全体系中,拉黑功能与举报功能互补。举报是一种"事后追责"机制------用户举报某人的不当行为,平台审核后对违规者进行处罚;拉黑则是一种"即时自保"机制------用户无需等待平台审核,立即获得对负面互动的完全控制权。举报保护的是"社区整体"的安全环境,拉黑保护的是"个体用户"的即时安全。二者缺一不可:仅有举报而无拉黑,用户在等待审核期间仍需忍受骚扰;仅有拉黑而无举报,违规者可以继续骚扰其他用户。
在NearPlay的具体场景中,拉黑系统的安全价值更加凸显。NearPlay是一款基于位置的社交游戏应用,用户通过"附近的人"发现身边的潜在玩伴。这种基于位置的社交模式带来了独特的安全挑战:用户的位置信息本身是敏感的,当某个不被信任的人能够持续看到你的位置时,会产生"被跟踪"的不安全感。拉黑功能在此场景下不仅是社交筛选工具,更是位置隐私的保护屏障------被拉黑者无法看到你的位置,你也不会出现在对方的"附近"列表中,从根本上消除了位置暴露的风险。
此外,NearPlay的社交场景通常涉及"一起玩游戏"这一深度互动。与简单的"看一眼附近的人"不同,游戏互动需要更长时间、更深度的交流,这意味着用户与陌生人之间会建立更高程度的社交暴露。在这种深度互动中,骚扰或冒犯的伤害也更深------一个在游戏中持续骚扰的队友比一个在附近列表中闪过的陌生人更具破坏力。拉黑功能的"即时终止"特性,为用户提供了一个"紧急退出按钮",使其在任何不适的互动中都能立即脱身。
从产品信任度的角度看,拉黑功能的可靠性直接影响用户对产品的信任感。如果用户发现拉黑后仍能看到被拉黑者的通知或聊天,会产生"产品不可靠"的认知,进而对整个产品的安全性产生怀疑。这种信任损害是累积性的------每一次"拉黑失效"都在削弱用户对产品的信任,最终导致用户放弃使用。因此,拉黑功能的"绝对可靠性"不是锦上添花,而是产品生存的底线要求。
最后,从法律合规的角度看,许多国家和地区的社交产品法规要求平台提供用户自主的屏蔽功能。欧盟的《数字服务法》要求社交平台为用户提供减少负面内容暴露的工具;中国的《网络信息内容生态治理规定》也要求平台提供屏蔽、拉黑等功能。拉黑功能不仅是产品需求,更是合规要求,其实现的正确性与完整性直接关系到产品的法律风险。
2. 方案A/B/C对比论证过程
NearPlay的拉黑系统经历了三种架构方案的深入对比与实际验证,最终采用了"模块级let变量 + exported function"的模式。这一选择并非教条式地遵循某种编程范式,而是经过对三种方案在ArkTS运行时环境中的实际表现进行充分验证后的理性决策。以下是完整的对比论证过程。
2.1 方案A:模块级let + exported function(最终采用方案)
这是BlockModel当前采用的方案。其核心结构如下:
typescript
let blockedList: BlockedUser[] = []
export function initBlockList(list: BlockedUser[]): void {
blockedList = list
}
export function isUserBlocked(userId: string): boolean {
for (let i = 0; i < blockedList.length; i++) {
if (blockedList[i].id === userId) {
return true
}
}
return false
}
export function blockUser(user: BlockedUser): void {
if (!isUserBlocked(user.id)) {
blockedList = [...blockedList, user]
}
}
export function unblockUser(userId: string): void {
blockedList = blockedList.filter((u: BlockedUser) => u.id !== userId)
}
export function getBlockedUsers(): BlockedUser[] {
return [...blockedList]
}
方案A的核心思路是将状态与操作封装在模块的词法作用域中。blockedList 是一个模块级的私有变量------它通过 let 声明于模块顶层,但不通过 export 暴露,因此外部代码无法直接访问或修改这个数组。所有对黑名单的读写操作都必须通过五个导出函数进行,这为状态变更提供了可控的入口点。
方案A的关键优势在于ES模块的语义保证。在ES模块规范中,每个模块只会有一个实例------无论多少个文件 import 了同一个模块,它们引用的都是同一个模块词法环境,同一个 let 变量。这是JavaScript/TypeScript语言规范的标准行为,不依赖于任何特定运行时的实现细节。在ArkTS中,这一语义同样得到了正确实现:Index.ets 中调用 blockUser() 和 ChatPage.ets 中调用 isUserBlocked() 操作的是完全同一份 blockedList 数据。
方案A的劣势在于缺乏UI响应式支持。blockedList 的变更不会自动触发任何组件的重渲染------它不是 @State、@Link 或 AppStorage 的一部分,ArkUI框架无法感知其变化。因此,每次对黑名单的修改后,需要手动调用 syncBlockState() 将最新状态同步到组件的 @State 变量中。这种手动同步增加了开发者的心智负担,也增加了遗漏同步调用的风险。但在实际开发中,这种风险是可控的------拉黑/取消拉黑操作只有有限的几个入口点,每个入口点都明确地调用了 syncBlockState()。
方案A在类型安全方面的表现是完美的。所有函数的参数与返回值都有明确的类型声明,TypeScript/ArkTS编译器能提供完整的类型检查。BlockedUser[] 在整个调用链中保持了类型信息的完整性,无需任何强制类型转换。
2.2 方案B:单例Class
这是更面向对象的设计方式:
typescript
export class BlockManager {
private static instance: BlockManager | null = null
private blockedList: BlockedUser[] = []
static getInstance(): BlockManager {
if (!BlockManager.instance) {
BlockManager.instance = new BlockManager()
}
return BlockManager.instance
}
blockUser(user: BlockedUser): void {
if (!this.isUserBlocked(user.id)) {
this.blockedList = [...this.blockedList, user]
}
}
isUserBlocked(userId: string): boolean {
return this.blockedList.some((u: BlockedUser) => u.id === userId)
}
// ... 其他方法
}
单例模式在传统TypeScript/Java中是经典的全局状态管理方案。它的理论优势包括:面向对象的封装性、延迟初始化、易于扩展(通过继承或接口实现多态)。然而,在ArkTS的运行时环境中,单例Class存在一个致命的实践问题------跨文件的 static instance 不同步。
这个问题的根源在于ArkTS的模块加载机制与传统JavaScript运行时的差异。在Node.js或浏览器的V8引擎中,ES模块的 import 语义保证了同一模块只有一个实例,包括其顶层 let 变量和Class的 static 属性。但在ArkTS的编译和运行时中,不同 .ets 文件可能被编译为不同的模块单元,每个模块单元可能维护独立的Class元数据------这意味着同一个 BlockManager Class的 static instance 属性在不同编译单元中可能指向不同的内存地址。
在开发初期的调试构建中,这个问题可能不会暴露,因为ArkTS的调试模式倾向于保留模块的单一实例。但在release构建中,编译器会进行更激进的优化(如模块拆分、tree-shaking、代码合并),这些优化可能改变模块的加载边界,导致原本共享的Class static 属性在不同编译单元中被独立实例化。正是这种"开发期正常、发布期出bug"的隐蔽性,使得单例Class方案成为了一个定时炸弹。
我们在实际开发中经历了这个问题的完整排查过程:开发阶段一切正常,拉黑操作在Index页面和ChatPage中都能正确生效;但在某次release构建后,测试报告了"拉黑用户后在聊天页仍能看到消息"的问题。经过数小时的排查,我们确认问题出在 BlockManager.instance 在两个编译单元中指向了不同对象------Index页面操作的BlockManager实例包含拉黑数据,而ChatPage查询的是另一个空的BlockManager实例。切换到方案A后,问题彻底消失。
2.3 方案C:AppStorage全局状态
ArkUI框架提供了 AppStorage 作为全局状态容器:
typescript
AppStorage.setOrCreate('blockedList', [])
// 在组件中:
@StorageLink('blockedList') blockedList: BlockedUser[] = []
方案C的理论优势是天然支持UI响应式------任何对 @StorageLink 的修改都会自动触发组件重渲染,无需手动同步。这消除了方案A中 syncBlockState() 的心智负担,也消除了遗漏同步调用的风险。
然而,AppStorage在NearPlay的拉黑场景中存在几个不可接受的问题。
类型安全缺失。 AppStorage存储的值类型为 Object,取值时需要强制类型转换。AppStorage.get('blockedList') 返回 Object | undefined,每次使用都需要 as BlockedUser[] 转换,既不优雅也不安全。在ArkTS的严格模式下,这种强制类型转换会引发编译警告甚至错误,增加维护成本。更重要的是,类型转换的缺失意味着编译器无法在编译期捕获"向blockedList中添加了错误类型的数据"这类bug------直到运行时才会暴露,而拉黑系统的运行时错误可能导致用户安全受威胁。
非组件代码无法使用。 AppStorage是与 @StorageLink/@StorageProp 装饰器绑定的,只有在 @Component 的上下文中才能通过装饰器访问。如果某个工具函数或纯逻辑模块需要判断用户是否被拉黑(如消息推送过滤服务),它无法直接使用AppStorage------必须通过组件间接传递,这严重破坏了架构的分层原则。在NearPlay的未来扩展中,消息推送、WebSocket消息过滤等功能都需要在非组件代码中判断拉黑状态,AppStorage的这一限制将成为架构瓶颈。
修改操作的原子性无法保证。 拉黑操作需要"先检查是否已存在,再决定是否添加"的逻辑,这在AppStorage中需要先 get 再 set 两次操作,中间可能被其他代码打断。在并发场景下(如用户快速连续拉黑多人),两次操作之间可能插入了其他对AppStorage的修改,导致逻辑不一致。而方案A中,blockUser() 函数内部自然包含了原子性的检查+添加逻辑------函数体内的代码是同步执行的,不会被中断。
2.4 方案对比总结
| 维度 | 方案A: 模块级let+function | 方案B: 单例Class | 方案C: AppStorage |
|---|---|---|---|
| 跨文件状态一致性 | ES模块语义保证 | static instance可能不同步 | 全局单例保证 |
| 类型安全 | 完整类型 | 完整类型 | Object类型,需强转 |
| UI响应式 | 需手动sync | 需手动sync | 自动 |
| 非组件可访问 | 直接import | 直接import | 需组件间接 |
| 操作原子性 | 函数内封装 | 方法内封装 | get+set分离 |
| 实际bug风险 | 低 | 高(跨文件不同步) | 中(类型安全缺失) |
方案A的唯一显著劣势是缺少UI响应式,但这一劣势通过 syncBlockState() 函数得到了有效弥补。而方案B的跨文件不同步bug是不可修复的运行时缺陷(除非ArkTS运行时修复其模块加载行为),方案C的类型安全缺失是架构层面的根本限制。因此,方案A在NearPlay的场景下是最优选择------它在正确性、安全性与可维护性之间取得了最佳平衡。
3. 单例Class跨文件static不同步bug故事
这个bug是NearPlay开发过程中最令人困惑的一次排查经历,它教会了我们一个重要的教训:在ArkTS运行时中,不能假设传统JavaScript的模块语义对所有语言特性都成立。
3.1 Bug的发现
开发初期的BlockModel采用了方案B(单例Class),代码如下:
typescript
export class BlockManager {
private static instance: BlockManager | null = null
private blockedList: BlockedUser[] = []
static getInstance(): BlockManager {
if (!BlockManager.instance) {
BlockManager.instance = new BlockManager()
}
return BlockManager.instance
}
blockUser(user: BlockedUser): void {
if (!this.isUserBlocked(user.id)) {
this.blockedList = [...this.blockedList, user]
}
}
isUserBlocked(userId: string): boolean {
for (let i = 0; i < this.blockedList.length; i++) {
if (this.blockedList[i].id === userId) return true
}
return false
}
getBlockedUsers(): BlockedUser[] {
return [...this.blockedList]
}
}
在开发阶段(debug构建),一切功能正常:在Index页面拉黑用户后,ChatPage中调用 isUserBlocked() 能正确返回true,三个数据流的过滤也正常工作。测试同学在模拟器上反复验证,均未发现问题。
然而,当项目进行release构建并在真机上测试时,问题突然出现:在Index页面拉黑"阿杰"后,返回聊天列表,与阿杰的聊天会话仍然可见。进入聊天页后,isUserBlocked() 返回false------BlockManager认为阿杰不在黑名单中。
3.2 排查过程
我们首先怀疑是数据持久化的问题------是否release构建的持久化行为与debug不同?但BlockModel当时并未接入任何持久化,数据完全在内存中,不存在持久化差异。
接着怀疑是页面生命周期的问题------是否ChatPage的 aboutToAppear() 在Index页面拉黑操作之前就执行了?但日志显示操作顺序是正确的:先拉黑,后进入ChatPage。
然后怀疑是ArkUI组件重建的问题------是否ChatPage在路由返回后被重建,导致 aboutToAppear() 重新执行时获取了新的BlockManager实例?我们在 getInstance() 中添加了日志:
typescript
static getInstance(): BlockManager {
if (!BlockManager.instance) {
console.log('BlockManager: creating new instance')
BlockManager.instance = new BlockManager()
} else {
console.log('BlockManager: reusing existing instance')
}
return BlockManager.instance
}
debug构建的日志显示始终是"reusing existing instance"。但release构建的日志惊人地显示了两次"creating new instance"------分别在Index页面和ChatPage首次调用 getInstance() 时。这意味着两个页面各自持有一个独立的BlockManager实例,Index页面的拉黑操作写入了自己的实例,而ChatPage查询的是自己的空实例。
3.3 根因确认
进一步分析发现,ArkTS在release构建中可能将不同 .ets 文件的代码编译到不同的模块单元中,每个模块单元有自己的Class元数据副本。当Index.ets和ChatPage.ets分别 import BlockModel时,它们可能加载了各自模块单元中的 BlockManager Class定义,而 static instance 属性是绑定在Class定义上的------不同Class定义意味着不同的static属性存储位置。
这与ES模块规范中"同一模块只有一个实例"的语义并不矛盾------ES模块规范保证的是模块的词法环境(顶层let/const/var变量)的单一性,而非Class的static属性的单一性。在某些运行时实现中,Class的static属性可能被视为Class对象的属性而非模块词法环境的属性,当Class对象被复制时,static属性也随之被复制。
3.4 解决方案与验证
将BlockModel重构为方案A(模块级let + exported function)后,问题彻底解决。模块级let变量属于模块的词法环境,受ES模块语义保证------无论多少个编译单元import同一个模块,它们引用的都是同一个词法环境中的同一个变量。release构建的日志确认了这一点:Index页面和ChatPage对 blockedList 的操作指向了同一份数据。
3.5 经验教训
这个bug的故事带来了几点深刻的教训:
第一,ArkTS不是标准TypeScript的简单超集。虽然ArkTS在语法层面与TypeScript高度兼容,但在运行时语义上存在差异------尤其是模块加载、Class元数据管理、内存模型等方面。开发者不能简单地假设"TypeScript中能工作的模式在ArkTS中也能工作",必须通过实际构建验证。
第二,debug构建与release构建的行为可能存在差异。ArkTS的release构建会进行更激进的优化,这些优化可能改变代码的运行时行为。任何涉及全局状态、模块语义、跨文件共享的功能,都必须在release构建中验证。
第三,"单例Class"在ArkTS中不是可靠的全局状态管理方案。如果需要在ArkTS中实现跨文件共享的全局状态,优先使用模块级let变量 + exported function,或者使用ArkUI框架提供的AppStorage(在类型安全可接受的场景下)。
4. 五个导出函数详解
BlockModel导出了五个函数,构成了拉黑系统的完整API。这些函数的设计遵循一个核心原则:模块内状态私有,外部仅通过函数访问。这一原则确保了状态变更的可追踪性和数据的一致性------任何对黑名单的修改都必须经过这些受控的入口点,外部代码无法绕过函数直接操作底层数组。
4.1 initBlockList(list: BlockedUser\[\]): void
typescript
export function initBlockList(list: BlockedUser[]): void {
blockedList = list
}
初始化函数,在应用启动时调用一次。在Index.ets的 aboutToAppear() 中:
typescript
const initialBlocked = MockBlockData.getInitialBlocked()
initBlockList(initialBlocked)
this.blockedUsers = getBlockedUsers()
this.blockedUserIds = this.blockedUsers.map((u: BlockedUser) => u.id)
为什么需要initBlockList而不是直接让blockedList从空数组开始?因为在真实应用中,黑名单数据需要从持久化存储(如Preferences或分布式数据库)中恢复。initBlockList提供了一个注入初始数据的入口点,使得BlockModel不依赖具体的持久化实现------MockBlockData提供模拟数据,未来替换为Preferences读取只需修改调用方,BlockModel本身无需变更。
值得注意的是,initBlockList 执行的是直接赋值(blockedList = list),而非合并或追加。这意味着如果被调用多次,后一次会完全覆盖前一次的数据。这是一个有意的设计决策:初始化应该是幂等的,多次调用不应导致数据累积。在应用生命周期中,initBlockList只应在页面初始化时调用一次。
从架构的角度看,initBlockList体现的是"依赖注入"的思想------BlockModel不自行创建初始数据,而是由外部注入。这种设计使得BlockModel在测试场景下也能轻松替换为不同的初始数据,无需修改BlockModel内部代码。在单元测试中,可以注入空数组测试"无拉黑用户"的场景,也可以注入多条记录测试"多用户拉黑"的过滤性能。
4.2 isUserBlocked(userId: string): boolean
typescript
export function isUserBlocked(userId: string): boolean {
for (let i = 0; i < blockedList.length; i++) {
if (blockedList[i].id === userId) {
return true
}
}
return false
}
这是拉黑系统中调用频率最高的函数。它接收一个用户ID,返回该用户是否在黑名单中。
为什么使用for循环而不是 blockedList.some() 或 blockedList.findIndex()?这是ArkTS性能优化的惯用手法。在ArkTS的编译环境中,for循环的性能通常优于数组的高阶函数方法(some/find/filter),因为高阶函数涉及闭包创建和函数调用开销,而for循环直接操作索引,编译器更容易进行内联优化。在高频调用场景(如过滤通知列表时的多次判断)中,这种微优化是有意义的。
该函数在两个页面中被使用:Index.ets 通过 blockedUserIds.includes() 间接使用(先将blockedList转为ID数组,再用includes判断);ChatPage.ets 直接调用 isUserBlocked(this.targetId) 来判断当前聊天对象是否被拉黑,用于决定是否显示"拉黑提示栏"和禁用消息输入。
在ChatPage中的使用场景:
typescript
aboutToAppear(): void {
this.isBlocked = isUserBlocked(this.targetId)
}
这里有一个微妙的设计决策:ChatPage不是通过Index的过滤逻辑间接获知拉黑状态,而是主动调用 isUserBlocked() 查询。这是因为ChatPage是通过路由独立打开的页面,它不共享Index的@State变量。当用户从通知列表进入聊天页时,ChatPage需要自行判断聊天对象是否被拉黑。
4.3 blockUser(user: BlockedUser): void
typescript
export function blockUser(user: BlockedUser): void {
if (!isUserBlocked(user.id)) {
blockedList = [...blockedList, user]
}
}
拉黑操作函数。两个关键设计点:
幂等性保障 :通过 !isUserBlocked(user.id) 的前置检查,确保同一用户不会被重复添加到黑名单中。即使UI层因为防抖失败等原因多次触发拉黑操作,BlockModel也不会产生重复记录。这比"依赖UI层的防抖逻辑"更可靠,因为UI层的防抖可能因路由切换、组件重建等原因失效。
不可变更新 :使用 [...blockedList, user] 创建新数组而非 blockedList.push(user)。虽然在模块级let的场景下,直接修改原数组也能达到"其他import方下次读取时看到新值"的效果,但使用不可变更新有更好的语义清晰性------每次修改都产生一个新引用,明确表达了"状态已变更"的信号。此外,在ArkUI的@State机制中,直接修改数组元素的内部属性不会触发重渲染,而赋值一个新数组则会触发。
4.4 unblockUser(userId: string): void
typescript
export function unblockUser(userId: string): void {
blockedList = blockedList.filter((u: BlockedUser) => u.id !== userId)
}
取消拉黑函数。通过 filter() 创建一个不包含指定用户的新数组。如果用户不在黑名单中,filter不会产生任何效果,blockedList保持不变------这也是幂等的。
值得注意的是,unblockUser的参数是 userId: string 而非 BlockedUser 对象。这是因为取消拉黑时,调用方通常只知道用户ID(从黑名单列表项的点击事件中获得),而不需要提供完整的BlockedUser信息。这种接口设计减少了调用方的工作量,也避免了"传入的BlockedUser信息与已存储的不一致"的潜在问题。
4.5 getBlockedUsers(): BlockedUser\[\]
typescript
export function getBlockedUsers(): BlockedUser[] {
return [...blockedList]
}
获取黑名单的完整副本。使用展开运算符 [...blockedList] 而非直接返回 blockedList,这是经典的防御性拷贝(defensive copy)模式。如果直接返回内部数组的引用,外部代码可以绕过 blockUser/unblockUser 函数直接修改数组内容,从而破坏BlockModel的状态一致性。返回副本后,外部对返回值的任何修改都不会影响BlockModel内部的blockedList。
这个函数主要服务于两个场景:syncBlockState() 将最新的黑名单同步到组件的@State变量;黑名单管理面板 ForEach遍历显示所有被拉黑用户。
5. 三处过滤实现详解
拉黑系统的核心价值在于"一处拉黑,三处生效"。这三个过滤点分别位于Index.ets的 refreshFilteredData() 方法中:
typescript
refreshFilteredData(): void {
const rawUsers = MockUserData.getNearbyUsers().filter((u: NearUser) => !this.blockedUserIds.includes(u.id))
this.nearbyUsers = this.enrichUsersWithMatch(rawUsers)
this.notifications = MockNotifyData.getNotifications().filter((n: NotifyItem) => !this.blockedUserIds.includes(n.fromUserId))
this.chatConversations = MockChatData.getConversations().filter((c: ChatConversation) => !this.blockedUserIds.includes(c.targetId))
}
5.1 附近用户过滤

typescript
const rawUsers = MockUserData.getNearbyUsers().filter((u: NearUser) => !this.blockedUserIds.includes(u.id))
this.nearbyUsers = this.enrichUsersWithMatch(rawUsers)
第一个过滤点。从MockUserData获取所有附近用户后,立即过滤掉被拉黑的用户,然后再执行匹配度计算(enrichUsersWithMatch)。这个顺序很重要------如果先计算匹配度再过滤,会浪费计算资源在被拉黑用户的匹配度计算上。虽然当前Mock数据规模下这个优化微不足道,但在真实场景中,匹配度计算涉及音乐分类和应用使用习惯的对比,计算成本不低,跳过被拉黑用户是有实际价值的。
过滤后的用户列表被赋值给 this.nearbyUsers,这是一个@State变量,赋值后触发UI重渲染,附近的用户列表立即更新。被拉黑的用户从列表中"消失",如同从未存在过。这种"消失感"正是拉黑功能安全价值的UI体现------用户不再需要在列表中"跳过"不想看到的人,因为这些人根本不会出现。
5.2 通知过滤

typescript
this.notifications = MockNotifyData.getNotifications().filter((n: NotifyItem) => !this.blockedUserIds.includes(n.fromUserId))
第二个过滤点。通知列表中的每条通知都有一个 fromUserId 字段,标识通知的来源用户。拉黑某人后,来自该用户的所有通知(报名通知、游戏邀请等)都会被过滤掉。
这个过滤基于一个设计假设:通知的 fromUserId 与用户的 id 使用同一套标识体系。在当前Mock数据中,这个假设成立------NotifyItem的fromUserId值(如'u1'、'u3')与NearUser的id值一致。在真实应用中,这需要后端保证用户ID的全局唯一性。
值得注意的是,系统通知(NotifyType.SYSTEM)的fromUserId为空字符串。由于空字符串不可能等于任何有效用户ID,系统通知不会被拉黑过滤误伤------这是一个"恰好正确"的行为,虽然不是显式设计的,但符合产品预期。未来可考虑将fromUserId改为可选字段(fromUserId?: string),使系统通知的"无来源"语义更加显式。
5.3 聊天会话过滤
typescript
this.chatConversations = MockChatData.getConversations().filter((c: ChatConversation) => !this.blockedUserIds.includes(c.targetId))
第三个过滤点。ChatConversation有一个 targetId 字段,标识聊天对方用户。拉黑后,与该用户的所有聊天会话从列表中消失。
与通知过滤类似,这个过滤依赖于ChatConversation的targetId与NearUser的id使用同一套标识体系。三个过滤点共同覆盖了用户在NearPlay中可能"看到"其他用户的所有入口。任何不在这些过滤点之内的用户展示(如活动列表中的发起人),当前暂不过滤------活动的发起人信息是活动本身的属性,不应因个人拉黑关系而影响其他用户对活动的可见性。这是一个有意识的产品决策:拉黑屏蔽的是"人际互动"(聊天、通知),而非"信息展示"(活动内容)。
5.4 过滤的触发时机
三个过滤都封装在 refreshFilteredData() 中,而 refreshFilteredData() 在以下时机被调用:
- aboutToAppear():页面初始化时,执行首次过滤
- syncBlockState():拉黑或取消拉黑操作后,执行重新过滤
这意味着过滤不是"实时监听式"的,而是"操作后刷新式"的。在拉黑操作和UI更新之间,有一个 syncBlockState() -> refreshFilteredData() -> @State赋值 -> 重渲染 的同步链路,整个过程在同一个事件循环中完成,用户不会感知到延迟。
5.5 syncBlockState()统一刷新
typescript
syncBlockState(): void {
this.blockedUsers = getBlockedUsers()
this.blockedUserIds = this.blockedUsers.map((u: BlockedUser) => u.id)
this.refreshFilteredData()
}
syncBlockState()是拉黑操作与UI更新之间的桥梁。它执行三个步骤:从BlockModel获取最新的黑名单副本;将BlockedUser\[\]转为string\[\]用于快速过滤;重新过滤三个数据流。
这个函数存在的根本原因是BlockModel不依赖ArkUI的响应式系统。模块级let变量的变更不会自动触发@Component的重渲染,必须通过手动赋值@State变量来"通知"框架状态已变更。syncBlockState()就是这个手动通知的封装。
每当Index.ets中执行拉黑或取消拉黑操作时,都会调用syncBlockState():
typescript
doBlockUser(userId: string, nickname: string, avatar: string): void {
blockUser(BlockedUser.of(userId, nickname, avatar))
this.syncBlockState()
}
doUnblockUser(userId: string): void {
unblockUser(userId)
this.syncBlockState()
}
这种"操作+同步"的两步模式看似冗余,实际上提供了重要的灵活性:操作和同步是解耦的。如果未来需要在批量操作(如"一键清空黑名单"或"批量拉黑")中避免多次不必要的UI刷新,可以先执行多次操作,最后只调用一次syncBlockState()。
syncBlockState()的另一个作用是保证 blockedUsers 和 blockedUserIds 两个@State变量的一致性。如果只更新其中一个,可能导致黑名单面板显示的用户与实际过滤逻辑不一致。将两个变量的更新放在同一个函数中,确保了它们的同步性。
6. @Builder闭包捕获bug故事
在开发NearPlay的附近用户列表时,我们遇到了一个令人困惑的bug:点击🚫按钮拉黑用户时,总是拉黑了错误的用户------不是当前行的用户,而是列表中最后一个用户的ID。这个问题困扰了我们整整一个下午,最终发现是ArkUI @Builder闭包参数捕获的问题。
6.1 问题复现
最初的NearbyUserCard实现是这样的:
typescript
@Builder
NearbyUserCard(user: NearUser) {
Row() {
Text(user.avatar).fontSize(36)
Column() {
Text(user.nickname).fontSize(16)
}
Button('🚫')
.onClick(() => {
this.doBlockUser(user.id, user.nickname, user.avatar)
})
}
}
在ForEach中使用:
typescript
ForEach(this.nearbyUsers, (user: NearUser) => {
ListItem() {
this.NearbyUserCard(user)
}
}, (user: NearUser) => user.id)
当用户点击第3行的🚫按钮(期望拉黑"大壮")时,实际被拉黑的是列表最后一行的用户。而且,无论点击哪一行的🚫,结果都是拉黑最后一个用户。这种行为在直觉上是完全不合理的------每个按钮的点击事件应该绑定到对应行的用户数据,怎么会全部指向最后一个元素?
6.2 根因分析
这个bug的根源在于ArkUI @Builder函数的闭包捕获机制与普通JavaScript/TypeScript函数的行为不同。
在标准的JavaScript中,@Builder的写法应该能正确捕获ForEach每次迭代中的 user 值。因为每次调用 this.NearbyUserCard(user) 时,user 作为参数传入,在Builder函数体内是一个局部绑定,onClick的闭包应该捕获这个局部的 user。
然而,ArkUI的@Builder有特殊的编译处理。@Builder并非普通的函数调用------它是一种声明式的UI描述,编译器会对其进行变换,以实现高效的UI更新。在这种变换过程中,@Builder的参数传递机制可能与直觉不符:参数并不总是在每次调用时创建新的绑定,而是可能被延迟绑定到最后一次迭代的值。
具体来说,ForEach为每个用户生成一个NearbyUserCard实例,但这些实例中的onClick闭包可能共享同一个 user 引用。当ForEach执行完毕后,user 最终指向了最后一个元素,所有onClick闭包中的 user 都解析为这同一个对象。
这个问题在概念上类似于JavaScript中经典的"循环中的var闭包"问题:
typescript
for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100)
}
// 输出: 5, 5, 5, 5, 5(而非 0, 1, 2, 3, 4)
但ArkUI @Builder的闭包捕获问题更为隐蔽------即使使用了 let(块级作用域),由于@Builder的特殊编译处理,参数仍可能被延迟绑定。这使得传统的JavaScript直觉("let解决闭包问题")在ArkUI中不再适用。
6.3 解决方案
最终的解决方案是:不使用@Builder,而是直接在ForEach中内联UI结构,通过ForEach的回调参数直接引用每次迭代中的user:
typescript
ForEach(this.nearbyUsers, (user: NearUser) => {
ListItem() {
Row() {
Text(user.avatar).fontSize(36)
Column() {
Text(user.nickname).fontSize(16)
}
Button('🚫')
.onClick(() => {
this.doBlockUser(user.id, user.nickname, user.avatar)
})
}
}
}, (user: NearUser) => user.id)
在当前代码中,Index.ets的HomeContent确实采用了这种内联方式(第143-231行),而非使用@Builder。这种写法虽然代码较长,但ForEach的回调参数 user 在每次迭代中是确定绑定的,onClick闭包能正确捕获当前行的用户数据。
6.4 保留的@Builder版本
尽管存在闭包捕获的问题,代码中仍然保留了 NearbyUserCard 的@Builder版本(第261行起)。这个@Builder目前没有被使用,保留它是作为文档参考和未来可能的重构准备。如果ArkUI后续版本修复了@Builder的闭包捕获问题,可以重新切换回@Builder以减少代码重复。
6.5 经验总结
这个bug教会了我们几件事:
-
ArkUI的@Builder不是普通函数,不能假设它的参数传递和闭包行为与TypeScript完全一致。在涉及用户交互事件(onClick等)的闭包中,尤其要小心参数捕获问题。
-
ForEach的回调参数是可靠的 。ForEach的
(item, index) => {}回调在每次迭代中都会正确绑定当前项,这是ArkUI框架保证的语义。当需要在列表项中绑定点击事件时,直接在ForEach回调中构建UI是最安全的方式。 -
测试要覆盖所有列表项的交互,而不仅仅是第一个。这个bug在只测试第一行的🚫按钮时不会被发现(因为第一行恰好是"小明",拉黑后小明消失,看起来正常),只有测试中间行和最后一行时才会暴露。这提醒我们,列表项的交互测试必须覆盖"非首行"的情况。
-
闭包捕获bug的通用排查模式:当列表中所有项的交互都指向最后一个元素时,首先怀疑闭包捕获问题------这是经典的"循环中的闭包"bug模式,在JavaScript中也是常见问题(var声明的变量提升导致),只是ArkUI中的表现形式不同。
-
@Builder的使用场景需要重新审视。@Builder在纯展示场景(无交互事件的闭包)中使用是安全的,因为即使参数延迟绑定,展示的内容也会在UI更新时被重新渲染为正确的值。但在包含onClick等事件处理闭包的场景中,@Builder的参数捕获行为可能导致闭包引用错误。因此,建议将@Builder的使用限制在"纯展示组件"中,含有交互事件的列表项应直接在ForEach中内联。
7. BlockedUser数据模型
typescript
export class BlockedUser {
id: string = ''
nickname: string = ''
avatar: string = ''
blockedAt: number = 0
static of(id: string, nickname: string, avatar: string): BlockedUser {
const b = new BlockedUser()
b.id = id; b.nickname = nickname; b.avatar = avatar; b.blockedAt = Date.now()
return b
}
}
7.1 字段设计考量
四个字段的选择各有其深层考量:
-
id:被拉黑用户的唯一标识,用于所有过滤和去重逻辑。这是整个拉黑系统中最关键的字段------所有过滤判断(blockedUserIds.includes(u.id)、isUserBlocked(userId))都依赖此字段。ID的稳定性至关重要:如果用户ID可能变更(如用户修改了用户名作为ID),拉黑关系将失效。在NearPlay中,用户ID由后端生成且不可变更,保证了拉黑关系的持久有效。 -
nickname:被拉黑时记录的昵称,用于黑名单面板显示,避免后续昵称变更导致无法辨认。这是一个"快照"设计------拉黑时记录的昵称可能与当前昵称不同,但黑名单面板显示的是拉黑时刻的昵称。这种设计在产品上是有意义的:用户拉黑"小明"后,在黑名单中看到"小明",能立即回忆起拉黑原因;如果显示的是小明后来改的昵称"大明",用户可能不记得这个人是谁。快照设计的代价是昵称可能过时,但这在黑名单场景下是可接受的。 -
avatar:同上,记录拉黑时的头像快照。在NearPlay的Mock数据中,头像使用emoji字符串(如'🧔'),快照的意义不大。但在真实应用中,头像是URL字符串,用户更换头像后URL会变化,快照保证了黑名单面板始终显示用户拉黑时看到的那个头像,增强了辨识的连续性。 -
blockedAt:拉黑时间戳,由Date.now()生成。这个字段在当前实现中未被使用,但为未来扩展预留了数据基础。可能的使用场景包括:按时间排序黑名单显示、实现"拉黑过期"功能(如30天后自动解除)、分析用户的拉黑行为模式(如是否频繁拉黑-取消拉黑循环)。
7.2 static of() 工厂方法
static of() 工厂方法采用了ArkTS的惯用模式:由于ArkTS不支持构造函数参数赋值(构造函数中不能直接赋值class字段),使用静态工厂方法+手动赋值的方式创建实例。这是一种妥协,但在整个项目中保持了风格一致性。
工厂方法只接收三个参数(id, nickname, avatar),blockedAt 在内部自动生成。这避免了调用方需要手动传入 Date.now() 的不便,也确保了时间戳的一致性------所有BlockedUser的blockedAt都是在创建时刻由系统生成的,不存在人为传入错误时间戳的风险。
8. 拉黑UI交互三处入口
拉黑功能在NearPlay中有三个入口:附近用户列表的🚫按钮、聊天页面的拉黑对话框、以及我的页面的黑名单管理面板。三个入口覆盖了用户可能产生"拉黑意图"的所有场景,每个入口的交互设计反映了其使用场景的心理特征。
8.1 附近用户🚫按钮
在附近在线用户列表中,每个用户行的右侧有一个🚫圆形按钮:
typescript
Button('🚫')
.fontSize(12)
.width(28)
.height(28)
.type(ButtonType.Circle)
.backgroundColor('#EEEEEE')
.fontColor('#999999')
.margin({ left: 4 })
.onClick(() => {
this.doBlockUser(user.id, user.nickname, user.avatar)
})
这个按钮的设计考量:
- 灰色调:使用#EEEEEE背景和#999999字体,视觉上弱化,不抢夺主要操作(邀请按钮)的注意力。拉黑是一个"负面操作",不应过度突出。
- 圆形小按钮:28x28的尺寸仅够放下一个emoji,紧凑不占空间,但仍然可点击。
- 无确认对话框:与ChatPage不同,附近列表的🚫点击直接执行拉黑,不弹出确认对话框。这是一个有意的快捷设计------在浏览附近用户时,拉黑操作是高频的"扫除"行为,如果每次都弹确认框会严重影响浏览效率。用户可以通过黑名单管理面板随时取消拉黑。
点击后立即执行 doBlockUser(),用户从列表中消失。由于没有动画过渡,消失是瞬间的------这强化了"拉黑即消失"的心理感受。在产品体验上,这种"瞬间消失"比"带动画的消失"更令人满意------它传达了一种"立即生效"的确定感,没有任何犹豫或延迟。
8.2 ChatPage拉黑对话框
在聊天页面中,拉黑操作需要更多仪式感。当用户点击右上角的"⋯"菜单时,会弹出一个确认对话框:
typescript
@Builder
BlockConfirmDialog() {
Column() {
Column() {
Text('确认拉黑')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text(`确定要拉黑 ${this.targetName} 吗?`)
.fontSize(14)
.fontColor('#666666')
.margin({ top: 12 })
Text('拉黑后将无法收到对方的消息和通知')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
Row() {
Button('取消')
.onClick(() => { this.showBlockConfirm = false })
Button('确认拉黑')
.backgroundColor('#F44336')
.onClick(() => { this.doBlockUser() })
}
}
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.backgroundColor('rgba(0,0,0,0.5)')
}
与附近列表不同,ChatPage的拉黑需要确认,原因有三:
- 聊天场景更敏感:在聊天中拉黑意味着中断一段正在进行的对话,用户可能是在情绪激动下操作,需要冷静期
- 后果更严重:聊天中的拉黑不仅屏蔽对方的消息,还会导致自己的输入栏被禁用------这个后果需要用户明确知晓
- 误操作代价更高:在附近列表误拉黑一个陌生人,影响有限;在聊天中误拉黑一个正在交流的朋友,可能导致重要信息丢失
确认对话框使用了半透明遮罩(rgba(0,0,0,0.5))覆盖整个页面,position定位在(0,0),确保用户无法绕过对话框继续聊天。对话框本身居中显示,视觉上清晰区分于底层内容。对话框中的"拉黑后将无法收到对方的消息和通知"这句提示,在确认操作前向用户明确传达了后果,符合"知情同意"的设计原则。
拉黑确认后,ChatPage的底部输入栏被替换为BlockedBar:
typescript
@Builder
BlockedBar() {
Row() {
Text('🚫 你已拉黑对方,无法发送消息')
.fontColor('#999999')
Button('取消拉黑')
.backgroundColor('#FF6B35')
.onClick(() => { this.doUnblockUser() })
}
}
BlockedBar明确告知用户"无法发送消息",并提供了一个显眼的"取消拉黑"按钮。这种设计避免了"拉黑后输入栏还在但发不出消息"的困惑------直接隐藏输入功能,用文字说明原因,给出解决路径。同时,顶部的导航栏也发生变化:当isBlocked为true时,右上角从"⋯"变为"取消拉黑"按钮,提供两个取消拉黑的入口。
8.3 黑名单管理面板
在"我的"页面中,黑名单管理作为一个独立的菜单项:
typescript
ListItem() {
Row() {
Text('🚫')
Text('黑名单管理')
Text(`${this.blockedUserIds.length}人`)
Text('>')
}
.onClick(() => { this.showBlockList = true })
}
菜单项右侧显示了当前被拉黑的用户数量(如"1人"),让用户无需进入面板就能了解黑名单规模。点击后,showBlockList 被设为true,触发BlockListPanel的渲染。
BlockListPanel是一个展开式的面板,而非路由到新页面。面板的内容包括标题栏("黑名单管理" + 关闭按钮)、空状态("暂无拉黑用户")、用户列表(ForEach遍历blockedUsers)。取消拉黑按钮调用 doUnblockUser(user.id),执行后调用syncBlockState(),整个页面状态刷新:黑名单面板更新、附近列表恢复该用户、通知和聊天列表也恢复。
8.4 三个入口的协同
三个拉黑入口看似重复,实际覆盖了不同的用户心理模型:
- 浏览时快速屏蔽:在附近用户列表中看到不想接触的人→🚫一键拉黑→继续浏览
- 聊天中紧急止损:在聊天中受到冒犯→⋯→确认拉黑→对话立即中断
- 事后管理:在个人中心查看和管理历史拉黑记录→取消误操作或恢复关系
三个入口共享同一个BlockModel底层,保证了状态一致性。无论从哪个入口拉黑,效果都是全局的------这是拉黑系统最基本的正确性保证。
9. MockBlockData设计
typescript
export class MockBlockData {
static getInitialBlocked(): BlockedUser[] {
return [
BlockedUser.of('u7', '阿杰', '🧔'),
]
}
}
MockBlockData的设计体现了几个关键决策。
第一,预置一条拉黑记录而非空列表。'u7 阿杰'被预先拉黑,这使得开发者可以在应用启动后立即看到拉黑系统的效果:阿杰不会出现在附近用户列表中,不会收到阿杰的通知,也不会看到与阿杰的聊天会话。如果初始黑名单为空,开发者需要手动执行拉黑操作才能验证过滤功能,增加了测试成本。预置数据是一种"开箱即测试"的设计理念,在原型开发阶段尤其有价值。
第二,'u7'对应MockUserData中的第7个用户,是一个离线用户(isOnline: false)。选择一个离线用户作为预设拉黑对象是有意为之------离线用户在附近列表中本身就不太显眼(可能被其他在线用户"淹没"),但拉黑后其通知和聊天仍然会被过滤,能更清楚地展示"三处过滤"的效果。如果选择一个在线用户作为预设拉黑对象,开发者可能分不清"该用户不出现是因为被拉黑还是因为恰好不在线"。
第三,MockBlockData是一个静态类而非导出函数。这与BlockModel本身采用"模块级let + 导出函数"的模式形成了有趣的对比------MockBlockData选择静态类是因为它不需要维护可变状态(getInitialBlocked()每次调用都返回新的数据),静态类的命名空间特性(MockBlockData.getInitialBlocked())比独立函数(getInitialBlocked())提供了更好的语义组织。
10. 边界情况与未来扩展
10.1 边界情况
拉黑自己 :当前实现未阻止用户拉黑自己的ID。虽然NearPlay的UI设计中不存在"拉黑自己"的入口,但BlockModel的函数接口没有进行自我拉黑检查。如果未来出现某种场景允许用户ID与blockedUserId相同,应添加 if (userId === currentUserId) return 的前置检查。
并发拉黑:在真实应用中,用户可能在短时间内快速连续拉黑多人。由于JavaScript的单线程特性,同步函数不存在真正的并发问题。但如果未来将拉黑操作改为异步(如写入数据库),需要考虑并发写入的一致性。
跨页面状态同步:当用户在ChatPage中拉黑对方后返回Index,Index页面的数据可能没有更新(因为Index的aboutToAppear不会在返回时重新执行)。在实际应用中,需要通过onPageShow生命周期或事件总线来处理这种跨页面的状态同步。当前的Mock实现中,Index每次aboutToAppear都会从Mock数据重新计算,因此不会有这个问题------但这只是Mock数据的特性,真实场景需要额外处理。
空黑名单的UI表现 :当所有被拉黑用户都被取消拉黑后,黑名单面板应显示"暂无拉黑用户"的空状态提示。当前实现通过 if (this.blockedUsers.length === 0) 判断并显示提示文字,这是正确的处理。但附近用户列表和聊天列表在被全部拉黑时的表现也需要考虑------如果附近只有被拉黑的用户,列表将为空,应显示"附近暂无用户"而非空白。
拉黑与被拉黑的双向性:当前实现只处理了"我拉黑别人"的单向场景。在真实社交产品中,"别人拉黑我"也应影响我的体验------如我无法给拉黑我的人发消息。这需要后端支持,前端需在发送消息前检查对方是否拉黑了自己。
10.2 未来扩展
持久化存储 :当前黑名单数据仅存于内存,应用重启后丢失。未来应使用HarmonyOS的Preferences或关系型数据库持久化黑名单,在应用启动时从存储中恢复数据并通过 initBlockList() 注入BlockModel。
Set结构优化 :当黑名单规模增大时,应将 blockedUserIds: string[] 替换为 Set<string> 以实现O(1)查找。BlockModel内部也可维护一个 Set<string> 作为 isUserBlocked() 的快速查找索引。
增量过滤 :当前 refreshFilteredData() 每次拉黑/取消拉黑后都从Mock数据重新计算全量过滤。真实场景中应改为增量更新------拉黑时从现有列表移除特定项,取消拉黑时将特定项重新插入,避免全量重算。
拉黑原因记录 :在确认对话框中添加"拉黑原因"选项(骚扰、冒犯、不感兴趣等),记录在BlockedUser的新字段 reason 中。这不仅为黑名单管理提供更多信息,也为平台的内容审核提供数据参考。
定时拉黑 :部分社交产品提供"临时拉黑"功能------拉黑24小时后自动解除。这需要在BlockedUser中添加 expiresAt 字段,并实现一个定时检查机制。
互拉黑检测:当双方互相拉黑时,可在黑名单面板中标记"你们已互相拉黑",并提供"双方解除"的快捷操作。
文档版本 :v2.0 | 对应源码 :
BlockModel.ets/Index.ets/ChatPage.ets| 最后更新:2026-07
4. 拉黑系统在六种游戏中的应用
4.1 狼人杀中的拉黑过滤
狼人杀游戏中,拉黑用户不会影响游戏进行中的玩家------已经在游戏中的被拉黑玩家仍然参与游戏,否则会破坏游戏平衡(如拉黑狼人来使其出局)。但拉黑会影响:游戏结束后的结算页面不显示被拉黑玩家的信息;下一局匹配时不会与被拉黑玩家组队;白天讨论阶段的聊天消息不显示被拉黑玩家的发言。
4.2 你画我猜中的拉黑处理
你画我猜的猜词环节,被拉黑玩家的猜测消息对拉黑者不可见。这可能导致一个有趣的现象------画手看到有人猜对了(被拉黑玩家的消息),但拉黑者看不到这条消息,继续猜。当前实现中,游戏结果以所有人的视角为准------被拉黑玩家的猜词仍然计入得分,只是拉黑者看不到过程。这种设计保证了游戏公平性,同时尊重了用户的拉黑意愿。
4.3 谁是卧底中的拉黑影响
谁是卧底是纯文字描述游戏,拉黑的影响更直接------被拉黑玩家的描述对拉黑者不可见。但描述是游戏的核心机制,如果拉黑者看不到某位玩家的描述,就无法正常推理,对拉黑者自己也不利。因此当前实现中,谁是卧底的游戏进行阶段不应用拉黑过滤,仅在匹配和游戏结束后的社交环节中应用。
4.4 拉黑与匹配的交互
NearPlay的匹配引擎在计算附近用户列表时,会调用isUserBlocked()过滤被拉黑的用户。这意味着拉黑某人后,该用户不会出现在你的附近列表、匹配结果和游戏组队候选中。这是拉黑最核心的功能------从你的社交视野中消失。
4.5 拉黑的不可逆性设计
当前版本的拉黑功能是可逆的------用户可以在权限页面查看黑名单并取消拉黑。但取消拉黑不会自动恢复之前的社交关系(如匹配分数、聊天历史),只是让该用户重新出现在附近列表中。这种设计避免了"拉黑-取消-拉黑"的反复操作对系统造成的频繁数据变更。
5. BlockModel的数据持久化展望
5.1 当前方案的内存局限
BlockModel的blockedList存储在模块级let变量中,应用重启后丢失。用户每次重新启动NearPlay都需要重新拉黑之前屏蔽过的人。这在日常使用中是可以接受的------附近用户列表本身就是动态变化的,上次拉黑的人这次可能不在附近了。但对于长期使用的用户,黑名单持久化是必要的功能。
5.2 持久化方案设计
推荐使用HarmonyOS的Preferences API实现黑名单持久化------Preferences是轻量级的键值存储,适合存储少量字符串数据。拉黑操作时,除了更新内存中的blockedList,同时调用Preferences.putSync()写入持久化存储。应用启动时,在EntryAbility.onCreate()中调用Preferences.getStringSync()加载历史黑名单,初始化BlockModel的blockedList。
5.3 云端同步的可能性
更高级的方案是将黑名单存储在云端,实现跨设备同步------用户在手机上拉黑的人,在平板上也自动拉黑。这需要用户账号系统和云端数据库的支持,是NearPlay未来版本的重要功能。云端同步还需要处理冲突场景------如用户在设备A取消拉黑,同时在设备B重新拉黑同一个人,需要定义冲突解决策略(如"以最新操作为准")。
6. 拉黑系统的隐私考量
6.1 拉黑通知的取舍
NearPlay不会通知被拉黑的用户"你已被某人拉黑"。这是社交应用的标准做法------通知被拉黑者可能引发冲突或骚扰升级。被拉黑者只会注意到自己的消息没有回复、对方不再出现在自己的匹配结果中,但这种"消失"是隐式的,不会被明确告知原因。
6.2 拉黑与举报的区分
拉黑是个人行为------"我不想看到这个人"。举报是社区行为------"这个人的行为违反了规则"。当前NearPlay只实现了拉黑功能,未实现举报功能。举报功能需要配合内容审核系统,当举报达到一定数量时触发人工审核或自动封禁。这是NearPlay社区治理的重要基础设施,未来版本必须实现。
6.3 黑名单的数据安全
黑名单是敏感数据------它反映了用户的社交偏好和冲突历史。黑名单数据不应暴露给第三方(包括被拉黑者),也不应在不安全的渠道传输。当前实现中,黑名单完全存储在本地内存中,不涉及网络传输,安全性有保障。接入持久化和云端同步后,需要确保数据加密存储和传输。
7. 拉黑系统的全局过滤流程图
┌──────────────┐ 拉黑操作 ┌──────────────┐
│ Index页面 │ ──────────────→ │ BlockModel │
│ (用户卡片) │ │ addBlock() │
└──────────────┘ └──────┬───────┘
│
blockedList.push(userId)
│
┌────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 附近用户列表 │ │ 聊天页面 │ │ 匹配引擎 │
│ 过滤拉黑用户 │ │ 过滤拉黑消息 │ │ 排除拉黑用户 │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
不显示被拉黑用户 不显示被拉黑消息 不匹配被拉黑用户
拉黑操作从Index页面发起,写入BlockModel的模块级blockedList。之后所有读取blockedList的页面和模块自动应用过滤------附近用户列表不显示被拉黑用户、聊天页面不显示被拉黑消息、匹配引擎排除被拉黑用户。这种"写入一次、全局生效"的设计确保了拉黑效果的一致性,没有任何遗漏的入口。