前言
很多工具型 App 都会有“知识库”“建议库”“技巧列表”,但大多数最后都变成没人点的静态页面。原因很简单:用户打开应用时不想读百科,他想要一个当下能执行的答案。
喵汪星球的陪玩模块就是围绕这个目标设计的:不是给用户一堆文章,而是直接回答“今天玩什么”。用户可以随机换一个,可以收藏,也可以从更多建议里选择一个玩法。每条内容都包含时长、道具、目标和注意事项。
这篇重点讲内容型功能的产品化实现。
内容型功能的关键:先给答案
陪玩页的设计不是先展示分类筛选,而是先展示一个主推荐:
@Builder PlayPage() { Column({ space: Theme.spaceXL }) { this.PageHeader('今天玩什么', this.playHeaderText()) this.EntertainmentNotice( '该功能以娱乐为主,陪玩建议请结合宠物年龄、体力和现场状态选择' ) Column({ space: Theme.spaceL }) { Stack({ alignContent: Alignment.BottomStart }) { this.PlayHeroImage(this.currentPlayTitle()) Column({ space: Theme.spaceS }) { Text(this.currentPlayTitle()) Text(this.currentPlayMeta()) Row({ space: Theme.spaceS }) { this.Tag('放电', 'primary') this.Tag('低门槛', 'warm') this.Tag('可收藏', 'blue') } } } Row({ space: Theme.spaceM }) { Text('换一个') Text(this.isFavoritePlay(this.currentPlayTitle()) ? '取消收藏' : '收藏') } } } }这个结构像一个“推荐卡”,而不是文章列表。用户打开页面后马上知道今天可以玩什么,再决定要不要换一个或收藏。
内容功能常见误区是把所有内容摊给用户。更好的方式是先给一个可执行答案,再提供更多选择。
内容模型:四个字段就够用
陪玩建议模型很克制:
interface PlaySuggestion { title: string meta: string goal: string caution: string }示例数据:
private readonly playCards: PlaySuggestion[] = [ { title: '纸团追逐', meta: '10 分钟 · 纸团 / 逗猫棒', goal: '放电 · 缓解无聊', caution: '结束后收走小物件,避免误吞' }, { title: '嗅闻寻宝', meta: '12 分钟 · 零食 / 嗅闻垫', goal: '探索 · 建立亲密关系', caution: '零食量计入当天总摄入' }, { title: '慢速跟随', meta: '8 分钟 · 无需道具', goal: '低强度 · 老年宠物友好', caution: '避免强迫互动,观察呼吸频率' } ]这四个字段刚好覆盖用户决策:
title:玩什么。meta:多久、需要什么。goal:为什么玩。caution:注意什么。
移动端内容不适合一上来写长文。结构化字段能让用户扫一眼就行动。
当前推荐:playIndex 而不是复制对象
项目用playIndex保存当前推荐:
@State playIndex: number = 0 private currentPlay(): PlaySuggestion { if (this.playIndex < 0 || this.playIndex >= this.playCards.length) { return this.playCards[0] } return this.playCards[this.playIndex] } private currentPlayTitle(): string { return this.currentPlay().title } private currentPlayMeta(): string { return this.currentPlay().meta }为什么不直接把当前玩法对象存到状态里?
因为玩法内容是静态数组,playIndex更轻,也更容易持久化。内容更新时,只要 index 合法,页面就能重新拿到最新内容。如果 index 越界,回退到第一条。
恢复状态时也做了保护:
if (this.playIndex < 0 || this.playIndex >= this.playCards.length) { this.playIndex = 0 }这是内容库迭代时很有用的小细节。以后删掉某条玩法,旧用户本地保存的 index 也不会导致页面异常。
随机换一个:避免“没变化”的尴尬
换一个推荐:
private nextPlay(): void { let nextIndex = Math.floor(Math.random() * this.playCards.length) if (this.playCards.length > 1 && nextIndex === this.playIndex) { nextIndex = (nextIndex + 1) % this.playCards.length } this.playIndex = nextIndex this.saveState() }这里特意避免连续随机到同一条。因为用户点击“换一个”后,如果内容没变,他会怀疑按钮失效。
这是内容型功能里非常细微但重要的体验:随机不是数学意义上的纯随机,而是用户感知上的“发生变化”。
收藏:从单字段升级到列表
早期可以只保存一个收藏标题:
@State favoritePlayTitle: string = ''后来升级为收藏列表:
interface FavoritePlayItem { title: string } @State favoritePlayItems: FavoritePlayItem[] = []判断是否收藏:
private isFavoritePlay(title: string): boolean { for (let index = 0; index < this.favoritePlayItems.length; index++) { if (this.favoritePlayItems[index].title === title) { return true } } return false }切换收藏:
private toggleFavoritePlay(title: string): void { if (this.isFavoritePlay(title)) { this.favoritePlayItems = this.favoritePlayItems.filter( (item: FavoritePlayItem) => item.title !== title ) } else { this.favoritePlayItems = this.favoritePlayItems.concat([{ title: title }]) } this.favoritePlayTitle = this.favoritePlayItems.length > 0 ? this.favoritePlayItems[0].title : '' this.saveState() }这里保留favoritePlayTitle是为了兼容旧字段。恢复状态时,会把旧收藏迁移到新列表:
if (this.favoritePlayTitle !== '' && !this.favoritePlayListContains(this.favoritePlayItems, this.favoritePlayTitle)) { this.favoritePlayItems = this.favoritePlayItems.concat([ { title: this.favoritePlayTitle } ]) }本地 App 的字段演进一定要考虑旧数据。用户升级后,不应该因为你改了收藏结构就丢收藏。
图片映射:用标题选择不同玩法图
玩法图不是动态下载,而是用本地资源:
@Builder PlayHeroImage(title: string) { if (title === '嗅闻寻宝' || title === '零食路线' || title === '毛毯藏物') { Image($r('app.media.play_sniff')) .width('100%') .height('100%') .objectFit(ImageFit.Cover) } else if (title === '慢速跟随' || title === '窗边观察' || title === '梳毛奖励') { Image($r('app.media.play_slow')) .width('100%') .height('100%') .objectFit(ImageFit.Cover) } else { Image($r('app.media.play_paper')) .width('100%') .height('100%') .objectFit(ImageFit.Cover) } }这是一个 MVP 取舍:不为每一条玩法都准备独立图片,而是把玩法归到几类视觉资源里。这样既有视觉丰富度,又不会增加太多资源成本。
如果后续内容库变大,可以把图片字段加入模型:
interface PlaySuggestion { title: string meta: string goal: string caution: string image: Resource }首版为了快速交付,用标题映射就够了。
更多建议列表:不是重复,而是切换入口
主推荐下面是更多玩法:
Column({ space: Theme.spaceM }) { ForEach(this.playCards, (item: PlaySuggestion, index: number) => { this.PlayRow(item, index) }, (item: PlaySuggestion) => item.title) }每个玩法行都能点击设置为当前推荐:
@Builder PlayRow(item: PlaySuggestion, index: number) { Row({ space: Theme.spaceM }) { this.PlayThumb(item.title) Column({ space: Theme.spaceS }) { Row() { Text(item.title) Blank() Text(this.isFavoritePlay(item.title) ? '已收藏' : '收藏') } Text(item.meta) Row({ space: Theme.spaceS }) { this.Tag(item.goal.split(' · ')[0], 'primary') this.Tag(item.meta.split(' · ')[0], 'warm') } Text(item.caution) } } .backgroundColor(index === this.playIndex ? Theme.primaryPale : Theme.surface) .onClick(() => { this.playIndex = index this.saveState() }) }列表不是单纯“更多内容”,而是主推荐的切换器。选中项用primaryPale高亮,用户能理解当前推荐和列表之间的关系。
风险提示是内容质量的一部分
陪玩内容每条都有caution:
结束后收走小物件,避免误吞 零食量计入当天总摄入 避免强迫互动,观察呼吸频率 确认窗户锁好,避免高处跌落 遇到抗拒立即暂停,少量多次这比单纯写“怎么玩”更重要。养宠建议的价值不只是让用户陪宠物玩,还要告诉用户什么时候该停、什么东西不能用、哪些情况要观察。
内容型功能不是“塞文案”,而是把专业边界产品化。
从 ArkTS 数组到内容库的演进
当前playCards写在代码里,适合首版。后续可以迁移为:
resources/rawfile/play_suggestions.json -> KnowledgeRepository -> PlayService -> PlayPage进一步可以支持:
- 按猫/狗过滤。
- 按幼年、成年、老年过滤。
- 按目标过滤:放电、安抚、嗅闻、亲密关系。
- 收藏优先推荐。
- 最近玩过的内容短期内不重复。
- 每日固定推荐,避免刷新后频繁变化。
但不要过早复杂化。首版最重要的是让用户打开页面就能马上行动。
这部分最容易踩的坑
第一,内容不要只做长列表。主推荐能显著提升使用率。
第二,随机推荐要避免连续重复。用户感知比数学随机更重要。
第三,收藏结构升级时要兼容旧字段。
第四,长标题、长注意事项要加maxLines和textOverflow。
第五,图片资源要有兜底,不要让某条内容因为没图就空白。
第六,内容建议要有风险提示,尤其是涉及运动、零食、小物件的玩法。
本篇小结
陪玩模块的技术难度不在 API,而在产品化:
- 用结构化模型代替长文本。
- 用主推荐回答“今天玩什么”。
- 用
playIndex管理当前推荐。 - 用随机切换制造轻量探索感。
- 用收藏沉淀用户偏好。
- 用本地图片资源提升视觉完整度。
- 用风险提示守住内容边界。
下一篇进入健康模块。健康症状库更敏感:它要帮用户整理异常信息,但不能替代兽医诊断。