ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android虚拟摄像头实现:基于LSPosed的Hook方案

Android虚拟摄像头实现:基于LSPosed的Hook方案 简介一套面向已 Root 安卓设备的虚拟摄像头实现方案通过 native 层 Hook 机制绕过系统摄像头硬件限制将录屏、动画或 AI 生成画面伪装成物理摄像头输入可直接被抖音、快手、微信等调用 Camera API 的直播与视频类 App 使用适合无人值守直播、数字人播报、自动化教学演示等场景。压缩包仅 91KB共 50 个文件以 xml 布局与资源文件、java 控制层代码、gradle 构建脚本、markdown 说明文档为主同时包含 xposed_init、proguard 混淆规则及 gitignore 等工程化配置结构紧凑且完整可编译。内置动态启停虚拟摄像头、帧率调节及 YUV/RGB 格式输出能力核心逻辑集中在 native 层 Hook 与 Java 层桥接并附带多语言 README便于快速理解与二次开发。目前已有 169 人学习适合有一定 Android 底层开发经验、需要稳定自定义视频源的开发者直接参考。 做Android端自动化测试、直播推流验证、还有各种人脸识别演示的朋友应该都遇到过同一个头疼的问题系统只有一颗物理摄像头某些场景下就是需要一个“能骗过App的摄像头源”。早些年市面上还有不少虚拟摄像头App但Android对摄像头权限和HAL层的管控越来越严普通思路基本都废了。我最近在Root设备上重新整理了一套基于Hook的虚拟摄像头实现方案把踩过的坑和最终可用的路子一起总结出来。这篇内容适合有Android开发基础、熟悉Root和Xposed/LSPosed框架的读者也适合正在做自动化测试和音视频开发的人参考。核心思路不复杂在Camera API调用链路上做拦截把真实摄像头的数据源替换成我们准备好的图片或视频流。下面我按方案选型、环境准备、核心实现、问题排查的顺序完整梳理一遍。1. 方案背景与设计思路1.1 虚拟摄像头的核心诉求在动手之前先搞清楚一件事虚拟摄像头到底要解决什么问题我接触到的需求主要有三类。一是自动化测试App里有人脸识别或者扫码逻辑测试环境没有真机摄像头这时候需要模拟一路稳定的视频源。二是直播推流调试推流端要验证不同分辨率、帧率、编码参数下的效果如果每次都拿真机对着屏幕拍光线和画面不可控测试数据没法对比。三是做演示比如给别人演示某个功能不希望暴露真实环境又需要摄像头画面。这个方案的目标很明确在App调用摄像头时我们返回的不是物理摄像头采集的画面而是一张图或者一段视频。所有改动不影响系统原有相机功能卸载Hook模块后一切恢复正常。1.2 三层Hook方案选型对比Android摄像头调用链路粗略可以分为三层App层Java Camera API/Camera2 API、Framework层CameraService、HAL层Camera HAL。对应到实现虚拟摄像头也有三种截然不同的技术路线。方案层级实现方式优点缺点Java层HookXposed/LSPosed Hook Camera/Camera2类实现简单、开发快、兼容性好只能骗过Java层调用Native代码直接访问Camera时失效Native层HookInline Hook/GOT Hook 拦截HAL或CameraService函数覆盖面更广开发量大不同Android版本差异大内核驱动层修改V4L2驱动或伪装UVC设备最底层所有应用通吃需要定制内核维护成本极高我最终选了Java层Hook作为主线方案再配合一处Native层的辅助Hook。原因很实际绝大多数App都是通过Java的Camera API拿数据的Java层Hook能覆盖95%的场景而且开发调试效率高。纯粹从技术炫技角度上Native Hook确实覆盖更广但实际项目里时间成本往往不划算。2. 环境准备与工具链搭建2.1 Root环境检测与基础准备这个方案的前置条件是设备已经获取Root权限。设备方面我建议用Pixel或者小米系的机器这两个阵营的Root生态最成熟。系统版本上Android 8到Android 13都试过LSPosed在Android 14上也能跑但需要对应版本。准备阶段依次确认三件事。第一Root是否正常用Root Explorer或终端执行su -c id能返回uid0(root)就说明Shell权限没问题。第二确认Magisk版本这个方案不依赖Magisk模块但LSPosed的安装方式因Magisk版本不同有差异。第三确认SELinux状态执行getenforce如果是Enforcing需要临时用setenforce 0切到Permissive不然Hook模块无法注入App进程。# 检查Root状态 su -c id # 查看SELinux状态 getenforce # 临时关闭SELinux调试用 su -c setenforce 0注意SELinux临时切换只影响当前运行状态重启后恢复。如果在Enforcing模式下LSPosed模块无法注入先确认是否是SELinux拦截导致而不是一上来就怀疑框架装错了。2.2 Hook框架选择从Xposed到LSPosed老玩家都对Xposed Framework有感情但Xposed官方版在Android 7.0后就停止维护了。现在Root设备上的主流选择是LSPosed它基于Magisk运行支持Android 8.0到Android 14代码仓库的活跃度也高。LSPosed的安装非常有代表性在Magisk里刷入LSPosed的ZIP包重启后在通知栏或桌面会出现LSPosed管理器的入口。装好之后要做的第一件事是在管理器里勾选“推荐配置”然后确认框架的“模块作用域”功能正常这个功能决定你的Hook模块只对指定App生效避免全局注入带来系统不稳定。2.3 开发工程搭建要点LSPosed模块的开发工程和普通Android工程差异不大但有几个必须注意的地方。SDK版本建议编译用SDK 33或34minSdkVersion设到26就够了对应Android 8.0。Gradle依赖里要引入LSPosed提供的API注意官方API在JitPack仓库不是Maven Central。实际的依赖配置是这样的repositories { maven { url https://jitpack.io } } dependencies { compileOnly de.robv.android.xposed:api:82 compileOnly de.robv.android.xposed:api:82:sources }这里用了compileOnly原因很关键LSPosed框架本身已经把这些API类注入到了目标进程里编译期只需要拿到类型定义打包时不能把这些类带进APK否则会引发类冲突。工程结构上需要在src/main/assets/下放一个xposed_init文件文件内容写你的入口类全路径。这个文件是LSPosed识别模块入口的关键漏掉它整个模块装上去完全没反应。我最初做的时候就在这个文件上卡了小半天。3. 核心实现Java层Hook与虚拟帧注入3.1 定位关键Hook点Camera与Camera2Java层Hook要找一个合适的切入点。老版本的android.hardware.Camera类核心方法是open(int)和setPreviewDisplay(SurfaceHolder)前者获取Camera实例后者绑定预览Surface。新一代android.hardware.camera2.CameraManager的openCamera(String, CameraDevice.StateCallback, Handler)则是Camera2的入口。在设计Hook策略时需要两条线同时处理。对于Camera旧接口HookCamera.open替换返回值、HooksetPreviewCallback注入帧数据。对于Camera2接口HookopenCamera方法在回调返回时替换我们自己的CameraDevice实现。实际开发中的工作量主要都集中在了替换设备实例上因为Camera2接口是状态机驱动要处理的回调比较多。3.2 核心Hook代码拆解LSPosed的Hook代码写起来很直接核心是XposedHelpers.findAndHookMethod。我贴一个实际的Hook入口代码public class CameraHook implements IXposedHookLoadPackage { private static final String TAG VirtualCamera; Override public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) { if (!lpparam.packageName.equals(BuildConfig.TARGET_PACKAGE)) { return; } hookOldCameraApi(lpparam); hookCamera2Api(lpparam); } private void hookOldCameraApi(XC_LoadPackage.LoadPackageParam lpparam) { XposedHelpers.findAndHookMethod( android.hardware.Camera, lpparam.classLoader, open, int.class, new XC_MethodHook() { Override protected void beforeHookedMethod(MethodHookParam param) { param.setResult(null); } Override protected void afterHookedMethod(MethodHookParam param) { if (param.getResult() null) { param.setResult(FakeCamera.create()); } } } ); } }这段代码的逻辑是这样的当目标App调用Camera.open(int)时beforeHookedMethod先把原始返回结果设为null防止真实摄像头被打开然后在afterHookedMethod中把一个自定义的FakeCamera对象返回给App。这样App拿到的Camera实例就是我们伪造的对象对App来说它调用的还是同一个方法根本感知不到差异。3.3 虚拟帧数据的构造与注入拿到FakeCamera之后最关键的问题变成了App调用setPreviewCallback时我们要在每一帧回调里返回什么数据简单方案是准备一张JPG图片解码之后转成NV21格式的像素数组。Android相机预览帧默认格式是NV21YUV420SP所以我们要做的就是把图片转成NV21字节流。这里有一个重要细节图片的尺寸必须和App请求的预览尺寸一致否则App端的图像拉伸会非常离谱。public class FakeCamera { private byte[] nv21Data; private int width 640; private int height 480; private Camera.PreviewCallback callback; public static FakeCamera create() { FakeCamera fake new FakeCamera(); fake.nv21Data loadFrameFromAssets(); return fake; } public void setPreviewCallback(Camera.PreviewCallback cb) { this.callback cb; startFrameDeliver(); } private void startFrameDeliver() { if (callback null) return; HandlerThread thread new HandlerThread(FakeCamera); thread.start(); Handler handler new Handler(thread.getLooper()); handler.postDelayed(new Runnable() { Override public void run() { if (callback ! null) { callback.onPreviewFrame(nv21Data, fakeCameraInstance); } handler.postDelayed(this, 33); // 约30fps } }, 0); } }这里最容易踩的坑是线程问题。onPreviewFrame的调用必须在一个有Looper的线程里且不能阻塞主线程。我见过很多第一次写这个模块的人直接在setPreviewCallback里同步调用onPreviewFrame结果App直接卡死。正确做法是单独起一个HandlerThread以固定间隔33ms对应30fps持续投递帧数据。4. Native层Hook绕过Java层限制的进阶方案4.1 什么时候必须上Native HookJava层Hook有一个明显死角如果App用了NDK的Camera API比如通过JNI直接调用android/ndk/camera.h或者在某些自定义渲染引擎里走Native采集链路Java层的替换就失效了。我遇到的一个实际案例是某个直播SDK它的摄像头采集不走Android标准Camera API而是直接把HAL层的帧数据拉走到了Native层做特效处理。这类App在Java层Hook完全没有效果必须下沉到Native层。Native Hook的思路可以分为两种全局函数Hook调用入口拦截和指令级Inline Hook修改函数头部指令跳转。4.2 GOT Hook与Inline Hook对比GOT Hook全局偏移表Hook本质上就是修改ELF文件的GOT表项。共享库里调用外部函数时地址是从GOT表里读取的我们把这个表项改成自己函数的地址调用就转到我们这儿了。优点是实现简单稳定性好缺点是只能拦截通过GOT表跳转的外部函数调用如果目标函数在同一模块内直接跳转GOT Hook就无能为力。Inline Hook则是直接修改目标函数的机器码在函数开头写入一条跳转指令让执行流跳转到我们的Hook函数等处理完再跳回原函数继续执行。覆盖面更广但需要处理指令缓存同步、ARM/Thumb模式切换等底层细节。成熟的Android Native Hook库有Dobby和xHook两者都支持ARM64和ARM32。xHook主要是PLT/GOT HookDobby则同时支持Inline Hook功能更全面。4.3 Native Hook的关键实现环节如果要用Native Hook去拦截Camera HAL层的process_capture_request链路会非常长涉及Camera HAL3的复杂状态机不太适合快速落地。更实际的路径是拦截libcamera_client.so里执行帧数据拷贝的函数把我们要注入的帧数据替换进去。这里分享一个Dobby的简单示例#include dobby/dobby.h typedef int (*original_camera_stream_t)(void* request, int stream); static original_camera_stream_t original_func nullptr; int hook_camera_stream(void* request, int stream) { // 替换帧数据从全局缓冲区复制数据 copy_virtual_frame(request, stream); // 如果不调用原函数就断了真正的数据流 return original_func(request, stream); } void install_hook() { void* target DobbySymbolResolver(libcamera_client.so, some_function); if (target ! nullptr) { DobbyHook(target, (void*)hook_camera_stream, (void**)original_func); } }这里有个容易忽略的点Native Hook里如果调用原函数原函数内部会去请求真实的硬件设备。如果此时真实设备已经被Java层的Hook挡住没有打开原函数调用很可能直接崩溃。所以需要设计一个开关在Native注入模式下保留一条真实设备通路或者干脆不调用原函数只做数据填充。这个决策会影响整个方案的稳定性。注意Native层Hook的调试难度比Java层高一个数量级。建议在开发阶段先用日志确认函数入口是否被正确拦截再逐步加入业务逻辑。一上来就完整替换帧数据出问题后很难定位是Hook点位选错了还是数据格式不对。5. 常见问题与排查技巧实录5.1 常见问题速查表整个开发调试过程中我整理了这些高频问题。用表格列出来方便大家直接对照定位。现象可能原因排查/解决方式模块安装后无任何反应xposed_init文件缺失或路径错误检查assets目录确认入口类名和实际类名一致Hook模块对目标App无效Target包名识别不了检查BuildConfig.TARGET_PACKAGE确认是完整包名App打开摄像头直接闪退帧数据格式不对或尺寸不匹配打印App请求的预览分辨率严格按要求构造NV21数据图像颜色偏色严重NV21数据中UV分量排列错误确认图片旋转和格式转换逻辑Android相机预览是NV21不是YUV420P预览画面卡顿帧投递线程和UI线程冲突确认帧回调在独立HandlerThread执行Thread.sleep不可取只对部分App生效该App可能走了Camera2 Native链路换Native Hook方案Java层Hook覆盖不到注入后其他App相机也异常模块作用域设置成了全局在LSPosed里将模块作用域限制为特定App从这张表里也能看出几个关键设计原则模块作用域一定要精确控制帧数据处理尽可能提前完成不要把解码和转换操作放在帧回调里。5.2 几个让我印象深刻的坑第一个坑是关于转码的。最初我直接把Bitmap从API 28以下的高效解码接口里拿的像素数据转成NV21再回调结果App预览画面明显发紫发绿。排查了大半天问题的根源是JPEG的像素格式是RGBNV21的Y分量是线性比例映射U/V分量是色差信号两套转换公式完全不能混用。后来用Android自带的YuvImage或者RenderScript做转换才解决。第二个坑是预览方向。竖屏App拿到的Camera预览数据是横屏的图像需要旋转90度或270度后再转成NV21。这个细节不处理好画面永远是歪的。实际操作中我封装了一个rotateNv21函数根据相机ID和传感器方向来判断旋转角度。第三个坑不太容易想到部分App在后台运行时会持续持有相机权限此时Hook模块如果在运行过程中被热更新会导致App的Camera状态和实际帧数据不一致出现诡异的花屏或黑屏。这个问题的排查很绕最终定位到是LSPosed热加载导致的模块状态不一致解决方案是每次更新模块后完全重启目标App而不是只切换前后台。6. 性能优化与后续扩展6.1 帧率与内存的取舍虚拟摄像头的性能瓶颈集中在两处图片解码和内存拷贝。一张1920x1080的NV21帧数据大约是3MB如果每帧都临时解码再拷贝内存分配和GC压力会非常大App端卡顿是必然的。我的建议是启动时就完成所有准备工作把预置图片解码并转换成NV21格式存放在全局的byte[]里帧回调直接引用这个数组不要每次重新new。如果用的是视频源用MediaExtractorMediaCodec解出帧数据后存成循环缓冲区帧率控制在25到30fps就足够逼真。帧率控制这块实测下来用Handler.postDelayed比用Choreographer更可控因为postDelayed只是按时把任务丢到消息队列不关心vsync信号帧率的抖动反而更小。6.2 可以继续深挖的方向这个方案跑通之后我还在尝试几个扩展方向。一个是把虚拟帧从静态图升级为动态视频流给自动化测试提供真实的动态场景。另一个是在FakeCamera里做多分辨率适配不固定640x480而是读取App侧的分辨率请求动态生成对应尺寸的帧数据。还有一个方向是对接AI算法把真实摄像头采集的画面交给算法处理后再把处理结果作为虚拟帧输出这样就在不修改App的情况下完成了滤镜效果。我在实际使用中越来越觉得Hook方案的价值不在于“欺骗”而在于它把摄像头数据流变成了一个可编程、可控制的节点。自动化测试的稳定性、直播推流的可重复性都因为这一点而大幅提升。在动手实现前我的建议是先在LSPosed里把最小的模块跑通再逐步加功能。直接一步到位写完整的虚拟摄像头模块出问题后真的很难判断是哪一层出了问题。如果你在实现过程中遇到什么奇葩问题欢迎在评论区交流我踩过的坑大概率能帮你省下不少排查时间。本文还有配套的精品资源点击获取
返回列表