AOP - 安全使用接口引用

Photo by Joseph Maxim Reskp on Unsplash

我使用Java 开发过很多项目,这其中包括一些Web 应用和Android 客户端应用。作为Android 开发人员,Java 就像我们的母语一样,但Android 世界是多元化的,并不是只有Java 才能用来写Android 程序,Kotlin 和Groovy 同样优秀,并且有着大量的粉丝。我在过去的一年中尝试学习并使用它们,它们的语法糖让我爱不释手,我尤其对安全导航(safe-navigation)操作符?. 感到惊讶,它让我写更少的代码,就能够避免空指针异常(NullPointerException)。可惜的是Java 并没有提供这种操作符,所以本文就和大家聊聊如何在Java 中取代繁琐的非空判断。

接口隔离原则

软件编程中始终都有一些好的编程规范值得我们的学习:如果你在一个多人协作的团队工作,那么模块之间的依赖关系就应该建立在接口上,这是降低耦合的最佳方式;如果你是一个SDK 的提供者,暴露给客户端的始终应该是接口,而不是某个具体实现类。

在Android 开发中我们经常会持有接口的引用,或注册某个事件的监听,如系统服务的通知,点击事件的回调等,虽不胜枚举,但大部分监听都需要我们去实现一个接口,因此我们就拿注册回调监听来举例:


  private Callback callback;

  public void registerXXXX(Callback callback) {
    this.callback = callback;
  }
  
  ......
  
  public interface Callback {
    void onXXXX();
  }

当事件真正发生的时候调用callback 接口中的函数:

......

 if (callback != null) {
   callback.onXXXX();
}

这看起来并没有什么问题,因为我们平时就是这样书写代码的,所以我们的项目中存在大量的对接口引用的非空判断,即使有参数型注解@NonNull 的标记,但仍无法阻止外部传入一个null 对象。

说实话,我需要的无非就是当接口引用为空的时候,不进行任何的函数调用,然而我们却需要在每一行代码之上强行添加丑陋的非空判断,这让我的代码看起来失去了信任,变得极其不可靠,而且频繁的非空判断让我感到十分疲惫 : (

使用操作符 ' ?. '

Kotlin 和Groovy 似乎意识到了上述尴尬,因此加入了非常实用的操作符:

?. 操作符只有对象引用不为空时才会分派调用

接下来分别拿Kotlin 和Groovy 举例:

在Kotlin 中使用 ' ?. ' :


  fun register(callback: Callback?) {
    
    ......

    callback?.on()
  }

  interface Callback {
    fun on()
  }

在Groovy 中使用 ' ?. ' :


  void register(Callback callback) {

    ......

    callback?.on()
  }

  interface Callback {
    void on()
  }

可以看到使用?. 操作符后我们再也不需要添加if (callback != null) {} 代码块了,代码更加清爽,所要表达的意思也更加直接:如果callback 引用不为空则调用on() 函数,否则不做任何处理

' ?. ' 是黑魔法吗?我们将在下一个章节介绍操作符?. 的实现原理。

反编译操作符 ' ?. '

我始终相信在代码层面没有所谓的黑魔法,更没有万能的银弹,我们之所以能够使用语法糖,一定是语言本身或者框架内部帮我们做了更复杂的操作。

现在,我们可以先提出一个假设:编译器将操作符?. 优化成了与if (callback != null) {} 效果相同的代码逻辑,无论是Java,Kotlin 还是Groovy,它们在字节码层面的表现相同

为了验证这个假设,我们分别用kotlinc 和groovyc 将之前的代码编译成class 文件,然后再使用javap 指令进行反汇编。

编译/反编译KotlinSample.kt

# $ kotlinc KotlinSample.kt
# $ javap -c KotlinSample.kt

Compiled from "KotlinSample.kt"
public final class KotlinSample {
  public final void register(KotlinSample$Callback);
    Code:
       0: aload_1
       1: dup
       2: ifnull        13
       5: invokeinterface #13,  1           // InterfaceMethod KotlinSample$Callback.on:()V
      10: goto          14
      13: pop
      14: return
    
    ......

}

通过分析register() 函数体中的所有JVM 指令,我们看到了熟悉的ifnull 指令,因此我们可以很快地将字节码还原:


  fun register(callback: Callback?) {
    if (callback!=null){
      callback.on()
    }
  }

由此可见:kotlinc 编译器在编译过程中将操作符?. 完完全全地替换成if (callback != null) {} 代码块。这和我们手写的Java 代码在字节码层面毫无差别。

编译/反编译GroovySample.groovy

# $ groovyc GroovySample.groovy
# $ javap -c GroovySample.class

Compiled from "GroovySample.groovy"
public class GroovySample implements groovy.lang.GroovyObject {

  public void register(GroovySample$Callback);
    Code:
       0: invokestatic  #19                 // Method $getCallSiteArray:()[Lorg/codehaus/groovy/runtime/callsite/CallSite;
       3: astore_2
       4: aload_2
       5: ldc           #32                 // int 0
       7: aaload
       8: aload_1
       9: invokeinterface #38,  2           // InterfaceMethod org/codehaus/groovy/runtime/callsite/CallSite.callSafe:(Ljava/lang/Object;)Ljava/lang/Object;
      14: pop
      15: return

    ......

}

需要注意的是,groovy 文件在编译过程中由编译器生成大量的不存在于源代码中的额外函数和变量,感兴趣的朋友可以自行阅读反编译后的字节码。此处为了方便理解,在不影响原有核心逻辑的条件下做出近似还原:


 public void register(GroovySample.Callback callback) {

    String[] strings = new String[1]
    strings[0] = 'on'

    CallSiteArray callSiteArray = new CallSiteArray(GroovySample.class, strings)
    CallSite[] array = callSiteArray.array

    array[0].callSafe(callback)
  }

其中CallSite 是一个接口,具体实现类是AbstractCallSite ,:


public class AbstractCallSite implements CallSite {

    public final Object callSafe(Object receiver) throws Throwable {
        if (receiver == null)
            return null;

        return call(receiver);
    }

  ......

}

函数AbstractCallSite#call(Object) 之后是一个漫长的调用过程,这其中包括一系列重载函数的调用和对接口引用callback 的代理等,最终得益于Groovy 的元编程能力,在标准GroovyObject对象上获取meatClass ,最后使用反射调用接口引用的指定方法,即callback.on()


callback.metaClass.invokeMethod(callback, 'on', null);

那么回到文章的主题,在AbstractCallSite#call(Object) 函数中我们可以看到对receiver 参数也就是对callback 引用进行了非空判断,因此我们可以肯定的是:操作符?. 在Groovy 和Kotlin 中的原理是基本相同的。

因此可以得出结论:编译器将?. 操作符编译成亦或在框架内部调用与if (callback != null) {} 等同效果的代码片段。Java,Kotlin 和Groovy 在字节码层面使用了相同方式的非空判断

为Java 添加' ?. ' 操作符

事情变得简单起来,我们只需要给Java 添加?. 操作符就行了。

其实,与其说为Java 添加?. 操作符不如说是通过一些小技巧达到相同的处理效果,毕竟改变javac 的编译方式成本较大。

面向接口的编程方式,使我们有天然的优势可以利用,而且动态代理也是基于接口的,因此我们可以对接口引进行动态代理并返回代理后的值,这样callback 实际指向了动态代理对象,在代理的内部我们使用反射调用callback 引用中的函数:


  private void register(Callback callback) {
    callback = ProxyHandler.wrap(callback, Callback.class);

    ......

    callback.on();
  }


public static final class ProxyHandler {

  public static <T> T wrap(final T reference, Class<? extends T> interfacee) {

    if (interfacee.isInterface()) {
      return (T) Proxy.newProxyInstance(interfacee.getClassLoader(), new Class[] { interfacee },
          new InvocationHandler() {
            @Override public Object invoke(Object proxy, Method method, Object[] args)
                throws Throwable {
              if (reference == null) return null;
              return method.invoke(reference, args);
            }
          });
    }
    return reference;
  }
}

通过这样的一层代理关系,我们可以安全使用callback 引用上的任何函数,而不必关心空指针的发生。也就是说,我们在Java 上通过使用动态代理加反射的方式,构造出了一个约等于?. 操作符的效果

集成Android gradle plugin (AGP)

我们发现每次使用前都需要手动添加代理关系实在麻烦,能否像javac 或者kotlinc 那样在编译过程或者构建过程中使用自动化的方式代替手动添加呢?

答案是肯定的:在构建过程中修改字节码!

首先,我们找一段简单的java 代码:


public class JavaSample {

  public Callback callback;

  public void doOperation() {

    //Called when progress is updated
    callback.onProgress(99);
  }

  interface Callback {
    void onProgress(int progress);
  }
}

编译/反编译JavaSample.java

# $ javac JavaSample.java
# $ javap -c JavaSample.class

public class JavaSample {
  public JavaSample$Callback callback;

  public void doOperation();
    Code:
       0: aload_0
       1: getfield      #2                  // Field callback:LJavaSample$Callback;
       4: bipush        99
       6: invokeinterface #3,  2            // InterfaceMethod JavaSample$Callback.onProgress:(I)V
      11: return
}

然后,通过观察字节码指令,我们知道调用Java 接口中声明的方法使用的是invokeinterface 指令,因此我们只需要找到函数体中invokeinterface 指令所在位置,对其进行就修改即可。本项目所采取的思路是将invokeinterface 替换成invokestatic 并调用根据接口函数调用信息所生成的静态函数static void buoy$onProgress(JavaSample$Callback, int);


  public void doOperation();
    Code:
       0: aload_0
       1: getfield      #19                 // Field callback:LJavaSample$Callback;
       4: bipush        99
       6: invokestatic  #23                 // Method buoy$onProgress:(LJavaSample$Callback;I)V
       9: return

  static void buoy$onProgress(JavaSample$Callback, int);
    Code:
       0: aload_0
       1: ldc           #25                 // String JavaSample$Callback
       3: ldc           #27                 // String JavaSample$Callback.onProgress:(int)void
       5: invokestatic  #33                 // Method com/smartdengg/interfacebuoy/compiler/InterfaceBuoy.proxy:(Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Object;
       8: iload_1
       9: invokeinterface #37,  2           // InterfaceMethod JavaSample$Callback.onProgress:(I)V
      14: return

值得一提的是:源码级别中我们无法在非静态内部类中创建静态函数,但是在字节码中这是允许的

下面我们将JavaSample.class 还原:


public class JavaSample {
  public Callback callback;

  public void doOperation() {
    buoy$onProgress(this.callback, 99);
  }

  @Buoy
  static void buoy$onProgress(JavaSample.Callback var0, int var1) {
    ((JavaSample.Callback)InterfaceBuoy.proxy(var0, "JavaSample$Callback", "JavaSample$Callback.onProgress:(int)void")).onProgress(var1);
  }

  interface Callback {
    void onProgress(int var1);
  }
}

其中:

  • @Buoy 注解表示该函数用户保护接口引用的安全使用。
  • InterfaceBuoy 类则用于创建接口引用的动态代理对象。

这里需要说明一下,我并没有在生成的静态函数中直接对接口引用进行非空判断,而是交给了源码级别的InterfaceBuoy 类,我给出的理由是:字节码织入应该尽可能的简单,更复杂的操作应该交给源码级别的类,这不仅可以防止调用栈的过度污染,从而降低调试成本,而且源代码比字节码更容易编写,出现问题的几率会更小,因为我们不会比编译器更了解字节码!

最后,通过ASM 修改字节码并集成到AGP 中,使其成为Android 构建过程的一部分,我们做到了 : )

总结&讨论

通篇下来,其实我们并没有修改javac ,我们不能也不应该去修改这些编译工具,我们使用Java 平台所提供的动态代理与反射就完成了类似?. 操作符的功能。

可能有人会说反射很慢,加上动态代理后会变得更慢,我倒是认为这种观点是缺乏说服力的,因为在这个级别上担心性能问题是不明智的,除非能够分析表明这种方式正是造成性能损耗的源头,否则在没有统一衡量标准的前提下,盲目反对反射和动态代理的观点是站不稳脚的。

为了安全使用定义在接口中的函数,我做了这个小工具,目前已经开源,所有代码都可以通过github 获取,希望这个避免空指针的“接口救生圈”能够让你在Java 的海洋中尽情遨游。

欢迎讨论或在评论区留下您宝贵的建议。

我的博客即将搬运同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=2whezn9l7yeco

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

推荐阅读更多精彩内容