做图片工具时,最容易被误报表述的是“用户下载了多少张”。前端点击了下载按钮,只能说明页面发起了一次保存流程;浏览器接手后,用户可能取消、改名、保存到云盘、转发到其他应用,网页通常无法知道文件是否真的落到了磁盘或相册。本文结合我在图片猫项目中整理下载统计时遇到的实际问题,说明如何把“保存意图”“保存操作”“浏览器交接”和“文件已保存”拆成不同状态。

文章中的项目代码摘录来自当前仓库,教学简化示例会单独标注。文中所说的“已完成验证”仅指仓库记录的本地测试和模拟后台检查,不等于所有真实用户设备的保存结果都可被网页确认。

图 1 下载链路的四个状态:最后一步通常不在网页可观测范围内(合成示意图)

一、先定义问题:下载不是一个布尔值

1. 保存意图:用户点击下载、长按图片或打开保存菜单,表示“想保存”,不表示已经生成文件。

2. 保存开始:前端已经创建 Blob、请求下载地址或准备系统分享,这是页面内部可以确认的动作。

3. 交接成功:浏览器、系统分享面板或新预览页已经接手,但交接成功仍不等于用户完成了保存。

4. 文件已保存:只有系统或客户端明确回调,网页才有条件把状态提升到“已保存”;普通桌面浏览器下载通常没有这样的回调。

这四层如果被压成一个 downloaded=true,后台会出现两个问题:一是把取消和失败算成成功,二是把一个 ZIP 包错误地当成一张图片。统计口径应该先承认未知,再谈转化率。

二、浏览器为什么不能告诉你文件是否落盘

传统的 a.download、Blob URL 和浏览器下载管理器之间存在安全边界。页面可以请求下载并提供建议文件名,但用户选择了哪个目录、是否覆盖同名文件、是否被安全策略拦截,通常由浏览器和操作系统控制。MDN 对 download 属性的说明也明确提示:最终行为会因浏览器、用户设置以及资源处理方式而变化。

网页能确认

网页不能可靠确认

建议记录

按钮事件发生、保存上下文已创建

用户是否点击了浏览器下载栏的完成按钮

result_save_started

Blob / URL 请求成功、保存方法已选择

文件是否写入磁盘、相册或第三方应用

result_save_handoff + method

Web Share API Promise 返回,表示已交给分享目标

分享目标是否最终保存、转发或取消后续操作

share 单列,不算下载文件数

部分客户端提供相册保存成功回调

普通 Web 页面是否能读取本地下载目录

result_save_succeeded 仅用于有明确回调的客户端

因此“下载文件数”只能在证据足够时累计。例如当前项目只把 method=download 的 handoff 作为可计数下载,并把分享、预览、取消、失败和数量未知单独统计。这个定义比追求一个看起来完整的数字更可靠。

三、图片猫公共保存工具怎样实现分层状态

当前 webClientVue/src/utils/download.ts 统一处理单文件、对象 URL、ZIP 和移动端分享。它先创建保存尝试 ID,再根据设备能力选择 Web Share、iOS 图片预览或普通 a.download;方法返回的是 download、share、preview 或 cancelled,而不是“文件已落盘”。

项目代码摘录:webClientVue/src/utils/download.ts。省略了链接节点清理和异常上下文。

export type DownloadMethod = 'download' | 'share' | 'preview' | 'cancelled';

 

const triggerAnchorDownload = (blob: Blob, filename: string): DownloadMethod => {

    const url = URL.createObjectURL(blob);

    const link = document.createElement('a');

    link.href = url;

    link.download = filename;

    link.dataset.piccatActivityDownload = 'true';

    document.body.appendChild(link);

    link.click();

    setTimeout(() => URL.revokeObjectURL(url), 1000);

    return 'download';

};

这里的返回值命名很重要:download 表示页面触发了浏览器下载通道,不应被翻译成 saved。iOS 图片预览返回 preview,用户还需要在预览页长按并选择系统保存动作。

项目代码摘录:保存尝试先生成 attemptId,并记录 started;这不是可直接运行的完整函数。

const beginSave = (options: DownloadBlobOptions, blob?: Blob) => {

    const context = {

        ...options,

        attemptId: options.attemptId || createSaveAttemptId(),

        saveContainer: options.saveContainer || 'single',

    };

    if (!options.attemptId) reportResultSave('started', context);

    return context;

};

四、一次 ZIP 为什么要同时记录操作次数和文件数

批量工具的用户动作是“一次打包下载”,但包内可能有 2 张、45 个甚至更多文件。当前公共 downloadArchive 会按最终 ZIP 目录统计非目录项,排除同名覆盖后的重复项;无法解析的成品 ZIP 不猜测数量,空包直接拒绝下载。

项目代码摘录:webClientVue/src/utils/download.ts。数量统计只针对可枚举的最终目录。

const savedFileCount = archive.files

    ? Object.values(archive.files).filter(file => !file.dir).length

    : undefined;

 

const context = beginSave({

    ...options,

    saveContainer: 'archive',

    savedFileCount

});

指标

单位

什么时候增加

不能推出什么

保存意图

次

点击或长按等保存相关动作

不代表文件已经生成或保存

下载操作

次

一次有效的保存交接,按 attemptId 去重

不等于包内文件数量

下载文件数

文件

method=download 且 savedFileCount 可确认

不代表用户打开或使用了每个文件

数量未知

次

有交接但缺少可靠文件数量或方法不唯一

不能默认填 1

服务端聚合还会把分片和网络重发按 attemptId 合并,跨功能 ZIP 按明确的 savedFeatureFileCounts 分配;缺少归属信息时进入未归属或未知,而不是按时间猜测。这个设计解决的是统计诚实性,不是把浏览器变成文件系统监控器。

五、保存事件如何把“可观测”和“不可观测”写清楚

项目代码摘录:resultSaveTracking.ts 的事件阶段示意,succeeded 只适用于明确回调的客户端。

reportResultSave('started', context);

// ... navigator.share / a.download / 图片预览

reportResultSave('handoff', context, { method: resolvedMethod });

 

// 只有有明确客户端回调时,才可以上报 succeeded

reportResultSave('succeeded', context, { method: 'album' });

在普通桌面浏览器里,handoff 是比较稳的上限;在微信小程序等能返回相册结果的客户端,才可能把客户端回调单列为 succeeded。后台页面也因此明确提示“下载为 0,不一定代表用户没有保存”,历史漏采和数量未知不能被补造成未下载。

建议把事件名称和值设计成状态机,而不是一个布尔字段。这样用户取消分享时可以记录 cancelled,分享面板打开时记录 share,图片预览时记录 preview,后续分析不会把这些动作混进下载文件数。

六、案例素材:批量结果展示不等于文件已落盘

下图使用项目现有批量压缩案例素材,展示的是批量结果前后对比,不是浏览器下载完成截图。它适合说明“结果列表可以展示处理结果”,但不能证明用户已经保存了文件;真正的保存状态仍要看公共下载链路的事件。

图 2 项目批量压缩案例素材:结果展示与本地文件落盘是两件事

七、当前实现、尚未完善与建议方案

状态

当前内容

边界或下一步

当前已经实现

统一 downloadBlob、downloadObjectUrl、downloadArchive;保存尝试 ID;单文件按 1 计数;ZIP 最终目录计数;分享、预览、取消、失败分开;后台展示下载操作、下载文件数、数量未知。

这是代码和本地聚合逻辑中的实现,不等于每台设备都能返回落盘结果。

尚未完善

普通 Web 页面不能确认用户是否在下载栏完成保存;部分本地工具仍缺少完整 localRunId 关联;历史没有采集到的数量无法可靠补造。

不能用下载文件数除处理次数当作下载率,也不能把 0 当成用户没有保存。

建议方案

需要强确认时,使用客户端原生保存回调;网页侧保留“已发起保存”和“请到浏览器下载记录确认”的提示。对关键交付记录文件名、哈希和用户确认动作。

showSaveFilePicker 等能力存在兼容性与安全上下文限制,不应直接当成全端默认方案。

八、发布前验收清单

1. 单图下载:检查 started、handoff、failed 是否按一次 attemptId 关联,重复点击是否按产品口径另计。

2. ZIP 下载:准备含目录、同名覆盖、空包和无法解析包的样例,确认文件数、未知数和错误状态分别落表。

3. 移动端:分别测试分享成功、用户取消、图片预览长按保存;不要把分享 Promise 返回直接标成相册保存成功。

4. 页面离线或刷新:确认采集失败不会阻止文件保存,补发事件不会重复累计;页面关闭导致的事件缺失应显示覆盖不完整。

5. 后台统计:核对“保存操作”和“下载文件数”两列,确认历史旧记录、OCR 复制和未归属包没有被强行推定。

仓库记录的本地验证包括下载聚合、分片去重、ZIP 数量、取消分享和模拟后台展示;发布后仍需要用测试账号在目标浏览器和移动端执行真实交接。浏览器能力边界决定了这类验收不能只看前端按钮有没有触发。

参考资料

MDN:download 属性:download - SVG | MDN

MDN:Navigator.share():Navigator: share() method - Web APIs | MDN

本文案例来自我正在维护的图片猫图片处理项目,演示入口:图片猫(PicCat)

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐