目标 分析App 站内跳离行为,采用 Instrumentation 在 execStartActivity 里阻塞 & 重分发
一、核心思路
- 拦截跳转:在 execStartActivity 里先不放行。
- 记录跳转时间:把 Intent 和时间戳缓存起来。
- 监听广告 SDK 回调:所有 SDK 点击回调都要统一入口,记录时间戳。
- 5 秒窗口匹配:判断跳转时间前后 5 秒内是否有广告点击回调。
- 放行或丢弃:如果匹配到回调 → 执行原跳转;没有 → 丢弃。
二、设计数据结构
// 记录跳转
class JumpRecord {
Intent intent;
long timestamp;
Runnable proceedRunnable; // 真正执行 startActivity 的回调
}
// 记录点击回调
class ClickRecord {
String adId; // 可选
long timestamp;
}
使用 队列 或 双端队列 保证顺序。
Deque jumpQueue = new ArrayDeque<>();
Deque clickQueue = new ArrayDeque<>();
三、Instrumentation 拦截示例
@Override
public ActivityResult execStartActivity(
Context who, IBinder contextThread, IBinder token,
Activity target, Intent intent, int requestCode, Bundle options) {
if (isJumpOut(who, intent)) {
long now = System.currentTimeMillis();
JumpRecord record = new JumpRecord();
record.intent = intent;
record.timestamp = now;
record.proceedRunnable = () -> {
try {
base.execStartActivity(
who, contextThread, token,
target, intent, requestCode, options);
} catch (Exception e) {
e.printStackTrace();
}
};
synchronized (jumpQueue) {
jumpQueue.add(record);
}
Log.e("JumpGuard", "Jump blocked and cached: " + intent);
// 不立即跳转
return new ActivityResult(Activity.RESULT_CANCELED, null);
}
return base.execStartActivity(who, contextThread, token,
target, intent, requestCode, options);
}
四、SDK 点击回调统一入口示例
假设你能 hook / 包装 SDK:
public void onAdClicked(String adId) {
long now = System.currentTimeMillis();
synchronized (clickQueue) {
clickQueue.add(new ClickRecord(adId, now));
}
checkJumpForClick(now);
}
五、匹配跳转与点击回调(5 秒窗口)
private void checkJumpForClick(long clickTime) {
synchronized (jumpQueue) {
Iterator it = jumpQueue.iterator();
while (it.hasNext()) {
JumpRecord jr = it.next();
if (Math.abs(jr.timestamp - clickTime) <= 5000) {
Log.e("JumpGuard", "Matched jump with click, proceed: " + jr.intent);
// 执行真实跳转
jr.proceedRunnable.run();
it.remove();
}
}
}
}
六、延迟清理机制
防止缓存无限增长:
private void cleanupOldRecords() {
long now = System.currentTimeMillis();
synchronized (jumpQueue) {
jumpQueue.removeIf(jr -> now - jr.timestamp > 5000);
}
synchronized (clickQueue) {
clickQueue.removeIf(cr -> now - cr.timestamp > 5000);
}
}
七、逻辑总结
- 拦截所有标准跳离 Intent
- 缓存跳转信息,不立刻执行
- 监控 SDK 点击回调
- 匹配跳转与回调时间(前后 5 秒)
- 匹配到 → 执行跳转,未匹配 → 自动丢弃 / 清理
八、工程注意点
- Instrumentation 拦截风险
-
- 只在 Debug / 测试环境使用
- Android 9+ ROM 可能绕过
- 时间窗口
-
- 可根据广告实际延迟调整(一般 3~5 秒)
- 线程安全
-
- 队列操作必须加锁或使用 ConcurrentLinkedDeque
- 回滚机制
-
- 防止误拦应用自己内部跳转,可使用白名单包名
九、核心代码逻辑
1.hook hookInstrumentation:
private void hookInstrumentation() {
try {
Class atClass = Class.forName("android.app.ActivityThread");
Method currentAT = atClass.getDeclaredMethod("currentActivityThread");
currentAT.setAccessible(true);
Object at = currentAT.invoke(null);
Field mInstrumentationField = atClass.getDeclaredField("mInstrumentation");
mInstrumentationField.setAccessible(true);
Instrumentation base = (Instrumentation) mInstrumentationField.get(at);
ProxyInstrumentation proxy = new ProxyInstrumentation(base);
mInstrumentationField.set(at, proxy);
Log.e("JumpGuard", "Instrumentation hooked successfully");
} catch (Throwable t) {
Log.e("JumpGuard", "hookInstrumentation failed", t);
}
}
2.ProxyInstrumentation.java(拦截标准 startActivity):
public class ProxyInstrumentation extends Instrumentation {
private final Instrumentation base;
public ProxyInstrumentation(Instrumentation base) {
this.base = base;
}
@Override
public ActivityResult execStartActivity(
Context who,
IBinder contextThread,
IBinder token,
Activity target,
Intent intent,
int requestCode,
Bundle options) {
if (JumpGuard.getInstance().isJumpOut(who, intent)) {
// 不立刻执行,先缓存
JumpGuard.getInstance().recordJump(intent, () -> {
try {
base.execStartActivity(
who, contextThread, token,
target, intent, requestCode, options);
} catch (Exception e) {
e.printStackTrace();
}
});
// 阻止立即跳转
return new ActivityResult(Activity.RESULT_CANCELED, null);
}
return base.execStartActivity(
who, contextThread, token,
target, intent, requestCode, options
);
}
}
3.JumpGuard.java(核心逻辑)
public class JumpGuard {
private static final long WINDOW_MS = 5000;
private final Deque<JumpRecord> jumpQueue = new ArrayDeque<>();
private final Deque<ClickRecord> clickQueue = new ArrayDeque<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private static volatile JumpGuard instance;
public static JumpGuard getInstance() {
if (instance == null) {
synchronized (JumpGuard.class) {
if (instance == null) {
instance = new JumpGuard();
}
}
}
return instance;
}
private JumpGuard() {}
// ================= 跳转记录 =================
public void recordJump(Intent intent, Runnable proceedRunnable) {
long now = System.currentTimeMillis();
JumpRecord jr = new JumpRecord(intent, now, proceedRunnable);
synchronized (this) {
// ① 先尝试匹配已有 click
ClickRecord matchedClick = findMatchedClickLocked(now);
if (matchedClick != null) {
Log.e("JumpGuard", "Jump matched immediately with prior click");
consumeClickLocked(matchedClick);
dispatchProceed(jr);
return;
}
// ② 否则缓存 jump
jumpQueue.addLast(jr);
}
cleanupAsync();
}
// ================= 点击记录 =================
public void recordClick(String adId) {
long now = System.currentTimeMillis();
ClickRecord cr = new ClickRecord(adId, now);
synchronized (this) {
// ① 先尝试匹配已有 jump
JumpRecord matchedJump = findMatchedJumpLocked(now);
if (matchedJump != null) {
Log.e("JumpGuard", "Click matched immediately with prior jump");
consumeJumpLocked(matchedJump);
dispatchProceed(matchedJump);
return;
}
// ② 否则缓存 click
clickQueue.addLast(cr);
}
cleanupAsync();
}
// ================= 匹配逻辑 =================
private JumpRecord findMatchedJumpLocked(long time) {
for (JumpRecord jr : jumpQueue) {
if (Math.abs(jr.timestamp - time) <= WINDOW_MS) {
return jr;
}
}
return null;
}
private ClickRecord findMatchedClickLocked(long time) {
for (ClickRecord cr : clickQueue) {
if (Math.abs(cr.timestamp - time) <= WINDOW_MS) {
return cr;
}
}
return null;
}
private void consumeJumpLocked(JumpRecord jr) {
jumpQueue.remove(jr);
}
private void consumeClickLocked(ClickRecord cr) {
clickQueue.remove(cr);
}
// ================= 真正执行跳转 =================
private void dispatchProceed(JumpRecord jr) {
mainHandler.post(() -> {
try {
jr.proceedRunnable.run();
} catch (Throwable t) {
Log.e("JumpGuard", "Proceed jump failed", t);
}
});
}
// ================= 清理 =================
private void cleanupAsync() {
mainHandler.postDelayed(this::cleanupLocked, WINDOW_MS);
}
private void cleanupLocked() {
long now = System.currentTimeMillis();
synchronized (this) {
jumpQueue.removeIf(jr -> now - jr.timestamp > WINDOW_MS);
clickQueue.removeIf(cr -> now - cr.timestamp > WINDOW_MS);
}
}
// ================= 判定是否跳出 =================
public boolean isJumpOut(Context context, Intent intent) {
ComponentName cmp = intent.getComponent();
if (cmp == null) return false;
return !context.getPackageName().equals(cmp.getPackageName());
}
// ================= 内部数据结构 =================
private static class JumpRecord {
final Intent intent;
final long timestamp;
final Runnable proceedRunnable;
JumpRecord(Intent intent, long timestamp, Runnable proceedRunnable) {
this.intent = intent;
this.timestamp = timestamp;
this.proceedRunnable = proceedRunnable;
}
}
private static class ClickRecord {
final String adId;
final long timestamp;
ClickRecord(String adId, long timestamp) {
this.adId = adId;
this.timestamp = timestamp;
}
}
}