记一次kotlin匿名内部类踩的坑

背景

在使用Kotlin开发Android时,有一个很常见的写法:把匿名内部类转化为Lambda形式。

比如,要给View增加点击事件监听,View的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

public void setOnClickListener(@Nullable OnClickListener l) {
if (!isClickable()) {
setClickable(true);
}
getListenerInfo().mOnClickListener = l;
}

public interface OnClickListener {
/**
* Called when a view has been clicked.
*
* @param v The view that was clicked.
*/
void onClick(View v);
}

参数为一个只有单一方法的接口,那么使用时的写法,最直接的就是用匿名内部类:

1
2
3
4
5
6
7

view.setOnClickListener(object : View.OnClickListener{
override fun onClick(v: View?) {
//do something
}

})

此时IDE会提示你可以简化为Lambda,这是Kotlin的一个特性:

只有一个抽象方法的接口称为函数式接口或 SAM(单一抽象方法)接口。
对于函数式接口,可以通过 lambda 表达式实现 SAM 转换,从而使代码更简洁、更有可读性。

所以上述代码我们往往会简化为这样:

1
2
3
4

view.setOnClickListener {
//do something
}

确实是相当简洁了,一般也不会有什么问题,但是最近我就因为这个简写踩过一个坑。

出现问题

是这样一个场景:有一个单例的管理类,需要为一些对象注册一个监听,在特定的时机还要取消注册。

监听类是Java写的:

1
2
3
4

public interface Observer<T> extends Serializable {
void onEvent(T var1);
}

注册和取消注册的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

fun bind() {
val key = "object key"
val observer = Observer{ param:String ->
//do something
}
mObserverMap[key] = observer
register(observer)
}

fun unbind(activity: BaseActivity) {
val key = "object key"
val observer = mObserverMap[key]
if (observer != null) {
mObserverMap.remove(key)
unregister(observer)
}
}

用一个 Map 记录每个对象及其对应的 Observer 对象。在每次给对象加注册时保存一下记录,在需要解绑时从 Map 中获取到 Observer 进行解绑。这样看起来是没有问题的,且创建 Observer 对象时还使用了Lambda简化写法。但是运行时发现,在多个对象分别用 bind() 注册自己的监听后,只要有一次 unbind() 调用,其他对象注册的监听也没了。

问题原因

很怪,看起来像是所有对象共用了同一个 Observer 对象,所以一次 unbind() 就全寄了。打日志看了下,还真是,通过以下代码创建:

1
2
3
4

val observer = Observer{ param:String ->
//do something
}

实际上每次创建的引用都指向了同一个对象。直接AndroidStudio查看字节码和对应的Java代码:

1
2
3
4
5
6

LINENUMBER 57 L2
GETSTATIC com/netease/gamechat/im/MsgStatusManager$prepare$observer$1.INSTANCE : Lcom/netease/gamechat/im/MsgStatusManager$prepare$observer$1;
CHECKCAST com/netease/nimlib/sdk/Observer
ASTORE 3
L3
1
2

Observer observer = (Observer)null.INSTANCE;

果然,使用Lambda简写时创建的竟然是一个单例对象,而如果用原来匿名内部类形式写的话:

1
2
3
4
5
6
7

LINENUMBER 57 L2
NEW com/netease/gamechat/im/MsgStatusManager$prepare$observer$1
DUP
INVOKESPECIAL com/netease/gamechat/im/MsgStatusManager$prepare$observer$1.<init> ()V
ASTORE 3
L3
1
2
3
4
5
6
7
8
9
10
11

<undefinedtype> observer = new Observer() {
public void onEvent(@Nullable String p0) {
}

// $FF: synthetic method
// $FF: bridge method
public void onEvent(Object var1) {
this.onEvent((String)var1);
}
};

这样写就没有问题了,每次都会new一个新的对象,而不是一直用同一个单例。那么为什么会有这个差异呢?

原因解析

在Java中,匿名内部类内部是无法直接更改外部的变量的,比如:

1
2
3
4
5
6
7
8
9
10

public void test(View view){
String test = "123";
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
test = "456";
}
});
}

这样写是编译不过的,Java最多允许读取外部非静态变量,而不允许修改。但是在kotlin中就没有这个限制了,可以读取也可以修改。在内部访问外部的非静态变量,外部的这个变量就叫做被捕获了。如果没有变量被捕获,他会创建一个实例,在每次使用lambda传参时,再次复用这个实例。而如果有捕获到变量或者对象,那么这里每次还是会创建一个新的实例,举个例子,同样写法不变,如果只是在外部定义一个变量并在lambda中改变他:

1
2
3
4
5
6
7
8
9
10
11

fun bind(activity: BaseActivity) {
val key = "object key"
var params = "init"
val observer = Observer<String> {
//do something
params = "changed"
}
mObserverMap[key] = observer
register(observer)
}

再用AndroidStudio查看反编译的Java代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

public final void bind(@NotNull BaseActivity activity) {
Intrinsics.checkNotNullParameter(activity, "activity");
String key = "object key";
final ObjectRef params = new ObjectRef();
params.element = "init";
Observer observer = (Observer)(new Observer() {
// $FF: synthetic method
// $FF: bridge method
public void onEvent(Object var1) {
this.onEvent((String)var1);
}

public final void onEvent(String it) {
params.element = "changed";
}
});
((Map)mObserverMap).put(key, observer);
this.register(observer);
}

这里Kotlin为了实现能在匿名内部类中修改变量,会封装一个 xxxRef 类,来保存这个变量,并替换他的位置。这样在匿名内部类中,我们修改的就不是原来的那个变量了,而是修改的封装后的 xxxRef 的一个实例的字段。Kotlin正是通过这种巧妙的方式实现了匿名内部类修改外部变量。

小结

上述只是简单的做了一个概括,并不十分严谨。比如,当把这个变量挪到方法外部,作为当前类的一个字段时,情况又不一样了,究其原因,我推测还是因为Java的限制。Java之所以不允许匿名内部类修改外部变量,就是因为外部变量和匿名内部类生命周期不同,如果贸然支持修改,会产生歧义。而Kotlin为了规避这一问题,就会根据当前变量和匿名内部类的生命周期状态,对lambda的处理做对应的不同的调整。

我们在实际应用中并不用考虑这么细,一般也不会需要一定要每次 new 一个新的实例。但是在真的需要的时候,就要注意这里的写法了。我在这里吸取了一个教训,即不要盲目的按IDE的提示优化代码写法,这并不一定是完全满足自己使用场景的。