
1. 理解Android存储权限变革从Android 10开始Google引入了分区存储Scoped Storage机制这个改变在Android 11API 30及更高版本中变得更加严格。我记得第一次适配这个新特性时发现原来直接操作SD卡目录的方式完全行不通了当时真是踩了不少坑。分区存储的核心思想是限制应用对共享存储空间的随意访问。在旧版本中应用只要获取了存储权限就能像文件管理器一样随意读写整个外部存储。现在系统将存储空间划分为三类应用专属目录无需权限共享媒体文件通过MediaStore API访问其他文件需要特殊权限实测下来最常用的图片保存场景主要涉及共享媒体文件区域。这里有个关键点即使你申请了READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限在Android 10上也仅能访问媒体文件不能像以前那样随意创建文件夹了。2. MediaStore API深度解析MediaStore就像是一个多媒体数据库管家它管理着设备上的图片、视频、音频等文件。我刚开始用的时候总把它想象成一个图书馆的管理系统MediaStore.Images对应图片区MediaStore.Video对应视频区MediaStore.Audio对应音乐区MediaStore.Downloads对应下载区每个区域都有详细的编目信息MediaColumns比如DISPLAY_NAME文件名MIME_TYPE文件类型DATE_ADDED创建时间RELATIVE_PATH相对路径保存图片时我们需要先准备好这些图书卡片ContentValues然后交给图书管理员ContentResolver处理。这里有个实用技巧设置IS_PENDING1可以避免保存过程中被其他应用扫描到半成品文件。3. 兼容不同Android版本的图片保存方案在实际项目中我总结出一套兼容方案核心逻辑是这样的public static boolean saveImageCompat(Context context, Bitmap bitmap) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { return saveWithMediaStore(context, bitmap); } else if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { return saveWithLegacyAPI(context, bitmap); } else { return savePreMarshmallow(context, bitmap); } }对于Android 10的版本重点在于正确使用MediaStoreprivate static boolean saveWithMediaStore(Context context, Bitmap bitmap) { ContentValues values new ContentValues(); values.put(MediaStore.Images.Media.DISPLAY_NAME, my_image.jpg); values.put(MediaStore.Images.Media.MIME_TYPE, image/jpeg); values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /MyApp); ContentResolver resolver context.getContentResolver(); Uri uri resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); try (OutputStream out resolver.openOutputStream(uri)) { return bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out); } catch (IOException e) { resolver.delete(uri, null, null); return false; } }对于Android 6-9的版本需要注意运行时权限检查和传统文件操作private static boolean saveWithLegacyAPI(Context context, Bitmap bitmap) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) ! PackageManager.PERMISSION_GRANTED) { return false; } File dir new File(Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_PICTURES), MyApp); if (!dir.exists() !dir.mkdirs()) { return false; } File file new File(dir, my_image.jpg); try (FileOutputStream out new FileOutputStream(file)) { boolean saved bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out); if (saved) { context.sendBroadcast(new Intent( Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(file))); } return saved; } catch (IOException e) { return false; } }4. 处理MANAGE_EXTERNAL_STORAGE权限Google Play对MANAGE_EXTERNAL_STORAGE权限的审核非常严格我提交的某个应用就因为这个被拒了三次。这个权限相当于给了应用管理员钥匙能访问所有存储区域但只有在以下场景才应该使用文件管理器类应用备份恢复工具防病毒软件对于普通应用Google建议优先使用MediaStore API。如果确实需要这个权限需要在manifest中声明uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE /然后在代码中检查if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { if (!Environment.isExternalStorageManager()) { Intent intent new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); context.startActivity(intent); return false; } }在Google Play上架时还需要在应用声明中提供正当理由。我的经验是能不用尽量不用因为用户看到这个权限请求也会很警惕。5. 常见问题与解决方案在实际开发中我遇到过几个典型问题问题1图片保存后相册不显示这是因为MediaStore的数据库更新有延迟。解决方案是确保正确设置了所有必需的ContentValues字段对于Android 10以下版本主动发送MEDIA_SCANNER广播可以调用MediaScannerConnection.scanFile()强制刷新问题2RELATIVE_PATH不生效这个问题我调试了很久发现要注意路径不能以/开头或结尾只能使用预定义的标准目录如PICTURES、DOWNLOADS子目录要用/分隔不能用File.separator问题3保存大图片OOM处理大图时建议采用采样率压缩使用try-with-resources确保流关闭考虑分块写入// 采样率压缩示例 BitmapFactory.Options options new BitmapFactory.Options(); options.inSampleSize 2; // 缩小为1/2 Bitmap scaledBitmap BitmapFactory.decodeFile(path, options);6. 性能优化实践在相册类应用中直接查询MediaStore可能会很慢。我通过以下优化手段将加载时间从3秒降到了300ms批量操作优化使用ContentResolver.applyBatch()代替多次单独操作ArrayListContentProviderOperation ops new ArrayList(); ops.add(ContentProviderOperation.newInsert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI) .withValues(values1).build()); ops.add(ContentProviderOperation.newInsert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI) .withValues(values2).build()); try { context.getContentResolver().applyBatch(MediaStore.AUTHORITY, ops); } catch (Exception e) { e.printStackTrace(); }分页查询技巧当需要加载大量媒体文件时// Android 11的现代方式 Bundle queryArgs new Bundle(); queryArgs.putInt(ContentResolver.QUERY_ARG_LIMIT, pageSize); queryArgs.putInt(ContentResolver.QUERY_ARG_OFFSET, page * pageSize); queryArgs.putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, new String[]{MediaStore.Images.Media.DATE_ADDED DESC}); Cursor cursor resolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, queryArgs, null);缩略图预加载使用ThumbnailUtils提前生成缩略图Bitmap thumbnail ThumbnailUtils.extractThumbnail( BitmapFactory.decodeFile(filePath), thumbWidth, thumbHeight);7. 文件删除与更新策略删除媒体文件时我发现很多开发者会犯两个错误只删除了数据库记录但没删除实际文件或者相反只删了文件没更新数据库正确的做法应该是// 先获取文件ID String[] projection {MediaStore.Images.Media._ID}; String selection MediaStore.Images.Media.DATA ?; String[] selectionArgs {filePath}; try (Cursor cursor resolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, selection, selectionArgs, null)) { if (cursor ! null cursor.moveToFirst()) { long id cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)); Uri contentUri ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); // 同时删除文件和数据库记录 resolver.delete(contentUri, null, null); } }对于批量删除我发现一个性能技巧先收集所有要删除的URI然后在一个事务中处理。8. 调试与测试建议在适配过程中这些调试方法帮了我大忙检查存储权限状态if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { // Android 11 Log.d(Storage, isExternalStorageManager: Environment.isExternalStorageManager()); } else { // Android 6-10 int write ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); Log.d(Storage, WRITE_EXTERNAL_STORAGE: (write PackageManager.PERMISSION_GRANTED)); }查看MediaStore中的记录adb shell content query --uri content://media/external/images/media清除MediaStore缓存有时候数据库会出现不一致的情况可以重置adb shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d file:///sdcard在真机测试时我建议准备三台设备Android 9、Android 11和最新Android版本覆盖所有场景。模拟器虽然方便但有些存储行为与实际设备有差异。