ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

3个核心模块搞定录屏软件手机版,面试必问的底层逻辑

2026/9/22 2:06:46 拓冰建站 浏览量
3个核心模块搞定录屏软件手机版,面试必问的底层逻辑 3个核心模块搞定录屏软件手机版,面试必问的底层逻辑 官方文档里全是晦涩的 API 定义和回调机制,读完脑子还是空的,根本抓不住重点。 别慌,今天不讲虚的,直接拆解一个能跑的录屏软件手机版核心实现。 这不仅是项目实战,更是面试必问的音视频处理底层逻辑,搞懂它,技术深度直接上一个台阶。 项目目标与核心痛点 我们要做的,不是一个简单的“点击开始-点击停止”的壳子,而是一个具备工业级稳定性的移动端录屏引擎。 很多开发者容易陷入误区:认为录屏就是调用系统 API 录制视频。 大错特错。 系统 API 只是黑盒,一旦遇到高帧率画面、音频不同步、内存溢出,你就彻底抓瞎。 我们的目标很明确:低延迟采集:确保画面和声音的时间戳严格对齐。 高效编码:利用硬件加速,降低 CPU 占用,避免手机发烫。 灵活输出:支持 MP4 容器封装,兼容主流播放器。这里必须引入一个权威标准:RFC 3986 (URI Generic Syntax)。 虽然它是讲 URI 的,但在处理媒体流元数据时,我们需要遵循类似的标准化结构来定义媒体轨道的标识符。 更重要的是,在音视频封装中,我们遵循 ISO/IEC 14496-14 标准,即 MP4 文件格式规范。 如果你不懂 MP4 的 Box 结构,你就不知道为什么录出来的视频在某些安卓手机上无法播放。 面试中,问到“为什么你的录屏文件在 iOS 能放,安卓不能?”这就是考点。 答案往往藏在 moov box 的原子顺序里,而不是简单的编码参数。 目录结构规划 为了保持代码的工程化,我们采用模块化设计。 项目结构如下,这是标准的 Android 原生开发结构,逻辑清晰,便于维护。 ScreenRecorderApp/ ├── app/ │ ├── src/ │ │ ├── main/ │ │ │ ├── java/ │ │ │ │ └── com.example.screenrecorder/ │ │ │ │ ├── core/ │ │ │ │ │ ├── AudioCapture.kt // 音频采集模块 │ │ │ │ │ ├── VideoCapture.kt // 视频采集模块 │ │ │ │ │ ├── Encoder.kt // 硬编码封装 │ │ │ │ │ └── Muxer.kt // 多路复用封装 │ │ │ │ ├── ui/ │ │ │ │ │ ├── MainActivity.kt // 主界面 │ │ │ │ │ └── RecorderViewModel.kt │ │ │ │ └── utils/ │ │ │ │ └── FileUtils.kt │ │ │ ├── res/ │ │ │ └── AndroidManifest.xml │ └── build.gradle ├── build.gradle └── settings.gradle核心模块说明:AudioCapture: 负责从 AudioRecord 读取 PCM 数据。 VideoCapture: 负责从 MediaProjection 获取 Surface 数据。 Encoder: 封装 MediaCodec,处理 H.264 视频和 AAC 音频编码。 Muxer: 使用 MediaMuxer 将编码后的数据流写入 MP4 文件。核心代码实现 这部分是重中之重。我们将分模块讲解,重点在于线程同步和时间戳处理。 1. 视频采集与硬编码 视频录屏的核心是 MediaProjection。它允许我们获取屏幕内容。 痛点: 很多教程直接用 Canvas 绘制到 MediaProjection 的 Surface,这是极低的性能陷阱。 方案: 直接让 MediaProjection 渲染到 MediaCodec 的输入 Surface。 // VideoCapture.kt import android.media.projection.MediaProjection import android.view.Surface import java.nio.ByteBufferclass VideoCapture(private val projection: MediaProjection) {private var codec: MediaCodec? = nullprivate var inputSurface: Surface? = nullprivate var outputBuffer: ByteBuffer? = nullprivate var isRecording = false// 初始化编码器fun init(width: Int, height: Int, bitrate: Int): Surface {val format = MediaFormat.createVideoFormat(video/avc, width, height)format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)format.setInteger(MediaFormat.KEY_BIT_RATE, bitrate)format.setInteger(MediaFormat.KEY_FRAME_RATE, 30) // 固定30帧format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) // 每秒1个关键帧codec = MediaCodec.createEncoderByType(video/avc).apply {configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)start()}// 获取输入 Surface,这是关键!inputSurface = codec?.createInputSurface()outputBuffer = codec?.getOutputBuffer(0)return inputSurface!!}// 释放资源fun release() {codec?.stop()codec?.release()inputSurface?.release()} }逐行解析:createInputSurface(): 这一步至关重要。它将 MediaProjection 的输出直接连接到 MediaCodec 的输入,避免了 CPU 拷贝,极大降低了延迟。 KEY_I_FRAME_INTERVAL: 设置为 1 意味着每秒插入一个 IDR 帧(关键帧)。这是为了在录制中断或快速拖动进度条时,能迅速解码出画面。面试常问:为什么视频开头是黑的?答:因为还没遇到关键帧。2. 音频采集与同步 音频比视频简单,但时间戳同步是难点。 视频有 PTS (Presentation Time Stamp),音频也有。 如果两者起点不一致,就会出现“声画不同步”。 // AudioCapture.kt import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder import java.util.concurrent.LinkedBlockingQueueclass AudioCapture {private var audioRecord: AudioRecord? = nullprivate var audioQueue: LinkedBlockingQueueByteBuffer = LinkedBlockingQueue()private var isRecording = falsefun init() {val sampleRate = 44100val channelConfig = AudioFormat.CHANNEL_IN_MONOval audioFormat = AudioFormat.ENCODING_PCM_16BITval minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)audioRecord = AudioRecord(MediaRecorder.AudioSource.MIC, // 注意:这里只能录麦克风,系统内音需要特殊权限或MediaProjection的音频流sampleRate,channelConfig,audioFormat,minBufferSize * 2)}// 在独立线程中调用fun startRecording() {isRecording = trueaudioRecord?.startRecording()val buffer = ByteArray(audioRecord?.minBufferSize ?: 0)while (isRecording) {val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0if (read 0) {val byteBuffer = ByteBuffer.wrap(buffer, 0, read)// 将数据放入队列,由编码器线程消费audioQueue.offer(byteBuffer)}}}fun stopRecording() {isRecording = falseaudioRecord?.stop()} }避坑指南:权限问题:Android 10+ 对音频源限制严格。MediaRecorder.AudioSource.MIC 只能录麦克风。如果想录系统内音(如游戏声音),必须使用 MediaProjection 的音频流,或者使用 AudioRecord 的 VOICE_COMMUNICATION 源(仅限通话场景)。 阻塞风险:read 是阻塞操作。如果编码器处理慢了,音频队列会堆积,导致内存泄漏。务必在队列消费端做背压控制。3. 多路复用 (Muxing) 这是将编码后的视频和音频数据打包成 MP4 的过程。 MediaMuxer 是 Android 提供的标准工具,但它的使用姿势有讲究。 // Muxer.kt import android.media.MediaCodec import android.media.MediaFormat import android.media.MediaMuxer import java.io.Fileclass Muxer(private val outputFile: File) {private var muxer: MediaMuxer? = nullprivate var videoTrackIndex = -1private var audioTrackIndex = -1private var muxerStarted = falsefun startMuxer(videoFormat: MediaFormat, audioFormat: MediaFormat) {muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)videoTrackIndex = muxer?.addTrack(videoFormat) ?: -1audioTrackIndex = muxer?.addTrack(audioFormat) ?: -1muxer?.start()muxerStarted = true}// 写入视频数据fun writeVideoData(codecBuffer: MediaCodec.BufferInfo) {if (!muxerStarted || videoTrackIndex 0) returnmuxer?.writeSampleData(videoTrackIndex, codecBuffer.buffer, codecBuffer)}// 写入音频数据fun writeAudioData(codecBuffer: MediaCodec.BufferInfo) {if (!muxerStarted || audioTrackIndex 0) returnmuxer?.writeSampleData(audioTrackIndex, codecBuffer.buffer, codecBuffer)}fun stopMuxer() {if (muxerStarted) {muxer?.stop()muxer?.release()muxerStarted = false}} }关键细节:BufferInfo 中的 presentationTimeUs 必须精确。 如果视频帧的时间戳比音频帧小,说明音频采集晚了,或者视频采集快了。 修复策略:在写入前,对比音视频的首帧时间戳。如果差值超过 50ms,强制调整后续帧的时间戳偏移量。运行与测试 代码写完,怎么验证? 不要只看播放器! 播放器容错率太高,掩盖了很多问题。 测试步骤:录制 10 秒纯白屏视频:检查文件头。使用 ffprobe 命令(Linux/Mac)或在线 MP4 解析工具。检查 moov box 是否在文件末尾?如果是,说明是快速启动模式失败,建议重新封装。录制高动态画面(如快速滑动的列表):观察 CPU 占用率。如果 CPU 超过 80%,说明硬件加速没生效,检查 MediaCodec 是否 fallback 到软编。 检查帧率是否稳定在 30fps。使用 adb shell dumpsys media.metrics 查看丢帧情况。断电测试:在录制过程中强制杀掉进程。 再次打开文件。如果文件损坏,说明 Muxer.stop() 没有被正确执行,或者异常处理缺失。常见 Bug 场景:Bug 1:视频有画面没声音。原因:音频编码失败,或者 AudioRecord 权限被拒绝。 排查:打印 AudioRecord.getState() 和 AudioRecord.getErrorCode()。Bug 2:文件体积巨大。原因:比特率设置过高,或者关键帧间隔太短。 优化:动态调整比特率,根据画面复杂度(I 帧比例)调整。优化扩展与进阶技巧 做到这里,你已经能跑通一个基础版录屏了。但要做到“资深”,还需要以下几点: 1. 动态分辨率与帧率 不要写死 1080p 30fps。 根据电池电量和温度动态调整。电量 20%:降至 720p 24fps。 温度 45℃:暂停录制,或降至 15fps。2. 断点续录 如果录制过程中 App 被系统杀死,重启后能否继续?方案:将录制的分段文件(Segment)单独保存。 恢复时,将之前保存的 Segment 合并(Remux)到新文件中。 这需要实现 MP4 的 Box 重写逻辑,难度较大,建议参考 FFmpeg 的 mp4 remux 逻辑。3. 水印与滤镜 在 VideoCapture 阶段,可以在 InputSurface 上叠加 Canvas 绘制水印。注意:这会引入 CPU 开销。 优化:使用 OpenGL ES 在 GPU 层面叠加,避免 CPU 参与像素操作。4. 网络推流 如果需要直播,将 MediaCodec 的输出 Buffer 直接通过 RtspServer 或 WebSocket 推流。这里涉及 RFC 2326 (RTP/RTCP) 协议的理解,虽然不一定要手写,但必须懂其时序要求。小结 回顾整个录屏软件手机版的实现过程,我们并没有依赖复杂的第三方库,而是直接操作 Android 原生的 MediaProjection、MediaCodec 和 MediaMuxer。 核心收获:硬件加速是王道:InputSurface 直接连接 MediaCodec 是性能的关键。 时间戳是灵魂:音视频同步不是靠“感觉”,而是靠精确的微秒级时间戳对齐。 容器格式很重要:MP4 的 Box 结构决定了文件的兼容性和可修复性。面试必问点往往藏在细节里:“如果视频录制过程中,手机锁屏了,怎么处理?”答:需要保持 WakeLock,并在 MediaProjection 上处理 onScreenCaptureStarted 回调,防止黑屏。“如何判断视频是否被篡改?”答:MP4 本身不支持签名,但可以在 udta Box 中添加数字签名数据。技术没有捷径,只有对底层协议的深刻理解。 你更常用哪种写法?是纯原生开发,还是集成 FFmpeg 库?评论区交流,看看大家是怎么踩坑的。