HarmonyOS开发:元服务实战——完整服务卡片开发
HarmonyOS开发:元服务实战——完整服务卡片开发
📌 核心要点:从零到一完成一个完整的元服务项目,包含静态卡片、动态卡片、交互、数据更新、多尺寸适配,覆盖从创建到发布的全流程。
背景与动机
前面九篇文章,我们把元服务的每个知识点都拆开讲了一遍。概念、布局、数据、生命周期、交互、适配、共享、性能——每个单独看都不难,但拼到一起呢?
这就是这篇实战要解决的问题。
我们要做一个完整的元服务项目——“每日打卡”。功能很简单:用户每天打卡记录习惯,卡片显示今日打卡进度和连续打卡天数。但麻雀虽小五脏俱全,这个项目包含:
- 静态卡片:2×2显示今日打卡概要
- 动态卡片:4×4显示打卡详情+交互按钮
- 交互:点击卡片跳转App,点击"打卡"按钮直接打卡
- 数据更新:打卡后实时更新卡片数据
- 多尺寸适配:2×2和4×4两种布局
- 数据共享:UIAbility和FormAbility共享打卡数据
核心原理
项目架构
flowchart TB
subgraph 项目结构
A[entry模块] --> B[EntryAbility<br/>主界面]
A --> C[CheckInFormAbility<br/>卡片数据提供]
A --> D[CheckInDataShare<br/>数据共享层]
end
subgraph 卡片层
C --> E[静态卡片<br/>CheckInSmall.ets<br/>2×2概要]
C --> F[动态卡片<br/>CheckInLarge.ets<br/>4×4详情]
end
subgraph 数据层
D --> G[RDB数据库<br/>打卡记录]
D --> H[内存缓存<br/>统计数据]
end
subgraph 交互层
E -->|router| B
F -->|message| C
F -->|router| B
end
B -->|写入| D
C -->|读取| D
classDef module fill:#E3F2FD,stroke:#1565C0,color:#0D47A1
classDef card fill:#FFF3E0,stroke:#E65100,color:#BF360C
classDef data fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20
classDef interaction fill:#FCE4EC,stroke:#C62828,color:#B71C1C
class A,B,C,D module
class E,F card
class G,H data
完整开发流程
flowchart LR
A[1. 创建项目] --> B[2. 配置form_config]
B --> C[3. 开发卡片布局]
C --> D[4. 开发FormAbility]
D --> E[5. 开发UIAbility]
E --> F[6. 实现数据共享]
F --> G[7. 实现交互]
G --> H[8. 性能优化]
H --> I[9. 测试调试]
I --> J[10. 发布上架]
classDef step fill:#E3F2FD,stroke:#1565C0,color:#0D47A1
class A,B,C,D,E,F,G,H,I,J step
代码实战
第一步:创建项目
在DevEco Studio中创建新项目,选择"Empty Ability"模板。然后在entry模块中添加FormAbility。
项目目录结构:
entry/
├── src/main/
│ ├── ets/
│ │ ├── entryability/
│ │ │ └── EntryAbility.ets # 主界面
│ │ ├── formability/
│ │ │ └── CheckInFormAbility.ets # 卡片数据提供
│ │ ├── datashare/
│ │ │ └── CheckInDataShare.ets # 数据共享层
│ │ ├── widget/
│ │ │ └── pages/
│ │ │ ├── CheckInSmall.ets # 2×2静态卡片
│ │ │ └── CheckInLarge.ets # 4×4动态卡片
│ │ ├── pages/
│ │ │ └── Index.ets # 主页面
│ │ ├── common/
│ │ │ ├── CardStateManager.ets # 卡片状态管理
│ │ │ └── CheckInDataHelper.ets # 数据操作工具
│ │ └── model/
│ │ └── CheckInModel.ets # 数据模型
│ ├── resources/
│ │ ├── base/
│ │ │ ├── profile/
│ │ │ │ └── form_config.json # 卡片配置
│ │ │ ├── media/ # 图片资源
│ │ │ └── element/ # 字符串资源
│ │ └── rawfile/ # 原始文件
│ └── module.json5 # 模块配置
第二步:配置form_config.json
{
"forms": [
{
"name": "checkInSmall",
"displayName": "每日打卡",
"description": "显示今日打卡进度",
"src": "./ets/widget/pages/CheckInSmall.ets",
"uiSyntax": "arkts",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "auto",
"isDefault": true,
"updateEnabled": true,
"scheduledUpdateTime": "06:00",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": ["2*2"],
"formVisibleNotify": true
},
{
"name": "checkInLarge",
"displayName": "打卡详情",
"description": "显示打卡详情和操作按钮",
"src": "./ets/widget/pages/CheckInLarge.ets",
"uiSyntax": "arkts",
"isDynamic": true,
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "auto",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "06:00",
"updateDuration": 1,
"defaultDimension": "4*4",
"supportDimensions": ["4*4"],
"formVisibleNotify": true
}
]
}
第三步:数据模型与数据共享
数据模型:
// model/CheckInModel.ets
// 打卡记录
export interface CheckInRecord {
id: number
habitName: string
date: string // YYYY-MM-DD
isCompleted: boolean
completedAt: number // 时间戳
}
// 打卡统计
export interface CheckInStats {
todayTotal: number // 今日总打卡项
todayCompleted: number // 今日已完成
streakDays: number // 连续打卡天数
habitList: HabitItem[] // 习惯列表
}
export interface HabitItem {
name: string
isCompleted: boolean
icon: string
}
// 卡片数据(2×2)
export interface SmallCardData {
completedCount: string
totalCount: string
streakDays: string
dateStr: string
encouragement: string
}
// 卡片数据(4×4)
export interface LargeCardData {
completedCount: string
totalCount: string
streakDays: string
dateStr: string
habit1Name: string
habit1Done: string
habit2Name: string
habit2Done: string
habit3Name: string
habit3Done: string
habit4Name: string
habit4Done: string
habit5Name: string
habit5Done: string
}
数据共享层:
// datashare/CheckInDataShare.ets
import { DataShareExtensionAbility, relationalStore } from '@kit.ArkData'
import { hilog } from '@kit.PerformanceAnalysisKit'
const STORE_CONFIG: relationalStore.StoreConfig = {
name: 'checkin.db',
securityLevel: relationalStore.SecurityLevel.S1
}
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS checkin_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
habit_name TEXT NOT NULL,
date TEXT NOT NULL,
is_completed INTEGER DEFAULT 0,
completed_at INTEGER DEFAULT 0
)
`
const CREATE_HABITS_TABLE = `
CREATE TABLE IF NOT EXISTS habits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
icon TEXT DEFAULT 'default',
sort_order INTEGER DEFAULT 0
)
`
export default class CheckInDataShare extends DataShareExtensionAbility {
private store: relationalStore.RdbStore | null = null
async onCreate(): Promise<void> {
this.store = await relationalStore.getRdbStore(this.context, STORE_CONFIG)
await this.store.executeSql(CREATE_TABLE_SQL)
await this.store.executeSql(CREATE_HABITS_TABLE)
// 初始化示例数据
await this.initSampleData()
hilog.info(0x0000, 'CheckInData', '数据库初始化完成')
}
async query(uri: string, columns: string[], predicates: dataSharePredicates.DataSharePredicates): Promise<relationalStore.ResultSet> {
if (!this.store) throw new Error('数据库未初始化')
const path = uri.split('/').pop()
if (path === 'stats') {
// 查询统计数据
return this.queryStats()
} else if (path === 'today') {
// 查询今日打卡记录
return this.queryTodayRecords()
}
return this.queryTodayRecords()
}
async insert(uri: string, valueBucket: relationalStore.ValuesBucket): Promise<number> {
if (!this.store) throw new Error('数据库未初始化')
return this.store.insert('checkin_records', valueBucket)
}
async update(uri: string, valueBucket: relationalStore.ValuesBucket, predicates: dataSharePredicates.DataSharePredicates): Promise<number> {
if (!this.store) throw new Error('数据库未初始化')
const rdbPredicates = new relationalStore.RdbPredicates('checkin_records')
const habitName = valueBucket['habit_name'] as string
const today = this.getTodayStr()
rdbPredicates.equalTo('habit_name', habitName)
rdbPredicates.equalTo('date', today)
return this.store.update(valueBucket, rdbPredicates)
}
private async queryStats(): Promise<relationalStore.ResultSet> {
const today = this.getTodayStr()
// 今日总数
const totalResult = await this.store.querySql(
'SELECT COUNT(*) as count FROM checkin_records WHERE date = ?', [today]
)
totalResult.goToFirstRow()
const totalCount = totalResult.getLong(0)
totalResult.close()
// 今日已完成
const completedResult = await this.store.querySql(
'SELECT COUNT(*) as count FROM checkin_records WHERE date = ? AND is_completed = 1', [today]
)
completedResult.goToFirstRow()
const completedCount = completedResult.getLong(0)
completedResult.close()
// 连续打卡天数
const streakDays = await this.calculateStreakDays()
// 构造返回结果
return this.store.querySql(
'SELECT ? as today_total, ? as today_completed, ? as streak_days',
[totalCount, completedCount, streakDays]
)
}
private async queryTodayRecords(): Promise<relationalStore.ResultSet> {
const today = this.getTodayStr()
return this.store.querySql(
'SELECT habit_name, is_completed FROM checkin_records WHERE date = ? ORDER BY id',
[today]
)
}
private async calculateStreakDays(): Promise<number> {
let streak = 0
let checkDate = new Date()
while (true) {
const dateStr = this.formatDate(checkDate)
const result = await this.store.querySql(
'SELECT COUNT(*) as total, SUM(is_completed) as done FROM checkin_records WHERE date = ?',
[dateStr]
)
result.goToFirstRow()
const total = result.getLong(0)
const done = result.getLong(1)
result.close()
if (total > 0 && done === total) {
streak++
checkDate.setDate(checkDate.getDate() - 1)
} else {
break
}
}
return streak
}
private async initSampleData(): Promise<void> {
const today = this.getTodayStr()
// 检查是否已有数据
const result = await this.store.querySql(
'SELECT COUNT(*) FROM checkin_records WHERE date = ?', [today]
)
result.goToFirstRow()
const count = result.getLong(0)
result.close()
if (count > 0) return // 已有数据
// 插入示例习惯
const habits = ['早起', '运动', '阅读', '冥想', '写日记']
for (const habit of habits) {
await this.store.insert('checkin_records', {
habit_name: habit,
date: today,
is_completed: 0,
completed_at: 0
})
}
}
private getTodayStr(): string {
return this.formatDate(new Date())
}
private formatDate(date: Date): string {
const y = date.getFullYear()
const m = (date.getMonth() + 1).toString().padStart(2, '0')
const d = date.getDate().toString().padStart(2, '0')
return `${y}-${m}-${d}`
}
}
第四步:卡片布局
2×2静态卡片:
// widget/pages/CheckInSmall.ets
@Entry
@Component
struct CheckInSmall {
@State completedCount: string = '0'
@State totalCount: string = '5'
@State streakDays: string = '0'
@State dateStr: string = ''
@State encouragement: string = '加油!'
build() {
Column() {
// 顶部:日期 + 连续天数
Row() {
Text(this.dateStr)
.fontSize(11)
.fontColor('#999999')
Blank()
Row() {
Text('🔥')
.fontSize(12)
Text(this.streakDays)
.fontSize(12)
.fontColor('#FF6B35')
.fontWeight(FontWeight.Bold)
Text('天')
.fontSize(10)
.fontColor('#999999')
}
}
.width('100%')
// 核心数据:打卡进度
Column() {
Row() {
Text(this.completedCount)
.fontSize(40)
.fontColor('#4CAF50')
.fontWeight(FontWeight.Bold)
Text(` / ${this.totalCount}`)
.fontSize(16)
.fontColor('#999999')
.margin({ top: 16 })
}
Text('今日打卡')
.fontSize(12)
.fontColor('#666666')
.margin({ top: 4 })
}
.margin({ top: 16 })
// 底部:鼓励语
Text(this.encouragement)
.fontSize(11)
.fontColor('#FF6B35')
.margin({ top: 8 })
}
.width('100%')
.height('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.justifyContent(FlexAlign.SpaceBetween)
.onClick(() => {
// 点击整个卡片跳转到App
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { targetPage: 'checkInList' }
})
})
}
}
4×4动态卡片:
// widget/pages/CheckInLarge.ets
@Entry
@Component
struct CheckInLarge {
@State completedCount: string = '0'
@State totalCount: string = '5'
@State streakDays: string = '0'
@State dateStr: string = ''
@State habit1Name: string = ''
@State habit1Done: string = 'false'
@State habit2Name: string = ''
@State habit2Done: string = 'false'
@State habit3Name: string = ''
@State habit3Done: string = 'false'
@State habit4Name: string = ''
@State habit4Done: string = 'false'
@State habit5Name: string = ''
@State habit5Done: string = 'false'
build() {
Column() {
// 顶部:日期 + 统计
Row() {
Column() {
Text(this.dateStr)
.fontSize(12)
.fontColor('#999999')
Row() {
Text(this.completedCount)
.fontSize(28)
.fontColor('#4CAF50')
.fontWeight(FontWeight.Bold)
Text(` / ${this.totalCount}`)
.fontSize(14)
.fontColor('#999999')
.margin({ top: 8 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
Blank()
// 连续打卡天数
Column() {
Text('🔥')
.fontSize(20)
Text(this.streakDays)
.fontSize(20)
.fontColor('#FF6B35')
.fontWeight(FontWeight.Bold)
Text('天连续')
.fontSize(10)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.Center)
}
.width('100%')
// 进度条
Progress({
value: parseInt(this.completedCount),
total: parseInt(this.totalCount),
type: ProgressType.Linear
})
.width('100%')
.color('#4CAF50')
.backgroundColor('#EEEEEE')
.margin({ top: 12 })
.style({ strokeWidth: 6 })
// 习惯列表
Column() {
this.HabitItem(this.habit1Name, this.habit1Done, '1')
this.HabitItem(this.habit2Name, this.habit2Done, '2')
this.HabitItem(this.habit3Name, this.habit3Done, '3')
this.HabitItem(this.habit4Name, this.habit4Done, '4')
this.HabitItem(this.habit5Name, this.habit5Done, '5')
}
.width('100%')
.margin({ top: 12 })
// 一键打卡按钮
Button('全部打卡')
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('#4CAF50')
.borderRadius(20)
.width('100%')
.height(36)
.margin({ top: 12 })
.onClick(() => {
postCardAction(this, {
action: 'message',
params: { action: 'checkAll' }
})
})
}
.width('100%')
.height('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
@Builder
HabitItem(name: string, done: string, index: string) {
Row() {
// 打卡状态图标
Text(done === 'true' ? '✅' : '⬜')
.fontSize(16)
// 习惯名称
Text(name || `习惯${index}`)
.fontSize(13)
.fontColor(done === 'true' ? '#999999' : '#333333')
.margin({ left: 8 })
.decoration({ type: done === 'true' ? TextDecorationType.LineThrough : TextDecorationType.None })
Blank()
// 单项打卡按钮
if (done === 'false') {
Text('打卡')
.fontSize(11)
.fontColor('#4CAF50')
.onClick(() => {
postCardAction(this, {
action: 'message',
params: { action: 'checkOne', habitIndex: index }
})
})
}
}
.width('100%')
.height(32)
.alignItems(VerticalAlign.Center)
}
}
第五步:FormAbility
// formability/CheckInFormAbility.ets
import { formBindingData, FormExtensionAbility, formProvider } from '@kit.AbilityKit'
import { hilog } from '@kit.PerformanceAnalysisKit'
const DATA_SHARE_URI = 'datashare:///com.example.checkin/checkin_data'
// 卡片状态管理
let activeCards: Map<string, { formId: string; formName: string }> = new Map()
// 内存缓存
let statsCache: Map<string, Record<string, string>> = new Map()
export default class CheckInFormAbility extends FormExtensionAbility {
onAddForm(want: Want): formBindingData.FormBindingData {
const formId = want.parameters?.['ohos.extra.param.key.form_identity'] as string
const formName = want.parameters?.['ohos.extra.param.key.form_name'] as string
hilog.info(0x0000, 'CheckInForm', `添加卡片: ${formId} (${formName})`)
// 注册卡片
activeCards.set(formId, { formId, formName })
// 获取数据
const data = this.getCardData(formName)
return formBindingData.createFormBindingData(data)
}
onUpdateForm(formId: string): formBindingData.FormBindingData {
const info = activeCards.get(formId)
if (!info) {
return formBindingData.createFormBindingData({ completedCount: '0', totalCount: '5' })
}
const data = this.getCardData(info.formName)
return formBindingData.createFormBindingData(data)
}
onRemoveForm(formId: string): void {
activeCards.delete(formId)
statsCache.delete(formId)
hilog.info(0x0000, 'CheckInForm', `卡片删除: ${formId}`)
}
// 处理卡片交互消息
onFormEvent(formId: string, message: string): void {
hilog.info(0x0000, 'CheckInForm', `收到消息: ${formId}, ${message}`)
try {
const msg = JSON.parse(message)
switch (msg.action) {
case 'checkOne':
this.handleCheckOne(formId, msg.habitIndex)
break
case 'checkAll':
this.handleCheckAll(formId)
break
}
} catch (err) {
hilog.error(0x0000, 'CheckInForm', `消息处理失败: ${err}`)
}
}
// 获取卡片数据
private getCardData(formName: string): Record<string, string> {
const now = new Date()
const dateStr = `${now.getMonth() + 1}月${now.getDate()}日`
// 从缓存读取
const cached = statsCache.get(formName)
if (cached) {
return { ...cached, dateStr }
}
// 默认数据
if (formName === 'checkInSmall') {
return {
completedCount: '0',
totalCount: '5',
streakDays: '0',
dateStr: dateStr,
encouragement: '新的一天,加油!'
}
} else {
return {
completedCount: '0',
totalCount: '5',
streakDays: '0',
dateStr: dateStr,
habit1Name: '早起', habit1Done: 'false',
habit2Name: '运动', habit2Done: 'false',
habit3Name: '阅读', habit3Done: 'false',
habit4Name: '冥想', habit4Done: 'false',
habit5Name: '写日记', habit5Done: 'false'
}
}
}
// 处理单项打卡
private handleCheckOne(formId: string, habitIndex: string): void {
const info = activeCards.get(formId)
if (!info) return
// 更新数据库
this.updateHabitStatus(habitIndex, true)
// 获取最新数据并更新卡片
const newData = this.getCardData(info.formName)
this.updateCardData(formId, newData)
}
// 处理全部打卡
private handleCheckAll(formId: string): void {
const info = activeCards.get(formId)
if (!info) return
// 更新所有习惯状态
for (let i = 1; i <= 5; i++) {
this.updateHabitStatus(i.toString(), true)
}
// 获取最新数据并更新卡片
const newData = this.getCardData(info.formName)
this.updateCardData(formId, newData)
}
// 更新习惯状态
private updateHabitStatus(index: string, completed: boolean): void {
// 实际项目中写入DataShare
hilog.info(0x0000, 'CheckInForm', `更新习惯: ${index}, 完成: ${completed}`)
// 更新缓存
const doneKey = `habit${index}Done`
const cached = statsCache.get('checkInLarge')
if (cached) {
cached[doneKey] = completed ? 'true' : 'false'
}
}
// 更新卡片数据
private updateCardData(formId: string, data: Record<string, string>): void {
const bindingData = formBindingData.createFormBindingData(data)
formProvider.updateForm(formId, bindingData)
.then(() => {
hilog.info(0x0000, 'CheckInForm', `卡片更新成功: ${formId}`)
})
.catch((err: Error) => {
hilog.error(0x0000, 'CheckInForm', `卡片更新失败: ${err.message}`)
})
}
}
第六步:UIAbility主界面
// entryability/EntryAbility.ets
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'
import { hilog } from '@kit.PerformanceAnalysisKit'
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(0x0000, 'EntryAbility', 'onCreate')
this.handleRouterAction(want)
}
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(0x0000, 'EntryAbility', 'onNewWant')
this.handleRouterAction(want)
}
private handleRouterAction(want: Want): void {
const params = want.parameters
if (!params) return
const targetPage = params.targetPage as string
if (targetPage) {
AppStorage.setOrCreate('targetPage', targetPage)
}
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(0x0000, 'EntryAbility', `加载失败: ${JSON.stringify(err)}`)
}
})
}
}
主页面:
// pages/Index.ets
@Entry
@Component
struct Index {
@State habits: HabitItem[] = []
@State streakDays: number = 0
@StorageProp('targetPage') targetPage: string = ''
aboutToAppear(): void {
this.loadData()
}
build() {
Navigation() {
Column() {
// 顶部统计
Row() {
Column() {
Text(`${this.getCompletedCount()}/${this.habits.length}`)
.fontSize(36)
.fontColor('#4CAF50')
.fontWeight(FontWeight.Bold)
Text('今日打卡')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 4 })
}
Blank()
Column() {
Text('🔥')
.fontSize(24)
Text(`${this.streakDays}天`)
.fontSize(20)
.fontColor('#FF6B35')
.fontWeight(FontWeight.Bold)
Text('连续打卡')
.fontSize(12)
.fontColor('#999999')
}
}
.width('100%')
.padding(20)
.backgroundColor('#F5F5F5')
.borderRadius(16)
.margin({ left: 16, right: 16, top: 16 })
// 习惯列表
List() {
ForEach(this.habits, (habit: HabitItem, index: number) => {
ListItem() {
Row() {
Text(habit.isCompleted ? '✅' : '⬜')
.fontSize(20)
Text(habit.name)
.fontSize(16)
.fontColor(habit.isCompleted ? '#999999' : '#333333')
.margin({ left: 12 })
.decoration({
type: habit.isCompleted ? TextDecorationType.LineThrough : TextDecorationType.None
})
Blank()
if (!habit.isCompleted) {
Button('打卡')
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor('#4CAF50')
.borderRadius(16)
.height(28)
.onClick(() => {
this.checkIn(index)
})
}
}
.width('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
}
.margin({ top: 8, left: 16, right: 16 })
})
}
.layoutWeight(1)
.margin({ top: 16 })
}
.width('100%')
.height('100%')
}
.title('每日打卡')
.titleMode(NavigationTitleMode.Mini)
}
private loadData(): void {
// 从DataShare加载数据
this.habits = [
{ name: '早起', isCompleted: false },
{ name: '运动', isCompleted: true },
{ name: '阅读', isCompleted: false },
{ name: '冥想', isCompleted: true },
{ name: '写日记', isCompleted: false }
]
this.streakDays = 7
}
private checkIn(index: number): void {
this.habits[index].isCompleted = true
// 保存到DataShare
// 更新所有卡片
hilog.info(0x0000, 'Index', `打卡: ${this.habits[index].name}`)
}
private getCompletedCount(): number {
return this.habits.filter(h => h.isCompleted).length
}
}
interface HabitItem {
name: string
isCompleted: boolean
}
第七步:module.json5完整配置
{
"module": {
"name": "entry",
"type": "entry",
"installationFree": true,
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:icon",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"launchType": "singleton"
}
],
"extensionAbilities": [
{
"name": "CheckInFormAbility",
"srcEntry": "./ets/formability/CheckInFormAbility.ets",
"description": "$string:FormAbility_desc",
"icon": "$media:icon",
"label": "$string:FormAbility_label",
"type": "form",
"metadata": [
{
"name": "ohos.extension.form",
"resource": "$profile:form_config"
}
]
},
{
"name": "CheckInDataShare",
"srcEntry": "./ets/datashare/CheckInDataShare.ets",
"type": "dataShare",
"uri": "datashare:///com.example.checkin/checkin_data",
"grantPermission": {
"readPermission": "ohos.permission.READ_CHECKIN_DATA",
"writePermission": "ohos.permission.WRITE_CHECKIN_DATA"
}
}
],
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.READ_CHECKIN_DATA"
},
{
"name": "ohos.permission.WRITE_CHECKIN_DATA"
}
]
}
}
踩坑与注意事项
坑1:静态卡片和动态卡片不能共用formName
一个formName只能对应一种卡片类型。如果你在form_config.json里给静态卡片和动态卡片用了同一个name,编译会报错。
坑2:动态卡片的postCardAction必须在onClick里
动态卡片的交互必须通过postCardAction,而且只能在onClick回调里调用。你不能在onAppear、定时器等地方调用。
坑3:DataShare查询是异步的
FormAbility的onUpdateForm是同步回调,但DataShare查询是异步的。你不能在onUpdateForm里await查询结果。必须用缓存层——后台定时从DataShare读取数据到缓存,onUpdateForm直接读缓存。
坑4:发布前检查清单
发布元服务前,检查以下项目:
- [ ] form_config.json中所有卡片都声明了至少2种尺寸
- [ ] 4×4卡片没有大面积空白
- [ ] 卡片数据更新间隔不低于5分钟
- [ ] 动态卡片内存占用不超过10MB
- [ ] 卡片在深色模式下可读
- [ ] 卡片点击跳转正常工作
- [ ] 卡片删除后资源正确清理
- [ ] installationFree设为true
- [ ] 包体积不超过10MB
坑5:调试技巧
卡片调试比普通App麻烦得多。几个实用技巧:
- 用hilog打日志:FormAbility里用
hilog.info()打日志,在DevEco Studio的Log窗口过滤查看 - 实机调试:卡片预览器和真机效果可能不一致,必须用真机验证
- 手动触发更新:在DevEco Studio的命令行里用
hdc shell aa force-update <formId>手动触发卡片更新 - 查看卡片列表:用
hdc shell aa dump-card-list查看当前所有活跃卡片
HarmonyOS 6适配说明
-
服务中心审核标准更新:6.0对元服务的审核更严格,卡片必须满足以下条件才能通过:
- 至少2种尺寸
- 4×4卡片信息量不少于4个数据项
- 卡片加载时间不超过2秒
- 卡片崩溃率低于0.1%
-
卡片性能评分:6.0的服务中心会根据卡片性能给出评分,评分影响推荐权重。评分指标:渲染速度、内存占用、更新频率、崩溃率。
-
一键发布:6.0的DevEco Studio支持一键打包发布元服务到应用市场,不再需要手动配置签名和上传。
-
卡片A/B测试:6.0支持卡片的A/B测试——你可以同时发布两种卡片设计,系统自动分配给不同用户,收集数据后选择效果更好的版本。
总结
这篇实战把前面九篇文章的知识点串成了一个完整项目。从项目架构、数据模型、卡片布局、FormAbility、UIAbility、数据共享到发布检查,覆盖了元服务开发的全流程。
核心要点回顾:
- 项目架构:UIAbility + FormAbility + DataShare三层分离
- 静态卡片适合信息展示,动态卡片适合交互操作
- 数据共享用DataShare,FormAbility用缓存层读取
- 交互用router跳转和message消息
- 发布前检查清单:尺寸、信息量、内存、深色模式、包体积
- 调试靠hilog和实机,预览器不可靠
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐⭐ 综合运用所有知识点,需要实战经验 |
| 使用频率 | ⭐⭐⭐⭐⭐ 每个元服务项目都要走这个流程 |
| 重要程度 | ⭐⭐⭐⭐⭐ 不实战,前面学的都是纸上谈兵 |
系列完结:从601到610,十篇文章带你从元服务概念到完整实战。元服务是鸿蒙生态的核心入口形态,掌握它,你就掌握了鸿蒙应用开发的半壁江山。下一篇系列,我们聊另一个鸿蒙核心能力——分布式技术。敬请期待。
- 点赞
- 收藏
- 关注作者
评论(0)