ArkTS Worker:离线相册元数据分片回传重复块合并与退场终止【鸿蒙心迹】
本文使用 MetaDock 演示工程,页面
MetadataPage,任务MD-2011。12 条IMG-001至IMG-012是人工生成的元数据记录,不访问真实相册;界面、HiLog 与回执只用于说明线程间协议,并非 DevEco 编译记录或真实设备日志。
一、日志里先出现了一个不该出现的 16
我在准备离线相册索引的演示页时,故意把总任务量设成 12 条,每次从 Worker 回传 4 条,理论上只会收到块 0、1、2。如果接收方简单地在每次 onmessage 里执行 count += chunk.length,一旦块 1 因业务重试被重复投递,页面就会从 12 变成 16。看上去像是“Worker 多算了”,其实错在宿主线程没有约定消息身份。
这不是浏览器式的线程共享数组问题。ArkTS Worker 通过消息通信传递可序列化数据,宿主线程通常拿到的是消息内容,而不是一个可任意共享修改的 UI 状态容器。Worker 的职责是生产分块,页面的职责是识别当次会话可以接收哪些分块。两个职责如果只靠“收到一条就加一次”连接,结果必然依赖偶然的投递顺序。
我给案例取名 MetaDock,把范围限制在 MD-2011 一次索引任务。会话号固定为 session=8,上一轮 session=7 的消息即使迟到也必须抛弃;块大小是 4,三个合法块完成后 indexed=12/12,接收端允许块 1 再到一次但不能让计数变成 16。场景足够小,才能把“合并”与“只是显示进度”这两个动作彻底分开。

二、用会话号和块号建立协议,不靠时间猜新旧
一条块消息需要携带 session、chunkIndex、items,结束消息需要携带总条数。不要在协议里只放百分比:百分比不知道哪些项已经真正合并,更没法判断重复投递。chunkIndex 是 0、1、2 这种确定的整数;session 是宿主线程分配的代次,不是墙上时钟,也不是 Date.now() 的随机替代品。接收端使用 Set<number> 记录已接受块的编号。
协议的另一半是输入的稳定 ID。用 IMG-001 这样的人工主键而非数组下标,后续即使改成按文件路径排序或分批补扫,也能区分“同一条又来一次”和“新条目恰好占用了旧位置”。真实项目里的文件标识应考虑权限、相册更新、路径暴露与隐私脱敏,不能直接把本例的字符串拼接当成生产级资源定位。
先明确消息类型与一个纯合并器。以下代码放到 entry/ets/common/MetaProtocol.ets,供 Worker 和页面分别导入。该函数只变更普通内存结构,不在 Worker 回调里直接拼 UI;如果块号重复或会话不符就返回 false。为简化示例,输入只存 ID 和像素宽度,避免无意义地牵涉图像解码 API。
export interface ImageMeta { id: string; width: number; }
export interface MetaChunk {
type: string;
session: number;
chunkIndex: number;
items: ImageMeta[];
}
export class ChunkMerger {
private readonly session: number;
private seen: Set<number> = new Set<number>();
private received: Map<string, ImageMeta> = new Map<string, ImageMeta>();
constructor(session: number) { this.session = session; }
accept(chunk: MetaChunk): boolean {
if (chunk.type !== 'chunk' || chunk.session !== this.session ||
chunk.chunkIndex < 0 || this.seen.has(chunk.chunkIndex)) { return false; }
this.seen.add(chunk.chunkIndex);
chunk.items.forEach((item: ImageMeta): void => {
if (item.id.length > 0) { this.received.set(item.id, item); }
});
return true;
}
count(): number { return this.received.size; }
}
这里 Map 还保护了同一块内部的重复 ID。注意计数来自去重后的键数,而非消息里宣称的数量;真正可上架的索引服务还应校验块号范围、ID 格式、宽高上限和 total 的可信来源。演示合并器只完成理解 Worker 协议所需的最小工作,故意没有实现真正的相册权限申请和文件读取。
三、Worker 负责分块,取消指令要能插进处理流程
ThreadWorker 创建以后,宿主线程用 postMessage 发出 start 指令。Worker 接受的输入是一个快照,而不是 UI 线程的 @State 数组。为了让取消消息有机会被处理,演示里的分块发送采用 setTimeout 分次推进,而不是一口气用很长的同步循环占满 Worker 事件循环。这不是性能保证,只是避免取消指令连被处理的机会都没有。
下面位于 entry/ets/workers/MetadataWorker.ets 的示例展示消息协议。12 条记录是演示输入,块大小固定为 4;生产环境不应该一次性将大批媒体内容复制进消息,而应分批传必要元数据。取消会话时清理计时器,之后的 pump 不能再继续发出块。
import { MessageEvents, ThreadWorkerGlobalScope, worker } from '@kit.ArkTS';
import { ImageMeta } from '../common/MetaProtocol';
const port: ThreadWorkerGlobalScope = worker.workerPort;
let activeSession: number = -1;
let timerId: number = -1;
port.onmessage = (event: MessageEvents): void => {
const message = event.data as { type: string; session: number };
if (message.type === 'cancel') {
if (message.session === activeSession) {
activeSession = -1;
if (timerId >= 0) { clearTimeout(timerId); timerId = -1; }
}
return;
}
if (message.type !== 'start') { return; }
if (timerId >= 0) { clearTimeout(timerId); }
activeSession = message.session;
const ids: ImageMeta[] = Array.from({ length: 12 }, (_, index: number): ImageMeta => ({
id: `IMG-${String(index + 1).padStart(3, '0')}`, width: 4000
}));
let nextIndex: number = 0;
const pump = (): void => {
if (message.session !== activeSession) { return; }
if (nextIndex >= 3) {
port.postMessage({ type: 'done', session: activeSession, total: 12 });
timerId = -1;
return;
}
port.postMessage({ type: 'chunk', session: activeSession,
chunkIndex: nextIndex, items: ids.slice(nextIndex * 4, (nextIndex + 1) * 4) });
nextIndex += 1;
timerId = setTimeout(pump, 0);
};
timerId = setTimeout(pump, 0);
};
分块发送不等于文件处理本身已经完成。这里的 done 只表示演示消息生产结束,能否在页面显示“已索引”还要看宿主线程是否完整收齐三个块。setTimeout 时间间隔在系统调度下没有实时性保证;中途页退场时,页面仍需主动终止 Worker,而不能仅靠发送 cancel。示例中引用的 Worker 文件路径必须与项目配置一致,不能直接复制到没有 worker 构建配置的工程就期待自动运行。
四、宿主线程收到消息,先验身份再改可见计数
接收侧代码是 entry/ets/pages/MetadataPage.ets 组件中的状态成员和生命周期片段(外层 @Entry @Component struct MetadataPage 及 build 结构按项目模板补齐)。接收侧的陷阱在于 UI 状态变化得很直观:日志打一行、进度加四,看起来一直正常。真正应该先执行的是 merger.accept,返回 true 以后才能读取新的唯一 ID 数量。老会话的回执和重复块都应该安静地消失,不参与当前状态。为了方便读者复现,我把三块的进度固定为 4/12、8/12、12/12。
import { MessageEvents, worker } from '@kit.ArkTS';
import { ChunkMerger, MetaChunk } from '../common/MetaProtocol';
@State indexed: number = 0;
@State status: string = '等待开始';
private readonly session: number = 8;
private merger: ChunkMerger = new ChunkMerger(8);
private indexWorker: worker.ThreadWorker | null = null;
private leaving: boolean = false;
aboutToAppear(): void {
this.leaving = false;
this.indexWorker = new worker.ThreadWorker('entry/ets/workers/MetadataWorker.ets');
this.indexWorker.onmessage = (event: MessageEvents): void => {
if (this.leaving) { return; }
const payload = event.data as MetaChunk;
if (payload.type === 'chunk' && this.merger.accept(payload)) {
this.indexed = this.merger.count();
this.status = `已索引 ${this.indexed}/12`;
}
};
this.status = '索引中 · session 8';
this.indexWorker.postMessage({ type: 'start', session: this.session });
}
aboutToDisappear(): void {
this.leaving = true;
if (this.indexWorker !== null) {
this.indexWorker.postMessage({ type: 'cancel', session: this.session });
this.indexWorker.terminate();
this.indexWorker = null;
}
}

注意 onmessage 只处理 chunk,演示页的完成提示由三个块都被合并之后派生;没有直接使用 Worker 的 done 覆盖进度。如果生产任务需要重新启动,必须换一个新的 session,并创建对应的合并器,旧 Set 不能沿用。线程的创建和终止并不是可以随便反复触发的轻操作,批量列表的长期运行场景还应根据任务时长选择 TaskPool 或复用受控 Worker,避免让每一个卡片都开一个线程。
五、固定的三块消息,比随机压测更容易定位错误
先发送 session=8 的第 0 块,接收端显示 4/12;紧接着是第 1 块,显示 8/12。这时手工重放第 1 块,界面必须保持 8/12。最后送达第 2 块,显示 12/12;再投递 session=7 的旧块,即使其中包含四条新的字符串,也不应该改变结果。这个脚本能检验的是消息合并逻辑,不是 Worker 消息队列的真实顺序或机器性能。
| 演示消息 | 理论唯一条数 | 页面可见状态 |
|---|---|---|
| session 8 / chunk 0 | 4 | 已索引 4/12 |
| session 8 / chunk 1 | 8 | 已索引 8/12 |
| session 8 / chunk 1 重放 | 8 | 维持 8/12 |
| session 8 / chunk 2 | 12 | 已索引 12/12 |
| session 7 / chunk 2 迟到 | 12 | 维持 12/12 |

手机屏幕示意选择最终的 12/12,显示任务 MD-2011 与会话 8,并列出“重复块 1 已忽略”和“旧会话 7 已忽略”两个结果标签。顶部时间、5G、Wi-Fi 和电池数字都是构图内容,不是设备采样值。图像与文章写同一组数字,是为了读者能一眼对照验证路径,而不是用“看上去很真”的截图给出不存在的测试证据。
六、不要把线程结束当成状态已经提交
Worker 生命周期比单次消息更长,组件生命周期却会因为页面退场中断。aboutToDisappear 的示例既标记 leaving,也执行 terminate();前者是 UI 回写门禁,后者是线程资源收口。因为关闭是异步环境下的系统行为,界面仍不应依赖“Terminate 一调用就绝无任何旧回调”的推测。请求取消与结果拒绝要做成两道防线。
如果要把这个最小例子改成真实相册索引,首先要增加权限判断、目录和媒体更新监听、异常重试上限、分块大小背压以及隐私合规处理。尤其不要把文件全路径与拍摄位置直接放进诊断日志。Map 的增长也需要配额、分页持久化或分阶段释放,不能因为本例只有 12 条就忽略大图集的内存上界。另外,当 Worker 报错或异常退出,应给用户可恢复的失败状态,而不是把 indexed 停在一半却仍显示任务运行中。
七、把结论收在协议上,而不是收在“用了多线程”
回过头看,导致 16/12 的原因不是 Worker 本身不可靠,而是接收端缺少会话代次和已处理块集合。MetaDock 用 session=8、chunkIndex=0..2、固定块大小 4 和稳定图片 ID,把重复块与旧任务隔离开,最终计数只能由真正合并过的 12 个唯一 ID 派生。这样的协议在接入真实元数据扫描之前就能用纯逻辑测试验证,减少把业务错误误归因于线程调度的时间。
本篇参考华为官方《使用Worker处理关联的同步任务》(2026-08)及《Worker向宿主线程进行消息插队通信》(2026-09-09):https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/sync-task-development ;https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/worker-postmessageatfront 。示例只使用普通 postMessage,不依赖 API 26 的新插队能力;完整 SDK 编译、worker 配置验证和真机压力测试仍需开发者在目标工程执行。
更多推荐




所有评论(0)