ARTICLE DETAIL

建站实战干货

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

Android 12下虚拟应用分身与模拟开发实战

2026/9/10 9:10:43 拓冰建站 浏览量
Android 12下虚拟应用分身与模拟开发实战 简介這份基於Android 12的虛擬應用分身與模擬開發設計源碼面向需要實現應用多開、設備模擬及插件化開發的Android開發者或團隊提供一套可直接參考的工程實現方案。資源共包含1250個文件其中836個Java文件承載核心邏輯105個XML配置界面與資源86個AIDL接口用於跨進程通信另有C/C頭文件與源碼配合完成底層Hook、模擬操作等能力整體壓縮包約51.67MB代碼結構清晰便於按模塊研讀。目前已有697人學習下載。通過該源碼讀者可以掌握應用分身、虛擬多開、WiFi/設備模擬、釘釘打卡、企微打卡等場景的實現思路並研究Java與C Hook、XP插件、插件開發等關鍵技術適合中高級開發者深度剖析Android虛擬化與模擬開發實踐。1. Android 12上做虚拟应用分身与模拟开发不能照搬VirtualApp旧方案在 Android 12 上把虚拟应用分身工程跑起来问题往往不是业务代码而是 PackageManager 返回 null。把一个 APK 以分身形式加载进自定义沙箱进程系统仍认为这个包不存在resolveActivity、getApplicationInfo 接连失效。Android 12 收紧了包可见性还开始审计 ApplicationInfo.dataDir 的访问路径早期 VirtualApp 只改 ActivityThread classloader 的方案已不够用。本文顺着这套源码结构讲清两个问题虚拟应用分身如何绕过安装器拉起目标 App模拟开发如何在限制更严的环境里 Mock 定位、设备与传感器最后拆成 virtual-core、virtual-manager 两个模块接入宿主工程。适合自己维护多开框架、做容器化测试或研究 Android 12 系统改造的人。示例统一按 API 31、targetSdkVersion 31 来写。2. 从Android 12的包管理变化切入拆解虚拟分身源码的启动设计不直接贴一套完整源码我主要讲实现思路宿主进程维护一个虚拟包表把每个未安装的 APK 变成一个不存在的 PackageInfo启动这个 APK 时由宿主注册的虚拟 Activity 代为创建再替换为目标主题。这套设计中Android 12 带来的主要变化是三处包可见性需要白名单LoadedApk 的 dataDir 访问受到 SELinux 审计ClassLoader 的资源路径宿主不再自动补全。2.1 用 解决“查不到分身包”的Android 12拒绝从 API 30 开始PackageManager.queryIntentActivities() 默认只能看到部分系统应用和自身。Android 12 进一步约束了 resolveActivity()。如果你在宿主里通过包名查询分身主界面返回值极有可能是 null这不是代码写错是包可见性过滤。在源码工程里常见做法不是申请 QUERY_ALL_PACKAGES审核和厂商检测都会被盯上而是在宿主 Manifest 中显式声明要查询的分身包。例如希望宿主能拉起一个包名为 com.example.object 的分身主界面可以这样写manifest xmlns:androidhttp://schemas.android.com/apk/res/android queries intent action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent package android:namecom.example.object / /queries /manifest的意图声明告诉系统宿主需要解析 Launcher 类型的 Intent 则直接声明要查询的目标包。这里要同时写两类是因为 launchMainActivity 底层会先对目标包做一次 queryIntentActivities拿不到入口 Activity 就直接抛 ActivityNotFoundException。如果维护的是通用源码工程建议把可能安装的分身包名都放进一个 resource 数组由构建脚本生成 避免每个新包都改 Manifest。这里要特别说明 的 只代表“允许查询该包的已安装状态”不代表获得调用其组件的权限。分身框架依然要把 APK 的 dex 加载到自己的 classloader 中真正启动入口是在代理 Activity 的 onCreate 里替换 Intent 的 ComponentName。2.2 代理Activity委托与多ClassLoader分身代码怎么找到自己的Application所有虚拟应用中一个经典入口是宿主里预先注册若干个虚拟 Activity。目标包被启动时系统实际启动的是代理 Activity再在它的 onCreate 里根据 uid 找到真正的 APK 路径和 ClassLoader把插件 Activity 实例手工创建出来。这里最容易出错的是 classloader 的父级关系。分身的 Application 不能直接用宿主 classloader 加载因为 ContextImpl 里的 mPackageInfo 还停留在宿主包上。常见做法是把插件 APK 的 dex 做一个 DexClassLoader并把它挂到宿主 classloader 之后。我一般建议优先级是先复用系统 framework classloader 里的公共类插件自己的类再从自己的 dex 加载。这样 Android 12 对包私有访问检查引发的 ClassNotFoundException 会少很多。下面是一段从源码框架中剥离出来的最小加载逻辑private ClassLoader createPluginClassLoader(String apkPath, Context host) { final String optimizedDir host.getDir(va_opt, Context.MODE_PRIVATE).getAbsolutePath(); final String libDir host.getDir(va_lib, Context.MODE_PRIVATE).getAbsolutePath(); String nativeLibDir host.getApplicationInfo().nativeLibraryDir; // 把宿主apk路径与插件apk路径拼在一起保证插件阶段还能引用宿主里的类 String combinedPath TextUtils.join(File.pathSeparator, new String[]{ apkPath, host.getApplicationInfo().sourceDir }); DexClassLoader loader new DexClassLoader(combinedPath, optimizedDir, nativeLibDir File.pathSeparator libDir, host.getClassLoader()); return loader; }DexClassLoader 第一个参数被很多人误当成单个 dex 路径其实它是用系统分隔符拼出的一组路径。optimizedDir 用于放优化后的 odex 文件libDir 为了让插件里的 .so 能被 System.loadLibrary 找到。Android 12 上如果插件 APK 与宿主签名不一致sourceDir 校验会在 ApplicationInfo.createForPackage 时抛 SecurityException所以你需要把虚拟包表里的 PackageInfo.signatures 字段替换成宿主签名数组让插件认为自己由宿主签名。2.3 data目录隔离把真实路径重定向到沙箱避免SELinux审计Android 12 开始对 ApplicationInfo.dataDir 做了更严格的来源校验凡是路径不落在包自身 data 目录内的访问都会被 Auditd 记录并拦截。虚拟分身若把目标包的真实 data 路径直接暴露给插件进程会在第一次写数据库时被 PermissionDenial 挡掉。常见做法是给每个分身账号分配一个独立的沙箱 root路径形如 /data/user/0/{宿主包}/va/account/{uid}/{package_name}然后把 ApplicationInfo.dataDir、getFilesDir、getDatabasePath 的返回值全部替换成这个沙箱路径。下表是我在本地工程里常用的映射策略类型真实环境路径沙箱虚拟路径files/data/user/0/pkg/files{vaRoot}/filesdatabase/data/user/0/pkg/databases{vaRoot}/databases/uid_$uidshared_prefs/data/user/0/pkg/shared_prefs{vaRoot}/shared_prefscode_cache/data/user/0/pkg/code_cache{vaRoot}/code_cache映射时不要直接做 File 字符串替换因为数据库路径还涉及 SQLiteDatabase 内部对 db 的锁和 journal 文件。正确顺序是先 mkdirs 到 {vaRoot}/databases/uid_$uid再通过反射设置 ApplicationInfo.dataDir 为该根目录。Android 12 上还需要把这个虚拟根目录加入 StrictMode 白名单否则 dataDirectory 校验会触发 IncorrectContextUseViolation导致 debug 构建下插件一启动就闪退。3. 模拟开发设计怎么做在分身进程里Mock定位、设备指纹和传感器模拟开发不是把 Android Studio 模拟器拿过来用而是在虚拟分身框架里提供一层服务让外部测试场景“感觉像换了台手机”同一台真机上分身 A 打开地图看到的经纬度来自 mock分身 B 打开天气看到的还是真实网络和传感器两者互不干扰。要达成这个目标关键是搞清楚哪些系统接口能直接 Mock哪些必须从 Binder 层面绕弯。3.1 用LocationManager.addTestProvider在分身体内创建MockProviderAndroid 12 依然保留 addTestProvider 这个标准入口但它只在持有 android.permission.ACCESS_MOCK_LOCATION 的应用里可用。该权限是签名级权限不能通过运行时对话框授权因此要放进 debug source set并用 adb 打开开关。很多模拟定位软件被提示“不允许模拟”根因就在这。下面是分身体内注册 mock provider 的最小代码LocationManager lm (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); if (lm.getProvider(gps_mock) null) { lm.addTestProvider( gps_mock, false, false, false, true, true, true, true, Criteria.POWER_LOW, Criteria.ACCURACY_FINE); } Location loc new Location(gps_mock); loc.setLatitude(31.2304); loc.setLongitude(121.4737); loc.setAccuracy(5.0f); loc.setTime(System.currentTimeMillis()); if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { loc.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos()); } lm.setTestProviderEnabled(gps_mock, true); lm.setTestProviderLocation(gps_mock, loc);addTestProvider 里七个 boolean 依次表示 requiresNetwork、requiresSatellite、requiresCell、hasMonetaryCost、supportsAltitude、supportsSpeed、supportsBearing后两个 int 决定 provider 的功耗和精度等级。setElapsedRealtimeNanos 是 Android 12 上很多插件判断定位是否过期的依据如果漏掉部分地图 SDK 会把 mock 数据当旧包丢弃。还需要在 adb 中打开 mock location 开关adb shell appops set com.example.host android:mock_location allow adb shell am force-stop com.example.host注意这里设置的是宿主包名。分身体内真正调用 getSystemService 时如果代理层不转发会直接对系统服务做请求所以一个完整的源码设计里应在 VirtualClientImpl 中把 LocationManager 的调用路由到“先查 mock 配置再决定是否回源”。3.2 设备指纹Mock的落地边界反射Build字段绕过SystemProperties很多人拿到模拟开发需求第一反应是 SystemProperties.set(ro.product.model, ...)。在 Android 12 非 root 环境里这行代码会静默失败即使反射拿到 ISystemProperties它也不是常规 Binder 服务不能像拦截 LocationManager 那样替换。可靠且影响面可控的办法是在插件 classloader 加载目标 App 之前对 android.os.Build 的静态字段做运行时覆盖。这里有一个前提Build.MODEL 的初始化值来自 SystemProperties.get(ro.product.model)运行时计算后才赋值给 final 字段因此在 Android ART 上反射 Field.set 仍然能改。下面是容器里常用的 BuildOverride 工具public final class BuildOverride { public static void apply(String model, String brand, String device) { set(Build.class, MODEL, model); set(Build.class, BRAND, brand); set(Build.class, DEVICE, device); } private static void set(Class? clazz, String fieldName, Object value) { try { Field field clazz.getDeclaredField(fieldName); field.setAccessible(true); field.set(null, value); } catch (Throwable ignored) { // 个别厂商ROM里字段被标记为final且内联这里只能放弃 } } }MODEL、BRAND、DEVICE 三个字段最常被风控读取模拟型号时必须一起改。Android 12 上有些 App 不再读 Build而是读 Settings.Secure.getString(resolver, android_id)所以还要在 VirtualClientImpl 里拦截 ContentResolver 的调用。不要在插件的 onCreate 里调用 BuildOverride.apply因为那时 Build 类已经被插件 ClassLoader 加载过了应该在 createPluginClassLoader 完成之后、加载第一个插件类之前调用。若担心污染宿主进程可以在分身进入 onDestroy 时恢复原始值。TelephonyManager.getImei() 在 Android 12 上被绑定为 READ_PRIVILEGED_PHONE_STATE 签名权限普通应用调用会直接 SecurityException。要 mock IMEI 时不是在源码里填死字符串而是把 TelephonyManager 当 Binder 服务拦截在虚拟 TelephonyStack 里根据 uid 返回不同的序列号。很多设备指纹方案在这里漏掉只改 Build结果风控一查 IMEI 全穿帮。3.3 模拟开发的功能路由表该在ServiceManager哪一层下手实际模拟开发设计最终会落到一张路由表上。下面是我维护框架时常用的一张“分身体内 vs 系统服务”对应表模拟能力系统服务常见 Mock 方式容易踩的坑定位LocationManagerServiceaddTestProvider / setTestProviderLocation未先开 appops 导致 provider 不生效设备型号Build / SystemProperties反射 Build 静态字段只改 MODEL 不改变量 DEVICE 与 BRANDIMEI/序列号TelephonyManagerService自定义虚拟 TelephonyStackAndroid 12 验签拒绝非特权权限电量/温度BatteryService发送 ACTION_BATTERY_CHANGED 广播需要同时改温度字段与 HealthInfo传感器SensorManagerService反射替换 Sensor 的 TYPE模拟器上 sensor HAL 不触发 flush 回调这张表第一列是模拟能力第二列决定从哪个系统服务切入口第三列是源码模块里能放的 Mock 点第四列是调试时最常出现的问题。不建议一上来就 hook ServiceManager.addService这会带来 binder 线程死锁更可控的办法是把 Mock 逻辑放进一个 VirtualClientImpl分身体内通过 Context.getSystemService 时被替换为 VaSystemServiceManager在 Manager 内部再做分发。4. 把虚拟应用分身源码工程化搭建Android 12宿主可集成的core与manager模块如果只是手动调试可以按第 2、3 章原理复制代码。但要做成可维护工程我通常会把它分成 virtual-core 和 virtual-manager 两个模块前者只依赖 framework 和 androidx.annotation负责加载插件 APK、管理 classloader、hook 系统上下文后者面向业务决定包名、账号、uid 的映射并向外暴露 mock 配置接口。这样宿主只依赖 manager后续升级 Android 版本时只改 core 内部。4.1 新建virtual-core与virtual-manager两个源集并配置Gradle依赖在根 settings.gradle 里先声明模块然后在 virtual-core/build.gradle 里把 minSdkVersion 固定在 26、compileSdk 固定在 31避免 IDE 自动升到 32 后权限行为出现差异android { namespace com.va.core compileSdk 31 defaultConfig { minSdk 26 targetSdk 31 } } dependencies { api androidx.annotation:annotation:1.6.0 implementation com.google.guava:guava:31.1-android }targetSdk31 是刻意选的分身体在 Android 12 与旧版本之间的逻辑分支很多以 31 作基线便于统一处理 PendingIntent mutability 和前台服务启动限制。Guava 在源码里用来处理任务队列和自动清理缓存如果担心方法数超标可以只保留需要的类。在宿主 app/build.gradle 中依赖 managerimplementation(project(:virtual-manager)) implementation(project(:virtual-core))注意 AIDL 接口的生成路径对模块顺序不敏感但两个模块都打包 aidl 时可能出现重复类建议在 packagingOptions 里 exclude“**.aidl/*.aidl”。4.2 宿主App初始化与安装分身APK的最小调用链在框架中manager 对外提供最少 API。初始化时需要注意init 会重定向 dataDir、注册代理 ActivityinstallPackageExternal 不是调用系统 PackageManager而是把 APK 导入 virtual-core 的虚拟包表下面是完整调用链class HostApp : Application() { override fun onCreate() { super.onCreate() // 初始化容器这一步会重定向 dataDir、注册虚拟 Activity VirtualCore.get().init(this) // 安装一个APK不写入系统包管理而是导入va的虚拟包表 val pkgId VirtualCore.get().installPackageExternal( File(filesDir, target.apk), InstallOptions.DEFAULT ) // 创建分身账号0代表默认分身uid val uid VirtualCore.get().createAccount(pkgId, 0) // 拉起身为主界面这个动作依赖2.1里的queries VirtualCore.get().launchMainActivity(pkgId, uid) } }installPackageExternal 在 Android 12 上只做 dex 解析和签名提取返回的 pkgId 是容器内部包表主键。createAccount 的第二个参数传 0 表示默认分身传大于等于 1 的值可做多用户分身。如果你在用别的 VirtualApp 衍生框架实际方法名可能是 createVirtualModule(packageName, userId)语义基本一致。4.3 适配Android 12的配置清单前台服务、包可见性与媒体权限Android 12 引入了前台服务启动限制、SCHEDULE_EXACT_ALARM 权限和更严格的包可见性过滤。实际集成时我按下面这张表逐项检查功能点Android 12 要求各模块处理方式启动分身 Activity必须通过宿主声明的代理 Activity 拉起不能从后台直接 startActivitylaunchMainActivity 内加一次前台可见性校验前台服务从后台启动前台服务会被拒绝VirtualCore.startForegroundService 必须带 stopWithTask包可见性需要 声明在 host manifest 用 resource 数组生成 PackageQueryList媒体权限READ_EXTERNAL_STORAGE 在 Android 12 仍是整包读取后台读取需要 MediaStore.setIncludePending(true)data目录SELinux 审计全部重定向到 va_shared 沙箱根这里最隐蔽的是 SYSTEM_ALERT_WINDOW。Android 12 对 overlay 后台弹出做了二次授权很多市场商店版分身卡在这一步。自己的源码工程不上架用adb shell appops set com.example.host SYSTEM_ALERT_WINDOW allow可以快速通过但记得在工程里把 StrictMode 对 overlay 的检查屏蔽掉否则 debug 模式下会有不少误报 ANR。5. 用一个adb命令驱动的DevShell快速验证分身与模拟开发把第 3 章的 mock 逻辑集成后最好再做一个专门用于调试的 DevShellActivity。它不显示 UI只负责把命令行参数转换成对 virtual-manager 的调用这样每次验证不需要重新编译整个宿主工程也不用手工点桌面图标。5.1 传入经纬度与UID启动分身体DevShellActivity 在 onNewIntent 里解析四个参数target、uid、mock 经纬度再交给 MockConfigurator 写入账号配置然后调用 launchMainActivity。每次启动前要先清掉该 uid 的日志缓存否则拿到上一次的旧值adb shell am start -n com.va.host/.DevShellActivity \ --es target com.example.object \ --ei uid 1 \ --es lat 31.2304 \ --es lng 121.4737 \ --ez clear_log truetarget 是分身包的原始包名不是 APK 路径uid 必须已经在 createAccount 阶段创建。--ez clear_log 用于幂等调试的时候能明显减少“我改没改生效”的困惑。5.2 用dumpsys location与appops复核权限启动完成后不要只信代码里的日志。先用 dumpsys 看 provider 是否仍活着再检查 appops 权限是否落在宿主的账号上adb shell dumpsys location | grep -E gps_mock|test provider adb shell appops get com.va.host android:mock_location adb shell appops get com.va.host android:system_alert_window如果第一行 test provider 下面只有 providergps_mock说明 mock provider 已经注册。常见错误是地址注入后没有刷新 ElapsedRealtimeNanos导致 App 侧把定位数据判断为过期。Android 12 上的第二类坑是 appops 显示 allow 但 dumpsys 仍拒绝原因是分身体自己的 uid 没有在虚拟客户端表里同步宿主权限。处理方式是在 VirtualClientImpl 里把 AppOpsManager.checkOpNoThrow 的结果也走一遍 mock 路由让分身体看到的权限与宿主保持一致。clear_log 参数传入后DevShell 在 onPause 会把日志重命名为 va_${uid}_last.log当你排查一个从 Android 11 升到 Android 12 后失效的多开案例时对比该日志能判断到底是卡在 PackageManager.getPackageInfo 还是 Context.getDatabasePath。本文还有配套的精品资源点击获取