动态代理分析与仿Retrofit实践

我们一直都在使用Retroift,都知道它的核心是动态代理。例如在之前的文章重温Retrofit源码,笑看协程实现中也简单提及到动态代理(来填之前挖的坑...)。

咳咳,大家不要关注起因,还是要回归当前的内容。

这次主要是来分析一下动态代理的作用与实现原理。既然都已经分析了原理,最后自然也要动手仿照Retrofit来简单实现一个Demo

通过最后的Demo实现,相信动态代理你也基本没什么问题了。

静态代理

既然说到动态代理,自然少不了静态代理。那么静态代理到底是什么呢?我们还是通过一个简单的场景来了解。

假设有一个Bird接口来代表鸟的一些特性,例如fly飞行特性

interface Bird {
    fun fly()
}

现在分别有麻雀、老鹰等动物,因为它们都是鸟类,所以都会实现Bird接口,内部实现自己的fly逻辑。

// 麻雀
class Sparrow : Bird {
    override fun fly() {
        println("Sparrow: is fly.")
        Thread.sleep(1000)
    }
}
// 老鹰
class Eagle : Bird {
    override fun fly() {
        println("Eagle: is fly.")
        Thread.sleep(2000)
    }
}

麻雀与老鹰的飞行能力都实现了,现在有个需求:需要分别统计麻雀与老鹰飞行的时长。

你会怎么做呢?相信在我们刚学习编程的时候都会想到的是:这还不简单直接在麻雀与老鹰的fly方法中分别统计就可以了。

如果实现的鸟类种类不多的话,这种实现不会有太大的问题,但是一旦实现的鸟类种类很多,那么这种方法重复做的逻辑将会很多,因为我们要到每一种鸟类的fly方法中都去添加统计时长的逻辑。

所以为了解决这种无意义的重复逻辑,我们可以通过一个ProxyBird来代理实现时长的统计。

class BirdProxy(private val bird: Bird) : Bird {
    override fun fly() {
        println("BirdProxy: fly start.")
        val start = System.currentTimeMillis() / 1000
        bird.fly()
        println("BirdProxy: fly end and cost time => ${System.currentTimeMillis() / 1000 - start}s")
    }
}

ProxyBird实现了Bird接口,同时接受了外部传进来的实现Bird接口的对象。当调用ProxyBirdfly方法时,间接调用了传进来的对象的fly方法,同时还进行来时长的统计。

class Main {
    companion object {
        @JvmStatic
        fun main(args: Array<String>) {
            ProxyBird(Sparrow()).fly()
            println()
            ProxyBird(Eagle()).fly()
        }

    }
}

最后输出如下:

ProxyBird: fly start.
Sparrow: is fly.
ProxyBird: fly end and cost time => 1s
 
ProxyBird: fly start.
Eagle: is fly.
ProxyBird: fly end and cost time => 2s

上面这种模式就是静态代理,可能有许多读者都已经不知觉的使用到了这种方法,只是自己没有意识到这是静态代理。

那它的好处是什么呢?

通过上面的例子,很自然的能够体会到静态代理主要帮我们解决的问题是:

  1. 减少重复逻辑的编写,提供统一的便捷处理入口。
  2. 封装实现细节。

动态代理

既然已经有了静态代理,为什么又要来一个动态代理呢?

任何东西的产生都是有它的必要性的,都是为了解决前者不能解决的问题。

所以动态代理就是来解决静态代理所不能解决的问题,亦或者是它的缺点。

假设我们现在要为Bird新增一种特性:chirp鸟叫。

那么基于前面的静态代理,需要做些什么改变呢?

  1. 修改Bird接口,新增chirp方法。
  2. 分别修改SparrowEagle,为它们新增chirp的具体实现。
  3. 修改ProxyBird,实现chirp代理方法。

1、3还好,尤其是2,一旦实现Bird接口的鸟类种类很多的话,将会非常繁琐,这时就真的是牵一发动全身了。

这还是改动现有的Bird接口,可能你还需要新增另外一种接口,例如Fish鱼,实现有关鱼的特性。

这时又要重新生成一个新的代理ProxyFish来管理有关鱼的代理。

所以从这一点,我们可以发现静态代理的机动性很差,对于那些实现了之后不怎么改变的功能,可以考虑使用它来实现,这也完全符合它的名字中的静态的特性。

那么这种情况动态代理就能够解决吗?别急,能否解决接着往下看。

接着上面,我们为Bird新增chirp方法

interface Bird {
    fun fly()
    
    fun chirp()
}

然后再通过动态代理的方式来实现这个接口

class Main {
    companion object {
        @JvmStatic
        fun main(args: Array<String>) {
            val proxy = (Proxy.newProxyInstance(this::class.java.classLoader, arrayOf(Bird::class.java), InvocationHandler { proxy, method, args ->
                if (method.name == "fly") {
                    println("calling fly.")
                } else if (method.name == "chirp") {
                    println("calling chirp.")
                }
            }) as Bird)
            
            proxy.fly()
            proxy.chirp()
        }
    }
}

输出如下:

calling fly.
calling chirp.

方式很简单,通过Proxy.newProxyInstance静态方法来创建一个实现Bird接口的代理。该方法主要有三个参数分别为:

  1. ClassLoader: 生成代理类的类类加载器。
  2. interface 接口Class数组: 对应的接口Class。
  3. InvocationHandler: InvocationHandler对象,所有代理方法的回调。

这里关键点是第三个参数,所有通过调用代理类的代理方法都会在InvocationHandler对象中通过它的invoke方法进行回调

public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}

这就是上面将判断调用具体接口方法的逻辑写在InvocationHandler对象的invoke方法的原因。

那它到底是如何实现的呢?怎么就成了一个代理类呢?我也没看到代理类在哪啊?怎么就所有调用都通过InvocationHandler的呢?

有这些疑问很正常,开始接触动态代理时都会有这些疑问。导致这些疑问的直接原因是我们不能直接看到所谓的代理类。因为动态代理是在运行时生成代理类的,所以不像在编译时期一样能够直接看到源码。

那么下面目标就很明确了,解决看不到源码的问题。

既然是运行时生成的,那么在运行的时候将生成的代理类写到本地目录下不就可以了吗?至于如何写Proxy已经提供了ProxyGenerator。它的generateProxyClass方法能够帮助我们得到生成的代理类。

class Main {
    companion object {
        @JvmStatic
        fun main(args: Array<String>) {
            val byte = ProxyGenerator.generateProxyClass("\$Proxy0", arrayOf(Bird::class.java))
            FileOutputStream("/Users/{path}/Downloads/\$Proxy0.class").apply {
                write(byte)
                flush()
                close()
            }
        }
    }
}

运行上面的代码就会在Downloads目录下找到$Proxy0.class文件,将其直接拖到编译器中,打开后的具体代码如下:

public final class $Proxy0 extends Proxy implements Bird {
    private static Method m1;
    private static Method m4;
    private static Method m2;
    private static Method m3;
    private static Method m0;
 
    public $Proxy0(InvocationHandler var1) throws  {
        super(var1);
    }
 
    public final boolean equals(Object var1) throws  {
        try {
            return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
        } catch (RuntimeException | Error var3) {
            throw var3;
        } catch (Throwable var4) {
            throw new UndeclaredThrowableException(var4);
        }
    }
 
    public final void fly() throws  {
        try {
            super.h.invoke(this, m4, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }
 
    public final String toString() throws  {
        try {
            return (String)super.h.invoke(this, m2, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }
 
    public final void chirp() throws  {
        try {
            super.h.invoke(this, m3, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }
 
    public final int hashCode() throws  {
        try {
            return (Integer)super.h.invoke(this, m0, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }
 
    static {
        try {
            m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
            m4 = Class.forName("com.daily.algothrim.Bird").getMethod("fly");
            m2 = Class.forName("java.lang.Object").getMethod("toString");
            m3 = Class.forName("com.daily.algothrim.Bird").getMethod("chirp");
            m0 = Class.forName("java.lang.Object").getMethod("hashCode");
        } catch (NoSuchMethodException var2) {
            throw new NoSuchMethodError(var2.getMessage());
        } catch (ClassNotFoundException var3) {
            throw new NoClassDefFoundError(var3.getMessage());
        }
    }
}

首先$Proxy0继承了Proxy同时实现了我们熟悉的Bird接口;然后在它的构造方法中接受了一个var1参数,它的类型是InvocationHandler。继续看方法,实现了类的默认三个方法equalstoStringhashCode,同时也找到了我们需要的flychirp方法。

例如fly方法,调用了

super.h.invoke(this, m4, (Object[])null)

这里的h就是之前的var1,即InvocationHandler对象。

到这里迷雾已经揭晓了,调用invoke方法,同时将代理类的自身this、对应的method信息与方法参数传递过去。

所以我们只需要在动态代理的最后一个参数InvocationHandlerinvoke方法中进行处理不同代理方法的相关逻辑。这样做的好处是,不管你如何新增与删除Bird中的接口方法,我都只要调整invoke的处理逻辑即可,将改动的范围缩小到最小化。

这就是动态代理的好处之一(另一个主要的好处自然是减少代理类的书写)。

Android中运用动态代理的典型非Retrofit莫属。由于是一个网络框架,一个App对于网络请求来说接口自然是随着App的迭代不断增加的。对于这种变化频繁的情况,Retrofit使用动态代理为入口,暴露出一个对应的Service接口,而相关的接口请求方法都在Service中进行定义。所以我们每新增一个接口,都不需要做过多的别的修改,相关的网络请求逻辑都封装到动态代理的invoke方法中,当然Retrofit原理是借助添加Annomation注解的方式来解析不同网络请求的方式与相关的参数逻辑。最终再将解析的数据进行封装传递给下层的OKHttp

所以Retrofit的核心就是动态代理与注解的解析。

这篇文章的原理解析部分就完成了,最后既然分析了动态代理与Retrofit的关系,我这里提供了一个Demo来巩固一下动态代理,同时借鉴Retroift的一些思想对一个简易版的打点系统进行上层封装。

Demo

Demo是一个简单的模拟打点系统,通过定义Statistic类来创建动态代理,暴露Service接口,具体如下:

class Statistic private constructor() {
 
    companion object {
        @JvmStatic
        val instance by lazy { Statistic() }
    }
 
    @Suppress("UNCHECKED_CAST")
    fun <T> create(service: Class<T>): T {
        return Proxy.newProxyInstance(service.classLoader, arrayOf(service)) { proxy, method, args ->
            return@newProxyInstance LoadService(method).invoke(args)
        } as T
    }

}

通过入口传进来的Service接口,从而创建对应的动态代理类,然后将对Service接口中的方法调用的逻辑处理都封装到了LoadServiceinvoke方法中。当然Statistic也借助了注解来解析不同的打点类型事件。

例如,我们需要分别对ButtonText进行点击与展示打点统计。

首先我们可以如下定义对应的Service接口,这里命名为StatisticService

interface StatisticService {
 
    @Scan(ProxyActivity.PAGE_NAME)
    fun buttonScan(@Content(StatisticTrack.Parameter.NAME) name: String)
 
    @Click(ProxyActivity.PAGE_NAME)
    fun buttonClick(@Content(StatisticTrack.Parameter.NAME) name: String, @Content(StatisticTrack.Parameter.TIME) clickTime: Long)
 
    @Scan(ProxyActivity.PAGE_NAME)
    fun textScan(@Content(StatisticTrack.Parameter.NAME) name: String)
 
    @Click(ProxyActivity.PAGE_NAME)
    fun textClick(@Content(StatisticTrack.Parameter.NAME) name: String, @Content(StatisticTrack.Parameter.TIME) clickTime: Long)
}

然后再通过Statistic来获取动态代理的代理类对象

private val mStatisticService = Statistic.instance.create(StatisticService::class.java)

有了对应的代理类对象,剩下的就是在对应的位置直接调用。

class ProxyActivity : AppCompatActivity() {
 
    private val mStatisticService = Statistic.instance.create(StatisticService::class.java)
 
    companion object {
        private const val BUTTON = "statistic_button"
        private const val TEXT = "statistic_text"
        const val PAGE_NAME = "ProxyActivity"
    }
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val extraData = getExtraData()
        setContentView(extraData.layoutId)
        title = extraData.title

        // statistic scan
        mStatisticService.buttonScan(BUTTON)
        mStatisticService.textScan(TEXT)
    }

    private fun getExtraData(): MainModel =
            intent?.extras?.getParcelable(ActivityUtils.EXTRA_DATA)
                    ?: throw NullPointerException("intent or extras is null")

    fun onClick(view: View) {
        // statistic click
        if (view.id == R.id.button) {
            mStatisticService.buttonClick(BUTTON, System.currentTimeMillis() / 1000)
        } else if (view.id == R.id.text) {
            mStatisticService.textClick(TEXT, System.currentTimeMillis() / 1000)
        }
    }
}

这样一个简单的打点上层逻辑封装就完成了。由于篇幅有限(懒...)内部具体的实现逻辑就不展开了。

相关源码都在android-api-analysis项目中,感兴趣的可以自行查看。

使用前请先把分支切换到feat_proxy_dev

项目

android_startup: 提供一种在应用启动时能够更加简单、高效的方式来初始化组件,优化启动速度。不仅支持Jetpack App Startup的全部功能,还提供额外的同步与异步等待、线程控制与多进程支持等功能。

AwesomeGithub: 基于Github客户端,纯练习项目,支持组件化开发,支持账户密码与认证登陆。使用Kotlin语言进行开发,项目架构是基于Jetpack&DataBindingMVVM;项目中使用了ArouterRetrofitCoroutineGlideDaggerHilt等流行开源技术。

flutter_github: 基于Flutter的跨平台版本Github客户端,与AwesomeGithub相对应。

android-api-analysis: 结合详细的Demo来全面解析Android相关的知识点, 帮助读者能够更快的掌握与理解所阐述的要点。

daily_algorithm: 每日一算法,由浅入深,欢迎加入一起共勉。

推荐阅读

重温Retrofit源码,笑看协程实现

我为何弃用Jetpack的App Startup?

AwesomeGithub组件化探索之旅

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345