用户在系统文件选择器中选了一份 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 作用域内持有流;何时触发:后台任务调用 openInputStream 或 openFileDescriptor 时;结果交给谁:拷贝完成后,预览或后续任务只收到应用沙盒中的完整缓存文件。
Java I/O:字节、字符与缓冲不是同一件事
File 是路径的抽象,不是已打开文件、更不是权限证明。FileInputStream 与 FileOutputStream 搬运原始字节,适合图片、PDF、压缩包和数据库导出;Reader 与 Writer 按字符集解码或编码文本,读取 JSON、CSV 或配置时必须明确 UTF-8 等编码,不能把任意二进制内容塞进字符流。
缓冲流如 BufferedInputStream、BufferedOutputStream、BufferedReader 会减少小块读取造成的系统调用;它不改变数据含义,也不替代关闭资源。NIO 的 Path、Files、Channels 和 ByteBuffer 提供路径操作、批量缓冲与 channel 概念,但 Android 上是否直接采用 java.nio.file 取决于 API 级别和 desugaring。面对一个 Uri 时,先走 ContentResolver 拿流或 ParcelFileDescriptor,不要因为 NIO 存在就猜测出一个 Path。
来源锚点与职责
下表同时回答谁创建、谁持有、何时触发和数据交给谁。这里的类名是公开 API 或实现定位线索,不应把内部实现当成稳定契约。
| 来源锚点 | 谁创建 | 谁持有 | 何时触发 | 数据交给谁 |
|---|---|---|---|---|
Context |
Framework 创建 Application、Activity 或 Service Context | 组件及其生命周期拥有者持有,避免无界面需求时长期泄漏 Activity | 组件创建或注入依赖时 | filesDir、cacheDir、ContentResolver 等能力交给应用层 |
ContentResolver |
Context.getContentResolver() 提供系统连接点 |
Context 与框架管理其连接;调用方只持有一次调用返回的资源 | openInputStream、openOutputStream、openFileDescriptor 被调用 |
InputStream、OutputStream 或 ParcelFileDescriptor 交给后台 I/O 代码 |
ParcelFileDescriptor 或 InputStream |
ContentProvider 按 Uri 和模式打开 | 调用方在局部作用域内持有并关闭;AutoCloseInputStream 可接管 descriptor 关闭 |
Provider 同意访问后 | 缓冲复制逻辑读取字节,随后交给临时文件 |
缓存目录 cacheDir |
Context 指向应用沙盒目录 |
应用拥有,系统可在空间紧张时清理 | 预览、可重建下载或转换结果需要暂存时 | 已完成文件交给同一应用的预览、上传或解析层 |
| DataStore | 应用在单例依赖图中创建 DataStore |
DataStore 实例与其协程或调用范围管理读写 | 偏好或小型类型化状态变更时 | Flow 或一次性读取结果交给状态层 |
| Room | 应用创建 RoomDatabase 单例 |
Room 持有连接、DAO 与数据库文件访问;应用通过 DAO 使用它 | 查询或事务执行时 | 实体或查询结果交给 Repository、UseCase 或 UI 状态 |
来源事实: 应用内部的 filesDir、cacheDir 和 Room 数据库文件属于 app sandbox;外部 Uri 的读取要经对应 Provider;DataStore 是面向偏好或类型化数据模型的存储组件,Room 通过 DAO 和事务管理关系型数据。 生产建议: 让 Repository 或专门的存储服务拥有 I/O 操作,将结果转换成业务模型,不把 InputStream、Cursor 或数据库实体一路暴露到 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.move 带 ATOMIC_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 放进后台执行器,只有最终的 Success、Cancelled、PermissionDenied 或 CorruptContent 状态回到 UI。
上线前检查
- 是否把外部内容当作
Uri,并通过ContentResolver获取流或 descriptor? - 每个
InputStream、OutputStream和ParcelFileDescriptor是否有清楚的关闭所有者? - 临时文件是否与目标在同一目录或同一文件系统,并且失败时会被清理?
- 缓存缺失、取消、权限失效与内容损坏是否都被建模为可恢复结果?
- Durable 配置是否使用 DataStore,关系数据是否使用 Room,而非一律写进普通文件?
- 所有磁盘与 Provider I/O 是否离开主线程,且生产实现已验证原子替换与迁移策略?
面试表达: "在 Android 上我把 Uri 当成带 Provider 权限的内容句柄,而不是路径。Repository 在后台经 ContentResolver 拿流,用 try-with-resources 关闭资源,先把字节写到同目录临时文件,校验后再替换缓存;取消或失败只清理临时文件,绝不破坏旧版本。DataStore 放小型状态,Room 管关系数据,二者都不替代通用文件流。"
下一步可以把这个存储边界连接到注解、反射与动态代理:用注解声明序列化字段或存储策略,用反射理解 schema 元数据,再用代理或拦截器把权限、线程和错误映射集中在 Repository 边界。