18、兼容性适配:不同Android版本、不同厂商ROM的差异处理

做音视频开发这么多年,我踩过最多的坑,不是API不会用,而是——同一套代码,在小米上跑得好好的,换到华为直接黑屏;Android 12没问题,Android 8.1直接崩溃。

说白了,MediaExtractor这套API虽然Google已经封装得挺好了,但底层实现还是依赖硬件解码器和厂商驱动。不同厂商、不同版本,行为差异比你想象的大得多。

核心原则:不要假设任何API在所有设备上行为一致。永远做防御性编程。

18.1 版本差异:API行为变化

先说说Android版本本身带来的差异。我整理了几个最容易踩坑的点:

API / 行为 Android 8.0 (API 26) 以下 Android 8.0+ Android 10+
MediaExtractor初始化 只能传文件路径 支持FD、Uri 支持MediaDataSource
轨道格式获取 getTrackFormat()可能返回null 基本稳定 增加了HDR元数据字段
seekTo模式 SEEK_TO_PREVIOUS_SYNC不稳定 修复了大部分问题 新增SEEK_TO_CLOSEST
音频PCM编码 部分设备不支持ENCODING_PCM_FLOAT 支持度提高 基本全覆盖

我记得有一次,项目需要在Android 7.1上跑。结果MediaExtractor.setDataSource()传Uri直接抛异常。查了半天文档才发现,API 26以下只支持文件路径。后来我加了个版本判断,低版本先转成FileDescriptor再传。

我的习惯:写一个VersionCompat工具类,把所有版本差异封装起来。业务层永远调用统一接口,底层自己判断版本。

18.2 厂商ROM差异:华为、小米、OPPO、vivo

厂商差异才是真正的噩梦。Google的CTS测试只能保证基础API能用,但具体行为嘛……嗯,你懂的。

18.2.1 华为(HarmonyOS / EMUI)

华为的设备有个特点:对MediaExtractor的轨道数量限制比较严格。我曾经遇到过,一个MP4文件有5条音轨,在小米上全都能读到,在华为上只暴露了前3条。

另外,华为的硬件解码器对某些编码格式支持不全。比如HEVC(H.265)在麒麟芯片上虽然支持,但MediaExtractor.getTrackFormat()返回的mime类型可能是"video/hevc"而不是标准的"video/hevc"。嗯,这里要注意,最好做一次mime标准化处理。

18.2.2 小米(MIUI / HyperOS)

小米的问题主要在seekTo上。我做过测试,同一段视频,在小米11上调用seekTo(1000000, SEEK_TO_PREVIOUS_SYNC),实际跳转到的位置偏差能达到500ms。这在做精确剪辑时完全没法用。

解决方案?我建议放弃SEEK_TO_PREVIOUS_SYNC,统一用SEEK_TO_CLOSEST(API 26+)。如果必须兼容低版本,那就自己实现一个近似seek:先seek到附近关键帧,然后逐帧往前读。

18.2.3 OPPO / vivo

这两家的问题比较类似:对某些封装格式的解析不完整。比如FLAC音频文件,在OPPO上MediaExtractor可能读不到duration。我遇到过,getTrackFormat().getLong(MediaFormat.KEY_DURATION)返回-1。

我的做法是:如果duration拿不到,就手动计算。读取第一个sample的timestamp和最后一个sample的timestamp,差值就是实际时长。虽然麻烦点,但至少不会崩。

避坑指南:我曾经在vivo X60上遇到一个诡异问题——MediaExtractor读取视频轨道时,getSampleSize()返回0。查了两天才发现,是文件头部的moov box位置太靠后。解决方案:用MediaMetadataRetriever先预读一下文件信息,确认文件结构完整再交给MediaExtractor。

18.3 兼容性适配的通用策略

说了这么多问题,总得给点解决方案。我个人总结了一套「三层防御」策略:

  1. 第一层:版本判断 + 厂商判断
    • 用Build.VERSION.SDK_INT判断API等级
    • 用Build.MANUFACTURER判断厂商("HUAWEI"、"XIAOMI"等)
    • 针对已知问题做特殊处理
  2. 第二层:运行时异常捕获
    • 所有MediaExtractor操作都包在try-catch里
    • 捕获IllegalStateException、IllegalArgumentException、RuntimeException
    • 捕获后降级处理(比如换解码器、换读取方式)
  3. 第三层:数据校验 + 重试机制
    • 读取到的数据做合法性校验(size > 0、timestamp递增等)
    • 如果数据异常,最多重试3次
    • 重试仍失败,主动抛出自定义异常,上层统一处理

核心代码片段:兼容性适配的骨架

// 兼容性适配工具类
public class MediaExtractorCompat {
    
    public static MediaExtractor createExtractor(Context context, Uri uri) {
        MediaExtractor extractor = new MediaExtractor();
        try {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                // Android 8.0+ 直接传Uri
                extractor.setDataSource(context, uri, null);
            } else {
                // 低版本转成FileDescriptor
                ContentResolver resolver = context.getContentResolver();
                ParcelFileDescriptor pfd = resolver.openFileDescriptor(uri, "r");
                extractor.setDataSource(pfd.getFileDescriptor());
            }
        } catch (IOException e) {
            // 降级处理:尝试用文件路径
            String path = getRealPathFromUri(context, uri);
            if (path != null) {
                extractor.setDataSource(path);
            } else {
                throw new MediaExtractorException("无法初始化Extractor", e);
            }
        }
        return extractor;
    }
    
    public static long getSafeDuration(MediaExtractor extractor, int trackIndex) {
        MediaFormat format = extractor.getTrackFormat(trackIndex);
        long duration = -1;
        if (format.containsKey(MediaFormat.KEY_DURATION)) {
            duration = format.getLong(MediaFormat.KEY_DURATION);
        }
        // 某些厂商ROM可能返回-1,手动计算
        if (duration <= 0) {
            duration = calculateDurationBySamples(extractor, trackIndex);
        }
        return duration;
    }
}

18.4 知识体系总览

下面这张图,是我对兼容性适配的整体理解。你可以把它当作一个检查清单:

MediaExtractor 兼容性适配知识体系 Android版本差异 厂商ROM差异 通用适配策略 API 26以下:仅文件路径 API 26+:支持Uri/FD API 29+:MediaDataSource 华为:轨道数量限制 小米:seekTo偏差大 OPPO/vivo:duration缺失 版本+厂商判断 运行时异常捕获 数据校验+重试 核心原则:防御性编程,永远假设API会失败 不要信任任何厂商的实现,自己做好兜底

18.5 实战中的一些细节

最后分享几个我在实战中积累的小细节,希望能帮你少走弯路:

  • 关于mime类型:不要直接用getTrackFormat().getString(MediaFormat.KEY_MIME)做equals比较。有些厂商会在后面加版本号,比如"video/avc"变成"video/avc; codecs=\"avc1.42E01E\""。我习惯用startsWith()判断。
  • 关于sample读取:readSampleData()返回的byteBuffer大小,在不同设备上可能不一样。不要假设一次能读完所有数据。我一般会循环读取,直到buffer剩余空间不足。
  • 关于资源释放:MediaExtractor的release()一定要放在finally块里。有些厂商的ROM在release时可能卡住,建议加个超时机制,比如用异步线程释放,主线程最多等2秒。
  • 关于日志:强烈建议在关键操作前后加日志。比如setDataSource成功/失败、getTrackCount返回值、每个sample的timestamp。线上出问题时,这些日志就是救命稻草。

一个小技巧:我习惯在应用启动时,用一个测试视频文件快速验证MediaExtractor在当前设备上是否正常工作。如果验证失败,直接提示用户「当前设备不支持该功能」,而不是等到用户选完视频再崩溃。用户体验会好很多。

兼容性适配这件事,说白了就是「见招拆招」。没有一劳永逸的方案,只有不断积累的经验。你遇到的坑越多,你的代码就越健壮。嗯,保持耐心,慢慢来。


公众号:蓝海资料掘金营,微信deep3321