
做内容型应用时,我们很容易下意识觉得:内容越多越好。
放到美食应用里,就是菜谱越多越好,食材越全越好,分类越细越好。这个方向没错,但真做起来会遇到一个更现实的问题:先补什么?
用户搜了"低脂夜宵"没有结果,和用户随手打开一个菜谱看了两秒,这两个信号明显不一样。前者像是在告诉我们"这里缺内容",后者更像是"这个入口有点吸引我"。如果都不记录,后面补内容就只能靠感觉。
中式美食这次加了一层很轻的用户行为记录。它不是要做什么复杂的数据平台,就是想把几个关键动作留下来:搜过什么、有没有结果、哪些菜被反复看、草稿有没有继续保存成正式菜谱。
这样下一次补内容时,就不是拍脑袋说"我觉得应该加这个",而是能回头看一眼:用户到底在哪里卡住了。
本文验证环境
| 项目 | 版本或范围 |
|---|---|
| 开发工具 | DevEco Studio 6.0.0 Release |
| HarmonyOS SDK | API 20 |
| 语言与框架 | ArkTS 6.0 / ArkUI V2 |
| 工程模型 | Stage 模型 |
| 复盘对象 | 中式美食 HarmonyOS 应用 |
| 相关文件 | UserBehaviorRepository.ets、UserInsightService.ets、ContentPlanningService.ets、SearchViewModel.ets |
这一篇不聊复杂 UI,主要聊这条数据链路怎么放。页面可以先简单,但记录点一旦放对,后面首页推荐、搜索补齐、我的厨房和内容运营都能接上。
为什么要单独做用户行为仓储
中式美食里已经有很多仓储:
FavoriteRepository管收藏。NoteRepository管笔记。ViewedRepository管最近浏览。UserDishRepository管用户自定义菜谱。SearchHistoryRepository管搜索历史。
那为什么还需要UserBehaviorRepository?
原因很简单:这些仓储解决的是"功能现在是什么状态",行为仓储解决的是"用户最近在往哪里走"。
举个例子:搜索历史只告诉你用户搜过"低脂鸡胸肉",但行为仓储还可以继续记录:- 这个关键词搜了几次?
- 结果数是多少?
- 有没有多次无结果?
- 最近一次搜索是什么时候?
这些信息放在一起,就不只是一个历史列表了,它会变成内容补齐线索。
行为数据分了三类
UserBehaviorRepository 里把行为拆成三类:菜品行为、搜索行为、草稿行为。
ts
export interface DishBehaviorStats {
dishId: string;
dishName: string;
viewCount: number;
favoriteAddCount: number;
favoriteRemoveCount: number;
noteCount: number;
lastViewedAt: number;
lastFavoritedAt: number;
updatedAt: number;
}
export interface SearchBehaviorStats {
keyword: string;
count: number;
resultCount: number;
missingCount: number;
lastSearchedAt: number;
updatedAt: number;
}
export interface DraftBehaviorStats {
draftSaveCount: number;
dishSaveCount: number;
lastDraftSavedAt: number;
lastDishSavedAt: number;
}
这三个结构对应的是三种很实际的问题:
| 行为类型 | 记录什么 | 能回答什么问题 |
|---|---|---|
| 菜品行为 | 浏览、收藏、取消收藏、笔记次数 | 哪些菜真的被用户反复看 |
| 搜索行为 | 关键词、搜索次数、结果数、缺口次数 | 用户想找但我们没给到什么 |
| 草稿行为 | 草稿保存、正式菜谱保存 | 用户有没有把创作流程走完 |
普通历史记录是让用户回到刚才做过的事;行为统计是让我们知道下一步该优化什么。两者用途不一样,最好不要混在一起。
搜索缺口是最值得记录的信号
在中式美食里,搜索入口很值钱。用户愿意敲关键词,说明他已经有明确目标了。
SearchViewModel 里提交搜索时,会把关键词、搜索结果数记录下来:
ts
submitSearch(): void {
const v: string = this.keyword.trim();
if (v.length === 0) return;
this.results = this.dishRepo.search(v);
this.searched = true;
this.historyRepo.add(v);
this.behaviorRepo.recordSearch(v, this.results.length);
this.history = this.historyRepo.getAll();
}
这里有个小细节很重要:实时输入过滤不写行为数据,只有用户真正回车或点击搜索按钮时才记录。
这样能避开很多脏数据。用户输入"红烧肉"的过程中,可能先输入"红""红烧""红烧肉"。如果每个变化都记一次,后面统计出来的关键词会很乱。真正有价值的,是用户确认搜索的那个词。
recordSearch 里会根据结果数判断是不是缺口:
ts
recordSearch(keyword: string, resultCount: number): void {
const value: string = keyword.trim();
if (value.length === 0) {
return;
}
const now: number = Date.now();
const next: SearchBehaviorStats[] = this.store.searchStats.slice();
const index: number = next.findIndex((it: SearchBehaviorStats): boolean => it.keyword === value);
if (index >= 0) {
const current: SearchBehaviorStats = next[index];
next[index] = {
keyword: current.keyword,
count: current.count + 1,
resultCount,
missingCount: resultCount === 0 ? current.missingCount + 1 : current.missingCount,
lastSearchedAt: now,
updatedAt: now
};
}
}
这里的 missingCount 很有用。用户搜"宝宝辅食"没有结果,偶尔一次还不好判断;如果这个词反复无结果,就基本能说明内容库缺这一类东西。
浏览和收藏不是同一种信号
一个菜被打开很多次,说明它吸引人;一个菜被收藏很多次,说明用户觉得以后可能会用;一个菜被写了笔记,说明用户大概率真的进入了做饭场景。
所以中式美食没有把这些动作粗暴合成一个"热度",而是分开记:
ts
recordDishView(dishId: string, dishName: string): void
recordFavorite(dishId: string, dishName: string, active: boolean): void
recordDishNote(dishId: string, dishName: string): void
分开记,后面看问题会清楚很多:
- 浏览高但收藏低:可能标题和图片吸引人,但详情不够有用。
- 收藏高但笔记低:可能用户想做,但步骤或食材门槛偏高。
- 笔记高:说明这个菜已经进入用户真实做饭场景。
这比只看点击量靠谱。点击量只能说明"看过",但收藏和笔记更接近"真的想用"。
为什么要限制统计数组长度
行为数据存在本地,不适合无限长。项目里设置了两个上限:
ts
const MAX_DISH_STATS: number = 500;
const MAX_SEARCH_STATS: number = 200;
每次写入后会裁剪:
ts
private trimDishStats(items: DishBehaviorStats[]): DishBehaviorStats[] {
return items
.sort((a: DishBehaviorStats, b: DishBehaviorStats): number => b.updatedAt - a.updatedAt)
.slice(0, MAX_DISH_STATS);
}
private trimSearchStats(items: SearchBehaviorStats[]): SearchBehaviorStats[] {
return items
.sort((a: SearchBehaviorStats, b: SearchBehaviorStats): number => b.updatedAt - a.updatedAt)
.slice(0, MAX_SEARCH_STATS);
}
这个设计比较务实。中式美食不是服务端分析平台,本地只需要留住最近一段时间最有参考价值的数据。数据太多,读写会变重,分析结果也会变散。
Preferences 落盘要封一层,不要让页面直接碰存储
这一类数据最后还是要落到本地。中式美食里我不建议页面直接调用 preferences.getPreferences,而是让 UserBehaviorRepository 自己管理读取、写入和默认值。
页面只管说"发生了什么动作",仓储负责决定"怎么存、存多少、什么时候刷盘"。
ts
const BEHAVIOR_STORE_NAME: string = 'user_behavior_store';
const BEHAVIOR_STORE_KEY: string = 'behavior_state_v1';
export class UserBehaviorRepository {
private preferences?: preferences.Preferences;
private store: UserBehaviorStore = createEmptyBehaviorStore();
async init(context: Context): Promise<void> {
this.preferences = await preferences.getPreferences(context, BEHAVIOR_STORE_NAME);
const raw: string = await this.preferences.get(BEHAVIOR_STORE_KEY, '') as string;
this.store = this.parseStore(raw);
}
private async persist(): Promise<void> {
if (!this.preferences) {
return;
}
const text: string = JSON.stringify(this.store);
await this.preferences.put(BEHAVIOR_STORE_KEY, text);
await this.preferences.flush();
}
private parseStore(raw: string): UserBehaviorStore {
if (raw.length === 0) {
return createEmptyBehaviorStore();
}
try {
const value: UserBehaviorStore = JSON.parse(raw) as UserBehaviorStore;
return this.normalizeStore(value);
} catch (_) {
return createEmptyBehaviorStore();
}
}
}
这里有两个点我会特别注意:
init里统一做读取,页面不要自己拼 key。parseStore里兜底空数据和异常 JSON,避免旧版本数据坏掉以后页面直接白屏。
这个封装看起来普通,但后面很省事。比如想把behavior_state_v1升到behavior_state_v2,只需要改仓储里的迁移逻辑,不需要到处改页面。
旧数据迁移要留位置
本地数据最怕"第一版能跑,第二版不能升级"。用户行为结构后面大概率会扩字段,比如增加 sourcePage、sceneTag、lastAction。如果一开始没有版本字段,后面迁移会很难判断。
所以 store 里最好放一个很轻的版本号:
ts
interface UserBehaviorStore {
version: number;
dishStats: DishBehaviorStats[];
searchStats: SearchBehaviorStats[];
draftStats: DraftBehaviorStats;
}
function createEmptyBehaviorStore(): UserBehaviorStore {
return {
version: 1,
dishStats: [],
searchStats: [],
draftStats: {
draftSaveCount: 0,
dishSaveCount: 0,
lastDraftSavedAt: 0,
lastDishSavedAt: 0
}
};
}
后面版本升级时,可以按版本号做兼容:
ts
private normalizeStore(value: UserBehaviorStore): UserBehaviorStore {
if (!value || value.version !== 1) {
return createEmptyBehaviorStore();
}
return {
version: 1,
dishStats: Array.isArray(value.dishStats) ? this.trimDishStats(value.dishStats) : [],
searchStats: Array.isArray(value.searchStats) ? this.trimSearchStats(value.searchStats) : [],
draftStats: value.draftStats || createEmptyBehaviorStore().draftStats
};
}
这段代码不是为了复杂,而是为了让应用后续能平滑升级。内容型应用的数据会慢慢变多,越早把迁移入口留好,后面越不容易被历史数据拖住。
UserInsightService:给页面一个快照
仓储负责记录,页面不应该到处自己拼分析逻辑。所以这里加了一个 UserInsightService,专门生成快照:
ts
export interface UserInsightSnapshot {
topViewed: DishBehaviorStats[];
topSearches: SearchBehaviorStats[];
topMissingSearches: SearchBehaviorStats[];
draftStats: DraftBehaviorStats;
generatedAt: number;
}
它做的事很简单:
ts
snapshot(limit: number = 5): UserInsightSnapshot {
const size: number = limit > 0 ? limit : 5;
return {
topViewed: this.behaviorRepo.topViewed(size),
topSearches: this.behaviorRepo.topSearches(size),
topMissingSearches: this.behaviorRepo.topMissingSearches(size),
draftStats: this.behaviorRepo.getDraftStats(),
generatedAt: Date.now()
};
}
这层服务的好处很直接:我的厨房、运营面板、内容补齐页,都可以直接拿快照。页面不用再自己排序、过滤、拼字段。
ContentPlanningService:把缺口变成候选清单
行为数据最有用的地方,是它能真的进入内容规划。
中式美食里有一个 ContentPlanningService,它会把两类信号合并:
- 用户主动提交的缺失菜谱愿望。
- 搜索无结果产生的缺口关键词。
合并后的候选项长这样:
ts
export interface ContentPlanningCandidate {
keyword: string;
score: number;
wishCount: number;
missingSearchCount: number;
source: ContentPlanningCandidateSource;
updatedAt: number;
}
排序规则也很直白:
ts
private score(wishCount: number, missingSearchCount: number): number {
return wishCount * 3 + missingSearchCount * 2;
}
为什么愿望乘 3,搜索缺口乘 2?
因为愿望更明确。用户已经直接告诉你"我想要这个菜"。搜索缺口也重要,但它可能只是临时查一下。所以愿望权重略高,搜索缺口作为补充信号。
这个规则不追求数学漂亮,先让第一版内容补齐能跑起来。后面数据多了,再调权重也不难。
候选合并时要注意去重
同一个关键词可能既出现在愿望里,也出现在无结果搜索里。
所以 ContentPlanningService 里用了 upsert,而不是简单拼数组:
ts
private upsert(items: ContentPlanningCandidate[], keyword: string, wishCount: number, missingSearchCount: number,
source: ContentPlanningCandidateSource, updatedAt: number): void {
const value: string = keyword.trim();
if (value.length === 0) {
return;
}
const index: number = items.findIndex((it: ContentPlanningCandidate): boolean => it.keyword === value);
if (index >= 0) {
const current: ContentPlanningCandidate = items[index];
const nextWishCount: number = current.wishCount + wishCount;
const nextMissingSearchCount: number = current.missingSearchCount + missingSearchCount;
items[index] = {
keyword: current.keyword,
score: this.score(nextWishCount, nextMissingSearchCount),
wishCount: nextWishCount,
missingSearchCount: nextMissingSearchCount,
source: current.wishCount + wishCount > 0 ? 'wish' : source,
updatedAt: Math.max(current.updatedAt, updatedAt)
};
return;
}
}
这段代码解决的是一个很常见的问题:用户行为来源不同,但最后要汇总成同一个决策列表。
比如"宝宝辅食"既有人提交愿望,也有人搜索无结果,它就应该合并成一个更高优先级的候选,而不是在列表里出现两次,看起来热闹但不好决策。
写入行为数据时,不能只想着"全记下来"
用户行为数据很容易越做越重。只要页面上有按钮,就想顺手记一笔;只要列表发生变化,就想把变化也记下来。这样做短期看很完整,长期会让本地存储越来越脏。
中式美食这里把记录点收得比较克制,只记录真正能反推产品问题的动作:
| 动作 | 记录吗 | 原因 |
|---|---|---|
| 输入框文字变化 | 不记录 | 还不是明确意图,容易产生噪声 |
| 提交搜索 | 记录 | 用户已经确认要找这个内容 |
| 打开菜品详情 | 记录 | 可以反映入口吸引力 |
| 停留时长 | 暂不记录 | 第一版没有可靠页面生命周期,先不把模型做重 |
| 收藏、取消收藏 | 记录 | 可以反映"以后可能会用" |
| 写笔记 | 记录 | 比收藏更接近真实烹饪场景 |
这一步很关键。行为数据不是越细越好,而是越能回答问题越好。第一版先把"搜索缺口、浏览兴趣、收藏笔记、草稿保存"这几类打通,已经足够支撑内容补齐判断。
写入频率也要控制,避免本地存储被频繁打扰
Preferences 适合保存轻量状态,但不适合被当成高频日志系统。比如用户连续快速点开多个菜品,如果每次都立刻完整写入,就会让页面操作和存储写入绑得太紧。
比较稳的做法是把行为更新做成"内存先更新,落盘再收口":
ts
class UserBehaviorRepository {
private dirty: boolean = false;
private flushTimer: number = -1;
recordDishView(dishId: string, dishName: string): void {
this.patchDishStats(dishId, dishName);
this.markDirty();
}
private markDirty(): void {
this.dirty = true;
if (this.flushTimer >= 0) {
return;
}
this.flushTimer = setTimeout((): void => {
this.flushTimer = -1;
if (!this.dirty) {
return;
}
this.dirty = false;
this.persist();
}, 600);
}
}
这里不是为了追求"技术花活",而是为了让页面交互更稳。用户连续操作时,内存状态可以先跟上;真正写入本地时,做一次合并后的保存就够了。对内容型应用来说,这种小节流比一上来做复杂数据平台更实用。
隐私边界要提前定清楚
用户行为数据听起来很有价值,但边界也要讲清楚。中式美食这一版只做本地行为记录,不上传用户搜索词,不做跨设备追踪,也不记录手机号、账号、定位这类和菜谱补齐无关的信息。
我会把行为字段限制在这些范围:
ts
interface LocalBehaviorBoundary {
keyword: string;
dishId: string;
dishName: string;
count: number;
resultCount: number;
updatedAt: number;
}
它回答的是"用户在应用里需要什么内容",不是"用户是谁"。这个边界定住以后,后面做首页推荐、内容补齐、我的厨房快照时就更安心。尤其是本地优先的 HarmonyOS 应用,能在端侧完成的判断,就不要一开始就把数据往外送。
排序要稳定,不然页面会跳来跳去
还有一个容易忽略的问题:候选列表如果只按分数排序,分数相同的时候顺序可能不稳定。用户打开内容补齐页时,列表一会儿这个在前,一会儿那个在前,会显得页面不靠谱。
所以排序可以加一层稳定规则:
ts
sortCandidates(items: ContentPlanningCandidate[]): ContentPlanningCandidate[] {
return items.slice().sort((a: ContentPlanningCandidate, b: ContentPlanningCandidate): number => {
if (b.score !== a.score) {
return b.score - a.score;
}
if (b.updatedAt !== a.updatedAt) {
return b.updatedAt - a.updatedAt;
}
return a.keyword.localeCompare(b.keyword);
});
}
先比分数,再比更新时间,最后按关键词兜底。这样列表既能反映优先级,也不会因为数组原始顺序不同而出现随机抖动。
我会怎么验收这条链路
这类功能不能只看代码有没有写完,最好按真实操作走一遍:
| 验收动作 | 预期结果 |
|---|---|
| 搜索一个已有菜名 | count 增加,resultCount 大于 0,missingCount 不增加 |
| 搜索一个不存在的词 | count 增加,resultCount 为 0,missingCount 增加 |
| 连续打开同一道菜 | viewCount 增加,lastViewedAt 更新 |
| 收藏再取消收藏 | favoriteAddCount 和 favoriteRemoveCount 分别增加 |
| 保存一次草稿 | draftSaveCount 增加 |
| 保存成正式菜谱 | dishSaveCount 增加 |
| 打开内容候选快照 | 愿望和无结果搜索被合并,列表按分数稳定排序 |
走完这组验证,才能说明这套行为数据不是"看起来有设计",而是真的能支撑内容补齐。
这套设计能给页面带来什么
行为统计本身不是页面,但它会反过来影响页面怎么长。
| 页面或模块 | 可以使用的数据 | 能优化什么 |
|---|---|---|
| 搜索页 | topMissingSearches | 给无结果页推荐更接近的入口 |
| 首页 | topViewed / topSearches | 做更贴近用户兴趣的推荐 |
| 我的厨房 | draftStats / topViewed | 展示用户最近关注和创作状态 |
| 内容补齐页 | ContentPlanningCandidate | 决定下一批补什么菜 |
| 运营面板 | UserInsightSnapshot | 看清用户真实需求变化 |
这样一来,用户行为就不是冷冰冰地记在本地,而是能慢慢影响应用内容结构。
容易踩的坑
1. 把输入变化当成搜索行为
输入框每变一次都记录,看起来数据很多,其实噪声也很多。用户还没确认搜索时,不应该把它当成明确意图。
中式美食里只在 submitSearch 里记录行为,就是为了避免这个问题。
2. 只记录搜索词,不记录结果数
没有结果数,就不知道这个词是不是缺口。比如"红烧肉"搜了 10 次,结果很多;"低脂夜宵"搜了 5 次但没有结果,后者反而更应该进入补齐候选。
3. 统计无限增长
本地存储不是数据仓库。行为数据要有上限,要按更新时间裁剪。否则长期用下来,读取、排序和保存都会变重。
4. 分析逻辑散落在页面里
如果首页、我的厨房、搜索页都自己做排序和过滤,后面规则一变会很痛苦。用 UserInsightService 和 ContentPlanningService 收口,后面会轻松很多。
一次完整链路怎么跑
这套链路可以按五步理解:
| 步骤 | 动作 | 代码入口 |
|---|---|---|
| 1 | 用户提交搜索 | SearchViewModel.submitSearch() |
| 2 | 记录关键词和结果数 | UserBehaviorRepository.recordSearch() |
| 3 | 找出无结果高频词 | topMissingSearches() |
| 4 | 合并愿望和搜索缺口 | ContentPlanningService.snapshot() |
| 5 | 输出内容候选 | ContentPlanningCandidate[] |
这条链路跑通后,内容补齐就不再完全靠感觉了。
小结
中式美食这套用户行为数据,不是为了做复杂报表,而是为了让应用慢慢有一点判断力。
搜索无结果说明内容库有缺口,浏览高频说明用户感兴趣,收藏和笔记说明用户愿意留下来,草稿保存说明用户开始创作。把这些动作记录下来,再通过 UserInsightService 和 ContentPlanningService 汇总,就能反推出下一轮更值得补的内容。
对 HarmonyOS 应用来说,这种设计很适合本地优先的工具类、内容类、学习类产品。第一版不用复杂,只要把记录点放对,后面就能一步步从"能用"走向"越用越懂用户"。