Flutter 附件下载记录
整理一下商业 IM 软件 App 的附件下载。网络请求只是其中一段,后面还要选文件名、移动临时文件,再把保存位置写回消息。
Android 和 iOS 返回的位置也不完全一样。只把它们都叫 localPath,调用的时候还是很容易用错。
连续点两次,要下载两次吗?
同一个文件可能被连续点击,也可能同时被两个地方请求。如果每个入口都直接开始下载,就会重复占用网络和磁盘。
当前下载服务维护了一个进行中任务表:
final Map<String, Future<String?>> _inflightDownloads = {};
入口先根据消息 ID 查找:
final existingFuture = _inflightDownloads[message.id];
if (existingFuture != null) {
return existingFuture;
}
没有任务时,创建 Completer,在开始等待实际下载之前把它的 Future 放进表里。后来的调用就可以等待同一项工作的结果,结束后由 finally 移除记录。
这里合并的是同一条消息对应的进行中任务。它不是按文件内容去重,不同消息即使引用同一个远端附件,仍可能各自下载。
任务表也只属于当前服务实例的内存,不能把它当成跨进程锁或重启后仍然存在的下载记录。
共享结果,不等于共享所有回调
这个入口还接收进度回调、消息更新回调和 force 参数。
但是命中已有任务时,方法直接返回了 Future。后一个调用传入的回调不会因此自动加入正在执行的下载,也不会重新执行它的 force 策略。
例如,第一个页面开始下载,第二个页面随后也请求同一条消息。两个页面都能等待最终结果,但当前写法不能保证第二个页面的进度回调也持续收到通知。
如果产品需要这种行为,就要进一步把任务结果与进度订阅拆开,或者由共享状态统一提供进度。这是当前实现可以继续完善的方向。
结果复用了,进度订阅还没有复用。接第二个页面时要注意这点。
本地已经有路径,还需要下载吗?
当前实现会先检查消息的 localPath。普通文件路径会解析为绝对路径,再判断文件存在且长度大于零;符合条件时直接复用。
这能避免常见的重复下载,但“非空文件”仍然不等于内容完整。它没有证明文件大小与远端一致,也没有证明内容校验通过。
content:// 则走另一条分支,当前代码会直接返回这个引用。这个分支没有实际打开内容,因此不能把结果理解为已经完成可读性验证。
路径提供的是访问位置。对应资源是否还在、是否仍然可访问,需要相应平台的访问过程来确认。
同名文件不能只靠先检查再创建
两条不同消息都带着 报价单.pdf,保存时总要决定如何命名。
项目使用“原始名、原始名加编号”的策略,并为选中的名字创建临时占位文件。核心调用是:
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 已收到回调,随后数据库写入失败,也可能出现内存和持久化状态不一致。
附件打不开时,可以先看文件是否还在,再看消息里存的引用。文件存在但消息没写回,和文件根本没下载下来,要走不同的排查方向。