当前位置: 首页 > news >正文

HarmonyOS ArkTS 实战|中式美食用户行为数据:Preferences 持久化、搜索缺口与候选排序

做内容型应用时,我们很容易下意识觉得:内容越多越好。

放到美食应用里,就是菜谱越多越好,食材越全越好,分类越细越好。这个方向没错,但真做起来会遇到一个更现实的问题:先补什么?

用户搜了“低脂夜宵”没有结果,和用户随手打开一个菜谱看了两秒,这两个信号明显不一样。前者像是在告诉我们“这里缺内容”,后者更像是“这个入口有点吸引我”。如果都不记录,后面补内容就只能靠感觉。

中式美食这次加了一层很轻的用户行为记录。它不是要做什么复杂的数据平台,就是想把几个关键动作留下来:搜过什么、有没有结果、哪些菜被反复看、草稿有没有继续保存成正式菜谱。

这样下一次补内容时,就不是拍脑袋说“我觉得应该加这个”,而是能回头看一眼:用户到底在哪里卡住了。

本文验证环境

项目版本或范围
开发工具DevEco Studio 6.0.0 Release
HarmonyOS SDKAPI 20
语言与框架ArkTS 6.0 / ArkUI V2
工程模型Stage 模型
复盘对象中式美食 HarmonyOS 应用
相关文件UserBehaviorRepository.etsUserInsightService.etsContentPlanningService.etsSearchViewModel.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,只需要改仓储里的迁移逻辑,不需要到处改页面。

旧数据迁移要留位置

本地数据最怕“第一版能跑,第二版不能升级”。用户行为结构后面大概率会扩字段,比如增加sourcePagesceneTaglastAction。如果一开始没有版本字段,后面迁移会很难判断。

所以 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更新
收藏再取消收藏favoriteAddCountfavoriteRemoveCount分别增加
保存一次草稿draftSaveCount增加
保存成正式菜谱dishSaveCount增加
打开内容候选快照愿望和无结果搜索被合并,列表按分数稳定排序

走完这组验证,才能说明这套行为数据不是“看起来有设计”,而是真的能支撑内容补齐。

这套设计能给页面带来什么

行为统计本身不是页面,但它会反过来影响页面怎么长。

页面或模块可以使用的数据能优化什么
搜索页topMissingSearches给无结果页推荐更接近的入口
首页topViewed / topSearches做更贴近用户兴趣的推荐
我的厨房draftStats / topViewed展示用户最近关注和创作状态
内容补齐页ContentPlanningCandidate决定下一批补什么菜
运营面板UserInsightSnapshot看清用户真实需求变化

这样一来,用户行为就不是冷冰冰地记在本地,而是能慢慢影响应用内容结构。

容易踩的坑

1. 把输入变化当成搜索行为

输入框每变一次都记录,看起来数据很多,其实噪声也很多。用户还没确认搜索时,不应该把它当成明确意图。

中式美食里只在submitSearch里记录行为,就是为了避免这个问题。

2. 只记录搜索词,不记录结果数

没有结果数,就不知道这个词是不是缺口。比如“红烧肉”搜了 10 次,结果很多;“低脂夜宵”搜了 5 次但没有结果,后者反而更应该进入补齐候选。

3. 统计无限增长

本地存储不是数据仓库。行为数据要有上限,要按更新时间裁剪。否则长期用下来,读取、排序和保存都会变重。

4. 分析逻辑散落在页面里

如果首页、我的厨房、搜索页都自己做排序和过滤,后面规则一变会很痛苦。用UserInsightServiceContentPlanningService收口,后面会轻松很多。

一次完整链路怎么跑

这套链路可以按五步理解:

步骤动作代码入口
1用户提交搜索SearchViewModel.submitSearch()
2记录关键词和结果数UserBehaviorRepository.recordSearch()
3找出无结果高频词topMissingSearches()
4合并愿望和搜索缺口ContentPlanningService.snapshot()
5输出内容候选ContentPlanningCandidate[]

这条链路跑通后,内容补齐就不再完全靠感觉了。

小结

中式美食这套用户行为数据,不是为了做复杂报表,而是为了让应用慢慢有一点判断力。

搜索无结果说明内容库有缺口,浏览高频说明用户感兴趣,收藏和笔记说明用户愿意留下来,草稿保存说明用户开始创作。把这些动作记录下来,再通过UserInsightServiceContentPlanningService汇总,就能反推出下一轮更值得补的内容。

对 HarmonyOS 应用来说,这种设计很适合本地优先的工具类、内容类、学习类产品。第一版不用复杂,只要把记录点放对,后面就能一步步从“能用”走向“越用越懂用户”。

http://www.jsqmd.com/news/1134961/

相关文章:

  • 类与对象:蓝图和房子的关系
  • Spark 流式处理:从入门到实战
  • EMA 指数移动平均 PyTorch 实现:3 种主流框架集成方案与性能开销实测
  • 2026年最新八字排盘应用推荐:天乙八字排盘、命枢、问真八字等怎么选?
  • 万能密码漏洞:认证逻辑缺陷的深度剖析与防御实践
  • 【JavaWeb】三大组件之——Servlet
  • pandas删列原理与性能优化:BlockManager、内存拷贝与CoW机制解析
  • 5分钟快速上手TEdit:泰拉瑞亚地图编辑器的终极指南
  • 深入 Linux 线程机制(三):线程互斥——竞争条件与互斥锁的本质
  • RAG 多路召回架构:向量 + 关键词 + 知识图谱的融合实战
  • 影刀RPA学习路线图:从小白到独立开发的完整学习路径
  • 《在飞机上偶遇前任后》穗雪|小说|txt下载|番外|免费阅读
  • PyTorch Dataset与DataLoader配置优化:4个关键参数对训练速度的影响实测
  • Python爬虫实战:多线程与异步IO并发爬虫——从10倍到100倍提速
  • MediaCrawler-new:多平台社交媒体数据采集技术框架与自动化解决方案
  • ICM-42688-P与PIC24HJ256GP610在运动控制中的高效协同
  • 城市违章搭建识别、棚户区语义分割与YOLO格式数据集:基于深度学习的城市治理AI实践全解析10693期
  • 《从 300 行到 30 万行代码,我学到的 5 个重构教训》
  • 5分钟找回QQ空间全部历史说说的终极指南:GetQzonehistory帮你永久保存青春记忆
  • 好用的区域教育一体化管理平台哪个供应商好
  • Google谷歌浏览器 Chrome浏览器安装包 官方正版离线安装包,支持中文界面!
  • 【Redis八股|第1篇】Redis 是什么?为什么它这么快?
  • 2026视频转文字工具选择指南:拆解免费付费坑点,看懂隐私、水印、收费套路避踩雷
  • AI生成图片文字模糊?AnyText技术原理与实战指南
  • 第48期 OpenAI的钱都花哪了?Capex支出全景调研
  • 一款AI笔记助手和远程同步的markdown笔记idea-note
  • 英雄联盟玩家必备:如何用LCU工具箱提升你的游戏效率3倍?
  • 质量管理必备的10个基本思维方式
  • 《夫人要和离》藤鹿山|小说|txt下载|番外|免费阅读
  • 38、<简单>编程输入10个正整数,然后自动按从大到小的顺序输出