NearPlay Mock数据架构设计

目录

  1. Mock设计哲学
  2. 静态工厂方法模式
  3. 9个Mock数据类完整数据展示
  4. Mock定时器缩短策略
  5. 定时器模拟服务器消息
  6. try-catch降级策略详解
  7. Mock→真实API切换路径
  8. Mock数据的真实性考量

1. Mock设计哲学

1.1 前端先行原则

NearPlay 项目采用「前端先行」的开发策略,即在后端服务尚未就绪的阶段,前端团队已经可以独立完成全部 UI 开发、交互逻辑验证和用户体验优化。这一策略的核心前提是:Mock 数据层必须足够完善,能够覆盖所有业务场景的数据需求。

在传统开发模式中,前端往往需要等待后端接口定义完成、联调环境搭建完毕后才能真正开始有意义地开发。这种串行依赖导致项目周期被拉长,尤其是在多人协作的场景下,前后端进度的不同步会形成瓶颈。NearPlay 的 Mock 架构彻底打破了这个依赖链:

传统模式(串行依赖):

  后端定义接口 → 后端实现 → 联调环境 → 前端开发 → 前后端联调 → 交付
  ────────────────────────────────────────────────────────────→ 时间

NearPlay Mock模式(并行开发):

  接口文档定义 ─┬→ 后端实现
                │
                └→ Mock数据层 → 前端独立开发 → 前后端联调 → 交付
  ────────────────────────────────────────────→ 时间(大幅缩短)

前端先行不是简单的「造假数据」,而是一套完整的数据架构。它要求 Mock 数据在结构、类型、取值范围上与预期的真实接口保持严格一致,这样当前端切换到真实 API 时,只需要替换数据源,而不需要修改任何 UI 逻辑或类型定义。NearPlay 中每一个 Model 类(如 NearUserActivityItemChatMsg)既是 Mock 的载体,也是未来真实数据的类型契约。

1.2 接口模拟与类型契约

Mock 数据不仅仅是填充列表的静态值,它还承担着定义接口契约的职责。在 NearPlay 中,每个数据类的属性定义就是接口的「隐式 Schema」。例如:

NearUser 类属性 = 未来 GET /api/nearby-users 响应体的字段定义
  ├── id: string          → 用户唯一标识
  ├── nickname: string    → 昵称
  ├── avatar: string      → 头像(当前为 Emoji,未来为 URL)
  ├── distance: number    → 距离(km)
  ├── isOnline: boolean   → 在线状态
  ├── latitude: number    → 纬度
  ├── longitude: number   → 经度
  └── currentGame: string → 当前参与的游戏名

当后端团队开始实现时,他们可以直接参照 Model 类的字段定义来构建响应结构,而不需要额外维护一份 API 文档。这种「代码即文档」的方式减少了前后端沟通成本,也避免了文档与实现不一致的经典问题。

1.3 可替换架构

Mock 数据层的核心设计原则是「可替换性」。整个数据获取链路被设计为三层结构:

┌───────────────────────────────────────────┐
│              UI层(Pages/Components)          │
│  WerewolfGame.ets / ChatPage.ets / ...      │
├────────────────────────────────────────────┤
│              数据获取层(调用入口)             │
│  MockUserData.getNearbyUsers()               │
│  MockActivityData.getActivities()            │
│  MockChatData.getConversations()             │
├────────────────────────────────────────────┤
│              数据定义层(Model Classes)        │
│  NearUser / ActivityItem / ChatMsg / ...     │
└───────────────────────────────────────────┘

UI 层只依赖数据获取层的静态方法和数据定义层的类型。当真实 API 到来时,我们只需要在数据获取层增加一个 UserService 类,提供同样的 getNearbyUsers() 方法签名,然后修改 UI 层的调用来源即可。数据定义层的 Model 类完全不需要改动,因为它们是纯粹的数据结构定义,不包含任何 Mock 逻辑。

这种可替换架构的实现依赖于 ArkTS 的静态类型系统。由于所有调用都通过明确的类方法进行,编译器会在切换数据源时帮助检查类型兼容性,防止因接口不一致导致的运行时错误。

1.4 前后端分离的边界

NearPlay 的前后端分离不是简单地把代码拆成两个仓库,而是在数据层的边界上画了一条清晰的线。Mock 数据类(如 MockUserDataMockActivityData)只存在于前端项目中,后端完全不知道它们的存在。而数据模型类(如 NearUserActivityItem)则是前后端共享的类型契约。

前端项目                    后端项目
┌──────────────┐           ┌──────────────┐
│ MockUserData │           │              │
│ MockActivity │           │  真实数据库    │
│ MockChatData │           │  真实业务逻辑  │
├──────────────┤           ├──────────────┤
│ NearUser     │ ← 共享 →  │ NearUser     │
│ ActivityItem │ ← 契约 →  │ ActivityItem │
│ ChatMsg      │           │ ChatMsg      │
└──────────────┘           └──────────────┘

这条边界的存在意味着前端开发者在编写 UI 代码时,可以完全专注于用户交互和视觉呈现,而不需要考虑数据的来源是 Mock 还是真实 API。数据来源的切换是一个「透明」的过程——对 UI 层而言,无论是 MockUserData.getNearbyUsers() 还是 UserService.getNearbyUsers(),返回的都是同一个 NearUser[] 类型,调用方式完全一致。


2. 静态工厂方法模式

2.1 ArkTS的对象字面量限制

ArkTS 作为 TypeScript 的严格子集,引入了一系列编译期约束以提升运行时性能和类型安全。其中对 NearPlay Mock 架构影响最大的约束是:对象字面量必须具有显式的类型上下文

在标准 TypeScript 中,我们可以非常自由地使用对象字面量来创建数据:

// 标准 TypeScript — 合法
function getUsers() {
  return [
    { id: 'u1', nickname: '小明', avatar: '👦', distance: 0.5 },
    { id: 'u2', nickname: '阿花', avatar: '👧', distance: 0.8 },
  ]
}

然而在 ArkTS 的严格模式下,上述代码会触发编译错误 arkts-no-obj-literals-as-types,因为对象字面量 { id: 'u1', ... } 没有显式的类型声明。ArkTS 编译器无法推断这些对象应该属于什么类型,也无法在后续使用中提供类型检查保障。

2.2 static of() 工厂方法的诞生

为了解决这个约束,NearPlay 采用了「静态工厂方法」模式,即在每个 Model 类中定义一个 static of(...) 方法,通过显式构造类实例来创建对象:

// ArkTS — 合法的 NearPlay 方式
export class NearUser {
  id: string = ''
  nickname: string = ''
  avatar: string = ''
  distance: number = 0
  isOnline: boolean = false
  latitude: number = 0
  longitude: number = 0
  currentGame: string = ''

  static of(id: string, nickname: string, avatar: string,
            dist: number, online: boolean, lat: number, lng: number,
            game: string): NearUser {
    const u = new NearUser()
    u.id = id; u.nickname = nickname; u.avatar = avatar; u.distance = dist
    u.isOnline = online; u.latitude = lat; u.longitude = lng; u.currentGame = game
    return u
  }
}

// 使用
MockUserData.getNearbyUsers() 返回:
[
  NearUser.of('u1', '小明', '👦', 0.5, true, 31.23, 121.47, ''),
  NearUser.of('u2', '阿花', '👧', 0.8, true, 31.24, 121.48, '狼人杀'),
  ...
]

这个模式的关键在于 const u = new NearUser() 这一行——通过 new 操作符,我们显式地创建了一个类型为 NearUser 的实例,编译器可以完全确定返回值的类型。随后的属性赋值也是在已确定类型的实例上进行的,不存在类型歧义。

2.3 与构造函数方案的对比

面向对象语言中创建对象的常见方式是使用带参数的构造函数。在 ArkTS 中,这同样是合法的。那么为什么 NearPlay 选择 static of() 而不是带参构造函数呢?

对比维度              构造函数                    static of()
─────────────────────────────────────────────────────────
类型安全              ✅ 编译器可推断              ✅ 编译器可推断
可读性                ⚠️ new NearUser(             ✅ NearUser.of(
                      'u1', '小明', '👦',             'u1', '小明', '👦',
                      0.5, true, 31.23,              0.5, true, 31.23,
                      121.47, '')                     121.47, '')
                      参数含义不直观                  方法名即语义
命名灵活性            ❌ 只能与类同名               ✅ 可定义多个工厂方法
多态创建              ❌ 构造函数总是返回当前类      ✅ 可返回子类实例
空安全默认值          ⚠️ 需要手动处理              ✅ 类属性已有默认值兜底

可读性是最直观的优势。NearUser.of('u1', '小明', '👦', 0.5, true, 31.23, 121.47, '') 相比 new NearUser('u1', '小明', '👦', 0.5, true, 31.23, 121.47, '')of 这个方法名传达了「根据这些参数构造一个实例」的语义,而 new NearUser(...) 则只是机械地调用构造函数。

命名灵活性ChatMsg 类中体现得最为明显。ChatMsg 拥有三种不同的消息类型(文本、语音、图片),每种类型的参数完全不同:

// ChatMsg 的三个工厂方法 — 构造函数无法实现这种语义区分
ChatMsg.text(convId, fromId, fromNick, fromAvatar, text)
ChatMsg.voice(convId, fromId, fromNick, fromAvatar, duration)
ChatMsg.image(convId, fromId, fromNick, fromAvatar, uri)

如果用构造函数,我们需要设计一个接受所有参数的「超级构造函数」,然后根据某个 type 参数来决定哪些字段有效——这既不优雅,也不类型安全。而 static of() 模式允许我们为不同的创建场景定义不同的工厂方法,每个方法只接受该场景所需的参数。

2.4 与对象字面量的对比

尽管对象字面量在 ArkTS 中受限,但它仍然是许多前端框架(尤其是 React 生态)最常用的数据创建方式。理解两者的差异有助于我们深入体会 NearPlay 选择 static of() 的理由:

对象字面量方式(ArkTS 不推荐/受限):
  return { id: 'u1', nickname: '小明', distance: 0.5 }

static of() 方式(NearPlay 采用):
  return NearUser.of('u1', '小明', '👦', 0.5, true, 31.23, 121.47, '')

对象字面量的优势是简洁——不需要预先定义类,不需要写工厂方法。但它的劣势也是明显的:没有类型约束,容易写错字段名,IDE 无法提供自动补全,运行时不会检查字段完整性。在一个拥有 9 个 Mock 数据类、数十个字段的项目中,对象字面量的这些劣势会被放大——任何一个字段名的拼写错误都会导致难以追踪的 Bug。

static of() 通过显式的参数列表,将字段名和字段类型都固化在方法签名中。编译器会检查参数数量、参数类型,IDE 会提示每个位置的参数含义。虽然代码量略多,但在类型安全和开发体验上的收益是巨大的。

2.5 工厂方法模式在项目中的统一应用

NearPlay 项目中所有 9 个 Mock 数据类都严格遵循 static of() 模式,包括:

Model 类 工厂方法 参数数量
NearUser static of(...) 8
ActivityItem static of(...) 14
ChatConversation static of(...) 5
ChatMsg static text/voice/image(...) 5-6
NotifyItem static of(...) 8
BlockedUser static of(...) 3
NowPlayingSong static of(...) 3
AppUsageRecord static of(...) 4
RunPlan static of(...) 11
FoodRecipe static of(...) 6
UserRunProfile static of(...) 5
GameItem static of(...) 8
GameRoom static of(...) 7

这种全项目范围内的模式统一,使得任何一个开发者打开任意一个 Model 文件,都能立即理解数据对象的创建方式,不需要额外学习成本。


3. 9个Mock数据类完整数据展示

3.1 MockUserData — 8个附近用户

MockUserData.getNearbyUsers() 返回 8 个 NearUser 实例,覆盖了从极近距离到较远距离的完整范围,同时包含了在线/离线状态和当前游戏参与情况的多样性:

┌────┬──────┬──────┬────────┬─────────┬──────────┬──────────┬────────────┐
│ ID │ 昵称  │ 头像  │ 距离km  │ 在线状态  │ 纬度      │ 经度      │ 当前游戏    │
├────┼──────┼──────┼────────┼─────────┼──────────┼──────────┼────────────┤
│ u1 │ 小明  │ 👦   │ 0.5    │ ✅ 在线  │ 31.23    │ 121.47   │ (无)       │
│ u2 │ 阿花  │ 👧   │ 0.8    │ ✅ 在线  │ 31.24    │ 121.48   │ 狼人杀      │
│ u3 │ 大壮  │ 🧑   │ 1.2    │ ✅ 在线  │ 31.22    │ 121.46   │ (无)       │
│ u4 │ 小美  │ 👩   │ 1.5    │ ❌ 离线  │ 31.25    │ 121.49   │ (无)       │
│ u5 │ 老王  │ 👨   │ 2.0    │ ✅ 在线  │ 31.21    │ 121.45   │ 剧本杀      │
│ u6 │ 小丽  │ 👱‍♀️  │ 2.3    │ ✅ 在线  │ 31.26    │ 121.50   │ (无)       │
│ u7 │ 阿杰  │ 🧔   │ 3.0    │ ❌ 离线  │ 31.20    │ 121.44   │ (无)       │
│ u8 │ 小雪  │ 👩‍🦰  │ 3.5    │ ✅ 在线  │ 31.27    │ 121.51   │ 你画我猜    │
└────┴──────┴──────┴────────┴─────────┴──────────┴──────────┴────────────┘

数据设计要点

  • 距离从 0.5km 到 3.5km 递增分布,覆盖了近场社交的典型距离范围
  • 8 个用户中有 6 个在线、2 个离线(小美 u4、阿杰 u7),模拟真实场景中约 75% 的在线率
  • 只有 3 个用户正在参与游戏(阿花-狼人杀、老王-剧本杀、小雪-你画我猜),其余 5 个处于空闲状态,这是合理的比例
  • 经纬度以上海南京西路附近(31.2x, 121.4x)为中心,各用户位置围绕中心点分布,偏移量与距离值逻辑一致

源码位置:entry/src/main/ets/model/UserModel.ets:42-53

3.2 MockGameData — 6个游戏

MockGameData.getGames() 返回 6 个 GameItem 实例,涵盖 NearPlay 支持的全部游戏类型:

┌────┬──────────┬──────┬─────────────────────────┬──────┬──────┬────────────┐
│ ID │ 游戏名称  │ 图标  │ 描述                      │ 最少  │ 最多  │ 类型        │
├────┼──────────┼──────┼─────────────────────────┼──────┼──────┼────────────┤
│ 1  │ 狼人杀    │ 🐺   │ 经典狼人杀,天黑请闭眼      │ 6    │ 12   │ WEREWOLF   │
│ 2  │ 剧本杀    │ 📜   │ 可导入内容的剧本杀,沉浸式推理│ 4    │ 8    │ SCRIPT     │
│ 3  │ 谁是卧底  │ 🕵   │ 找出人群中的卧底            │ 4    │ 10   │ PARTY      │
│ 4  │ 你画我猜  │ 🎨   │ 画画猜词,欢乐无限          │ 2    │ 8    │ CASUAL     │
│ 5  │ 真心话大冒险│ 💬  │ 交友破冰必备               │ 2    │ 10   │ PARTY      │
│ 6  │ 看谁反应快│ 🔔   │ 快速抢拍,手速为王          │ 2    │ 6    │ CASUAL     │
└────┴──────────┴──────┴─────────────────────────┴──────┴──────┴────────────┘

数据设计要点

  • 6 个游戏分属 4 种类型:WEREWOLF(1个)、SCRIPT(1个)、PARTY(2个)、CASUAL(2个)
  • 人数范围从 2 人到 12 人,覆盖了从双人互动到大型聚会的全场景
  • 剧本杀的 canImportContent = true,其余为 false,这是剧本杀支持外部导入剧本文件的独特特性
  • 游戏名称和描述使用了真实流行的中文桌游术语,确保用户理解零门槛

源码位置:entry/src/main/ets/model/GameModel.ets:50-60

3.3 MockActivityData — 4个活动

MockActivityData.getActivities() 返回 4 个 ActivityItem 实例:

┌────┬────────────────┬──────────────────────────────┬───────────────────┬──────────┬────────┬────────┬──────────┐
│ ID │ 活动标题        │ 描述                          │ 地点               │ 游戏内容  │ 组织者  │ 已/最多  │ 是否已加入│
├────┼────────────────┼─────────────────────────────┼───────────────────┼──────────┼────────┼────────┼──────────┤
│ a1 │ 周末狼人杀聚会  │ 来一起玩狼人杀吧!新手友好     │ 星巴克(南京西路店) │ 狼人杀    │ 我(me) │ 5/12   │ ❌       │
│ a2 │ 剧本杀推理之夜  │ 沉浸式剧本杀,有多个剧本可选   │ 桌游吧(静安寺)     │ 剧本杀    │ 老王u5 │ 3/8    │ ❌       │
│ a3 │ 你画我猜欢乐局  │ 轻松搞笑,适合破冰交友         │ 瑞幸咖啡(人民广场) │ 你画我猜  │ 小雪u8 │ 2/6    │ ✅       │
│ a4 │ 卡牌游戏下午茶  │ 牛头王+谁是卧底               │ 麦当劳(徐家汇)     │ 卡牌游戏  │ 大壮u3 │ 4/8    │ ❌       │
└────┴────────────────┴─────────────────────────────┴──────────────────┴──────────┴────────┴────────┴──────────┘

数据设计要点

  • 4 个活动使用了真实的商业场所名称(星巴克、瑞幸咖啡、麦当劳),增强了场景真实感
  • 活动组织者来自 MockUserData 中的用户(我/老王/小雪/大壮),体现了用户和活动之间的关联
  • a3 的 isJoined = true,模拟了用户已加入某个活动的状态,其余为未加入
  • 距离从 0.5km 到 3.0km,与附近用户数据的距离范围一致
  • 时间戳使用 Unix 毫秒值(1721347200000 等),对应 2024 年 7 月的周末时间

源码位置:entry/src/main/ets/model/ActivityModel.ets:27-35

3.4 MockChatData — 4个会话 + 6条消息

4个会话
┌────┬──────────┬────────────────┬──────┬──────────┬────────────────────┬──────┐
│ ID │ 类型      │ 名称            │ 头像  │ 目标ID    │ 最后一条消息          │ 未读  │
├────┼──────────┼────────────────┼──────┼──────────┼─────────────────────┼──────┤
│ c1 │ PRIVATE  │ 阿花            │ 👧   │ u2       │ 在吗?一起玩狼人杀吧  │ 2    │
│ c2 │ PRIVATE  │ 大壮            │ 🧑   │ u3       │ 剧本杀缺人,来不来    │ 0    │
│ c3 │ GAME     │ 狼人杀房间      │ 🐺   │ room1    │ 游戏进行中...         │ 5    │
│ c4 │ ACTIVITY │ 周末狼人杀聚会  │ 🎲   │ a1       │ 明天2点集合           │ 1    │
└────┴──────────┴────────────────┴──────┴──────────┴────────────────────┴──────┘
默认6条消息(任一会话的示例消息流)
┌────────────────────────────────────────────────────────┐
│  阿花 👧: 在吗?                                          │
│  我 😊: 在的,怎么了                                       │
│  阿花 👧: 一起玩狼人杀吧,缺2个人                           │
│  阿花 👧: 🔊 语音消息 (3秒)                                │
│  我 😊: 好啊,什么时候?                                    │
│  阿花 👧: 📷 图片消息 (internal://cache/test.jpg)           │
└────────────────────────────────────────────────────────┘

数据设计要点

  • 3 种会话类型(私聊/GAME/ACTIVITY)各至少出现一次,覆盖了应用的所有聊天场景
  • 消息流包含 3 种消息类型:TEXT(4条)、VOICE(1条,3秒)、IMAGE(1条),展示完整的消息类型支持
  • 未读数从 0 到 5 不等,模拟了真实的消息阅读状态分布
  • 消息内容自然连贯,模拟了真实的对话场景——从打招呼、提出邀约、语音确认到图片分享

源码位置:entry/src/main/ets/model/ChatModel.ets:66-91

3.5 MockNotifyData — 通知数据

MockNotifyData.getNotifications() 返回 5 条通知:

┌────┬─────────────┬──────────┬─────────────────────────────────────────┬────────┐
│ ID │ 类型         │ 标题      │ 内容                                      │ 来源    │
├────┼─────────────┼──────────┼─────────────────────────────────────────┼────────┤
│ n1 │ SIGNUP      │ 新报名    │ 小明 报名了你的活动"周末狼人杀聚会"         │ u1 小明 │
│ n2 │ SIGNUP      │ 新报名    │ 阿花 报名了你的活动"周末狼人杀聚会"         │ u2 阿花 │
│ n3 │ GAME_INVITE │ 游戏邀请  │ 大壮 邀请你加入狼人杀                      │ u3 大壮 │
│ n4 │ SYSTEM      │ 系统通知  │ 附近有3位新用户上线                         │ (系统)  │
│ n5 │ SIGNUP      │ 新报名    │ 小雪 报名了你的活动"你画我猜欢乐局"         │ u8 小雪 │
└────┴─────────────┴──────────┴─────────────────────────────────────────┴────────┘

MockNotifyData.getUnreadCount() 返回 3(3条未读通知)。

数据设计要点

  • 3 种通知类型均有覆盖:SIGNUP(3条)、GAME_INVITE(1条)、SYSTEM(1条)
  • SIGNUP 通知关联了具体的活动ID(a1、a3),与 MockActivityData 中的活动对应
  • 系统通知没有来源用户,符合业务逻辑
  • 5 条通知中有 3 条未读,模拟了典型的通知阅读状态

源码位置:entry/src/main/ets/model/NotifyModel.ets:28-41

3.6 MockBlockData — 拉黑数据

MockBlockData.getInitialBlocked() 返回 1 个已拉黑用户:

┌────┬──────┬──────┬──────────────┐
│ ID │ 昵称  │ 头像  │ 拉黑时间       │
├────┼──────┼──────┼──────────────┤
│ u7 │ 阿杰  │ 🧔   │ Date.now()   │
└────┴──────┴──────┴──────────────┘

数据设计要点

  • 只预置了 1 个拉黑用户,因为拉黑是低频操作,多数用户不会有大量拉黑记录
  • 阿杰(u7)同时也是离线用户之一(距离 3.0km),暗示了拉黑与不活跃之间的关联
  • 拉黑时间使用 Date.now(),确保每次初始化时时间戳都是最新的
  • BlockModel 模块除了 Mock 数据外,还提供了完整的 CRUD 函数:isUserBlocked()blockUser()unblockUser()getBlockedUsers(),这些函数操作一个模块级变量 blockedList,在 Mock 阶段模拟了持久化存储的行为

源码位置:entry/src/main/ets/model/BlockModel.ets:43-49

3.7 MockMusicData — 8首歌曲

我的正在播放
  🎤 晴天 — 周杰伦 [流行]
8首附近用户正在播放
┌──────────────────────────────┬──────────┬──────┬──────────┐
│ 歌曲名                         │ 歌手      │ 类型  │ 类型图标  │
├──────────────────────────────┼──────────┼──────┼──────────┤
│ 七里香                         │ 周杰伦    │ 流行  │ 🎤       │
│ 光年之外                       │ 邓紫棋    │ 流行  │ 🎤       │
│ Bohemian Rhapsody             │ Queen    │ 摇滚  │ 🎸       │
│ Lose Yourself                 │ Eminem   │ 嘻哈  │ 🎧       │
│ Fade                          │ Alan Walker│ 电子 │ 🎹       │
│ Take Five                     │ Dave Brubeck│ 爵士│ 🎷       │
│ 成都                          │ 赵雷      │ 民谣  │ 🪕       │
│ 月光奏鸣曲                     │ 贝多芬    │ 古典  │ 🎻       │
└──────────────────────────────┴──────────┴──────┴──────────┘

数据设计要点

  • 8 首歌曲覆盖了 8 种不同的音乐流派(流行/摇滚/嘻哈/电子/爵士/民谣/古典),确保音乐匹配功能的展示具有足够的多样性
  • 中文歌曲和英文歌曲各占一半,模拟了中国都市用户的真实听歌习惯
  • 歌曲都是广为人知的热门作品,便于评审和测试人员快速理解音乐流派分类的准确性
  • 每种流派有专属的 Emoji 图标(通过 getGenreIcon() 函数映射),为 UI 层提供直观的视觉标识

源码位置:entry/src/main/ets/model/MusicModel.ets:70-87

3.8 MockUsageData — 8组使用记录

我的使用记录(5条)
┌─────────────────────────┬──────────┬──────┬──────────┐
│ Bundle Name              │ 应用名称  │ 分类  │ 使用分钟  │
├─────────────────────────┼──────────┼──────┼──────────┤
│ com.tencent.mm           │ 微信      │ 社交  │ 120      │
│ tv.danmaku.bili          │ 哔哩哔哩  │ 视频  │ 90       │
│ com.netease.cloudmusic   │ 网易云音乐│ 音乐  │ 60       │
│ com.tencent.tmgp.sgame   │ 王者荣耀  │ 游戏  │ 45       │
│ com.keep                 │ Keep      │ 运动  │ 30       │
└─────────────────────────┴──────────┴──────┴──────────┘
8组附近用户使用记录
用户1: 微信(社交 100min) + 网易云音乐(音乐 80min) + 哔哩哔哩(视频 50min)
用户2: 腾讯视频(视频 150min) + 微信(社交 80min) + 和平精英(游戏 70min)
用户3: 微信(社交 90min) + 多看阅读(阅读 120min) + 网易云音乐(音乐 40min)
用户4: 钉钉(办公 200min) + 微信(社交 60min)
用户5: 王者荣耀(游戏 180min) + 微信(社交 70min) + 哔哩哔哩(视频 60min)
用户6: 京东(购物 90min) + 淘宝(购物 80min) + 哔哩哔哩(视频 40min)
用户7: 小米运动(运动 120min) + Keep(运动 60min) + 网易云音乐(音乐 30min)
用户8: 哔哩哔哩(视频 100min) + 腾讯视频(视频 80min) + 微信(社交 50min)

数据设计要点

  • 8 组使用记录展现了 8 种不同的「用户画像」:社交型、视频型、阅读型、办公型、游戏型、购物型、运动型、综合型
  • 微信出现在 7/8 组中(使用时间 50-120min),反映了中国用户微信使用率极高的现实
  • 使用了真实的 Android Bundle Name(如 com.tencent.mmtv.danmaku.bili),与 bundleCategoryMap 中的映射表对应
  • UsageProfile.fromRecords() 方法可以自动聚合这些记录,计算 top3 分类和各类总时长,用于匹配度计算

源码位置:entry/src/main/ets/model/UsageModel.ets:122-175

3.9 MockRunAdvisorData — 8个跑步顾问档案

我的跑步计划(3个)
┌──────────────────┬────────┬──────────┬──────────┬──────────┬──────┬──────┬──────────────────────────┐
│ ID               │ 类型    │ 名称      │ 目标距离km│ 目标配速  │ 时长  │ 强度  │ 描述/提示                  │
├─────────────────┼────────┼──────────┼──────────┼──────────┼──────┼──────┼─────────────────────────┤
│ plan_easy_3km    │ easy   │ 轻松跑    │ 3        │ 7'00"    │ 25min│ 低   │ 保持均匀呼吸,享受跑步过程  │
│ plan_standard_5km│ standard│ 标准跑   │ 5        │ 6'00"    │ 35min│ 中   │ 控制配速,注意节奏变化      │
│ plan_endurance_10km│ endurance│ 耐力跑 │ 10       │ 6'30"    │ 70min│ 高   │ 前半程压速,后半程稳住      │
└──────────────────┴────────┴──────────┴──────────┴──────────┼──────┼──────┴──────────────────────────┘
                              颜色: 🟢低   🔵中   🟠高
我的食谱(3个)
┌──────────────────────┬──────────┬──────────┬──────────────┬────────────┬──────────────┐
│ ID                   │ 名称      │ 分类      │ 热量/100g    │ 默认克数    │ 总热量(kcal)  │
├──────────────────────┼──────────┼──────────┼──────────────┼────────────┼──────────────┤
│ recipe_chicken_breast│ 鸡胸肉    │ 蛋白质    │ 133          │ 150g       │ 200          │
│ recipe_broccoli      │ 西兰花    │ 蔬菜      │ 34           │ 200g       │ 68           │
│ recipe_rice          │ 米饭      │ 主食      │ 116          │ 200g       │ 232          │
└──────────────────────┴──────────┴──────────┴──────────────┴────────────┴──────────────┘
  总计: 500 kcal
8个附近用户的跑步档案(每个包含跑步计划+食谱)
┌────┬──────┬──────┬───────────────────────────────────┬────────────────────────────────────────┐
│ ID │ 昵称  │ 头像  │ 跑步计划                              │ 食谱                                      │
├────┼──────┼──────┼───────────────────────────────────┼────────────────────────────────────────┤
│ u1 │ 小明  │ 👦   │ 轻松跑3km + 标准跑5km               │ 鸡胸肉150g + 燕麦片40g + 苹果200g          │
│ u2 │ 阿花  │ 👧   │ 标准跑5km                           │ 鱼肉鲈鱼150g + 生菜150g + 酸奶200g         │
│ u3 │ 大壮  │ 🧑   │ 耐力跑10km + 标准跑5km               │ 牛肉100g + 红薯200g + 香蕉120g             │
│ u4 │ 小美  │ 👩   │ 轻松跑3km                           │ 虾仁100g + 豆腐150g + 橙子200g              │
│ u5 │ 老王  │ 👨   │ 耐力跑10km                          │ 羊肉100g + 馒头100g + 葡萄150g              │
│ u6 │ 小丽  │ 👱‍♀️  │ 轻松跑3km                           │ 猪瘦肉100g + 菠菜150g + 草莓150g            │
│ u7 │ 阿杰  │ 🧔   │ 标准跑5km + 耐力跑10km               │ 鸡腿100g + 土豆200g + 西瓜300g              │
│ u8 │ 小雪  │ 👩‍🦰  │ 轻松跑3km                           │ 鱼肉三文鱼100g + 黄瓜200g + 蓝莓100g        │
└────┴──────┴──────┴───────────────────────────────────┴────────────────────────────────────────┘

数据设计要点

  • 8 个档案与 MockUserData 的 8 个用户一一对应,ID 和昵称完全匹配
  • 跑步计划覆盖了 3 种强度:轻松跑(u1/u4/u6/u8)、标准跑(u2/u7)、耐力跑(u3/u5),模拟了不同健身水平的用户
  • 食谱包含蛋白质/蔬菜/主食/水果/乳制品多种分类,热量值参考了真实营养数据
  • FoodRecipe.getCalories() 方法可以自动计算总热量(caloriesPer100g × defaultGrams / 100),例如鸡胸肉:133 × 150 / 100 = 200 kcal
  • 每个用户的食谱设计与其跑步计划强度相匹配:耐力跑用户的蛋白质摄入量更高(牛肉/羊肉),轻松跑用户的食谱更清淡(虾仁/酸奶/黄瓜)

源码位置:entry/src/main/ets/model/RunAdvisorModel.ets:93-186


4. Mock定时器缩短策略

4.1 各游戏定时器配置总览

NearPlay 中 6 个游戏页面使用了不同的定时器配置,这些配置值经过刻意缩短以适应开发演示场景:

┌──────────────────┬─────────────────────┬──────────┬──────────┬──────────────┐
│ 游戏              │ 定时器场景            │ Mock值    │ 真实值    │ 缩短比例      │
├──────────────────┼────────────────────┼──────────┼──────────┼──────────────┤
│ 谁是卧底          │ 描述回合(descTimer)   │ 10s      │ 30-60s   │ 1/3 ~ 1/6   │
│ 谁是卧底          │ 投票回合(voteTimer)   │ 10s      │ 30s      │ 1/3          │
│ 狼人杀            │ 发言回合(speakerTimer)│ 30s      │ 60-120s  │ 1/2 ~ 1/4   │
│ 狼人杀            │ 投票回合(voteTimer)   │ 10s      │ 30s      │ 1/3          │
│ 真心话大冒险       │ 众包出题(crowdSource) │ 8s       │ 30s      │ ~1/4         │
│ 真心话大冒险       │ 回答计时(respondTimer)│ 60s      │ 60s      │ 1:1          │
│ 你画我猜          │ 绘画回合(roundTimer)  │ 20s      │ 60s      │ 1/3          │
│ 看谁反应快         │ 翻牌间隔(flipCard)   │ 2s       │ 3-5s     │ ~1/2         │
│ 剧本杀            │ 发言回合(speakerTimer)│ 15s      │ 30-60s   │ 1/2 ~ 1/4   │
└──────────────────┴────────────────────┴──────────┴──────────┴──────────────┘

4.2 缩短的核心理由:开发效率

Mock 阶段定时器缩短的首要目的是提升开发效率。在开发调试过程中,开发者需要反复触发各种游戏状态来验证 UI 表现。如果每个描述回合要等 60 秒、每轮投票要等 30 秒,那么完整走一遍狼人杀的游戏流程可能需要 20 分钟以上——这在频繁迭代 UI 的开发阶段是完全不可接受的。

缩短后的定时器使得:

  • 谁是卧底:一个完整的描述+投票轮次从约 90 秒缩短到约 20 秒
  • 狼人杀:一轮昼夜循环从约 5 分钟缩短到约 2 分钟
  • 真心话大冒险:众包出题从 30 秒缩短到 8 秒,快速进入问题展示
  • 你画我猜:绘画回合从 60 秒缩短到 20 秒,快速触发时间耗尽场景

4.3 为什么又增加了某些定时器?

值得注意的是,并非所有定时器都被缩短。真心话大冒险的 respondTimer 保持 60 秒不变,剧本杀的幕间过渡和 NPC 台词展示也保留了足够的时长。这里的原因是:

  1. 需要实际体验的交互不能压缩:回答真心话大冒险问题是一个需要用户真实参与的操作,如果只有 10 秒,用户来不及思考,就失去了验证交互流程的意义。

  2. 动画展示需要时间:剧本杀的 NPC 台词使用了逐字展示的动画效果(line.content.length * 200 + 1500ms),如果强行缩短,文字动画会被截断,无法验证视觉效果。

  3. 节奏感验证:某些定时器的时长本身就是用户体验的一部分。你画我猜的 20 秒虽然比真实的 60 秒短,但仍然保留了「时间紧迫」的体验感;如果缩到 5 秒,就完全失去了「猜画」的趣味性,无法验证 UI 在倒计时末段(红色警告、进度条变短)的视觉表现。

4.4 定时器缩短的实现方式

定时器缩短不是通过修改 ArkTS 框架的定时器精度来实现的,而是直接修改各游戏页面中的 @State 初始值和赋值语句:

// 狼人杀 — WerewolfGame.ets:176
this.speakerTimer = 30     // 而非真实场景的 60-120

// 狼人杀 — WerewolfGame.ets:201
this.voteTimer = 10        // 而非真实场景的 30

// 谁是卧底 — UndercoverGame.ets:61
this.descTimer = 10        // 而非真实场景的 30-60

// 真心话大冒险 — TruthOrDareGame.ets:57
this.crowdSourceTimer = 8  // 而非真实场景的 30

// 你画我猜 — DrawGuessGame.ets:58
this.roundTimer = 20       // 而非真实场景的 60

这种实现方式的优点是:所有定时器值都集中在 @State 变量的初始化/赋值处,未来切换到真实定时时只需要修改这些数值,不涉及逻辑结构的变更。

4.5 定时器值的未来配置化展望

在当前的 Mock 阶段,定时器值是硬编码在各游戏页面中的。未来可以将其抽取为配置项:

当前方式(硬编码):
  @State speakerTimer: number = 30

未来方式(配置化):
  @State speakerTimer: number = GameConfig.WEREWOLF_SPEAK_TIME
  // GameConfig 从服务器获取或使用本地默认值

这种配置化设计将使得定时器值可以在不修改代码的情况下调整,也为未来 A/B 测试不同定时时长提供了基础设施。


5. 定时器模拟服务器消息

5.1 为什么需要模拟服务器推送?

NearPlay 的游戏功能在真实环境中应该通过 WebSocket 接收服务器推送的游戏状态消息。例如在狼人杀中,当服务器判定「天黑了」,应该通过 WebSocket 推送一个 NIGHT_START 消息给所有客户端;当某个玩家发言完毕,服务器应该推送 SPEECH_END 消息触发下一个人发言。

在 Mock 阶段,WebSocket 服务器尚未实现。如果等待服务器就绪才能测试游戏流程,前端开发将再次被阻塞。因此,NearPlay 使用 setTimeout/setInterval 在客户端模拟服务器的消息推送行为,实现无服务器依赖的游戏流程驱动。

5.2 模拟架构概览

真实环境(未来):
  服务器 ──WebSocket──→ GameNetwork.onMessage() ──→ 游戏状态更新 ──→ UI刷新

Mock环境(当前):
  setTimeout/setInterval ──→ 直接调用状态更新方法 ──→ UI刷新

在真实环境中,游戏流程的驱动链路是:服务器发送消息 → GameNetwork 接收并解析为 GameMessage → 页面根据消息类型更新状态 → ArkUI 框架刷新 UI。

在 Mock 环境中,setTimeout 的回调函数直接调用了页面中的状态更新方法,跳过了 GameNetwork 中间层。这种简化是合理的,因为 Mock 的目标不是验证网络通信,而是验证 UI 逻辑。

5.3 各游戏的定时器消息模拟详解

狼人杀 — 复杂状态机模拟

狼人杀拥有最复杂的状态机(11 个阶段),每个阶段之间的转换都通过 setTimeout 模拟服务器推送:

ROLE_ASSIGN ──3s──→ NIGHT_START ──2s──→ WOLF_TURN ──5s──→ SEER_TURN ──5s──→
WITCH_TURN ──5s──→ GUARD_TURN ──5s──→ NIGHT_RESULT ──3s──→ DAY_DISCUSS ──→
(发言轮次: 30s/人 × N人) ──→ DAY_VOTE ──10s──→ VOTE_RESULT ──3s──→
GAME_OVER 或 新一轮 NIGHT_START

每个 setTimeout 的延迟值都有其设计考量:

转换 延迟 设计理由
ROLE_ASSIGN → NIGHT_START 3s 给玩家足够时间阅读角色信息
NIGHT_START → WOLF_TURN 2s 营造「天黑」的氛围感
WOLF_TURN → SEER_TURN 5s 狼人需要时间选择击杀目标
SEER_TURN → WITCH_TURN 5s 预言家需要时间查验
WITCH_TURN → GUARD_TURN 5s 女巫需要时间决定是否用药
GUARD_TURN → NIGHT_RESULT 5s 守卫需要时间选择守护
NIGHT_RESULT → DAY_DISCUSS 3s 让玩家阅读夜晚结果
VOTE_RESULT → GAME_OVER/新夜 3s 让玩家阅读投票结果

发言阶段使用 setInterval(1秒间隔倒计时)而非 setTimeout,因为需要持续更新倒计时 UI:

// WerewolfGame.ets:179-187
this.speakerTimerId = setInterval(() => {
  this.speakerTimer--
  if (this.speakerTimer <= 0) {
    clearInterval(this.speakerTimerId)
    this.speakerTimerId = -1
    this.speakerOrderIndex++
    this.startPlayerSpeech()  // 自动切换到下一位发言者
  }
}, 1000)
谁是卧底 — 循环描述+投票模拟
WORD_ASSIGN → DESCRIBE_TURN(10s倒计时) → 下一位描述或 → VOTE_PHASE(10s倒计时)
→ ELIMINATE → 检查胜负 → 新一轮 DESCRIBE_TURN 或 FINAL_REVEAL

描述阶段同样使用 setInterval 实现 1 秒粒度的倒计时,并在倒计时归零时自动推进到下一位玩家或进入投票阶段。

真心话大冒险 — 众包出题模拟
TURN_START ──1.5s──→ CHOOSE_TYPE ──用户选择──→ CROWD_SOURCE(8s倒计时)
──→ 收集题目 ──→ SHOW_QUESTION(60s倒计时) ──→ RESPOND ──2s──→ 下一轮

众包出题阶段的 8 秒倒计时模拟了多人同时提交题目的时间窗口。在真实环境中,这个时间窗口应该由服务器控制并同步到所有客户端;在 Mock 环境中,通过 setInterval 本地倒计时来模拟。

你画我猜 — 绘画倒计时模拟
ROUND_START ──3s──→ DRAWING(20s倒计时) ──→ ROUND_RESULT ──3s──→ 下一轮或GAME_OVER

绘画阶段 20 秒的 setInterval 倒计时会在 roundTimer <= 0 时自动触发 endRound(false)(时间耗尽,未猜对),或由 submitGuess() 中猜对后手动 clearInterval 并调用 endRound(true)

剧本杀 — NPC 台词逐条推送

剧本杀的 NPC 台词模拟是最精细的。每条 NPC 台词的展示时长基于文字长度动态计算:

// ScriptKillGame.ets:152-153
setTimeout(() => {
  this.currentNpcLineIndex++
  this.playNpcLines()
}, line.content.length * 200 + 1500)  // 每字200ms + 基础1.5s

这种动态延迟设计模拟了「NPC 朗读台词」的体验:短台词展示时间短,长台词展示时间长。200ms/字的速率接近正常中文朗读速度(约 5 字/秒),1.5 秒的基础延迟为台词切换提供了缓冲。

看谁反应快 — 翻牌间隔模拟
DEAL_CARDS ──1.5s──→ FLIP_PHASE(每2s翻一张牌) ──检测到5个相同水果──→
REACT_WINDOW ──玩家抢拍──→ REACT_RESULT ──3s──→ 新一轮FLIP_PHASE

翻牌使用 setInterval(flipCard, 2000),每 2 秒翻出一张随机水果牌。这个间隔在真实游戏中通常是 3-5 秒,Mock 环境缩短到 2 秒以加快节奏。当累计同种水果达到 5 个时,进入可抢拍状态。

5.4 定时器资源管理

所有游戏页面都在 aboutToDisappear() 生命周期回调中清理定时器资源,防止页面退出后定时器继续运行导致的内存泄漏和状态异常:

// WerewolfGame.ets:268-271
aboutToDisappear(): void {
  if (this.speakerTimerId !== -1) {
    clearInterval(this.speakerTimerId)
  }
  this.voiceHelper.destroy()
}

使用 -1 作为「无定时器」的哨兵值是 NearPlay 的统一约定。每个定时器变量初始化为 -1,启动定时器时将返回的 timerId 赋值给变量,清理后将变量重置为 -1。在清理前检查 !== -1 确保不会对不存在的定时器调用 clearInterval()


6. try-catch降级策略详解

6.1 为什么需要降级策略?

NearPlay 的核心功能依赖于多个 HarmonyOS 系统 API,这些 API 在不同设备、不同系统版本、不同权限状态下可能表现不同:

  • 某些 API 只在特定系统版本以上可用
  • 某些 API 需要用户授权特定权限
  • 某些 API 在模拟器上不可用
  • 某些 API 调用可能因系统繁忙而失败

如果不对这些 API 调用进行保护,任何一个失败都会导致应用崩溃或功能完全不可用。NearPlay 采用 try-catch 降级策略,确保即使系统 API 不可用,应用的核心功能仍然可以正常运行,只是某些增强特性被降级或关闭。

6.2 AVSession.getAVMetadata 系统API降级

业务场景

NearPlay 的「音乐匹配」功能需要读取用户当前正在播放的歌曲信息(歌名、歌手),用于与附近听同类音乐的用户匹配。在 HarmonyOS 中,这需要通过 AVSession API 获取媒体元数据。

降级策略

AVSession.getAVMetadata() 不可用时,降级到 MockMusicData.getMyNowPlaying() 提供的静态 Mock 数据:

// 伪代码展示完整降级逻辑
import { avSession } from '@kit.MultimediaKit'

function getMyNowPlaying(): NowPlayingSong {
  try {
    // 尝试通过系统API获取真实正在播放的歌曲
    const session = avSession.getAllSessionDescriptors()
    if (session.length > 0) {
      const currentSession = session[0]
      const metadata = currentSession.getAVMetadata()
      if (metadata.title !== '' && metadata.artist !== '') {
        const genre = classifyGenre(metadata.title, metadata.artist)
        return NowPlayingSong.of(metadata.title, metadata.artist, genre)
      }
    }
  } catch (e) {
    // 降级路径:AVSession API 不可用
    // 可能原因:权限未授予、系统不支持、模拟器环境
  }

  // 降级到 Mock 数据
  return MockMusicData.getMyNowPlaying()
}
降级触发条件
条件 说明
权限未授予 ohos.permission.MANAGE_MEDIA_RESOURCES 未授权
无活跃媒体会话 用户当前没有播放任何媒体
模拟器环境 部分模拟器不支持 AVSession
系统版本过低 AVSession API 在低版本不可用
降级效果

降级后,用户在「音乐匹配」页面看到的歌曲始终是「晴天 — 周杰伦 [流行]」,而非真实播放内容。核心的 UI 交互和匹配逻辑仍然可以正常展示和测试,只是数据来源从实时变为静态。

6.3 usageStatistics.queryBundleEvents 降级

业务场景

NearPlay 的「兴趣匹配」功能需要读取用户的应用使用记录,根据使用时长和分类(社交/游戏/视频/音乐等)计算与附近用户的匹配度。这需要通过 usageStatistics.queryBundleEvents() API 查询设备使用统计。

降级策略
// 伪代码展示完整降级逻辑
import { usageStatistics } from '@kit.DeviceUsageStatisticsKit'

function getMyUsageRecords(): AppUsageRecord[] {
  try {
    // 尝试通过系统API获取真实应用使用记录
    const endTime = Date.now()
    const startTime = endTime - 24 * 60 * 60 * 1000  // 最近24小时
    const events = usageStatistics.queryBundleEvents(startTime, endTime)

    const records: AppUsageRecord[] = []
    for (const event of events) {
      const bundleName = event.bundleName
      const category = classifyApp(bundleName)
      const appName = getAppName(bundleName)
      const minutes = Math.round(event.duration / 60000)
      if (minutes > 0) {
        records.push(AppUsageRecord.of(bundleName, appName, category, minutes))
      }
    }
    if (records.length > 0) {
      return records
    }
  } catch (e) {
    // 降级路径:usageStatistics API 不可用
    // 可能原因:BUNDLE_ACTIVE_INFO权限未授予、系统限制
  }

  // 降级到 Mock 数据
  return MockUsageData.getMyUsage()
}
降级触发条件
条件 说明
BUNDLE_ACTIVE_INFO 权限未授予 用户拒绝授权应用使用记录读取权限
系统限制 部分设备限制非系统应用访问使用统计
查询结果为空 首次使用或系统未收集足够数据
API 不可用 低版本系统或模拟器环境
降级效果

降级后,用户看到的使用记录始终是 Mock 数据中的 5 条记录(微信 120min、哔哩哔哩 90min、网易云音乐 60min、王者荣耀 45min、Keep 30min)。匹配度计算逻辑仍然正常工作,只是基于静态数据而非真实使用情况。

6.4 BUNDLE_ACTIVE_INFO 权限降级

业务场景

ohos.permission.BUNDLE_ACTIVE_INFO 是 NearPlay 三个权限请求之一(另外两个是位置权限和媒体资源权限)。该权限在 PermissionModel 中被标记为 isRequired: false,即「可选权限」——用户拒绝该权限不应影响应用的核心功能。

降级策略

权限降级与 API 降级是联动关系。权限降级发生在更早的阶段(权限请求页面),而 API 降级发生在实际调用时:

┌───────────────────────────────────────────────────────────────┐
│ 权限请求阶段                                                     │
│                                                                 │
│  PermissionPage.ets                                              │
│  ┌─────────────────────────────────────────────┐               │
│  │ 🎵 读取正在播放的音乐    (可选)  → 用户拒绝     │               │
│  │ 📱 读取应用使用记录      (可选)  → 用户拒绝     │               │
│  │ 📍 获取位置信息          (必需)  → 用户必须同意  │               │
│  └─────────────────────────────────────────────┘               │
│                                                                 │
│  用户拒绝可选权限 → 记录权限状态 → 进入应用                       │
├───────────────────────────────────────────────────────────────┤
│ API调用阶段                                                      │
│                                                                 │
│  首页/发现页                                                     │
│  ┌─────────────────────────────────────────────┐               │
│  │ try {                                          │               │
│  │   usageStatistics.queryBundleEvents(...)       │               │
│  │ } catch (e) {                                  │               │
│  │   // 降级到 MockUsageData                      │               │
│  │ }                                              │               │
│  └──────────────────────────────────────────────┘               │
└───────────────────────────────────────────────────────────────┘
完整的权限降级代码示例
// PermissionModel.ets 中的权限定义
PermissionItem.of(
  'usage',
  '读取应用使用记录',
  '读取您每天的手机使用记录(打开的软件及时间),用于与附近用户计算匹配度',
  '📱',
  'ohos.permission.BUNDLE_ACTIVE_INFO',
  false   // isRequired = false → 可选权限
)
// 权限请求页面的降级处理逻辑
async requestPermissions(): Promise<void> {
  const permissions = getDefaultPermissions()

  for (const perm of permissions) {
    try {
      const result = await abilityAccessCtrl.requestPermissionsFromUser(
        this.context, [perm.permissionName]
      )
      if (result.authResults[0] === 0) {
        perm.isGranted = true
      } else if (perm.isRequired) {
        // 必需权限被拒绝 → 提示用户无法使用应用
        this.showRequiredPermissionDenied()
        return
      }
      // 可选权限被拒绝 → 静默继续,后续API调用时降级
    } catch (e) {
      if (perm.isRequired) {
        this.showRequiredPermissionDenied()
        return
      }
    }
  }

  // 权限请求完成(无论是否全部授予)→ 进入应用
  this.enterApp()
}

6.5 try-catch 降级的统一模式

NearPlay 中所有 try-catch 降级遵循相同的模式:

try {
  // 1. 尝试调用系统API
  const result = systemApi.method()

  // 2. 验证结果有效性
  if (isValid(result)) {
    return processResult(result)   // 使用真实数据
  }
} catch (e) {
  // 3. 静默捕获异常,不向用户展示错误
  // (降级是内部行为,用户不应感知)
}

// 4. 降级路径:使用 Mock 数据
return MockData.getDefault()

这个模式的几个关键设计决策:

  1. 静默降级:catch 块中不做任何日志记录或用户提示。降级是预期内的行为,不是异常情况。用户不需要知道「音乐数据来自 Mock 而非真实播放」。

  2. 双重验证:即使 API 调用没有抛出异常,也验证返回结果的有效性。queryBundleEvents() 可能返回空数组,getAllSessionDescriptors() 可能返回空列表——这些都属于「调用成功但数据无效」的情况,同样应该触发降级。

  3. 降级路径始终可用:Mock 数据始终作为兜底方案存在,不依赖任何外部条件。这保证了应用在任何环境下都能正常启动和运行。

6.6 其他 try-catch 降级场景

除了上述三个主要的系统 API 降级外,NearPlay 中还有多个次要的 try-catch 保护:

位置 保护对象 降级行为
QuickReactGame.ets:41-45 display.getDefaultDisplaySync() 使用默认 canvasWidth=360
DrawGuessGame.ets:35-39 display.getDefaultDisplaySync() 使用默认 canvasWidth=340
ScriptKillGame.ets:47-50 TTS 引擎 shutdown 静默忽略
ScriptKillGame.ets:57-63 TTS 引擎初始化 ttsAvailable = false
ScriptKillGame.ets:78-91 DocumentViewPicker 静默忽略导入失败
ChatPage.ets:87-100 PhotoViewPicker showMorePanel = false
GameMessage.ets:21-33 JSON.parse 返回默认空 GameMessage
GameNetwork.ets:27-66 WebSocket 连接 自动重连机制
EntryAbility.ets:24-26 setColorMode 静默忽略

这些降级场景遵循同样的原则:功能降级而非崩溃。屏幕信息获取失败就用默认尺寸,TTS 不可用就关闭语音朗读,文件选择器失败就关闭预览面板——应用始终保持在可用状态。


7. Mock→真实API切换路径

7.1 数据层与UI层解耦

NearPlay 的架构设计使得 Mock→真实API的切换成为一次「数据源替换」而非「代码重构」。这种解耦的关键在于 UI 层只通过类型接口与数据交互,不关心数据的来源:

当前状态(Mock):
  UI层 ──调用──→ MockUserData.getNearbyUsers()
                     │
                     └──→ 返回 NearUser[]

未来状态(真实API):
  UI层 ──调用──→ UserService.getNearbyUsers()
                     │
                     ├──→ HTTP请求 GET /api/nearby-users
                     └──→ 返回 NearUser[]

UI 层的代码完全不需要修改——getNearbyUsers() 方法签名相同,返回类型 NearUser[] 相同,数据结构相同。唯一的变化是方法所属的类从 MockUserData 变成了 UserService

7.2 Mock类→Service类替换

每个 Mock 数据类对应一个未来的 Service 类,替换路径清晰明确:

┌──────────────────┬───────────────────┬─────────────────────────┐
│ Mock类            │ 未来Service类       │ 替换内容                  │
├──────────────────┼───────────────────┼─────────────────────────┤
│ MockUserData     │ UserService       │ 静态数组 → HTTP请求       │
│ MockGameData     │ GameService       │ 静态数组 → HTTP请求       │
│ MockActivityData │ ActivityService   │ 静态数组 → HTTP请求       │
│ MockChatData     │ ChatService       │ 静态数组 → WebSocket     │
│ MockNotifyData   │ NotifyService     │ 静态数组 → WebSocket推送  │
│ MockMusicData    │ MusicService      │ 静态数组 → AVSession API │
│ MockUsageData    │ UsageService      │ 静态数组 → 系统API        │
│ MockBlockData    │ BlockService      │ 模块变量 → 本地数据库      │
│ MockRunAdvisorData│ RunAdvisorService│ 静态数组 → HTTP请求       │
└──────────────────┴──────────────────┴────────────────────────┘

替换过程的步骤是:

  1. 创建 Service 类:新建 UserService.ets,实现与 MockUserData 相同的方法签名
  2. 修改导入语句:将 UI 页面中的 import { MockUserData } 改为 import { UserService }
  3. 修改调用点:将 MockUserData.getNearbyUsers() 改为 UserService.getNearbyUsers()
  4. 删除 Mock 类:确认所有引用已切换后,删除 MockUserData

7.3 接口抽象层设计

为了使替换过程更加平滑,可以引入一个接口抽象层(在 ArkTS 中通过抽象类实现),让 Mock 类和 Service 类实现同一个接口:

// 接口抽象层 — IDataService.ets
export abstract class INearbyService {
  abstract getNearbyUsers(): NearUser[]
  abstract getNearbyNowPlaying(): NowPlayingSong[]
  abstract getNearbyUsage(): AppUsageRecord[][]
}

// Mock实现 — MockUserData 继承接口
export class MockNearbyService extends INearbyService {
  getNearbyUsers(): NearUser[] {
    return [
      NearUser.of('u1', '小明', '👦', 0.5, true, 31.23, 121.47, ''),
      // ... 其他 Mock 数据
    ]
  }
  getNearbyNowPlaying(): NowPlayingSong[] { return MockMusicData.getNearbyNowPlaying() }
  getNearbyUsage(): AppUsageRecord[][] { return MockUsageData.getNearbyUsage() }
}

// 真实API实现 — 未来
export class RealNearbyService extends INearbyService {
  getNearbyUsers(): NearUser[] {
    // HTTP请求 /api/nearby-users
    // 解析响应为 NearUser[]
  }
  getNearbyNowPlaying(): NowPlayingSong[] {
    // 调用 AVSession API
  }
  getNearbyUsage(): AppUsageRecord[][] {
    // 调用 usageStatistics API
  }
}

使用抽象层后,UI 页面的依赖变为对接口的依赖:

// 全局数据服务配置
let nearbyService: INearbyService = new MockNearbyService()

// UI 页面使用
const users = nearbyService.getNearbyUsers()

// 切换到真实API只需修改一处
nearbyService = new RealNearbyService()

7.4 渐进式切换策略

Mock→真实API的切换不需要一次性完成。NearPlay 可以采用渐进式策略,逐个功能模块切换:

阶段1:所有功能使用 Mock
  MockUserData ──→ UI
  MockChatData ──→ UI
  MockMusicData ──→ UI
  ...

阶段2:切换音乐功能到真实API
  MockUserData ──→ UI
  MockChatData ──→ UI
  MusicService ──→ UI  ← 已切换
  ...

阶段3:切换聊天功能到 WebSocket
  MockUserData ──→ UI
  ChatService  ──→ UI  ← 已切换
  MusicService ──→ UI
  ...

阶段N:全部切换完成
  UserService   ──→ UI
  ChatService   ──→ UI
  MusicService  ──→ UI
  ...

这种渐进式切换的好处是:

  • 风险可控:每次只切换一个模块,出问题容易定位
  • 并行开发:后端可以按优先级逐个实现接口,前端按顺序切换
  • 随时可回退:如果某个 Service 类出现问题,只需将导入改回 Mock 类

7.5 切换时的注意事项

  1. 异步化:Mock 数据是同步返回的,而真实 API 调用是异步的。切换时需要将方法签名从 getNearbyUsers(): NearUser[] 改为 async getNearbyUsers(): Promise<NearUser[]>,UI 层的调用也需要相应改为 await

  2. 错误处理:Mock 数据不会失败,但 HTTP 请求可能超时、服务器可能返回错误。Service 类需要增加错误处理逻辑,UI 层需要增加 loading 状态和错误提示。

  3. 数据刷新:Mock 数据是静态的,不会变化。真实 API 的数据是动态的,UI 层需要增加下拉刷新、自动轮询等机制。

  4. 分页加载:Mock 数据一次性返回全部数据,真实 API 可能需要分页。数据获取层需要增加分页参数,UI 层需要支持增量加载。


8. Mock数据的真实性考量

8.1 为什么使用真实中文昵称?

NearPlay 的 8 个 Mock 用户使用了「小明」「阿花」「大壮」「小美」「老王」「小丽」「阿杰」「小雪」这样的中文昵称,而非 TestUser1、User_A 等无意义的占位符。这不仅是审美选择,更有多重考量:

  1. UI 适配性验证:中文昵称比英文占位符占用更多的水平空间,使用真实中文名可以验证 UI 组件在不同文本长度下的布局表现。「小雪」是 2 个字符,「老王」也是 2 个字符,而「小明」同样是 2 个字符——但它们在 UI 中的视觉宽度可能不同(因为不同汉字的宽度差异),这种差异只有在真实中文名场景下才能被发现。

  2. 交互体验验证:测试人员在操作时需要区分不同用户。「阿花发了消息」「大壮邀请你」比「User2发了消息」「User3邀请你」更容易建立心理映射,减少操作错误。

  3. 演示场景适配:在产品评审和投资人演示时,真实中文名的应用界面比充满 TestUser 的界面更有说服力。它传递了一个信号:这不是一个技术 Demo,而是一个接近成品的产品。

  4. 团队沟通效率:在日常开发讨论中,说「阿花的角色是预言家」比说「u2的角色是预言家」更直观。虽然代码中使用 ID(u1、u2…)标识,但昵称在人类沟通中更高效。

8.2 为什么使用真实食物名称?

RunAdvisor 模块的食谱使用了「鸡胸肉」「鱼肉鲈鱼」「牛肉」「虾仁」「豆腐」「羊肉」「猪瘦肉」「鸡腿」「鱼肉三文鱼」等真实食物名,以及「西兰花」「生菜」「菠菜」「黄瓜」「红薯」「馒头」「土豆」「燕麦片」「米饭」等真实食材,还有「苹果」「橙子」「葡萄」「草莓」「蓝莓」「香蕉」「西瓜」等真实水果。这些选择背后的考量:

  1. 热量数据可验证:鸡胸肉 133kcal/100g、西兰花 34kcal/100g、米饭 116kcal/100g——这些数值都来自真实的营养数据库,任何开发者都可以通过搜索引擎验证。如果使用虚构的食物名和随意编造的热量值,FoodRecipe.getCalories() 的计算逻辑将无法得到有效验证。

  2. 食谱合理性验证:耐力跑用户(大壮)的食谱包含牛肉和红薯——这是增肌+碳水的合理搭配;轻松跑用户(小美)的食谱包含虾仁和豆腐——这是低脂高蛋白的合理选择。如果食物名是虚构的,就无法验证食谱与运动计划的匹配逻辑。

  3. 中文饮食文化适配:NearPlay 面向中国用户,食谱中的馒头、米饭、红薯、豆腐等都是中国饮食中的常见食材,这比使用面包、牛油果、藜麦等西方食材更能验证中国用户场景下的数据呈现效果。

8.3 为什么使用真实歌曲名称?

MockMusicData 中的 9 首歌曲(含我正在播放的 1 首)全部使用真实歌曲名:周杰伦的《晴天》和《七里香》、邓紫棋的《光年之外》、Queen 的《Bohemian Rhapsody》、Eminem 的《Lose Yourself》、Alan Walker 的《Fade》、Dave Brubeck 的《Take Five》、赵雷的《成都》、贝多芬的《月光奏鸣曲》。

这些选择确保了:

  1. 流派分类可验证:《晴天》应该被分类为「流行」,《Bohemian Rhapsody》应该被分类为「摇滚」,《成都》应该被分类为「民谣」——使用真实歌曲名可以让任何熟悉这些歌曲的人立即验证 classifyGenre() 函数的准确性。如果使用虚构歌曲名,就无从判断分类结果是否正确。

  2. 关键词匹配测试classifyGenre() 函数基于关键词匹配工作——歌曲名或歌手名中包含「周杰伦」就映射到「流行」,包含「Queen」就映射到「摇滚」。真实歌曲名包含了这些关键识别信息,而虚构歌曲名无法提供有效的测试覆盖。

  3. 中英文混合场景:9 首歌曲中 5 首中文、4 首英文,模拟了中国都市用户的真实听歌习惯。这也测试了 classifyGenre() 函数对中英文混合输入的处理能力。

8.4 距离 0.5-3.5km 的合理性

8 个 Mock 用户的距离分布在 0.5km 到 3.5km 之间,这个范围的选择基于以下考量:

  1. 近场社交的自然范围:NearPlay 定位为「近场社交+游戏」应用。在都市环境中,0.5km 大约是同一栋写字楼或同一个商圈的距离,3.5km 大约是同一城区相邻商圈的距离。超过 3.5km 的用户虽然技术上可以被发现,但线下约玩的意愿会大幅降低,3.5km 是近场社交的「舒适距离」上限。

  2. 蓝牙/Wi-Fi 发现的现实范围:在真实环境中,NearPlay 可能使用蓝牙 BLE 或 Wi-Fi Aware 进行近距离设备发现,这两种技术的有效范围通常在 100m-500m。3.5km 的上限意味着还包含了 GPS 级别的位置发现,这覆盖了「蓝牙直接发现」和「GPS 扩展发现」两种模式。

  3. 递增分布的测试价值:8 个用户的距离从 0.5km 逐步递增到 3.5km(0.5→0.8→1.2→1.5→2.0→2.3→3.0→3.5),这种等差递增分布可以验证:

    • 距离排序逻辑(UI 是否按距离从近到远排列)
    • 距离格式化显示(0.5km vs 1.2km vs 3.5km 的显示精度)
    • 距离筛选功能(如「只看 1km 内」的过滤)
    • 地图上的距离标注
  4. 避免边界值问题:如果所有距离都是 0.5km 或都是 3.5km,就无法发现排序、格式化、筛选等功能在不同距离值下的行为差异。递增分布确保了每个距离数量级(<1km、1-2km、2-3km、>3km)都有测试覆盖。

8.5 跨模块数据一致性

NearPlay 的 Mock 数据在不同模块之间保持了一致性,这是真实性考量的延伸:

  • 用户 ID 一致:8 个用户(u1-u8)在 MockUserData、MockWerewolfData、MockUndercoverData、MockChatData、MockNotifyData、MockBlockData、MockRunAdvisorData 中使用相同的 ID 和昵称
  • 活动关联一致:MockNotifyData 中 n1/n2 的活动 ID 是 a1,与 MockActivityData 中的「周末狼人杀聚会」对应
  • 游戏参与一致:MockUserData 中阿花(u2)的 currentGame 是「狼人杀」,与 MockChatData 中 c3「狼人杀房间」对应
  • 拉黑状态一致:MockBlockData 中拉黑了阿杰(u7),而阿杰恰好是离线用户之一

这种跨模块一致性确保了当不同功能页面展示关联数据时(如从通知页点击进入活动详情、从聊天页查看用户资料),数据能够无缝衔接,不会出现「通知中提到的小美和用户列表中的小美不是同一个人」这种割裂感。


附录:Mock数据类与源文件映射

entry/src/main/ets/model/
├── UserModel.ets          → MockUserData (8个附近用户 + LocationShareRequest)
├── ActivityModel.ets      → MockActivityData (4个活动)
├── ChatModel.ets          → MockChatData (4个会话 + 6条示例消息)
├── NotifyModel.ets        → MockNotifyData (5条通知)
├── BlockModel.ets         → MockBlockData (1个已拉黑用户 + CRUD函数)
├── MusicModel.ets         → MockMusicData (1首我的 + 8首附近歌曲)
├── UsageModel.ets         → MockUsageData (5条我的 + 8组附近使用记录)
├── RunAdvisorModel.ets    → MockRunAdvisorData (3个跑步计划 + 3个食谱 + 8个用户档案)
├── GameModel.ets          → MockGameData (6个游戏 + GameRoom)
├── GameMessage.ets        → GameMessage (游戏网络消息 + JSON序列化)
├── GameNetwork.ets        → GameNetwork (WebSocket连接管理 + 重连)
├── PermissionModel.ets    → getDefaultPermissions (3个权限项)
└── game/
    ├── WerewolfModel.ets  → MockWerewolfData (8个狼人杀玩家)
    ├── UndercoverModel.ets→ MockUndercoverData (8组词对 + 6个玩家)
    ├── ScriptKillModel.ets→ MockScriptData (1个完整剧本)
    ├── DrawGuessModel.ets → MockDrawGuessData (12个词汇 + 4个玩家)
    ├── QuickReactModel.ets→ MockQuickReactData (动态牌组生成)
    └── TruthOrDareModel.ets→ MockTruthOrDareData (6个真心话 + 6个大冒险 + 4个玩家)
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐