05|HarmonyOS资料板跨组件拖拽与状态归属【共创稿事节】
现场电气柜巡检资料板经过四篇迭代,已经能够识别拖起的卡片、按照文件类型判断能否投放、处理拖拽取消,并正确计算同栏和跨栏的插入序号。但把页面拆成附件卡片、三列容器、统计标题和详情抽屉之后,又冒出了另一类错乱:卡片看起来已经进入“设备照片”,左侧“待整理”的数量却没有减少;详情抽屉还保留着旧栏位,等下一次拖拽才一起刷新。
这不是数组插入序号算错了,而是状态归属没定清楚。拖拽源组件、目标栏组件和详情组件各自修改了一份列表,界面短时间内出现多个“事实版本”。这次要解决的重点是把所有正式变更收口到同一个资料板状态容器。源组件只提供cardId,目标组件只提出意图,只有统一的BoardStore负责校验、应用变更和提高修订号。

把事件与业务命令分开处理
设想资料板有三栏:待整理、设备照片、检修记录。卡片“配电柜现场.jpg”起初在待整理,文件类型image/jpeg,目标栏“设备照片”接受图片。第04篇的moveCard已经完成数组层的移除与插入,本篇不能在三个组件里各自调用一次。正确路径是源组件记录稳定卡片身份,目标组件生成带有目标栏、目标插入序号、资料板当前版本的DropIntent,由一个中心处理器提交。
如果只传“源栏第2张”,拖拽时另一个附件插入列表,序号会立即失效。卡片ID比序号稳定,栏位与索引只是此次操作的目标描述。boardRevision用于拦截在拖拽期间发生的其他变更;若版本已更新,目标栏应重新计算插入位置并提示用户,而不是悄悄覆盖最近一次整理结果。
源事件和目标事件经常不是同步触发,拖拽途中可能出现onDragLeave、页面跳转、布局尺寸变化。ArkUI的拖拽事件参考列出了onDragStart、onDragEnter、onDragMove、onDragLeave与onDrop等事件;页面适配时应依据工程所采用SDK对应版本确认签名。业务处理器不用依赖这些事件对象本身,把它们转换成普通数据结构后,规则更容易排查。
明确一处可写状态源
资料板状态由revision、cards和lanes组成。cards按卡片ID保存标题、类型和实际栏位;lanes记录各栏稳定卡片ID顺序。两处数据看似有重复,其实承担不同工作:前者用于判断文件和展示详情,后者负责顺序;因此每次移动都要由同一个提交点同时更新。只让目标栏推入卡片却忘记从源栏移除,很快就会有两个栏位都展示同一个ID。
下面把上一章的位置算法封装进集中提交函数。代码以Node.js普通对象实现,适合单独核查幂等、版本和目标栏规则;typeAllowed在项目内由第02篇已建立的附件类型规则提供。源码使用复本而不是直接修改传入对象,失败时返回原状态,便于界面保留当前布局。
const assert = require('node:assert/strict');
function applyDrop(board, intent, allowed) {
if (intent.boardRevision !== board.revision) return { ok: false, reason: 'STALE_BOARD' };
const card = board.cards[intent.cardId];
if (!card) return { ok: false, reason: 'MISSING_CARD' };
if (card.lane !== intent.sourceLane) return { ok: false, reason: 'STALE_SOURCE' };
if (!board.lanes[intent.targetLane] || !allowed(intent.targetLane, card.mime)) {
return { ok: false, reason: 'TARGET_REJECTED' };
}
const lanes = Object.fromEntries(Object.entries(board.lanes).map(([k, v]) => [k, [...v]]));
const source = lanes[card.lane];
const oldIndex = source.indexOf(card.id);
if (oldIndex < 0) throw Error('BOARD_INCONSISTENT');
source.splice(oldIndex, 1);
const end = lanes[intent.targetLane];
const adjusted = card.lane === intent.targetLane && oldIndex < intent.targetIndex
? intent.targetIndex - 1 : intent.targetIndex;
end.splice(Math.max(0, Math.min(adjusted, end.length)), 0, card.id);
return { ok: true, state: {
revision: board.revision + 1,
cards: { ...board.cards, [card.id]: { ...card, lane: intent.targetLane } }, lanes
}};
}
const board = { revision: 7, cards: { pic: { id: 'pic', lane: 'pending', mime: 'image/jpeg' } }, lanes: { pending: ['pic'], photos: [], records: [] } };
const result = applyDrop(board, { cardId:'pic', sourceLane:'pending', targetLane:'photos', targetIndex:0, boardRevision:7 }, (lane, mime) => lane === 'photos' && mime.startsWith('image/'));
assert.equal(result.state.lanes.photos[0], 'pic');
assert.equal(board.lanes.pending.length, 1);
这段函数没有直接触碰组件的装饰状态。拖拽浮层、悬停边框和目标插入线仍然属于短生命周期的交互状态;正式卡片位置则只认返回的新state。业务接受时先推进修订号,再通知列表、统计、详情等订阅者;失败时所有正式栏位保持不变,仅展示失败原因。如此便可以把“刚进入目标栏时亮起的边框”与“已落库的卡片位置”分开描述。

UI组件只提出意图,不保留第二套列表
在源卡片的拖拽开始事件中,记录sessionId、cardId以及拖拽时读取的boardRevision。交接给目标栏的不是完整卡片对象,也不是指向UI组件的引用,而是少量业务字段。目标栏根据当前指针位置算出插入位置,检查本栏类型规则,然后构造DropIntent交给容器。对于短期无法取得目标栏身份的情况,直接结束临时悬停状态即可,不能靠默认目标把图片塞进“检修记录”。
这套方案还避免另一个细节问题:跨组件重复收到同一结束事件。我们为一次提交生成requestId,容器保存已处理集合;第一次成功返回版本8,再收到同样的意图时复用结果,不会让版本变成9,更不会触发第二次插入。若版本7的另一条独立请求迟到,则按STALE_BOARD拒绝,让UI刷新后重新发起。
const assert = require('node:assert/strict');
class BoardStore {
constructor(state, apply) { this.state = state; this.apply = apply; this.receipts = new Map(); }
dispatch(intent) {
if (this.receipts.has(intent.requestId)) return this.receipts.get(intent.requestId);
const result = this.apply(this.state, intent);
if (result.ok) this.state = result.state;
this.receipts.set(intent.requestId, result);
return result;
}
}
let calls = 0;
const store = new BoardStore({ revision: 7 }, (s) => {
calls++; return { ok: true, state: { revision: s.revision + 1 } };
});
const intent = { requestId: 'drag-photo-01', boardRevision: 7 };
assert.equal(store.dispatch(intent).state.revision, 8);
assert.equal(store.dispatch(intent).state.revision, 8);
assert.equal(calls, 1);
需要区分内存去重和持久化操作身份。页面短期事件防抖可以放在容器里;若卡片移动需要保存至关系数据或网络接口,真正提交还应有持久化操作记录与回执。只在UI进程里存Set,应用重启后便不再知道同一操作是否已经提交。重放历史移动时,必须连同当时的资料板修订号核对,避免将旧布局硬塞到新版布局中。
统计和详情如何同步获得一致画面
统一状态源负责生成不可变快照,而三个栏位、顶部总数和详情抽屉从相同快照派生数据。举例:卡片从待整理移进设备照片,版本由7变8,来源计数减1,目标计数加1,详情中的栏位也读出photos。这些派生值不必分别维护新的可写数组,减少后续排查路径。
订阅者还需要成对注册和释放。界面关闭时移除监听,避免再次打开资料板后同一个变更触发两次刷新;订阅过程中若抛出异常,中心存储不应回滚已经成功的业务提交,可以记录错误并让对应组件在下一轮状态快照中恢复。
const assert = require('node:assert/strict');
function createObservableBoard(initial) {
let state = initial;
const listeners = new Set();
return {
subscribe(fn) { listeners.add(fn); fn(state); return () => listeners.delete(fn); },
commit(next) { state = next; for (const fn of [...listeners]) fn(state); },
getSnapshot() { return state; },
listenerCount() { return listeners.size; }
};
}
const board = createObservableBoard({ revision: 7, lanes: { pending:['pic'], photos:[] } });
let observed = [];
const release = board.subscribe(s => observed.push(s.revision));
board.commit({ revision: 8, lanes: { pending:[], photos:['pic'] } });
release();
board.commit({ revision: 9, lanes: { pending:[], photos:['pic'] } });
assert.deepEqual(observed, [7, 8]);
assert.equal(board.listenerCount(), 0);

从一张卡片追查不同组件的状态分歧
排查时我会沿cardId记录四个关键点:拖拽源开始时的资料板修订号,目标栏生成的投放意图,中心处理器的接受或拒绝理由,以及最后一次派发给组件的快照版本。若来源栏已减少而详情仍是旧数据,检查详情是否缓存了旧卡片对象;若目标栏卡片重复,则检查是不是两个目标组件都调用了写入方法;若重新进入页面才恢复正常,多半是组件订阅没有接收最新快照。
文件权限或来源可访问性变化仍要按第02篇的附件规则复核;类型匹配只说明目标栏允许这种资料,并不保证来源资源在稍后仍可读取。拖拽过程出现页面离开、窗口收窄或用户取消时,清理临时会话、悬停标记和局部定时器即可,不应提交正式移动。华为拖拽交互设计指导描述了同应用内移动、复制等交互情境,这里的状态容器属于资料板自身的工程设计。
还需考虑日志中是否存在敏感文件路径。用于排错的信息保留卡片ID、目标栏ID、结果码和版本号就够了,避免输出用户本地完整文件路径或者附件正文。为长时间打开的看板设置合理的变更快照缓存上限,并在离开页面时释放订阅,能降低历史闭包积累的问题。
完成这次调整后,资料板的每一次移动都有可核查的单一路径:交互组件提出意图,中心容器做版本与类型检查,生成一次新状态,再让各组件从相同版本读取展示。下一篇可以专门研究附件来源失效后的重新关联,重点不再是卡片去哪里,而是卡片指向的资源还能否被准确访问。
更多推荐

所有评论(0)