Java I/O、文件与 Android 存储

用户在系统文件选择器中选了一份 PDF,应用要把它复制到本地缓存供预览;网络中断、用户取消、Provider 没有长期授权或进程被回收时,旧缓存不能被半截新文件覆盖。这个场景同时碰到了 Java I/O 的资源所有权、Android 的 Uri 权限、文件替换原子性和线程边界。

一句话结论: Android 中先把外部数据当作只能经 ContentResolver 打开的 Uri 流,再在后台以临时文件写入并同卷原子替换;缓存、持久配置和数据库各自使用对应的所有者与 API。

本文以 Java 17 语言能力、Android 14 附近的存储模型为概念边界。不同最低 API、core library desugaring、文件系统和 DocumentProvider 会影响 java.nio.file 与原子移动的细节;ContentResolver、DataStore 与 Room 的具体实现也应以所用 AndroidX 版本为准。

先沿着数据流走一遍

文件选择器交给应用的是 Uri,不是可自由拼接的磁盘路径。ContentResolver 代表应用向对应 Provider 请求读权限和流;应用复制字节时持有输入、输出和临时文件,成功后才把结果交给缓存消费者。
#mermaid-svg-pdnpF19ckMFAXmAP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pdnpF19ckMFAXmAP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pdnpF19ckMFAXmAP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pdnpF19ckMFAXmAP .error-icon{fill:#552222;}#mermaid-svg-pdnpF19ckMFAXmAP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pdnpF19ckMFAXmAP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pdnpF19ckMFAXmAP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pdnpF19ckMFAXmAP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pdnpF19ckMFAXmAP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pdnpF19ckMFAXmAP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pdnpF19ckMFAXmAP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pdnpF19ckMFAXmAP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pdnpF19ckMFAXmAP .marker.cross{stroke:#333333;}#mermaid-svg-pdnpF19ckMFAXmAP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pdnpF19ckMFAXmAP p{margin:0;}#mermaid-svg-pdnpF19ckMFAXmAP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pdnpF19ckMFAXmAP .cluster-label text{fill:#333;}#mermaid-svg-pdnpF19ckMFAXmAP .cluster-label span{color:#333;}#mermaid-svg-pdnpF19ckMFAXmAP .cluster-label span p{background-color:transparent;}#mermaid-svg-pdnpF19ckMFAXmAP .label text,#mermaid-svg-pdnpF19ckMFAXmAP span{fill:#333;color:#333;}#mermaid-svg-pdnpF19ckMFAXmAP .node rect,#mermaid-svg-pdnpF19ckMFAXmAP .node circle,#mermaid-svg-pdnpF19ckMFAXmAP .node ellipse,#mermaid-svg-pdnpF19ckMFAXmAP .node polygon,#mermaid-svg-pdnpF19ckMFAXmAP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pdnpF19ckMFAXmAP .rough-node .label text,#mermaid-svg-pdnpF19ckMFAXmAP .node .label text,#mermaid-svg-pdnpF19ckMFAXmAP .image-shape .label,#mermaid-svg-pdnpF19ckMFAXmAP .icon-shape .label{text-anchor:middle;}#mermaid-svg-pdnpF19ckMFAXmAP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pdnpF19ckMFAXmAP .rough-node .label,#mermaid-svg-pdnpF19ckMFAXmAP .node .label,#mermaid-svg-pdnpF19ckMFAXmAP .image-shape .label,#mermaid-svg-pdnpF19ckMFAXmAP .icon-shape .label{text-align:center;}#mermaid-svg-pdnpF19ckMFAXmAP .node.clickable{cursor:pointer;}#mermaid-svg-pdnpF19ckMFAXmAP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pdnpF19ckMFAXmAP .arrowheadPath{fill:#333333;}#mermaid-svg-pdnpF19ckMFAXmAP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pdnpF19ckMFAXmAP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pdnpF19ckMFAXmAP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pdnpF19ckMFAXmAP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pdnpF19ckMFAXmAP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pdnpF19ckMFAXmAP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pdnpF19ckMFAXmAP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pdnpF19ckMFAXmAP .cluster text{fill:#333;}#mermaid-svg-pdnpF19ckMFAXmAP .cluster span{color:#333;}#mermaid-svg-pdnpF19ckMFAXmAP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pdnpF19ckMFAXmAP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pdnpF19ckMFAXmAP rect.text{fill:none;stroke-width:0;}#mermaid-svg-pdnpF19ckMFAXmAP .icon-shape,#mermaid-svg-pdnpF19ckMFAXmAP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pdnpF19ckMFAXmAP .icon-shape p,#mermaid-svg-pdnpF19ckMFAXmAP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pdnpF19ckMFAXmAP .icon-shape .label rect,#mermaid-svg-pdnpF19ckMFAXmAP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pdnpF19ckMFAXmAP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pdnpF19ckMFAXmAP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pdnpF19ckMFAXmAP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 系统选择器:返回 Uri
Uri:内容标识与授权句柄
ContentResolver:请求 Provider
DocumentProvider:校验读取权限
InputStream:连续字节读取
App Sandbox:写入临时缓存
预览层:读取已完成缓存

谁创建:系统选择器或调用方创建并返回 Uri,应用的 Context 提供 ContentResolver;谁持有:Provider 持有真实内容和授权规则,调用方在 try-with-resources 作用域内持有流;何时触发:后台任务调用 openInputStreamopenFileDescriptor 时;结果交给谁:拷贝完成后,预览或后续任务只收到应用沙盒中的完整缓存文件。

Java I/O:字节、字符与缓冲不是同一件事

File 是路径的抽象,不是已打开文件、更不是权限证明。FileInputStreamFileOutputStream 搬运原始字节,适合图片、PDF、压缩包和数据库导出;ReaderWriter 按字符集解码或编码文本,读取 JSON、CSV 或配置时必须明确 UTF-8 等编码,不能把任意二进制内容塞进字符流。

缓冲流如 BufferedInputStreamBufferedOutputStreamBufferedReader 会减少小块读取造成的系统调用;它不改变数据含义,也不替代关闭资源。NIO 的 PathFilesChannelsByteBuffer 提供路径操作、批量缓冲与 channel 概念,但 Android 上是否直接采用 java.nio.file 取决于 API 级别和 desugaring。面对一个 Uri 时,先走 ContentResolver 拿流或 ParcelFileDescriptor,不要因为 NIO 存在就猜测出一个 Path

来源锚点与职责

下表同时回答谁创建、谁持有、何时触发和数据交给谁。这里的类名是公开 API 或实现定位线索,不应把内部实现当成稳定契约。

来源锚点 谁创建 谁持有 何时触发 数据交给谁
Context Framework 创建 Application、Activity 或 Service Context 组件及其生命周期拥有者持有,避免无界面需求时长期泄漏 Activity 组件创建或注入依赖时 filesDircacheDirContentResolver 等能力交给应用层
ContentResolver Context.getContentResolver() 提供系统连接点 Context 与框架管理其连接;调用方只持有一次调用返回的资源 openInputStreamopenOutputStreamopenFileDescriptor 被调用 InputStreamOutputStreamParcelFileDescriptor 交给后台 I/O 代码
ParcelFileDescriptorInputStream ContentProvider 按 Uri 和模式打开 调用方在局部作用域内持有并关闭;AutoCloseInputStream 可接管 descriptor 关闭 Provider 同意访问后 缓冲复制逻辑读取字节,随后交给临时文件
缓存目录 cacheDir Context 指向应用沙盒目录 应用拥有,系统可在空间紧张时清理 预览、可重建下载或转换结果需要暂存时 已完成文件交给同一应用的预览、上传或解析层
DataStore 应用在单例依赖图中创建 DataStore DataStore 实例与其协程或调用范围管理读写 偏好或小型类型化状态变更时 Flow 或一次性读取结果交给状态层
Room 应用创建 RoomDatabase 单例 Room 持有连接、DAO 与数据库文件访问;应用通过 DAO 使用它 查询或事务执行时 实体或查询结果交给 Repository、UseCase 或 UI 状态

来源事实: 应用内部的 filesDircacheDir 和 Room 数据库文件属于 app sandbox;外部 Uri 的读取要经对应 Provider;DataStore 是面向偏好或类型化数据模型的存储组件,Room 通过 DAO 和事务管理关系型数据。 生产建议: 让 Repository 或专门的存储服务拥有 I/O 操作,将结果转换成业务模型,不把 InputStreamCursor 或数据库实体一路暴露到 UI。

Android 存储映射:选对容器比选对类更重要

  • App sandbox: filesDir 放应用拥有的持久文件,cacheDir 放可重建内容。卸载应用会移除它们,缓存还可能被系统提前清理,所以每次读取都要接受不存在。
  • Uri 与 scoped storage: Storage Access Framework、MediaStore 和其他 Provider 可以给出 content:// Uri。它可能根本没有公开文件路径,getRealPathFromUri 之类的猜测既不可靠,也会绕开 Provider 的权限模型。需要跨重启访问时,只有在 Provider 支持并且业务确有需要时调用 takePersistableUriPermission,并保存 Uri 字符串与授权状态。
  • DataStore: Preferences DataStore 保存键值偏好,Proto DataStore 保存受 schema 约束的数据模型。它不是通用二进制文件 API,也不适合存放大文件、媒体或频繁随机写入的日志。
  • Room: Room 数据库文件位于应用私有空间,但应用应通过 DAO、迁移和事务操作,而不是把它当普通 File 直接改写。需要一致读写时,使用 Room 事务,而不是用"先读再写"的文件习惯替代数据库隔离。
  • 序列化边界: 网络 JSON、Proto 或数据库行在边界被解析成应用模型;Java Serializable 不宜作为 Android 长期磁盘格式或跨版本协议,它有兼容性、性能和反序列化安全风险。持久格式要显式版本化,解析失败要能丢弃或迁移。

原子缓存写入:新内容没有完整之前,旧内容仍然可读

"先清空目标文件再下载"会让断网留下损坏内容。更稳妥的模式是在目标目录创建唯一临时文件,写完、flush 并关闭,再尝试同一文件系统内的替换。概念上的 Files.moveATOMIC_MOVE 只在底层文件系统支持且源、目标在同一卷时才可能原子;在 Android 的应用私有文件中,生产代码可评估 AtomicFile 或平台兼容的替换策略,并对不支持原子移动的情形保守处理。
#mermaid-svg-ImMn4hYddtwiTlUO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ImMn4hYddtwiTlUO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ImMn4hYddtwiTlUO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ImMn4hYddtwiTlUO .error-icon{fill:#552222;}#mermaid-svg-ImMn4hYddtwiTlUO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ImMn4hYddtwiTlUO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ImMn4hYddtwiTlUO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ImMn4hYddtwiTlUO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ImMn4hYddtwiTlUO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ImMn4hYddtwiTlUO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ImMn4hYddtwiTlUO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ImMn4hYddtwiTlUO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ImMn4hYddtwiTlUO .marker.cross{stroke:#333333;}#mermaid-svg-ImMn4hYddtwiTlUO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ImMn4hYddtwiTlUO p{margin:0;}#mermaid-svg-ImMn4hYddtwiTlUO .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ImMn4hYddtwiTlUO .cluster-label text{fill:#333;}#mermaid-svg-ImMn4hYddtwiTlUO .cluster-label span{color:#333;}#mermaid-svg-ImMn4hYddtwiTlUO .cluster-label span p{background-color:transparent;}#mermaid-svg-ImMn4hYddtwiTlUO .label text,#mermaid-svg-ImMn4hYddtwiTlUO span{fill:#333;color:#333;}#mermaid-svg-ImMn4hYddtwiTlUO .node rect,#mermaid-svg-ImMn4hYddtwiTlUO .node circle,#mermaid-svg-ImMn4hYddtwiTlUO .node ellipse,#mermaid-svg-ImMn4hYddtwiTlUO .node polygon,#mermaid-svg-ImMn4hYddtwiTlUO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ImMn4hYddtwiTlUO .rough-node .label text,#mermaid-svg-ImMn4hYddtwiTlUO .node .label text,#mermaid-svg-ImMn4hYddtwiTlUO .image-shape .label,#mermaid-svg-ImMn4hYddtwiTlUO .icon-shape .label{text-anchor:middle;}#mermaid-svg-ImMn4hYddtwiTlUO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ImMn4hYddtwiTlUO .rough-node .label,#mermaid-svg-ImMn4hYddtwiTlUO .node .label,#mermaid-svg-ImMn4hYddtwiTlUO .image-shape .label,#mermaid-svg-ImMn4hYddtwiTlUO .icon-shape .label{text-align:center;}#mermaid-svg-ImMn4hYddtwiTlUO .node.clickable{cursor:pointer;}#mermaid-svg-ImMn4hYddtwiTlUO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ImMn4hYddtwiTlUO .arrowheadPath{fill:#333333;}#mermaid-svg-ImMn4hYddtwiTlUO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ImMn4hYddtwiTlUO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ImMn4hYddtwiTlUO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ImMn4hYddtwiTlUO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ImMn4hYddtwiTlUO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ImMn4hYddtwiTlUO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ImMn4hYddtwiTlUO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ImMn4hYddtwiTlUO .cluster text{fill:#333;}#mermaid-svg-ImMn4hYddtwiTlUO .cluster span{color:#333;}#mermaid-svg-ImMn4hYddtwiTlUO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ImMn4hYddtwiTlUO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ImMn4hYddtwiTlUO rect.text{fill:none;stroke-width:0;}#mermaid-svg-ImMn4hYddtwiTlUO .icon-shape,#mermaid-svg-ImMn4hYddtwiTlUO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ImMn4hYddtwiTlUO .icon-shape p,#mermaid-svg-ImMn4hYddtwiTlUO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ImMn4hYddtwiTlUO .icon-shape .label rect,#mermaid-svg-ImMn4hYddtwiTlUO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ImMn4hYddtwiTlUO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ImMn4hYddtwiTlUO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ImMn4hYddtwiTlUO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

输入流:外部内容
临时文件:目标目录内
BufferedOutputStream:复制字节
关闭资源:完成写入
内容校验通过
原子替换:临时文件改名
缓存文件:完整版本
删除临时文件:保留旧缓存

谁创建:存储服务根据稳定 cache key 创建临时文件;谁持有:当前后台任务持有临时路径与两个流;何时触发:复制和校验都成功后才触发替换;结果交给谁:缓存读者得到旧完整版本或新完整版本,不会读到被覆盖一半的目标。文件名应来自受控 key 或随机临时名,不能把不可信 Uri 文本直接拼成路径。

关闭、取消与线程边界

try-with-resources 是 Java 资源所有权的基本写法:离开代码块时,无论成功、抛出 IOException 还是返回,流都会按相反顺序关闭。取消不是"什么也不做":任务被取消或发生错误后,仍要关闭资源、删除临时文件,并把取消与真正 I/O 失败区分开。不要在主线程读取文件、遍历目录或做大块序列化;Java 可用受控 ExecutorService,Kotlin 工程通常使用 Dispatchers.IO,可延后且需保证执行的工作则考虑 WorkManager。
#mermaid-svg-w9Eeh1Jqs2qx5Dqa{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .error-icon{fill:#552222;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .marker.cross{stroke:#333333;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa p{margin:0;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .cluster-label text{fill:#333;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .cluster-label span{color:#333;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .cluster-label span p{background-color:transparent;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .label text,#mermaid-svg-w9Eeh1Jqs2qx5Dqa span{fill:#333;color:#333;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .node rect,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .node circle,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .node ellipse,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .node polygon,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .rough-node .label text,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .node .label text,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .image-shape .label,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .icon-shape .label{text-anchor:middle;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .rough-node .label,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .node .label,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .image-shape .label,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .icon-shape .label{text-align:center;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .node.clickable{cursor:pointer;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .arrowheadPath{fill:#333333;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .cluster text{fill:#333;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .cluster span{color:#333;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa rect.text{fill:none;stroke-width:0;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .icon-shape,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .icon-shape p,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .icon-shape .label rect,#mermaid-svg-w9Eeh1Jqs2qx5Dqa .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-w9Eeh1Jqs2qx5Dqa :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否



后台任务:开始复制
打开输入流与临时输出流
循环读取缓冲区
取消或 I O 错误
还有数据
关闭后提交替换
关闭流:释放 Provider 与文件句柄
删除临时文件
返回取消或存储失败
返回完整缓存结果

谁创建:调用层创建任务和取消信号,Provider 创建底层访问;谁持有:任务作用域持有 Future、流和临时文件;何时触发:用户离开页面、生命周期取消、网络中断或磁盘错误时;结果交给谁:上层收到可区分的取消、权限、I/O 或内容损坏结果,UI 不直接管理文件句柄。

Java-first 简化示例:安全读写缓存

下面是 简化伪代码 ,展示如何把选择到的 Uri 复制到应用缓存并在读取时拒绝空文件。它可以改造成 Java Android 项目中的 Repository 方法;真正项目应在 ExecutorService 或其他 I/O 调度器运行这段工作,不能在主线程调用。

路径安全边界: 这个 API 接受的是逻辑 cacheToken,不是调用方指定的文件名、Uri 文本或相对路径。cacheFileForToken 只接受字母、数字、下划线和连字符组成的短 token,并把候选文件和缓存目录都规范化为 canonical path;只有候选路径以 canonical 缓存目录加分隔符为前缀时才继续。这样 token 不能选择子目录、扩展名或 .. 路径。生产代码更适合从可信的数据库主键、服务端内容 id 或受控哈希派生 token,而不是把未验证的外部输入传进来。

java 复制代码
import java.util.regex.Pattern;

final class CacheStore {
    private static final Pattern CACHE_TOKEN =
            Pattern.compile("[A-Za-z0-9_-]{1,64}");

    private final Context appContext;

    CacheStore(Context context) {
        this.appContext = context.getApplicationContext();
    }

    File copyUriToCache(Uri uri, String cacheToken) throws IOException {
        File directory = canonicalCacheDirectory();
        File cacheFile = cacheFileForToken(directory, cacheToken);
        File temp = File.createTempFile("tmp-" + cacheToken + ".", ".part", directory);

        boolean committed = false;
        try {
            try (InputStream raw = appContext.getContentResolver().openInputStream(uri);
                 BufferedInputStream input = new BufferedInputStream(requireStream(raw));
                 OutputStream rawOutput = new FileOutputStream(temp);
                 BufferedOutputStream output = new BufferedOutputStream(rawOutput)) {
                byte[] buffer = new byte[16 * 1024];
                for (int count; (count = input.read(buffer)) != -1; ) {
                    output.write(buffer, 0, count);
                    if (Thread.currentThread().isInterrupted()) {
                        throw new InterruptedIOException("copy cancelled");
                    }
                }
                output.flush();
            }
            if (temp.length() == 0) {
                throw new IOException("empty cache content");
            }
            replaceAtomically(temp, cacheFile);
            committed = true;
            return cacheFile;
        } finally {
            if (!committed && temp.exists() && !temp.delete()) {
                Log.w("CacheStore", "temporary cache file could not be deleted");
            }
        }
    }

    InputStream openCache(String cacheToken) throws IOException {
        File file = cacheFileForToken(canonicalCacheDirectory(), cacheToken);
        if (!file.isFile() || file.length() == 0) {
            throw new IOException("cache missing or corrupt");
        }
        return new BufferedInputStream(new FileInputStream(file));
    }

    private static InputStream requireStream(InputStream stream) throws IOException {
        if (stream == null) throw new IOException("provider returned no stream");
        return stream;
    }

    private File canonicalCacheDirectory() throws IOException {
        File directory = appContext.getCacheDir().getCanonicalFile();
        if (!directory.isDirectory()) {
            throw new IOException("cache directory unavailable");
        }
        return directory;
    }

    private static File cacheFileForToken(File canonicalDirectory, String cacheToken)
            throws IOException {
        if (cacheToken == null || !CACHE_TOKEN.matcher(cacheToken).matches()) {
            throw new IOException("cache token must match [A-Za-z0-9_-]{1,64}");
        }
        File candidate = new File(canonicalDirectory, cacheToken + ".bin");
        File canonicalTarget = candidate.getCanonicalFile();
        String directoryPath = canonicalDirectory.getPath();
        if (!canonicalTarget.getPath().startsWith(directoryPath + File.separator)) {
            throw new IOException("cache target escaped cache directory");
        }
        return canonicalTarget;
    }

    private static void replaceAtomically(File temp, File target) throws IOException {
        // Simplified: use a same-volume atomic replacement strategy where supported.
        if (!temp.renameTo(target)) {
            throw new IOException("cache replacement failed");
        }
    }
}

这个教学版故意省略了加密、配额策略、跨进程锁、schema migration、MIME 与内容哈希校验、Provider 可恢复权限、原子移动能力探测、目标已存在时的兼容替换方案,以及与协程或 Future 的真实取消协作。特别注意:上例的 renameTo 只表达"同目录临时文件再替换"的意图;生产实现必须检查设备和 API 的替换语义,必要时使用 AtomicFile 或保留旧目标的回退方案。

常见陷阱与修正

  • Uri 假设文件系统路径: content:// 可指向云端、媒体库或任意 Provider。始终用 ContentResolver 打开,保留或释放授权要与业务生命周期一致。
  • 把持久数据放进 cache: 缓存可被系统清理,收藏、草稿、离线业务数据应使用文件目录、DataStore 或 Room 等真正持久的方案。
  • 在主线程做磁盘 I/O: 这会造成掉帧甚至 ANR。开发期用 StrictMode 暴露问题,生产中在明确的 I/O 调度边界执行。
  • 忘记关闭或取消清理: 流、ParcelFileDescriptor 与临时文件都要有单一所有者;try-with-resources 关闭句柄,finally 或结构化取消删除未提交文件。
  • 把 DataStore 当普通二进制文件 API: 它服务于偏好或类型化小数据,不用于大文件复制、随机访问或图片缓存。
  • 绕过 ContentResolver 权限: 直接猜路径无法获得 Provider 授权,也会在 scoped storage 下失效。权限错误要反馈为可操作状态,而不是静默降级成空文件。

可改造练习:复制选中的 Uri,并处理半截内容

为一个"导入附件"页面实现以下流程:用户选择 Uri 后,把内容复制到 cacheDir/imports/ 中同目录的 .part 临时文件;每复制一个缓冲区都检查任务是否取消;写完后校验最小长度或内容哈希,再以原子或保守替换策略提交为最终缓存文件。

验收时做四次实验:取消复制,确认没有最终文件且 .part 被清理;拔掉网络或让云 Provider 读取失败,确认旧完整缓存仍能打开;制造零字节或错误哈希内容,确认它被视为损坏并删除临时文件;撤销 Uri 授权,确认 UI 得到"需要重新选择文件"而不是尝试读取猜测路径。把 I/O 放进后台执行器,只有最终的 SuccessCancelledPermissionDeniedCorruptContent 状态回到 UI。

上线前检查

  • 是否把外部内容当作 Uri,并通过 ContentResolver 获取流或 descriptor?
  • 每个 InputStreamOutputStreamParcelFileDescriptor 是否有清楚的关闭所有者?
  • 临时文件是否与目标在同一目录或同一文件系统,并且失败时会被清理?
  • 缓存缺失、取消、权限失效与内容损坏是否都被建模为可恢复结果?
  • Durable 配置是否使用 DataStore,关系数据是否使用 Room,而非一律写进普通文件?
  • 所有磁盘与 Provider I/O 是否离开主线程,且生产实现已验证原子替换与迁移策略?

面试表达: "在 Android 上我把 Uri 当成带 Provider 权限的内容句柄,而不是路径。Repository 在后台经 ContentResolver 拿流,用 try-with-resources 关闭资源,先把字节写到同目录临时文件,校验后再替换缓存;取消或失败只清理临时文件,绝不破坏旧版本。DataStore 放小型状态,Room 管关系数据,二者都不替代通用文件流。"

下一步可以把这个存储边界连接到注解、反射与动态代理:用注解声明序列化字段或存储策略,用反射理解 schema 元数据,再用代理或拦截器把权限、线程和错误映射集中在 Repository 边界。

相关推荐
wno7043 小时前
Spring Security基础使用
java·后端·spring
小蒜学长3 小时前
基于RFID技术的仓储管理系统设计(代码+数据库+LW)
java·数据库·spring boot·后端·rfid技术·仓储管理
梦影_3 小时前
Langchain简单快速上手教程(五)——聊天模型之流式传输
java·数据库·人工智能·python·langchain
数据狐(Datafox)4 小时前
1688商品列表API接口解析(附 JSON 样例)
java·开发语言·数据库·爬虫·json
xcl09254 小时前
健身场馆无人自动化系统:从架构设计到落地实践
java·spring boot
EatFan4 小时前
一个二维码背后的系统设计:批次生成、绑定、扫码与数据统计怎么做?
java·后端·微信小程序·二维码·qrcode
白远山4 小时前
自助健身小程序源码:架构拆解、核心链路与本地部署实战
java·架构·uni-app·需求分析
Lyyaoo.4 小时前
【动态规划】【待更新】
java·数据结构·算法
我不会起名字3224 小时前
一天一道算法题(35):电话号码的字母组合
java·数据结构·后端·python·leetcode·go·回溯