ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SAF文件批量提取工具SafExtractor的设计思路与踩坑记录

SAF文件批量提取工具SafExtractor的设计思路与踩坑记录 简介SafExtractor是一款面向游戏开发学习者与资源素材收集者的.saf格式资源提取工具主要解决加密或打包在.saf文件中的图片、界面素材难以直接获取的问题。工具使用方式简单运行后输入.saf文件完整路径即可一键提取适合游戏设计、逆向练习或素材复用等场景。压缩包内共含1385个文件以570个png、462个jpg图片素材和351个xml配置文件为主另有SafExtractor.exe可执行程序及txt说明文件整体体积仅5.86MB便于快速下载。其中“资源”目录收录了“大鱼吃小鱼”游戏的整套美术资源覆盖地图背景、主界面、信息框等常用元素可作为游戏原型设计或界面布局参考。目前已有768人学习下载对于需要快速获取分类游戏素材、研究saf资源封装结构的读者而言是一份小而实用的工具包。 如果你做过Android文件类的应用一定感受过SAFStorage Access Framework的别扭——它能让你优雅地避开存储权限的申请但真想把用户选中的那批文件批量保存到本地时你会发现它一点都不优雅。我最近就是因为一个素材管理系统需要批量导入图片和文档被SAF的Uri和权限模型折腾得够呛最终自己写了SafExtractor这个资源提取工具把从Uri到本地文件的整条链路彻底打通了。今天就把这个工具的设计思路、核心实现和踩坑记录一次性分享出来希望给正在跟SAF较劲的朋友一些参考。1. 先说说为什么我会去写这么个工具1.1 SAF把权限问题解决了却把文件访问变得别扭过去要在Android上做个文件导入功能最省事的做法是直接申请READ_EXTERNAL_STORAGE权限然后拿Environment.getExternalStorageDirectory()去拼路径。但这带来两个问题一是对用户来说权限太粗暴一个应用要读整个存储二是从Android 10开始分区存储越来越严格这种老写法逐渐走不通了。SAF的核心思路是让用户通过系统文件选择器主动授权某个文件或某个目录给应用应用之后只能访问用户明确给出的那部分内容。听起来很美好但真实现起来你会发现拿到的是一个content://开头的Uri不再是/storage/emulated/0/...这样的路径。文件路径没法用普通Java File操作整套失效连获取文件名都得通过ContentResolver查字段。一次两次的单文件操作还能忍一旦涉及批量提取几十个文件靠手写循环处理Uri、权限、流读写代码量直接翻倍而且处处是坑。1.2 SafExtractor到底解决什么问题SafExtractor是我自己封装的一个SAF资源提取工具主打两个能力一是把用户通过系统选择器选中的多个SAF Uri安全高效地提取到应用私有目录或用户指定的目标目录二是支持从ACTION_OPEN_DOCUMENT_TREE返回的树Uri中递归提取整个目录的文件。工具的核心不是做花哨的界面而是把那些看似简单、一写就错的底层逻辑统一收口Uri权限持久化、文件名解析、冲突处理、流式复制、进度回调、取消机制。如果你正在做素材管理、备份工具、文件导入导出这类功能这套思路可以直接拿去用或者把SafExtractor理解成SAF和传统文件系统之间的一座桥帮你把两者顺畅地接起来。2. SAF的权限模型不搞懂这个后面全是坑2.1 content Uri和File路径的本质区别SAF返回的Uri长这样content://com.android.providers.media.documents/document/image%3A123456。它不直接指向文件系统里的某个具体路径而是指向一个文档ID最终由系统里对应的DocumentsProvider来解析。应用要读取这个Uri背后的内容唯一的标准途径是走ContentResolver比如contentResolver.openInputStream(uri)?.use { input - // 从input里读字节 }因此SafExtractor里所有读取动作都建立在ContentResolver之上不去猜路径不拼路径不依赖具体提供器的实现。这一条既是规范也是保证工具稳定性的底线。2.2 一次性授权和持久化授权系统文件选择器返回Uri时会通过Intent的flags携带授权信息常见的有Intent.FLAG_GRANT_READ_URI_PERMISSION和Intent.FLAG_GRANT_WRITE_URI_PERMISSION。如果你只在这个Activity存活期间使用Uri那没问题但应用进程一旦被杀或者用户过段时间再回来授权就失效了再次用这个Uri去读文件会直接抛SecurityException。正确的做法是第一时间调用takePersistableUriPermission来持久化授权val flags intent.flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) if (flags ! 0) { contentResolver.takePersistableUriPermission(uri, flags) }这里有两个容易忽略的细节。第一takePersistableUriPermission并不是所有Uri都能调用成功如果返回的Uri本来就不支持持久化会抛SecurityException所以工具里要做异常捕获不能因为一次授权失败让整个提取流程崩溃。第二持久化之后要把Uri字符串保存下来下次冷启动时从本地存储恢复再配合contentResolver.persistedUriPermissions检查当前是否还有权限这样应用重启后依然能继续提取未完成的文件。2.3 权限检查是提取之前的第一道门SafExtractor在读取任何Uri之前都会调用checkUriPermission做一次检查。检查逻辑本身很简单查询contentResolver.persistedUriPermissions看看当前Uri对应的读写权限是否还存在再尝试用openInputStream做一次轻量探测。这里额外做一次探测是为了处理某些第三方DocumentsProvider实现不规范明明有权限但查询时依然会返回异常的情况。fun isReadable(uri: Uri): Boolean { return try { contentResolver.openInputStream(uri)?.close() ?: return false true } catch (e: Exception) { false } }就是这个简单的检查函数帮我在后续开发中省掉了大量为什么突然读不了的排查时间。3. SafExtractor的核心实现从Uri到文件的完整链路3.1 工具的整体模块划分SafExtractor不是一个单文件搞定所有事的脚本而是按职责拆了几个核心类模块职责UriHelper负责从Intent里提取多个Uri、判断Uri类型、解析持久化权限MetadataResolver通过ContentResolver查询文件名、大小、MIME类型StreamExtractor核心提取器把输入流写到目标文件支持进度通知和取消ConflictResolver处理目标路径重名、非法字符、路径穿越等问题BatchProcessor协调多文件的批量提取任务管理并发和异常隔离每个模块都尽量保持单一职责互相之间通过简单的数据类传参这样后面单独替换或测试某个环节都比较轻松。3.2 从Intent中取出全部Uri用ACTION_OPEN_DOCUMENT配合EXTRA_ALLOW_MULTIPLE可以实现多选文件返回时用户选中的文件可能是一个Uri也可能是多个Uri处理逻辑要有兼容性fun extractUrisFromIntent(intent: Intent): ListUri { val uris mutableListOfUri() intent.data?.let { uris.add(it) } intent.clipData?.let { clip - for (i in 0 until clip.itemCount) { clip.getItemAt(i).uri?.let { uris.add(it) } } } return uris.distinct() }如果直接取intent.data多选时很可能只拿到第一个又或者有些机型在多选时data为null所有Uri都放在clipData里所以两者都要处理。这一步虽然简单但它是整个SafExtractor入口正确性的基础。3.3 查询文件元数据文件名和大小SAF没有提供直接的获取文件路径的接口但大多数DocumentsProvider都会暴露OpenableColumns.DISPLAY_NAME和OpenableColumns.SIZE这两个字段可以通过ContentResolver查询得到data class ResourceMeta(val name: String, val size: Long, val mimeType: String) fun resolveMeta(context: Context, uri: Uri): ResourceMeta? { val name: String val size: Long var mimeType: String? null context.contentResolver.query( uri, null, null, null, null )?.use { cursor - if (cursor.moveToFirst()) { val nameIdx cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val sizeIdx cursor.getColumnIndex(OpenableColumns.SIZE) name if (nameIdx 0) cursor.getString(nameIdx) ?: unknown_${System.currentTimeMillis()} else unknown_${System.currentTimeMillis()} size if (sizeIdx 0) cursor.getLong(sizeIdx) else -1L mimeType context.contentResolver.getType(uri) } else { return null } } return ResourceMeta(name, size, mimeType ?: application/octet-stream) }这里有几个经验点一是DISPLAY_NAME字段可能存在但返回的字符串为空所以有兜底逻辑二是SIZE可能返回-1表示不确定大小尤其当文件来自云盘这类虚拟存储时三是cursor必须用use块安全关闭否则会泄漏。3.4 流式复制的标准写法拿到输入流之后下一步是把它写入目标文件。SafExtractor这里采用流式复制不走一次性读入内存的粗暴路线因为用户选择的文件可能达到几百MB甚至几个GB直接readBytes()会把内存撑爆。fun extract(input: InputStream, targetFile: File, callback: ProgressCallback?): Boolean { val buffer ByteArray(DEFAULT_BUFFER_SIZE) // 64KB var totalRead 0L targetFile.outputStream().use { output - var read input.read(buffer) while (read ! -1) { output.write(buffer, 0, read) totalRead read callback?.onProgress(totalRead) if (Thread.currentThread().isInterrupted) { return false } read input.read(buffer) } } return true }缓冲区我默认用64KB实测在Android真机上既能保证吞吐量又不会因为缓冲区过大导致内存压力。回调onProgress是在IO线程里执行的所以回调内部如果有UI更新记得切到主线程。之所以在循环里检查中断标记是为了配合用户点击取消按钮时能快速中断复制而不是傻傻等到整个文件复制完。4. 批量提取场景下的关键处理细节4.1 多文件的异常隔离批量提取最怕一个文件出错整批失败。SafExtractor的BatchProcessor采用逐文件独立处理的策略每个文件提取时单独捕获异常失败的文件记录错误原因成功的文件正常返回结果最后把所有结果汇总给调用方。这样用户一次选了50个文件即使有3个因为权限或者流读取异常失败也不影响另外47个成功提取。异常信息要尽量具体比如区分无权限文件不存在IO失败等错误类型方便在UI上展示给用户看。data class ExtractResult(val uri: Uri, val success: Boolean, val targetPath: String?, val error: String?)4.2 文件名冲突和非法字符处理提取文件到目标目录时重名是最常见的问题。举个例子两个不同目录下可能都有photo.jpg如果都提取到同一个目录后一个会把前一个覆盖。SafExtractor的ConflictResolver会在写入之前检查目标文件是否已存在如果存在就在名字末尾追加(1)、(2)这样的序号而不是简单覆盖或报错。还有一个更隐蔽的问题某些DocumentsProvider返回的DISPLAY_NAME可能包含/、\等路径分隔符甚至包含空字符如果直接用它拼路径轻则提取失败重则引发路径穿越。所以任何来自Uri的名称信息都必须要做一次清洗fun sanitizeFileName(name: String): String { val cleaned name.replace(Regex([\\\\/:*?\|]), _) return cleaned.ifBlank { unnamed_${System.currentTimeMillis()} } }4.3 大文件处理和进度通知对于大于100MB的文件建议在UI上展示进度条否则用户会以为程序卡死。SafExtractor的进度通知逻辑基于文件总大小如果MetadataResolver能拿到SIZE字段就用已读字节/总大小计算百分比如果拿不到总大小就退化为已读取字节数这种不精确的反馈同时增加一个确定或不确定状态的标志。大文件还有另一个问题网络磁盘或云盘上的文件读取速度很慢如果全部串行执行用户会等很久。SafExtractor提供了基于协程的并发提取选项但默认并发数控制为固定大小比如同时最多处理3个文件。并发数的选择不是越大越好——如果文件来自同一个提供器并发太高反而因为IO竞争导致变慢。4.4 目录模式ACTION_OPEN_DOCUMENT_TREE的递归提取单选文件模式之外还需要支持用户选择一个目录然后提取整个目录下所有文件。SAF通过ACTION_OPEN_DOCUMENT_TREE返回一个树Uri此时可以配合DocumentFile.fromTreeUri(context, treeUri)来遍历目录树。fun walkDocumentTree(documentFile: DocumentFile, outputDir: File) { if (documentFile.isDirectory) { documentFile.listFiles().forEach { child - walkDocumentTree(child, outputDir) } } else if (documentFile.isFile) { val target File(outputDir, sanitizeFileName(documentFile.name ?: file_${System.currentTimeMillis()})) val input contentResolver.openInputStream(documentFile.uri) ?: return extract(input, target, callback) } }这里有个体验上的细节目录遍历时很可能遇到某些子目录没有读取权限或者因为嵌套层次过深导致递归栈溢出。SafExtractor把遍历深度限制在5层以内同时忽略以.开头的隐藏文件避免把系统垃圾文件也提取出来。5. 实测踩过的三个大坑和最终解法5.1 坑一takePersistableUriPermission引发的SecurityException最早一版里我在拿到Uri后直接调用持久化授权结果在部分小米和一加机型上运行时直接崩溃。日志显示是SecurityException: Permission denial。原因是这些设备自带的DocumentsProvider并没有实现支持持久化授权的接口或者返回的Uri本身就不符合持久化条件。最终的解决办法是在调用takePersistableUriPermission时用try-catch包住并且捕获异常后把错误透传给上层同时保证即使持久化失败本次进程内的提取依然可以正常进行只是下次启动时这个Uri会失效。从用户角度来说改动后应用再也不容易闪退了最多是上次选的文件夹需要重新选一次。5.2 坑二DISPLAY_NAME返回空字符串导致文件无法打开有用户反馈从某个第三方网盘应用选择文件后SafExtractor提取出来的文件名全是unknown_xxx而且部分文件无法预览。排查下来发现那个网盘的DocumentsProvider在DISPLAY_NAME字段上返回的是空字符串而我的代码没有对空字符串做处理导致目标文件以unknown_xxx命名后缀丢失预览器自然认不出格式。修复方案是两件事第一如果DISPLAY_NAME为空就从Uri的最后一个路径段里尝试推断文件名第二如果MIME类型可以获取到就结合MIME映射一个合理的扩展名。这两个兜底逻辑加上之后再没有出现过文件提取成功但打不开的情况。5.3 坑三大文件提取容易出现ANR和流泄漏有段时间不少用户反馈提取1GB以上的视频文件时界面会卡住很久然后弹出应用无响应。定位后发现问题核心在于我最初把提取逻辑放到了主线程执行同时又调用了频繁的进度回调主线程被IO操作和UI更新双重堵塞自然就ANR了。重构方案是所有提取操作统一放在Dispatchers.IO协程里跑进度回调通过withContext(Dispatchers.Main)切回主线程同时给CancellationException单独处理保证用户点击取消后任务能及时退出。另外每个输入流和输出流使用use块不依赖调用方去手动关闭从源头上杜绝了流泄漏。6. 扩展思路和我的后续实践6.1 把SafExtractor接进更多场景SafExtractor做出来之后我陆续给它加了几个实际很受用的功能点。第一个是内容hash校验提取时计算文件MD5提取完和目标文件的MD5做比对能第一时间发现静默损坏的情况。第二个是支持提取后删除源文件相当于做移动操作这需要额外的FLAG_GRANT_WRITE_URI_PERMISSION而且要弹出确认框让用户明确知道删除是不可逆操作。第三个是导出MediaStore当目标目录是系统公共目录比如Pictures时可以用MediaStore的接口把文件注册到媒体库这样用户在相册里能看到提取后的图片。6.2 工具本身的稳定性设计整体框架稳定之后我把SafExtractor的每个环节都加上了失败可回退的机制。比如resolveMeta拿不到元数据时不是直接返回失败而是继续尝试用Cursors里能拿到的任何字段补充再比如openInputStream失败时会重新检查一次权限如果权限确实还在就再重试一次IO操作再报错。这些细节虽然增加了一些代码量但换来的是面对不同厂商ROM、不同DocumentsProvider时依然有稳定表现。6.3 如果你也想自己写一个提取工具我的建议是不要一上来就写完整功能先做最小闭环从系统选择器拿到一个Uri把内容复制到目标目录跑通之后再逐步加上多选、批量、树目录、进度、并发。每一个阶段都做真机验证特别是各品牌ROM的DocumentsProvider实现差别很大模拟器上往往一切正常真机上就会有各种奇怪问题。如果你只是想用现成的方案也可以借鉴SafExtractor的整体设计根据自己项目的需求裁剪。核心就是那三件事权限要持久化、流要正确关闭、文件名要消毒。把这三件事处理好SafExtractor这个工具的价值就真正发挥出来了。我后来在项目里把这段提取逻辑定为基础设施所有文件操作都走它半年下来没有再被SAF的问题困扰过。本文还有配套的精品资源点击获取
返回列表