【日常随笔】Android 跳离行为分析 - Instrumentation

146 阅读3分钟

image.png

目标 分析App 站内跳离行为,采用 Instrumentation 在 execStartActivity 里阻塞 & 重分发

一、核心思路

  1. 拦截跳转:在 execStartActivity 里先不放行。
  2. 记录跳转时间:把 Intent 和时间戳缓存起来。
  3. 监听广告 SDK 回调:所有 SDK 点击回调都要统一入口,记录时间戳。
  4. 5 秒窗口匹配:判断跳转时间前后 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);  
    }  
}

七、逻辑总结

  1. 拦截所有标准跳离 Intent
  2. 缓存跳转信息,不立刻执行
  3. 监控 SDK 点击回调
  4. 匹配跳转与回调时间(前后 5 秒)
  5. 匹配到 → 执行跳转,未匹配 → 自动丢弃 / 清理

八、工程注意点

  1. Instrumentation 拦截风险
    • 只在 Debug / 测试环境使用
    • Android 9+ ROM 可能绕过
  2. 时间窗口
    • 可根据广告实际延迟调整(一般 3~5 秒)
  3. 线程安全
    • 队列操作必须加锁或使用 ConcurrentLinkedDeque
  4. 回滚机制
    • 防止误拦应用自己内部跳转,可使用白名单包名

九、核心代码逻辑

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;
        }
    }
}