HarmonyOS ArkTS 实战|中式美食用户行为数据:Preferences 持久化、搜索缺口与候选排序
做内容型应用时,我们很容易下意识觉得:内容越多越好。
放到美食应用里,就是菜谱越多越好,食材越全越好,分类越细越好。这个方向没错,但真做起来会遇到一个更现实的问题:先补什么?
用户搜了“低脂夜宵”没有结果,和用户随手打开一个菜谱看了两秒,这两个信号明显不一样。前者像是在告诉我们“这里缺内容”,后者更像是“这个入口有点吸引我”。如果都不记录,后面补内容就只能靠感觉。
中式美食这次加了一层很轻的用户行为记录。它不是要做什么复杂的数据平台,就是想把几个关键动作留下来:搜过什么、有没有结果、哪些菜被反复看、草稿有没有继续保存成正式菜谱。
这样下一次补内容时,就不是拍脑袋说“我觉得应该加这个”,而是能回头看一眼:用户到底在哪里卡住了。
本文验证环境
| 项目 | 版本或范围 |
|---|---|
| 开发工具 | 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里把行为拆成三类:菜品行为、搜索行为、草稿行为。
exportinterfaceDishBehaviorStats{dishId:string;dishName:string;viewCount:number;favoriteAddCount:number;favoriteRemoveCount:number;noteCount:number;lastViewedAt:number;lastFavoritedAt:number;updatedAt:number;}exportinterfaceSearchBehaviorStats{keyword:string;count:number;resultCount:number;missingCount:number;lastSearchedAt:number;updatedAt:number;}exportinterfaceDraftBehaviorStats{draftSaveCount:number;dishSaveCount:number;lastDraftSavedAt:number;lastDishSavedAt:number;}这三个结构对应的是三种很实际的问题:
| 行为类型 | 记录什么 | 能回答什么问题 |
|---|---|---|
| 菜品行为 | 浏览、收藏、取消收藏、笔记次数 | 哪些菜真的被用户反复看 |
| 搜索行为 | 关键词、搜索次数、结果数、缺口次数 | 用户想找但我们没给到什么 |
| 草稿行为 | 草稿保存、正式菜谱保存 | 用户有没有把创作流程走完 |
普通历史记录是让用户回到刚才做过的事;行为统计是让我们知道下一步该优化什么。两者用途不一样,最好不要混在一起。
搜索缺口是最值得记录的信号
在中式美食里,搜索入口很值钱。用户愿意敲关键词,说明他已经有明确目标了。
SearchViewModel里提交搜索时,会把关键词、搜索结果数记录下来:
submitSearch():void{constv: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里会根据结果数判断是不是缺口:
recordSearch(keyword:string,resultCount:number):void{constvalue:string=keyword.trim();if(value.length===0){return;}constnow:number=Date.now();constnext:SearchBehaviorStats[]=this.store.searchStats.slice();constindex:number=next.findIndex((it:SearchBehaviorStats):boolean=>it.keyword===value);if(index>=0){constcurrent: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很有用。用户搜“宝宝辅食”没有结果,偶尔一次还不好判断;如果这个词反复无结果,就基本能说明内容库缺这一类东西。
浏览和收藏不是同一种信号
一个菜被打开很多次,说明它吸引人;一个菜被收藏很多次,说明用户觉得以后可能会用;一个菜被写了笔记,说明用户大概率真的进入了做饭场景。
所以中式美食没有把这些动作粗暴合成一个“热度”,而是分开记:
recordDishView(dishId:string,dishName:string):voidrecordFavorite(dishId:string,dishName:string,active:boolean):voidrecordDishNote(dishId:string,dishName:string):void分开记,后面看问题会清楚很多:
- 浏览高但收藏低:可能标题和图片吸引人,但详情不够有用。
- 收藏高但笔记低:可能用户想做,但步骤或食材门槛偏高。
- 笔记高:说明这个菜已经进入用户真实做饭场景。
这比只看点击量靠谱。点击量只能说明“看过”,但收藏和笔记更接近“真的想用”。
为什么要限制统计数组长度
行为数据存在本地,不适合无限长。项目里设置了两个上限:
constMAX_DISH_STATS:number=500;constMAX_SEARCH_STATS:number=200;每次写入后会裁剪:
privatetrimDishStats(items:DishBehaviorStats[]):DishBehaviorStats[]{returnitems.sort((a:DishBehaviorStats,b:DishBehaviorStats):number=>b.updatedAt-a.updatedAt).slice(0,MAX_DISH_STATS);}privatetrimSearchStats(items:SearchBehaviorStats[]):SearchBehaviorStats[]{returnitems.sort((a:SearchBehaviorStats,b:SearchBehaviorStats):number=>b.updatedAt-a.updatedAt).slice(0,MAX_SEARCH_STATS);}这个设计比较务实。中式美食不是服务端分析平台,本地只需要留住最近一段时间最有参考价值的数据。数据太多,读写会变重,分析结果也会变散。
Preferences 落盘要封一层,不要让页面直接碰存储
这一类数据最后还是要落到本地。中式美食里我不建议页面直接调用preferences.getPreferences,而是让UserBehaviorRepository自己管理读取、写入和默认值。
页面只管说“发生了什么动作”,仓储负责决定“怎么存、存多少、什么时候刷盘”。
constBEHAVIOR_STORE_NAME:string='user_behavior_store';constBEHAVIOR_STORE_KEY:string='behavior_state_v1';exportclassUserBehaviorRepository{privatepreferences?:preferences.Preferences;privatestore:UserBehaviorStore=createEmptyBehaviorStore();asyncinit(context:Context):Promise<void>{this.preferences=awaitpreferences.getPreferences(context,BEHAVIOR_STORE_NAME);constraw:string=awaitthis.preferences.get(BEHAVIOR_STORE_KEY,'')asstring;this.store=this.parseStore(raw);}privateasyncpersist():Promise<void>{if(!this.preferences){return;}consttext:string=JSON.stringify(this.store);awaitthis.preferences.put(BEHAVIOR_STORE_KEY,text);awaitthis.preferences.flush();}privateparseStore(raw:string):UserBehaviorStore{if(raw.length===0){returncreateEmptyBehaviorStore();}try{constvalue:UserBehaviorStore=JSON.parse(raw)asUserBehaviorStore;returnthis.normalizeStore(value);}catch(_){returncreateEmptyBehaviorStore();}}}这里有两个点我会特别注意:
init里统一做读取,页面不要自己拼 key。parseStore里兜底空数据和异常 JSON,避免旧版本数据坏掉以后页面直接白屏。
这个封装看起来普通,但后面很省事。比如想把behavior_state_v1升到behavior_state_v2,只需要改仓储里的迁移逻辑,不需要到处改页面。
旧数据迁移要留位置
本地数据最怕“第一版能跑,第二版不能升级”。用户行为结构后面大概率会扩字段,比如增加sourcePage、sceneTag、lastAction。如果一开始没有版本字段,后面迁移会很难判断。
所以 store 里最好放一个很轻的版本号:
interfaceUserBehaviorStore{version:number;dishStats:DishBehaviorStats[];searchStats:SearchBehaviorStats[];draftStats:DraftBehaviorStats;}functioncreateEmptyBehaviorStore():UserBehaviorStore{return{version:1,dishStats:[],searchStats:[],draftStats:{draftSaveCount:0,dishSaveCount:0,lastDraftSavedAt:0,lastDishSavedAt:0}};}后面版本升级时,可以按版本号做兼容:
privatenormalizeStore(value:UserBehaviorStore):UserBehaviorStore{if(!value||value.version!==1){returncreateEmptyBehaviorStore();}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,专门生成快照:
exportinterfaceUserInsightSnapshot{topViewed:DishBehaviorStats[];topSearches:SearchBehaviorStats[];topMissingSearches:SearchBehaviorStats[];draftStats:DraftBehaviorStats;generatedAt:number;}它做的事很简单:
snapshot(limit:number=5):UserInsightSnapshot{constsize: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,它会把两类信号合并:
- 用户主动提交的缺失菜谱愿望。
- 搜索无结果产生的缺口关键词。
合并后的候选项长这样:
exportinterfaceContentPlanningCandidate{keyword:string;score:number;wishCount:number;missingSearchCount:number;source:ContentPlanningCandidateSource;updatedAt:number;}排序规则也很直白:
privatescore(wishCount:number,missingSearchCount:number):number{returnwishCount*3+missingSearchCount*2;}为什么愿望乘 3,搜索缺口乘 2?
因为愿望更明确。用户已经直接告诉你“我想要这个菜”。搜索缺口也重要,但它可能只是临时查一下。所以愿望权重略高,搜索缺口作为补充信号。
这个规则不追求数学漂亮,先让第一版内容补齐能跑起来。后面数据多了,再调权重也不难。
候选合并时要注意去重
同一个关键词可能既出现在愿望里,也出现在无结果搜索里。
所以ContentPlanningService里用了upsert,而不是简单拼数组:
privateupsert(items:ContentPlanningCandidate[],keyword:string,wishCount:number,missingSearchCount:number,source:ContentPlanningCandidateSource,updatedAt:number):void{constvalue:string=keyword.trim();if(value.length===0){return;}constindex:number=items.findIndex((it:ContentPlanningCandidate):boolean=>it.keyword===value);if(index>=0){constcurrent:ContentPlanningCandidate=items[index];constnextWishCount:number=current.wishCount+wishCount;constnextMissingSearchCount: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适合保存轻量状态,但不适合被当成高频日志系统。比如用户连续快速点开多个菜品,如果每次都立刻完整写入,就会让页面操作和存储写入绑得太紧。
比较稳的做法是把行为更新做成“内存先更新,落盘再收口”:
classUserBehaviorRepository{privatedirty:boolean=false;privateflushTimer:number=-1;recordDishView(dishId:string,dishName:string):void{this.patchDishStats(dishId,dishName);this.markDirty();}privatemarkDirty():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);}}这里不是为了追求“技术花活”,而是为了让页面交互更稳。用户连续操作时,内存状态可以先跟上;真正写入本地时,做一次合并后的保存就够了。对内容型应用来说,这种小节流比一上来做复杂数据平台更实用。
隐私边界要提前定清楚
用户行为数据听起来很有价值,但边界也要讲清楚。中式美食这一版只做本地行为记录,不上传用户搜索词,不做跨设备追踪,也不记录手机号、账号、定位这类和菜谱补齐无关的信息。
我会把行为字段限制在这些范围:
interfaceLocalBehaviorBoundary{keyword:string;dishId:string;dishName:string;count:number;resultCount:number;updatedAt:number;}它回答的是“用户在应用里需要什么内容”,不是“用户是谁”。这个边界定住以后,后面做首页推荐、内容补齐、我的厨房快照时就更安心。尤其是本地优先的 HarmonyOS 应用,能在端侧完成的判断,就不要一开始就把数据往外送。
排序要稳定,不然页面会跳来跳去
还有一个容易忽略的问题:候选列表如果只按分数排序,分数相同的时候顺序可能不稳定。用户打开内容补齐页时,列表一会儿这个在前,一会儿那个在前,会显得页面不靠谱。
所以排序可以加一层稳定规则:
sortCandidates(items:ContentPlanningCandidate[]):ContentPlanningCandidate[]{returnitems.slice().sort((a:ContentPlanningCandidate,b:ContentPlanningCandidate):number=>{if(b.score!==a.score){returnb.score-a.score;}if(b.updatedAt!==a.updatedAt){returnb.updatedAt-a.updatedAt;}returna.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 应用来说,这种设计很适合本地优先的工具类、内容类、学习类产品。第一版不用复杂,只要把记录点放对,后面就能一步步从“能用”走向“越用越懂用户”。