云计算百科
云计算领域专业知识百科平台

基于鸿蒙OS开发附近社交游戏平台(二十五)-附近匹配引擎

附近匹配引擎

1. 匹配系统概述

NearPlay的核心理念是"发现附近与你志趣相投的人"。单纯的地理距离只能告诉你"谁在你身边",却无法回答"谁和你更聊得来"。匹配引擎的存在正是为了补全这后半个问题——它通过分析用户的音乐偏好和应用使用习惯,计算两个用户之间的匹配度,让"附近的人"不再是随机排列的陌生人列表,而是一个按"可能聊得来"程度排序的同好推荐流。

匹配引擎的数据输入来自两个独立的维度:

音乐维度:通过系统API获取用户当前正在播放的歌曲信息(曲名+歌手),经过MusicModel的classifyGenre()函数分类为11种音乐流派之一。当两个用户的当前播放属于同一流派时,产生"同好"匹配标记。

应用维度:通过系统API获取用户的应用使用记录(bundleName+使用时长),经过UsageModel的classifyApp()函数分类为10种应用类别之一,再通过UsageProfile聚合出Top3高频类别。当两个用户的Top3类别有重叠时,产生匹配度分数。

两个维度在MatchEngine中综合计算,输出一个0-100的匹配度总分,以及"同好"标签和"匹配度·极高/高/中/低"的等级描述。这些匹配结果被enrichUsersWithMatch()函数注入到NearUser对象中,最终在附近用户卡片上以紫色和橙色标签的形式展示给用户。

匹配引擎的设计遵循了"尽力而为"的原则——它依赖的系统API(AVSession获取播放信息、usageStatistics获取应用使用统计)在用户未授权或设备不支持时会降级为Mock数据,确保匹配功能始终可用,只是精度有所不同。这种降级策略在本章最后一节中详细讨论。

1.1 为什么选择音乐和应用两个维度

社交匹配的维度选择是一个产品决策,需要在"区分度"和"获取成本"之间取平衡:

  • 年龄/性别:区分度高,但涉及隐私,且HarmonyOS无直接API
  • 位置:已有,但不够(NearPlay本身就是基于位置的)
  • 职业/学历:区分度高,但需要用户手动填写,获取成本高
  • 音乐偏好:区分度中等,可通过系统API自动获取,实时性强(正在听什么)
  • 应用使用:区分度较高,可通过系统API自动获取,反映长期习惯

选择音乐+应用的组合,是因为它们具有互补性:音乐反映的是"当下心情"(短时态,实时性强),应用反映的是"长期习惯"(长时态,稳定性强)。一个人今天在听摇滚可能只是心情波动,但如果他的应用使用Top3是社交+视频+音乐,这更可能是稳定的行为特征。两个维度的组合比任一单独维度都有更高的匹配可靠性。

1.2 匹配引擎的局限性

当前匹配引擎有以下已知局限:

  • 音乐匹配只看当前一首歌:如果A正在听流行、B刚听完了流行切换到了摇滚,两人的音乐匹配就失败了。更准确的做法是取最近N首歌的流派分布进行比较。
  • 应用使用不做时长加权:使用微信2小时和使用微信10分钟都被归入"社交"类别,但两者反映的社交活跃度截然不同。
  • 分类体系较粗:10种音乐流派、10种应用类别是相当粗粒度的分类,可能遗漏有意义的差异(如"游戏"类别下,王者荣耀和动物森友会反映的社交偏好完全不同)。
  • 无法处理冷启动:新用户没有历史播放记录和应用使用数据,匹配引擎无法给出有意义的分数。
  • 这些局限是MVP阶段的有意识取舍,后续版本可以通过引入更细粒度的分类、更丰富的特征维度、以及基于协同过滤的推荐算法来逐步改进。

    2. MusicModel完整详解

    MusicModel负责音乐流派分类,是匹配引擎音乐维度的数据基础。

    2.1 MusicGenre枚举

    typescript export enum MusicGenre { POP = '流行', ROCK = '摇滚', HIPHOP = '嘻哈', ELECTRONIC = '电子', JAZZ = '爵士', CLASSICAL = '古典', RNB = 'R&B', FOLK = '民谣', METAL = '金属', COUNTRY = '乡村', UNKNOWN = '未知' }

    11种音乐流派,涵盖了中文互联网用户最常见的音乐类型。每种流派使用中文标签(枚举值),便于直接用于UI展示。

    枚举值的选取基于以下考量:

    • 流行(POP):覆盖面最广的类别,包括华语流行、欧美流行
    • 摇滚(ROCK):与流行有明确区分的独立流派,朋克也归入此类
    • 嘻哈(HIPHOP):近年来中文互联网增长最快的音乐类别,说唱/rap归入此类
    • 电子(ELECTRONIC):EDM、House、Techno等子流派统一归入,避免分类过细
    • 爵士(JAZZ):与布鲁斯(Blues)合并,两者听众高度重叠
    • 古典(CLASSICAL):交响乐、奏鸣曲归入此类
    • R&B:与Soul合并,保持国际通用的R&B标签
    • 民谣(FOLK):中国特有的大类别,赵雷、宋冬野等歌手的归属
    • 金属(METAL):与摇滚区分,Heavy Metal独立分类
    • 乡村(COUNTRY):欧美特色类别,中文用户较少但仍有
    • 未知(UNKNOWN):无法识别的默认类别,不参与匹配计算

    10种有效流派+1种未知,这个粒度在"区分度"和"分类可行性"之间取得了合理平衡。更细的粒度(如将电子细分为House/Techno/Trance)会增加classifyGenre()的关键词维护成本,且匹配价值有限——两个听不同电子子流派的人仍然有"电子音乐"这个共同语言。

    2.2 NowPlayingSong数据类

    `typescript
    export class NowPlayingSong {
    title: string = ‘’
    artist: string = ‘’
    genre: MusicGenre = MusicGenre.UNKNOWN
    genreIcon: string = ‘🎵’

    static of(title: string, artist: string, genre: MusicGenre): NowPlayingSong {
    const s = new NowPlayingSong()
    s.title = title; s.artist = artist; s.genre = genre
    s.genreIcon = getGenreIcon(genre)
    return s
    }
    }
    `

    NowPlayingSong封装了一首"正在播放"的歌曲信息。四个字段:title歌曲名(如"晴天"),artist歌手名(如"周杰伦"),genre通过classifyGenre()确定的流派,genreIcon流派对应的emoji图标通过getGenreIcon()获取。genreIcon在of()工厂方法中自动计算,调用方无需手动设置。这是一种便利设计,避免调用方忘记设置图标。

    2.3 getGenreIcon()函数

    typescript export function getGenreIcon(genre: MusicGenre): string { if (genre === MusicGenre.POP) { return '🎤' } if (genre === MusicGenre.ROCK) { return '🎸' } if (genre === MusicGenre.HIPHOP) { return '🎧' } if (genre === MusicGenre.ELECTRONIC) { return '🎹' } if (genre === MusicGenre.JAZZ) { return '🎷' } if (genre === MusicGenre.CLASSICAL) { return '🎻' } if (genre === MusicGenre.RNB) { return '🎶' } if (genre === MusicGenre.FOLK) { return '🪕' } if (genre === MusicGenre.METAL) { return '🤘' } if (genre === MusicGenre.COUNTRY) { return '🤠' } return '🎵' }

    10种流派各对应一个emoji图标,未知流派使用通用的🎵。图标的选取原则是"直觉关联":🎤流行麦克风演唱,🎸摇滚电吉他乐队,🎧嘻哈耳机说唱,🎹电子键盘制作,🎷爵士萨克斯风,🎻古典小提琴,🎶R&B音符,🪕民谣班卓琴,🤘金属摇滚手势,🤠乡村牛仔。这些emoji在附近用户卡片的"同好"标签旁展示,为匹配结果增加了视觉辨识度。

    2.4 classifyGenre()函数与30+关键词

    classifyGenre()是MusicModel的核心算法,负责将歌曲标题+歌手名的组合映射到MusicGenre:

    `typescript
    const genreKeywords: string[][] = [
    [‘流行’, ‘pop’, ‘华语流行’, ‘POP’, MusicGenre.POP],
    [‘摇滚’, ‘rock’, ‘ROCK’, ‘朋克’, ‘punk’, MusicGenre.ROCK],
    [‘嘻哈’, ‘hip’, ‘hop’, ‘rap’, ‘HIPHOP’, ‘说唱’, MusicGenre.HIPHOP],
    [‘电子’, ‘electro’, ‘EDM’, ‘house’, ‘techno’, ‘trance’, ‘dj’, MusicGenre.ELECTRONIC],
    [‘爵士’, ‘jazz’, ‘JAZZ’, ‘blues’, MusicGenre.JAZZ],
    [‘古典’, ‘classical’, ‘CLASSICAL’, ‘交响’, ‘奏鸣’, MusicGenre.CLASSICAL],
    [‘r&b’, ‘rnb’, ‘R&B’, ‘soul’, MusicGenre.RNB],
    [‘民谣’, ‘folk’, ‘FOLK’, ‘乡村民谣’, MusicGenre.FOLK],
    [‘金属’, ‘metal’, ‘METAL’, ‘heavy’, MusicGenre.METAL],
    [‘乡村’, ‘country’, ‘COUNTRY’, MusicGenre.COUNTRY],
    ]

    export function classifyGenre(songTitle: string, artist: string): MusicGenre {
    const combined = ${songTitle} .toLowerCase()
    for (let i = 0; i < genreKeywords.length; i++) {
    const keywords = genreKeywords[i]
    const genre = keywords[keywords.length – 1] as MusicGenre
    for (let j = 0; j < keywords.length – 1; j++) {
    if (combined.indexOf(keywords[j].toLowerCase()) !== -1) {
    return genre
    }
    }
    }
    return MusicGenre.UNKNOWN
    }
    `

    数据结构:genreKeywords是一个二维数组,每行的最后一个元素是MusicGenre枚举值,前面的元素是关键词。这种"关键词到分类"的映射表模式简单、可维护、易扩展——添加新的关键词只需在对应行追加字符串,添加新的流派只需增加一行。

    算法流程:

  • 将songTitle和artist拼接为combined字符串,转为小写
  • 遍历每行关键词
  • 对每行中的每个关键词,检查combined中是否包含该关键词(indexOf !== -1)
  • 一旦匹配,立即返回对应的MusicGenre
  • 所有行都无匹配,返回UNKNOWN
  • 关键词统计:流行4个(流行、pop、华语流行、POP),摇滚5个(摇滚、rock、ROCK、朋克、punk),嘻哈6个(嘻哈、hip、hop、rap、HIPHOP、说唱),电子7个(电子、electro、EDM、house、techno、trance、dj),爵士4个(爵士、jazz、JAZZ、blues),古典5个(古典、classical、CLASSICAL、交响、奏鸣),R&B 4个(r&b、rnb、R&B、soul),民谣4个(民谣、folk、FOLK、乡村民谣),金属4个(金属、metal、METAL、heavy),乡村3个(乡村、country、COUNTRY),总计约46个关键词。

    优先级问题:由于遍历是从第一行开始,第一个匹配即返回,因此关键词表的行序决定了分类的优先级。当前顺序是:流行→摇滚→嘻哈→电子→爵士→古典→R&B→民谣→金属→乡村。这意味着如果一个字符串同时包含"流行"和"民谣"(如"流行民谣"),它会被分类为"流行"——因为流行先于民谣被检查。这种"先到先得"的策略可能导致某些边界情况的误分类,但在实际场景中,歌曲标题和歌手名同时包含两个流派关键词的情况极少。

    大小写处理:combined被转为小写,但关键词中保留了大写形式(如’POP’、‘ROCK’)。由于indexOf比较时也将关键词转为小写(keywords[j].toLowerCase()),这些大写关键词实际上等价于其小写形式,是冗余的。但保留它们不会造成错误——只是为了代码的可读性,让维护者一眼看到"POP"和"pop"都在列表中。

    关键词选择策略:中文关键词面向中文用户的歌曲标题和歌手名,英文关键词面向英文歌曲,缩写/简称覆盖常见的简写形式(如rap、dj),子流派名覆盖主要子流派(如house、techno、朋克)。这种多维度的关键词覆盖确保了分类的召回率——无论是中文歌还是英文歌,无论是主流流派还是亚文化流派,都能被正确识别。

    关键词表的一个隐含风险:某些关键词可能产生误分类。例如,‘hip’和’hop’作为嘻哈的关键词,如果一首歌的歌手名恰好包含"hiphop"(如某品牌名),就会被误分为嘻哈。在实际数据中这种情况极少,但如果需要更精确的分类,可以考虑使用更长的关键词优先匹配(如先检查’hiphop’,再检查’hip’和’hop’),或引入正则表达式的单词边界匹配。

    2.5 MockMusicData

    `typescript
    export class MockMusicData {
    static getMyNowPlaying(): NowPlayingSong {
    return NowPlayingSong.of(‘晴天’, ‘周杰伦’, MusicGenre.POP)
    }

    static getNearbyNowPlaying(): NowPlayingSong[] {
    return [
    NowPlayingSong.of(‘七里香’, ‘周杰伦’, MusicGenre.POP),
    NowPlayingSong.of(‘光年之外’, ‘邓紫棋’, MusicGenre.POP),
    NowPlayingSong.of(‘Bohemian Rhapsody’, ‘Queen’, MusicGenre.ROCK),
    NowPlayingSong.of(‘Lose Yourself’, ‘Eminem’, MusicGenre.HIPHOP),
    NowPlayingSong.of(‘Fade’, ‘Alan Walker’, MusicGenre.ELECTRONIC),
    NowPlayingSong.of(‘Take Five’, ‘Dave Brubeck’, MusicGenre.JAZZ),
    NowPlayingSong.of(‘成都’, ‘赵雷’, MusicGenre.FOLK),
    NowPlayingSong.of(‘月光奏鸣曲’, ‘贝多芬’, MusicGenre.CLASSICAL),
    ]
    }
    }
    `

    Mock数据精心选择了8首代表性歌曲,覆盖了8种不同的音乐流派:前两首是流行(与"我"同流派,会触发音乐匹配),其余6首各代表一种不同流派。注意8首歌曲对应8个附近用户(MockUserData中的8个NearUser),这种一一对应关系在enrichUsersWithMatch()中被使用。第9个及以后的用户将没有匹配数据。

    3. UsageModel完整详解

    UsageModel负责应用使用习惯的分类和聚合,是匹配引擎应用维度的数据基础。

    3.1 AppCategory枚举

    typescript export enum AppCategory { SOCIAL = '社交', ENTERTAINMENT = '娱乐', MUSIC = '音乐', GAME = '游戏', READING = '阅读', VIDEO = '视频', WORK = '办公', SHOPPING = '购物', SPORTS = '运动', OTHER = '其他' }

    10种应用类别,覆盖了HarmonyOS用户最常见的应用使用场景。与MusicGenre的10种有效分类形成对称设计。各类别涵盖了社交(微信/QQ/微博)、音乐(网易云/QQ音乐)、游戏(王者荣耀/和平精英)、视频(B站/腾讯视频)、阅读(多看/Kindle)、办公(钉钉/企业微信)、购物(淘宝/京东)、运动(Keep/小米运动)等主流应用类别。ENTERTAINMENT(娱乐)作为预留类别保留,当前Mock数据中没有对应的App映射。

    3.2 AppUsageRecord数据类

    `typescript
    export class AppUsageRecord {
    bundleName: string = ‘’
    appName: string = ‘’
    category: AppCategory = AppCategory.OTHER
    usageMinutes: number = 0
    lastUsedTime: number = 0

    static of(bundle: string, name: string, cat: AppCategory, minutes: number): AppUsageRecord {
    const r = new AppUsageRecord()
    r.bundleName = bundle; r.appName = name; r.category = cat; r.usageMinutes = minutes
    r.lastUsedTime = Date.now()
    return r
    }
    }
    `

    五个字段记录了一个应用的使用信息:bundleName用于系统API识别(如’com.tencent.mm’),appName用于UI展示(如’微信’),category通过classifyApp()确定,usageMinutes用于计算Top3类别,lastUsedTime预留了时间衰减计算的可能性(当前未使用,但在未来"越近的使用越重要"的加权方案中会被用到)。

    3.3 getCategoryIcon()函数

    10种类别各对应一个emoji图标:💬社交、🎉娱乐、🎵音乐、🎮游戏、📚阅读、🎬视频、💼办公、🛒购物、⚽运动、📱其他。与getGenreIcon()对称的设计,OTHER类别使用📱(手机),暗示"其他App"。这些图标为匹配结果中重叠类别的展示提供了视觉标识。

    3.4 classifyApp()函数与19个App映射

    typescript const bundleCategoryMap: string[][] = [ ['com.tencent.mm', '微信', AppCategory.SOCIAL], ['com.tencent.mobileqq', 'QQ', AppCategory.SOCIAL], ['com.sina.weibo', '微博', AppCategory.SOCIAL], ['com.tencent.qqlive', '腾讯视频', AppCategory.VIDEO], ['com.youku.phone', '优酷', AppCategory.VIDEO], ['tv.danmaku.bili', '哔哩哔哩', AppCategory.VIDEO], ['com.netease.cloudmusic', '网易云音乐', AppCategory.MUSIC], ['com.kugou.android', '酷狗音乐', AppCategory.MUSIC], ['com.tencent.qqmusic', 'QQ音乐', AppCategory.MUSIC], ['com.tencent.tmgp.sgame', '王者荣耀', AppCategory.GAME], ['com.tencent.ig', '和平精英', AppCategory.GAME], ['com.duokan.reader', '多看阅读', AppCategory.READING], ['com.amazon.kindle', 'Kindle', AppCategory.READING], ['com.alibaba.android.rimet', '钉钉', AppCategory.WORK], ['com.tencent.wework', '企业微信', AppCategory.WORK], ['com.taobao.taobao', '淘宝', AppCategory.SHOPPING], ['com.jingdong.app.mall', '京东', AppCategory.SHOPPING], ['com.xiaomi.hm.health', '小米运动', AppCategory.SPORTS], ['com.keep', 'Keep', AppCategory.SPORTS], ]

    classifyApp()使用精确匹配(===),与classifyGenre()的模糊包含匹配(indexOf)不同。这是因为bundleName是标准化的包名标识符,不存在"包含"的歧义——要么完全匹配,要么不匹配。19个App覆盖了8个有效类别(缺少"娱乐"类别的App),是中国Android/HarmonyOS生态中最主流的应用。

    19个App的类别分布:

    • 社交:3个(微信、QQ、微博)— 最高频的日常应用
    • 视频:3个(腾讯视频、优酷、B站)— 高时长消耗类别
    • 音乐:3个(网易云、酷狗、QQ音乐)— 与音乐维度互补
    • 游戏:2个(王者荣耀、和平精英)— NearPlay核心场景
    • 阅读:2个(多看、Kindle)— 知识型用户标识
    • 办公:2个(钉钉、企业微信)— 工作型用户标识
    • 购物:2个(淘宝、京东)— 生活型用户标识
    • 运动:2个(小米运动、Keep)— 健康型用户标识

    19个App的选择标准是"日活用户数Top50中与类别强关联的应用"。排除了通用工具类(如文件管理器、设置)和系统应用(如拨号、短信),因为它们对用户行为特征的区分度太低。每个类别2-3个App的覆盖度足以识别该类别的重度用户——如果一个人的使用记录中出现了微信、QQ、微博中的任意一个或多个,他大概率是一个社交活跃用户。

    3.5 getAppName()函数

    辅助函数,通过bundleName查找应用的显示名称。如果不在映射表中,返回bundleName本身作为fallback——虽然不够友好(如"com.some.app"对用户无意义),但至少不是空白或undefined。这个函数在展示应用使用记录详情时使用,让用户能看到"微信(120分钟)“而非"com.tencent.mm(120分钟)”。

    3.6 UsageProfile数据类与fromRecords()

    UsageProfile是应用使用数据的聚合结果,用于匹配计算:

    `typescript
    export class UsageProfile {
    topCategories: AppCategory[] = []
    totalMinutes: number = 0
    categoryMinutes: Record<string, number> = {}

    static fromRecords(records: AppUsageRecord[]): UsageProfile {
    const profile = new UsageProfile()
    const catMap: Record<string, number> = {}
    let total = 0
    for (let i = 0; i < records.length; i++) {
    const catKey = records[i].category as string
    const existing = catMap[catKey]
    if (existing !== undefined) {
    catMap[catKey] = existing + records[i].usageMinutes
    } else {
    catMap[catKey] = records[i].usageMinutes
    }
    total += records[i].usageMinutes
    }
    profile.categoryMinutes = catMap
    profile.totalMinutes = total

    const cats: string[] = ['社交', '娱乐', '音乐', '游戏', '阅读', '视频', '办公', '购物', '运动', '其他']
    const sorted: string[] = []
    for (let i = 0; i < cats.length; i++) {
    if (catMap[cats[i]] !== undefined && catMap[cats[i]] > 0) {
    sorted.push(cats[i])
    }
    }
    sorted.sort((a: string, b: string) => (catMap[b] ?? 0) – (catMap[a] ?? 0))
    const topCats: AppCategory[] = []
    const limit = Math.min(3, sorted.length)
    for (let i = 0; i < limit; i++) {
    topCats.push(sorted[i] as AppCategory)
    }
    profile.topCategories = topCats
    return profile

    }
    }
    `

    fromRecords()的算法流程:

  • 分类汇总:遍历所有AppUsageRecord,将同一类别的使用时长累加到catMap中。例如,微信(120min)和QQ(50min)都归入"社交",catMap[“社交”]=170。
  • 总量计算:累加所有记录的usageMinutes得到totalMinutes。这个值当前未直接用于匹配计算,但为未来的时长归一化预留了数据。
  • 排序提取Top3:先按预定义的类别顺序筛选出有值的类别,然后使用sort按使用时长降序排列,取前Math.min(3, sorted.length)个作为topCategories。
  • 排序逻辑的细节:预定义的cats数组(社交、娱乐、音乐…其他)确保了排序前类别的确定性顺序。sort使用catMap[b]-catMap[a]的降序排列,?? 0处理了catMap中不存在该键时的undefined情况。Top3的选择意味着我们只关心用户最常用的3个应用类别,而非完整的类别分布。这种截断策略有两个好处:减少噪音(偶尔使用的应用不应影响匹配判断),提高区分度(Top3通常能代表用户的核心使用习惯)。

    一个排序稳定性的考量:当两个类别的使用时长相同时,sort的结果取决于ArkTS的排序实现是否稳定。当前代码没有对同时长类别做额外排序,这意味着同时长时Top3的选取可能有不确定性。但在实际数据中,两个类别的使用时长完全相同的概率很低,这个边界情况可以忽略。

    categoryMinutes的用途:除了Top3提取,categoryMinutes还保留了完整的分类时长数据。虽然当前代码中只使用了topCategories进行匹配计算,但categoryMinutes为未来的"加权匹配"预留了数据基础——例如,两个用户Top1都是社交,但一个用了200分钟、一个只用了30分钟,加权匹配可以区分这种"深度社交"和"浅度社交"的差异。

    3.7 MockUsageData

    "我"的使用数据Top3为:社交(120min) > 视频(90min) > 音乐(60min)。这个Top3将成为所有匹配计算的基准——其他用户的Top3与"我"的Top3重叠度越高,匹配分数越高。

    8组附近用户的使用数据覆盖了不同的使用画像:

    • 用户1:社交+音乐+视频 — 与"我"Top3高度重叠(三个全中)
    • 用户2:视频+社交+游戏 — 与"我"部分重叠(社交+视频匹配)
    • 用户3:社交+阅读+音乐 — 与"我"部分重叠(社交+音乐匹配)
    • 用户4:办公+社交 — 与"我"低重叠(仅社交匹配,且非Top1)
    • 用户5:游戏+社交+视频 — 与"我"部分重叠(社交+视频匹配)
    • 用户6:购物+购物+视频 — 与"我"低重叠(仅视频匹配)
    • 用户7:运动+运动+音乐 — 与"我"部分重叠(仅音乐匹配)
    • 用户8:视频+视频+社交 — 与"我"高度重叠(社交+视频匹配,视频还重叠了Top1)

    这种分布确保了匹配引擎能产出从"极高"到"低"的各级匹配度,验证评分算法的区分度。

    4. MatchEngine算法

    MatchEngine是匹配引擎的核心计算模块,定义在MatchEngine.ets中。它包含三个导出函数:computeMusicMatch()、computeUsageMatch()、computeMatch(),以及一个结果数据类MatchResult。

    4.1 MatchResult数据类

    `typescript
    export class MatchResult {
    totalScore: number = 0
    musicMatch: boolean = false
    musicGenre: MusicGenre = MusicGenre.UNKNOWN
    usageScore: number = 0
    matchedCategories: AppCategory[] = []
    label: string = ‘’
    scoreLabel: string = ‘’

    static of(total: number, musicMatch: boolean, genre: MusicGenre, usageScore: number, cats: AppCategory[]): MatchResult {
    const r = new MatchResult()
    r.totalScore = total; r.musicMatch = musicMatch; r.musicGenre = genre
    r.usageScore = usageScore; r.matchedCategories = cats
    if (musicMatch) {
    r.label = 同好·
    }
    if (total >= 80) {
    r.scoreLabel = ‘匹配度·极高’
    } else if (total >= 60) {
    r.scoreLabel = ‘匹配度·高’
    } else if (total >= 40) {
    r.scoreLabel = ‘匹配度·中’
    } else if (total >= 20) {
    r.scoreLabel = ‘匹配度·低’
    } else {
    r.scoreLabel = ‘’
    }
    return r
    }
    }
    `

    MatchResult包含6个数据字段和2个计算标签。数据字段有totalScore(0-100总分)、musicMatch(布尔值)、musicGenre(匹配的流派)、usageScore(0-80应用分)、matchedCategories(重叠类别列表)。计算标签有label("同好·XX"格式,仅在musicMatch为true时生成)和scoreLabel(四档等级描述)。

    scoreLabel的阈值设计:≥80"匹配度·极高",≥60"匹配度·高",≥40"匹配度·中",≥20"匹配度·低",<20为空字符串。这种分级展示避免了标签泛滥——只有匹配度达到一定阈值才展示标签,保证标签的参考价值。低于20分的用户被认为"基本无匹配",不展示标签比展示"匹配度·极低"更友好。

    4.2 computeMusicMatch()

    typescript export function computeMusicMatch(mySong: NowPlayingSong, otherSong: NowPlayingSong): boolean { if (mySong.genre === MusicGenre.UNKNOWN || otherSong.genre === MusicGenre.UNKNOWN) { return false } return mySong.genre === otherSong.genre }

    音乐匹配的算法极简:两首歌属于同一流派即匹配。UNKNOWN的处理:任何一方为UNKNOWN时直接返回false,因为UNKNOWN意味着无法识别流派,匹配不可靠。两个UNKNOWN的用户可能听完全不同风格的音乐,只是恰好都无法识别。

    匹配粒度只判断流派是否相同,不考虑歌手或歌曲是否相同。这是MVP的简化设计——更精确的匹配可以包括"同歌手不同曲"、"同曲不同版本"等更细粒度的判断。但流派级匹配已经足以识别"两人都在听流行音乐"这种显著的共同点。

    为什么是布尔值而非分数:音乐匹配的输出是一个布尔值而非0-100的分数,这与应用匹配的分数输出形成不对称设计。原因在于:音乐数据是瞬时快照(只看当前一首歌),用一个固定分数(+20)表达"音乐同好"的信号比用连续分数更合理——我们无法从一首歌推断"有多喜欢"这个流派,只能判断"是否在听"。相比之下,应用使用数据反映的是长期习惯,Top3的排列和重叠位置能提供更丰富的区分度信息,因此适合用连续分数表达。

    4.3 computeUsageMatch()

    typescript export function computeUsageMatch(myProfile: UsageProfile, otherProfile: UsageProfile): number { let score = 0 const myCats = myProfile.topCategories const otherCats = otherProfile.topCategories const matchedCats: AppCategory[] = [] for (let i = 0; i < myCats.length; i++) { for (let j = 0; j < otherCats.length; j++) { if (myCats[i] === otherCats[j]) { matchedCats.push(myCats[i]) if (i === 0 && j === 0) { score += 40 } else if (i === 0 || j === 0) { score += 25 } else if (i === 1 && j === 1) { score += 20 } else { score += 10 } break } } } return Math.min(score, 80) }

    应用匹配的算法比音乐匹配复杂得多,它基于两个用户的Top3类别的重叠情况计算分数。

    评分矩阵:

    位置组合分数含义
    我#1 = 对方#1 40 两人的最高频类别相同,高度相似
    我#1 = 对方#2/3 25 我的最高频是对方的次高频
    我#2/3 = 对方#1 25 对方的最高频是我的次高频
    我#2 = 对方#2 20 两人的次高频类别相同
    其他重叠 10 两个较低频类别偶然相同

    这个评分矩阵的设计逻辑是:排名越高的类别重叠,权重越大。这是因为Top1通常占据了用户大部分的使用时长(如社交120分钟远超其他),是用户最核心的行为特征。两个用户的Top1相同,比Top3相同更能说明两人相似。

    边界情况分析:

  • 三重完全重叠(Top1=Top1, Top2=Top2, Top3=Top3):40+20+10=70。实际计算中,Top2匹配时i=1,j=1得20,Top3匹配时i=2,j=2(不满足任何特殊条件)得10。如果只有Top1匹配且Top2也匹配:40+20=60。

  • Top1双向不匹配但Top2匹配:当i=1且otherCats中有匹配时,如果j=1(对方Top2也相同),得20分;否则得10分。

  • break语句的作用:一旦myCats[i]与otherCats[j]匹配,立即break跳出内层循环,确保每个myCat只匹配一次。这避免了"一个类别同时匹配对方多个位置"的重复计分问题。

  • 80分封顶:Math.min(score, 80)确保应用匹配分不超过80。这个封顶值的选取是为了给音乐匹配留出20分的空间(80+20=100),使得总分的满分恰好为100。如果应用匹配不封顶,在极端情况下得分可能达到70以上,加上音乐的20分就会超过100——虽然computeMatch()最终也有100的封顶,但80分封顶使得分数分布更均匀,避免了"应用分过高导致音乐分无意义"的问题。

    评分对称性:当前评分矩阵在"我#1=对方#2"和"我#2=对方#1"两种情况下都给25分,这是对称的。但从语义上说,"我的最高频是对方的次高频"和"对方的最高频是我的次高频"可能有不同的含义。当前实现不考虑方向性差异,保持了简洁。

    4.4 computeMatch()

    匹配分数

    `

    computeMatch()是两个维度的综合入口。算法步骤:

  • 调用computeMusicMatch()获取音乐匹配布尔值
  • 调用computeUsageMatch()获取应用匹配分数(0-80)
  • 总分 = 应用分 + 音乐分(如果匹配则+20)
  • Math.min(total, 100)封顶到100
  • 再次计算matchedCategories(与computeUsageMatch()中的逻辑重复,但为了在MatchResult中记录重叠类别)
  • 音乐+20分的权重设计:音乐分固定为20分,应用分最高80分,满分100。这个4:1的权重比反映了一个判断:应用使用习惯比当前播放的单首歌曲更能代表用户的长期特征。一个正在听古典音乐但日常使用都是社交+购物应用的人,与一个也在听古典但日常使用也是社交+购物的人的相似度,远高于与一个听古典但日常使用是办公+阅读的人。应用习惯是"稳定的底色",音乐偏好是"即时的点缀"。

    matchedCategories的重复计算:computeMatch()中重新遍历了两个Top3来提取matchedCategories,这与computeUsageMatch()中的逻辑完全相同。这看似是代码重复,但实际上是因为computeUsageMatch()只返回分数,不返回匹配的类别列表。如果需要消除重复,可以让computeUsageMatch()返回一个包含分数和类别列表的结构体,但这会增加接口复杂度。当前的重复在可维护性上是可接受的。

    总分封顶的意义:即使应用分已经是80(封顶值),加上音乐的20分恰好等于100。如果未来应用分的封顶值调整(如提高到90),音乐的20分不变,总分上限变为110,Math.min(total, 100)确保了总分始终不超过100。这是一个防御性设计,为未来的分数调整留出了安全边界。

    4.5 评分示例

    以Mock数据为例,计算"我"与几个典型附近用户的匹配度:

    我:音乐=流行,应用Top3=[社交120, 视频90, 音乐60]

    用户1(小明):音乐=流行,应用Top3=[社交100, 音乐80, 视频50]

    • 音乐匹配:流行=流行 → true,+20
    • 应用匹配:社交(我#1=对方#1) → 40,视频(我#2=对方#3) → 10,音乐(我#3=对方#2) → 25,合计75→min(75,80)=75
    • 总分:75+20=95 → min(95,100)=95
    • scoreLabel:匹配度·极高
    • label:同好·流行

    用户4(小美):音乐=?,应用Top3=[办公200, 社交60]

    • 应用匹配:社交(我#1=对方#2) → 25,合计25→min(25,80)=25
    • 总分:25+音乐分(取决于对方流派)
    • scoreLabel:匹配度·低(无音乐匹配时)

    用户6(小丽):音乐=?,应用Top3=[购物90, 购物80, 视频40]

    • 应用匹配:视频(我#2=对方#3) → 10,合计10→min(10,80)=10
    • 总分:10+音乐分
    • scoreLabel:匹配度·低或空

    这些示例验证了评分算法的区分度:与使用习惯相似的用户(小明)匹配度极高,与使用习惯差异大的用户(小丽)匹配度极低。

    5. enrichUsersWithMatch()

    enrichUsersWithMatch()是匹配引擎与UI之间的桥梁函数,定义在Index.ets中:

    函数职责:为每个NearUser计算匹配度,并将匹配结果注入到NearUser的三个扩展字段中(musicGenreLabel、matchScore、matchLabel)。

    数据来源:当前使用MockData作为数据源。每次调用都会重新获取Mock数据和重新计算UsageProfile。这在Mock阶段是可接受的,但在真实场景中需要缓存mySong和myUsage(它们在页面生命周期内不会变化),避免重复计算。

    索引对应的隐含契约:nearbySongs[i]和nearbyUsages[i]对应用户列表中的第i个用户。这个契约要求Mock数据的顺序必须与NearUser列表的顺序一致。如果两者顺序不一致,匹配结果就会张冠李戴——用户A可能被赋予了用户B的匹配度。在真实场景中,每个NearUser应该携带自己的音乐和使用数据,而非依赖索引对应。

    边界保护:if (i < nearbySongs.length && i < nearbyUsages.length)确保了当用户数量超过Mock数据数量时不会越界访问。超出的用户将没有匹配数据(三个扩展字段保持默认空值/0值),UI上不显示匹配标签。

    直接修改传入对象的问题:函数直接修改了user对象的musicGenreLabel、matchScore、matchLabel字段,而非创建新的NearUser副本。这在ArkTS中是可行的(class是引用类型),但如果调用方持有原始数组的引用并期望其不被修改,就会产生副作用。在当前实现中,enrichUsersWithMatch()在refreshFilteredData()中被调用,返回值被赋值给this.nearbyUsers,由于filter已经创建了新数组,原始Mock数据不受影响。

    6. 附近用户卡片标签UI

    匹配标签

    匹配结果在附近用户卡片上以两个标签的形式展示:

    紫色同好标签:musicGenreLabel使用紫色系(#9C27B0文字 + #F3E5F5背景),显示"同好·流行"等音乐流派匹配信息。紫色在NearPlay的品牌色系中代表"同好/兴趣"维度,与主品牌橙色形成视觉区分。#9C27B0是Material Design的Purple色系,#F3E5F5是其100级浅色变体,文字与背景的对比度保证了可读性。

    橙色匹配度标签:matchLabel使用橙色系(#FF6B35文字 + #FFF3E0背景),显示"匹配度·极高"等等级信息。橙色与NearPlay的品牌色一致,代表"匹配/推荐"维度。#FFF3E0是Material Design的Orange 50级浅色变体,与#FF6B35的组合在浅色背景下清晰可辨。

    两个标签的视觉层次:紫色标签在左(更具体的同好信息),橙色标签在右(更概括的匹配度等级)。用户可以先看到"同好·流行"了解具体的共同点,再看到"匹配度·极高"了解整体相似度。

    标签的条件渲染:只有当对应标签非空时才渲染,避免了"同好·未知"或"匹配度·低"(scoreLabel为空字符串时)的无意义标签显示。外层Row的条件(musicGenreLabel !== ‘’ || matchLabel !== ‘’)确保了当两个标签都为空时,整个Row不会渲染,不占用布局空间。

    标签的尺寸设计:fontSize(10)是NearPlay中最小的文字尺寸,配合4像素的水平padding和1像素的垂直padding,形成紧凑的"胶囊"标签。borderRadius(4)提供了轻微的圆角,与卡片的borderRadius(12)形成视觉层次。

    7. 系统API降级策略

    匹配引擎依赖两个HarmonyOS系统API获取真实数据:AVSession(媒体会话,获取当前播放信息)和usageStatistics(应用使用统计)。这两个API都需要用户授权,且在模拟器上可能不可用。因此,匹配引擎必须有一个降级策略:当API不可用时,回退到Mock数据。

    7.1 AVSession降级

    AVSession API用于获取用户当前正在播放的媒体信息。预期的调用方式:

    `typescript
    import { avSession } from ‘@kit.AVSessionKit’

    try {
    const sessionDescriptors = await avSession.getAllSessionDescriptors()
    // 从session中提取当前播放的曲名和歌手
    // 使用classifyGenre()分类
    } catch (e) {
    // 降级:使用MockMusicData
    mySong = MockMusicData.getMyNowPlaying()
    }
    `

    降级场景:

  • 用户未授权:AVSession需要"媒体控制"权限,用户可能拒绝
  • 模拟器不支持:模拟器没有音乐播放器,AVSession返回空列表
  • 未在播放:用户当前没有在听音乐,session列表为空
  • 系统版本不兼容:某些旧版本系统可能不支持getAllSessionDescriptors API
  • 降级后的行为:使用预定义的Mock数据作为"我"的播放信息。这意味着匹配结果是基于"模拟的流行音乐偏好"计算的,而非真实的播放数据。用户看到的"同好·流行"标签可能不准确——真实的用户可能正在听爵士,但因为API降级而被当作流行偏好。

    7.2 usageStatistics降级

    usageStatistics API用于获取用户的应用使用统计。预期的调用方式:

    `typescript
    import { usageStatistics } from ‘@kit.UsageKit’

    try {
    const bundles = await usageStatistics.queryBundleStatsInfos(0, Date.now())
    // 将bundles转换为AppUsageRecord[]
    // 使用classifyApp()分类,UsageProfile.fromRecords()聚合
    } catch (e) {
    // 降级:使用MockUsageData
    myUsage = UsageProfile.fromRecords(MockUsageData.getMyUsage())
    }
    `

    降级场景:

  • 用户未授权:usageStatistics需要"应用使用统计"权限,属于敏感权限
  • 模拟器不支持:模拟器没有真实的应用使用数据
  • 首次启动:系统可能还没有收集到足够的使用数据
  • 隐私模式:用户可能开启了隐私保护,禁止统计
  • 降级后的行为:使用Mock的使用数据计算Top3。与AVSession类似,降级后的匹配结果可能与真实偏好不符。

    7.3 try-catch降级模式

    两个API的降级都采用try-catch模式:

    这种模式的优势是:

  • 零影响降级:API失败不会导致匹配功能不可用,用户体验不受影响
  • 无需预检查:不需要在调用前检查API是否可用,直接try即可
  • 统一处理:所有失败场景(未授权、不可用、异常)都在catch中统一处理
  • 缺点是:

  • 静默降级:用户不知道自己看到的是Mock数据还是真实数据。后续版本可以考虑在UI上添加"模拟数据"的提示。
  • 无法区分失败原因:catch块无法区分"未授权"和"系统不支持",无法给出针对性的引导(如"请前往设置授权")。
  • 7.4 权限请求流程

    在真实实现中,权限请求应该在用户首次进入"附近"页面时触发:

  • 检查音乐权限(AVSession)和应用使用权限(usageStatistics)是否已授予
  • 对未授予的权限,弹出系统权限对话框
  • 用户授权后,调用API获取真实数据
  • 用户拒绝或API不可用,降级到Mock数据
  • 在设置页面提供"重新授权"入口
  • 当前Index.ets中有两个@State变量预留了权限状态:

    这两个变量目前未被使用,但为未来的权限管理流程预留了状态基础。

    7.5 降级对匹配结果的影响分析

    完全降级(两个API都不可用):所有匹配基于Mock数据计算。匹配结果有意义但不准确——它们反映的是"假设的偏好"而非"真实的偏好"。用户可能看到"同好·流行"但实际上并不在听流行音乐。

    部分降级(音乐可用但应用不可用):音乐匹配是准确的,应用匹配基于Mock。总分中,音乐的20分是可靠的,应用分不可靠。这比完全降级好,但仍需在UI上区分。

    无降级(两个API都可用):匹配结果完全基于真实数据,是最理想的情况。但需要注意,"真实数据"本身也有局限——当前播放的单首歌曲可能不反映长期偏好,一天的应用使用记录可能受特殊情况影响(如出差日大量使用导航App)。

    7.6 未来改进方向

  • 数据缓存:将API获取的真实数据缓存到本地,避免每次进入页面都重新请求。缓存应有合理的过期策略(如音乐数据5分钟过期,应用数据1天过期)。

  • 增量更新:应用使用数据变化缓慢,可以采用增量更新策略,只获取上次缓存时间后的新数据。

  • 多源融合:除了AVSession和usageStatistics,还可以从其他来源获取偏好数据,如用户在App内手动选择的兴趣标签、参与过的游戏类型统计等。

  • 隐私透明:在UI上明确告知用户"匹配基于哪些数据"以及"这些数据是否真实",让用户理解匹配结果的可信度。

  • 八、匹配分数的实际应用场景

    8.1 附近用户排序

    当前附近用户列表按距离排序(近→远)。未来可以改为按匹配分数排序——匹配度高的用户排在前面,即使距离稍远。这符合"找同好"的产品定位——和兴趣相投的人玩比和距离近但无共同兴趣的人玩更有趣。

    8.2 游戏匹配优化

    当前GameRoom只按距离匹配玩家。未来可以结合匹配分数进行精准匹配:

    • 狼人杀偏好匹配陌生人间(matchScore低的优先,减少场外信息干扰)
    • 你画我猜偏好匹配创意型玩家(MUSIC/ENTERTAINMENT类别使用多的)
    • 真心话大冒险偏好匹配开放型玩家(SOCIAL类别使用多的)

    8.3 推荐算法

    匹配分数可以用于推荐系统:

    • "你可能感兴趣的人"推荐列表
    • “推荐你加入的游戏房间”
    • “推荐你报名的活动”

    8.4 匹配分数归一化问题

    当前分数范围零到一百,但实际分布集中在四十到六十分区间。这意味着"匹配度·中"是最常见的结果,"极高"和"低"都比较少见。未来可使用百分位归一化——将原始分数映射到零到一百的百分位排名,使得分数分布更均匀,"极高/高/中/低"各占约百分之二十五。

    8.5 冷启动问题

    新用户没有历史音乐播放和应用使用数据,匹配分数为零。这导致新用户在附近列表中没有任何标签,降低了被点击的概率。解决方案:

  • 注册时让用户选择3-5个兴趣标签
  • 新用户给予基础匹配分(如30分起步)
  • 首次使用后逐步积累真实数据
  • 九、匹配引擎的可测试性设计

    9.1 纯函数设计

    computeMusicMatch()和computeUsageMatch()都是纯函数——输入相同参数总是返回相同结果,没有副作用。这使得单元测试非常简单:构造不同的输入组合,验证输出是否符合预期。

    9.2 Mock数据的可控性

    Mock数据完全可控——我们知道每个Mock用户正在播放什么歌曲、使用什么App,可以精确预测匹配结果。这在调试和演示时非常有用。

    9.3 边界测试用例

    • 双方音乐流派都是UNKNOWN → musicMatch=false
    • 双方Top3分类完全相同 → usageScore=75(40+25+10)
    • 一方没有使用记录 → usageScore=0
    • 音乐匹配+使用匹配同时满分 → total=100(capped)

    10. 匹配引擎的算法优化展望

    10.1 从硬编码权重到机器学习

    当前匹配算法的权重(音乐二十分、应用最高八十分、位置排名权重四十/二十五/二十/十)都是人为设定的硬编码值。这些值基于直觉和经验,不一定是最优的。未来可以基于用户行为数据(谁和谁成为了好友、谁和谁一起玩了游戏)训练机器学习模型,自动学习最优的权重配置。例如,如果数据显示"音乐同好"对好友关系形成的预测力超过百分之二十,可以调高音乐匹配的权重。

    10.2 协同过滤与偏好迁移

    协同过滤是推荐系统的经典算法——“喜欢类似东西的人也会喜欢其他类似的东西”。在NearPlay中,如果用户A和用户B有相似的App使用习惯(应用匹配高),而A经常参加狼人杀活动,那么可以向B推荐狼人杀活动。这种"基于用户的协同过滤"将匹配引擎从"两人相似度计算"扩展为"多人偏好网络推理"。

    10.3 实时匹配与离线预计算

    当前匹配计算是实时的——用户进入附近页面时才计算匹配分数。在用户数量较少时这没问题,但当用户数量增长到数千人时,对每对用户计算匹配分数的复杂度是O(n²),可能需要数秒。优化方案是离线预计算——服务端定期(如每五分钟)批量计算所有用户对的匹配分数,将结果缓存。客户端只需查询缓存即可,匹配展示的延迟从"计算时间"降低为"网络请求时间"。

    10.4 匹配解释性设计

    “匹配度·极高"告诉用户"你们很相似”,但不告诉用户"为什么相似"。更好的设计是提供匹配解释——“你们都喜欢流行音乐,都常用社交和视频应用”。这种解释性标签比单纯的分数更有说服力,也更有社交价值——用户可以基于具体共同点发起话题。匹配解释的实现需要在MatchResult中记录更多细节,如匹配的具体类别名称和各自的排名位置。

    11. 匹配引擎与隐私保护的平衡

    11.1 最小化数据采集原则

    匹配引擎只采集"必要的偏好数据"而非"全部行为数据"。具体来说:音乐数据只获取当前播放的流派(不获取曲名和歌手),应用数据只获取Top3类别(不获取具体App名称和使用时长)。这种最小化采集确保了即使数据泄漏,也无法精确还原用户的具体行为——只知道"喜欢流行音乐"和"常用社交应用",不知道"正在听周杰伦"和"每天刷微信两小时"。

    11.2 本地计算优先原则

    当前匹配计算在客户端完成——"我"的数据不出设备,只获取"对方"的偏好数据来计算匹配。这种方式保护了"我"的隐私,但"对方"的偏好数据仍然需要传输。更严格的隐私方案是联邦学习——双方各自在本地计算加密的特征向量,服务端只聚合加密结果,无法获取任何个体的原始数据。

    11.3 用户对匹配可见性的控制

    用户应能控制自己的偏好数据是否对他人可见。设置选项包括:完全公开(所有人都可看到你的匹配标签)、仅好友可见(只有加为好友后才能看到)、完全隐藏(不参与匹配计算,也不显示匹配标签)。这种分层控制尊重了用户的隐私选择——愿意社交的人可以选择公开,注重隐私的人可以选择隐藏。

    12. 匹配引擎在NearPlay产品策略中的定位

    12.1 匹配不是目的而是手段

    匹配引擎的直接产出是匹配分数和标签,但它的真正价值不是让用户看到"匹配度·极高"这几个字,而是驱动用户采取社交行动——点击对方头像、发起聊天、约跑步、加入同一场游戏。匹配分数是"相识"的催化剂,而非终点。NearPlay的产品设计应围绕"如何将匹配结果转化为社交行为"展开——在匹配标签旁添加"打招呼"按钮、在极高匹配的用户卡片上突出显示、在匹配结果中优先推荐游戏类型。

    12.2 匹配分数的动态性

    匹配分数不是静态的——用户此刻在听流行音乐,十分钟后可能切换到古典,匹配分数随之变化。当前实现每次进入附近页面都重新计算,确保了分数的时效性。但如果用户在附近页面停留时间较长(超过五分钟),页面上的匹配分数可能已经过时。未来可以在页面显示期间定期刷新匹配数据(如每三分钟一次),保持分数的实时性。

    12.3 匹配与推荐的统一

    匹配引擎计算"两人相似度",推荐系统决定"展示什么内容给谁看"。两者本质上是同一个问题的不同面向——匹配是推荐的基础,推荐是匹配的应用。NearPlay未来可以将匹配引擎与推荐系统统一为一个偏好模型——每个用户有一个偏好向量,匹配计算向量相似度,推荐基于向量距离排序内容。这种统一架构比独立的匹配+推荐系统更高效、更一致。

    12.4 匹配引擎的持续迭代

    匹配算法不是一蹴而就的——它需要根据用户的真实反馈持续迭代。核心指标是"匹配转化率"——两个匹配度极高的用户中,有多少比例最终发起了聊天或参加了同一活动。如果转化率低,说明匹配算法的区分度不够或标签展示不够吸引人;如果转化率高,说明算法有效。每次算法调整后都应对比转化率的变化,用数据驱动而非直觉驱动算法优化。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 基于鸿蒙OS开发附近社交游戏平台(二十五)-附近匹配引擎
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!