Surging企业级电商系统微服务架构设计与实现:10个关键实践指南
把手机摄像头换成一段视频:安卓虚拟摄像头 Xposed 实现全拆解
android_virtual_cam 是一款基于 Xposed 的安卓虚拟摄像头模块,支持安卓 5.0 及以上系统。它能在完全不改动目标应用代码的前提下,把摄像头预览画面和拍照结果悄悄替换成指定视频与图片,对应用开发者、测试工程师和安全研究人员都很有价值。本文会从落地步骤讲起,再逐层拆开 Hook 原理、MediaCodec 硬解码和避坑要点,帮你把原理吃透。
一、先回答一个问题:虚拟摄像头到底有什么用?
做应用测试的人大概都遇到过这样的场景:要验证扫码、人脸识别、直播推流功能,但手边没有稳定的测试环境,画面忽明忽暗、背景杂乱,根本没法复现问题。更麻烦的是,有些功能必须在"摄像头真实出画面"的前提下才能跑通流程。
虚拟摄像头就是为这类场景准备的。它像一扇"假窗户":应用以为自己在看真实的摄像头,实际上看到的是一段循环播放的视频或一张静态图片。典型用途包括:
- 功能测试:用固定画面反复验证相机相关逻辑,保证每次测试条件一致
- 隐私审计:监控哪些应用在后台偷偷调起摄像头、怎么处理画面数据
- 演示教学:给摄像头功能做 Demo 时,不用真人出镜,画面可控又整洁
android_virtual_cam 这个项目选择的实现路线很"硬核":用 Xposed 在系统层拦截相机 API。这意味着它对目标应用透明,应用自身完全感知不到异样。
二、模块很小,五脏俱全
整个项目的核心代码只有三个 Java 文件,结构非常清爽:
| 文件 | 职责 |
|---|---|
app/src/main/java/com/example/vcam/HookMain.java |
所有 Hook 点的注册与替换逻辑,是"偷天换日"的总指挥 |
app/src/main/java/com/example/vcam/VideoToFrames.java |
基于 MediaCodec 的硬解码器,把视频拆成一帧帧画面 |
app/src/main/java/com/example/vcam/MainActivity.java |
配置界面,提供几个开关控制模块行为 |
app/src/main/assets/xposed_init |
声明模块入口类,Xposed 框架靠它找到 HookMain |
入口声明只有一行,写的是 com.example.vcam.HookMain。Xposed 框架在目标应用进程启动时,会加载这个类并调用 handleLoadPackage(),所有 Hook 都注册在这个方法里。
想自己拉源码研究,可以执行:
git clone https://gitcode.com/gh_mirrors/an/android_virtual_cam
三、动手:十分钟让目标应用"看到"你的视频
先跑通再讲原理,这样后面读代码会更有感觉。准备一台已安装 Xposed 框架(如 LSPosed)的安卓 5.0+ 设备,按下面四步走:
第 1 步:安装并启用模块。 安装这个 App 后,在 Xposed 管理器里勾选启用。如果用的是 LSPosed,记得在作用域里选上目标应用,不需要勾选系统框架。
第 2 步:准备视频文件。 在手机存储的 DCIM/Camera1/ 目录下放入一段视频,命名为 virtual.mp4。目录不存在就手动创建。
第 3 步:读分辨率提示。 打开目标应用的相机,界面会弹出一行气泡提示"宽:xx 高:xx"。这个数值就是应用实际请求的预览分辨率,替换视频必须与它完全一致,否则会花屏。
第 4 步:处理权限问题。 如果目标应用没有申请存储权限,模块会把 Camera1 目录自动重定向到该应用的私有目录:
/内部存储/Android/data/应用包名/files/Camera1/
重定向发生时会有气泡提示,别错过。私有目录下的配置只对这一个应用生效。
拍照替换同理:根据拍照时的提示分辨率准备一张图片,命名为 1000.bmp 放进同一目录即可。其他图片格式改成 .bmp 后缀也能用。
四、核心机制:Hook 是怎么做到"偷天换日"的
可以把 Xposed 的 Hook 理解成在系统门口安插的检查员。应用每次调用某个系统 API,都得先经过这位检查员过目,检查员可以放行、改参数,甚至直接把结果换掉。android_virtual_cam 一共安插了十几位检查员,分别守着不同岗位。
岗位一:守预览出口(Camera1 预览替换)
老相机 API(android.hardware.Camera)里,应用通常用 setPreviewTexture() 把相机画面连到一块 SurfaceTexture 上。Hook 抓住这个方法,把应用传进来的纹理偷偷换成自己创建的假纹理:
XposedHelpers.findAndHookMethod(
"android.hardware.Camera", // 要拦截的类:老相机 API
lpparam.classLoader, // 目标应用的类加载器
"setPreviewTexture", // 方法名
SurfaceTexture.class, // 方法签名(参数类型)
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
// param.args[0] 是应用原本要传入的纹理对象
// 这里偷梁换柱:替换成我们自己的假纹理
fake_SurfaceTexture = new SurfaceTexture(10);
param.args[0] = fake_SurfaceTexture;
}
});
替换之后,应用拿到的是一块"空转"的纹理,真正的画面哪来的?答案是 MediaPlayer。模块把视频文件交给 MediaPlayer,让它把画面渲染到这块纹理对应的 Surface 上,于是应用看到的就是视频画面,而且是无缝循环播放。如果还想要声音,创建一个 no-silent.jpg 开关文件,MediaPlayer 就会取消静音。
岗位二:守帧数据通道(预览帧回调替换)
有些应用不走纹理,而是用 setPreviewCallback() 这类回调接口直接拿预览帧的字节数组(通常是 NV21 格式)做分析,比如人脸检测、扫码。这个岗位更刁钻:Hook 拦截回调注册,然后在 onPreviewFrame() 被调用前,把解码好的视频帧数据"填"进字节数组:
// 拦截 onPreviewFrame 回调,把真实相机数据换成视频帧
beforeHookedMethod(paramd) {
// data_buffer 是解码线程持续写入的最新视频帧
System.arraycopy(data_buffer, 0, paramd.args[0],
0, Math.min(data_buffer.length, ((byte[]) paramd.args[0]).length));
}
data_buffer 里装的就是 VideoToFrames 解码出的 NV21 帧。这招的精妙之处在于:回调的时序、缓冲区大小都保持原样,应用完全看不出数据是假的。三个回调注册方法(setPreviewCallback、setPreviewCallbackWithBuffer、setOneShotPreviewCallback)都被守住了。
岗位三:守拍照入口(照片替换)
拍照走的是 takePicture(),它接受三个回调:快门回调、YUV 回调、JPEG 回调。Hook 在 afterHookedMethod 里分别拦截,把 1000.bmp 读出来:
- 对 JPEG 回调:Bitmap 压缩成 JPEG 字节,替换回调参数
- 对 YUV 回调:先把 Bitmap 的像素按公式转成 YCbCr420 字节(代码里实现了完整的 RGB 转 YUV 换算),再替换回调参数
// 读取预设图片,压缩成 JPEG 字节流替换拍照结果
Bitmap pict = getBMP(video_path + "1000.bmp");
ByteArrayOutputStream temp = new ByteArrayOutputStream();
pict.compress(Bitmap.CompressFormat.JPEG, 100, temp);
paramd.args[0] = temp.toByteArray(); // 偷换成假照片
注意一个细节:模块会先弹出"发现拍照 + 分辨率"的提示。如果拍照时没有这个提示,说明 1000.bmp 不生效——因为应用走的可能不是常规拍照路径。
五、MediaCodec 硬解码:视频是怎么变成一帧帧画面的
前面提到的"解码线程"就是 VideoToFrames 类,它是整个项目的血管。它的工作分三步:
第一步,用 MediaExtractor 选轨道。 从视频文件里挑出 mime 以 video/ 开头的视频轨道,拿到宽、高、编码格式等信息。
第二步,创建并配置 MediaCodec 解码器。 按 mime 类型创建解码器,把解码输出的颜色格式尽量设为 YUV420Flexible(硬件解码器普遍支持的一种灵活格式),并指定输出 Surface。
第三步,循环解码。 这是最核心的一段,逻辑是标准的"输入-输出"双循环:
while (!sawOutputEOS && !stopDecode) {
// 1) 取空闲输入缓冲区,把视频数据喂给解码器
int inId = decoder.dequeueInputBuffer(TIMEOUT_US);
if (inId >= 0) {
int size = extractor.readSampleData(decoder.getInputBuffer(inId), 0);
if (size < 0) {
// 视频读完了,标记输入结束
decoder.queueInputBuffer(inId, 0, 0, 0, BUFFER_FLAG_END_OF_STREAM);
sawInputEOS = true;
} else {
decoder.queueInputBuffer(inId, 0, size,
extractor.getSampleTime(), 0);
extractor.advance(); // 推进到下一帧
}
}
// 2) 取解码完成的输出帧,渲染或取数据
int outId = decoder.dequeueOutputBuffer(info, TIMEOUT_US);
if (outId >= 0) {
// 按帧时间戳控制节奏,让播放速度与视频本身一致
long sleep = info.presentationTimeUs / 1000 - elapsed;
if (sleep > 0) Thread.sleep(sleep);
decoder.releaseOutputBuffer(outId, true);
}
}
解码出的帧有两个去向:渲染到指定 Surface(对应纹理预览路径),或取出 YUV 数据写进 data_buffer(对应帧回调路径)。取数据时还会处理 YUV 平面到 NV21/I420 的排列转换,把三个 plane 按行间距重新排布成标准字节流——这一步不处理干净,画面就会出现色偏和花屏。
解码完成后 extractor.seekTo(0, 0) 回到起点,形成无缝循环,正好对应虚拟摄像头"一直有画面"的需求。
六、Camera2 新接口:换一套打法
安卓 5.0 之后新增了 Camera2 API,架构完全不同,但思路一致:把应用要用的输出 Surface 全部换成假的。模块在 CameraManager.openCamera() 处拦截,接管相机状态回调;在 CaptureRequest.Builder.addTarget() 里做替换:
// 应用每次把目标 Surface 加进拍照/预览请求,
// 我们都悄悄替换成虚拟 Surface(视频画面)
XposedHelpers.findAndHookMethod(
"android.hardware.camera2.CaptureRequest.Builder",
lpparam.classLoader, "addTarget", Surface.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.args[0] = c2_virtual_surface; // 换!
}
});
同时还要盯住 createCaptureSession 系列方法,把应用传给会话的 Surface 列表整体替换。另外,模块会拦截 ImageReader.newInstance()——应用创建渲染器时,宽、高、格式这些参数会暴露出来,正好用来提示开发者"视频应该做成多大"。
这里有个识别技巧:addTarget 传入的 Surface 名字里如果带 Surface(name=null),一般是读取数据的 Surface;带名字的通常是预览 Surface。模块会分别记录,为每个 Surface 都准备一套解码器或播放器,所以 Camera2 路径下最多会同时跑两个解码器加两个 MediaPlayer。
七、藏在文件名里的"开关"
这个项目最巧的设计之一:配置靠文件存在与否来控制。在 Camera1 目录下创建特定名字的文件,就相当于拨动开关,而且全局实时生效,无需重启应用:
| 文件名 | 作用 |
|---|---|
virtual.mp4 |
替换预览画面的视频(核心素材) |
1000.bmp |
替换拍照结果的图片 |
disable.jpg |
临时停用视频替换,恢复真实摄像头 |
no_toast.jpg |
关闭所有气泡提示 |
no-silent.jpg |
播放视频的声音(默认静音) |
force_show.jpg |
强制重新显示权限提示(默认只提示一次) |
private_dir.jpg |
强制所有应用走私有目录 |
MainActivity 界面上的五个开关,本质就是帮你创建/删除这些文件。手动建文件也能达到同样效果,两条路都通。
八、避坑清单:黑屏、花屏、方向错乱怎么办
跑起来之后,常见的坑基本集中在下面几类,对照排查即可:
画面黑屏或相机启动失败。 优先检查三点:一是替换视频是否真的在正确目录(最常见的翻车点是建了两级目录,比如 DCIM/Camera1/Camera1/virtual.mp4,只要一级就够);二是目标应用是否属于"无法替换"的类型——系统相机应用基本替换不了,某些自研相机的应用也不行;三是视频路径是否因为权限问题被重定向到了私有目录。
画面花屏。 九成是分辨率不匹配。用气泡提示的宽高数值,用剪辑软件把视频裁成完全一致的尺寸。
画面扭曲变形。 视频比例和屏幕比例不一致导致,去剪辑软件里重新裁剪画面匹配即可。
前置摄像头方向不对。 大多数情况下,替换前置摄像头的视频需要水平翻转并右旋 90 度,处理后的分辨率也要和提示一致。但"大多数"不等于"全部",具体以实际效果为准。
disable.jpg 不生效。 注意版本差异:模块版本 4.0 及以下,有存储权限的应用看 DCIM/Camera1 下的文件,无权限应用要在私有目录下建;4.1 及以上版本,统一在 DCIM/Camera1 下创建即可。
九、性能与边界:哪些做得到,哪些做不到
先说做不到的:录像无法替换。模块拦截了 MediaRecorder.setCamera(),但它只能弹出"触发了录像,目前无法拦截"的提示,无法真正把录像内容换成视频。这是当前实现的明确边界。
性能方面有几个值得注意的点:
- 资源要及时释放。解码器、MediaPlayer、SurfaceTexture 用完都要
release(),项目里每次重建前都会先释放旧对象,避免内存泄漏 - 解码线程有节流。按帧时间戳
Thread.sleep控制节奏,避免解码器空转烧 CPU - 多路并行有代价。Camera2 路径下双解码器 + 双播放器同时运行,对低端机的内存和功耗都是考验
- 硬解码依赖编解码器支持。H.264 是兼容性最好的选择,H.265 在高版本系统上才普遍支持
兼容性上,项目覆盖安卓 5.0+,对老 API 的多个版本(P 及以上的 Executor 版本 openCamera、N 及以上的 createCaptureSessionByOutputConfigurations 等)都做了分支处理,看得出在适配层面下了不少功夫。
十、除了换画面,这套机制还能玩出什么
理解了原理之后,这个项目的价值会超出"换视频"本身:
- 测试提效:相机类功能测试从此有了确定性的输入,回归测试不再受环境干扰
- 隐私研究:通过它观察应用对摄像头数据的处理行为,评估权限滥用风险
- 教学演示:摄像头原理、帧回调机制、MediaCodec 解码流程,都能拿它当活教材
从演进方向看,这个项目未来可以做的还有很多:支持更多视频格式、增加实时滤镜、优化多路解码的资源占用、把配置界面做得更图形化。如果你有兴趣,完全可以在现有 Hook 骨架上叠加自己的功能——毕竟"拦截系统 API"这个能力本身,就是一套通用的舞台。
写在最后
android_virtual_cam 用一个不大的代码量,把 Xposed Hook、MediaCodec 硬解码、Surface 与帧数据替换这几块硬骨头啃了下来,是一份很适合入门的系统级开发教材。读源码时建议按这个顺序:先看 handleLoadPackage 里注册了哪些 Hook,再看 VideoToFrames 的解码循环,最后回到 process_camera2_play 看各路画面是怎么汇合的。
动手跑通一个 demo,再回头读代码,你会发现自己对安卓相机架构的理解深了一层。记得把技术用在合法合规的场景里,测试、研究、学习,都是它最好的归宿。
更多推荐




所有评论(0)