ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Android MediaCodec解码视频并逐帧保存YUV的工程实践

Android MediaCodec解码视频并逐帧保存YUV的工程实践

视频解码这件事,只要落到 Android 平台上,MediaCodec 就是绕不过去的一道关。我这次要做的需求很朴素:把一个本地视频文件解出原始 YUV 数据,一帧一帧地写到 SD 卡上,后面拿去做图像算法验证、模型训练样本采集,或者干脆就是排查解码链路上的花屏问题。标题里这几个关键词——MediaCodec、YUV、逐帧保存——单拎出来都不复杂,但真正串成一条能稳定跑完整个视频、不丢帧、不花屏、不把手机存爆的流水线,坑比想象中多得多。

这篇内容适合三类人看:刚接触 Android 音视频、想知道 MediaCodec 到底怎么用起来的新手;已经能解出画面、但被 stride、sliceHeight、NV21 这些名词绕晕的中级开发者;以及需要把 YUV 数据落到磁盘做后续处理、但被 IO 速度卡住的工程实践者。我会把整条链路从选型到落盘拆开讲,代码给到能直接跑的粒度,重点放在那些文档里不写、只有真机跑过才知道的地方。

1. 先想清楚整条链路的形状

1.1 这个方案到底在解决什么问题

很多人第一次听到"把视频解成 YUV 存到 SD 卡",第一反应是:为什么不直接存成图片?答案在于后续用途。PNG、JPEG 这类格式是有损或经过色彩空间转换的,RGB 三通道每像素 3 字节,而 YUV420 每像素平均只有 1.5 字节,数据量直接砍掉一半,同时保留了亮度全分辨率信息。做画质分析、做色彩校正验证、做视频超分的训练样本,YUV 才是"没被加工过"的原始素材。

代价也很直接:一个 1080p、30 秒、30fps 的视频,一共 900 帧,每帧 1920×1080×1.5 ≈ 3.1MB,合计接近 2.8GB。这个体量决定了整个方案的设计重心不只是"能不能解出来",而是"怎么解得不卡、写得下、找得到"。所以整条链路必须同时考虑解码吞吐、内存占用、磁盘写入三件事,任何一环掉链子,最终结果就是丢帧或者文件损坏。

我的设计原则是先保证正确性,再谈速度:先让每一帧的像素排布绝对正确,再考虑批量写入、缓冲复用这些优化。反过来做,很容易在花屏的状态下调试 IO,最后两头都搞不定。

1.2 解码器的两种驱动模式怎么选

MediaCodec 从 API 21 开始提供同步和异步两套接口。同步模式靠dequeueInputBuffer和dequeueOutputBuffer主动轮询,代码写起来是线性的 while 循环,调试友好,逻辑一眼能看懂;异步模式靠setCallback回调,省掉了轮询空转,理论上延迟更低,但回调在内部 Handler 线程上执行,一旦你在回调里做重活(比如写 3MB 的文件),会直接把解码器堵死。

我这次选同步模式。理由很实际:这个需求是离线批处理,不是实时播放,对延迟完全不敏感;而同步循环天然支持"我处理完一帧再取下一帧"的节奏,写文件慢的时候自动形成背压,不需要额外做流控。异步模式更适合播放器那种要跟渲染时钟对齐的场景,用在批量导出上属于给自己找麻烦。

有一点要提醒:同步模式下所有dequeue调用都必须带超时,不能传 -1 死等。我一般输入用 10000 微秒,输出用 10000 微秒,超时后检查一下是否已经收到 EOS,没收到就继续循环。传 -1 在某些编码异常的文件上会永久阻塞,日志里什么都看不到,非常难查。

1.3 模块拆分与线程模型

整个流程我拆成四块:解封装(MediaExtractor 负责抽轨道、读压缩包)、解码(MediaCodec 吐 YUV)、格式归一(把厂商给的千奇百怪的排布统一成 I420)、落盘(写文件 + 写元信息)。前三块跑在同一个工作线程里,第四块如果直接同步写,会明显拖慢解码节奏,所以我做了一级缓冲队列。

队列长度我实测下来取 8 到 16 帧比较合适。太小(比如 2)起不到削峰作用,磁盘偶发卡顿照样传导到解码;太大(比如 64)内存直接吃掉 200MB,低端机上很容易被系统干掉。8 帧对 1080p 来说是 25MB 左右,是个比较安全的平衡点。队列满的时候解码线程阻塞等待,这就是天然的背压,比自己去算速率简单得多。

线程优先级也值得设一下。解码线程用Process.THREAD_PRIORITY_DISPLAY或者普通后台优先级都行,但写文件线程建议用THREAD_PRIORITY_BACKGROUND,避免它跟解码抢 CPU。这个细节在小文件上看不出差别,跑 4K 素材的时候能明显减少超时次数。

2. YUV 不是一种格式,是一族格式

2.1 Planar 与 SemiPlanar 的排布差异

这是最容易出问题的地方。很多人脑子里只有一个"YUV"的概念,实际上 Android 解码器在不同芯片上吐出来的东西完全不一样。最常见的三种:

I420(YUV420Planar):先存完整的 Y 平面,然后是完整的 U 平面,再是完整的 V 平面。Y 有 width×height 个字节,U 和 V 各有 (width/2)×(height/2) 个字节。这种排布最规整,也最适合做算法输入。

NV12(YUV420SemiPlanar):先存 Y 平面,然后 UV 交错存储,顺序是 U、V、U、V…… 这种格式在移动端 GPU 上特别友好,所以大量硬件解码器默认就吐这个。

NV21:跟 NV12 一样是交错,但顺序反过来,先是 V 后是 U。这个在相机预览里很常见,解码器输出里相对少一些,但不是没有。

如果你拿 NV12 的数据按 I420 去解析,得到的画面会是彩色的、横向拉丝的鬼影,而 Y 通道(灰度)看起来完全正常。这个现象很有辨识度:一旦你看到灰度对、颜色错,八成就是平面顺序搞反了。

2.2 stride 和 sliceHeight:最容易翻车的地方

这两个参数是硬件解码器留下的"对齐填充"。显卡处理图像时为了内存访问效率,会要求每行字节数是 16 或 32 的倍数,视频高度也可能被补齐到 16 的倍数。于是你拿到的缓冲区里,一行有效像素后面可能跟着几十个无用字节,图像底部也可能多出几行垃圾数据。

具体来说,rowStride是每行实际占用的字节数(含填充),sliceHeight是实际占用的行数(含填充)。一帧 1920×1080 的 NV12,rowStride可能是 1920,也可能是 2048;高度可能是 1080,也可能是 1088。如果你直接用width × height去算偏移,画面就会出现整体斜切——每一行都往右偏了几个像素,越往下偏得越多,形成一条条斜纹。

我踩过的最典型的一次:某台设备上解 1080p,Y 平面rowStride是 1920 没问题,但 UV 平面的rowStride报的是 1920 而不是 960,导致我按 960 去跳行,颜色通道整体错位,画面出现大面积紫绿色块。后来才明白,UV 平面的rowStride是"整行字节数",包含 U 和 V 两个通道,所以它天然就是 Y 平面的一半再乘 2,等于跟 Y 平面一样宽。

2.3 统一归一到 I420

不管解码器吐什么,我都在内存里先转成标准 I420 再落盘。这样做有两个好处:文件格式统一,后续读取的代码只有一套;转换过程本身就是一次校验,能第一时间发现 stride 计算错误。

转换的关键是逐行拷贝,绝不整块memcpy。伪代码逻辑是这样的:对 Y 平面,从第 0 行到第 height-1 行,每行从源缓冲区偏移row × rowStride处拷width个字节到目标;对 UV 平面,情况稍微复杂,如果源是 NV12 且pixelStride == 1,说明 UV 是紧凑排列的,可以按行拷width个字节再拆;如果pixelStride == 2,说明 U 和 V 是交错的,必须逐字节挑出来分别放到两个平面。

从 API 21 开始,MediaCodec.getOutputImage()会返回一个Image对象,每个 plane 自带getRowStride()和getPixelStride(),这比自己猜格式可靠得多。我现在的做法是优先走getImage(),拿不到(部分老设备会返回 null)再退回到ByteBuffer手动解析KEY_STRIDE和KEY_SLICE_HEIGHT。

3. 初始化解码器:参数怎么填才不花屏

3.1 用 MediaExtractor 拿到靠谱的 MediaFormat

永远不要自己拼 MediaFormat。视频文件的容器里有 SPS、PPS、时长、帧率、色彩标准这些信息,MediaExtractor.getTrackFormat()会把它们解析好塞进去,你只需要把 mime 换一下就行。

流程是:setDataSource(path)打开文件,遍历getTrackCount(),对每个轨道取getTrackFormat(i),判断KEY_MIME是不是以video/开头,找到就selectTrack(i)并把这个 format 缓存下来。这一步看起来平淡无奇,但有个细节:getTrackFormat返回的对象里KEY_MIME是video/avc这种编码格式,而解码器要的就是这个值,直接传给createDecoderByType即可,不需要做任何映射。

有些视频有多条视频轨(比如带封面图的),要选码率最高或者分辨率最大的那条,否则你可能解出来一帧封面就结束了。我加了一个判断:优先选KEY_WIDTH × KEY_HEIGHT最大的轨道。

3.2 选对 ColorFormat

配置解码器时,KEY_COLOR_FORMAT怎么填?很多教程直接写COLOR_FormatYUV420Flexible,这个值(0x7F420888)是个"通配符",意思是"你爱吐什么吐什么,我自己适配"。这在 API 21 以上是推荐做法,因为getOutputImage()能告诉你真实排布。

但我建议做一层兼容:先用 Flexible 配置,configure之后调getOutputFormat()看实际拿到的KEY_COLOR_FORMAT是什么,把它打印出来。我手上几台设备实测:有的吐 21(SemiPlanar),有的吐 19(Planar),还有的干脆吐 0x7F420888 然后在getImage()里才暴露真实布局。日志里记下这个值,后面出问题第一时间能判断方向。

如果目标设备是 API 21 以下,那就没有getImage()可用,必须显式指定 19 或 21,并且自己按 stride 解析。这种情况下我一般写一个ColorFormatAdapter,把各种情况统一收敛到 I420 输出,业务代码完全不感知。

3.3 configure / start 的细节

configure(format, null, null, 0)这四个参数:第二个是 Surface,我们不要渲染,传 null;第三个是 Crypto,不加密传 null;第四个 flags,0 表示普通解码,如果需要配合 Secure 播放才用CONFIGURE_FLAG_DECODE_ONLY之类的值。

start()之后,getInputBuffers()和getOutputBuffers()这两个方法在 API 21 之后其实已经废弃了,应该用带 index 的getInputBuffer(index)。老代码里那种一次性拿到 buffer 数组的写法在新版本上虽然还能跑,但已经不是推荐路径,尤其配合getOutputImage()时容易拿到过期引用。

启动后第一件事是取一次getOutputFormat(),打印KEY_WIDTH、KEY_HEIGHT、KEY_STRIDE、KEY_SLICE_HEIGHT、KEY_COLOR_FORMAT。这四个值决定了后面所有的偏移计算,值得单独打一行日志。

注意:getOutputFormat()在start()之后、第一帧输出之前调用,拿到的可能是输入格式的副本;真正准确的输出格式要在收到第一个INFO_OUTPUT_FORMAT_CHANGED之后取。所以我习惯在循环里监听这个事件,收到就重新读一次参数并更新本地变量。

4. 逐帧解码与落盘实现

4.1 主循环骨架

同步模式的循环结构大致是这样:先尝试取一个输入 buffer,取到就喂数据;再尝试取输出 buffer,取到就处理。两个动作放在一个 while 里,任何一边返回TRY_AGAIN_LATER都继续下一轮。

val info = MediaCodec.BufferInfo() var inputDone = false var outputDone = false while (!outputDone) { if (!inputDone) { val inIndex = decoder.dequeueInputBuffer(10_000L) if (inIndex >= 0) { val inBuf = decoder.getInputBuffer(inIndex)!! val size = extractor.readSampleData(inBuf, 0) if (size < 0) { decoder.queueInputBuffer( inIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM ) inputDone = true } else { val pts = extractor.sampleTime decoder.queueInputBuffer(inIndex, 0, size, pts, 0) extractor.advance() } } } val outIndex = decoder.dequeueOutputBuffer(info, 10_000L) when { outIndex >= 0 -> { if (info.size > 0) { handleFrame(decoder, outIndex, info) } decoder.releaseOutputBuffer(outIndex, false) if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { outputDone = true } } outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { refreshOutputFormat(decoder.outputFormat) } } }

这段代码里有几个必须做对的地方。releaseOutputBuffer的第二个参数是render,我们不解到 Surface,必须传 false,传 true 在某些设备上会触发渲染路径,直接崩给你看。info.size > 0这个判断也不能省,INFO_OUTPUT_FORMAT_CHANGED之后紧接着可能出现 size 为 0 的空帧,拿去写文件就是空文件。

BUFFER_FLAG_CODEC_CONFIG这个标志也要过滤掉。部分编码器会把 SPS、PPS 当作一帧吐出来,info.size不为 0,但内容根本不是图像,写进去文件长度就乱了。判断方式是info.flags and BUFFER_FLAG_CODEC_CONFIG != 0就跳过。

4.2 从 Image 中抠出有效像素

拿到Image之后,真正的体力活在这里。以最常见的 NV12 为例,Y 平面pixelStride = 1,rowStride可能是 1920 或 2048;UV 平面pixelStride = 2,rowStride通常等于 Y 的 rowStride。

private fun imageToI420(image: Image, out: ByteArray, width: Int, height: Int) { val yPlane = image.planes[0] val uPlane = image.planes[1] val vPlane = image.planes[2] val yBuf = yPlane.buffer val yRowStride = yPlane.rowStride var pos = 0 for (row in 0 until height) { yBuf.position(row * yRowStride) yBuf.get(out, pos, width) pos += width } val uBuf = uPlane.buffer val vBuf = vPlane.buffer val uRowStride = uPlane.rowStride val uPixelStride = uPlane.pixelStride val vRowStride = vPlane.rowStride val vPixelStride = vPlane.pixelStride val chromaHeight = height / 2 val chromaWidth = width / 2 for (row in 0 until chromaHeight) { for (col in 0 until chromaWidth) { out[pos++] = uBuf.get(row * uRowStride + col * uPixelStride) } } for (row in 0 until chromaHeight) { for (col in 0 until chromaWidth) { out[pos++] = vBuf.get(row * vRowStride + col * vPixelStride) } } }

这段代码的价值在于它同时兼容了 I420 和 NV12:如果pixelStride是 1,col * 1就是顺序取;如果是 2,就是隔一个取一个。rowStride负责处理填充。两个参数一组合,四种常见组合全都能覆盖。

性能上,逐字节get确实慢,1080p 一帧大概要 5 到 10 毫秒。优化手段是把buffer转成ByteBuffer后用批量get(byteArray)拷整行到临时数组,再从临时数组里按步长取。我实测这么改能把耗时压到 2 毫秒以内。不过第一版别急着优化,先跑通再提速。

提示:image.planes返回的ByteBuffer位置是共享的,循环里反复position()会互相影响。要么每次重新rewind(),要么直接算绝对偏移用get(index),后者更安全。

4.3 写文件的正确姿势

每帧往 SD 卡写 3MB,写法不对就是灾难。最朴素的做法是每帧FileOutputStream.write然后close,我试过,1080p 跑下来平均每帧多花 30 毫秒在打开关闭文件上,直接把解码速度拖到个位数帧率。

正确的做法是:整个视频只开一个RandomAccessFile或者FileChannel,所有帧顺序追加,最后统一关闭。如果按帧分文件(比如frame_000001.yuv),那就要用BufferedOutputStream包一层,缓冲区开到 256KB 以上,把系统调用次数降下来。

按帧分文件还是合成一个大文件?我的建议是两个都留。合成大文件方便后续用dd或者脚本按固定偏移切帧,读取时不用管文件系统开销;按帧分文件方便肉眼抽查某几帧,尤其是调试花屏问题的时候,直接拿单帧去工具里看,比在大文件里算偏移快得多。我的做法是默认写大文件,同时加一个开关,调试时切成分帧模式。

文件命名不要用时间戳做唯一标识,视频多的时候根本分不清。我用"视频名 + 分辨率 + 帧序号"的组合,帧序号补零到 6 位,这样文件管理器里排序就是正确的帧顺序。

5. 性能、内存与存储的平衡

5.1 一帧三兆,别小看 IO

算笔账:1080p 30fps,每帧 3.1MB,一秒就是 93MB,一分钟 5.6GB。普通 UFS 存储的持续写入速度在 300MB/s 以上,理论够用,但那是理想状态。实际写入还受文件系统、缓存刷盘策略、后台其他应用影响,尤其是 Android 有写入限流,持续大流量写会触发内核的 dirty page 回写,出现周期性卡顿。

应对办法是控制队列水位加限速。我在写线程里统计最近一秒的实际写入字节数,超过 150MB/s 就主动sleep几毫秒。听起来有点土,但比让内核突然卡你 500 毫秒要平滑得多。另外每写入 64 帧做一次flush,避免缓冲区攒太多,进程被杀时丢一大片数据。

还有一个容易忽略的点:存储权限和分区。Android 10 之后的分区存储限制下,往外部存储根目录随便建文件夹已经不行了,要么用应用专属目录(getExternalFilesDir),要么走 SAF 让用户选目录。应用专属目录虽然路径难看,但不需要任何权限申请,卸载时自动清理,做数据采集场景其实更合适。

5.2 缓冲区复用

每帧ByteArray(width * height * 3 / 2)新建一个,1080p 就是 3MB,30fps 下每秒分配 90MB,GC 会被打爆。必须复用:在解码线程里预分配两个缓冲区,一个给imageToI420写,一个在队列里等着被写线程消费,用完之后还回池子。

我实现的是一个容量为队列长度的小型缓冲池,acquire()从池里取,release()还回去。池里没有可用缓冲时阻塞等待。这样整条链路的内存占用是固定的,跟视频长度完全无关。

顺便说一句,MediaCodec的输出 buffer 本身也是复用机制的一部分,你在releaseOutputBuffer之前必须把数据拷走,因为它随时会被解码器回收。这个坑我见过不少人踩:把image.planes[0].buffer直接塞进队列,回头再读就是垃圾数据。

5.3 采样与裁剪策略

不是所有场景都需要全帧全分辨率。如果下游只是做算法验证,完全可以支持三个参数:隔帧采样(比如每 3 帧取 1 帧)、区域裁剪(只取 ROI 区域)、降采样(宽高各减半)。这三个开关加上去,存储需求能降一个数量级。

隔帧采样的实现要注意:不是简单地跳过handleFrame,而是照常解码、照常释放 buffer,只是不写文件。跳过解码是不行的,因为 P 帧依赖前面的帧,你跳着解会直接花屏。这一点必须写清楚,我见过有人为了省 CPU 在queueInputBuffer那一步跳数据,结果整个视频后半段全是噪点。

区域裁剪相对麻烦,因为 YUV420 的色度平面是 2×2 下采样的,裁剪坐标必须是偶数,否则色度会错位。我的做法是强制把裁剪矩形的 left、top、width、height 全部对齐到 2 的倍数,宁可少裁两个像素,也不引入色度偏移。

6. 排查实录:那些年踩过的坑

6.1 花屏、绿边与斜条纹

斜条纹基本可以锁定为 stride 没处理。特征是画面整体向右倾斜,越靠下偏移越大,每条纹路的间距等于 (rowStride - width)。解决方法是老老实实按 rowStride 跳行。

底部绿边或灰边,通常是 sliceHeight 大于 height。解码器分配的缓冲区高度是 1088,你按 1080 去读,最后 8 行读到的其实是缓冲区里的残留数据,颜色发绿或者发灰。写文件时按 height 严格截断就行,但如果文件长度按 sliceHeight 算,就会多出一截垃圾。

大面积紫绿色块,多半是 UV 平面顺序搞反,NV12 当 NV21 用了。验证方法是只看 Y 通道转灰度,如果灰度正常而颜色错乱,就是这个原因。快速修复可以试着把 U、V 两个平面的读取顺序对调,几十秒就能确认。

画面上下颠倒,这个在直接用 Surface 渲染的场景里常见,但我们走的是 buffer 路径,一般不会遇到。真遇到了检查一下是不是在configure里传了CONFIGURE_FLAG_ENCODE之类的错误 flag。

6.2 卡死与超时

dequeue一直返回TRY_AGAIN_LATER,然后循环空转,日志刷屏但进度不动。这种情况优先查两件事:输入是不是已经喂完了(EOS 发出去了),输出是不是已经收到 EOS 了。如果输入 EOS 已发,输出没动静,很可能是解码器内部还有数据没吐完,继续等就行,但要加一个最大循环次数保护,比如连续 500 次超时就抛异常退出,避免死循环。

还有一种更隐蔽的:文件本身编码异常,或者中途截断,解码器吐出一帧之后再也不吐了。这时候dequeueOutputBuffer会一直超时,但也不报错。我的做法是记录最后成功输出的 PTS,如果超过 5 秒没有任何新帧,就认为异常,主动stop并release,把已经写出去的数据保留下来。

configure阶段抛IllegalStateException,一般是 format 里缺少关键字段,比如KEY_WIDTH为 0,或者 mime 跟解码器不支持。这种情况先打印完整的 format 内容,逐个字段核对。

6.3 帧数对不上

解出来的帧数比预期少,最常见的原因是漏了BUFFER_FLAG_CODEC_CONFIG的过滤,或者把info.size == 0的帧也算了进去。反过来,帧数比预期多,通常是 EOS 之后还多处理了一次空帧。

还有一种情况是可变帧率视频。你以为 30 秒 × 30fps = 900 帧,实际文件里可能是 870 帧,因为编码时做了帧率控制。这个不要用时长去推算,直接extractor.getTrackFormat().getInteger(MediaFormat.KEY_FRAME_RATE)拿标称值也未必准。最可靠的办法是解完之后统计实际写出的帧数,把它写进元信息文件。

6.4 常见问题速查表

现象最可能原因排查动作
画面斜切、条纹rowStride 未处理打印 stride,按行偏移读取
颜色紫绿、灰度正常UV 顺序反了对调 U/V 读取顺序验证
底部绿边sliceHeight 大于 height写文件时按 height 截断
帧数为 0未选对轨道或未 advance检查 selectTrack 和 readSampleData 返回值
进度卡住不动未处理 EOS 或输出超时加循环保护,打印最后 PTS
文件大小异常写入了 codec config 帧过滤 BUFFER_FLAG_CODEC_CONFIG
内存持续上涨每帧新建 ByteArray改用缓冲池复用
写入速度越来越慢未 flush、缓冲区积压定期 flush,控制队列水位

7. 一些工程化收尾的做法

7.1 元信息必须落盘

原始 YUV 文件没有文件头,你不写元信息,三天后自己都不知道这个out_1920x1080.yuv的色度平面是 I420 还是 NV12。我强制要求同目录下生成一个文本文件,记录宽高、色彩格式、帧数、每帧字节数、源文件路径、解码耗时、使用的解码器名称。

解码器名称这个字段特别有用。MediaCodec.getName()返回的是底层实现名,比如带硬件厂商前缀的那种。某些机型上花屏,把名字记下来,一对比就能发现是特定实现的 bug,后续可以针对性地加白名单或者换软解兜底。

7.2 校验与回放

写完不要直接信任结果。我加了一个轻量校验:读出文件长度,跟帧数 × 每帧字节数比对,不相等就有问题。再进一步,从第 0 帧和第 N/2 帧各取一段出来,用工具转成 PNG 看一眼,能发现大部分排布错误。

回放工具我一般用两种:一是写个简单的 Java 程序把 I420 转 RGB 存成 PNG,纯代码可控;二是直接扔进通用播放器,用-f rawvideo -pix_fmt yuv420p -s 1920x1080这类参数喂进去。前一种适合自动化验证,后一种适合肉眼快速确认。

如果你后续要做批量处理,建议在文件命名里带上色彩格式标识,比如xxx_i420_frame000001.yuv,避免不同来源的数据混在一起之后再靠人工回忆区分。

我个人在多次实践中的体会是:这套流程里 90% 的 bug 都出在"格式假设"上——假设了 rowStride 等于 width,假设了 UV 是交错还是平面,假设了 sliceHeight 等于 height。凡是涉及像素排布的地方,全部从Image或者MediaFormat里读出来,一个都不要猜,调试时间能省掉一大半。真机上第一次跑通之后,建议立刻在低端机和高端机上各跑一遍同一个文件,对比输出的 MD5,不一致就说明还有设备相关的问题没处理干净。

返回列表