查看: 44|回复: 3

鸿蒙互动卡片开发实践:事件驱动交互与多尺寸布局适配

[复制链接]
发表于 23 分钟前 | 显示全部楼层 |阅读模式
把玩法搬到桌面上,是 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 张,新卡插到最前:
  1. /** 稀有度权重:普通最易、传说最难(越小越稀有) */
  2. export function rarityWeight(rarity: string): number {
  3. if (rarity === '普通') return 6;
  4. if (rarity === '稀有') return 3;
  5. return 1; // 传说
  6. }
  7. /** 最近抽到列表最多保留 5 张,新卡插到最前 */
  8. export function pushRecent(recent: string[], id: string): string[] {
  9. const next = [id, ...recent.filter(x => x !== id)];
  10. return next.slice(0, 5);
  11. }
  12. export function drawOne(state: CollectionState): CollectionState {
  13. const missing = ALL_POSTCARDS.filter(p => !state.ownedIds.includes(p.id));
  14. if (missing.length === 0) return { ...state }; // 已集齐
  15. let totalWeight = 0;
  16. for (const p of missing) totalWeight += rarityWeight(p.rarity);
  17. let rnd = Math.random() * totalWeight;
  18. let pick = missing[0];
  19. for (const p of missing) { // 加权随机抽样
  20. rnd -= rarityWeight(p.rarity);
  21. if (rnd <= 0) { pick = p; break; }
  22. }
  23. const alreadyOwned = state.ownedIds.includes(pick.id);
  24. const ownedIds = alreadyOwned ? state.ownedIds : [...state.ownedIds, pick.id];
  25. const duplicates = { ...state.duplicates };
  26. if (alreadyOwned) duplicates[pick.id] = (duplicates[pick.id] ?? 0) + 1;
  27. return {
  28. ownedIds, featuredId: pick.id, duplicates,
  29. exchangeCount: state.exchangeCount,
  30. totalDraws: state.totalDraws + 1,
  31. recent: pushRecent(state.recent, pick.id) // 最近抽到,最多 5 张
  32. };
  33. }
复制代码

集换的逻辑是:消耗一张重复卡(优先消耗数量最多的那张),把目标城市加入收藏,并累加集换次数。
  1. export function exchange(state: CollectionState, targetId: string): CollectionState {
  2. if (state.ownedIds.includes(targetId)) return { ...state }; // 已拥有
  3. if (totalSpare(state) <= 0) return { ...state }; // 没筹码可换
  4. const duplicates = { ...state.duplicates };
  5. let best = '', bestN = 0;
  6. for (const k of Object.keys(duplicates)) {
  7. if (duplicates[k] > bestN) { bestN = duplicates[k]; best = k; }
  8. }
  9. duplicates[best] = bestN - 1;
  10. if (duplicates[best] <= 0) delete duplicates[best];
  11. return {
  12. ownedIds: [...state.ownedIds, targetId], featuredId: targetId,
  13. duplicates, exchangeCount: state.exchangeCount + 1,
  14. totalDraws: state.totalDraws, recent: pushRecent(state.recent, targetId)
  15. };
  16. }
  17. /** 统一入口:卡片与页面都只发指令字符串,规则全在这里 */
  18. export function applyAction(state: CollectionState, msg: string): CollectionState {
  19. if (msg === 'draw') return drawOne(state);
  20. if (msg === 'reset') return emptyState();
  21. if (msg.startsWith('exchange:')) {
  22. return exchange(state, msg.slice('exchange:'.length));
  23. }
  24. return { ...state };
  25. }
复制代码

把"抽一张""集换某城市""重置"统统归一成指令字符串,再由 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 互动卡片"轻交互、重联动"的设计意图。
回复

使用道具 举报

发表于 13 分钟前 | 显示全部楼层

Re: 鸿蒙互动卡片开发实践:事件驱动交互与多尺寸布局适配

楼主的架构思路挺清楚的,纯函数层加两个视图共享同一份状态,这个"卡片和页面都只是 CollectionState 的视图"的定位比很多教程里把逻辑塞进 form 的做法干净多了,后面加尺寸加成就也不用动规则,这点很赞。 不过看代码有两处想跟楼主确认一下: 一个是 drawOne 里 missing 已经把已拥有的卡过滤掉了,所以 pick 必然不在 ownedIds 里,下面那个 alreadyOwned 分支其实永远走不到,重复卡也就永远产生不出来。但玩法里又需要重复卡去集换,这里是不是中间还漏了一段,比如抽卡时先从全量池里抽、再判断是否已有?还是说重复卡另有一条获取途径没贴出来? 另一个是 exchange 里消耗重复卡那段,duplicates 被直接赋成了 bestN - 1 这个数字,后面 delete duplicates 也是作用在数字上,看着像是排版时丢了下标,原意应该是 duplicates = bestN - 1 吧,顺手提一句以免别人照抄踩坑。 另外标题说的 2×2 和 2×4 多尺寸布局适配,正文里只在玩法部分提了 2×4 多展示两个字段,具体是怎么做的?是按 formDimension 在 build 里分支,还是用栅格或相对布局让同一套结构自适应?这块其实是最容易在真机上翻车的,尤其 2×4 横向空间变大后文字截断和卡片圆角处理,挺想听听楼主的做法。
回复 支持 反对

使用道具 举报

发表于 13 分钟前 | 显示全部楼层

Re: 鸿蒙互动卡片开发实践:事件驱动交互与多尺寸布局适配

思路挺清楚的,把规则收敛到 PostcardModel 纯函数层、卡片和页面只做两个视图,这个分层确实省事,后面加尺寸或成就墙基本不用动核心逻辑。pushRecent 那个先过滤再插到最前的写法也很干净,不用额外判重。 不过代码里有几处想跟你确认下,可能是我理解得不对: 一是 drawOne 里 missing 是从“未拥有”里筛的,那 pick 必然是没拥有的卡,下面的 alreadyOwned 判断就恒为 false,重复卡分支实际上永远走不到。按你前面说的“抽到已拥有的记为重复卡”,抽卡池应该用全量 ALL_POSTCARDS 才对吧?否则集换交易所就没筹码可用了。 二是 exchange 里遍历 duplicates 的 `if (duplicates > bestN)` 看着像漏了下标,应该是 `duplicates > bestN`;后面 `duplicates = bestN - 1` 也不太对,常量对象这么赋值会报错,估计本意是 `duplicates = bestN - 1`。 三是帖子末尾 reset 那个分支好像被截断了,applyAction 没写完,方便的话可以补上。 其他部分挺受用的,尤其“卡片不自己维护状态、读写统一走 Store”这条,多人协作时能避免不少状态不同步的坑。2×2 和 2×4 的差异、以及 updateForm 刷新时机要以真机为准的提
回复 支持 反对

使用道具 举报

发表于 13 分钟前 | 显示全部楼层

Re: 鸿蒙互动卡片开发实践:事件驱动交互与多尺寸布局适配

这套"纯逻辑层 + 两个视图 + 统一持久化"的切分我很认同,卡片和页面都不持有状态,规则全部集中在 PostcardModel 里,这样以后加尺寸、加成就墙确实不用再动规则。把 applyAction 做成字符串指令的统一入口也很省心,onFormEvent 回传什么就交给它处理,卡片端不用关心业务逻辑。 不过代码这块我有几点想和你探讨,可能是笔误,也可能是真有逻辑问题: 第一,抽卡这里的 missing 已经是过滤掉已拥有之后的池子,所以 pick 一定不在 ownedIds 里,alreadyOwned 恒为 false,duplicates 永远不会被加一。这样一来"抽到重复卡"的路径其实是不存在的,集换交易所永远没筹码可用,整条集换链路就成了死代码。如果本意是"抽到重复转重复卡",应该从全量池里加权抽,抽完再判断是否已拥有,成本和稀有权重可以分开算;如果本意是"未集齐前只出未拥有的新卡,集齐后才出重复",那也建议在 UI 上明确说明这个规则,不然用户会觉得集换功能是坏的。 第二个是 exchange 里的写法:const duplicates 后面又 duplicates = bestN - 1 重新赋值,这在 TS 里会直接报错吧;另外 for 循环里 if (duplicates > bestN) 少了下标,应该是 duplicates,下面那行 duplicates
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-8 00:23 , Processed in 0.022721 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部