模块划分与依赖关系

一、模块化设计理念

1.1 为什么选择 model / components / pages 三层分离

NearPlay 应用从一开始就确立了三层目录架构:model/components/pages/。这不是随意的文件分组,而是经过深思熟虑的架构决策。

model 层承载所有业务数据结构、领域逻辑和状态管理。一个 model 文件只做一件事:定义该领域的数据类型、提供对数据的操作函数、以及仅限该领域的模块级私有状态。例如 BlockModel.ets 只关心黑名单的增删查,MusicModel.ets 只关心音乐流派分类,MatchEngine.ets 只关心匹配分数计算。每个 model 都是一个自包含的知识边界——如果某个功能的数据和逻辑跨越了两个领域,那就说明边界划分有问题,需要重新审视。

components 层承载可复用的 UI 组件。这些组件只依赖 model 层的类型定义,不包含任何页面级导航逻辑。GameChatPanelVoiceInput 两个组件被多个游戏页面共享,如果它们被放在 pages 层就会导致代码复制。components 层的存在使得"一个组件修改,所有使用方同步受益"成为可能。

pages 层承载页面级组合逻辑。每个 page 是一个 @Entry @Component,它从 model 层导入数据和函数,从 components 层导入可复用 UI,负责将一切组装成可交互的页面。pages 层不定义新的业务逻辑,只做"胶水"——调用 model 的函数、传递数据给 components、处理路由跳转。这种设计确保了页面的可替换性:如果你要重写 ChatPage 的 UI,你只需要重写 pages 文件,model 和 components 完全不受影响。

1.2 单一职责原则的实际体现

单一职责不是口号,在 NearPlay 中有具体的衡量标准:一个模块被修改的原因应该只有一个

  • 如果因为"音乐流派分类规则变了"而修改 MusicModel.ets,这是合理的——它只负责音乐分类。
  • 如果因为"黑名单过滤逻辑变了"而同时需要修改 UserModel.ets,那就违反了单一职责——UserModel 不应该关心黑名单。

这种原则的实际体现是:每个 model 文件的 import 列表非常干净。MusicModel.ets 没有任何 import,UsageModel.ets 没有任何 import,BlockModel.ets 没有任何 import。它们是独立的叶子节点。而 MatchEngine.ets 只 import 了 MusicModelUsageModel——因为它需要两者的类型来计算匹配分数,但不需要也不应该修改两者的数据。

1.3 可测试性考量

三层分离带来的另一个好处是可测试性。model 层的函数大多是纯函数或模块级状态操作,可以脱离 ArkUI 框架独立测试。例如 computeMatch 函数接受 NowPlayingSongUsageProfile 两个参数,返回 MatchResult,没有任何副作用——这是最理想的单元测试目标。classifyGenre 函数接受歌曲标题和歌手名,返回 MusicGenre,同样可以构建测试用例而无需启动模拟器。

如果业务逻辑散落在 pages 的 @Component 内部,测试就只能通过 UI 自动化进行,成本高、速度慢、稳定性差。三层架构将逻辑推入 model,使得大部分测试可以快速完成。


二、model 层模块逐个详解

模块总览

model 层是 NearPlay 的核心,共有 16 个文件(含 game/ 子目录 6 个),下文逐一解析。

2.1 UserModel

文件路径: model/UserModel.ets

导出清单:

  • NearUser 类 — 附近用户的数据结构
  • LocationShareRequest 类 — 位置共享请求
  • ShareStatus 枚举 — 共享状态(PENDING / ACCEPTED / REJECTED)
  • MockUserData 类 — Mock 数据工厂

职责: 定义"人"在系统中的核心表示。NearUser 包含 id、昵称、头像、距离、在线状态、经纬度、当前游戏、音乐流派标签、匹配分数、匹配标签等字段。LocationShareRequest 表达"A请求与B共享位置"的语义,是 GameRoom 页面中"申请定位"功能的数据基础。

字段概要:

  • NearUser.id / nickname / avatar / distance / isOnline / latitude / longitude / currentGame / musicGenreLabel / matchScore / matchLabel
  • LocationShareRequest.fromUserId / fromNickname / toUserId / status

与其他模块关系: UserModel 是被依赖最多的模块之一。Index 页面用 NearUser 列表展示附近的人,GameRoom 页面用 NearUser 做匹配,MatchEngine 间接通过 Index 的 enrichUsersWithMatch 将计算结果写入 NearUser 的 musicGenreLabel / matchScore / matchLabel 字段。UserModel 自身不 import 任何其他 model。

2.2 GameModel

文件路径: model/GameModel.ets

导出清单:

  • GameItem 类 — 游戏条目
  • GameType 枚举 — 游戏类型(PARTY / WEREWOLF / SCRIPT / CASUAL)
  • GameRoom 类 — 游戏房间
  • RoomStatus 枚举 — 房间状态(WAITING / MATCHING / PLAYING / FINISHED)
  • MockGameData 类 — Mock 数据工厂

职责: 定义游戏大厅层面的数据——有哪些游戏可玩、游戏房间是什么状态。GameItem 的 canImportContent 字段特别值得注意:它标记了"剧本杀"这类可以导入外部内容的游戏,是 ScriptKillGame 页面"导入剧本"功能的入口条件。

字段概要:

  • GameItem.id / name / icon / description / playerMin / playerMax / type / canImportContent
  • GameRoom.id / gameId / gameName / hostId / currentPlayers / maxPlayers / status

与其他模块关系: GameModel 被 Index 页面(展示游戏列表和游戏详情卡片)和 GameRoom 页面(通过 RouteParams 获取 gameId/gameName 后路由到具体游戏页面)依赖。GameModel 自身不 import 任何其他 model,是纯粹的叶子模块。

2.3 ActivityModel

文件路径: model/ActivityModel.ets

导出清单:

  • ActivityItem 类 — 活动条目
  • MockActivityData 类 — Mock 数据工厂

职责: 定义线下活动的数据结构。一个活动包含标题、描述、地点、游戏内容、发起人、经纬度、距离、最大参与人数、当前参与人数、开始时间、是否已报名等完整信息。

字段概要:

  • ActivityItem.id / title / description / location / gameContent / organizerId / organizerName / latitude / longitude / distance / maxParticipants / currentParticipants / startTime / isJoined

与其他模块关系: 被 Index 页面(活动列表卡片)、ActivityDetail 页面(活动详情展示)、ActivityPublish 页面(发布活动表单)依赖。ActivityModel 不 import 任何其他 model,是独立叶子节点。

2.4 ChatModel

文件路径: model/ChatModel.ets

导出清单:

  • ChatConversation 类 — 聊天会话
  • ChatType 枚举 — 会话类型(PRIVATE / GAME / ACTIVITY)
  • ChatMsg 类 — 聊天消息
  • ChatMsgType 枚举 — 消息类型(TEXT / VOICE / IMAGE)
  • MockChatData 类 — Mock 数据工厂

职责: 定义消息系统的基础数据。ChatConversation 区分私聊、游戏群聊、活动群聊三种会话类型。ChatMsg 提供三个静态工厂方法——text()voice()image()——分别创建文本消息、语音消息和图片消息。这个设计使得创建消息的代码非常干净:ChatMsg.text(convId, fromId, fromNick, fromAvatar, textContent)

字段概要:

  • ChatConversation.id / type / name / avatar / targetId / lastMessage / lastTime / unreadCount
  • ChatMsg.id / conversationId / fromUserId / fromNickname / fromAvatar / msgType / content / duration / timestamp

与其他模块关系: 被 Index 页面(消息 Tab 的会话列表)、ChatPage 页面(聊天详情页的消息流)、GameChatPanel 组件(游戏内聊天面板)依赖。ChatModel 不 import 任何其他 model。GameChatPanel 的消息数据结构直接复用 ChatMsg,避免了为游戏聊天定义一套独立的消息类型。

2.5 NotifyModel

文件路径: model/NotifyModel.ets

导出清单:

  • NotifyItem 类 — 通知条目
  • NotifyType 枚举 — 通知类型(SIGNUP / SYSTEM / GAME_INVITE)
  • MockNotifyData 类 — Mock 数据工厂

职责: 定义通知系统的数据。三种通知类型覆盖了 NearPlay 的核心场景:SIGNUP 是有人报名了你的活动,GAME_INVITE 是有人邀请你加入游戏,SYSTEM 是系统级通知。NotifyItem 的 activityId 字段建立了通知与活动的关联——点击报名通知可以跳转到对应活动详情页。

字段概要:

  • NotifyItem.id / type / title / content / fromUserId / fromNickname / fromAvatar / activityId / timestamp / isRead

与其他模块关系: 被 Index 页面(消息 Tab 的通知列表)和 ActivityDetail 页面(发起人视角的报名管理)依赖。NotifyModel 不 import 任何其他 model。

2.6 BlockModel

文件路径: model/BlockModel.ets

导出清单:

  • BlockedUser 类 — 被拉黑用户
  • initBlockList(list: BlockedUser[]): void — 初始化黑名单
  • isUserBlocked(userId: string): boolean — 判断用户是否被拉黑
  • blockUser(user: BlockedUser): void — 拉黑用户
  • unblockUser(userId: string): void — 取消拉黑
  • getBlockedUsers(): BlockedUser[] — 获取所有被拉黑用户(返回副本)
  • MockBlockData 类 — Mock 数据工厂

职责: 管理全局黑名单状态。这是 NearPlay 中少数使用"模块级 let 变量 + exported function"模式而非 class 的模块。黑名单数据在模块顶层声明为 let blockedList: BlockedUser[] = [],通过五个导出函数提供操作接口。所有返回数组的方法都返回 [...blockedList] 副本,防止外部直接修改内部状态。

字段概要:

  • BlockedUser.id / nickname / avatar / blockedAt

与其他模块关系: BlockModel 是全局横切关注点,被 Index 页面(拉黑/取消拉黑、过滤附近用户和通知)、ChatPage 页面(判断聊天对象是否被拉黑、在聊天内拉黑/取消拉黑)依赖。BlockModel 不 import 任何其他 model。它之所以能成为独立模块,正是因为黑名单是一个正交于任何业务领域的横切功能——无论是附近的人、消息列表还是聊天详情,都需要查询"这个用户是否被拉黑"。

特别说明: BlockModel 不使用单例 Class、不使用 AppStorage,采用 exported function 模式。后文第七节将专门论证这一设计决策。

2.7 MusicModel

文件路径: model/MusicModel.ets

导出清单:

  • MusicGenre 枚举 — 10 种音乐流派 + UNKNOWN(POP / ROCK / HIPHOP / ELECTRONIC / JAZZ / CLASSICAL / RNB / FOLK / METAL / COUNTRY / UNKNOWN)
  • NowPlayingSong 类 — 正在播放的歌曲
  • getGenreIcon(genre: MusicGenre): string — 获取流派图标
  • classifyGenre(songTitle: string, artist: string): MusicGenre — 根据歌名和歌手分类流派
  • MockMusicData 类 — Mock 数据工厂

职责: 音乐流派分类是 NearPlay 匹配算法的第一维。MusicGenre 枚举定义了 10 种流派,classifyGenre 函数通过关键词匹配实现流派识别——内置了一个 genreKeywords 二维数组,每组关键词的最后一个元素是目标 MusicGenre,其余元素是匹配关键词。例如 ['流行', 'pop', '华语流行', 'POP', MusicGenre.POP] 表示当歌名或歌手名包含"流行"、“pop”、"华语流行"或"POP"时,分类为流行音乐。

字段概要:

  • NowPlayingSong.title / artist / genre / genreIcon

与其他模块关系: 被 MatchEngine(computeMusicMatch 需要 MusicGenreNowPlayingSong 类型)、Index 页面(enrichUsersWithMatch 中获取自己和附近人的正在播放歌曲)依赖。MusicModel 自身不 import 任何其他 model。

2.8 UsageModel

文件路径: model/UsageModel.ets

导出清单:

  • AppUsageRecord 类 — 单条应用使用记录
  • AppCategory 枚举 — 10 种应用分类(SOCIAL / ENTERTAINMENT / MUSIC / GAME / READING / VIDEO / WORK / SHOPPING / SPORTS / OTHER)
  • getCategoryIcon(cat: AppCategory): string — 获取分类图标
  • classifyApp(bundleName: string): AppCategory — 根据 bundleName 分类应用
  • getAppName(bundleName: string): string — 根据 bundleName 获取应用名
  • UsageProfile 类 — 用户使用画像(含 fromRecords 静态工厂方法)
  • MockUsageData 类 — Mock 数据工厂

职责: 应用使用行为分类是 NearPlay 匹配算法的第二维。classifyApp 函数通过内置的 19 条 bundleName→分类映射表,将原始的 app 使用记录归类到 10 个分类中。UsageProfile.fromRecords 是核心聚合函数——它接收一组 AppUsageRecord,按分类汇总使用时长,降序排列后取 Top3 分类,生成用户的"使用画像"。

字段概要:

  • AppUsageRecord.bundleName / appName / category / usageMinutes / lastUsedTime
  • UsageProfile.topCategories / totalMinutes / categoryMinutes

与其他模块关系: 被 MatchEngine(computeUsageMatch 需要 UsageProfileAppCategory 类型)、Index 页面(构建使用画像后传入匹配计算)依赖。UsageModel 自身不 import 任何其他 model。

19 条应用映射表: 微信/QQ/微博→社交、腾讯视频/优酷/B站→视频、网易云/酷狗/QQ音乐→音乐、王者荣耀/和平精英→游戏、多看阅读/Kindle→阅读、钉钉/企业微信→办公、淘宝/京东→购物、小米运动/Keep→运动。

2.9 MatchEngine

文件路径: model/MatchEngine.ets

导出清单:

  • MatchResult 类 — 匹配结果
  • computeMusicMatch(mySong, otherSong): boolean — 音乐匹配判断
  • computeUsageMatch(myProfile, otherProfile): number — 使用匹配评分
  • computeMatch(mySong, otherSong, myProfile, otherProfile): MatchResult — 综合匹配计算

职责: 纯计算引擎,没有任何副作用。computeMusicMatch 判断两人是否在听同一流派的音乐(UNKNOWN 流派不算匹配)。computeUsageMatch 通过 Top3 分类交集计算使用匹配评分——双方第一分类相同得 40 分,一方第一分类等于对方任意 Top3 得 25 分,双方第二分类相同得 20 分,其余交集得 10 分,上限 80 分。computeMatch 组合两者:使用匹配评分 + 音乐匹配额外 20 分,总分上限 100 分。MatchResult 还提供语义标签:音乐匹配时生成"同好·XX"标签,总分 80+ 为"匹配度·极高"、60+ 为"匹配度·高"、40+ 为"匹配度·中"、20+ 为"匹配度·低"。

字段概要:

  • MatchResult.totalScore / musicMatch / musicGenre / usageScore / matchedCategories / label / scoreLabel

与其他模块关系: import 了 MusicModelMusicGenre, NowPlayingSong)和 UsageModelAppCategory, UsageProfile)。这是 model 层中唯一有 import 依赖的非叶子模块。但它只 import 类型,不修改对方的数据,保持了单向数据依赖。

核心设计约束: MatchEngine 没有外部依赖——不依赖任何 model 层以外的代码,不依赖任何运行时服务,不持有任何状态。这使得它可以在任何环境中运行,包括单元测试、Worker 线程、未来的云函数。纯函数设计是 MatchEngine 最宝贵的架构特性。

2.10 RunAdvisorModel

文件路径: model/RunAdvisorModel.ets

导出清单:

  • RunPlan 类 — 跑步计划
  • FoodRecipe 类 — 食谱(含 getCalories() 方法)
  • UserRunProfile 类 — 用户运动画像
  • toggleLike(id: string): void — 切换点赞
  • isLiked(id: string): boolean — 判断是否已点赞
  • getLikeCount(id: string): number — 获取点赞数
  • initLikes(initial: Record<string, number>): void — 初始化点赞数据
  • MockRunAdvisorData 类 — Mock 数据工厂

职责: 运动社交子系统的数据核心。RunPlan 描述一个跑步计划(轻松跑/标准跑/耐力跑),FoodRecipe 描述一个食谱条目(含热量计算),UserRunProfile 聚合一个人的计划和食谱。点赞功能采用与 BlockModel 相同的"模块级 let 变量 + exported function"模式:let likeMap: Record<string, number> = {},通过 toggleLike / isLiked / getLikeCount 三个函数操作。

字段概要:

  • RunPlan.id / runType / runTypeName / targetDistance / targetPaceMin / targetPaceSec / durationMin / intensity / color / description / tips
  • FoodRecipe.id / name / category / caloriesPer100g / defaultGrams / description
  • UserRunProfile.userId / nickname / avatar / runPlans / foodRecipes

与其他模块关系: 被 UserRunProfilePage 页面依赖。该页面使用 toggleLike / isLiked / getLikeCount 实现点赞交互,并通过 likeRefresh++ 计数器触发 UI 重渲染(详见第七节论证)。RunAdvisorModel 不 import 任何其他 model。

LikeStore 设计: likeMap 是一个 Record<string, number>,key 是点赞目标 ID(如 "plan_plan_easy_3km""recipe_recipe_chicken_breast"),value 是点赞数。toggleLike 在 0 和 1 之间切换——这是一种简化实现,真实场景下 value 可以是任意正整数。

2.11 PermissionModel

文件路径: model/PermissionModel.ets

导出清单:

  • PermissionItem 类 — 权限条目
  • getDefaultPermissions(): PermissionItem[] — 获取默认权限列表

职责: 定义权限引导流程的数据。三个权限:读取正在播放的音乐(可选)、读取应用使用记录(可选)、获取位置信息(必需)。getDefaultPermissions 返回有序的权限列表,PermissionPage 按序逐个引导用户授权。

字段概要:

  • PermissionItem.id / title / description / icon / permissionName / isGranted / isRequired

与其他模块关系: 仅被 PermissionPage 页面依赖。PermissionModel 不 import 任何其他 model。它是整个应用最独立的模块之一——权限数据不与任何业务数据交叉。

2.12 RouteParams

文件路径: model/RouteParams.ets

导出清单:

  • GameRoomParams interface — 游戏房间路由参数(gameId / gameName)
  • ActivityDetailParams interface — 活动详情路由参数(activityId)
  • UserProfileParams interface — 用户资料路由参数(userId)
  • UserRunProfileParams interface — 用户运动画像路由参数(userId / nickname / avatar)
  • ChatPageParams class — 聊天页路由参数(conversationId / targetName / targetAvatar / targetId)

职责: 集中定义所有页面间路由传递的参数类型。将这些类型放在一个文件中而非散落在各页面,是因为多个页面可能传递相同的参数(例如 GameRoom 和各游戏页面都使用 GameRoomParams)。

与其他模块关系: 被几乎所有页面 import(Index、GameRoom、所有游戏页面、ActivityDetail、ActivityPublish、ChatPage、UserRunProfilePage)。RouteParams 自身不 import 任何其他模块。它是"被最多模块依赖但自身零依赖"的典型。

2.13 GameMessage

文件路径: model/GameMessage.ets

导出清单:

  • GameMessage 类 — 游戏协议消息(含 fromJson 静态方法和 toJson 实例方法)
  • MessageType 枚举 — 消息类型(ACTION / STATE / SYSTEM / PRIVATE)

职责: 定义游戏实时通信的协议格式。GameMessage 包含 type、gameId、roomId、fromUserId、toUserId、action、payload、timestamp 八个字段,覆盖了游戏过程中的动作消息、状态同步消息、系统消息和私聊消息四种类型。fromJsontoJson 提供了与 JSON 字符串的双向转换能力,是 WebSocket 通信的基础。

字段概要:

  • GameMessage.type / gameId / roomId / fromUserId / toUserId / action / payload / timestamp

与其他模块关系: 被 GameNetwork(解析收到的 WebSocket 消息为 GameMessage 对象)依赖。GameMessage 不 import 任何其他 model。它是通信层的数据协议,独立于任何游戏逻辑。

2.14 GameNetwork

文件路径: model/GameNetwork.ets

导出清单:

  • MessageCallback type — 消息回调类型
  • StatusCallback type — 连接状态回调类型
  • GameNetwork 类 — WebSocket 客户端

职责: 封装 @kit.NetworkKitwebSocket API,提供游戏实时通信能力。GameNetwork 类管理 WebSocket 连接的生命周期:connect(url) 建立连接,send(msg) 发送 GameMessage(自动调用 toJson() 序列化),onMessage(cb) 注册消息回调(自动调用 fromJson() 反序列化),onStatusChange(cb) 注册连接状态回调,close() 关闭连接。内置断线重连机制——最多重试 5 次,每次间隔 3 秒。

字段概要:

  • 内部字段:socket / messageCallback / statusCallback / serverUrl / reconnectAttempts / maxReconnect / isConnected
  • 公共属性:connected: boolean

与其他模块关系: import 了 GameMessage(反序列化收到的消息)和 @kit.NetworkKit(WebSocket API)。GameNetwork 是 model 层中少数依赖 HarmonyOS SDK 的模块。

2.15 VoiceInputHelper

文件路径: model/VoiceInputHelper.ets

导出清单:

  • VoiceInputHelper 类 — 语音识别辅助类

职责: 封装 @kit.CoreSpeechKitspeechRecognizer API,提供语音输入能力。这是一个普通类(不是 @Component),负责语音识别引擎的创建、监听器设置、录音计时、识别结果回调。startListening(onResult) 创建引擎、设置监听器、开始识别;stopListening() 停止识别、触发回调;destroy() 释放资源。

字段概要:

  • isListening / recognizedText / listenDuration / canSpeak — 公共状态
  • engine / durationTimerId / onResultCallback / sessionId — 私有实现细节

与其他模块关系: 被 VoiceInput 组件(@Component 包装器)和所有 6 个游戏页面直接 import。VoiceInputHelper 依赖 @kit.CoreSpeechKit。它是 model 层中另一个依赖 HarmonyOS SDK 的模块。

拆分故事: VoiceInputHelper 最初是 VoiceInput 组件的一部分——语音识别逻辑和 UI 渲染逻辑混在同一个 @Component 中。后来拆分为 Helper(model 层,纯逻辑)和 VoiceInput(components 层,纯 UI),原因和过程将在第三节详细讲述。

2.16 game/ 子目录六个游戏模型

game/ 子目录包含 6 个独立的游戏模型文件,每个文件定义一种游戏的完整数据模型:

2.16.1 WerewolfModel

文件路径: model/game/WerewolfModel.ets

导出清单: WerewolfPlayer / WerewolfRole(VILLAGER / WEREWOLF / SEER / WITCH / HUNTER / GUARD)/ WerewolfPhase(11 个阶段从 ROLE_ASSIGN 到 GAME_OVER)/ WerewolfNightAction / WerewolfVoteResult / getRoleName / getRoleIcon / MockWerewolfData

职责: 狼人杀游戏的完整状态机。WerewolfPhase 定义了 11 个阶段:角色分配→天黑→狼人回合→预言家回合→女巫回合→守卫回合→天亮结果→白天讨论→白天投票→投票结果→游戏结束。WerewolfNightAction 聚合一夜之间所有角色的行动结果。

2.16.2 ScriptKillModel

文件路径: model/game/ScriptKillModel.ets

导出清单: ScriptData / ScriptCharacter / ScriptNPC / NPCLine / ScriptAct / ScriptPhase(9 个阶段)/ MockScriptData

职责: 剧本杀游戏的数据结构。ScriptData 是一棵树:根节点包含背景故事、角色列表(ScriptCharacter 含背景和秘密)、NPC 列表(ScriptNPC 含台词 NPCLine)、幕列表(ScriptAct 含公开线索、私有线索、NPC 台词引用)。ScriptPhase 从选择剧本→角色分配→幕介绍→NPC 发言→自由讨论→线索揭示→幕总结→最终投票→真相揭示。

2.16.3 UndercoverModel

文件路径: model/game/UndercoverModel.ets

导出清单: UndercoverPlayer / UndercoverRoleType(CIVILIAN / UNDERCOVER / BLANK)/ UndercoverPhase(5 个阶段)/ WordPair / MockUndercoverData

职责: 谁是卧底游戏的数据结构。WordPair 存储平民词和卧底词,UndercoverRoleType 增加了"白板"角色(没有词的玩家),UndercoverPhase 从词语分配→描述回合→投票阶段→淘汰→最终揭示。

2.16.4 DrawGuessModel

文件路径: model/game/DrawGuessModel.ets

导出清单: DrawGuessPlayer / DrawPoint / GuessRecord / DrawGuessPhase / MockDrawGuessData

职责: 你画我猜游戏的数据结构。DrawPoint 是画布上的一个点,含坐标、是否新笔画、颜色、线宽——这是 Canvas 渲染的基础数据单元。GuessRecord 记录每次猜词的结果。

2.16.5 TruthOrDareModel

文件路径: model/game/TruthOrDareModel.ets

导出清单: TruthOrDarePlayer / ChoiceType(NONE / TRUTH / DARE)/ TruthOrDarePhase(5 个阶段)/ QuestionSubmission / MockTruthOrDareData

职责: 真心话大冒险游戏的数据结构。最大特色是"众包出题"机制——TruthOrDarePhase.CROWD_SOURCE 阶段让其他玩家提交问题(QuestionSubmission),再由当前玩家从中选择回答。

2.16.6 QuickReactModel

文件路径: model/game/QuickReactModel.ets

导出清单: QuickReactPlayer / FruitCard / FruitType(5 种水果)/ QuickReactPhase / getFruitEmoji / getFruitName / MockQuickReactData

职责: 看谁反应快(类 Halli Galli)游戏的数据结构。FruitCard 是核心数据单元——一张牌有水果类型和数量。QuickReactPhase 从发牌→翻牌→反应窗口→反应结果→罚牌→游戏结束。

game/ 子目录的共同特征: 六个模型互不依赖,各自定义独立的枚举、类和 Mock 数据。它们唯一共同依赖的 model 是 RouteParams(通过页面间 import),但模型文件本身不 import RouteParams——RouteParams 只在页面层使用。六个模型也不 import GameModel——GameModel 定义的是"游戏列表"层面的数据(GameItem、GameRoom),而 game/ 下定义的是具体游戏的运行时状态。这是两不同抽象层次的数据,分开管理是正确的。


三、components 层两个组件

3.1 GameChatPanel

文件路径: components/GameChatPanel.ets

依赖: ChatModel(ChatMsg / ChatMsgType)

职责: 游戏内嵌聊天面板。这是一个可折叠的面板——收起时只显示最新一条消息,展开时显示完整消息列表和输入区域。支持文本消息发送和语音录制(简化版,使用计时器模拟录音时长)。canSpeak 属性控制发言权限——在狼人杀等游戏中,不是所有阶段都能发言。

接口:

  • @Prop myId / myNickname / myAvatar — 当前用户信息
  • @Prop canSpeak: boolean — 是否可以发言
  • @State messages: ChatMsg[] — 消息列表(由外部传入或内部生成)
  • @State isExpanded: boolean — 是否展开

复用情况: 被 6 个游戏页面全部使用——WerewolfGame、ScriptKillGame、UndercoverGame、DrawGuessGame、TruthOrDareGame、QuickReactGame。如果没有这个组件,6 个游戏页面需要各自实现一套聊天 UI,代码重复量约为 6 × 120 行 = 720 行。

设计选择: GameChatPanel 直接复用 ChatModel 的 ChatMsg 类型,而不是定义一套"游戏消息"类型。这是因为聊天消息的语义在私聊和游戏内聊天中是完全一致的——都有发送者、消息类型(文本/语音/图片)、内容、时间戳。复用类型定义减少了概念负担和转换代码。

3.2 VoiceInput(含 VoiceInputHelper 拆分故事)

文件路径: components/VoiceInput.ets

依赖: VoiceInputHelper(model 层)

职责: 语音输入 UI 组件。显示麦克风按钮、录音状态指示器(录音中时长)、识别结果预览。canSpeak 控制是否允许发言。onVoiceResult 回调将识别结果传递给外部。

接口:

  • @Prop canSpeak: boolean — 是否可以发言
  • @State isListening / recognizedText / listenDuration — 录音状态
  • onVoiceResult: ((text: string) => void) | null — 识别结果回调

复用情况: 与 GameChatPanel 一样,被 6 个游戏页面全部使用。

VoiceInputHelper 拆分故事:

最初,语音识别的全部逻辑——包括 speechRecognizer.createEngine、设置 RecognitionListener、计时器管理、状态同步——都写在 VoiceInput 组件内部。这导致了几个问题:

  1. 不可测试: 语音识别逻辑被 @Component 装饰器包裹,无法在非 UI 环境中测试。
  2. 不可复用: 如果有非 UI 场景需要语音识别(例如后台语音指令),就需要复制逻辑。
  3. 职责混乱: VoiceInput 既管理 UI 状态(按钮样式、动画),又管理引擎生命周期(创建、销毁、回调),违反单一职责。

拆分后,VoiceInputHelper 成为一个普通类(plain class),只负责语音识别的引擎管理和回调处理。VoiceInput 成为一个薄 UI 包装器——它持有 VoiceInputHelper 实例,通过 200ms 间隔的定时器轮询 Helper 的状态(isListening / recognizedText / listenDuration)来同步 UI,在 Helper 回调触发时更新组件状态并通知外部。

这种"Helper(model)+ Wrapper(component)"的拆分模式,本质上是将"能力"和"表现"分离。Helper 可以在任何地方使用,Wrapper 只负责把 Helper 的状态映射到 ArkUI 的 @State 变量上。


四、pages 层十三个页面

pages 层共有 13 个页面文件,按功能分为四组。

4.1 核心导航页

Indexpages/Index.ets)— 应用主页面,5 个 Tab(首页/游戏/活动/消息/我的)

  • 依赖 model:UserModel / GameModel / ActivityModel / NotifyModel / ChatModel / BlockModel / MusicModel / UsageModel / MatchEngine / RouteParams
  • 这是依赖 model 最多的页面,因为它需要在首页 Tab 展示附近用户(含匹配标签)、游戏列表、活动列表,在消息 Tab 展示通知和聊天会话,在"我的"Tab 展示黑名单管理。它还是唯一使用 computeMatch 的页面——在 enrichUsersWithMatch 方法中,为每个附近用户计算匹配分数并填充 label。

4.2 游戏流程页(2 个)

GameRoompages/GameRoom.ets)— 游戏房间,匹配附近玩家

  • 依赖 model:RouteParams / UserModel
  • 从路由参数获取 gameId/gameName,展示在线用户,逐步扩大搜索半径匹配玩家(1km→10km),支持位置共享请求,匹配完成后路由到具体游戏页面。

6 个游戏页面pages/game/ 子目录):

页面 依赖的 game model 依赖的 components
WerewolfGame WerewolfModel GameChatPanel + VoiceInput + VoiceInputHelper
ScriptKillGame ScriptKillModel GameChatPanel + VoiceInput + VoiceInputHelper
UndercoverGame UndercoverModel GameChatPanel + VoiceInput + VoiceInputHelper
DrawGuessGame DrawGuessModel GameChatPanel + VoiceInput + VoiceInputHelper
TruthOrDareGame TruthOrDareModel GameChatPanel + VoiceInput + VoiceInputHelper
QuickReactGame QuickReactModel GameChatPanel + VoiceInput + VoiceInputHelper

所有 6 个游戏页面共享相同的 import 模式:RouteParams(路由参数)+ 对应的 game model(游戏状态和数据)+ GameChatPanel(聊天 UI)+ VoiceInput(语音输入 UI)+ VoiceInputHelper(语音识别逻辑)。ScriptKillGame 和 DrawGuessGame 还额外 import 了 @kit.CoreFileKit(文件选择,用于导入剧本)和 @kit.ArkUI(display 模块,用于获取屏幕尺寸)。

4.3 社交流程页(3 个)

ChatPagepages/ChatPage.ets)— 聊天详情页

  • 依赖 model:ChatModel / RouteParams / BlockModel
  • 依赖 SDK:@kit.CoreFileKit(图片选择器)
  • 从路由参数获取会话信息,展示消息流,支持文本/语音/图片消息,集成拉黑/取消拉黑功能。当检测到 isUserBlocked(targetId) 时显示禁言状态栏。

ActivityDetailpages/ActivityDetail.ets)— 活动详情页

  • 依赖 model:ActivityModel / NotifyModel / RouteParams
  • 从路由参数获取活动 ID,展示活动信息、参与者列表。如果是发起人(organizerId === ‘me’),额外展示报名管理面板(使用 NotifyModel 过滤该活动的报名通知)。

ActivityPublishpages/ActivityPublish.ets)— 发布活动页

  • 依赖 model:无(纯表单页面)
  • 这是一个例外——它不 import 任何 model,因为发布活动的表单数据是临时的 @State 变量,不需要持久化到 model 层。

4.4 设置与工具页(2 个)

PermissionPagepages/PermissionPage.ets)— 权限引导页

  • 依赖 model:PermissionModel
  • 依赖 SDK:@kit.AbilityKit(abilityAccessCtrl 权限请求 API)
  • 按序展示 3 个权限,用户可以选择"允许"或"暂不允许"(必需权限不显示跳过按钮),完成后跳转到 Index 页面。

UserRunProfilePagepages/UserRunProfilePage.ets)— 用户运动画像页

  • 依赖 model:RouteParams / RunAdvisorModel
  • 从路由参数获取用户信息,展示该用户的跑步计划和食谱,支持点赞交互(通过 toggleLike / isLiked / getLikeCount)。点赞后通过 likeRefresh++ 触发 UI 重渲染。

五、跨模块依赖关系图

以下是 NearPlay model 层的 ASCII 依赖关系图。箭头方向表示"import"关系,即 A → B 表示 A import 了 B。

                        ┌─────────────────────────────────────────────────┐
                        │                   pages 层                      │
                        │  Index  GameRoom  ChatPage  ActivityDetail ...  │
                        └──┬───────┬─────────┬──────────┬────────────────┘
                           │       │         │          │
              ┌────────────┼───────┼─────────┼──────────┼───────────────┐
              │            │       │         │          │               │
              ▼            ▼       ▼         ▼          ▼               ▼
  ┌────────────────────────────────────────────────────────────────────────┐
  │                           model 层                                       │
  │                                                                          │
  │  UserModel ─────────────────────────────────────────┐                  │
  │  GameModel ─────────────────────────────────────────┤                  │
  │  ActivityModel ─────────────────────────────────────┤                  │
  │  ChatModel ─────────────────────────────────────────┤                  │
  │  NotifyModel ───────────────────────────────────────┤                  │
  │  BlockModel ────────────────────────────────────────┤   被页面        │
  │  MusicModel ────────────┐                            │   直接          │
  │  UsageModel ────────────┼────────────────────────────┤   import        │
  │  PermissionModel ───────┤                            │                  │
  │  RouteParams ───────────┤                            │                  │
  │  RunAdvisorModel ───────┤                            │                  │
  │  GameMessage ──────┐    │                            │                  │
  │  VoiceInputHelper ─┤    │                            │                  │
  │                    │    │                            │                  │
  │  MatchEngine ◄─────┼────┼──── MusicModel             │                  │
  │       │            │    │     UsageModel             │                  │
  │       │            │    │                            │                  │
  │  GameNetwork ◄─────┘    │                            │                  │
  │       │                 │                            │                  │
  │       └──► GameMessage  │                            │                  │
  │                         │                            │                  │
  │  ┌──────── game/ ───────┤                            │                  │
  │  │ WerewolfModel       │                            │                  │
  │  │ ScriptKillModel     │                            │                  │
  │  │ UndercoverModel     │                            │                  │
  │  │ DrawGuessModel      │                            │                  │
  │  │ TruthOrDareModel    │                            │                  │
  │  │ QuickReactModel     │                            │                  │
  │  └─────────────────────┘                            │                  │
  └─────────────────────────────────────────────────────┼─────────────────┘
                                                         │
  ┌────────────────────────────────────────────────────┼──────────────────┐
  │                   components 层                       │                  │
  │  GameChatPanel ◄── ChatModel                         │                  │
  │  VoiceInput ◄── VoiceInputHelper                     │                  │
  └────────────────────────────────────────────────────┴─────────────────┘

简化版 model 内部依赖图:

  MatchEngine ──► MusicModel
       │
       └──────► UsageModel

  GameNetwork ──► GameMessage

  game/WerewolfModel      ──► (无)
  game/ScriptKillModel    ──► (无)
  game/UndercoverModel    ──► (无)
  game/DrawGuessModel     ──► (无)
  game/TruthOrDareModel   ──► (无)
  game/QuickReactModel    ──► (无)

  UserModel / GameModel / ActivityModel / ChatModel / NotifyModel
  BlockModel / PermissionModel / RouteParams / RunAdvisorModel
  VoiceInputHelper ──► (无内部 model 依赖,依赖 @kit.CoreSpeechKit)
  GameNetwork ──► GameMessage + @kit.NetworkKit

关键观察:

  1. model 层内部只有两条 import 边: MatchEngine→MusicModel、MatchEngine→UsageModel、GameNetwork→GameMessage。其余 13 个 model 文件之间零依赖。
  2. 不存在任何反向依赖: MusicModel 不知道 MatchEngine 的存在,GameMessage 不知道 GameNetwork 的存在。依赖是严格单向的。
  3. game/ 子目录完全独立: 6 个游戏模型之间零依赖,也不依赖外部的 GameModel 或其他 model。
  4. 横切模块独立: BlockModel、RouteParams、PermissionModel 被多个页面依赖,但自身零依赖。

六、循环依赖规避

6.1 问题背景

在 JavaScript/TypeScript 的模块系统中,循环依赖(circular dependency)是一个经典的工程问题。当 A import B、B 又 import A 时,模块加载顺序会导致其中一方拿到未初始化的导出,产生运行时错误。ArkTS 作为 TypeScript 的严格子集,同样面临这个问题。

在 NearPlay 的规模下,循环依赖的风险主要来自两个方向:

  1. 横切关注点: 如果 BlockModel 依赖 UserModel(获取用户信息来拉黑),而 UserModel 又依赖 BlockModel(查询某用户是否被拉黑),就会形成循环。
  2. 匹配计算: 如果 MatchEngine 的结果需要写入 NearUser(UserModel),而 NearUser 又需要引用 MatchResult(MatchEngine),也会形成循环。

6.2 BlockModel 独立设计规避循环依赖

BlockModel 的设计精确地规避了第一个风险。在最初的需求讨论中,有一种设计是让 NearUser 类包含一个 isBlocked: boolean 字段,由 UserModel 内部调用 BlockModel 的查询函数来填充。这会导致 UserModel → BlockModel 的依赖。同时,BlockModel 的 blockUser 函数需要构造 BlockedUser 对象,而 BlockedUser 包含 id / nickname / avatar——这些字段来自 NearUser,如果 BlockModel import NearUser,就形成 UserModel ↔ BlockModel 的循环依赖。

最终的设计是:

  • BlockedUser 是一个独立的类,不继承 NearUser,不引用 NearUser 的任何字段定义。
  • BlockModel 不 import UserModel。
  • UserModel 不 import BlockModel。
  • 页面层(Index、ChatPage)同时 import 两者,在页面代码中将 NearUser 的字段提取出来构造 BlockedUser:BlockedUser.of(user.id, user.nickname, user.avatar)

这种"在消费端组装"的模式,将依赖关系从 model 层内部提升到了 pages 层,而 pages 层作为顶层消费者,天然不会形成循环。

6.3 MatchEngine 无外部依赖规避循环依赖

MatchEngine 的设计规避了第二个风险。它只 import MusicModel 和 UsageModel 的类型(MusicGenre / NowPlayingSong / AppCategory / UsageProfile),不 import UserModel。匹配结果的写入不在 MatchEngine 内部完成,而是在 Index 页面的 enrichUsersWithMatch 方法中——该方法调用 computeMatch 获得 MatchResult,然后将 MatchResult 的 label 和 score 写入 NearUser 的对应字段。

如果 MatchEngine 直接返回 NearUser(即 MatchEngine import UserModel),就会形成潜在的循环依赖链——未来如果 UserModel 需要显示匹配信息而 import MatchEngine,就会形成 UserModel ↔ MatchEngine 的循环。通过让 MatchEngine 只依赖纯粹的数据类型(MusicGenre / NowPlayingSong / AppCategory / UsageProfile),它成为了一个可以放在任何地方使用的计算工具。

6.4 game/ 子目录独立规避循环依赖

6 个游戏模型被放在 game/ 子目录中,而不是与 GameModel 合并,也是为了规避循环依赖。如果狼人杀的 WerewolfModel import GameModel(获取游戏基础信息),而 GameModel 又需要知道每种游戏的状态类型(用于 GameRoom 页面的统一处理),就会形成 GameModel ↔ game/* 的循环。

当前设计是:GameModel 只定义"游戏列表"层面的数据(有哪些游戏、房间状态),game/ 下的模型只定义具体游戏的运行时状态。两者通过 RouteParams(gameId / gameName)在页面层桥接,不形成 model 层内部的依赖。


七、BlockModel 模块级状态设计论证

7.1 三种可选方案

BlockModel 管理的是全局黑名单状态——一个用户列表,支持增删查。在 ArkTS 中,实现这种"全局可变状态"有三种方案:

  1. 单例 Class(static instance 模式)
  2. AppStorage(HarmonyOS 全局状态)
  3. 模块级 let 变量 + exported function(当前方案)

下文逐一分析,重点讲述方案一为何被否决——它的 bug 发现过程是本节最有价值的部分。

7.2 方案一:单例 Class 及其跨文件 static instance 不同步 bug

最初,BlockModel 使用单例 Class 实现:

export class BlockManager {
  private static instance: BlockManager | null = null
  private blockedList: BlockedUser[] = []

  static getInstance(): BlockManager {
    if (BlockManager.instance === null) {
      BlockManager.instance = new BlockManager()
    }
    return BlockManager.instance
  }

  isUserBlocked(userId: string): boolean { ... }
  blockUser(user: BlockedUser): void { ... }
  unblockUser(userId: string): void { ... }
}

这在 TypeScript 中是经典模式,在 Node.js 等环境中工作正常。但在 ArkTS 的模块加载机制中,出现了严重的 bug:在不同文件中调用 BlockManager.getInstance() 获得的实例,其内部状态不同步

Bug 发现过程

第一步:现象观察。 在 Index 页面拉黑一个用户后,切换到 ChatPage 页面,发现该用户的聊天消息仍然正常显示——isUserBlocked 返回了 false。但回到 Index 页面,该用户确实从附近列表消失了——说明 Index 页面内的 BlockManager 实例状态是正确的。

第二步:初步排查。blockUser 方法中加入日志,发现 ChatPage 中的 BlockManager 实例的 blockedList 确实是空的——不是旧数据,而是从未被修改过的初始数据。这说明两个页面拿到的是不同的实例,或者更准确地说,是同一个 Class 的不同内存空间。

第三步:根因定位。 经过对 ArkTS 模块加载机制的分析,发现问题出在 static 属性的跨文件行为。在 ArkTS 的编译和运行时环境中,每个文件对 BlockManager 类的 import 可能触发独立的类对象初始化。static instance 属性附加在类对象上,但如果不同文件看到了不同的类对象副本,那么 BlockManager.instance 就是各自独立的——A 文件设置的 instance,B 文件看不到。

这是 ArkTS 与标准 TypeScript 的关键差异之一。在 Node.js 的 CommonJS 模块系统中,require() 返回的是缓存过的模块导出对象,类定义是同一个引用。但在 HarmonyOS 的方舟编译器(Ark Compiler)中,模块的加载和链接策略可能使得跨文件的类引用不是同一个对象。static 属性作为类对象的属性,自然也不同步。

第四步:验证。static instance 替换为模块级变量,问题立即消失。这确认了根因:问题不在于单例模式本身的逻辑,而在于 ArkTS 运行时中 static 属性的跨文件语义与预期不符。

第五步:通用化认知。 这个 bug 不仅影响 BlockModel,也影响任何使用 static instance 单例模式的模块。它是一个 ArkTS 平台特性,不是特定模块的 bug。因此,我们在整个项目中禁止了 static instance 单例模式。

7.3 方案二:AppStorage 及其不够灵活的问题

AppStorage 是 HarmonyOS 提供的全局状态管理机制,通过 AppStorage.setOrCreate('key', value)AppStorage.get('key') 在应用全局共享状态。它天然解决了跨文件同步问题——AppStorage 的数据存储在应用级别的运行时中,所有组件和模块访问的是同一份数据。

然而,AppStorage 有两个不适合 BlockModel 的限制:

限制一:数据类型必须是 @State 可观察类型。 AppStorage 设计的初衷是驱动 UI 更新,它存储的值需要能被 ArkUI 的状态观察机制追踪。BlockedUser[] 是一个自定义类数组,虽然可以通过 @Observed 装饰器使其可观察,但 BlockModel 的核心需求是"查询"(isUserBlocked),而不是"驱动 UI"——UI 的刷新由页面的 @State 变量负责,不需要 AppStorage 参与。

限制二:缺乏操作封装。 AppStorage 只提供 set / get / setOrCreate 等原语操作。如果黑名单数据放在 AppStorage 中,每次操作都需要先 get 出来、修改、再 set 回去,代码分散在各个页面中,无法封装为 blockUser(id) / unblockUser(id) 这样的语义化操作。而语义化操作恰恰是 BlockModel 的价值——页面不需要知道黑名单的内部存储结构,只需要调用函数。

限制三:类型安全弱。 AppStorage 的 key 是字符串,值类型在运行时才确定。AppStorage.get('blockedList') 返回的是 Object | undefined,需要手动类型转换。相比之下,模块级变量 + exported function 天然是类型安全的——getBlockedUsers() 的返回类型就是 BlockedUser[],编译器可以检查。

7.4 方案三:模块级 let 变量 + exported function——当前方案的优势

当前 BlockModel 的实现:

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]
}

优势一:跨文件状态同步。 模块级变量(let blockedList)属于模块的词法作用域。在 ArkTS 的模块系统中,一个模块无论被多少文件 import,其顶层变量只初始化一次,所有 import 方共享同一个模块实例。这保证了 Index 页面修改的 blockedList,ChatPage 页面立即可见——因为它们操作的是同一块内存。

优势二:操作封装。 所有对 blockedList 的修改都通过 exported function 完成,调用方无需知道内部存储结构。blockUser 自动去重(通过 isUserBlocked 前置检查),unblockUser 使用 filter 返回新数组(不可变更新),getBlockedUsers 返回副本(防止外部直接修改内部数组)。

优势三:类型安全。 每个函数的参数和返回类型都是明确的,编译器完整检查。没有字符串 key,没有运行时类型转换。

优势四:可测试。 通过 initBlockList 可以在测试开始时设置任意初始状态,测试结束后重置。这比单例模式(需要添加 reset 方法)和 AppStorage(需要清理 key)都更干净。

优势五:UI 刷新由消费方控制。 BlockModel 本身不关心 UI 刷新——它只管理数据。页面在调用 blockUser / unblockUser 后,自行通过 this.blockedUsers = getBlockedUsers() 更新 @State 变量来触发 UI 刷新。这种"数据层不感知 UI"的设计是最干净的分层。

7.5 LikeStore 采用相同模式及其 likeRefresh++ 技巧

RunAdvisorModel 的 LikeStore 采用了与 BlockModel 相同的"模块级 let + exported function"模式:

let likeMap: Record<string, number> = {}

export function toggleLike(id: string): void { ... }
export function isLiked(id: string): boolean { ... }
export function getLikeCount(id: string): number { ... }

LikeStore 有一个额外的设计考量:UI 刷新触发。在 UserRunProfilePage 中,点赞操作后需要刷新 UI。但 toggleLike 是一个 void 函数,它不返回新状态,也不触发任何回调。页面如何知道状态变了?

答案是 likeRefresh++ 技巧。UserRunProfilePage 定义了一个 @State likeRefresh: number = 0,每次调用 toggleLike 后手动递增:

doToggleLike(id: string): void {
  toggleLike(id)
  this.likeRefresh++
}

likeRefresh 本身在 build 方法中不被直接使用,但递增它触发了 ArkUI 的脏标记机制——组件被标记为需要重新渲染。在重新渲染时,build 方法中的 isLiked(...)getLikeCount(...) 会被重新调用,获取最新数据。

这个技巧的本质是:用一个无语义的 @State 计数器作为"版本号",手动通知 ArkUI"数据可能变了,请重新读取"。 这比将 likeMap 放入 @State 或 AppStorage 更轻量——@State 变量不需要持有完整的数据,只需要一个整数。


八、模块粒度选择

8.1 为什么 MusicModel 和 UsageModel 不合并为 MatchModel

MusicModel 和 UsageModel 分别定义了音乐流派分类和应用使用分类的数据和逻辑,而 MatchEngine 是唯一同时消费两者的模块。既然它们都是"匹配"的输入维度,为什么不在一个 MatchModel 中统一定义?

原因一:独立的演变速率。 音乐流派分类规则可能因为曲库更新而频繁调整——新增一个 EDM 子类型、修改嘻哈的匹配关键词。应用使用分类规则可能因为新 App 上架而调整——新增一个社交 App 的 bundleName 映射。两者变化的触发条件不同、变化的频率不同、变化的审核流程也不同。合并后,任何一方的修改都需要重新审查整个 MatchModel,增加了认知负担和合并冲突的风险。

原因二:独立的复用场景。 MusicModel 的 classifyGenre 函数不仅被 MatchEngine 使用,未来可能被"正在播放"页面直接使用来展示当前歌曲的流派标签。UsageModel 的 classifyApp 函数不仅被 MatchEngine 使用,未来可能被"数字健康"页面直接使用来展示用户的 App 使用分布。如果两者合并,不关心匹配计算的页面也需要 import 整个 MatchModel。

原因三:可测试性。 classifyGenre 的测试只需要构造歌名和歌手,不涉及 App 使用记录。classifyApp 的测试只需要构造 bundleName,不涉及歌曲信息。合并后,测试文件的 import 会更重,测试夹具的构建会更复杂。

原因四:依赖关系清晰。 MatchEngine import MusicModel 和 UsageModel 是两条明确的边,如果合并为一个 MatchModel,MatchEngine 只需要 import 一个 MatchModel——看似简化了,但模糊了"匹配引擎到底依赖哪些数据维度"这个问题。两个 import 让依赖关系一目了然:音乐匹配 + 使用匹配。

8.2 为什么 game/ 子目录单独存在

6 个游戏模型被放在 model/game/ 子目录中,而不是直接放在 model/ 下与 GameModel 并列。这个决定基于三个考量:

考量一:命名空间隔离。 6 个游戏模型都定义了各自的 Player 类、Phase 枚举、MockData 类。如果放在 model/ 根目录,文件名就需要加前缀来避免混淆——WerewolfPlayer vs UndercoverPlayer 不如 game/WerewolfModel vs game/UndercoverModel 的目录级隔离清晰。子目录本身就是一种命名空间。

考量二:概念层次区分。 GameModel 定义的是"游戏大厅"层面的概念——有哪些游戏、房间是什么状态。game/ 下的模型定义的是"游戏进行中"的概念——狼人杀的夜晚行动、剧本杀的 NPC 台词、你画我猜的画布点。这是两个不同抽象层次的数据,放在不同的目录层级是自然的映射。

考量三:按需加载。 虽然当前 ArkTS 的模块加载策略是全量编译,但未来如果支持按需加载(类似 Web 的 code splitting),game/ 子目录可以整体懒加载——用户只有在点击"开始游戏"后才需要下载游戏逻辑代码。如果游戏模型与大厅模型混在一起,就无法实现这种分割。

8.3 为什么 GameMessage 和 GameNetwork 不合并

GameMessage 定义协议格式,GameNetwork 实现传输通道。两者职责明确不同,但它们总是成对使用——GameNetwork import GameMessage 来序列化/反序列化消息。为什么不合为一个"GameComm"模块?

原因:协议与传输的分离是网络架构的基本原则。 GameMessage 可以有多种传输实现——当前是 WebSocket,未来可能是长轮询、MQTT、甚至蓝牙直连。GameNetwork 也可以传输不同协议的消息——当前只传 GameMessage,未来可能传心跳包、文件块。分离后,任何一方的替换都不影响另一方。

此外,GameMessage 的 fromJson / toJson 方法是纯数据处理,不依赖任何网络 SDK。如果合并到 GameNetwork 中,测试 JSON 序列化就需要 mock WebSocket 环境——这是不必要的复杂度。


九、未来重构方向

9.1 Service 层抽象

当前 model 层混合了三种角色:

  1. 数据类型定义(NearUser / GameItem / ChatMsg)
  2. 领域逻辑(classifyGenre / computeMatch / toggleLike)
  3. 数据获取(MockUserData.getNearbyUsers / MockMusicData.getMyNowPlaying)

在 MVP 阶段,这种混合是合理的——所有数据都来自 Mock,没有真正的 I/O。但当后端 API 就绪后,"数据获取"需要从 model 层剥离为独立的 Service 层。

重构路径:

当前:
  pages → model (类型 + 逻辑 + Mock数据获取)

目标:
  pages → model (类型 + 逻辑)
        → service (API调用,返回 model 类型)

具体步骤:

  1. 定义 Service 接口(不依赖具体实现):
export interface UserService {
  getNearbyUsers(): Promise<NearUser[]>
  requestLocationShare(toUserId: string): Promise<LocationShareRequest>
}
  1. 实现 MockService(包装当前的 Mock 数据):
export class MockUserService implements UserService {
  getNearbyUsers(): Promise<NearUser[]> {
    return Promise.resolve(MockUserData.getNearbyUsers())
  }
}
  1. 实现 RealService(调用 HTTP API):
export class RealUserService implements UserService {
  async getNearbyUsers(): Promise<NearUser[]> {
    const response = await http.get('/api/nearby/users')
    return response.data as NearUser[]
  }
}
  1. 在应用入口注入 Service 实现:
let userService: UserService = new MockUserService()
export function getUserService(): UserService { return userService }
export function setUserService(s: UserService): void { userService = s }

这种"接口 + 注入"的模式,使得从 Mock 到 Real 的迁移是渐进式的——一个 Service 一个 Service 地替换,不需要一次性重写。

9.2 Mock 替换为真实 API 的路径

当前每个 model 都有对应的 Mock 数据类(MockUserData / MockGameData / MockActivityData / …),它们都是 static 方法,返回硬编码数据。替换为真实 API 时,需要注意以下几点:

1. 异步化: Mock 方法是同步的(直接 return 数据),真实 API 是异步的(Promise / callback)。页面中的 aboutToAppear 需要改为异步模式:

async aboutToAppear(): Promise<void> {
  this.nearbyUsers = await userService.getNearbyUsers()
}

ArkUI 的 aboutToAppear 支持异步函数,但需要注意在数据返回前展示加载状态。

2. 错误处理: Mock 数据永远不会失败,真实 API 会。需要统一错误处理策略——网络错误、服务器错误、数据格式错误都需要有用户可感知的反馈。

3. 缓存策略: Mock 数据是内存中的,"获取"操作没有性能代价。真实 API 需要缓存——附近用户列表不需要每次进入首页都重新请求。Service 层应该内置缓存逻辑(带 TTL),对页面层透明。

4. 分页加载: Mock 数据量小(8 个用户、6 个游戏、4 个活动),可以一次性加载。真实数据量可能很大,需要分页。这需要修改 model 的数据结构(增加分页元信息)和页面的渲染逻辑(增加"加载更多"触发器)。

9.3 GameNetwork 从模拟到真实的演进

当前 GameNetwork 已经封装了 WebSocket 连接管理,但页面层还没有真正使用它——6 个游戏页面的游戏逻辑完全是本地模拟的。未来演进路径:

  1. 阶段一: 游戏页面使用 GameNetwork 发送/接收 GameMessage,但游戏逻辑仍在本地计算(状态机驱动)。这是"伪在线"模式——消息走网络,但状态不依赖服务器。

  2. 阶段二: 引入 GameServer,游戏逻辑在服务器端计算,客户端只负责展示和输入。GameMessage 的 action 字段从本地消费变为网络传输——客户端发送玩家动作,服务器返回状态更新。

  3. 阶段三: GameNetwork 增加房间管理 API(创建房间、加入房间、离开房间),与 GameRoom 页面的匹配逻辑整合。当前 GameRoom 的匹配是本地过滤在线用户,未来应该是服务器端的匹配队列。

9.4 状态管理升级

当前的"模块级 let + exported function"模式在应用规模增长后可能面临挑战:

  • 多实例问题: 如果应用需要支持多账号切换,模块级变量是全局唯一的,无法为不同账号维护独立状态。
  • 持久化问题: 模块级变量在应用重启后丢失,需要额外的持久化层。
  • 调试困难: 模块级变量的修改无法被 ArkUI DevTools 追踪。

未来可以考虑引入轻量级状态管理方案,如基于 @Observed / @ObjectLink 的响应式模型类,或社区的状态管理库。但核心原则不变:状态的修改必须通过封装的函数/方法,而不是直接赋值。这个原则无论是 exported function 还是 class method 都应该坚持。

9.5 组件化深化

当前 GameChatPanel 和 VoiceInput 是仅有的两个共享组件。随着功能增长,可以提取更多组件:

  • UserCard:附近用户卡片(Index 页面的 HomeContent 和 NearbyUserCard 中重复出现)
  • ActivityCard:活动卡片(Index 页面和 ActivityDetail 页面中重复出现)
  • MatchBadge:匹配标签(“同好·流行” “匹配度·高”)
  • BlockAction:拉黑/取消拉黑操作按钮(Index 和 ChatPage 中重复出现)

组件化深化的判断标准是:同一 UI 片段在 3 个以上位置出现,或者未来可能出现时。不要过早抽象——2 个位置重复是可以容忍的,3 个位置重复才值得提取。


附录:模块文件清单与代码行数

模块 文件路径 代码行数
UserModel model/UserModel.ets 54
GameModel model/GameModel.ets 61
ActivityModel model/ActivityModel.ets 36
ChatModel model/ChatModel.ets 92
NotifyModel model/NotifyModel.ets 42
BlockModel model/BlockModel.ets 49
MusicModel model/MusicModel.ets 87
UsageModel model/UsageModel.ets 176
MatchEngine model/MatchEngine.ets 87
RunAdvisorModel model/RunAdvisorModel.ets 187
PermissionModel model/PermissionModel.ets 24
RouteParams model/RouteParams.ets 25
GameMessage model/GameMessage.ets 51
GameNetwork model/GameNetwork.ets 103
VoiceInputHelper model/VoiceInputHelper.ets 122
WerewolfModel model/game/WerewolfModel.ets 86
ScriptKillModel model/game/ScriptKillModel.ets 108
UndercoverModel model/game/UndercoverModel.ets 69
DrawGuessModel model/game/DrawGuessModel.ets 62
TruthOrDareModel model/game/TruthOrDareModel.ets 75
QuickReactModel model/game/QuickReactModel.ets 77
GameChatPanel components/GameChatPanel.ets 183
VoiceInput components/VoiceInput.ets 100
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐