Flutter 附件下载记录

Aug 30·6 min
AI 生成的摘要
商业 IM 软件的附件下载实现:复用进行中的任务、处理同名文件、保存临时文件,以及 Android URI 和 iOS 相对路径的区别。

整理一下商业 IM 软件 App 的附件下载。网络请求只是其中一段,后面还要选文件名、移动临时文件,再把保存位置写回消息。

Android 和 iOS 返回的位置也不完全一样。只把它们都叫 localPath,调用的时候还是很容易用错。

连续点两次,要下载两次吗?

同一个文件可能被连续点击,也可能同时被两个地方请求。如果每个入口都直接开始下载,就会重复占用网络和磁盘。

当前下载服务维护了一个进行中任务表:

snippet
dart
final Map<String, Future<String?>> _inflightDownloads = {};

入口先根据消息 ID 查找:

snippet
dart
final existingFuture = _inflightDownloads[message.id];
if (existingFuture != null) {
  return existingFuture;
}

没有任务时,创建 Completer,在开始等待实际下载之前把它的 Future 放进表里。后来的调用就可以等待同一项工作的结果,结束后由 finally 移除记录。

这里合并的是同一条消息对应的进行中任务。它不是按文件内容去重,不同消息即使引用同一个远端附件,仍可能各自下载。

任务表也只属于当前服务实例的内存,不能把它当成跨进程锁或重启后仍然存在的下载记录。

共享结果,不等于共享所有回调

这个入口还接收进度回调、消息更新回调和 force 参数。

但是命中已有任务时,方法直接返回了 Future。后一个调用传入的回调不会因此自动加入正在执行的下载,也不会重新执行它的 force 策略。

例如,第一个页面开始下载,第二个页面随后也请求同一条消息。两个页面都能等待最终结果,但当前写法不能保证第二个页面的进度回调也持续收到通知。

如果产品需要这种行为,就要进一步把任务结果与进度订阅拆开,或者由共享状态统一提供进度。这是当前实现可以继续完善的方向。

结果复用了,进度订阅还没有复用。接第二个页面时要注意这点。

本地已经有路径,还需要下载吗?

当前实现会先检查消息的 localPath。普通文件路径会解析为绝对路径,再判断文件存在且长度大于零;符合条件时直接复用。

这能避免常见的重复下载,但“非空文件”仍然不等于内容完整。它没有证明文件大小与远端一致,也没有证明内容校验通过。

content:// 则走另一条分支,当前代码会直接返回这个引用。这个分支没有实际打开内容,因此不能把结果理解为已经完成可读性验证。

路径提供的是访问位置。对应资源是否还在、是否仍然可访问,需要相应平台的访问过程来确认。

同名文件不能只靠先检查再创建

两条不同消息都带着 报价单.pdf,保存时总要决定如何命名。

项目使用“原始名、原始名加编号”的策略,并为选中的名字创建临时占位文件。核心调用是:

snippet
dart
await File(tmpPath).create(recursive: true, exclusive: true);

exclusive: true 表示目标文件已存在时创建失败。这个语义由 Dart File.create 定义。

相比先查目录、再选择一个看起来不存在的名字,排他创建给合作使用这套命名规则的下载任务提供了一个竞争结果:谁先创建成功,谁拿到对应的临时位置。

不过,占位文件保护的是这套流程约定的临时路径,并不能阻止其他程序直接创建最终文件。残留占位也需要清理策略,不能把它描述成整个文件系统范围内绝对不会冲突的锁。

临时文件和最终文件分别意味着什么

当前实现先把内容下载到临时路径,检查临时文件存在且非空,再尝试移动到最终位置。移动失败时,还有复制后删除临时文件的回退路径。

这样可以把“正在写入的内容”和“准备交给后续使用的内容”区分开。

但这条流程不能统称为原子提交。复制有自己的过程;强制覆盖分支还会先尝试删除旧文件,再把临时文件移过去。失败时能否保留旧版本,要沿实际分支判断。

另外,File.rename 对已存在的目标文件有替换语义,不能把重命名天然当成“目标存在就失败”的保护。参见 Dart File.rename

临时文件也会残留,失败路径还需要把这些文件清理掉。

Android 和 iOS 返回的“路径”不完全一样

当前 Android 分支先下载到应用临时目录,再调用原生导出接口。保存消息时优先使用返回的 URI,也有其他路径字段作为回退。

iOS 分支把文件放到应用 Documents 下,再把相对路径写入消息记录,需要访问时重新拼接当前 Documents 目录。

这意味着 localPath 实际可能表示不同类型的位置:相对路径、绝对路径,或者内容 URI。调用方必须经过统一的解析入口,不能看到一个字符串就直接假设它适合传给 File

也不能把当前设备的本地路径发给另一台设备当下载地址。跨设备恢复仍然要依靠消息中的远端文件引用。

文件写好了,消息状态也写好了吗?

下载服务在取得最终位置后,会更新消息对象、调用更新回调,再写入数据库。

这又带来一个边界:文件操作成功,数据库更新却可能失败。当前内部下载方法会捕获异常并返回 null,但返回失败不意味着之前已经写出的文件一定被撤销。

如果 UI 已收到回调,随后数据库写入失败,也可能出现内存和持久化状态不一致。

附件打不开时,可以先看文件是否还在,再看消息里存的引用。文件存在但消息没写回,和文件根本没下载下来,要走不同的排查方向。

最后修改时间: Sep 15
cd ..