Android系统保存重名文件后引发的异常解决

问题背景

前提业务能力介绍:应用中【相册选择器】原有功能是单选逻辑。当选择图片资源后就不能选择视频资源;相反的当选择了视频资源后就不能选择图片资源。

此功能是业务功能上所约束的必然条件,但在一次测试过程中却发生异常情况:当选择一张特殊图片后可继续选择视频资源;但选择其他图片又不会触发该异常。因此初步判断该特殊图片存在异常情况,从而导致原有业务判断逻辑失效无法辨别该图片资源为图片格式使得可继续选择视频资源。

问题分析

当时出现问题的手机是【华为手机】,系统为【鸿蒙4.0】其对应是【Android系统为10】。首先需要定位该异常图片从何而来以及图片格式是否存在特殊性?

  1. 进入系统相册查看图片资源发现该图片的命名不太正常,此图片资源命名为:2025_xxx_xxx_xxx.jpg(2),推测异常情况大致是该原因导致。
  2. 该图片是应用内部某一业务代码生成,保存图片逻辑是内部统一方法(因此理论上方法保存功能不会存在问题)。
  3. 后缀(2)并非开发代码实现,大概是系统内部文件重名采用重命名生成。
  4. 文件重名情况是因为业务方调用保存图片时采用并发操作,同时发起4个图片保存方法。
  5. 走业务逻辑验证了这一事实,并发保存确实会将【累加值】保存在文件后缀名最后。

以上初步分析大致定位到问题所在:应用中某一个业务并发保存图片时因为重名从而导致文件重名保存在文件后缀最后无法识别为图片文件再而引发其他业务异常。

开发代码中的文件命名

公用的图片保存逻辑中文件命名是以时间戳作为唯一标识符。如下代码是文件名称命名,文件名其精确到秒并加上文件jpeg后缀:

Java 复制代码
SimpleDateFormat df = new SimpleDateFormat("yyyy_MM_dd_HH_mm_ss", Locale.CHINA);//设置日期格式
String fileName = df.format(new Date()) + ".jpeg";

图片保存走ContentProviderMediaStore.Images.MediaDISPLAY_NAME是以上面介绍的fileName命名;MediaStore.Images.Media.MIME_TYPE命名为"image/*"

Java 复制代码
ContentValues values = new ContentValues();
values.put(MediaStore.Images.MediaRELATIVE_PATH, Environment.DIRECTORY_PICTURES+ File.separator + dirName);
values.put(MediaStore.Images.MediaDISPLAY_NAME, fileName);
values.put(MediaStore.Images.Media.MIME_TYPE,"image/*");
Uri uri = null;
try {
    uri = context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
} catch (Exception e) {
    e.printStackTrace();
}

异常是否必然存在

下一步尝试在其他手机上是否可复现该问题:

  1. 使用【小米手机14】,系统为【澎湃OS】,对应【Android系统为15】。
  2. 同样走一遍相同业务逻辑验证问题,并发保存同样也会触发【累加值】逻辑,但文件命名为2025_xxx_xxx_xxx(2).jpg
  3. 再使用应用内相册选择器选中刚保存的文件,业务逻辑正常无异常。

那么问题出在哪呢?不同Android版本上表现有所不同。这大概率是底层对于保存文件重名逻辑上存在差异以及兼容性问题。

PS:在常规操作中,例如PC端或是Mac端当你多次复制粘贴同一份文件时在文件重名时系统会自动在文件名中加上数字累加编号而不是在文件后缀名最后累加。

源码解析

顺着Android源码可知ContentProvider会执行到AOSP实例对象packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java。【insert】方法执行到【insertFile】,然后是【ensureUniqueFileColumns】方法中的【ensureFileColumns】,最后重点阅读其中的2102行代码:

Java 复制代码
final String mimeType = values.getAsString(MediaColumns.MIME_TYPE);
final String displayName = sanitizeDisplayName(
                    values.getAsString(MediaColumns.DISPLAY_NAME));
...
res = FileUtils.buildUniqueFile(res, mimeType, displayName);
  1. mimeType直接取自开发中设置的文件格式。
  2. displayName则也是直接取自开发设置的DISPLAY_NAME。其中sanitizeDisplayName方法可以忽略。
  3. 最终由FileUtils输出最终File文件。
java 复制代码
    public static File buildUniqueFile(File parent, String mimeType, String displayName)
            throws FileNotFoundException {
        final String[] parts = splitFileName(mimeType, displayName);
        return buildUniqueFileWithExtension(parent, parts[0], parts[1]);
    }

    private static File buildUniqueFileWithExtension(File parent, String name, String ext)
            throws FileNotFoundException {
        File file = buildFile(parent, name, ext);
        // If conflicting file, try adding counter suffix
        int n = 0;
        while (file.exists()) {
            if (n++ >= 32) {
                throw new FileNotFoundException("Failed to create unique file");
            }
            file = buildFile(parent, name + " (" + n + ")", ext);
        }

        return file;
    }

FileUtils.buildUniqueFile最终会走到buildUniqueFileWithExtension;通过代码可知上述所说的【累加值】是由此实现。此处的累计逻辑如下:

  1. 当创建的file文件已经存在则n++直至file不存在为止。
  2. 最终以n++累加结果" (" + n + ")"最为文件名的后缀。
  3. ext是文件格式后缀,其正常情况下为jpg。

此处出现异常情况是因为在Android10版本中如上所示直接使用DISPLAY_NAME作为文件名导致。

通过翻阅Android不同版本的AOSP可窥探其一二,Android11起不在直接使用DISPLAY_NAME作为文件名且MimeType做了兼容性设置(当MimeType不存在时会通过File查询文件类型)。

Android10 Android11

Android11则通过File.getName()设置文件名从而避免了文件名中存在后缀名的问题。

问题解决

在回顾代码时发现异常情况发生有许多地方是可优化提升的,例如相册选择器文件资源筛选过滤、图片文件保存命名规范、兼容异步保存情况等避免出现重名情况。

相册选择器扫描

为了兼容已发生的异常文件情况则需要修改原有相册选择器加载文件资源的逻辑。之前异常情况保存的图片文件格式类型的MimeTypeapplication/octet-stream。虽然在低版本系统相册中可识别为图片但文件格式识别出来不是图片类型因此可过滤剔除。

因此可优化只筛选特定类型数据:FileColumns.MEDIA_TYPE_IMAGEFiles.FileColumns.MEDIA_TYPE_VIDEO,过滤掉其他不使用的文件格式。

Java 复制代码
   // 投影列(公共字段+类型特定字段)
    String[] projection = {
        MediaStore.Files.FileColumns._ID,
        MediaStore.Files.FileColumns.DISPLAY_NAME,
        MediaStore.Files.FileColumns.DATE_ADDED,
        MediaStore.Files.FileColumns.SIZE,
        MediaStore.Files.FileColumns.MEDIA_TYPE,  // 关键字段
        MediaStore.Video.Media.DURATION           // 视频时长
    };
    // 过滤条件:只查询图片和视频
    String selection = MediaStore.Files.FileColumns.MEDIA_TYPE + "=?"
        + " OR " + MediaStore.Files.FileColumns.MEDIA_TYPE + "=?";
    
    String[] selectionArgs = new String[] {
        String.valueOf(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE),
        String.valueOf(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO)
    };
    // 按添加时间倒序
    String sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC";
    Cursor cursor = getContentResolver().query(
        uri, projection, selection, selectionArgs, sortOrder)

图片保存逻辑

图片命名规则是以时间戳生成文件名,最小单位是秒级别,当存在异步并发是有风险存在重名情况。

Java 复制代码
SimpleDateFormat df = new SimpleDateFormat("yyyy_MM_dd_HH_mm_ss", Locale.CHINA);
String fileName = getPictureUriName(activity, df.format(new Date()), suffix);

修改成以毫秒级别规避并发重名可能性。

Java 复制代码
SimpleDateFormat df = new SimpleDateFormat("yyyy_MM_dd_HH_mm_ss_SSS", Locale.CHINA);
String fileName = getPictureUriName(activity, df.format(new Date()), suffix);

但在一些场景下保存图片并非是网络图片时可能毫秒级别也无法做到唯一性,那么可以通过再增加一些随机数或唯一值来达到文件绝对唯一性。

规范Insert实现

如下代码中文件名(fileName)和文件格式(mimeType)。为了避免出现【累加值】出现在文件后缀,则可以通过修改文件名(fileName)不添加.xxx并且文件格式(mimeType)写明为image/jpeg即可,若不表明具体mimeType可能会出现文件无具体后缀。

java 复制代码
String fileName = "xxxxxx.jpg"; // "xxxxxx"
String mimeType = "image/*"; // image/jpeg
ContentValues values = new ContentValues();
values.put(MediaStore.Images.MediaRELATIVE_PATH, Environment.DIRECTORY_PICTURES+ File.separator + dirName);
values.put(MediaStore.Images.MediaDISPLAY_NAME, fileName);
values.put(MediaStore.Images.Media.MIME_TYPE,"image/*");
Uri uri = null;
try {
    uri = context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
} catch (Exception e) {
    e.printStackTrace();
}

但此问题在Android11版本及以上不会触发异常,因此最佳实现应该是尽力避免出现重名情况。

其他情况

当我使用模拟器保存图片时有时候会触发以下异常,记一个 Andorid 生成文件失败的bug

文中提到问题与其类似,同样也是因为文件名冲突导致的异常现象。因此除了保证文件名设置正确外还需要避免重名情况过度发生,若超过内部设定的32重名也会抛出异常情况。

总结

最后总结下问题:

  1. 这是一个兼容性问题,起因是异步并发保存文件时重名导致文件命名出现异常(只有在Android10及以下版本存在)。
  2. 在相册选择器扫码筛选文件时需要做好过滤工作,例如可筛选过滤MimeTypeapplication/octet-stream文件,只查询图片恩和视频文件。
  3. insert方法没规范化使用。尽量避免文件重名,保存文件前检验文件名是否可用,重名文件累加也有上限尽量规避。

但解决问题思路应该是在以源头上做好防护和规避,因此异步保存图片文件需要做好并发处理。解决方法上可以将原先文件名命名规则做升级处理,时间戳命名可以采用毫秒做好重名规避措施:

Java 复制代码
SimpleDateFormat df = new SimpleDateFormat("yyyy_MM_dd_HH_mm_ss_SSS", Locale.CHINA);

若毫秒级别还不够预防并发重名问题可再追加随机唯一键等方式处理保证文件名唯一性,这里就不再展开赘述,最主要的点是为了保证文件名是绝对唯一即可。

此问题对于我来说是比较有趣的解决问题案例,当发生一个小问题时从而引发一系列后续异常情况,有点蝴蝶效应那味了。从中也有少许反思:健全的代码难为可贵,良好的编码习惯以及周全所有可能性的全局观是非常重要的。有时候不能觉得自己的代码有多靠谱,有时候也不能觉得系统有多靠谱。

参考

相关推荐
风往哪边走9 分钟前
Media3在线本地视频播放器
android
激昂网络13 分钟前
android kernel代码 common-android13-5.15 下载 编译
android·大数据·elasticsearch
Monkey-旭13 分钟前
Android 人脸识别技术全解析
android·android 人脸识别·ml kit 实战·活体检测技术·人脸识别性能优化·人脸考勤系统·移动端人脸特征提取
vivo互联网技术1 小时前
桌面挂件不能承受之重——GIF
android·gif加载·桌面挂件
444A4E2 小时前
深入理解Linux进程管理:从创建到替换的完整指南
linux·c语言·操作系统
叽哥2 小时前
Kotlin学习第 2 课:Kotlin 基础语法:掌握变量、数据类型与运算符
android·kotlin·app
tangweiguo030519872 小时前
Android原生(Kotlin)与Flutter混合开发 - 设备控制与状态同步解决方案
android·flutter
灵魂猎手2 小时前
7. MyBatis 的 ResultSetHandler(一)
java·后端·源码