从零到一实现安卓虚拟摄像头:Xposed Hook 与 MediaCodec 硬解码实战全解析

从零到一实现安卓虚拟摄像头:Xposed Hook 与 MediaCodec 硬解码实战全解析

【免费下载链接】android_virtual_cam xposed安卓虚拟摄像头 android virtual camera on xposed hook 【免费下载链接】android_virtual_cam 项目地址: https://gitcode.com/gh_mirrors/an/android_virtual_cam

android_virtual_cam 是一个基于 Xposed 框架的开源安卓虚拟摄像头模块:它不碰任何硬件,就能把目标 App 的相机预览替换成视频画面、把拍照结果替换成静态图片,同时兼容 Camera1 与 Camera2 两代接口,背后靠的是 MediaCodec 硬解码与运行时方法拦截两项核心技术。这篇文章不打算按教程的套路平铺直叙,而是以「闯关」的视角,把实现虚拟摄像头的每一步难点、取舍和踩坑过程完整复盘一遍,希望能帮你在阅读源码时少走弯路。

为什么需要「软件虚拟摄像头」而不是改硬件

先聊一个实际问题:在测试、演示或隐私研究场景里,我们常常希望某个应用"看到"的画面不是真实镜头拍到的。改硬件固件、做 USB 摄像头伪装器,成本高且不可移植;相比之下,在应用层拦截相机 API 是更现实的一条路。

这条路天然分成两个流派:

  • 替换数据流:拦截相机返回的预览帧和照片字节流,直接塞入我们准备好的图片或视频帧;
  • 替换出口对象:拦截 setPreviewTexture() / addTarget() 这类方法,把应用的 Surface 偷偷换成我们控制的纹理,再让 MediaPlayer 或解码器把视频画上去。

android_virtual_cam 走的是第二条路线为主、第一条为辅的混合方案:能直接换 Surface 就换 Surface,遇到必须回调字节数组的场景(比如 onPreviewFrame)再走数据替换。理解了这两条思路,后面所有 Hook 点就都能对号入座。

第一关:Xposed 凭什么能"劫持"系统相机调用

Xposed 的核心思想是在 Zygote 进程启动早期注入代码。安卓里每个 App 进程都由 Zygote 孵化,模块一旦在 Zygote 里站稳脚跟,就等于给之后诞生的所有进程都装上了一层"代理",从而能在运行时修改任意类的方法行为。

具体到本项目,入口只有一行声明:app/src/main/assets/xposed_init 里写着 com.example.vcam.HookMain。框架启动时加载这个类,HookMain 实现 IXposedHookLoadPackage,在 handleLoadPackage() 里拿到目标进程的 classLoader,然后用 XposedHelpers.findAndHookMethod() 对系统相机类做方法级拦截:

XposedHelpers.findAndHookMethod(
        "android.hardware.Camera",          // 要 hook 的类
        lpparam.classLoader,                // 目标进程的类加载器
        "setPreviewTexture",                // 方法名
        SurfaceTexture.class,               // 参数类型
        new XC_MethodHook() {
            @Override
            protected void beforeHookedMethod(MethodHookParam param) {
                // 方法执行前动手脚:param.args 就是方法入参
            }
        });

beforeHookedMethod 可以在方法真正执行前改写参数甚至直接 setResult() 短路返回,afterHookedMethod 则可以在方法返回后继续操作。这套「参数替换 + 结果伪造」的组合拳,就是整个虚拟摄像头的地基。

第二关:Camera1 的四个关键 Hook 点

老一代 android.hardware.Camera API 虽然过时,但存量应用极多,必须优先覆盖。代码里主要 Hook 了四处:

① 预览纹理setPreviewTexture(SurfaceTexture) 是应用把预览画面接到屏幕的关键。Hook 时先判断替换视频 virtual.mp4 是否存在、disable.jpg 是否创建,然后把入参换成我们自建的 SurfaceTexture,之后把视频画面渲染到这个纹理上,应用便"以为"摄像头在工作:

if (fake_SurfaceTexture == null) {
    fake_SurfaceTexture = new SurfaceTexture(10);
}
param.args[0] = fake_SurfaceTexture;   // 偷梁换柱

② 预览回调setPreviewCallbacksetPreviewCallbackWithBuffersetOneShotPreviewCallback 三兄弟都要 Hook,因为不同应用调用习惯不同。收到回调后,先拿到应用注册的 PreviewCallback 类,再动态 hook 它的 onPreviewFrame(byte[], Camera) 方法——这里有一个非常"黑科技"的细节:把解码器产出的帧数据通过 System.arraycopy 直接覆盖回调参数里的字节数组,应用拿到的就是虚拟画面。由于解码帧生成是异步的,代码里用 while (data_buffer == null) {} 忙等来保证数据就绪,这也是一个值得体会的取舍。

③ 拍照拦截takePicture 有四个回调参数(快门、原始、缩放、JPEG),需要分别 hook 对应的 onPictureTaken。替换逻辑很朴素:把 Camera1/1000.bmp 读成 Bitmap,压缩成 JPEG 字节流塞进回调;如果是 YUV 回调,则走一遍 RGB→YCbCr420 转换公式。

④ 预览启动startPreview 被 Hook 后,项目会直接创建 MediaPlayer 绑定到应用的真实纹理上循环播放 virtual.mp4,同时把音量设为 0(除非存在 no-silent.jpg)。setPreviewDisplay(SurfaceHolder) 则走另一条分支:用假纹理接管后直接 param.setResult(null),阻止真正的预览流程。

第三关:Camera2 才是真正的硬骨头

Camera2 是异步架构,光 hook 一个入口远远不够,本项目为此铺了一张相当密的网:

  • CameraManager.openCamera 的两个重载:老签名带 Handler,Android 9+ 又出现带 Executor 的版本,代码里按 Build.VERSION.SDK_INT 分别处理,拿到 StateCallback 后再动态 hook 它的 onOpenedonErroronDisconnected
  • CaptureRequest.Builder.addTarget/removeTarget/build:应用往请求里塞的 Surface 全部被换成项目自建的虚拟 Surface,同时把真实的预览 Surface 和 ImageReader Surface 悄悄记录下来,供后续分发视频流;
  • createCaptureSession 家族createCaptureSessioncreateCaptureSessionByOutputConfigurationscreateConstrainedHighSpeedCaptureSessioncreateReprocessableCaptureSession 以及 Android 9 的 SessionConfiguration 版本,全部用同一招——把 List<Surface> 参数替换成虚拟 Surface 列表;
  • ImageReader.newInstance:拦截应用创建渲染器的宽高和格式,用于判断该喂 JPEG 还是 NV21 数据流。

一句话总结 Camera2 的策略:让应用以为自己在操作真实相机会话,实际上它的所有输入输出目标都被重定向到了虚拟 Surface 上。值得留意的是,虚拟 Surface 需要反复创建与释放(代码里称之为"重建垃圾场"),因为每个 onOpened 周期都要一套全新的 Surface,否则会出现画面卡死或无法预览。

第四关:视频画面从哪来——MediaCodec 硬解码链路

画面有了"出口"(虚拟 Surface),还缺"源头"。项目用 Android 的 MediaCodec 做硬件加速解码,核心实现在 app/src/main/java/com/example/vcam/VideoToFrames.java,完整链路分为四步:

① 媒体提取MediaExtractor.setDataSource() 打开视频文件;② 轨道选择:遍历所有轨道,挑出 mimevideo/ 开头的那条,selectTrack() 选中;③ 解码器配置:根据轨道 MediaFormat 里的 MIME 调用 MediaCodec.createDecoderByType() 创建解码器,configure(format, play_surf, null, 0) 时把虚拟 Surface 作为输出目标;④ 循环取帧:用 dequeueInputBuffer 喂入压缩数据、dequeueOutputBuffer 取回解码结果:

while (!sawOutputEOS && !stopDecode) {
    int inIdx = decoder.dequeueInputBuffer(TIMEOUT_US);
    if (inIdx >= 0) {
        ByteBuffer buf = decoder.getInputBuffer(inIdx);
        int size = extractor.readSampleData(buf, 0);
        if (size < 0) {
            decoder.queueInputBuffer(inIdx, 0, 0, 0L,
                    MediaCodec.BUFFER_FLAG_END_OF_STREAM);
            sawInputEOS = true;
        } else {
            decoder.queueInputBuffer(inIdx, 0, size,
                    extractor.getSampleTime(), 0);
            extractor.advance();
        }
    }
    int outIdx = decoder.dequeueOutputBuffer(info, TIMEOUT_US);
    if (outIdx >= 0) {
        // 渲染到虚拟 Surface,应用侧即可"看到"视频画面
        decoder.releaseOutputBuffer(outIdx, true);
    }
}

一个重要的细节是循环播放:视频播到结尾(收到 EOS)后,代码会 extractor.seekTo(0, 0) 从头再来,实现无限循环,同时用 info.presentationTimeUs 计算睡眠时间做帧率节流。相比软件解码(CPU 逐帧解析),这种硬解方案功耗低、帧率高,是预览流畅度的关键保障。

第五关:NV21、I420、YUV_420_888 到底怎么选

当没有 Surface 可输出、必须返回原始字节时,就轮到颜色格式登场了。三者都属于 YUV 4:2:0 家族,但内存排布不同:

  • I420(YUV420P):Y、U、V 三个平面连续存放,最规整;
  • NV21(YUV420SP):Y 平面之后紧跟 VU 交错排列,是安卓相机预览回调的"传统默认格式";
  • YUV_420_888Image API 的通用格式,带 rowStridepixelStride 对齐信息,兼容性最好但处理最繁琐。

VideoToFrames.java 里的 getDataFromImage() 正是为处理 YUV_420_888 的 stride 对齐而写的:逐平面逐行拷贝,遇到 pixelStride != 1 时还要抽稀,否则会得到花屏或错位画面。项目在 Camera2 场景下会根据 ImageReader 的格式决定输出 JPEG(格式码 256)还是 NV21,两个场景各配一套解码器实例。给新手的建议:能走 Surface 渲染就别碰字节拷贝,只有必须填 onPreviewFrame 或拍照回调时才做格式转换,这样能省掉一半的麻烦。

目录即配置:几个 jpg 文件就是整套开关

这个项目最巧的设计之一,是把配置做进了文件系统。默认工作目录是 /[内部存储]/DCIM/Camera1/,里面每个文件都有明确含义:

  • virtual.mp4:替换预览画面的视频,必备;
  • 1000.bmp:替换拍照结果的图片(其他格式改后缀也能读);
  • disable.jpg:临时停用替换,全局实时生效;
  • no_toast.jpg:关闭所有气泡提示;
  • no-silent.jpg:允许视频声音外放;
  • force_show.jpg:强制再次显示目录重定向提示;
  • private_dir.jpg:强制每个应用使用自己的私有目录。

配套的 MainActivity.java 提供了一组 Switch 开关,本质上就是帮你创建/删除这些文件,二者完全等价。

这里还藏着一个非常实用的权限自适应机制:如果目标应用没有存储读取权限,模块会在 Instrumentation.callApplicationOnCreate 被 Hook 时检测权限状态,把 Camera1 目录自动重定向到 /[内部存储]/Android/data/[应用包名]/files/Camera1/,并通过 Toast 告知用户,避免出现"视频放对了目录却读不到"的尴尬。

踩坑实录:黑屏、花屏、方向错乱的排查思路

把上面的模块跑起来只是开始,真正的修罗场在调试阶段。根据项目 README 和源码里的日志,最常见的问题基本可以归为四类:

① 黑屏或相机启动失败。九成原因是视频路径不对——比如手滑建成了两级目录 DCIM/Camera1/Camera1/virtual.mp4;另一成是目标应用本身无法替换,尤其是系统相机这类对相机有强绑定的应用,Xposed 方案目前无解。

② 花屏。几乎可以锁定为分辨率不匹配。应用打开预览时会弹 Toast 报出期望的宽高(如"宽:1280 高:720"),视频必须与之一致;若用 setPreviewCallback 场景,视频分辨率还需与应用设置的预览分辨率完全相同。

③ 画面扭曲变形。这是比例问题,需要拿剪辑软件把视频裁切到目标宽高比,而不是让模块去做缩放。

④ 前置摄像头方向错乱。多数前置场景需要把视频水平翻转再右旋 90°,处理后分辨率仍需与提示一致——但这不是铁律,个别应用行为不同,要以实际效果为准。

此外还有两个已知边界:MediaRecorder.setCamera 目前只能检测并弹提示,录像功能尚无法拦截;Camera2 的某些高级功能(如重复处理、高速录制)即使 hook 了对应会话创建方法,兼容性也仍然依赖具体设备驱动。

性能优化与进阶方向

从源码里能明显看到作者对资源管理的用心:每次重新解码前都会 stopDecode() 并释放旧解码器,Surface 按需重建,这提醒我们在 Hook 场景里资源泄漏的代价比普通应用更高——解码器和 Surface 都是系统级资源,泄漏会导致相机打开失败。

后续可以发力的方向也很明确:为每个应用分配独立视频(private_dir.jpg 已经是雏形)、支持多摄像头同时虚拟化、加入实时滤镜、用图形化配置取代文件开关体系。对想动手改造的读者,建议从 VideoToFrames.java 入手,把它改造成可复用的解码组件。

写在最后

复盘整个项目,最值得学习的并不是某一段 Hook 代码,而是那种"先画出口、再造源头、最后补适配"的系统化思路:先用 Xposed 把应用的相机出口全部接管,再用 MediaCodec 把视频源源不断地送进去,最后用文件开关和目录重定向解决配置与权限问题。三层各司其职,才拼出了这个看似简单实则五脏俱全的虚拟摄像头。

想边读边实验的读者,可以直接拉取源码对照本文的关卡顺序逐一验证:

git clone https://gitcode.com/gh_mirrors/an/android_virtual_cam

最后必须提醒一句:虚拟摄像头技术本身是中性的,它适合用于应用测试、隐私研究和教学演示,但切勿用于绕过实名认证、欺诈或任何非法场景。理解原理的意义,在于让我们更清醒地审视自己手机里那些"正在使用相机"的应用——这正是安全研究最朴素的价值。

【免费下载链接】android_virtual_cam xposed安卓虚拟摄像头 android virtual camera on xposed hook 【免费下载链接】android_virtual_cam 项目地址: https://gitcode.com/gh_mirrors/an/android_virtual_cam

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值