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

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

一、核心思路

  1. 拦截跳转:在 execStartActivity 里先不放行。
  2. 记录跳转时间:把 Intent 和时间戳缓存起来。
  3. 监听广告 SDK 回调:所有 SDK 点击回调都要统一入口,记录时间戳。
  4. 5 秒窗口匹配:判断跳转时间前后 5 秒内是否有广告点击回调。
  5. 放行或丢弃:如果匹配到回调 → 执行原跳转;没有 → 丢弃。

二、设计数据结构

arduino 复制代码
// 记录跳转  
class JumpRecord {  
    Intent intent;  
    long timestamp;  
    Runnable proceedRunnable; // 真正执行 startActivity 的回调  
}  
   
// 记录点击回调  
class ClickRecord {  
    String adId; // 可选  
    long timestamp;  
}

使用 队列 或 双端队列 保证顺序。

ini 复制代码
Deque jumpQueue = new ArrayDeque<>();  
Deque clickQueue = new ArrayDeque<>();

三、Instrumentation 拦截示例

csharp 复制代码
@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:

scss 复制代码
public void onAdClicked(String adId) {  
    long now = System.currentTimeMillis();  
   
    synchronized (clickQueue) {  
        clickQueue.add(new ClickRecord(adId, now));  
    }  
   
    checkJumpForClick(now);  
}

五、匹配跳转与点击回调(5 秒窗口)

java 复制代码
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();  
            }  
        }  
    }  
}

六、延迟清理机制

防止缓存无限增长:

scss 复制代码
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:

ini 复制代码
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):

csharp 复制代码
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(核心逻辑)

java 复制代码
public class JumpGuard {  
   
    private static final long WINDOW_MS = 5000; // 前后5秒  
   
    private final Deque jumpQueue = new ArrayDeque<>();  
    private final Deque clickQueue = new ArrayDeque<>();  
   
    private static JumpGuard instance;  
   
    public static JumpGuard getInstance() {  
        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 (jumpQueue) {  
            jumpQueue.add(jr);  
        }  
   
        Log.e("JumpGuard", "Jump cached: " + intent);  
   
        cleanupOldRecords();  
    }  
   
    // ----------------- 点击回调 -----------------  
    public void recordClick(String adId) {  
        long now = System.currentTimeMillis();  
        ClickRecord cr = new ClickRecord(adId, now);  
   
        synchronized (clickQueue) {  
            clickQueue.add(cr);  
        }  
   
        Log.e("JumpGuard", "Click recorded: " + adId);  
   
        matchJumpWithClick(now);  
        cleanupOldRecords();  
    }  
   
    private void matchJumpWithClick(long clickTime) {  
        synchronized (jumpQueue) {  
            Iterator it = jumpQueue.iterator();  
            while (it.hasNext()) {  
                JumpRecord jr = it.next();  
                if (Math.abs(jr.timestamp - clickTime) <= WINDOW_MS) {  
                    Log.e("JumpGuard", "Matched jump with click: " + jr.intent);  
                    jr.proceedRunnable.run();  
                    it.remove();  
                }  
            }  
        }  
    }  
   
    private void cleanupOldRecords() {  
        long now = System.currentTimeMillis();  
        synchronized (jumpQueue) {  
            jumpQueue.removeIf(jr -> now - jr.timestamp > WINDOW_MS);  
        }  
        synchronized (clickQueue) {  
            clickQueue.removeIf(cr -> now - cr.timestamp > WINDOW_MS);  
        }  
    }  
   
    // ----------------- 判定是否跳出 App -----------------  
    public boolean isJumpOut(Context context, Intent intent) {  
        ComponentName cmp = intent.getComponent();  
        if (cmp == null) return false;  
        String targetPkg = cmp.getPackageName();  
        return !context.getPackageName().equals(targetPkg);  
    }  
   
    // ----------------- 内部数据类 -----------------  
    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;  
        }  
    }  
}
相关推荐
aningxiaoxixi2 小时前
android 媒体之 MediaSession
android·媒体
GoldenPlayer2 小时前
Android文件权限报错
android
Coder个人博客2 小时前
LinuxPTP 整体架构框图与源码深度分析
架构
乾元2 小时前
把 SLA / SLO 放到网络可观测的核心:从指标到证据链的工程化路径
运维·开发语言·网络·人工智能·网络协议·架构
Jomurphys2 小时前
Compose 适配 - 全屏显示 EdgeToEdge
android
ii_best2 小时前
「安卓开发辅助工具按键精灵」xml全分辨率插件jsd插件脚本教程
android·xml·开发语言·编辑器·安卓
消失的旧时光-19432 小时前
从 Android 回调到 C 接口:函数指针 + void* self 的一次彻底理解
android·c语言·开发语言
dvlinker2 小时前
动态代理技术实战测评—高效解锁Zillow房价历史
android·java·数据库
峥嵘life2 小时前
Android16 EDLA 认证BTS测试Failed解决总结
android·java·linux·运维·学习