基于 Instrumentation Hook 与 Activity 生命周期监控的广告防护方案
背景与痛点
在 Android 应用开发中,广告 SDK 的"自动跳转"问题一直是用户体验的顽疾。用户明明没有点击广告,却莫名其妙地被跳转到第三方应用商店或外部浏览器。这种行为不仅损害用户体验,还可能导致应用被应用商店下架。
常见的广告自动跳转场景包括:
- 摇一摇广告:利用加速度传感器触发跳转
- 误触广告:悬浮按钮或全屏广告的误触
- 静默跳转:广告 SDK 在后台主动发起跳转
本文将介绍一种基于 Instrumentation Hook 和 用户行为追踪 的拦截方案,能够在系统层面拦截可疑的自动跳转行为。
核心设计思路
我们的拦截器遵循五条严格的判定规则:
拦截条件 = 拦截器启用
∧ 严格模式开启
∧ 处于广告场景
∧ 外部跳转意图
∧ 近期无用户交互
只有当这五个条件同时满足时,才会阻断跳转。这种设计既能有效拦截自动跳转,又能保证正常用户操作不受影响。
架构解析
1. 整体架构图
┌─────────────────────────────────────────────────────────┐
│ Application │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ Activity 生命周期 │ │ Window Touch 事件监控 │ │
│ │ 监控 (前台) │ │ (记录用户交互时间) │ │
│ └────────┬────────┘ └───────────┬──────────────┘ │
│ │ │ │
│ └──────────┬──────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ 判断是否广告场景 │ │
│ │ (类名关键词匹配) │ │
│ └──────────┬──────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ ProxyInstrumentation (Hook 层) │ │
│ │ - 拦截 execStartActivity 调用 │ │
│ │ - 判定是否为外部跳转 │ │
│ │ - 检查近期是否有用户交互 │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
2. 关键技术点
2.1 Instrumentation Hook 原理
Android 中所有 Activity 的启动最终都会经过 Instrumentation.execStartActivity() 方法。通过反射替换 ActivityThread 中的 mInstrumentation 字段,我们可以拦截所有 Activity 启动请求。
// 获取 ActivityThread 实例
val activityThreadClz = Class.forName("android.app.ActivityThread")
val currentThread = activityThreadClz
.getDeclaredMethod("currentActivityThread")
.invoke(null)
// 替换 Instrumentation
val instField = activityThreadClz.getDeclaredField("mInstrumentation")
instField.isAccessible = true
val base = instField.get(currentThread) as Instrumentation
instField.set(currentThread, ProxyInstrumentation(base))
2.2 用户交互追踪
广告 SDK 的自动跳转通常发生在没有用户交互的情况下。我们通过以下方式追踪用户行为:
- Window.Callback 代理:拦截触摸事件和按键事件
- 时间窗口判定:记录最后一次交互时间,超过阈值则认为无交互
// 记录最后一次触摸或按键时间
val lastTouchAt = AtomicLong(0L)
// 在 TouchAwareWindowCallback 中更新
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN ||
event.actionMasked == MotionEvent.ACTION_UP) {
onTouchOrKey.invoke() // 更新时间戳
}
return delegate.dispatchTouchEvent(event)
}
2.3 广告场景识别
为了避免误判正常页面跳转,我们仅对疑似广告页面进行拦截。识别方式是通过类名关键词匹配:
//广告关键字
private val adKeywords = listOf(
"ads", "reward", "interstitial", "splash",
"gromore", "pangle", "csj", "anythink",
"taku", "wind", "sigmob", "mbridge",
"unityads", "mobads", "gdt", "ksad", "tramini"
)
// 检查类名是否包含广告关键词
private fun isAdClassName(name: String?): Boolean {
if (name.isNullOrBlank()) return false
val lower = name.lowercase()
return adKeywords.any { lower.contains(it) }
}
为什么不用 "ad" 关键词? 避免与常见单词如 "add"、"address" 等产生误判。
2.4 外部跳转判定
我们需要区分应用内部导航和外部跳转:
private fun isExternalJump(context: Context?, intent: Intent): Boolean {
val currentPkg = context?.packageName ?: appPackageName
// 显式 Intent:比较包名
intent.component?.packageName?.let { explicitPkg ->
return explicitPkg != currentPkg
}
// 隐式 Intent:解析目标 Activity 包名
val pm = context?.packageManager ?: return false
val resolved = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
val resolvedPkg = resolved?.activityInfo?.packageName ?: return false
return resolvedPkg != currentPkg
}
完整代码实现
package com.ilatte.app.device.utils
import android.app.Activity
import android.app.Application
import android.app.Instrumentation
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.IBinder
import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.Window
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
/**
* 广告自动跳转拦截器
*
* 基于 Instrumentation Hook 实现,在系统层面拦截可疑的广告自动跳转行为。
*
* @author Your Name
* @date 2026
*/
object AdJumpInterceptor {
private const val TAG = "AdJumpInterceptor"
/** 拦截器总开关 */
@Volatile
var isInterceptorEnabled = false
/**
* 严格模式开关
* - true: 拦截广告场景下的自动跳转(无近期触摸/按键)
* - false: 不进行拦截
*/
@Volatile
var strictMode = false
/**
* 用户交互有效时间窗口(毫秒)
* 在此时间内的触摸/按键会被视为有效交互,不会触发拦截
*/
@Volatile
var touchValidDurationMs: Long = 1200L
private var appPackageName: String = ""
// 状态标记,防止重复初始化
private val hasHookedInstrumentation = AtomicBoolean(false)
private val hasRegisteredLifecycle = AtomicBoolean(false)
// 用户交互时间追踪
private val lastTouchAt = AtomicLong(0L)
// 当前前台 Activity 名称
private val resumedActivityName = AtomicReference<String?>(null)
/**
* 广告相关关键词列表
* 用于识别广告 Activity,避免使用过于通用的 "ad" 以减少误判
*/
private val adKeywords = listOf(
"ads", "reward", "interstitial", "splash",
"gromore", "pangle", "csj", "anythink", "taku", "wind", "sigmob",
"mbridge", "unityads", "mobads", "gdt", "ksad", "tramini"
)
/**
* 初始化拦截器
*
* @param app Application 实例
*/
fun init(app: Application) {
if (!isInterceptorEnabled) {
Log.w(TAG, "Interceptor is disabled, skip initialization")
return
}
appPackageName = app.packageName
registerLifecycleCallbacks(app)
hookInstrumentation()
Log.i(TAG, "Initialized successfully. strictMode=$strictMode, " +
"touchValidDurationMs=$touchValidDurationMs")
}
/**
* 注册 Activity 生命周期回调
* 用于追踪前台 Activity 并安装触摸监控
*/
private fun registerLifecycleCallbacks(app: Application) {
if (!hasRegisteredLifecycle.compareAndSet(false, true)) {
Log.d(TAG, "Lifecycle callbacks already registered")
return
}
app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
installTouchAwareWindowCallback(activity)
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) {
resumedActivityName.set(activity::class.java.name)
installTouchAwareWindowCallback(activity)
}
override fun onActivityPaused(activity: Activity) {
// 清理前台 Activity 引用
if (resumedActivityName.get() == activity::class.java.name) {
resumedActivityName.set(null)
}
}
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
})
Log.d(TAG, "Activity lifecycle callbacks registered")
}
/**
* 为 Activity 安装触摸感知 WindowCallback
* 用于记录用户触摸和按键事件的时间
*/
private fun installTouchAwareWindowCallback(activity: Activity) {
val window = activity.window ?: run {
Log.w(TAG, "Window is null for ${activity::class.java.name}")
return
}
val origin = window.callback ?: return
// 避免重复包装
if (origin is TouchAwareWindowCallback) {
Log.d(TAG, "TouchAwareWindowCallback already installed for ${activity::class.java.name}")
return
}
window.callback = TouchAwareWindowCallback(origin) {
val currentTime = SystemClock.elapsedRealtime()
lastTouchAt.set(currentTime)
Log.v(TAG, "User interaction detected at $currentTime")
}
Log.d(TAG, "TouchAwareWindowCallback installed for ${activity::class.java.name}")
}
/**
* Hook 系统 Instrumentation
* 替换 ActivityThread 中的 mInstrumentation 字段
*/
private fun hookInstrumentation() {
if (!hasHookedInstrumentation.compareAndSet(false, true)) {
Log.d(TAG, "Instrumentation already hooked")
return
}
try {
// 获取 ActivityThread 类
val activityThreadClz = Class.forName("android.app.ActivityThread")
// 获取当前 ActivityThread 实例
val currentThread = runCatching {
// Android 10+ 使用 sCurrentActivityThread 字段
val field = activityThreadClz.getDeclaredField("sCurrentActivityThread")
field.isAccessible = true
field.get(null)
}.getOrElse {
// 旧版本使用 currentActivityThread() 方法
val method = activityThreadClz.getDeclaredMethod("currentActivityThread")
method.isAccessible = true
method.invoke(null)
} ?: throw IllegalStateException("Unable to get ActivityThread instance")
// 获取并替换 mInstrumentation
val instField = activityThreadClz.getDeclaredField("mInstrumentation")
instField.isAccessible = true
val base = instField.get(currentThread) as Instrumentation
// 避免重复 Hook
if (base is ProxyInstrumentation) {
Log.d(TAG, "ProxyInstrumentation already installed")
return
}
instField.set(currentThread, ProxyInstrumentation(base))
Log.i(TAG, "Instrumentation hooked successfully")
} catch (e: Exception) {
hasHookedInstrumentation.set(false)
Log.e(TAG, "Failed to hook Instrumentation", e)
}
}
/**
* Instrumentation 代理类
* 拦截 execStartActivity 方法进行跳转判定
*/
private class ProxyInstrumentation(private val base: Instrumentation) : Instrumentation() {
/**
* 拦截带有 Activity 参数的 execStartActivity
*/
@Suppress("unused")
fun execStartActivity(
who: Context?,
contextThread: IBinder?,
token: IBinder?,
target: Activity?,
intent: Intent?,
requestCode: Int,
options: Bundle?
): ActivityResult? {
if (intent != null && shouldBlock(who, target, intent)) {
Log.w(TAG, "🚫 Blocked auto jump: " +
"uri=${intent.data}, " +
"component=${intent.component}, " +
"source=${target?.javaClass?.name}")
return null
}
return invokeActivitySignature(
who = who,
contextThread = contextThread,
token = token,
target = target,
intent = intent,
requestCode = requestCode,
options = options
)
}
/**
* 拦截带有 String 参数的 execStartActivity
*/
@Suppress("unused")
fun execStartActivity(
who: Context?,
contextThread: IBinder?,
token: IBinder?,
target: String?,
intent: Intent?,
requestCode: Int,
options: Bundle?
): ActivityResult? {
if (intent != null && shouldBlock(who, null, intent)) {
Log.w(TAG, "🚫 Blocked auto jump: " +
"uri=${intent.data}, " +
"component=${intent.component}, " +
"source=$target")
return null
}
return invokeStringSignature(
who = who,
contextThread = contextThread,
token = token,
target = target,
intent = intent,
requestCode = requestCode,
options = options
)
}
/**
* 判定是否应该拦截此次跳转
*
* @return true 表示拦截,false 表示放行
*/
private fun shouldBlock(context: Context?, sourceActivity: Activity?, intent: Intent): Boolean {
// 1. 检查拦截器是否启用
if (!isInterceptorEnabled) return false
// 2. 检查严格模式
if (!strictMode) return false
// 3. 检查是否为广告场景
if (!isAdScene(sourceActivity)) return false
// 4. 检查是否为外部跳转
if (!isExternalJump(context, intent)) return false
// 5. 检查近期是否有用户交互
val timeSinceLastTouch = SystemClock.elapsedRealtime() - lastTouchAt.get()
val touchedRecently = timeSinceLastTouch <= touchValidDurationMs
if (touchedRecently) {
Log.d(TAG, "✅ User interaction detected ${timeSinceLastTouch}ms ago, allowing jump")
return false
}
// 所有拦截条件满足,阻断跳转
Log.w(TAG, "⚠️ No recent user interaction (${timeSinceLastTouch}ms ago), blocking jump")
return true
}
/**
* 判定当前是否为广告场景
* 检查源 Activity 或前台 Activity 的类名是否包含广告关键词
*/
private fun isAdScene(sourceActivity: Activity?): Boolean {
val sourceName = sourceActivity?.javaClass?.name
val resumedName = resumedActivityName.get()
val isSourceAd = isAdClassName(sourceName)
val isResumedAd = isAdClassName(resumedName)
if (isSourceAd || isResumedAd) {
Log.d(TAG, "Ad scene detected: source=$sourceName, resumed=$resumedName")
}
return isSourceAd || isResumedAd
}
/**
* 检查类名是否包含广告关键词
*/
private fun isAdClassName(name: String?): Boolean {
if (name.isNullOrBlank()) return false
val lower = name.lowercase()
return adKeywords.any { keyword ->
lower.contains(keyword).also { matches ->
if (matches) Log.v(TAG, "Ad keyword '$keyword' matched in '$name'")
}
}
}
/**
* 判定是否为外部跳转(跳转到其他应用)
*/
private fun isExternalJump(context: Context?, intent: Intent): Boolean {
val currentPkg = context?.packageName ?: appPackageName
// 处理显式 Intent
intent.component?.packageName?.let { explicitPkg ->
val isExternal = explicitPkg != currentPkg
Log.v(TAG, "Explicit intent: target=$explicitPkg, current=$currentPkg, external=$isExternal")
return isExternal
}
// 处理隐式 Intent,需要解析目标
val pm = context?.packageManager ?: run {
Log.w(TAG, "PackageManager is null, assuming external jump")
return false
}
val resolved = runCatching {
pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
}.getOrNull()
if (resolved == null) {
Log.w(TAG, "Failed to resolve activity for intent: $intent")
return false
}
val resolvedPkg = resolved.activityInfo?.packageName ?: run {
Log.w(TAG, "Resolved activity has no package name")
return false
}
val isExternal = resolvedPkg != currentPkg
Log.v(TAG, "Implicit intent: resolved=$resolvedPkg, current=$currentPkg, external=$isExternal")
return isExternal
}
/**
* 调用原始的 Activity 签名 execStartActivity
*/
private fun invokeActivitySignature(
who: Context?,
contextThread: IBinder?,
token: IBinder?,
target: Activity?,
intent: Intent?,
requestCode: Int,
options: Bundle?
): ActivityResult? {
return try {
val method = Instrumentation::class.java.getDeclaredMethod(
"execStartActivity",
Context::class.java,
IBinder::class.java,
IBinder::class.java,
Activity::class.java,
Intent::class.java,
Int::class.javaPrimitiveType,
Bundle::class.java
)
method.isAccessible = true
method.invoke(base, who, contextThread, token, target, intent, requestCode, options)
as? ActivityResult
} catch (e: Exception) {
Log.e(TAG, "Failed to invoke original execStartActivity (Activity signature)", e)
null
}
}
/**
* 调用原始的 String 签名 execStartActivity
*/
private fun invokeStringSignature(
who: Context?,
contextThread: IBinder?,
token: IBinder?,
target: String?,
intent: Intent?,
requestCode: Int,
options: Bundle?
): ActivityResult? {
return try {
val method = Instrumentation::class.java.getDeclaredMethod(
"execStartActivity",
Context::class.java,
IBinder::class.java,
IBinder::class.java,
String::class.java,
Intent::class.java,
Int::class.javaPrimitiveType,
Bundle::class.java
)
method.isAccessible = true
method.invoke(base, who, contextThread, token, target, intent, requestCode, options)
as? ActivityResult
} catch (e: Exception) {
Log.e(TAG, "Failed to invoke original execStartActivity (String signature)", e)
null
}
}
}
/**
* 触摸感知 WindowCallback
* 代理原始 Callback 并记录用户交互时间
*/
private class TouchAwareWindowCallback(
private val delegate: Window.Callback,
private val onTouchOrKey: () -> Unit
) : Window.Callback by delegate {
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
// 在 DOWN 和 UP 事件时记录交互
if (event.actionMasked == MotionEvent.ACTION_DOWN ||
event.actionMasked == MotionEvent.ACTION_UP) {
onTouchOrKey.invoke()
}
return delegate.dispatchTouchEvent(event)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
// 在按键按下时记录交互
if (event.action == KeyEvent.ACTION_DOWN) {
onTouchOrKey.invoke()
}
return delegate.dispatchKeyEvent(event)
}
override fun toString(): String {
return "TouchAwareWindowCallback(delegate=$delegate)"
}
}
}
使用方式
1. 在 Application 中初始化
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// 配置并初始化广告跳转拦截器
AdJumpInterceptor.apply {
isInterceptorEnabled = true // 启用拦截
strictMode = true // 开启严格模式
touchValidDurationMs = 1200L // 触摸有效时间 1.2 秒
}.init(this)
}
}
2. 动态控制
// 临时关闭拦截(如用户主动点击广告时)
AdJumpInterceptor.strictMode = false
// 或记录一次虚拟触摸,使后续跳转被允许
AdJumpInterceptor.lastTouchAt.set(SystemClock.elapsedRealtime())
效果验证
启动应用后,观察 Logcat 输出:
// 正常拦截
🚫 Blocked auto jump: uri=market://details?id=com.example.app,
component=ComponentInfo{com.android.vending/...},
source=com.bytedance.sdk.openadsdk.core.activity.TTDelegateActivity
// 用户主动点击后放行
✅ User interaction detected 150ms ago, allowing jump
注意事项与局限性
表格
| 注意事项 | 说明 |
|---|---|
| Android 版本兼容 | 在不同 Android 版本上,ActivityThread 的获取方式可能不同,代码中已做兼容处理 |
| 混淆配置 | 如果开启代码混淆,需要保留相关类名:com.ilatte.app.device.utils.AdJumpInterceptor |
| 性能影响 | 每次 Activity 启动都会进行判定,但开销极小,可忽略不计 |
| 误判风险 | 如果广告 Activity 类名不包含关键词,可能漏判;可通过配置文件动态扩展关键词 |
进阶优化建议
- 云端配置关键词:将
adKeywords改为从服务器获取,及时应对新广告 SDK - 统计上报:记录拦截次数和广告来源,用于分析广告 SDK 行为
- 白名单机制:为特定广告位添加白名单,允许其跳转
- 用户提示:拦截后显示 Toast 提示用户"已阻止自动跳转"
总结
本文介绍的广告跳转拦截器通过 Instrumentation Hook 在系统层面拦截 Activity 启动,结合 用户行为追踪 和 场景识别,实现了精准的广告自动跳转防护。方案具有以下优势:
- ✅ 非侵入式:无需修改广告 SDK 代码
- ✅ 精准拦截:五重判定规则,减少误判
- ✅ 用户体验:不影响正常点击跳转
- ✅ 易于集成:单文件实现,即插即用
希望这篇文章能帮助你解决广告自动跳转的困扰。如果有任何问题或优化建议,欢迎在评论区交流!
参考链接: