1. 起播慢半拍,问题常常藏在前几十个字节里
手机端 mp4 文件头前置检测,说白了就是拿一个几十行的函数,回答一个非黑即白的问题:这个 mp4 视频文件的 moov box,到底在不在 mdat 前面。听上去像个微不足道的细节,但做过播放器、短视频上传、媒资入库的朋友应该都有体会,同一个视频换个网络环境,起播时间能从 0.2 秒变成 3 秒,很多时候根子就在这 8 个字节的偏移上。
先把概念摆清楚。MP4 属于 ISO BMFF 家族,文件不是连续流,而是一串“盒子”平铺拼起来的,业内叫 box,早期也叫 atom。每个盒子有自己的类型和长度,ftyp声明品牌,mdat装真正的音视频采样数据,而moov装的是整个文件的目录:有几条轨道、时长多少、每一帧数据在文件里的偏移量和大小、关键帧在哪。播放器想解码第一帧,就必须先拿到这份目录,否则它连第一帧的字节在哪儿都不知道。
于是文件结构就决定了播放体验。假如顺序是ftyp → moov → mdat,播放器读完开头那点字节就能把索引建好,然后按需去请求后面的数据,边下边播、秒开、拖动都顺滑,这种方式通常被称作 faststart 或者“流式优化”。假如顺序是ftyp → mdat → moov,那播放器要建索引就得先摸到文件尾部,在本地磁盘上这不算事,随机读一下就行;可一旦换成网络拉流、分段下载、弱网环境,它就得先把大量数据搬过来,首帧时间直接被拖垮。
手机端为什么要专门做这件事,理由很现实。一方面,手机是内容生产的第一现场,不同机型、不同相机应用、不同剪辑 SDK 导出的 mp4,封装策略五花八门,有些默认就把 moov 写在尾部;另一方面,手机是内容消费的第一现场,用户对流量的敏感度、对等待的容忍度都比桌面低得多。你在上传前花几毫秒判断一下,就能决定要不要在服务端补一次重封装,或者在播放器里换一套预加载策略。这套检测逻辑适合移动端音视频开发、做上传 SDK 的同学,也适合后台做媒资清洗、批量转码的同行,哪怕你只是好奇“为什么我的视频要处理一遍才能秒开”,读完也能自己写出来。
2. 把判定逻辑讲透:box 怎么爬,moov 怎么认
2.1 八字节头、六十四位扩展,还有那个容易漏掉的 size=0
每个 box 的最小结构是固定的八字节:前四字节是大端序(big-endian)的无符号整数,表示整个 box 的字节数,包含头自身;后四字节是四字符码,通常是可以打印的 ASCII,比如ftyp、moov、mdat、free。真正麻烦的是长度字段有两种逃逸写法,写检测代码时必须一次性处理干净,否则遇到大文件立刻翻车。
第一种逃逸是 size 等于 1。这时候四字节装不下了,紧跟着会再补一个八字节的 64 位无符号整数,叫 largesize,此时 box 的头变成 16 字节。视频文件超过 4GB 之后mdat基本都这么写,你如果只看前四字节拿到 1,会以为这是个 1 字节的畸形盒子,直接判定文件损坏。第二种逃逸是 size 等于 0,含义是“这个盒子一直延伸到文件末尾”,老式的流式写入器喜欢这么干,因为录制时它根本不知道最终会写多大。
| 长度字段 | 头的实际大小 | 真实 box 大小 | 典型出现场景 |
|---|---|---|---|
| size > 1 | 8 字节 | 就是 size 的值 | 绝大多数 box |
| size == 1 | 16 字节 | 后面 8 字节的 largesize | 超过 4GB 的 mdat |
| size == 0 | 8 字节 | 文件总长减去当前偏移 | 流式录制写出的 mdat |
| size < 8 且不为 0/1 | 无效 | 视为损坏 | 截断文件、拼装错误 |
这里还有一个隐蔽的坑:四字符码不一定全是可打印字符。有些私有封装器会写入带控制字符的类型名,你用字符串比较没问题,但如果你打算把它打印到日志里,最好做一次转义,否则日志文件会莫名其妙乱码,排查时一脸问号。
2.2 顶层遍历的状态机,其实只有四个分支
理解了单个 box 的结构,遍历逻辑就非常直白了:从偏移 0 开始,读 8 字节头,算出这个 box 有多长,加上去得到下一个 box 的起点,如此往复。整个过程只爬顶层,绝对不要递归进moov内部去解析trak、stbl,因为我们要回答的是位置问题,不是内容问题,递归只会平白引入解析失败的风险。
这个循环里只有四种出口:碰到moov,说明索引在前,判定为前置;碰到mdat,说明数据区已经开始了,索引就算存在也只能在后面,判定为未前置;长度字段非法或者越过文件尾,说明文件损坏或被截断,判定为未知;循环跑完了还没结论,同样归为未知。这四个分支覆盖了 99% 的真实文件。
offset = 0 loop: 读 offset 处的 8 字节 size = 前4字节 (大端 u32) type = 后4字节 若 size == 1 → 再读 8 字节得到 largesize,headerSize = 16 若 size == 0 → boxSize = 文件长度 - offset,headerSize = 8 若 type == "moov" → 返回 BEFORE_DATA 若 type == "mdat" → 返回 AFTER_DATA 若 boxSize < headerSize 或 offset + boxSize > 文件长度 → 返回 UNKNOWN offset += boxSize我特别建议你把结论设计成三态而不是布尔值。用true/false表示“是否前置”,你迟早会被损坏文件和不完整下载逼疯——检测失败到底是“没优化”还是“没读完”,业务侧处理方式完全不同。三态返回里再带上已扫描字节数和经过的 box 类型列表,线上出问题时这两个字段就是救命稻草,能一眼看出文件长什么样。
2.3 为什么不做全文件校验,也不该碰 moov 内部
有同行问过,既然都读到moov了,顺手把mvhd里的时长、mvex里的分片标记也解析出来不好吗?我的答案是:不要。检测函数的定位是极轻量的前置判断,一旦它开始解析内容,就会遇到版本号差异、可选 box 缺失、字段对齐等一堆问题,代码量翻十倍,出错概率也翻十倍,而它带来的业务价值并没有增加多少。真想拿时长,交给播放器或者专门的解析库。
同样不建议做全文件 CRC 或者完整性校验。检测的目标是快速决策,不是审计。一个 2GB 的视频,你哪怕只是全量扫一遍,在手机上也是几秒起步,用户早就不耐烦了。
3. 手机端落地:Android 与 iOS 两份可直接抄的实现
3.1 先把预算设计好,再动手写代码
写之前要先定两个预算参数,这是很多示例代码里缺失、但工程上必须有的东西。第一个是逻辑跳数上限,第二个是实际读取字节上限。为什么要区分这两个?因为 seek 和 read 的成本完全不同:一个 200MB 的freebox 只需要一次 seek 就能跳过,不消耗任何读取带宽,但如果你按逻辑偏移来设预算,就会把它误判成“扫太远了”。
我的经验值是:顶层 box 最多允许 64 个,实际读取的总字节数不超过 64KB。真实文件里顶层 box 通常只有 3 到 10 个,64 个已经是极其宽松的冗余,超过这个数基本可以断定文件结构异常。实际读取 64KB 也足够覆盖绝大多数ftyp加若干个free的情况。这两个数字写死成常量,后面线上排查时你会感谢自己。
3.2 Android 侧:RandomAccessFile 是首选,但要小心里面的类型陷阱
Android 上最省事的方案就是RandomAccessFile,它支持 seek,不需要把文件读进内存。核心在于长度字段必须用 Long 接收,Java 的 int 是有符号的,你把 0xFFFFFFFF 读成 int 会得到 -1,然后offset += boxSize直接把偏移量干成负数,循环要么死掉要么越界,这个坑我踩过不止一次。
import java.io.File import java.io.RandomAccessFile import java.nio.ByteBuffer import java.nio.ByteOrder enum class MoovPosition { BEFORE_DATA, AFTER_DATA, UNKNOWN } data class MoovCheckResult( val position: MoovPosition, val scannedBytes: Long, val boxes: List<String> ) object Mp4FastStartChecker { private const val MAX_BOX_COUNT = 64 private const val MAX_READ_BYTES = 64L * 1024 fun check(file: File): MoovCheckResult { val boxes = ArrayList<String>(8) var readBytes = 0L RandomAccessFile(file, "r").use { raf -> val fileLength = raf.length() var offset = 0L val head = ByteArray(16) while (offset + 8 <= fileLength && boxes.size < MAX_BOX_COUNT) { if (readBytes > MAX_READ_BYTES) { return MoovCheckResult(MoovPosition.UNKNOWN, readBytes, boxes) } raf.seek(offset) if (raf.read(head, 0, 8) != 8) { return MoovCheckResult(MoovPosition.UNKNOWN, readBytes, boxes) } readBytes += 8 val buf = ByteBuffer.wrap(head, 0, 8).order(ByteOrder.BIG_ENDIAN) val rawSize = buf.int.toLong() and 0xFFFFFFFFL val type = String(head, 4, 4, Charsets.US_ASCII) boxes.add(type) var headerSize = 8L var boxSize = rawSize if (rawSize == 1L) { if (raf.read(head, 8, 8) != 8) { return MoovCheckResult(MoovPosition.UNKNOWN, readBytes, boxes) } readBytes += 8 boxSize = ByteBuffer.wrap(head, 8, 8) .order(ByteOrder.BIG_ENDIAN).long headerSize = 16L } else if (rawSize == 0L) { boxSize = fileLength - offset } if (type == "moov") { return MoovCheckResult(MoovPosition.BEFORE_DATA, readBytes, boxes) } if (type == "mdat") { return MoovCheckResult(MoovPosition.AFTER_DATA, readBytes, boxes) } if (boxSize < headerSize || offset + boxSize > fileLength) { return MoovCheckResult(MoovPosition.UNKNOWN, readBytes, boxes) } offset += boxSize } return MoovCheckResult(MoovPosition.UNKNOWN, readBytes, boxes) } } }这段代码有几个刻意为之的细节。rawSize == 1L和rawSize == 0L的判断放在moov/mdat判断之前,是因为一个 size 为 1 的mdat你不能直接用 1 当长度往外跳。offset + boxSize > fileLength这一句是我后来加的,之前漏掉了,遇到被截断的文件会疯狂 seek 到文件尾之外,虽然不崩,但日志里全是无效读取。MAX_BOX_COUNT的检查放在 while 条件里,比放在循环体内部要干净。
还有个现实问题:Android 10 以后分区存储铺开,你手里拿到的往往不是File路径,而是content://开头的 URI。这时候RandomAccessFile用不了,得走ContentResolver拿文件描述符,再用FileInputStream的 channel 做定位。
fun checkByUri(resolver: ContentResolver, uri: Uri): MoovCheckResult { resolver.openFileDescriptor(uri, "r")?.use { pfd -> FileInputStream(pfd.fileDescriptor).channel.use { channel -> // 用 channel.position(pos) 定位 + channel.read(buffer) 读取, // 其余遍历逻辑与上面完全一致,只是把 raf.seek 换成 channel.position } } return MoovCheckResult(MoovPosition.UNKNOWN, 0, emptyList()) }注意:不要试图用
resolver.openInputStream(uri)拿到的InputStream去做 seek,它没有定位能力,你只能用skip()一点点蹭,对于需要跳过几十 MBfree的场景基本没法用。
3.3 iOS 侧:FileHandle 加上手写大端解析,别用 load(as:)
iOS 上对应的是FileHandle加上seek(toOffset:)。这里有一个我必须重点提醒的坑:Data的withUnsafeBytes { $0.load(as: UInt32.self) }要求内存对齐,而Data内部的存储起始地址并不保证四字节对齐,在某些切片场景下会直接崩给你看。稳妥写法是老老实实按位拼。
import Foundation enum MoovPosition { case beforeData, afterData, unknown } struct MoovCheckResult { let position: MoovPosition let scannedBytes: Int64 let boxes: [String] } enum Mp4FastStartChecker { static let maxBoxCount = 64 static let maxReadBytes: Int64 = 64 * 1024 private static func u32BE(_ data: Data, _ start: Int) -> UInt32 { var value: UInt32 = 0 for i in 0..<4 { value = (value << 8) | UInt32(data[data.startIndex + start + i]) } return value } private static func u64BE(_ data: Data, _ start: Int) -> UInt64 { var value: UInt64 = 0 for i in 0..<8 { value = (value << 8) | UInt64(data[data.startIndex + start + i]) } return value } static func check(url: URL) -> MoovCheckResult { guard let handle = try? FileHandle(forReadingFrom: url) else { return MoovCheckResult(position: .unknown, scannedBytes: 0, boxes: []) } defer { try? handle.close() } let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) let fileLength = (attrs?[.size] as? NSNumber)?.int64Value ?? 0 var boxes: [String] = [] var readBytes: Int64 = 0 var offset: Int64 = 0 while offset + 8 <= fileLength && boxes.count < maxBoxCount { if readBytes > maxReadBytes { return MoovCheckResult(position: .unknown, scannedBytes: readBytes, boxes: boxes) } guard (try? handle.seek(toOffset: UInt64(offset))) != nil, let head = try? handle.read(upToCount: 8), head.count == 8 else { return MoovCheckResult(position: .unknown, scannedBytes: readBytes, boxes: boxes) } readBytes += 8 var boxSize = Int64(u32BE(head, 0)) let type = String(data: head.subdata(in: 4..<8), encoding: .ascii) ?? "????" boxes.append(type) var headerSize: Int64 = 8 if boxSize == 1 { guard let ext = try? handle.read(upToCount: 8), ext.count == 8 else { return MoovCheckResult(position: .unknown, scannedBytes: readBytes, boxes: boxes) } readBytes += 8 boxSize = Int64(bitPattern: u64BE(ext, 0)) headerSize = 16 } else if boxSize == 0 { boxSize = fileLength - offset } if type == "moov" { return MoovCheckResult(position: .beforeData, scannedBytes: readBytes, boxes: boxes) } if type == "mdat" { return MoovCheckResult(position: .afterData, scannedBytes: readBytes, boxes: boxes) } if boxSize < headerSize || offset + boxSize > fileLength { return MoovCheckResult(position: .unknown, scannedBytes: readBytes, boxes: boxes) } offset += boxSize } return MoovCheckResult(position: .unknown, scannedBytes: readBytes, boxes: boxes) } }Int64(bitPattern:)这一句是防 Largesize 超过Int64.max时转换崩溃,虽然实际上不可能遇到,但养成习惯没坏处。整个函数在真实文件上跑下来,读取次数基本是 2 到 4 次,总耗时在微秒级,放在主线程里都不会造成可感知的卡顿,但我还是建议放到子线程,因为磁盘冷启动的第一次读可能有几十毫秒的抖动。
3.4 性能账要算清楚:读多少、跳多远
把账算一下你就明白这个方案为什么能随便用。一个典型的优化过的短视频文件:ftyp约 32 字节,moov假设 800 字节到 3KB,读完这两次一共两次读操作、一次 seek 到 32 字节处,总共读取不到 4KB。一个未优化的两分钟视频:ftyp32 字节、mdat假设 80MB,读到ftyp后一次 seek 跳到 32,再读 8 字节发现是mdat,直接返回,全程两次读、共 40 字节。
绝大多数情况下这个检测只消耗一次或两次 8 字节读取,比你打开一次数据库、发一次网络请求便宜好几个数量级。所以它完全可以放在文件选择的回调里、上传按钮的点击事件里、播放器prepare之前的钩子里,同步跑都没问题。真正需要注意的是别在循环里反复new File或者反复开关句柄,一次打开一次关闭就够了。
4. 边界情况:这些文件会让你的检测翻车
4.1 分片 MP4:长得像优化过,但含义完全不同
分片 MP4(fragmented MP4)的结构是ftyp → moov → moof → mdat → moof → mdat ...,moov里只有轨道模板和mvex标记,真正的采样索引被拆到每个moof里。按前面的算法,它会在第一个moov处返回BEFORE_DATA,这个结论在“能否流式播放”的意义上是正确的,因为播放器确实能立刻建好模板。但如果你的业务逻辑是“判定为前置就跳过重封装”,那就可能误伤——某些老播放器对 fMP4 支持不好,重封装成普通 MP4 反而是必要的。
识别方式也很简单:在moov内部找mvex子 box,找到就是分片。但这违背了我前面说的“不递归”原则,所以我更倾向于把这个信息放到业务侧的另一个检测里,不要在快速检测函数里塞。真要区分,可以在返回结果里加一个hasMoof标志——遍历过程中如果碰到moof,说明这是分片流。不过由于碰到moov就已经返回了,一般看不到moof,所以这个标志的实用价值有限。
4.2 size=0 的 mdat 和半截文件,是两个不同的头疼
size=0的mdat在老设备录制的文件里还能碰到,处理方式是把它当成“延伸到文件尾”。因为我们碰到mdat就返回AFTER_DATA了,实际上根本走不到那一步的长度计算,所以这个分支更像是给非mdat的异常文件兜底。
真正麻烦的是半截文件。用户正在下载,本地只缓存了前 2MB,ftyp读到了,接着读到mdat的头,返回AFTER_DATA——这个结论是错的,因为完整文件的moov可能就在这 2MB 之后、mdat之前?不对,如果mdat已经出现了,moov就不可能在前置位置了,这个结论其实是对的。真正会误判的是另一种情况:文件只缓存了 100 字节,连ftyp都没读完,循环直接退出,返回UNKNOWN。这时候业务侧要能区分“文件不完整”和“文件损坏”,我的做法是判断fileLength是否小于一个阈值(比如 1KB),小于就直接标记为“下载未完成”,压根不进检测流程。
4.3 free、wide、uuid 这些干扰项,一个都不能当异常
free是占位用的空闲 box,封装器在写moov之前预留空间时经常写它,几 KB 到几 MB 都有。wide是 QuickTime 时代的遗留占位,固定 8 字节。uuid是私有扩展 box,里面塞什么的都有,某些相机厂会拿它写元数据。
这三个类型你都不需要认识,也不需要特殊处理,因为算法的默认动作就是“跳过”。只要长度字段合法,跳过去就完事了。我见过有人在代码里写if (type != "ftyp" && type != "moov" && type != "mdat") return UNKNOWN;,这就直接把带free的正常文件判死了,是非常典型的过度设计。
还有一种情况是文件开头根本没有ftyp。老式的 QuickTime.mov文件可能直接以moov开头,或者以mdat开头。前者会被判为BEFORE_DATA,实际上也没错;后者会被判为AFTER_DATA。所以哪怕不校验ftyp,结论也基本合理。我不会强行要求第一个 box 必须是ftyp,那样反而会把.mov、.3gp这些同族格式拒之门外。
4.4 网络文件的定位能力,决定了方案能不能用
如果你的文件来自 HTTP,只能顺序读,那这套 seek 方案就用不了。这时候有两种做法:一是用 Range 请求直接拿前 64KB 到本地临时文件再检测,这也是很多上传 SDK 的做法,代价是多一次网络往返;二是直接放弃,由服务端在入库时统一处理。手机端本地文件的场景占了绝大多数,所以我建议把检测函数的入参设计成“支持随机访问的抽象”,Android 传RandomAccessFile或者FileChannel,iOS 传FileHandle,需要走网络的时候再包一层。
5. 检测出问题之后:验证、修复和工程配套
5.1 先用命令行确认你的判断,别急着改代码
怀疑检测结果不对的时候,最快的验证方式是直接把文件头 dump 出来看。Linux 和 macOS 上用xxd就可以,Windows 上可以用任意十六进制编辑器。
xxd -l 64 demo.mp4输出里你会看到类似0000 0018 6674 7970 6973 6f6d这样的内容,前四字节00000018就是长度 24,接着66747970是ftyp的 ASCII 编码,69736f6d是isom品牌。往后看,如果很快出现6d6f6f76(moov),那就是前置的;如果先出现6d646174(mdat),那就是未优化。
我更习惯用一个几十行的 Python 脚本批量扫目录,做媒资清洗的时候一次跑几千个文件,几秒钟出结果。
import struct, sys, os def inspect(path): with open(path, 'rb') as f: f.seek(0, os.SEEK_END) length = f.tell() offset, boxes = 0, [] while offset + 8 <= length and len(boxes) < 64: f.seek(offset) head = f.read(16) size, typ = struct.unpack('>I4s', head[:8]) name = typ.decode('ascii', 'replace') boxes.append(name) header = 8 if size == 1: size = struct.unpack('>Q', head[8:16])[0] header = 16 elif size == 0: size = length - offset if name == 'moov': return 'BEFORE', boxes if name == 'mdat': return 'AFTER', boxes if size < header: return 'BROKEN', boxes offset += size return 'UNKNOWN', boxes if __name__ == '__main__': for p in sys.argv[1:]: status, boxes = inspect(p) print(f'{status:7} {" -> ".join(boxes):40} {p}')这个脚本跟手机端算法用的是同一套逻辑,所以拿它对拍是最高效的验证方式。我曾经遇到过一次线上误报,最后就是靠这个脚本扫了同一批文件,发现是某个封装器在ftyp前面写了一个 4 字节的垃圾头,导致整个偏移链条全歪了。
5.2 修复这件事,命令行工具一分钟搞定
确认是AFTER_DATA之后,绝大多数场景下重新封装一次就够了。主流转码工具都支持 faststart 参数,把索引挪到前面,音视频流本身是直接复制的,不重新编码,所以画质零损失、速度快。
# 音视频流直接拷贝,只调整封装顺序 ffmpeg -i input.mp4 -c copy -movflags +faststart output.mp4这里的-c copy是关键,千万别漏,漏了就会用默认编码器重压一遍,画质掉一档、耗时翻几十倍。-movflags +faststart的语义是:写文件时先把moov留空占位,等所有数据写完、拿到完整的索引信息,再回头把moov填回去,最终顺序就是前置的。
这个过程的代价要提前跟业务方说清楚:它需要两次文件写入,一次写数据一次回填索引,所以耗时大致是文件大小除以磁盘写入速度再乘以一点几。一个 500MB 的视频在手机上重封装大约需要几秒到十几秒,不能放在主线程,也不能在用户上传时静默执行,必须给进度反馈。另外它需要额外的临时磁盘空间,手机存储紧张的时候要先检查剩余容量。
注意:对已经损坏、或者
mdat长度字段是 0 的老文件,重封装可能失败或者产出体积异常的结果。批量处理前建议先跑一遍检测,把UNKNOWN的文件单独拎出来人工看,别一股脑丢进批处理队列。
5.3 把检测结果用起来,比检测本身更有价值
检测函数写完了,如果只是打个日志,那价值约等于零。我一般会把它接进三个地方。
第一个是上传前置校验。用户选完视频,本地先检测一次,命中AFTER_DATA就在上传队列里标记“需要优化”,等服务端转封装完成后再通知客户端刷新播放地址。这样用户看到的是“视频处理中,约 10 秒”,而不是“怎么上传完还是转圈”。
第二个是播放器预加载策略。拿到本地文件先判断,前置的就按需拉取,只有几百 KB 的预加载窗口;未前置的就把预加载窗口调大,或者直接提示“该视频暂不支持边下边播”。这个策略上的差异,用户是能明显感知到的。
第三个是媒资库的批量巡检。后台跑定时任务,只读每个文件的头部几十字节,几万个文件几分钟扫完,生成一份“需要重封装”的清单,交给转码集群慢慢处理。这个场景下检测成本几乎可以忽略,收益却是实打实的带宽节省。
6. 排查速查表与几条踩过的坑
线上跑久了,问题会集中到有限的几类。我把遇到过的整理成一张表,出问题的时候对着看,比翻代码快得多。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 结果全是 UNKNOWN,boxes 为空 | 文件 0 字节,或没有读权限 | 先校验文件存在和可读性,再进检测 |
| 第一个 box 类型是乱码 | 文件前面有非标准头,或根本不是 MP4 | dump 前 64 字节人工确认 |
| 读到 moov 但业务说播放还是慢 | 起播慢的原因在码率、关键帧间隔或网络 | 换方向排查,别死磕封装顺序 |
| 判定 BEFORE_DATA 但服务端仍报未优化 | 服务端用的是另一套判定标准 | 对齐两边的判定规则,统一用三态 |
| 大文件检测耗时突然变长 | 每次都重新打开句柄,或用了整文件读取 | 复用句柄,确认走的是 seek 而非全量读 |
| Android 上偶发 crash | 长度字段用了 Int 导致负数偏移 | 全部改成 Long,加越界校验 |
| iOS 上偶发 crash | Data 未对齐情况下用了 load(as:) | 改成逐字节移位拼接 |
| content:// URI 检测失败 | RandomAccessFile 不支持 URI | 走 ContentResolver 拿 FD 再用 channel |
再补几条文档里不会写、但确实会咬人的经验。
第一条,别把扫描预算按逻辑偏移算。我最初写的是if (offset > 4MB) return UNKNOWN,结果一批带 6MBfree占位的文件全被判成未知,查了半天才发现是预算判断的位置错了。改成按实际读取字节数算之后,这类文件全部正常。
第二条,检测函数一定要能吐出中间过程。我现在的返回结构里永远带着boxes列表和scannedBytes,出问题的时候把这两个字段往日志里一打,文件长什么样一目了然,不用让用户重新传一遍文件来复现。这个设计带来的排查效率提升,远超它增加的那点内存开销。
第三条,别指望用系统播放框架反推封装顺序。有人问能不能用MediaExtractor或者AVAsset打开文件,看它多久能拿到轨道信息来判断。这个思路不可靠,本地文件的随机读太快了,无论moov在哪都是毫秒级返回,你根本测不出差异,而且这些接口也不会告诉你 box 的位置。想知道位置,就老老实实自己读字节。
第四条,也是我吃过亏的一条:检测和修复之间要留一道人工确认。早期我做过“检测到未前置就自动重封装再上传”,结果有一批用户的视频原始文件被临时文件覆盖,出了问题没法追溯。后来改成检测结果只做标记,原始文件永远保留,重封装产出新文件,出问题还能回滚。这个改动看起来跟技术无关,但它救过我一次。
最后分享一个我在实际项目里养成的小习惯:把这个检测函数和那个 Python 脚本放在同一个仓库里,改算法的时候两边一起改,用脚本跑一批样本做回归。封装格式的边界情况太多了,靠人脑记不住,靠对拍最踏实。