【日常随笔】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;  
        }  
    }  
}
相关推荐
爱勇宝11 小时前
我做了一个只用来搜歌词的小 App
android·前端·后端
众少成多积小致巨15 小时前
JNI (Java Native Interface) 技术手册中文参考指南
android·java·c++
Cerrda18 小时前
开发体验升级:UnoCSS 自定义 SVG 图标热更新方案
架构·前端框架
Coffeeee21 小时前
如何使用Glide和Coil加载WebP动图
android·kotlin·glide
Kstheme21 小时前
把任何 GitHub 仓库变成系统设计课:这个开源项目做到了
架构
Kapaseker1 天前
5 分钟搞懂 Kotlin DSL
android·kotlin
禅思院1 天前
路由性能高可用架构实战方案
前端·架构·前端框架
恋猫de小郭1 天前
AI Agent 开发究竟是啥?如何用 AI 开发 Agent ?深入浅出给你一套概念
android·前端·ai编程
黄林晴1 天前
Android 17 正式发布!target 37 一大批旧代码直接不能用了
android
Carson带你学Android1 天前
Android 17 正式发布:AI 终于成了系统能力
android·前端·ai编程