Android 10分区存储适配实战:从MediaStore到SAF的完整迁移指南

发布时间:2026/5/20 9:56:48

Android 10分区存储适配实战:从MediaStore到SAF的完整迁移指南 Android 10分区存储迁移实战MediaStore与SAF的深度应用指南当小米工程师在Redmi K30上测试新版相册应用时发现一个诡异现象通过传统File API保存的用户照片在系统升级到Android 10后突然消失了。这背后正是Android 10引入的分区存储机制在发挥作用——一个让无数开发者又爱又恨的存储革命。本文将带你穿透概念迷雾直击适配核心用真实项目经验告诉你如何优雅跨越这道技术鸿沟。1. 分区存储的本质与挑战在Android 10之前开发者可以像在自家后院一样随意访问整个外部存储空间。这种自由带来的代价是用户相册里突然出现各种应用的缓存目录卸载应用后残留大量垃圾文件隐私数据被随意读取。Google的解决方案就是分区存储Scoped Storage它像给每个应用划分了专属领地。关键变化点对比表特性Android 9及之前Android 10分区存储模式访问范围整个外部存储应用私有目录受限公共媒体文件权限要求需要READ/WRITE_EXTERNAL_STORAGE自建文件无需权限文件持久性卸载后残留私有目录随应用卸载清除访问方式直接文件路径主要依赖MediaStore/SAF我在适配某相机应用时踩过的典型坑// 旧代码 - 在Pictures目录创建子目录 File galleryDir new File(Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_PICTURES), MyCamera); if (!galleryDir.exists()) { galleryDir.mkdirs(); // Android 10上会失败 }提示判断是否启用分区存储的标准方法if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q !Environment.isExternalStorageLegacy()) { // 分区存储模式逻辑 }2. MediaStore API的完全指南MediaStore就像个智能档案管理员它知道所有媒体文件的元信息。但要用好它需要掌握这些实战技巧2.1 文件创建的正确姿势以保存拍摄的照片为例注意这些细节ContentValues values new ContentValues(); values.put(MediaStore.Images.Media.DISPLAY_NAME, IMG_ System.currentTimeMillis()); values.put(MediaStore.Images.Media.MIME_TYPE, image/jpeg); values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /MyCamera); // 关键相对路径 Uri uri getContentResolver().insert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); try (OutputStream out getContentResolver().openOutputStream(uri)) { bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out); }常见踩坑点硬编码绝对路径如/sdcard/Pictures将导致失败未指定RELATIVE_PATH时文件会保存到媒体类型根目录非媒体文件如PDF不能通过MediaStore保存2.2 复杂查询的优化策略当需要查询特定条件的媒体文件时String[] projection { MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATE_TAKEN }; String selection MediaStore.Images.Media.DATE_TAKEN ?; String[] args { String.valueOf(startTimestamp) }; String sortOrder MediaStore.Images.Media.DATE_TAKEN DESC; try (Cursor cursor getContentResolver().query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, selection, args, sortOrder)) { while (cursor.moveToNext()) { long id cursor.getLong(0); Uri uri ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); // 处理文件URI } }注意查询结果只包含媒体文件元信息实际文件访问仍需通过ContentResolver打开流3. Storage Access Framework的进阶技巧当需要处理非媒体文件或获取持久访问权限时SAF是唯一选择。但它的使用远比看起来复杂3.1 目录授权的正确流程获取文档树授权的完整示例// 启动目录选择器 Intent intent new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); startActivityForResult(intent, REQUEST_CODE_DIRECTORY); // 处理返回结果 Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode REQUEST_CODE_DIRECTORY resultCode RESULT_OK) { Uri treeUri data.getData(); // 持久化保存权限 getContentResolver().takePersistableUriPermission( treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); // 使用DocumentFile操作文件 DocumentFile root DocumentFile.fromTreeUri(this, treeUri); DocumentFile newFile root.createFile(text/plain, note.txt); } }关键注意事项每次应用重启后都需要检查权限是否仍然有效通过DocumentFile类进行文件操作而非传统File API用户随时可能撤销授权代码需做好异常处理3.2 特定文件类型处理技巧处理PDF文件的典型场景Intent intent new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType(application/pdf); startActivityForResult(intent, REQUEST_CODE_PDF); // 在onActivityResult中获取文件URI后 try (ParcelFileDescriptor pfd getContentResolver().openFileDescriptor(uri, r)) { FileDescriptor fd pfd.getFileDescriptor(); PDFRenderer renderer new PDFRenderer(ParcelFileDescriptor.dup(fd)); // 渲染PDF页面 }4. 混合存储架构的设计实践在适配某云盘应用时我们创新性地采用了分层存储策略4.1 存储策略决策树是否媒体文件? ├── 是 → 使用MediaStore API │ ├── 需要长期共享? → 存入公共媒体目录 │ └── 仅应用使用? → 存入应用私有目录 └── 否 → 使用SAF或应用私有存储 ├── 用户需要选择? → SAF文档选择器 └── 应用私有文件 → Context.getExternalFilesDir()4.2 性能优化方案缓存管理策略// 在私有缓存目录保存缩略图 File cacheDir new File(getExternalFilesDir(Environment.DIRECTORY_PICTURES), .thumbnail_cache); if (!cacheDir.exists()) { cacheDir.mkdirs(); } // 定期清理如使用WorkManager File[] files cacheDir.listFiles(); long cutoff System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7); for (File file : files) { if (file.lastModified() cutoff) { file.delete(); } }批量操作优化ArrayListContentProviderOperation ops new ArrayList(); for (ImageInfo image : imagesToAdd) { ContentValues values new ContentValues(); values.put(MediaStore.Images.Media.DISPLAY_NAME, image.name); values.put(MediaStore.Images.Media.RELATIVE_PATH, Pictures/MyApp); ops.add(ContentProviderOperation.newInsert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI) .withValues(values) .build()); } try { getContentResolver().applyBatch(MediaStore.AUTHORITY, ops); } catch (RemoteException | OperationApplicationException e) { // 处理异常 }5. 疑难问题排查手册在真实项目中遇到的典型问题及解决方案5.1 权限丢失问题现象用户反馈重启手机后无法访问之前授权的文件。解决方案// 启动时检查并恢复权限 ListUriPermission perms getContentResolver().getPersistedUriPermissions(); for (UriPermission perm : perms) { if (perm.isReadPermission()) { // 重新建立文档树访问 DocumentFile root DocumentFile.fromTreeUri(this, perm.getUri()); // 验证是否仍然可访问 if (root.canRead()) { // 恢复业务逻辑 } } }5.2 文件路径兼容方案对于必须使用文件路径的第三方库如某些图像处理SDK可采用临时文件桥接// 从MediaStore URI获取临时文件 Uri mediaUri ...; // 从MediaStore获取的URI try (InputStream in getContentResolver().openInputStream(mediaUri); OutputStream out new FileOutputStream(tempFile)) { byte[] buf new byte[1024]; int len; while ((len in.read(buf)) 0) { out.write(buf, 0, len); } } // 将tempFile路径传给第三方库 processImage(tempFile.getAbsolutePath());在适配某金融应用时我们发现其使用的PDF签名库必须接收文件路径。通过这种桥接方式既满足了库的要求又遵循了分区存储规范。

相关新闻