基于鸿蒙OS开发附近社交游戏平台(四)-模块划分与依赖关系
模块划分与依赖关系
一、模块化设计理念
1.1 为什么选择 model / components / pages 三层分离
NearPlay 应用从一开始就确立了三层目录架构:model/、components/、pages/。这不是随意的文件分组,而是经过深思熟虑的架构决策。
model 层承载所有业务数据结构、领域逻辑和状态管理。一个 model 文件只做一件事:定义该领域的数据类型、提供对数据的操作函数、以及仅限该领域的模块级私有状态。例如 BlockModel.ets 只关心黑名单的增删查,MusicModel.ets 只关心音乐流派分类,MatchEngine.ets 只关心匹配分数计算。每个 model 都是一个自包含的知识边界——如果某个功能的数据和逻辑跨越了两个领域,那就说明边界划分有问题,需要重新审视。
components 层承载可复用的 UI 组件。这些组件只依赖 model 层的类型定义,不包含任何页面级导航逻辑。GameChatPanel 和 VoiceInput 两个组件被多个游戏页面共享,如果它们被放在 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 了 MusicModel 和 UsageModel——因为它需要两者的类型来计算匹配分数,但不需要也不应该修改两者的数据。
1.3 可测试性考量
三层分离带来的另一个好处是可测试性。model 层的函数大多是纯函数或模块级状态操作,可以脱离 ArkUI 框架独立测试。例如 computeMatch 函数接受 NowPlayingSong 和 UsageProfile 两个参数,返回 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 / matchLabelLocationShareRequest.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 / canImportContentGameRoom.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 / unreadCountChatMsg.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 需要 MusicGenre 和 NowPlayingSong 类型)、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 / lastUsedTimeUsageProfile.topCategories / totalMinutes / categoryMinutes
与其他模块关系: 被 MatchEngine(computeUsageMatch 需要 UsageProfile 和 AppCategory 类型)、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 了 MusicModel(MusicGenre, NowPlayingSong)和 UsageModel(AppCategory, 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 / tipsFoodRecipe.id / name / category / caloriesPer100g / defaultGrams / descriptionUserRunProfile.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
导出清单:
GameRoomParamsinterface — 游戏房间路由参数(gameId / gameName)ActivityDetailParamsinterface — 活动详情路由参数(activityId)UserProfileParamsinterface — 用户资料路由参数(userId)UserRunProfileParamsinterface — 用户运动画像路由参数(userId / nickname / avatar)ChatPageParamsclass — 聊天页路由参数(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 八个字段,覆盖了游戏过程中的动作消息、状态同步消息、系统消息和私聊消息四种类型。fromJson 和 toJson 提供了与 JSON 字符串的双向转换能力,是 WebSocket 通信的基础。
字段概要:
GameMessage.type / gameId / roomId / fromUserId / toUserId / action / payload / timestamp
与其他模块关系: 被 GameNetwork(解析收到的 WebSocket 消息为 GameMessage 对象)依赖。GameMessage 不 import 任何其他 model。它是通信层的数据协议,独立于任何游戏逻辑。
2.14 GameNetwork
文件路径: model/GameNetwork.ets
导出清单:
MessageCallbacktype — 消息回调类型StatusCallbacktype — 连接状态回调类型GameNetwork类 — WebSocket 客户端
职责: 封装 @kit.NetworkKit 的 webSocket 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.CoreSpeechKit 的 speechRecognizer 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 组件内部。这导致了几个问题:
- 不可测试: 语音识别逻辑被
@Component装饰器包裹,无法在非 UI 环境中测试。 - 不可复用: 如果有非 UI 场景需要语音识别(例如后台语音指令),就需要复制逻辑。
- 职责混乱: 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 核心导航页
Index(pages/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 个)
GameRoom(pages/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 个)
ChatPage(pages/ChatPage.ets)— 聊天详情页
- 依赖 model:ChatModel / RouteParams / BlockModel
- 依赖 SDK:
@kit.CoreFileKit(图片选择器) - 从路由参数获取会话信息,展示消息流,支持文本/语音/图片消息,集成拉黑/取消拉黑功能。当检测到
isUserBlocked(targetId)时显示禁言状态栏。
ActivityDetail(pages/ActivityDetail.ets)— 活动详情页
- 依赖 model:ActivityModel / NotifyModel / RouteParams
- 从路由参数获取活动 ID,展示活动信息、参与者列表。如果是发起人(organizerId === ‘me’),额外展示报名管理面板(使用 NotifyModel 过滤该活动的报名通知)。
ActivityPublish(pages/ActivityPublish.ets)— 发布活动页
- 依赖 model:无(纯表单页面)
- 这是一个例外——它不 import 任何 model,因为发布活动的表单数据是临时的 @State 变量,不需要持久化到 model 层。
4.4 设置与工具页(2 个)
PermissionPage(pages/PermissionPage.ets)— 权限引导页
- 依赖 model:PermissionModel
- 依赖 SDK:
@kit.AbilityKit(abilityAccessCtrl 权限请求 API) - 按序展示 3 个权限,用户可以选择"允许"或"暂不允许"(必需权限不显示跳过按钮),完成后跳转到 Index 页面。
UserRunProfilePage(pages/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
关键观察:
- model 层内部只有两条 import 边: MatchEngine→MusicModel、MatchEngine→UsageModel、GameNetwork→GameMessage。其余 13 个 model 文件之间零依赖。
- 不存在任何反向依赖: MusicModel 不知道 MatchEngine 的存在,GameMessage 不知道 GameNetwork 的存在。依赖是严格单向的。
- game/ 子目录完全独立: 6 个游戏模型之间零依赖,也不依赖外部的 GameModel 或其他 model。
- 横切模块独立: BlockModel、RouteParams、PermissionModel 被多个页面依赖,但自身零依赖。
六、循环依赖规避
6.1 问题背景
在 JavaScript/TypeScript 的模块系统中,循环依赖(circular dependency)是一个经典的工程问题。当 A import B、B 又 import A 时,模块加载顺序会导致其中一方拿到未初始化的导出,产生运行时错误。ArkTS 作为 TypeScript 的严格子集,同样面临这个问题。
在 NearPlay 的规模下,循环依赖的风险主要来自两个方向:
- 横切关注点: 如果 BlockModel 依赖 UserModel(获取用户信息来拉黑),而 UserModel 又依赖 BlockModel(查询某用户是否被拉黑),就会形成循环。
- 匹配计算: 如果 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 中,实现这种"全局可变状态"有三种方案:
- 单例 Class(static instance 模式)
- AppStorage(HarmonyOS 全局状态)
- 模块级
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 层混合了三种角色:
- 数据类型定义(NearUser / GameItem / ChatMsg)
- 领域逻辑(classifyGenre / computeMatch / toggleLike)
- 数据获取(MockUserData.getNearbyUsers / MockMusicData.getMyNowPlaying)
在 MVP 阶段,这种混合是合理的——所有数据都来自 Mock,没有真正的 I/O。但当后端 API 就绪后,"数据获取"需要从 model 层剥离为独立的 Service 层。
重构路径:
当前:
pages → model (类型 + 逻辑 + Mock数据获取)
目标:
pages → model (类型 + 逻辑)
→ service (API调用,返回 model 类型)
具体步骤:
- 定义 Service 接口(不依赖具体实现):
export interface UserService {
getNearbyUsers(): Promise<NearUser[]>
requestLocationShare(toUserId: string): Promise<LocationShareRequest>
}
- 实现 MockService(包装当前的 Mock 数据):
export class MockUserService implements UserService {
getNearbyUsers(): Promise<NearUser[]> {
return Promise.resolve(MockUserData.getNearbyUsers())
}
}
- 实现 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[]
}
}
- 在应用入口注入 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 个游戏页面的游戏逻辑完全是本地模拟的。未来演进路径:
-
阶段一: 游戏页面使用 GameNetwork 发送/接收 GameMessage,但游戏逻辑仍在本地计算(状态机驱动)。这是"伪在线"模式——消息走网络,但状态不依赖服务器。
-
阶段二: 引入 GameServer,游戏逻辑在服务器端计算,客户端只负责展示和输入。GameMessage 的 action 字段从本地消费变为网络传输——客户端发送玩家动作,服务器返回状态更新。
-
阶段三: 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 |
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)