用 LSPosed 模拟录屏音频编码器卡顿:从思路到落地
标签:Android / LSPosed / MediaCodec / 音画同步 / 缺陷复现
适用场景:录屏音画不同步、音频断续等缺陷,需要可控地「造」出编码器慢一拍的现象
预备
需要安装kernelSU 和LSPosed 安装kernelSU 和LSPosed
一、为什么要做这个
录屏链路里,视频和音频往往分开编码,最后再封装进同一个文件。
如果音频编码器偶尔变慢,就会出现:
- 听感上断续、拖沓
- 口型与画面不同步
- 严重时录屏进程无响应
真实机器上这类问题偶发、难复现。最理想的做法是:在不改录屏业务代码的前提下,用 Hook 在音频编码收发缓冲时按概率注入随机延迟,把「编码器卡顿」变成可配置的实验条件。
本文介绍一套基于 LSPosed 模块 + 配置 App 的实现:MockEncoder(音频编码卡顿模拟)。
界面示意:

可配置项包括:
| 项 | 含义 | 示例 |
|---|---|---|
| 开启 / 关闭 | 是否注入延迟 | 已开启 |
| 触发概率 | 每次收发缓冲命中概率 | 40% |
| 最小 / 最大延迟 | 命中后 sleep 的随机区间 | 69--317 ms |
二、整体架构
bash
┌─────────────────┐ Settings.Global ┌──────────────────────┐
│ 配置 App │ ───────────────────────► │ 录屏进程 │
│ 调参 / 开关 │ │ LSPosed 注入模块 │
└─────────────────┘ │ Hook MediaCodec │
│ 概率 sleep │
└──────────────────────┘
分工很清晰:
- 配置 App:负责 UI 与参数持久化
- LSPosed 模块 :在录屏进程内 Hook
MediaCodec - 跨进程传参 :优先写
Settings.Global(任意进程可读),文件 / ContentProvider / XSharedPreferences 作兜底
作用域只勾选录屏应用(如 com.oplus.screenrecorder),避免误伤其他 App。
三、环境前提
- 已 Root,Magisk + Zygisk
- 已安装 LSPosed(Zygisk 版)
- 模块在 LSPosed 中启用,作用域勾选录屏应用
- 写入系统设置需一次性授权(见下文)
bash
adb shell pm grant com.mockencoder.audio android.permission.WRITE_SECURE_SETTINGS
为什么要这个权限?
不是 Hook 需要它,而是「配置 App 把参数写给录屏进程」需要它。在较新的 ColorOS 上:
- App 私有目录受 SELinux 限制,录屏进程读不到
- ContentProvider 受应用可见性限制,可能报
Unknown authority - 传统
XSharedPreferences路径也可能被挪走
Settings.Global:写入 需权限,读取不需要。录屏侧 Hook 直接读同名键即可。
四、核心实现一:模块入口
目标进程加载时,框架回调入口,再安装 Hook。模块自身进程不注入。
kotlin
/**
* LSPosed 模块入口。
* 目标 App(如录屏)进程启动时,框架会回调这里,随后在该进程内安装 Hook。
*/
class HookEntry : IXposedHookLoadPackage {
override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
// 不 Hook 配置 App 自身,避免无意义注入
if (lpparam.packageName == MODULE_PACKAGE) {
return
}
runCatching { AudioCodecHook.install(lpparam) }
.onFailure { /* 打日志 */ }
}
}
assets/xposed_init 指向该类全名;Manifest 里声明 xposedmodule 等 meta-data。
五、核心实现二:只卡音频,不卡视频
录屏会同时创建视频与音频编码器。必须在 configure 之后用 MIME 区分:
audio/mp4a-latm→ 加入跟踪集合,后续注入延迟video/avc→ 不注入
kotlin
/**
* configure 之后根据 MediaFormat.KEY_MIME 判断是否为音频编码器。
* audio/mp4a-latm 等加入跟踪集合;video/avc 等从集合移除。
*/
override fun afterHookedMethod(param: MethodHookParam) {
val format = param.args.firstOrNull { it is MediaFormat } as? MediaFormat ?: return
val mime = format.getString(MediaFormat.KEY_MIME)
val isAudio = mime != null && mime.startsWith("audio/")
if (isAudio) {
audioCodecs.add(param.thisObject) // 按对象身份跟踪
} else {
audioCodecs.remove(param.thisObject)
}
}
用 IdentityHashMap 跟踪 MediaCodec 实例,避免错误地把视频编码也卡住。
在 queueInputBuffer / dequeueOutputBuffer 前,仅对集合内实例调用卡顿逻辑:
kotlin
/** 送入编码输入缓冲前注入延迟,模拟「编码器处理慢」。 */
override fun beforeHookedMethod(param: MethodHookParam) {
if (audioCodecs.contains(param.thisObject)) {
LagSimulator.maybeStall("queueInput")
}
}
成功日志示例:
ini
hooks installed configure=3 queue=1 dequeue=1 release=1
codec configured mime=audio/mp4a-latm audio=true
codec configured mime=video/avc audio=false
六、核心实现三:按概率随机延迟
kotlin
/**
* 核心卡顿逻辑:
* 1. 未开启 / 概率为 0 / 最大延迟为 0 → 直接返回
* 2. 生成 [0,100) 随机数,小于设定概率则命中
* 3. 在 [minDelay, maxDelay] 内随机 sleep
*/
fun maybeStall(tag: String) {
val config = currentConfig()
if (!config.enabled || config.probabilityPercent <= 0 || config.maxDelayMs <= 0) {
return
}
val hit = ThreadLocalRandom.current().nextInt(100) < config.probabilityPercent
if (!hit) return
val min = config.minDelayMs
val max = max(config.maxDelayMs, min)
val delay = if (max == min) min else ThreadLocalRandom.current().nextInt(min, max + 1)
if (delay > 0) {
Thread.sleep(delay.toLong())
// 日志:stall injected at queueInput delay=123ms
}
}
注意:Thread.sleep 堵的是编码线程 。概率过高、延迟过大时,录屏可能像卡死------建议先用 20% + 100--300ms 验证,再加压。
配置约每 1.5 秒刷新一次,避免每次编码都读设置。
七、核心实现四:配置如何传到录屏进程
保存时三路冗余:
kotlin
/** 本地保存 + 导出文件 + 写入 Settings.Global,三路冗余提高跨进程可读性。 */
fun save(context: Context, config: MockConfig): Boolean {
// 1. SharedPreferences:App 自己读
prefs(context).edit()....commit()
// 2. 导出文件:部分机型可能被 SELinux 挡住
exportToFile(context, c, now)
// 3. Settings.Global:跨进程最稳(需 WRITE_SECURE_SETTINGS)
writeSettings(context, c, now)
}
录屏侧读取优先级:Settings.Global → 文件 → ContentProvider → XSharedPreferences。
界面底部状态区应显示类似:
matlab
磁盘开关 已开启
概率 40%
延迟 69-317ms
写入时间 19:43:45
共享文件已就绪
系统设置(global) 已就绪
看到「系统设置(global) 已就绪」,说明录屏进程有机会读到真实参数。
八、踩坑记录(强烈建议看)
1. 编译期 Xposed API 占位声明返回类型写错
内网若拉不到官方 api:82,常会自写 stub。若把:
hookAllMethods写成void- 而运行时实际是
Set<Unhook>
Android 按「参数 + 返回类型」匹配方法,会直接 NoSuchMethodError,表现为挂接全部失败(configure=0 queue=0)。
stub 的方法签名必须和运行时 LSPosed 一致。
2. 以为保存成功,其实开关一直是关的
早期 UI 开关开/关外观几乎一样,磁盘上 enabled=false,模块永远不注入。
后来改成「开启模拟 / 关闭模拟」大按钮,并在底部回显磁盘真实状态。
4. Hook 成功 ≠ 生效
可能看到:
hooks installed ...codec configured mime=audio/...
但若日志是:
ini
config[prefs] enabled=false probability=10%
说明配置没传过去,延迟逻辑被短路。目标应是:
ini
config[settings] enabled=true probability=40% delay=69-317ms
stall injected at queueInput delay=xxxms
九、使用步骤(精简)
- 安装配置 App(即 LSPosed 模块 APK)
- LSPosed 启用模块,勾选录屏包名,强制停止录屏
adb shell pm grant ... WRITE_SECURE_SETTINGS- 打开 App → 点「开启模拟」→ 调好概率/延迟 → 确认底部「系统设置(global) 已就绪」
- 重新开始录屏,观察音画;用日志确认
stall injected
查看模块日志(LSPosed 模块日志页,或过滤):
bash
adb logcat -s MockEncoder
# 或查看 /data/adb/lspd/log/modules_*.log
十、小结
| 目标 | 做法 |
|---|---|
| 不改录屏源码造故障 | LSPosed Hook MediaCodec |
| 只影响音频 | configure 后按 MIME 过滤 |
| 可调强度 | 概率 + 随机延迟区间 |
| 跨进程配置 | 优先 Settings.Global |
| 可观测 | 关键路径打日志,UI 回显磁盘状态 |
这套方案适合做音画同步 / 音频断续类问题的可控复现。参数要循序渐进:先确认链路通,再加大概率和延迟,否则很容易把录屏直接「卡死」当成模块没生效。
附录:相关声明
- 仅建议在自有测试设备、合规 Root 环境下使用
- 勿用于非授权应用或破坏他人设备稳定性
- 文中包名、截图以 ColorOS 录屏为例,其他 ROM 请自行改作用域
(完)