做内容型应用时我们很容易下意识觉得内容越多越好。放到美食应用里就是菜谱越多越好食材越全越好分类越细越好。这个方向没错但真做起来会遇到一个更现实的问题先补什么用户搜了“低脂夜宵”没有结果和用户随手打开一个菜谱看了两秒这两个信号明显不一样。前者像是在告诉我们“这里缺内容”后者更像是“这个入口有点吸引我”。如果都不记录后面补内容就只能靠感觉。中式美食这次加了一层很轻的用户行为记录。它不是要做什么复杂的数据平台就是想把几个关键动作留下来搜过什么、有没有结果、哪些菜被反复看、草稿有没有继续保存成正式菜谱。这样下一次补内容时就不是拍脑袋说“我觉得应该加这个”而是能回头看一眼用户到底在哪里卡住了。本文验证环境项目版本或范围开发工具DevEco Studio 6.0.0 ReleaseHarmonyOS SDKAPI 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:stringthis.keyword.trim();if(v.length0)return;this.resultsthis.dishRepo.search(v);this.searchedtrue;this.historyRepo.add(v);this.behaviorRepo.recordSearch(v,this.results.length);this.historythis.historyRepo.getAll();}这里有个小细节很重要实时输入过滤不写行为数据只有用户真正回车或点击搜索按钮时才记录。这样能避开很多脏数据。用户输入“红烧肉”的过程中可能先输入“红”“红烧”“红烧肉”。如果每个变化都记一次后面统计出来的关键词会很乱。真正有价值的是用户确认搜索的那个词。recordSearch里会根据结果数判断是不是缺口recordSearch(keyword:string,resultCount:number):void{constvalue:stringkeyword.trim();if(value.length0){return;}constnow:numberDate.now();constnext:SearchBehaviorStats[]this.store.searchStats.slice();constindex:numbernext.findIndex((it:SearchBehaviorStats):booleanit.keywordvalue);if(index0){constcurrent:SearchBehaviorStatsnext[index];next[index]{keyword:current.keyword,count:current.count1,resultCount,missingCount:resultCount0?current.missingCount1: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:number500;constMAX_SEARCH_STATS:number200;每次写入后会裁剪privatetrimDishStats(items:DishBehaviorStats[]):DishBehaviorStats[]{returnitems.sort((a:DishBehaviorStats,b:DishBehaviorStats):numberb.updatedAt-a.updatedAt).slice(0,MAX_DISH_STATS);}privatetrimSearchStats(items:SearchBehaviorStats[]):SearchBehaviorStats[]{returnitems.sort((a:SearchBehaviorStats,b:SearchBehaviorStats):numberb.updatedAt-a.updatedAt).slice(0,MAX_SEARCH_STATS);}这个设计比较务实。中式美食不是服务端分析平台本地只需要留住最近一段时间最有参考价值的数据。数据太多读写会变重分析结果也会变散。Preferences 落盘要封一层不要让页面直接碰存储这一类数据最后还是要落到本地。中式美食里我不建议页面直接调用preferences.getPreferences而是让UserBehaviorRepository自己管理读取、写入和默认值。页面只管说“发生了什么动作”仓储负责决定“怎么存、存多少、什么时候刷盘”。constBEHAVIOR_STORE_NAME:stringuser_behavior_store;constBEHAVIOR_STORE_KEY:stringbehavior_state_v1;exportclassUserBehaviorRepository{privatepreferences?:preferences.Preferences;privatestore:UserBehaviorStorecreateEmptyBehaviorStore();asyncinit(context:Context):Promisevoid{this.preferencesawaitpreferences.getPreferences(context,BEHAVIOR_STORE_NAME);constraw:stringawaitthis.preferences.get(BEHAVIOR_STORE_KEY,)asstring;this.storethis.parseStore(raw);}privateasyncpersist():Promisevoid{if(!this.preferences){return;}consttext:stringJSON.stringify(this.store);awaitthis.preferences.put(BEHAVIOR_STORE_KEY,text);awaitthis.preferences.flush();}privateparseStore(raw:string):UserBehaviorStore{if(raw.length0){returncreateEmptyBehaviorStore();}try{constvalue:UserBehaviorStoreJSON.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:number5):UserInsightSnapshot{constsize:numberlimit0?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*3missingSearchCount*2;}为什么愿望乘 3搜索缺口乘 2因为愿望更明确。用户已经直接告诉你“我想要这个菜”。搜索缺口也重要但它可能只是临时查一下。所以愿望权重略高搜索缺口作为补充信号。这个规则不追求数学漂亮先让第一版内容补齐能跑起来。后面数据多了再调权重也不难。候选合并时要注意去重同一个关键词可能既出现在愿望里也出现在无结果搜索里。所以ContentPlanningService里用了upsert而不是简单拼数组privateupsert(items:ContentPlanningCandidate[],keyword:string,wishCount:number,missingSearchCount:number,source:ContentPlanningCandidateSource,updatedAt:number):void{constvalue:stringkeyword.trim();if(value.length0){return;}constindex:numberitems.findIndex((it:ContentPlanningCandidate):booleanit.keywordvalue);if(index0){constcurrent:ContentPlanningCandidateitems[index];constnextWishCount:numbercurrent.wishCountwishCount;constnextMissingSearchCount:numbercurrent.missingSearchCountmissingSearchCount;items[index]{keyword:current.keyword,score:this.score(nextWishCount,nextMissingSearchCount),wishCount:nextWishCount,missingSearchCount:nextMissingSearchCount,source:current.wishCountwishCount0?wish:source,updatedAt:Math.max(current.updatedAt,updatedAt)};return;}}这段代码解决的是一个很常见的问题用户行为来源不同但最后要汇总成同一个决策列表。比如“宝宝辅食”既有人提交愿望也有人搜索无结果它就应该合并成一个更高优先级的候选而不是在列表里出现两次看起来热闹但不好决策。写入行为数据时不能只想着“全记下来”用户行为数据很容易越做越重。只要页面上有按钮就想顺手记一笔只要列表发生变化就想把变化也记下来。这样做短期看很完整长期会让本地存储越来越脏。中式美食这里把记录点收得比较克制只记录真正能反推产品问题的动作动作记录吗原因输入框文字变化不记录还不是明确意图容易产生噪声提交搜索记录用户已经确认要找这个内容打开菜品详情记录可以反映入口吸引力停留时长暂不记录第一版没有可靠页面生命周期先不把模型做重收藏、取消收藏记录可以反映“以后可能会用”写笔记记录比收藏更接近真实烹饪场景这一步很关键。行为数据不是越细越好而是越能回答问题越好。第一版先把“搜索缺口、浏览兴趣、收藏笔记、草稿保存”这几类打通已经足够支撑内容补齐判断。写入频率也要控制避免本地存储被频繁打扰Preferences适合保存轻量状态但不适合被当成高频日志系统。比如用户连续快速点开多个菜品如果每次都立刻完整写入就会让页面操作和存储写入绑得太紧。比较稳的做法是把行为更新做成“内存先更新落盘再收口”classUserBehaviorRepository{privatedirty:booleanfalse;privateflushTimer:number-1;recordDishView(dishId:string,dishName:string):void{this.patchDishStats(dishId,dishName);this.markDirty();}privatemarkDirty():void{this.dirtytrue;if(this.flushTimer0){return;}this.flushTimersetTimeout(():void{this.flushTimer-1;if(!this.dirty){return;}this.dirtyfalse;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大于 0missingCount不增加搜索一个不存在的词count增加resultCount为 0missingCount增加连续打开同一道菜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 应用来说这种设计很适合本地优先的工具类、内容类、学习类产品。第一版不用复杂只要把记录点放对后面就能一步步从“能用”走向“越用越懂用户”。