不打开 App,在桌面扫一眼就知道今天的情绪状态——鸿蒙元服务如何让 E-Brufen 的"情绪体检"触手可及


目录

  1. 引言:元服务——鸿蒙生态的秘密武器
  2. 元服务的概念与类型全景
  3. 元服务 vs 完整应用:能力边界与适用场景
  4. 元服务的包体限制与技术约束
  5. 实战一:创建 E-Brufen "今日情绪"服务卡片
  6. 实战二:卡片点击跳转——从卡片到完整应用
  7. 实战三:卡片的定时刷新——随时间变化的情绪问候
  8. Service Widget 的完整开发流程
  9. FormExtensionAbility 的生命周期与数据更新
  10. 元服务分发:AppGallery 的原子化能力
  11. 元服务的适用场景分析
  12. 小结

1. 引言:元服务——鸿蒙生态的秘密武器

在这里插入图片描述

让我们做一个思想实验:

你开发了一个情绪健康应用 E-Brufen,核心功能是记录每天的心情。但多数用户每天打开它的次数不超过 3 次(早、中、晚各一次)。App 图标安静地躺在桌面的某个文件夹里,被微信、抖音、淘宝的图标淹没。用户可能好几周才打开一次。

但如果——用户在桌面就能直接看到今天的情绪状态呢?不需要打开应用,不需要加载 Flutter 引擎,不需要等待启动画面。只需从桌面瞄一眼卡片:一个 emoji 表情,一行文案"今天还没记录心情,来打个卡吧",一个箭头指向完整应用。

这就是**元服务(Atomic Service)**的核心价值:把应用的核心信息"原子化"到桌面,让用户以最低的认知负担完成最高频的操作。

传统应用 vs 元服务扩展
═══════════════════════════════════════════════════════

传统应用使用路径:
  看到桌面图标 → 点击 → 等待启动 → 找到功能 → 操作 → 退出
  (认知负担:6 步,耗时:3-5 秒)

元服务卡片使用路径:
  看到卡片信息 → (可选)点击进入详细页
  (认知负担:1-2 步,耗时:0-1 秒)

鸿蒙把这种"即看即用、零安装成本"的能力称为原子化服务——它是鸿蒙区别于 Android/iOS 的核心差异化能力之一。

对于 E-Brufen 这样的情绪健康应用,元服务尤其有价值:

场景 元服务的价值 用户受益
早晨看桌面 卡片显示"☀️ 早上好!今天的心情如何?" 起床第一眼就被提醒关注情绪
午休扫一眼 卡片显示"😐 今天还没记录" or “😊 今天心情不错” 快速了解自己的情绪状态
睡前回顾 卡片提示"🌙 睡前回顾一下今天" 养成每日情绪记录的习惯
情绪低落时 卡片显示"需要放松一下吗?→ 呼吸练习" 直接引导到解决方案

这些场景的共同特点是:高频、轻量、即时性。完全符合元服务的"DNA"。


2. 元服务的概念与类型全景

2.1 什么是元服务(Atomic Service)

元服务是鸿蒙提供的一种轻量级应用形态。它不需要用户安装,可以直接在桌面、服务中心、智慧搜索等入口使用。元服务基于 HAP(HarmonyOS Ability Package)构建,包体更小(通常 < 10MB)、启动更快、消耗资源更少。

从开发者视角看,元服务是一个特殊的 HAP 包,包含了一个或多个 ServiceAbilityFormExtensionAbility。它和完整应用的关系是:

┌─────────────────────────────────────────────────────────┐
│                    E-Brufen 完整应用                       │
│                                                         │
│  ┌─────────┐  ┌──────────┐  ┌─────────┐  ┌───────────┐ │
│  │ 白噪音   │  │ 呼吸练习  │  │ 情绪日记  │  │ 数据统计   │ │
│  │ (AVPlayer)│  │(Animation)│  │(Hive CRUD)│ │(Chart)   │ │
│  └─────────┘  └──────────┘  └─────────┘  └───────────┘ │
│                                                         │
│  ┌─────────────────────────────────────────────────┐   │
│  │             E-Brufen 元服务 (轻量子集)             │   │
│  │                                                  │   │
│  │  ┌────────────┐  ┌──────────────┐               │   │
│  │  │ 今日情绪卡片 │  │ 快捷呼吸入口  │               │   │
│  │  │ (2×2/4×4)  │  │ (直达服务)    │               │   │
│  │  └────────────┘  └──────────────┘               │   │
│  └─────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

2.2 元服务的两大类型

类型 描述 入口 交互方式
服务卡片 (Service Widget) 显示在桌面的微型 UI 组件 桌面、服务中心、负一屏 查看信息 + 点击跳转
直达服务 (Instant Service) 无需安装即可使用的轻量应用 智慧搜索、扫码、NFC、链接 完整的页面交互

服务卡片又按尺寸分为:

卡片尺寸 格数 典型用途 E-Brufen 适用场景
1×2 微型 显示一个关键指标(如步数) 不推荐——信息太少
2×2 小方形 显示 2-3 行信息 + 图标 今日情绪 emoji + 文案
2×4 矩形 显示列表或趋势 今日情绪 + 本周趋势微图表
4×4 大方块 复杂信息展示 情绪日历月视图 + 快捷操作
4×2 横条 横版信息流 情绪时间线 + 心情标签

2.3 Service Widget 的技术架构

┌──────────────────────────────────────────────┐
│                桌面 / 服务中心                  │
│                                              │
│   ┌──────────────────────────────────────┐  │
│   │          Service Widget 卡片           │  │
│   │   (由 ArkTS 声明式 UI 渲染)            │  │
│   └──────────────┬───────────────────────┘  │
│                  │                          │
│                  │ 系统调度                  │
│                  ▼                          │
│   ┌──────────────────────────────────────┐  │
│   │       FormExtensionAbility           │  │
│   │   (卡片的数据提供者)                   │  │
│   │                                      │  │
│   │   onCreate()  → 初始化                │  │
│   │   onUpdate()  → 定时刷新              │  │
│   │   onEvent()   → 处理点击事件          │  │
│   │   onDestroy() → 清理资源              │  │
│   └──────────────┬───────────────────────┘  │
│                  │                          │
│                  │ 读取本地数据              │
│                  ▼                          │
│   ┌──────────────────────────────────────┐  │
│   │           Hive CE 数据库               │  │
│   │   (E-Brufen 的情绪记录)                │  │
│   └──────────────────────────────────────┘  │
└──────────────────────────────────────────────┘

卡片由 ArkTS 声明式 UI 渲染(不是 Flutter),数据由 FormExtensionAbility 提供。这意味着你需要在项目中同时编写 ArkTS 代码(卡片 UI)和 Dart 代码(数据读取)。好在鸿蒙的 Hive CE 使用文件系统存储,卡片端可以直接读取同一路径的数据库文件。


3. 元服务 vs 完整应用:能力边界与适用场景

3.1 能力全景对比

维度 元服务 (Atomic Service) 完整应用 (Full App)
安装方式 无需安装,即点即用 需要从应用商店下载安装
包体大小 < 10MB(HAP/APS) 通常 20-200MB
启动速度 < 100ms(卡片已预加载) 1-3 秒(含 Flutter 引擎初始化)
UI 框架 ArkTS 声明式 UI 任意(Flutter/ArkTS/JS)
后台任务 不支持 支持(长时任务、WorkScheduler 等)
硬件访问 受限(部分传感器权限不可用) 完整硬件访问
通知能力 不支持独立发通知 支持通知管理
数据存储 受限(< 10MB 本地存储) 完整(Hive/SQLite/文件)
Flutter 引擎 不可用(卡片不运行 Flutter) 可用
桌面入口 固定到桌面 需要用户手动添加到桌面
分发渠道 AppGallery 元服务专区、扫码、链接 AppGallery 主站
用户留存 通过高频曝光建立习惯 依赖用户主动打开

3.2 什么功能适合元服务

用一个简单的决策树来判断:

需要持续后台运行?(如白噪音播放)
  └─ YES → 必须完整应用

需要复杂 UI 交互?(如呼吸球动画、多步骤表单)
  └─ YES → 完整应用更合适

需要访问敏感硬件?(如麦克风、摄像头)
  └─ YES → 完整应用(权限体系更完善)

核心价值是"看一眼就知道"?(如今天的心情、今日步数)
  └─ YES → ★ 完美适合元服务!

操作路径是"看一眼 → 点一下 → 完成"?(如快速记录心情)
  └─ YES → ★ 直达服务是最佳形态

对于 E-Brufen,以下功能适合元服务:

功能 形态 原因
今日情绪状态展示 2×2 卡片 一眼看到,零操作成本
情绪趋势微图表 4×4 卡片 本周/月情绪变化一目了然
快速记录心情 直达服务 3 步完成(选表情 → 写备注 → 保存)
呼吸练习快捷键 1×2 卡片点击 直接跳转完整应用
每日情绪提醒文案 2×4 卡片 时段相关的提示文字

以下功能不适合元服务:

功能 原因
白噪音播放 需要后台长时任务,元服务不支持
呼吸球动画 需要 Flutter 引擎 + AnimationController,卡片 UI 无法实现
情绪日记列表 数据量大、交互复杂、需要滚动/搜索/编辑
数据导出(CSV/JSON) 需要文件系统写入权限

4. 元服务的包体限制与技术约束

4.1 包体大小限制

项目 限制值 说明
单个 HAP 包 < 10MB 所有资源(代码 + 图片 + 字体)的总和
APS(Atomic Service Package) < 10MB 元服务的分发包格式
图标资源 建议 < 500KB 使用 SVG 或 WebP 压缩
数据库 < 10MB 元服务的本地存储上限
依赖库 对包体有贡献 减少不必要的三方库引用

对于 E-Brufen 的元服务,包体压力主要来自哪里?好消息是:卡片不使用 Flutter 引擎(那是一个 15MB+ 的庞然大物),所以包体非常宽松。ArkTS 编译产物 + 图标资源通常不超过 2MB。

4.2 API 使用限制

元服务可以使用的 API 是完整应用的一个子集。以下 API 在元服务中不可用受限

API 类别 限制原因
backgroundTaskManager 系列 元服务不能后台运行
notificationManager 系列 防止卡片滥用通知
camera / microphone 隐私考虑
bluetooth 需要完整应用权限体系
distributedData 部分 API 跨设备数据同步受限
call / sms 通信类 API 受限

但关键的本地数据读取 API 可用

  • relationalStore(RDB 数据库)
  • dataPreferences(键值对存储)
  • fileIO(文件读写)

这意味着元服务可以读取 E-Brufen 完整应用存储在 Hive 中的情绪数据(Hive 底层是文件存储 + 自定义序列化,元服务可以直接读取文件并解析 JSON)。

4.3 与完整应用的数据共享

元服务和完整应用共享同一 bundleName,也共享同一沙箱文件路径。这是"卡片能显示今天情绪"的技术基础:

// 在 FormExtensionAbility 中读取 Hive 数据
import fs from '@ohos.file.fs';
import { BusinessError } from '@ohos.base';

function readTodayMood(context: Context): MoodInfo | null {
  try {
    // Hive CE 的数据文件位于应用沙箱的 hive/ 目录
    const hiveDir = context.filesDir + '/hive/moods.hive';
    // 实际上 Hive 使用自定义的二进制格式,
    // 需要在 ArkTS 侧实现一个简单的解析器
    // 或者让完整应用 export 一份 JSON 缓存供元服务读取
    const jsonPath = context.filesDir + '/cards/mood_cache.json';
    const stat = fs.statSync(jsonPath);
    const file = fs.openSync(jsonPath, fs.OpenMode.READ_ONLY);
    const buffer = new ArrayBuffer(stat.size);
    fs.readSync(file.fd, buffer);
    fs.closeSync(file);

    const content = String.fromCharCode(...new Uint8Array(buffer));
    return JSON.parse(content) as MoodInfo;
  } catch (e) {
    const err = e as BusinessError;
    console.warn(`[E-Brufen Card] No mood cache: ${err.message}`);
    return null;
  }
}

最佳实践:不在元服务中直接读取 Hive 二进制文件(格式可能随版本变化),而是由完整应用在每次数据变更后,导出一个小型 JSON 缓存文件到共享目录,元服务只读这个缓存文件。这是一种"约定大于配置"的解耦方式。


5. 实战一:创建 E-Brufen "今日情绪"服务卡片

5.1 卡片 UI 设计

E-Brufen 的"今日情绪"卡片有四种状态,对应不同的 UI:

状态 触发条件 卡片内容
未记录 今天还没有任何情绪记录 大 emoji + “记录今天的心情吧 🎨”
已记录 今天有最新一条记录 情绪 emoji + 一句话描述 + 记录时间
多记录 今天有多条记录 最新情绪 emoji + “今天已记录 N 次” + 小图标时间线
夜间 当前时间在 22:00-06:00 “🌙 晚安,明天继续加油”

5.2 2×2 尺寸卡片实现

首先在 DevEco Studio 中创建卡片 UI(ArkTS):

// TodayMoodCard_2x2.ets
// 位于 entry/src/main/ets/widget/pages/

import { formInfo, formBindingData } from '@kit.FormKit';

// 卡片状态数据
interface CardData {
  hasRecord: boolean;
  moodEmoji: string;         // '😊' | '😢' | '😴' | '😐' | '😡'
  moodLabel: string;         // '开心' | '难过' | ...
  recordTime: string;        // '14:32'
  recordCount: number;       // 今天记录次数
  greeting: string;          // 根据时段变化的问候语
  isNightMode: boolean;      // 是否晚间模式
}

@Entry
@Component
struct TodayMoodCard_2x2 {
  // ★ 卡片的初始数据由 FormExtensionAbility 通过 formBindingData 传入
  @LocalStorageProp('cardData') cardData: CardData = {
    hasRecord: false,
    moodEmoji: '😐',
    moodLabel: '平静',
    recordTime: '',
    recordCount: 0,
    greeting: '记录今天的心情吧',
    isNightMode: false
  };

  build() {
    Stack() {
      // 背景渐变
      Column()
        .width('100%')
        .height('100%')
        .linearGradient({
          direction: GradientDirection.BottomRight,
          colors: this.getGradientColors()
        })
        .borderRadius(16);

      // 内容区
      Column() {
        // 顶部问候语
        Text(this.cardData.greeting)
          .fontSize(13)
          .fontColor('#ffffffcc')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .padding({ top: 12, left: 16, right: 16 })
          .width('100%');

        Blank();

        // 中间核心区:情绪 emoji
        if (this.cardData.hasRecord) {
          // 已记录状态
          Row() {
            Text(this.cardData.moodEmoji)
              .fontSize(36)
              .padding({ left: 16 });

            Column() {
              Text(this.cardData.moodLabel)
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor(Color.White);

              if (this.cardData.recordCount > 1) {
                Text(`今天已记录 ${this.cardData.recordCount}`)
                  .fontSize(11)
                  .fontColor('#ffffffaa');
              }

              Text(this.cardData.recordTime)
                .fontSize(10)
                .fontColor('#ffffff88')
                .margin({ top: 2 });
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 });
          }
          .width('100%');
        } else {
          // 未记录状态
          Column() {
            Text('🎨')
              .fontSize(32)
              .margin({ bottom: 8 });
            Text('记录今天的心情吧')
              .fontSize(14)
              .fontColor(Color.White)
              .fontWeight(FontWeight.Medium);
          }
          .width('100%');
        }

        Blank();

        // 底部操作提示
        Row() {
          Text(this.cardData.isNightMode ? '🌙 晚安' : '📝 点击记录')
            .fontSize(10)
            .fontColor('#ffffff88');
          Blank();
          Text('E-Brufen')
            .fontSize(9)
            .fontColor('#ffffff66');
        }
        .width('100%')
        .padding({ left: 16, right: 16, bottom: 12 });
      }
      .width('100%')
      .height('100%');
    }
    .width('100%')
    .height('100%')
    .borderRadius(16);
  }

  // 情绪 → 渐变色映射
  getGradientColors(): [[string, number], [string, number]] {
    if (this.cardData.isNightMode) {
      return [['#2c1b4d', 0.0], ['#16213e', 1.0]];
    }
    switch (this.cardData.moodEmoji) {
      case '😊':
        return [['#FFB74D', 0.0], ['#FFCC80', 1.0]]; // 开心-暖橙
      case '😢':
        return [['#64B5F6', 0.0], ['#90CAF9', 1.0]]; // 难过-蓝
      case '😡':
        return [['#EF5350', 0.0], ['#EF9A9A', 1.0]]; // 生气-红
      case '😴':
        return [['#9575CD', 0.0], ['#B39DDB', 1.0]]; // 疲惫-紫
      case '😐':
        return [['#66BB6A', 0.0], ['#81C784', 1.0]]; // 平静-绿
      default:
        return [['#B39DDB', 0.0], ['#CE93D8', 1.0]]; // 默认-淡紫
    }
  }
}

5.3 4×4 尺寸卡片实现(扩展版)

4×4 卡片可以展示更多信息,包括本周情绪趋势的迷你视图:

// TodayMoodCard_4x4.ets

interface CardData4x4 {
  // 继承 CardData 所有字段
  todayMood: CardData;
  // 本周趋势(最近 7 天的情绪值)
  weekTrend: number[];       // [5, 3, 4, 4, 5, 5, 4] ← MoodType.value
  weekLabels: string[];      // ['一', '二', '三', '四', '五', '六', '日']
}

@Entry
@Component
struct TodayMoodCard_4x4 {
  @LocalStorageProp('cardData4x4') data: CardData4x4 = {
    todayMood: {
      hasRecord: false,
      moodEmoji: '😐',
      moodLabel: '平静',
      recordTime: '',
      recordCount: 0,
      greeting: '记录今天的心情吧',
      isNightMode: false
    },
    weekTrend: [],
    weekLabels: []
  };

  build() {
    Column() {
      // 上半部分:今日情绪(复用 2×2 的核心布局)
      Stack() {
        Column()
          .width('100%')
          .height('45%')
          .linearGradient({
            direction: GradientDirection.BottomRight,
            colors: this.getWeekGradient()
          })
          .borderRadius({ topLeft: 16, topRight: 16 });

        // 今日心情摘要
        Column() {
          Row() {
            Text(this.data.todayMood.moodEmoji)
              .fontSize(44)
              .padding({ left: 20, top: 16 });
            Column() {
              Text(this.data.todayMood.todayMood.moodLabel)
                .fontSize(22)
                .fontWeight(FontWeight.Bold)
                .fontColor(Color.White);
              Text(this.data.todayMood.greeting)
                .fontSize(12)
                .fontColor('#ffffffcc');
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12, top: 20 });
          }
        }
      }
      .width('100%')
      .height('45%');

      // 下半部分:本周趋势微图表
      Column() {
        Text('本周情绪趋势')
          .fontSize(13)
          .fontWeight(FontWeight.Medium)
          .fontColor('#5D4037')
          .padding({ left: 16, top: 12, bottom: 4 });

        // 迷你柱状图
        Row() {
          ForEach(Array.from(this.data.weekTrend.entries()), (item: [number, number], index: number) => {
            Column() {
              // 柱状条
              Column()
                .width(16)
                .height(item[1] * 12)  // 数值 × 12 = 柱子高度(最大 60px)
                .backgroundColor(this.getBarColor(item[1]))
                .borderRadius(4)
                .animation({ duration: 500, curve: Curve.EaseOut });

              // 星期几标签
              Text(this.data.weekLabels[index])
                .fontSize(9)
                .fontColor('#888888')
                .margin({ top: 4 });
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1);
          });
        }
        .width('100%')
        .padding({ left: 20, right: 20, top: 8 });
      }
      .width('100%');
    }
    .width('100%')
    .height('100%')
    .backgroundColor(Color.White)
    .borderRadius(16);
    .shadow({
      radius: 8,
      color: '#1a000000',
      offsetX: 0,
      offsetY: 2,
    });
  }

  getBarColor(value: number): string {
    if (value >= 5) return '#FFB74D';  // 开心
    if (value >= 4) return '#81C784';  // 平静
    if (value >= 3) return '#B39DDB';  // 疲惫
    if (value >= 2) return '#64B5F6';  // 难过
    return '#EF5350';                  // 生气
  }

  getWeekGradient(): [[string, number], [string, number]] {
    const avg = this.data.weekTrend.length > 0
      ? this.data.weekTrend.reduce((a, b) => a + b, 0) / this.data.weekTrend.length
      : 4;
    if (avg >= 4.5) return [['#FFB74D', 0.0], ['#FFCC80', 1.0]];
    if (avg >= 3.5) return [['#66BB6A', 0.0], ['#81C784', 1.0]];
    if (avg >= 2.5) return [['#9575CD', 0.0], ['#B39DDB', 1.0]];
    return [['#64B5F6', 0.0], ['#90CAF9', 1.0]];
  }
}

4×4 卡片通过"上半今日情绪 + 下半本周趋势"的布局,让用户在桌面就能获得完整的情绪体检报告。迷你柱状图利用 ArkTS 的 Column 组件模拟,避免引用第三方图表库(控制包体大小)。


6. 实战二:卡片点击跳转——从卡片到完整应用

6.1 卡片事件机制

服务卡片支持 onClick 事件。卡片上的不同区域可以绑定不同的点击行为。在 ArkTS 中,通过 postCardAction 实现卡片的交互:

// 在卡片 UI 中绑定点击事件
Column() {
  // 情绪区域 → 点击跳转到完整应用的情绪记录页
  Row() {
    Text(this.cardData.moodEmoji).fontSize(44);
  }
  .onClick(() => {
    postCardAction(this, {
      action: 'router',       // ★ 路由跳转类型
      abilityName: 'EntryAbility',
      params: {
        targetPage: 'diary',  // 目标页面标识
        action: 'record',     // 具体操作(记录心情)
      }
    });
  });

  // 呼吸区域 → 点击跳转到呼吸练习页
  Row() {
    Text('🫁 呼吸练习').fontSize(12).fontColor('#ffffffcc');
  }
  .onClick(() => {
    postCardAction(this, {
      action: 'router',
      abilityName: 'EntryAbility',
      params: {
        targetPage: 'breathe',
      }
    });
  });

  // 统计区域 → 点击跳转到统计页
  Row() {
    Text('📊 本周统计').fontSize(12).fontColor('#ffffffcc');
  }
  .onClick(() => {
    postCardAction(this, {
      action: 'router',
      abilityName: 'EntryAbility',
      params: {
        targetPage: 'stats',
      }
    });
  });
}

6.2 在 Flutter 侧接收跳转参数

卡片通过 router 动作启动 EntryAbility,携带了 params。在 Flutter 的 main.dart 中,我们需要读取这些参数来路由到正确的页面:

// main.dart 中处理卡片跳转
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // ... 现有初始化 ...

  // 读取由卡片传入的路由参数
  Map<String, dynamic>? launchParams;
  try {
    final result = await const MethodChannel('com.ebrufen/launch')
        .invokeMapMethod<String, dynamic>('getLaunchParams');
    launchParams = result?.cast<String, dynamic>();
    debugPrint('[E-Brufen] Launch params from card: $launchParams');
  } catch (e) {
    debugPrint('[E-Brufen] Normal launch (not from card)');
  }

  runApp(EBrufenApp(
    settings: settings,
    moodStorage: moodStorage,
    launchParams: launchParams,
  ));
}

class EBrufenApp extends StatelessWidget {
  final AppSettings settings;
  final MoodStorage moodStorage;
  final Map<String, dynamic>? launchParams;

  // ...

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'E-Brufen',
      home: _buildInitialPage(),
    );
  }

  Widget _buildInitialPage() {
    if (launchParams != null) {
      final targetPage = launchParams!['targetPage'] as String?;
      switch (targetPage) {
        case 'diary':
          return DiaryPage(moodStorage: moodStorage);
        case 'breathe':
          return BreathePage(settings: settings);
        case 'stats':
          return HomePage(moodStorage: moodStorage, settings: settings);
        default:
          return HomePage(moodStorage: moodStorage, settings: settings);
      }
    }
    return HomePage(moodStorage: moodStorage, settings: settings);
  }
}

6.3 跳转后的"快速记录"体验设计

如果用户从卡片点击"记录心情"进入应用,应该直接展示情绪选择器(MoodPicker),而不是让用户在日记列表中再点"新增"。这需要 DiaryPage 增加一个 initialAction 参数:

class DiaryPage extends StatefulWidget {
  final MoodStorage moodStorage;
  final String? initialAction; // 'record' 表示直接打开记录面板

  const DiaryPage({
    super.key,
    required this.moodStorage,
    this.initialAction,
  });

  // ...
}

class _DiaryPageState extends State<DiaryPage> {
  
  void initState() {
    super.initState();
    if (widget.initialAction == 'record') {
      // 延迟一帧后打开记录面板(等待 build 完成)
      WidgetsBinding.instance.addPostFrameCallback((_) {
        _showRecordPanel();
      });
    }
  }

  void _showRecordPanel() {
    showModalBottomSheet(
      context: context,
      builder: (_) => MoodPicker(
        onSaved: (entry) {
          moodStorage.insert(entry);
          Navigator.pop(context);
        },
      ),
    );
  }
}

这样,用户从卡片点击 → 应用打开 → 情绪选择器自动弹出 → 选一个表情 → 保存。整个流程 3 步完成,耗时不超过 5 秒。


7. 实战三:卡片的定时刷新——随时间变化的情绪问候

7.1 刷新策略设计

卡片的刷新由 FormExtensionAbility 中的 updateForm 方法控制。刷新分为两种:

刷新方式 触发机制 适用场景 频率限制
定时刷新 formConfig.json 中设置 scheduledUpdateTime 卡片内容随时间变化 最短间隔 30 分钟
事件驱动刷新 应用内代码调用 formProvider.updateForm() 用户操作后立即更新 无限制
系统触发刷新 系统在特定时机更新(如网络恢复) 兜底保证 由系统决定

对于 E-Brufen 的"今日情绪"卡片,我们使用混合刷新策略

  • 定时刷新(30 分钟一次):更新问候语(上午/下午/晚上不同的文案)
  • 事件驱动刷新(用户记录心情后):立即更新卡片内容
  • 每天跨天时触发刷新(通过完整应用的 WorkScheduler 调度)

7.2 定时刷新的配置

formconfig.json(或 form_config.json)中配置定时刷新:

{
  "forms": [
    {
      "name": "TodayMoodCard_2x2",
      "description": "E-Brufen 今日情绪卡片 (2×2)",
      "src": "./ets/widget/pages/TodayMoodCard_2x2.ets",
      "formConfigAbility": "entryability",
      "colorMode": "auto",
      "formVisibleNotify": true,
      "isDefault": true,
      "updateEnabled": true,
      "scheduledUpdateTime": "30",          // ★ 每 30 分钟定时刷新一次
      "updateDuration": 0,
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2", "4*4"],
      "metadata": [
        {
          "name": "ohos.form.metadata.FORM_CUSTOMIZE_ABILITY",
          "value": "FormAbility"
        }
      ]
    }
  ]
}

scheduledUpdateTime: "30" 表示每 30 分钟系统调用一次 onUpdateForm()。这是鸿蒙允许的最短定时刷新间隔——不适合"每秒倒计时"这种高频更新,但对于"上午/下午/晚上"这种低频变化完全够用。

7.3 时段感知的问候语系统

FormExtensionAbilityonUpdateForm 中,根据当前时间生成不同的问候语:

// FormAbility.ets
import { formProvider, formInfo, formBindingData } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';

export default class FormAbility extends FormExtensionAbility {
  onUpdateForm(formId: string): void {
    const now = new Date();
    const hour = now.getHours();
    const moodData = this.loadMoodData(); // 从缓存文件读取

    const cardData: CardData = {
      hasRecord: moodData !== null,
      moodEmoji: moodData?.emoji ?? '😐',
      moodLabel: moodData?.label ?? '',
      recordTime: moodData ? this.formatTime(moodData.timestamp) : '',
      recordCount: moodData?.count ?? 0,
      greeting: this.getGreeting(hour, moodData),
      isNightMode: hour >= 22 || hour < 6,
    };

    // 绑定数据到卡片
    const formData = formBindingData.createFormBindingData({
      'cardData': cardData
    });
    formProvider.updateForm(formId, formData);
  }

  private getGreeting(hour: number, mood: MoodInfo | null): string {
    // 未记录时:引导用户记录
    if (!mood || mood.count === 0) {
      if (hour < 10) return '☀️ 早上好!今天的心情是?';
      if (hour < 14) return '🌿 上午的心情如何?';
      if (hour < 17) return '🎯 午后的心情还好吗?';
      if (hour < 22) return '🌅 傍晚了,心情怎么样?';
      return '🌙 睡前回顾一下今天?';
    }

    // 已记录时:根据情绪和时间给出回应
    if (mood.emoji === '😊') {
      if (hour < 12) return '✨ 开心的早晨!元气满满';
      if (hour < 18) return '🌻 心情不错,继续保持';
      return '🌟 美好的一天,晚安';
    }
    if (mood.emoji === '😢' || mood.emoji === '😡') {
      return '💙 不开心也没关系,试试呼吸练习?';
    }
    return '🧘 平静也是好状态';
  }

  // 从缓存文件加载情绪数据
  private loadMoodData(): MoodInfo | null {
    try {
      const cachePath = this.context.filesDir + '/cards/mood_cache.json';
      const stat = fs.statSync(cachePath);
      const file = fs.openSync(cachePath, fs.OpenMode.READ_ONLY);
      const buffer = new ArrayBuffer(stat.size);
      fs.readSync(file.fd, buffer);
      fs.closeSync(file);

      const text = bufferToString(buffer);
      return JSON.parse(text) as MoodInfo;
    } catch (e) {
      return null;
    }
  }
}

interface MoodInfo {
  emoji: string;
  label: string;
  value: number;
  count: number;
  timestamp: number;
}

这个问候语系统模拟了"一个关心你情绪的虚拟同伴"——早、中、晚不同的语气,开心时给你鼓励,难过时给你关怀,未记录时给你提醒。它不是冷冰冰的数据展示,而是有人情味的对话。


8. Service Widget 的完整开发流程

8.1 开发流程全景

创建 E-Brufen 服务卡片的完整步骤
═══════════════════════════════════════════════════════

Step 1: 项目配置
  ├─ 在 DevEco Studio 中,右键 entry 模块 → New → Service Widget
  ├─ 配置卡片名称、尺寸、描述
  └─ 自动生成 formconfig.json 和卡片 UI 模板

Step 2: 编写卡片 UI(ArkTS)
  ├─ 创建 .ets 文件(卡片页面组件)
  ├─ 使用 @Entry + @Component 装饰器
  ├─ 用声明式语法构建布局
  └─ 本地预览(DevEco Previewer)

Step 3: 实现 FormExtensionAbility
  ├─ 创建 FormAbility.ets(继承 FormExtensionAbility)
  ├─ 实现 onCreate / onUpdateForm / onEvent / onDestroy
  └─ 在 onUpdateForm 中读取数据并绑定到卡片

Step 4: 配置 formconfig.json
  ├─ 设置定时刷新间隔(scheduledUpdateTime)
  ├─ 声明支持的卡片尺寸(supportDimensions)
  └─ 指定 FormExtensionAbility(metadata.CUSTOMIZE_ABILITY)

Step 5: 数据桥接(Flutter ↔ 卡片)
  ├─ 在 Flutter 端每次数据变更后导出 JSON 缓存
  ├─ 在 FormExtensionAbility 中读取缓存
  └─ 测试:Flutter 记录心情 → 卡片立即更新

Step 6: 添加卡片到桌面(用户操作)
  ├─ 在完整应用中提供"添加到桌面"按钮
  ├─ 或在 AppGallery 元服务详情页提供添加入口
  └─ 长按桌面 → 服务卡片 → 搜索"E-Brufen" → 添加

Step 7: 签名与打包
  ├─ 配置元服务的签名(与完整应用共用证书)
  ├─ 打包为 .hap 或 .app 文件
  └─ 上传到 AppGallery 元服务专区

Step 8: 测试
  ├─ 桌面显示测试:不同情绪状态的展示效果
  ├─ 点击跳转测试:三种路由是否正确
  ├─ 定时刷新测试:30 分钟后问候语是否更新
  ├─ 夜间模式测试:22:00 后是否切换深色背景
  └─ 存量兼容测试:旧版本完整应用 + 新版本卡片

8.2 在 Flutter 端添加"添加到桌面"按钮

在完整应用的设置页面中,增加一个"添加到桌面"入口:

// 在 HomePage 或设置页面中
ListTile(
  leading: const Icon(Icons.widgets_outlined),
  title: const Text('添加到桌面'),
  subtitle: const Text('将今日情绪卡片添加到桌面'),
  onTap: () async {
    try {
      await const MethodChannel('com.ebrufen/card')
          .invokeMethod('addCardToDesktop');
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('已添加到桌面 ✓')),
        );
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('添加失败: $e')),
        );
      }
    }
  },
);

原生侧(ETS)实现:

// 在 EntryAbility 的 MethodCallHandler 中
case 'addCardToDesktop':
  const formId = await formHost.addForm(
    'TodayMoodCard_2x2',
    this.context
  );
  result.success(formId);
  break;

8.3 Flutter 数据变更后触发卡片更新

这是最关键的一步:Flutter 端记录心情后,需要立即通知卡片更新。实现方式是在数据保存后导出缓存文件,然后通过 MethodChannel 通知原生端刷新:

// 在 MoodStorage.insert() 中增加卡片同步逻辑
Future<int> insert(MoodEntry entry) async {
  final id = _nextId++;
  final data = entry.toJson();
  data['id'] = id;
  await _box?.put(id, jsonEncode(data));
  notifyListeners();

  // ★ 导出卡片缓存 + 触发卡片刷新
  _syncToCard();

  return id;
}

void _syncToCard() async {
  try {
    // 1. 获取今天的数据摘要
    final today = getByDate(DateTime.now());
    final cache = {
      'emoji': today.isNotEmpty ? today.first.moodType.emoji : null,
      'label': today.isNotEmpty ? today.first.moodType.label : null,
      'value': today.isNotEmpty ? today.first.moodType.value : null,
      'count': today.length,
      'timestamp': DateTime.now().millisecondsSinceEpoch,
    };

    // 2. 写入缓存文件(卡片端读取)
    await const MethodChannel('com.ebrufen/card')
        .invokeMethod('updateCardCache', cache);

    // 3. 通知原生端刷新所有卡片实例
    await const MethodChannel('com.ebrufen/card')
        .invokeMethod('refreshAllCards');
  } catch (e) {
    debugPrint('[E-Brufen] Card sync failed: $e');
  }
}

原生侧:

case 'updateCardCache':
  const cache = call.args as Record<string, Object>;
  const jsonStr = JSON.stringify(cache);
  const cacheDir = this.context.filesDir + '/cards';
  mkdirSync(cacheDir); // 确保目录存在
  const file = fs.openSync(
    cacheDir + '/mood_cache.json',
    fs.OpenMode.CREATE | fs.OpenMode.TRUNC | fs.OpenMode.WRITE_ONLY
  );
  fs.writeSync(file.fd, jsonStr);
  fs.closeSync(file);
  result.success(null);
  break;

case 'refreshAllCards':
  // 获取所有卡片实例的 formId 并逐一更新
  const formIds = await formHost.getRunningFormInfos();
  for (const info of formIds) {
    const formData = this.buildCardData(); // 读取缓存并构造数据
    formProvider.updateForm(info.formId, formData);
  }
  result.success(null);
  break;

9. FormExtensionAbility 的生命周期与数据更新

9.1 生命周期详解

FormExtensionAbility 是卡片的数据后端,其生命周期与 Ability 类似但更精简:

FormExtensionAbility 生命周期
═══════════════════════════════════════════════════════

  onCreate(want)
    │  初始化:读取配置,建立数据库连接
    │  ★ 只调用一次,在整个卡片服务存活期间
    ▼
  ┌──────────────────────────┐
  │    onUpdateForm(formId)   │  ← 定时刷新触发
  │    onCastToNormal()       │  ← 用户点击触发
  │    onEvent(formId, msg)   │  ← 事件触发
  │    onVisibilityChange()   │  ← 可见性变化触发
  └──────────────────────────┘
    │
    │  多种触发方式调用上述方法
    ▼
  onDestroy()
    │  清理:关闭数据库连接,释放文件句柄
    └─ ★ 所有卡片实例被移除后调用

9.2 完整实现

// FormAbility.ets —— E-Brufen 卡片的数据提供者
import { formProvider, formBindingData, FormExtensionAbility } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import fs from '@ohos.file.fs';

export default class FormAbility extends FormExtensionAbility {
  private cachePath: string = '';

  onCreate(want: Want): formBindingData.FormBindingData {
    // 初始化缓存文件路径
    this.cachePath = this.context.filesDir + '/cards/mood_cache.json';

    // 返回初始绑定数据(卡片首次显示时使用的数据)
    const initialData = this.buildFormData();
    return formBindingData.createFormBindingData(initialData);
  }

  onUpdateForm(formId: string): void {
    // ★ 定时刷新:每 30 分钟调用一次
    console.info(`[E-Brufen Card] Update form ${formId}`);
    const formData = this.buildFormData();
    const bindingData = formBindingData.createFormBindingData(formData);
    formProvider.updateForm(formId, bindingData);
  }

  onCastToNormal(formId: string): void {
    // 用户点击卡片进入应用 → 不需要特殊处理
    // 但可以在这里记录"通过卡片进入"的埋点数据
  }

  onEvent(formId: string, message: string): void {
    // 处理来自应用的事件(如 Flutter 端调用 refreshAllCards)
    if (message === 'REFRESH') {
      const formData = this.buildFormData();
      const bindingData = formBindingData.createFormBindingData(formData);
      formProvider.updateForm(formId, bindingData);
    }
  }

  onVisibilityChange(newStatus: Record<string, number>): void {
    // 卡片可见性变化(如桌面滑动到卡片所在页)
    // 不强制刷新,避免不必要的性能开销
  }

  onDestroy(): void {
    console.info('[E-Brufen Card] FormAbility destroyed');
  }

  // ── 数据构造 ──

  private buildFormData(): object {
    const now = new Date();
    const hour = now.getHours();
    const mood = this.loadMoodCache();

    const isNightMode = hour >= 22 || hour < 6;
    const greeting = this.buildGreeting(hour, mood);

    return {
      'cardData': {
        hasRecord: mood !== null && mood.count > 0,
        moodEmoji: mood?.emoji ?? '😐',
        moodLabel: mood?.label ?? '',
        recordTime: mood ? this.formatTime(mood.timestamp) : '',
        recordCount: mood?.count ?? 0,
        greeting: greeting,
        isNightMode: isNightMode,
      }
    };
  }

  private loadMoodCache(): MoodCache | null {
    try {
      const stat = fs.statSync(this.cachePath);
      const file = fs.openSync(this.cachePath, fs.OpenMode.READ_ONLY);
      const buffer = new ArrayBuffer(stat.size);
      fs.readSync(file.fd, buffer);
      fs.closeSync(file);

      // ArrayBuffer → string → JSON
      const decoder = new util.TextDecoder('utf-8');
      const text = decoder.decode(new Uint8Array(buffer));
      return JSON.parse(text) as MoodCache;
    } catch (e) {
      console.warn(`[E-Brufen Card] Cache not found: ${e}`);
      return null;
    }
  }
}

interface MoodCache {
  emoji: string;
  label: string;
  value: number;
  count: number;
  timestamp: number;
}

9.3 一个常见的坑:onUpdateForm 不触发

现象:配置了 scheduledUpdateTime: "30",但卡片从未自动刷新。

排查步骤

  1. 检查 formconfig.json 是否正确放置(应在 entry/src/main/resources/base/profile/ 路径下)
  2. 确认 module.json5 中注册了 FormExtensionAbility
    {
      "extensionAbilities": [
        {
          "name": "FormAbility",
          "srcEntry": "./ets/FormAbility.ets",
          "type": "form"
        }
      ]
    }
    
  3. 系统对定时刷新有节流策略:如果设备在 Doze 模式或电量过低,可能延迟触发。
  4. 测试建议:使用 hdc shell aa start -A com.ebrufen.app -U form 手动触发卡片更新,排除代码问题。

10. 元服务分发:AppGallery 的原子化能力

10.1 分发渠道全景

渠道 描述 用户获取路径
AppGallery 元服务专区 鸿蒙应用商店的元服务板块 商店搜索"E-Brufen" → 元服务标签
桌面快捷方式 用户手动添加到桌面 应用内"添加到桌面"按钮
服务中心 桌面负一屏 / 下拉搜索栏下方 搜索"情绪" → E-Brufen 卡片
智慧搜索 桌面下拉全局搜索 搜索"心情记录" → 直达服务
扫码 二维码 / NFC 标签 扫描二维码 → 打开元服务
链接分享 深度链接 点击链接 → 浏览器 → 拉起元服务
碰一碰 NFC 近场通信 手机碰 NFC 标签 → 打开元服务
小艺建议 AI 推荐 小艺根据使用习惯推荐卡片

10.2 AppGallery 上架流程

元服务上架流程
═══════════════════════════════════════════════════════

Step 1: 签署开发者协议
  ├─ 登录 AppGallery Connect
  ├─ 完成实名认证(企业/个人)
  └─ 签署原子化服务分发协议

Step 2: 创建元服务
  ├─ 在 AppGallery Connect 中创建应用
  ├─ 选择"元服务"类型
  └─ 获取 bundleName 和 appId

Step 3: 配置元服务信息
  ├─ 上传元服务图标(512×512px)
  ├─ 填写元服务名称、描述、截图
  ├─ 设置元服务分类(健康/生活)
  └─ 配置支持的分发方式

Step 4: 上传包体
  ├─ 打包 .app 文件(含完整应用 + 元服务模块)
  ├─ 上传到 AppGallery Connect
  └─ 填写版本号、更新日志

Step 5: 审核与发布
  ├─ 提交审核(通常 1-3 个工作日)
  ├─ 审核通过 → 设置发布范围(国家/地区)
  └─ 点击"发布" → 用户在 AppGallery 可搜索到

Step 6: 推广与分发
  ├─ 生成元服务二维码(用于线下推广)
  ├─ 配置深度链接(分享到社交平台)
  └─ 接入华为分析(追踪元服务使用数据)

10.3 元服务与完整应用的用户转化路径

元服务不仅是一个独立的产品形态,更是完整应用的获客引擎

用户转化漏斗
═══════════════════════════════════════════════════════

  看到元服务(搜索/推荐/扫码)
  │  1000 次曝光
  ▼
  添加到桌面 / 使用元服务
  │  300 人(30% 转化)
  ▼
  高频使用元服务(每天 > 3 次查看卡片)
  │  100 人(10% 留存)
  ▼
  卡片点进去 → 发现更多功能 → 下载完整应用
  │  40 人(4% 安装转化)
  ▼
  成为活跃用户(每天使用白噪音 / 呼吸练习 / 情绪日记)
  │  15 人(1.5% 核心用户)
  ▼
  口口相传 → 推荐给朋友 → 裂变增长

这个漏斗的核心洞察是:元服务降低了"首次体验"的摩擦力。用户不需要下载安装,不需要注册登录,不需要授权权限——只需在桌面看一眼卡片。如果卡片传达的价值打动了用户(“今天的心情数据一目了然”),他们自然会产生"想看看更多功能"的欲望——这时完整应用就等在那里。


11. 元服务的适用场景分析

11.1 什么功能适合做成元服务

基于 E-Brufen 的实践,我们总结出元服务的"四大黄金特征":

适合元服务的功能特征
═══════════════════════════════════════════════════════

① 高频:用户每天至少接触一次
   E-Brufen: 情绪状态是"每天看一眼"的需求

② 轻量:信息在 3 秒内可理解
   E-Brufen: 一个 emoji + 一句话,不需要阅读复杂数据

③ 即时性:用户"当下"就需要
   E-Brufen: 当下此刻的心情是什么?立即记录

④ 指向性:卡片引导到完整应用的核心功能
   E-Brufen: 卡片 → 记录心情(核心功能)→ 触发更多交互

11.2 不适合元服务的功能

功能类型 原因 正确做法
需要复杂交互的 卡片 UI 不支持多步表单、绘图、拖拽 放在完整应用中,卡片只做入口
需要后台运行的 元服务不支持长时任务 白噪音/计时器必须在完整应用中
需要大数据传输的 卡片更新频率和数据量受限 大量数据在完整应用中处理
需要硬件权限的 相机、麦克风、蓝牙受限 完整应用申请权限
极高安全要求的 支付、身份认证 完整应用更安全

11.3 E-Brufen 的元服务矩阵

基于以上分析,E-Brufen 可以构建一个"1 底座 + 3 卡片 + 2 直达服务"的元服务矩阵:

E-Brufen 元服务矩阵
═══════════════════════════════════════════════════════

  ┌─────────────────────────────────────────────────┐
  │           E-Brufen 完整应用(1 底座)              │
  │   白噪音 + 呼吸练习 + 情绪日记 + 数据统计          │
  └──────────┬──────────┬──────────┬────────────────┘
             │          │          │
    ┌────────▼──┐ ┌─────▼─────┐ ┌─▼──────────────┐
    │ 今日情绪卡片│ │ 周情绪卡片 │ │ 呼吸快捷键卡片  │
    │  (2×2)    │ │  (4×4)    │ │  (1×2)         │
    │           │ │           │ │                │
    │ 今天的心情 │ │ 本周趋势图 │ │ 🫁 快速开始练习  │
    │ 一眼便知   │ │ 情绪追踪   │ │ 跳转呼吸页     │
    └───────────┘ └───────────┘ └────────────────┘
             │
    ┌────────▼──────────┐
    │ 快速记录心情 (直达服务) │
    │ 选表情 → 写备注 → 保存  │
    │ 3 步完成,无需安装     │
    └─────────────────────┘

这个矩阵覆盖了"看(卡片)→ 记(直达服务)→ 练(完整应用)"的完整用户旅程,形成一个从轻到重、由浅入深的产品体验闭环。


12. 小结

本文以 E-Brufen 情绪健康应用的元服务开发为线索,系统性地覆盖了鸿蒙元服务(Atomic Service)的完整知识体系:

  1. 元服务的核心价值:将应用的核心信息"原子化"到桌面,降低用户触达成本。情绪健康应用天然适合元服务——"今天心情如何"是一个高频、轻量、即时性的需求。
  2. 服务卡片的两类形态:2×2 卡片展示今天情绪(emoji + 文案),4×4 卡片扩展为"今日情绪 + 本周趋势微图表"。卡片 UI 使用 ArkTS 声明式语法编写,不使用 Flutter。
  3. 卡片点击跳转:通过 postCardAction({action: 'router'}) 实现点击跳转。卡片的不同区域(情绪区、呼吸区、统计区)可以路由到完整应用的不同页面。Flutter 端读取 launchParams 做页面路由。
  4. 定时刷新与事件驱动scheduledUpdateTime: "30"(最短 30 分钟)实现定时刷新(时段感知问候语)。Flutter 端数据变更后通过导出缓存文件 + formProvider.updateForm() 实现即时刷新。
  5. FormExtensionAbility 生命周期onCreate 初始化 → onUpdateForm 定时刷新 → onEvent 事件响应 → onDestroy 清理。缓存文件是"约定大于配置"的数据桥接方案。
  6. 元服务分发:AppGallery 元服务专区、桌面快捷方式、服务中心、智慧搜索、扫码、深度链接等多种分发渠道。元服务是完整应用的"获客引擎"——先看到卡片,再下载完整应用。
  7. 适用场景判断:高频、轻量、即时、有指向性——四个维度判断功能是否适合元服务化。白噪音播放、呼吸球动画等复杂功能放完整应用,情绪展示、快速记录等轻量功能放元服务。

鸿蒙元服务是移动应用开发的一次范式转换——它打破了"安装→打开→使用"的传统路径,代之以"零安装→即看即用→自然转化"的新模式。对于 E-Brufen 这样以"养成健康习惯"为目标的情绪健康应用,元服务不是锦上添花,而是核心体验的一部分:它在用户最需要的时候出现,在用户最不经意的地方提醒,让关心自己的情绪变得像看天气一样自然。


作者简介:E-Brufen Dev,Flutter 与鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化。

项目地址AtomGit Flutter 鸿蒙客户端

Logo

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

更多推荐