HarmonyOS 6.1: Live Cooking Progress Card via FormExtensionAbility & Cross-Process Bridge
This article details building a dynamic HarmonyOS service card that shows real-time cooking progress on the desktop, using FormExtensionAbility in API 23 with a main-process push architecture and Preferences-based cross-process data bridge to overcome process isolation.
Introduction: From Opening App to Glancing at Desktop
The "Lingxi Kitchen" app previously integrated atomic services for recipe recommendations, but users still had to open the app to check cooking progress (oven timer, induction cooker step). This article demonstrates creating a desktop "Cooking Progress Card" using HarmonyOS 6.1.0 (API 23) FormExtensionAbility service card capability, enabling second-level refresh without opening the app.
Core Principle: API 23's Three-Body Isolation & Push Architecture
API 23 introduces strict process isolation. Two common outdated approaches no longer work:
"Card self-driven" is dead : Using @Watch to trigger postCardAction in a loop is flagged as malicious high-frequency IPC and silently dropped.
"FormAbility callback bridge" fails : EntryFormAbility and EntryAbility run in completely separate processes (different PIDs). Storing formId in a singleton in FormAbility gives the main app an empty shell; data cannot be pushed.
The only legitimate architecture: Local DB cross-process bridge + Main process forced push .
FormAbility downgraded : Only extracts real formId from Want (key: ohos.extra.param.key.form_identity) and writes to Preferences. Returns static hard-coded data; no business logic imports.
Main app empowered : EntryAbility reads formId from Preferences on foreground, starts a 1-second timer, and pushes business snapshots via formProvider.updateForm().
Card UI pure : Uses @LocalStorageProp only; no @Watch, no new LocalStorage(), no system vector icons (cause C++ crashes).
Layered Architecture: Data Flow Panorama
Following the app's four-layer architecture, two key data flows exist:
ID Registration Flow : Card process writes formId to Preferences in onAddForm, cleans up in onRemoveForm.
Data Push Flow : Main app reads IDs cross-process from Preferences, gets cooking snapshot from Business layer, pushes via formProvider.updateForm to system, rendering on desktop card. No direct call between Card UI and FormAbility; all communication via system layer and local DB.
Key Implementation Steps
Step 1: module.json5 & form_config.json Declaration
Declare extension ability with type: "form" pointing to form_config.json. Critical config:
{
"forms": [{
"name": "CookingProgressCard",
"isDynamic": true,
"updateEnabled": true,
"updateDuration": 0,
"defaultDimension": "2*2",
"formConfigAbility": "ability://EntryFormAbility"
}]
}isDynamic: true enables system-level dynamic refresh; updateDuration: 0 hands refresh rhythm entirely to main app. Both are prerequisites for "main process forced push".
Step 2: Card UI Layout (Detox & Rebuild)
WidgetCard.etsmust avoid:
❌ let storage = new LocalStorage(); @Entry(storage) — cuts system data channel
❌ SymbolGlyph($r('sys.symbol.timer')) — triggers card process C++ crash
❌ @Watch + postCardAction loop — throttled by API 23
Correct pattern: Use @LocalStorageProp to receive data; replace system icons with plain Emoji (e.g., ⏱️). @Entry must have no parameters.
@Entry
@Component
struct WidgetCard {
@LocalStorageProp('RECIPE_NAME') recipeName: string = 'Not Cooking';
@LocalStorageProp('STEP_TEXT') stepText: string = 'No Step';
@LocalStorageProp('STEP_PROGRESS') stepProgress: string = '-/-';
@LocalStorageProp('TIMER_LABEL') timerLabel: string = 'Timer';
@LocalStorageProp('TIMER_SECONDS') timerSeconds: number = 0;
@LocalStorageProp('TIMER_DISPLAY') timerDisplay: string = '--:--';
@LocalStorageProp('IS_COOKING') isCooking: boolean = false;
@LocalStorageProp('DEVICE_NAME') deviceName: string = '';
build() {
Stack() {
Column() {
// ... layout omitted
Row() {
Text('⏱️').fontSize(13)
Text(this.timerDisplay).fontSize(18)
}
}
}.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { targetPage: 'KitchenDevicePage' }
});
})
}
}@LocalStorageProp is the only legal way for card UI to receive data — system auto-injects formBindingData fields into same-named properties. @Entry must never receive a LocalStorage parameter. Emoji replaces SymbolGlyph to avoid C++ crash.
Step 3: EntryFormAbility — Pure Registrar
No business callbacks, no data push. Sole mission: get ID, store locally, return safe static data.
import { formBindingData, FormExtensionAbility } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
const FORM_KEY = 'FORM_IDS';
export default class EntryFormAbility extends FormExtensionAbility {
onAddForm(want: Want): formBindingData.FormBindingData {
// API 23: real ID hidden in form_identity
const formId: string = (want.parameters?.['ohos.extra.param.key.form_identity'] as string) ?? '';
if (formId.length > 0) {
this.saveFormId(formId); // async write, non-blocking
}
// Return pure static hard-coded data
return formBindingData.createFormBindingData({
'RECIPE_NAME': 'Waiting...', 'STEP_TEXT': '', 'STEP_PROGRESS': '-/-',
'TIMER_LABEL': '', 'TIMER_SECONDS': 0, 'TIMER_DISPLAY': '--:--',
'IS_COOKING': false, 'DEVICE_NAME': ''
});
}
onRemoveForm(formId: string): void {
this.removeFormId(formId);
}
private async saveFormId(formId: string): Promise<void> {
const pref = await preferences.getPreferences(this.context, 'widget_store');
const oldStr: string = await pref.get(FORM_KEY, '[]') as string;
let ids: string[] = JSON.parse(oldStr) as string[];
if (!ids.includes(formId)) {
ids.push(formId);
await pref.put(FORM_KEY, JSON.stringify(ids));
await pref.flush();
}
}
private async removeFormId(formId: string): Promise<void> {
const pref = await preferences.getPreferences(this.context, 'widget_store');
const oldStr: string = await pref.get(FORM_KEY, '[]') as string;
let ids: string[] = JSON.parse(oldStr) as string[];
ids = ids.filter((id: string) => id !== formId);
await pref.put(FORM_KEY, JSON.stringify(ids));
await pref.flush();
}
}Deduplication in saveFormId prevents duplicate records from multiple onAddForm triggers. removeFormId cleans up when user removes card. No business module imports; static data avoids cross-process dependency crashes.
Step 4: EntryAbility — Forced Push Brain
Main app takes over. In onForeground, reads DB for IDs, starts timer. Introduces wasActive state machine to precisely capture "timer just ended" moment.
// EntryAbility.ets core push logic
import { formProvider, formBindingData } from '@kit.FormKit';
import { preferences } from '@kit.ArkData';
private timerHandle: number = -1;
private formIds: string[] = [];
private wasActive: boolean = false;
const FORM_KEY = 'FORM_IDS';
// Read DB for IDs
private async loadFormIds(): Promise<void> {
const pref = await preferences.getPreferences(this.context, 'widget_store');
// Avoid getAll/getAllKeys; use raw get for JSON string
const idsStr: string = await pref.get(FORM_KEY, '[]') as string;
this.formIds = JSON.parse(idsStr) as string[];
}
// Start forced push timer
private startPushTimer(): void {
if (this.timerHandle !== -1) return;
this.timerHandle = setInterval(() => {
if (this.formIds.length === 0) return; // silent wait, never suicide
const snapshot = cookingProgressManager.getSnapshot();
if (cookingProgressManager.isActive) {
this.wasActive = true;
this.pushDataToForms(snapshot); // normal push
} else if (this.wasActive) {
// Precise capture: cooking last second, now stopped
this.wasActive = false;
this.pushDataToForms(snapshot);
this.stopPushTimer();
}
}, 1000);
}
// NOTE: onBackground must NEVER call stopPushTimer! Cooking in background must keep pushing.wasActive logic: only when "was cooking last second ( wasActive=true ) + stopped this second ( isActive=false )" triggers final reset push then stops timer. If never cooking ( wasActive=false and isActive=false ), timer stays silent waiting — user may start next dish anytime. onBackground must not call stopPushTimer() , else card becomes a tombstone when user checks desktop.
Step 5: CookingProgressManager — Smart Step Advance & Auto-Off
Beyond 1-second polling, adds two features:
private startPolling(): void {
this.pollingHandle = setInterval(() => {
// Auto-off: detect device stop, auto clean state
if (this.activeDeviceId.length > 0) {
const device = kitchenDeviceSimulator.getDevice(this.activeDeviceId);
if (!device || (device.timerSeconds <= 0 && device.status !== DeviceStatus.WORKING)) {
this.stopCooking(); // isActive becomes false, triggers main app timer stop
return;
}
}
}, 1000);
}
// Smart step advance (in getSnapshot)
if (this.targetTimerSeconds > 0 && this.totalSteps > 0 && this.currentRecipeId > 0) {
const elapsed: number = this.targetTimerSeconds - timerSeconds;
if (elapsed > 0) {
// Auto-calculate current step by elapsed/total ratio
this.currentStepIndex = Math.min(
Math.floor((elapsed / this.targetTimerSeconds) * this.totalSteps),
this.totalSteps - 1
);
}
}Smart step advance solves "user forgets to flip page" — e.g., 15-min timer, 6 steps → auto-advance every 2.5 minutes. Auto-off detects device timer zero and non-working status, calls stopCooking() , flips isActive to false, letting EntryAbility 's wasActive state machine capture and stop push.
Code Change Checklist
File: entry/src/main/ets/widget/pages/WidgetCard.ets — New/Modified: New — Responsibility: 2×2 card UI, pure display, Emoji instead of system icons
File: entry/src/main/ets/entryformability/EntryFormAbility.ets — New/Modified: New — Responsibility: Pure registrar: extract ID → write DB → return static data
File: entry/src/main/ets/entryability/EntryAbility.ets — New/Modified: Modified — Responsibility: Push brain: read DB → 1s timer → updateForm push
File: entry/src/main/ets/business/CookingProgressManager.ets — New/Modified: Modified — Responsibility: Smart step advance + auto-off mechanism
File: entry/src/main/resources/base/profile/form_config.json — New/Modified: New — Responsibility: Card metadata config
Hard-Earned Pitfall Summary (API 23 Must-Read)
Symptom: Card added but never refreshes — Root Cause: postCardAction loop throttled silently by system — Solution: Abandon card self-drive; use main app EntryAbility forced push
Symptom: Main app pushes but no effect — Root Cause: FormAbility and EntryAbility in separate processes; singleton fails — Solution: FormAbility writes ID to local Preferences; main app reads DB and pushes
Symptom: Compile errors: missing modules/attributes — Root Cause: API 23 removed/changed TS declarations for formHost, getAllKeys, etc. — Solution: Drop high-level APIs; only use basic pref.get('KEY', '[]') for JSON string
Symptom: Simulator right-click add card shows no logs — Root Cause: Simulator's "right-click menu add" uses static image fallback — Solution: Must use real-device gesture: long-press icon, drag up, release
Symptom: Card process shows (Dead) — Root Cause: SymbolGlyph($r('sys.symbol.timer')) causes C++ out-of-bounds crash — Solution: Strictly forbid system vector icons in card UI; replace with plain Emoji
Design Decisions
Decision: Data flow direction — Choice: Main app forced push — Rationale: API 23 completely blocks card-initiated IPC; main push is only legal & efficient path
Decision: Cross-process communication — Choice: Local Preferences DB — Rationale: formHost types broken; basic pref.get() cross-process JSON string most reliable
Decision: Card UI components — Choice: Plain text Emoji — Rationale: SymbolGlyph crashes card process; 2×2 space Emoji clear enough
Decision: End-state handling — Choice: State machine precise capture ( wasActive) — Rationale: Multiple overlapping timers: simple isActive check causes "premature suicide" or "missed last frame"
Decision: Background push strategy — Choice: Never kill timer on background — Rationale: Cooking scenario requires background push; state machine auto-cleans after timer zero
Run Verification Results
Deploy to simulator/device (series uses phone simulator).
Long-press app icon, select "Card" from menu, add to desktop.
Click service card, pick a recipe, pick a kitchen device (e.g., induction cooker with timer).
Send app to background; observe desktop card: current step, progress bar, countdown, status update normally.
When countdown ends, service card returns to initial state.
Summary & Next Episode Preview
Based on real HarmonyOS 6.1.0 (API 23) behavior, this article overturns old callback-bridge documentation and rebuilds the desktop cooking progress card. The "main process forced push + local DB cross-process bridge" architecture solves card non-refresh, cross-process data loss, and timer-zero-not-reset issues. EntryFormAbility becomes pure "registrar", using form_identity to avoid API 23 extraction pitfalls, safely persisting IDs. EntryAbility carries forced push flag, with wasActive state machine achieving "precise push while cooking, perfect cleanup with zero power after". WidgetCard clears mines, removing C++-crashing system components, becoming a stable pure display panel.
Now, while searing steak, you only need to glance at the desktop card to know oven seconds left, whether to flip or serve.
Next episode : "Multimedia: Embedding Teaching Video with AVPlayer". We'll embed cooking tutorial videos in recipe detail page, supporting picture-in-picture — letting users watch chef demo in a small window while chopping. Stay tuned!
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
51CTO HarmonyOS Developer Community
The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
