背景
在使用Kotlin开发Android时,有一个很常见的写法:把匿名内部类转化为Lambda形式。
比如,要给View增加点击事件监听,View的源码:
1 |
|
参数为一个只有单一方法的接口,那么使用时的写法,最直接的就是用匿名内部类:
1 |
|
此时IDE会提示你可以简化为Lambda,这是Kotlin的一个特性:
只有一个抽象方法的接口称为函数式接口或 SAM(单一抽象方法)接口。
对于函数式接口,可以通过 lambda 表达式实现 SAM 转换,从而使代码更简洁、更有可读性。
所以上述代码我们往往会简化为这样:
1 |
|
确实是相当简洁了,一般也不会有什么问题,但是最近我就因为这个简写踩过一个坑。
出现问题
是这样一个场景:有一个单例的管理类,需要为一些对象注册一个监听,在特定的时机还要取消注册。
监听类是Java写的:
1 |
|
注册和取消注册的代码如下:
1 |
|
用一个 Map
记录每个对象及其对应的 Observer
对象。在每次给对象加注册时保存一下记录,在需要解绑时从 Map
中获取到 Observer
进行解绑。这样看起来是没有问题的,且创建 Observer
对象时还使用了Lambda简化写法。但是运行时发现,在多个对象分别用 bind()
注册自己的监听后,只要有一次 unbind()
调用,其他对象注册的监听也没了。
问题原因
很怪,看起来像是所有对象共用了同一个 Observer
对象,所以一次 unbind()
就全寄了。打日志看了下,还真是,通过以下代码创建:
1 |
|
实际上每次创建的引用都指向了同一个对象。直接AndroidStudio查看字节码和对应的Java代码:
1 |
|
1 |
|
果然,使用Lambda简写时创建的竟然是一个单例对象,而如果用原来匿名内部类形式写的话:
1 |
|
1 |
|
这样写就没有问题了,每次都会new一个新的对象,而不是一直用同一个单例。那么为什么会有这个差异呢?
原因解析
在Java中,匿名内部类内部是无法直接更改外部的变量的,比如:
1 |
|
这样写是编译不过的,Java最多允许读取外部非静态变量,而不允许修改。但是在kotlin中就没有这个限制了,可以读取也可以修改。在内部访问外部的非静态变量,外部的这个变量就叫做被捕获了。如果没有变量被捕获,他会创建一个实例,在每次使用lambda传参时,再次复用这个实例。而如果有捕获到变量或者对象,那么这里每次还是会创建一个新的实例,举个例子,同样写法不变,如果只是在外部定义一个变量并在lambda中改变他:
1 |
|
再用AndroidStudio查看反编译的Java代码:
1 |
|
这里Kotlin为了实现能在匿名内部类中修改变量,会封装一个 xxxRef
类,来保存这个变量,并替换他的位置。这样在匿名内部类中,我们修改的就不是原来的那个变量了,而是修改的封装后的 xxxRef
的一个实例的字段。Kotlin正是通过这种巧妙的方式实现了匿名内部类修改外部变量。
小结
上述只是简单的做了一个概括,并不十分严谨。比如,当把这个变量挪到方法外部,作为当前类的一个字段时,情况又不一样了,究其原因,我推测还是因为Java的限制。Java之所以不允许匿名内部类修改外部变量,就是因为外部变量和匿名内部类生命周期不同,如果贸然支持修改,会产生歧义。而Kotlin为了规避这一问题,就会根据当前变量和匿名内部类的生命周期状态,对lambda的处理做对应的不同的调整。
我们在实际应用中并不用考虑这么细,一般也不会需要一定要每次 new
一个新的实例。但是在真的需要的时候,就要注意这里的写法了。我在这里吸取了一个教训,即不要盲目的按IDE的提示优化代码写法,这并不一定是完全满足自己使用场景的。