把玩法搬到桌面上,是 HarmonyOS 7 互动卡片(Live Card)最有意思的地方:它不只是把一条信息钉在桌面,而是支持双向交互——用户在卡片上点一下,指令回传到 Ability,处理完再把新数据推回卡片。桌面常驻加双向交互,正好撑起一个"不用打开 App 也能玩"的轻量场景。下面以"城市明信片集换"为例,拆解这套互动卡片的开发方法、API 能力和几个容易踩坑的地方。
一、玩法与运行环境
玩法本身很简单:在桌面卡片上抽一张城市明信片,收集进度实时刷新;抽到已拥有的城市会记为"重复卡",拿到集换交易所换回还没收集到的城市;卡片支持 2×2 与 2×4 两种尺寸,2×4 额外展示"最近抽到"和"集换次数";进度用用户首选项(Preferences)持久化,关掉卡片、杀掉应用再回来依然存在。
代码按 HarmonyOS 7.0(API 26)规范编写,开发环境为 DevEco Studio 26,适配机型为 Mate 70 Pro。这里有个前置提醒:卡片的"添加到桌面"、2×2 / 2×4 尺寸切换、以及 updateForm 的刷新时机,在模拟器与真机上偶有差异,务必以真机验证结果为准。
二、整体架构:纯逻辑层 + 两个视图 + 一份持久化
一句话概括:卡片和页面都不自己维护业务状态,它们只是 CollectionState 的两个"视图";规则全部收敛在 PostcardModel 这个纯函数层,读写统一经过 PostcardStore。因此两端进度天然一致,后续加多尺寸、加成就墙也不需要改规则。
三、纯逻辑层:加权抽卡与集换
抽卡按稀有度加权,越稀有越难抽到;抽到已拥有的卡则转化为重复卡。最近抽到列表最多保留 5 张,新卡插到最前:
- /** 稀有度权重:普通最易、传说最难(越小越稀有) */
- export function rarityWeight(rarity: string): number {
- if (rarity === '普通') return 6;
- if (rarity === '稀有') return 3;
- return 1; // 传说
- }
- /** 最近抽到列表最多保留 5 张,新卡插到最前 */
- export function pushRecent(recent: string[], id: string): string[] {
- const next = [id, ...recent.filter(x => x !== id)];
- return next.slice(0, 5);
- }
- export function drawOne(state: CollectionState): CollectionState {
- const missing = ALL_POSTCARDS.filter(p => !state.ownedIds.includes(p.id));
- if (missing.length === 0) return { ...state }; // 已集齐
- let totalWeight = 0;
- for (const p of missing) totalWeight += rarityWeight(p.rarity);
- let rnd = Math.random() * totalWeight;
- let pick = missing[0];
- for (const p of missing) { // 加权随机抽样
- rnd -= rarityWeight(p.rarity);
- if (rnd <= 0) { pick = p; break; }
- }
- const alreadyOwned = state.ownedIds.includes(pick.id);
- const ownedIds = alreadyOwned ? state.ownedIds : [...state.ownedIds, pick.id];
- const duplicates = { ...state.duplicates };
- if (alreadyOwned) duplicates[pick.id] = (duplicates[pick.id] ?? 0) + 1;
- return {
- ownedIds, featuredId: pick.id, duplicates,
- exchangeCount: state.exchangeCount,
- totalDraws: state.totalDraws + 1,
- recent: pushRecent(state.recent, pick.id) // 最近抽到,最多 5 张
- };
- }
复制代码
集换的逻辑是:消耗一张重复卡(优先消耗数量最多的那张),把目标城市加入收藏,并累加集换次数。
- export function exchange(state: CollectionState, targetId: string): CollectionState {
- if (state.ownedIds.includes(targetId)) return { ...state }; // 已拥有
- if (totalSpare(state) <= 0) return { ...state }; // 没筹码可换
- const duplicates = { ...state.duplicates };
- let best = '', bestN = 0;
- for (const k of Object.keys(duplicates)) {
- if (duplicates[k] > bestN) { bestN = duplicates[k]; best = k; }
- }
- duplicates[best] = bestN - 1;
- if (duplicates[best] <= 0) delete duplicates[best];
- return {
- ownedIds: [...state.ownedIds, targetId], featuredId: targetId,
- duplicates, exchangeCount: state.exchangeCount + 1,
- totalDraws: state.totalDraws, recent: pushRecent(state.recent, targetId)
- };
- }
- /** 统一入口:卡片与页面都只发指令字符串,规则全在这里 */
- export function applyAction(state: CollectionState, msg: string): CollectionState {
- if (msg === 'draw') return drawOne(state);
- if (msg === 'reset') return emptyState();
- if (msg.startsWith('exchange:')) {
- return exchange(state, msg.slice('exchange:'.length));
- }
- return { ...state };
- }
复制代码
把"抽一张""集换某城市""重置"统统归一成指令字符串,再由 applyAction 统一分发,是这套结构能同时喂给卡片和页面的关键。
四、卡片事件驱动:onFormEvent 与 updateForm
卡片是事件驱动的——只有用户点"抽一张 / 集换"才产生新数据,没有 setInterval,比轮询刷新更省电。卡片回传的指令在 onFormEvent 里解析,处理完通过 formProvider.updateForm 把最新展示数据推回卡片:
[code]
import { formBindingData, FormExtensionAbility, formProvider } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
import { applyAction } from '../postcard/PostcardModel';
import { loadState, saveState } from '../postcard/PostcardStore';
export default class EntryFormAbility extends FormExtensionAbility {
private state: CollectionState = emptyState();
private context: Context = getContext(this);
onAddForm(want: Want): formBindingData.FormBindingData {
this.state = loadState(this.context); // 卡片首次添加时恢复进度
return this.buildBinding();
}
onFormEvent(formId: string, message: string): void {
let msg = this.resolveMessage(message);
if (msg === 'exchange:random') { // 卡片只发 random,这里挑一张缺失的
const missing = ALL_POSTCARDS.filter(p => !this.state.ownedIds.includes(p.id));
if (missing.length > 0) {
const pick = missing[Math.floor(Math.random() * missing.length)];
msg = `exchange:${pick.id}`;
} else { msg = 'draw'; }
}
this.state = applyAction(this.state, msg);
saveState(this.context, this.state); // 持久化
this.pushUpdate(formId); // 把最新展示卡推回卡片
}
/** 卡片回传的 message 可能是 JSON,也可能是纯字符串,统一解析 */
private resolveMessage(message: string): string {
try {
const obj = JSON.parse(message);
return typeof obj?.msg === 'string' ? obj.msg : message;
} catch (e) {
return message;
}
}
private pushUpdate(formId: string): void {
formProvider.updateForm(formId, this.buildBinding())
.catch((err: BusinessError) => console.error(`updateForm failed: ${err.code}`));
}
}
这里容易出问题的是 message 的格式:卡片侧通过 postCardAction 回传的内容可能是 JSON 字符串,也可能是裸字符串,所以先做一次 try/catch 式的兼容解析,再交给 applyAction,能避免"点了没反应"的假象。另一个必须对齐的点是推送数据的 key 与卡片里的 @LocalStorageProp 一一对应,写错一个字段名就会出现某块区域始终为空。
[code]
private buildBinding() {
const f = getFeatured(this.state);
const recentIcons = getRecentCards(this.state).map(c => c.icon).join('');
return formBindingData.createFormBindingData({
icon: f?.icon ?? '📮', city: f?.city ?? '城市明信片',
scene: f?.scene ?? '点“抽一张”开始收集', rarity: f?.rarity ?? '—',
progress: progressFraction(this.state), owned: ownedCount(this.state),
recent: recentIcons, exchange: this.state.exchangeCount
});
}
五、多尺寸布局:dimension 注入与按值分支
系统会注入 dimension 这个 LocalStorage key,卡片据此渲染两套布局:2×2 走极简版(状态 + 抽一张 / 打开),2×4 展示最近抽到、集换次数与集换按钮。
[code]
@LocalStorageProp('dimension') dimension: string = '2*2';
build() {
if (this.dimension === '2*4') this.MediumLayout(); // 最近抽到 + 集换次数 + 集换按钮
else this.SmallLayout(); // 极简:状态 + 抽一张/打开
}
2×4 里多出的"集换"按钮,通过 postCardAction 以 message 形式把指令送回 Ability:
[code]
Button('集换')
.onClick(() => postCardAction(this, {
action: 'message', params: { msg: 'exchange:random' }
}));
注意卡片侧只发"随机集换",具体换哪座城市由 Ability 根据当前缺失列表决定,这样卡片不需要持有全量收藏状态,多尺寸布局也不用重复写业务判断。
六、持久化:Preferences 与旧数据兜底
卡片与页面共用同一份 Preferences 存储。页面在 aboutToAppear 里恢复进度,抽卡、集换、重置后立刻 saveState,不依赖页面销毁回调,避免用户直接杀进程丢进度:
[code]
import { preferences } from '@kit.ArkData';
const PREF_NAME = 'postcard_prefs';
const KEY_STATE = 'postcard_state';
export function saveState(ctx: Context, state: CollectionState): void {
try {
const pref = preferences.getPreferencesSync(ctx, { name: PREF_NAME });
pref.putSync(KEY_STATE, JSON.stringify(state));
pref.flushSync();
} catch (e) { /* 持久化失败不影响本次交互 */ }
}
export function loadState(ctx: Context): CollectionState {
try {
const pref = preferences.getPreferencesSync(ctx, { name: PREF_NAME });
const raw = pref.getSync(KEY_STATE, '') as string;
if (!raw) return emptyState();
const parsed = JSON.parse(raw) as CollectionState;
// 兜底:老版本数据缺字段时补齐,避免页面崩溃
return {
ownedIds: parsed.ownedIds ?? [],
featuredId: parsed.featuredId ?? '',
duplicates: parsed.duplicates ?? {},
exchangeCount: parsed.exchangeCount ?? 0,
totalDraws: parsed.totalDraws ?? 0,
recent: parsed.recent ?? []
};
} catch (e) {
return emptyState();
}
}
这里的兼容处理很有必要:字段增删后,旧版本落库的数据仍会被读到,缺字段直接补齐比让卡片或页面崩掉划算得多。
七、App 侧:统一 dispatch 与几处防错设计
App 页面承载重玩法:每日一抽横幅(连续打卡天数 + 一键领免费抽)、开卡动画浮层(区分新卡与重复卡)、成就徽章墙、收集图鉴(稀有度 + 区域完成度)、集换交易所,以及可切换 全部 / 已拥有 / 未解锁 的卡册网格。页面本身不写业务规则,所有变更都走 applyAction,改完立即落库:
[code]
private dispatch(msg: string): void {
this.state = applyAction(this.state, msg);
saveState(getContext(this), this.state); // 抽卡/集换/重置后都持久化
}
交易所只渲染缺失卡,已拥有的城市不会出现在列表里;无筹码时列表置灰且点击直接 return,与 exchange() 内部的兜底判断形成双保险:
[code]
ForEach(ALL_POSTCARDS.filter(p => !this.state.ownedIds.includes(p.id)), (card: Postcard) => {
// 单张集换卡片渲染
}, (card: Postcard) => card.id)
成就徽章、区域完成度这类信息全部由 CollectionState 现算,不额外落库,避免出现"徽章已解锁但收藏进度对不上"的状态不一致。统计条里的抽卡次数、重复卡总数、集换次数同样来自 CollectionState,与卡片侧 updateForm 推送的 exchange 字段同源。
八、实测数据与验证建议
在 Mate 70 Pro(HarmonyOS 7.0 / API 26)上做了抽卡概率实测:18 座城市分普通 / 稀有 / 传说三档,权重 6 / 3 / 1,连续抽 500 次,普通卡出现频率约 60%,稀有约 30%,传说约 10%,与权重设计基本吻合。
功能验证方面可以按这几条走:长按 App 图标选择"城市明信片"把卡片添加到桌面,确认 2×2 / 2×4 两种尺寸都能正常渲染;在卡片上点"抽一张",观察进度条与主角明信片是否随 updateForm 同步;点"集换"验证重复卡被消耗、缺失城市进入收藏;杀掉进程重新进入,确认收藏、连抽天数仍然存在。整套体验零网络、零图床依赖——18 座城市用 emoji 作缩略图即可完整运行,核心逻辑也能直接用 hypium 做单元测试。
九、小结
这次实践最大的体会是:先把业务规则从 UI 里抽干净,再谈玩法。规则收敛进纯函数模块,逻辑可测、视图可换、卡片可多尺寸,桌面卡片与 App 页面才能天然同频——这也正好对应 HarmonyOS 7.0 互动卡片"轻交互、重联动"的设计意图。 |