惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Microsoft Azure Blog
Microsoft Azure Blog
有赞技术团队
有赞技术团队
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
F
Fox-IT International blog
Recorded Future
Recorded Future
T
ThreatConnect
T
The Exploit Database - CXSecurity.com
SecWiki News
SecWiki News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
人人都是产品经理
人人都是产品经理
T
Tenable Blog
L
LINUX DO - 最新话题
博客园_首页
Hugging Face - Blog
Hugging Face - Blog
罗磊的独立博客
博客园 - 司徒正美
The Hacker News
The Hacker News
博客园 - 聂微东
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Scott Helme
Scott Helme
博客园 - 【当耐特】
O
OpenAI News
Schneier on Security
Schneier on Security
Latest news
Latest news
S
Security @ Cisco Blogs
S
Secure Thoughts
F
Full Disclosure
L
Lohrmann on Cybersecurity
S
SegmentFault 最新的问题
T
Tor Project blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
量子位
小众软件
小众软件
T
Threat Research - Cisco Blogs
Simon Willison's Weblog
Simon Willison's Weblog
IT之家
IT之家
大猫的无限游戏
大猫的无限游戏
N
News and Events Feed by Topic
E
Exploit-DB.com RSS Feed
J
Java Code Geeks
Last Week in AI
Last Week in AI
酷 壳 – CoolShell
酷 壳 – CoolShell
Application and Cybersecurity Blog
Application and Cybersecurity Blog
S
Schneier on Security
Cisco Talos Blog
Cisco Talos Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
P
Proofpoint News Feed
Recent Commits to openclaw:main
Recent Commits to openclaw:main
雷峰网
雷峰网

掘金

AI应用开发七:可以替代 RAG 的技术 juejin.cn 小书匠:一款本地优先、去中心化的全能笔记软件 juejin.cn juejin.cn juejin.cn Shadow实战接入与生产落地:从零搭建到稳定运行 juejin.cn juejin.cn Hermes Agent:一个真正“会成长”的开源 AI Agent,正在改变 AI 自动化玩法 juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn CryptoJS:数据安全的JavaScript加密利器 juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn ArkClaw AI 盯盘管家 —— 从手动口令到自动推送,4 套预置定时任务模版一键启用 juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn “杀!杀!杀!”、“我最讨厌事后道歉”——骂“杀哥”之前,谁还没当过情绪崩溃的人 juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn Crawlee StagehandCrawler:自然语言点 Load More 的工程化爬虫 juejin.cn juejin.cn juejin.cn juejin.cn 人人都在鼓吹的OPC,我想给你泼盆冷水 juejin.cn juejin.cn juejin.cn Redis内存用爆了,原来我们都忽略了这个配置 juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn Android 专家岗 Kotlin 面试题:能答出这些,说明你对语言设计有自己的理解 juejin.cn juejin.cn 业务系统集成 OpenClaw 多 Agent 方案:从架构到落地的完整指南 juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn 四、Agent 评估与可观测性:LangSmith 与客服 A/B 测试 🍃 MongoDB 从入门到上手:一篇写给新手的科普指南 juejin.cn juejin.cn juejin.cn juejin.cn RAG 系列(十九):增量更新——知识库如何保持新鲜 juejin.cn juejin.cn juejin.cn juejin.cn juejin.cn 当 00 后开始用 token 给学校送礼 juejin.cn SwiftUI 多线程与并发编程深度总结 juejin.cn juejin.cn juejin.cn Combine 架构模式:构建响应式应用的蓝图 Combine 高级实践:多线程调度、调试与测试 SSE(Server-Sent Events)完全指南 juejin.cn
Shadow Transform:编译期的魔法——字节码替换实战
陆业聪 · 2026-05-23 · via 掘金

上一篇聊完壳子Activity代理机制,文末我留了一个问题:插件APK里的代码明明写的是extends Activity,运行时却变成了extends ShadowActivity——这中间到底发生了什么?

答案就是今天的主角:Shadow Transform。它在编译期通过ASM字节码改写,把插件代码中所有对Android系统组件的继承和调用,悄悄替换成Shadow的代理类。整个过程对插件开发者完全透明——你照常写class MyActivity : AppCompatActivity(),编译出来的字节码里已经不是那么回事了。

说实话,第一次看懂Transform的实现我是震惊的——不是技术难度有多大,而是这帮人把一个运行时问题硬生生搬到了编译期解决,这个思路本身就值得反复品味。

为什么非得在编译期动手

先搞清楚一个前置问题:为什么不在运行时做替换?

传统方案(DroidPlugin/VirtualAPK)在运行时用反射修改类的行为。但Shadow的设计哲学是零反射,那怎么让插件代码"认不到"真正的系统组件?

有两条路:

路线A:让插件开发者自己改代码,把Activity改成ShadowActivity

路线B:编译期自动替换,开发者无感

路线A对于自研代码还行,但一旦涉及第三方库(比如AppCompatActivity、Fragment),你不可能去改AndroidX的源码吧?路线B才是正解——在编译产物(.class文件)上动手,把所有继承关系和方法调用做一次"全局查找替换"。

这就是Shadow Transform的使命:在.class变成.dex之前,把所有Android系统组件的引用替换为Shadow的代理类

Gradle Transform API:编译流水线的切入点

要理解Shadow Transform的工作时机,先得知道Android的编译流水线在哪里给了我们"动手"的机会。

Android Gradle Plugin(AGP)的编译流程大致是:

.java/.kt 源码

↓ javac / kotlinc

.class 字节码文件

Transform 在此介入

修改后的 .class 文件

↓ D8/R8

.dex 文件 → APK

Gradle Transform API(AGP 1.5引入,AGP 7.0标记废弃,AGP 8.0移除)允许开发者注册一个自定义的Transform,在.class→.dex这一步之前,拿到所有编译产物的字节码进行修改。

注意:Shadow最初基于Transform API实现。AGP 8.0废弃后,新版本迁移到了AsmClassVisitorFactory(Instrumentation API)。核心思路不变,只是注册方式变了。本文先讲原理,最后补充新API的适配方式。

Shadow的Transform注册代码大致长这样:

class ShadowTransformPlugin
    : Plugin<Project> {

    override fun apply(
        project: Project
    ) {
        val android = project.extensions
            .getByType(
                AppExtension::class.java
            )
        android.registerTransform(
            ShadowTransform(project)
        )
    }
}

注册完之后,每次编译到字节码阶段,Gradle就会把所有.class文件(包括第三方jar里的)交给ShadowTransform处理。

ASM:字节码改写的手术刀

拿到.class文件只是第一步,怎么改才是核心。Shadow选择的工具是ASM——Java生态里最老牌、最高性能的字节码操作库。

ASM提供两套API:

Core API(事件驱动/访问者模式):像SAX解析XML一样,逐个"事件"地处理类的各个部分,内存占用极小

Tree API(对象模型):像DOM一样把整个类加载到内存中的树结构,方便做复杂分析,但内存开销大

Shadow用的是Core API,因为它的需求很明确——只做字符串级别的类名替换,不需要复杂的数据流分析。用访问者模式(Visitor Pattern)足矣。

ClassVisitor:类级别的改写

ASM的核心概念是ClassVisitor——你继承它,重写感兴趣的方法,ASM在遍历.class文件时会回调你:

class ShadowClassVisitor(
    cv: ClassVisitor
) : ClassVisitor(
    Opcodes.ASM9, cv
) {

    override fun visit(
        version: Int,
        access: Int,
        name: String,
        signature: String?,
        superName: String?,
        interfaces: Array<String>?
    ) {
        // 核心:替换父类名
        val newSuper =
            mapSuperClass(superName)
        super.visit(
            version, access, name,
            signature, newSuper,
            interfaces
        )
    }
}

visit()方法在解析一个类时首先被调用,参数里的superName就是父类的内部名称(用/分隔,如android/app/Activity)。Shadow在这里做的事情很简单——查表替换:

private val classMapping = mapOf(
    "android/app/Activity"
        to "com/tencent/shadow/core/runtime/ShadowActivity",
    "android/app/Service"
        to "com/tencent/shadow/core/runtime/ShadowService",
    "android/app/Application"
        to "com/tencent/shadow/core/runtime/ShadowApplication",
    "androidx/appcompat/app/AppCompatActivity"
        to "com/tencent/shadow/core/runtime/ShadowActivity",
    // ... 更多映射
)

fun mapSuperClass(
    name: String?
): String? {
    return classMapping[name] ?: name
}

就这么简单。一个HashMap查找,如果命中就替换,不命中就保持原样。但魔鬼在细节——光替换父类名远远不够。

MethodVisitor:方法级别的改写

替换了父类名只是第一步。插件代码里还有大量对父类方法的调用,比如:

// 插件源码
override fun onCreate(
    savedState: Bundle?
) {
    super.onCreate(savedState)
    setContentView(
        R.layout.activity_main
    )
    val ctx = getApplicationContext()
}

这里有三个需要处理的调用:

super.onCreate() → 字节码里是INVOKESPECIAL android/app/Activity.onCreate

setContentView() → 字节码里是INVOKEVIRTUAL android/app/Activity.setContentView

getApplicationContext() → 字节码里是INVOKEVIRTUAL android/content/ContextWrapper.getApplicationContext

这些方法调用指令里都硬编码了类的owner,如果只换了父类名而不换方法调用的owner,运行时就会找不到方法。所以Shadow还需要一个MethodVisitor

override fun visitMethodInsn(
    opcode: Int,
    owner: String,
    name: String,
    descriptor: String,
    isInterface: Boolean
) {
    // 替换方法调用的owner类
    val newOwner =
        mapClassName(owner)
    super.visitMethodInsn(
        opcode, newOwner, name,
        descriptor, isInterface
    )
}

override fun visitTypeInsn(
    opcode: Int,
    type: String
) {
    // NEW/CHECKCAST/INSTANCEOF指令
    val newType =
        mapClassName(type)
    super.visitTypeInsn(
        opcode, newType
    )
}

override fun visitFieldInsn(
    opcode: Int,
    owner: String,
    name: String,
    descriptor: String
) {
    // 字段访问的owner也要替换
    val newOwner =
        mapClassName(owner)
    val newDesc =
        mapDescriptor(descriptor)
    super.visitFieldInsn(
        opcode, newOwner,
        name, newDesc
    )
}

你看到了——Shadow的Transform本质上是对字节码做了一次**"全局字符串替换"**,只不过这个替换发生在结构化的字节码层面,而不是文本层面。每一条涉及类名的指令(方法调用、类型转换、字段访问、异常处理表、注解……),都要过一遍映射表。

四大组件的替换策略

Android有四大组件,Shadow对每一个的处理策略其实不太一样。搞清楚这些差异,才能真正理解Shadow的工程取舍。

Activity:最核心,替换最彻底

Activity是插件化的重中之重。Shadow需要替换的不只是Activity本身,还有它的整个继承链:

原始类替换为
android.app.ActivityShadowActivity
androidx...AppCompatActivityShadowActivity
androidx...FragmentActivityShadowActivity
android.app.FragmentShadowFragment

这里有个细节:AppCompatActivityFragmentActivity都被"拍平"成ShadowActivity。也就是说,Shadow放弃了AppCompat的那些兼容特性(ActionBar、主题兼容等)。这是一个工程取舍——如果你的插件强依赖AppCompat的特性,Shadow开发者需要在ShadowActivity里重新实现对应逻辑。

Service:替换 + 注册转发

Service的处理跟Activity类似——把android.app.Service替换为ShadowService。但Service还有一个额外问题:startService()bindService()这些调用也得拦截,把Intent指向壳子Service。

Shadow Transform在处理Service时,不仅替换继承关系,还会把代码中的Context.startService(intent)调用重定向为ShadowContext.startPluginService(intent),在运行时由Shadow的调度器分发到正确的壳子Service。

BroadcastReceiver:静态注册需特殊处理

动态注册的BroadcastReceiver(registerReceiver())比较简单,运行时直接用宿主的Context注册就行。但静态注册(AndroidManifest里声明的)麻烦——插件的Manifest不会被系统解析。

Shadow的解法是:编译期解析插件Manifest中的<receiver>声明,在运行时由Shadow框架动态注册这些Receiver。Transform主要负责把Receiver的父类替换为ShadowBroadcastReceiver

ContentProvider:最棘手的组件

ContentProvider是四大组件中最难插件化的——它在Application创建之前就被系统初始化,而且通过authority全局唯一标识。Shadow对ContentProvider的处理相对保守:替换父类为ShadowContentProvider,运行时通过代理Provider转发query/insert/update/delete调用。

需要注意的是,很多第三方SDK(比如Firebase、LeakCanary)会在Manifest里声明ContentProvider来做自动初始化。这些Provider的字节码也会被Transform改写,所以接入Shadow时要仔细检查第三方库的行为——有些库可能因为Provider被替换后初始化时序出问题。

自定义Transform规则:第三方库兼容

现实中的项目不可能只有自己的代码。第三方库里也充满了对系统组件的引用,而且往往更复杂。Shadow允许通过配置文件定义额外的映射规则:

// shadow-transform.json
{
  "classMapping": [
    {
      "from":
        "android/app/Dialog",
      "to":
        "com/.../ShadowDialog"
    },
    {
      "from":
        "android/webkit/WebView",
      "to":
        "com/.../ShadowWebView"
    }
  ],
  "excludePackages": [
    "com/google/gson",
    "okhttp3",
    "retrofit2"
  ]
}

这里有两个关键配置:

classMapping:扩展映射表。除了四大组件,你还可以把Dialog、WebView等也纳入代理体系

excludePackages:白名单。有些包不需要改写(纯Java库如Gson、OkHttp),跳过可以加速编译

我在实际接入中踩过一个坑:某个第三方SDK内部用反射获取当前Activity的类名做埋点上报,Transform改写后它拿到的是ShadowActivity而不是原始类名,导致埋点数据全乱了。解决方案是在ShadowActivity中override getClass()方法——不对,getClass()是final的不能override。最后只能在Transform里识别出那个SDK的反射调用,把getClass().getName()替换为Shadow提供的getOriginalClassName()方法。这种case就是为什么Shadow允许自定义规则的原因。

Transform调试技巧与常见踩坑

字节码改写属于"黑箱操作"——出了问题你看源码没用,因为问题在编译产物里。这里分享几个实战中的调试技巧。

技巧1:用javap验证Transform结果

Transform产物在build/intermediates/transforms/ShadowTransform/目录下。找到你关心的.class文件,用javap -c -p反编译看字节码:

# 查看Transform后的字节码
javap -c -p \
  build/intermediates/transforms/\
  ShadowTransform/debug/\
  com/example/MyActivity.class

# 重点关注:
# 1. 类头的 extends 是否变了
# 2. invokespecial/invokevirtual
#    的owner是否替换正确

技巧2:增量编译的陷阱

Gradle的增量编译会跳过"未变化"的文件。但Transform的映射规则变了的话,所有文件都该重新处理。Shadow需要在Transform的isIncremental()返回值和getSecondaryInputs()上做正确处理:

override fun isIncremental():
    Boolean = true

override fun transform(
    invocation: TransformInvocation
) {
    if (!invocation.isIncremental) {
        // 全量模式:清除输出,全部重处理
        invocation.outputProvider
            .deleteAll()
    }
    // 增量模式:只处理CHANGED/ADDED
    invocation.inputs.forEach { input ->
        input.jarInputs.forEach { jar ->
            when (jar.status) {
                Status.ADDED,
                Status.CHANGED ->
                    processJar(jar)
                Status.REMOVED ->
                    deleteOutput(jar)
                else -> { }
            }
        }
    }
}

我遇到过一个诡异bug:修改了映射规则后,增量编译没有重新处理已有的.class文件,导致部分类的父类被替换了、部分没有,运行时直接NoSuchMethodError。教训:改了Transform规则后一定要clean build

技巧3:R文件的特殊处理

Android的R文件(资源ID)在编译期会被内联为常量。但在插件化场景下,插件的资源ID和宿主的资源ID可能冲突。Shadow的Transform对R类有特殊逻辑——不做内联,保持为字段引用,这样运行时可以通过修改R类的字段值来避免冲突。

具体来说,Transform会检测GETSTATIC com/example/R$id.xxx这样的指令,确保R文件的引用不会被错误替换。同时,Shadow的资源打包流程会为插件分配独立的packageId(默认0x7f,插件用0x7e/0x7d等),从根源上避免ID冲突。

AGP 8.0+的迁移:AsmClassVisitorFactory

前面说了,Transform API在AGP 8.0正式移除。新的替代方案是Instrumentation API中的AsmClassVisitorFactory。核心区别是什么?

旧API:你自己管理输入输出流,遍历JAR/目录,调用ASM

新API:AGP帮你管理I/O,你只需要提供一个ClassVisitor工厂

// AGP 8.0+ 写法
abstract class ShadowAsmFactory
    : AsmClassVisitorFactory<
        ShadowParams
    > {

    override fun createClassVisitor(
        classContext: ClassContext,
        nextVisitor: ClassVisitor
    ): ClassVisitor {
        return ShadowClassVisitor(
            nextVisitor
        )
    }

    override fun isInstrumentable(
        classData: ClassData
    ): Boolean {
        // 过滤:只处理需要改写的类
        return !isExcluded(
            classData.className
        )
    }
}

// 注册方式
androidComponents {
    onVariants { variant ->
        variant.instrumentation
            .transformClassesWith(
                ShadowAsmFactory::class.java,
                InstrumentationScope.ALL
            ) { params ->
                params.mappingFile.set(
                    file("shadow-mapping.json")
                )
            }
    }
}

新API的好处是:AGP自动处理增量编译、并行处理、缓存等逻辑,你不用再操心isIncremental那些坑了。坏处是灵活性降低——你不能再拿到完整的JAR做全局分析,只能逐个类处理。

对Shadow来说这不是问题,因为它的Transform逻辑本来就是逐类处理的"无状态"替换。

完整Transform流程回顾

把今天讲的串起来,Shadow Transform的完整流程是这样的:

Gradle编译触发Transform

加载映射规则(classMapping + excludePackages)

遍历所有.class文件(项目 + 第三方JAR)

排除白名单包?

需要处理 → ASM ClassReader读取 → ClassVisitor改写父类/接口 → MethodVisitor改写方法调用owner → ClassWriter输出

在白名单中 → 直接拷贝,不做任何修改

输出改写后的.class → 继续D8/R8流程

写在最后

Shadow Transform的设计哲学可以用一句话概括:把"欺骗系统"这件事从运行时前推到编译期

运行时欺骗(Hook/反射)是脆弱的——系统每升级一次你就得适配一次。编译期改写是稳固的——只要字节码规范不变(JVM规范极其稳定),Transform就能一直工作。这也是为什么Shadow能做到"零反射、零Hook"——所有"脏活"在编译期就已经干完了,运行时看到的是一个完全合法的、系统视角无异常的应用。

当然Transform不是万能的。它的局限在于:映射表必须覆盖所有需要替换的类,遗漏一个就会在运行时出ClassCastException。第三方库的内部实现如果绕过了标准API(比如直接反射系统类),Transform也无能为力。

下一篇是这个系列的最后一篇,聊实战接入:怎么从零搭建Shadow工程、把一个现有App改造成插件、以及生产环境的稳定性保障方案。那些才是真正决定"能不能上线"的东西。

本文是「Android插件化:Shadow深度剖析」系列第3篇。 上一篇:Shadow核心原理:壳子Activity与代理机制的精妙设计 下一篇:Shadow实战接入与生产落地:从零搭建到稳定运行