AtomGit Flutter 鸿蒙客户端:情绪健康快捷卡片
不打开 App,在桌面扫一眼就知道今天的情绪状态——鸿蒙元服务如何让 E-Brufen 的"情绪体检"触手可及
目录
- 引言:元服务——鸿蒙生态的秘密武器
- 元服务的概念与类型全景
- 元服务 vs 完整应用:能力边界与适用场景
- 元服务的包体限制与技术约束
- 实战一:创建 E-Brufen "今日情绪"服务卡片
- 实战二:卡片点击跳转——从卡片到完整应用
- 实战三:卡片的定时刷新——随时间变化的情绪问候
- Service Widget 的完整开发流程
- FormExtensionAbility 的生命周期与数据更新
- 元服务分发:AppGallery 的原子化能力
- 元服务的适用场景分析
- 小结
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 包,包含了一个或多个 ServiceAbility 或 FormExtensionAbility。它和完整应用的关系是:
┌─────────────────────────────────────────────────────────┐
│ 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 时段感知的问候语系统
在 FormExtensionAbility 的 onUpdateForm 中,根据当前时间生成不同的问候语:
// 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",但卡片从未自动刷新。
排查步骤:
- 检查
formconfig.json是否正确放置(应在entry/src/main/resources/base/profile/路径下) - 确认
module.json5中注册了FormExtensionAbility:{ "extensionAbilities": [ { "name": "FormAbility", "srcEntry": "./ets/FormAbility.ets", "type": "form" } ] } - 系统对定时刷新有节流策略:如果设备在 Doze 模式或电量过低,可能延迟触发。
- 测试建议:使用
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)的完整知识体系:
- 元服务的核心价值:将应用的核心信息"原子化"到桌面,降低用户触达成本。情绪健康应用天然适合元服务——"今天心情如何"是一个高频、轻量、即时性的需求。
- 服务卡片的两类形态:2×2 卡片展示今天情绪(emoji + 文案),4×4 卡片扩展为"今日情绪 + 本周趋势微图表"。卡片 UI 使用 ArkTS 声明式语法编写,不使用 Flutter。
- 卡片点击跳转:通过
postCardAction({action: 'router'})实现点击跳转。卡片的不同区域(情绪区、呼吸区、统计区)可以路由到完整应用的不同页面。Flutter 端读取launchParams做页面路由。 - 定时刷新与事件驱动:
scheduledUpdateTime: "30"(最短 30 分钟)实现定时刷新(时段感知问候语)。Flutter 端数据变更后通过导出缓存文件 +formProvider.updateForm()实现即时刷新。 - FormExtensionAbility 生命周期:
onCreate初始化 →onUpdateForm定时刷新 →onEvent事件响应 →onDestroy清理。缓存文件是"约定大于配置"的数据桥接方案。 - 元服务分发:AppGallery 元服务专区、桌面快捷方式、服务中心、智慧搜索、扫码、深度链接等多种分发渠道。元服务是完整应用的"获客引擎"——先看到卡片,再下载完整应用。
- 适用场景判断:高频、轻量、即时、有指向性——四个维度判断功能是否适合元服务化。白噪音播放、呼吸球动画等复杂功能放完整应用,情绪展示、快速记录等轻量功能放元服务。
鸿蒙元服务是移动应用开发的一次范式转换——它打破了"安装→打开→使用"的传统路径,代之以"零安装→即看即用→自然转化"的新模式。对于 E-Brufen 这样以"养成健康习惯"为目标的情绪健康应用,元服务不是锦上添花,而是核心体验的一部分:它在用户最需要的时候出现,在用户最不经意的地方提醒,让关心自己的情绪变得像看天气一样自然。
作者简介:E-Brufen Dev,Flutter 与鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化。
更多推荐




所有评论(0)