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


做内容型应用时,我们很容易下意识觉得:内容越多越好。
放到美食应用里,就是菜谱越多越好,食材越全越好,分类越细越好。这个方向没错,但真做起来会遇到一个更现实的问题:先补什么?
用户搜了"低脂夜宵"没有结果,和用户随手打开一个菜谱看了两秒,这两个信号明显不一样。前者像是在告诉我们"这里缺内容",后者更像是"这个入口有点吸引我"。如果都不记录,后面补内容就只能靠感觉。
中式美食这次加了一层很轻的用户行为记录。它不是要做什么复杂的数据平台,就是想把几个关键动作留下来:搜过什么、有没有结果、哪些菜被反复看、草稿有没有继续保存成正式菜谱。
这样下一次补内容时,就不是拍脑袋说"我觉得应该加这个",而是能回头看一眼:用户到底在哪里卡住了。

本文验证环境

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

旧数据迁移要留位置

本地数据最怕"第一版能跑,第二版不能升级"。用户行为结构后面大概率会扩字段,比如增加 sourcePagesceneTaglastAction。如果一开始没有版本字段,后面迁移会很难判断。
所以 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 更新
收藏再取消收藏 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 应用来说,这种设计很适合本地优先的工具类、内容类、学习类产品。第一版不用复杂,只要把记录点放对,后面就能一步步从"能用"走向"越用越懂用户"。

相关推荐
智塑未来1 小时前
鸿蒙系统对比安卓、iOS,核心优势是什么?
android·ios·harmonyos
心中有国也有家2 小时前
AtomGit Flutter 鸿蒙客户端:呼吸球动画
学习·flutter·华为·harmonyos
ZZZMMM.zip2 小时前
信息压缩站-长文智能摘要的HarmonyOS开发实践
人工智能·华为·harmonyos·鸿蒙·鸿蒙系统
绝世番茄3 小时前
VisualEffect 视觉特效鸿蒙HarmonyOS ArkTS原生学习
学习·华为·harmonyos·鸿蒙
爱吃大芒果3 小时前
领域驱动视角下的情绪追踪底座:打造高内聚、零耦合的鸿蒙应用模型
华为·harmonyos
在书中成长3 小时前
HarmonyOS 小游戏《对战五子棋》开发第25篇 - 最后一手标记
harmonyos
解局易否结局4 小时前
鸿蒙PC应用开发:窗口管理 + 鼠标键盘交互 + 文件对话框
笔记·华为·计算机外设·交互·harmonyos
心中有国也有家4 小时前
AtomGit Flutter 鸿蒙客户端:呼吸练习的三种科学模式
学习·flutter·华为·harmonyos
FrameNotWork5 小时前
HarmonyOS 6.1 Tabs组件深度定制 — 底部导航栏从入门到踩坑
华为·交互·harmonyos