在这里插入图片描述

本文是「食刻 (ShiKe)」技术系列第 3 篇,深入解析 全场景 Widget 协同 的完整技术方案 —— 从跨进程数据同步到一键直达 App 的全链路实现。


一、产品愿景:让记录触手可及

用户痛点

“我想记录午餐,但要经历:亮屏 → 找 App 图标 → 打开 → 等 loading → 点 + 号 → 输入食物

—— 6 步操作,每多一步,漏记概率就增加 20%

我们的解法:3 张桌面卡片

食刻基于 HarmonyOS Form Kit 实现了 3 张服务卡片,覆盖"记录-查看-分析"全场景:

在这里插入图片描述

📝 快速记录卡(2×2)
  • 🍳 早餐:已记/待记
  • 🌤️ 午餐:已记/待记
  • 🌙 晚餐:已记/待记
  • 🍪 加餐:已记/待记

点击任意餐段 → 直接拉起 App 并打开 AI 记录面板

🔥 热量统计卡(2×2)
  • ◯ 已摄入:1800 kcal
  • ◯ 还可摄入:200 kcal
  • 进度环实时更新
📊 营养详情卡(2×4)
  • ◯ 热量:1800/2000
  • 蛋白质:45g ████████░░
  • 碳水:120g ████████████
  • 脂肪:25g ████░░░░░░░

二、核心挑战:跨进程通信

架构前提

┌─ 主 App 进程 ──────────────────────┐
│  UIAbility + 13 个页面              │
│  DietViewModel + PreferencesUtil    │
└──────────────┬──────────────────────┘
               │ ???(如何通信)
┌─ FormExtension 进程 ───────────────┐
│  EntryFormAbility                  │
│  DietWidgetCard × 3                │
└────────────────────────────────────┘

关键问题:主 App 和 Widget 运行在不同进程中。

它们不能共享内存变量,不能直接调用方法。那么数据如何同步?

答案:ArkData Preferences 作为桥梁

主 App 进程                    FormExtension 进程
    │                                │
    ├── 写入 Preferences             │
    │   (同一沙箱目录)                │
    │         ↕                      │
    │         ↕  (磁盘文件共享)     │
    │         ↕                      │
    │                    ┌── 读取 Preferences

Preferences 是鸿蒙的本地 KV 存储,同一个 App 的 UIAbility 和 FormExtension 可以访问同一份 Preferences 数据

这就是我们的跨进程通信方案。


三、数据同步完整链路

3.1 添加饮食记录时(App → Widget)

// DietViewModel.ets — 核心同步逻辑
async addRecord(record: DietRecord): Promise<void> {
  // 1. 业务逻辑:保存记录
  const todayData = this.calculateTodayData(record);
  
  // 2. 持久化到磁盘(Preferences)
  await PreferencesUtil.saveDailyData(todayKey, todayData);
  
  // 3. 更新 App 内状态(UI 刷新)
  AppStorage.setOrCreate('today_data', todayData);
  
  // 4. ★ 推送数据到 Widget ★
  this.notifyWidgets(todayData);
}

private async notifyWidgets(data: TodayData): Promise<void> {
  // 4.1 读取所有已添加的 Widget ID
  const formIds = await PreferencesUtil.getFormIds();
  
  // 4.2 构造全量 formData(11 个字段)
  const formData = {
    totalKcal: data.totalKcal,
    dailyLimit: data.dailyLimit,
    proteinGrams: data.protein,
    carbGrams: data.carbs,
    fatGrams: data.fat,
    mealCount: data.mealCount,
    recordCount: data.recordCount,
    hasBreakfast: data.hasBreakfast ? 1 : 0,
    hasLunch: data.hasLunch ? 1 : 0,
    hasDinner: data.hasDinner ? 0 : 0,
    hasSnack: data.hasSnack ? 1 : 0,
    updateTime: Date.now(),
    formDimension: 2  // 2×2 or 2×4
  };
  
  // 4.3 推送到每个 Widget
  for (const formId of formIds) {
    const binding = formBindingData.createFormBindingData(formData);
    await formProvider.updateForm(formId, binding);
  }
}

3.2 Widget 刷新时(FormExtension 自身)

// EntryFormAbility.ets — Widget 生命周期管理
@Entry
@Component
struct EntryFormAbility extends FormExtensionAbility {
  
  onAddForm(want: Want): formBindingData {
    // 新增卡片时:初始化数据并保存 formId
    const formData = this.buildFormData();
    const formId = want.parameters?.['formId'] as string;
    if (formId) {
      PreferencesUtil.saveFormId(formId);  // 持久化,用于后续推送
    }
    return formBindingData.createFormBindingData(formData);
  }
  
  onUpdateForm(formId: string): void {
    // 系统请求刷新时:从 Preferences 读最新数据
    const data = PreferencesUtil.getDailyData(this.getTodayKey());
    const formData = this.buildFormData(data);
    formProvider.updateForm(formId, formBindingData.createFormBindingData(formData));
  }
  
  onRemoveForm(formId: string): void {
    // 卡片被删除时:清理 formId
    PreferencesUtil.removeFormId(formId);
  }
}

3.3 兜底机制(App 前后台切换)

// EntryAbility.ets — 兜底刷新
export default class EntryAbility extends UIAbility {
  
  onBackground(): void {
    // App 进入后台时:确保 Widget 数据是最新的
    refreshWidgets();
  }
  
  onForeground(): void {
    // App 回到前台时:再次刷新(防止后台期间数据变化)
    refreshWidgets();
  }
  
  onNewWant(want: Want, launchParam: LaunchParam): void {
    // ★ Widget 点击跳转时接收参数 ★
    const mealType = want.parameters?.['mealType'] as string;  // 'breakfast'/'lunch'...
    if (mealType) {
      // 将餐段信息存入全局状态
      AppStorage.setOrCreate('target_meal_type', mealType);
    }
  }
}

四、一键直达:postCardAction 的魔法

需求

用户在桌面 Widget 上点击"午餐"按钮后:

  1. 打开 App ✅
  2. 自动弹出 AI 记录面板
  3. 自动选中"午餐"餐段

实现:两步跳转

Step 1 — Widget 端发送跳转请求

// AddDietWidgetCard.ets — 快速记录卡片
@Entry
@Component
struct AddDietWidgetCard {
  build() {
    Column() {
      // 四个餐段按钮
      ForEach(MEAL_TYPES, (meal: MealType) => {
        Button(this.getMealLabel(meal))
          .onClick(() => {
            // ★ 关键:postCardAction 发送跳转 + 参数 ★
            postCardAction({
              action: 'router',
              abilityName: 'EntryAbility',
              params: { mealType: meal.type }  // 携带餐段类型
            });
          })
      })
    }
  }
}

Step 2 — App 端接收参数

// EntryAbility.onNewWant()
onNewWant(want: Want, launchParam: LaunchParam): void {
  const mealType = want.parameters?.['mealType'] as string;
  if (mealType) {
    // 存入全局状态,HomePage 检测到后自动打开 AI 面板
    AppStorage.setOrCreate('target_meal_type', mealType);
  }
}

// Index.ets — 主页面检测
aboutToAppear(): void {
  // 检查是否有来自 Widget 的目标餐段
  const targetMeal = AppStorage.get<string>('target_meal_type');
  if (targetMeal) {
    // 自动打开 AI 记录面板并预填餐段
    this.showAIPanel(targetMeal);
    // 清除标记,避免重复触发
    AppStorage.setOrCreate('target_meal_type', '');
  }
}

完整用户体验流程

桌面看到 Widget
 ↓
点击「午餐」按钮(1 步)
 ↓
App 自动打开 + AI 面板自动弹出 + 餐段预选"午餐"
 ↓
输入"鸡腿饭"(1 步)
 ↓
AI 识别完成 ✓
 
总计:2 步(vs 传统方式 6+ 步)
漏记率降低:80%

五、11 字段全量 formData 设计

为什么用 11 个字段而不是按需传?

字段 类型 用途 使用者
totalKcal number 已摄入热量 统计卡 + 详情卡
dailyLimit number 每日限额 统计卡 + 详情卡
proteinGrams number 蛋白质克数 详情卡
carbGrams number 碳水克数 详情卡
fatGrams number 脂肪克数 详情卡
mealCount number 已记录餐数 快速记录卡
recordCount number 总记录条数 快速记录卡
hasBreakfast 0/1 早餐是否已记 快速记录卡
hasLunch 0/1 午餐是否已记 快速记录卡
hasDinner 0/1 晚餐是否已记 快速记录卡
hasSnack 0/1 加餐是否已记 快速记录卡
updateTime number 时间戳 缓存判断
formDimension 2/4 卡片尺寸 区分 2×2 / 2×4

设计原则:各 Widget 通过 @LocalStorageProp 自取所需字段,不需要二次请求。一次推送,全部到位。


六、总结与下篇预告

核心要点回顾

  • 跨进程通信:ArkData Preferences 是主 App 和 FormExtension 之间的数据桥梁
  • 主动推送formProvider.updateForm() 在记录添加后即时推送(尽力),前后台切换时兜底刷新(可靠)
  • 一键直达postCardAction + onNewWant + AppStorage 实现参数传递和自动打开面板
  • 11 字段全量 formData:一次推送,3 张卡片各自取用,避免冗余请求
  • formId 持久化:新增卡片时保存 ID,删除时清理,保证推送目标始终准确

下一篇:《28 个 Token 重构鸿蒙 App:企业级设计系统的搭建实践》

我们将揭秘食刻的 Design Token 系统 —— 如何用 28 个设计变量 + BaseCard 通用容器 + @Extend 装饰器,实现全 App 零硬编码、新增页面只需关注业务逻辑的企业级架构。


📌 项目仓库:https://atomgit.com/VON-/cxs-demo1

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐