
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.ByteBuffer
class VideoCapture(private val projection: MediaProjection) {
private var codec: MediaCodec? = null
private var inputSurface: Surface? = null
private var outputBuffer: ByteBuffer? = null
private 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.LinkedBlockingQueue
class AudioCapture {
private var audioRecord: AudioRecord? = null
private var audioQueue: LinkedBlockingQueueByteBuffer = LinkedBlockingQueue()
private var isRecording = false
fun init() {
val sampleRate = 44100
val channelConfig = AudioFormat.CHANNEL_IN_MONO
val audioFormat = AudioFormat.ENCODING_PCM_16BIT
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC, // 注意:这里只能录麦克风,系统内音需要特殊权限或MediaProjection的音频流
sampleRate,
channelConfig,
audioFormat,
minBufferSize * 2
)
}
// 在独立线程中调用
fun startRecording() {
isRecording = true
audioRecord?.startRecording()
val buffer = ByteArray(audioRecord?.minBufferSize ?: 0)
while (isRecording) {
val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0
if (read 0) {
val byteBuffer = ByteBuffer.wrap(buffer, 0, read)
// 将数据放入队列,由编码器线程消费
audioQueue.offer(byteBuffer)
}
}
}
fun stopRecording() {
isRecording = false
audioRecord?.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.File
class Muxer(private val outputFile: File) {
private var muxer: MediaMuxer? = null
private var videoTrackIndex = -1
private var audioTrackIndex = -1
private var muxerStarted = false
fun startMuxer(videoFormat: MediaFormat, audioFormat: MediaFormat) {
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
videoTrackIndex = muxer?.addTrack(videoFormat) ?: -1
audioTrackIndex = muxer?.addTrack(audioFormat) ?: -1
muxer?.start()
muxerStarted = true
}
// 写入视频数据
fun writeVideoData(codecBuffer: MediaCodec.BufferInfo) {
if (!muxerStarted || videoTrackIndex 0) return
muxer?.writeSampleData(videoTrackIndex, codecBuffer.buffer, codecBuffer)
}
// 写入音频数据
fun writeAudioData(codecBuffer: MediaCodec.BufferInfo) {
if (!muxerStarted || audioTrackIndex 0) return
muxer?.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 库?评论区交流,看看大家是怎么踩坑的。