kotlin如何在ViewHolder中直接使用控件id,以及KAE插件在Adapter中使用的坑

2,735 阅读5分钟

在标题中提出了两个问题,第二个问题中带出了第一个问题的答案。我相信有很多人看到后会说KAE —— Kotlin-Android-Extensions(下文使用KAE简称)这个插件已经被废弃了,不过切换过渡总还需要时间,这篇文章就是写给仍在使用的同学们,希望你们能注意关于Adapter中可能出现的一个坑

1. 被废弃的Kotlin-Android-Extensions

关于KAE的介绍、废弃及替代方案,以及标题中的第二个问题,在郭神的这篇文章解答得很详细了:

kotlin-android-extensions插件也被废弃了?扶我起来

废弃的主要原因: 每一个View都需要使用一个额外的HashMap存储所有控件实例,无形中增加了一些内存的开支。

每次从HashMap中找出对应控件实例,也降低了程序的运行效率。

最重要的是,这些内容对于绝大部分开发者来说都是黑盒,使用kotlin-android-extensions插件的人可能并不知道这些隐藏的“坑”。

2.KAE插件在Adapter中使用的坑

此处摘录郭神文章中关于Adapter使用KAE的部分:

刚才我们已经看到过了使用kotlin-android-extensions插件后的代码,非常简单:
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        viewToShowText.text = "Hello"
    }
    
}
复制代码
那么这段代码为什么可以工作呢?
我们可以通过点击Android Studio顶部导航栏的Tools -> Kotlin -> Show Kotlin Bytecode来查看这段代码对应的Kotlin字节码,然后在弹出窗口中点击Decompile按钮将字节码反编译成Java代码。
为了方便阅读,我将反编译后的代码又做了些整理,大致如下所示:
public final class MainActivity extends AppCompatActivity {
   private HashMap _$_findViewCache;

   protected void onCreate(@Nullable Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      this.setContentView(1300023);
      TextView var10000 = (TextView)this._$_findCachedViewById(id.textView);
      var10000.setText((CharSequence)"Hello");
   }

   public View _$_findCachedViewById(int var1) {
      if (this._$_findViewCache == null) {
         this._$_findViewCache = new HashMap();
      }
      View var2 = (View)this._$_findViewCache.get(var1);
      if (var2 == null) {
         var2 = this.findViewById(var1);
         this._$_findViewCache.put(var1, var2);
      }
      return var2;
   }
}
复制代码
可以看到,实际上kotlin-android-extensions插件会帮我们生成一个_$_findCachedViewById()函数(使用这种奇怪的命名方式是为了防止和开发者定义的函数名冲突)。在这个函数中首先会尝试从一个HashMap中获取传入的资源id参数所对应的控件实例缓存,如果还没有缓存的话,就调用findViewById()函数来查找控件实例,并写入HashMap缓存当中。这样当下次再获取相同控件实例的话,就可以直接从HashMap缓存中获取了。
这就是kotlin-android-extensions插件的实现原理,其实还是非常简单的。


然而有些读者朋友跟我反馈,说这种写法还要在ViewHolder当中声明控件变量,
还要编写findViewById(),实在是太复杂了。
自己找到了一种更简单的写法,只需要借助kotlin-android-extensions插件,就可以这样写:

class FruitAdapter(val fruitList: List<Fruit>) : RecyclerView.Adapter<FruitAdapter.ViewHolder>() {

    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.fruit_item, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val fruit = fruitList[position]
        holder.itemView.fruitImage.setImageResource(fruit.imageId)
        holder.itemView.fruitName.text = fruit.name
    }

    override fun getItemCount() = fruitList.size

}

可以看到,这里ViewHolder中没有进行任何控件声明,相当于只是定义了一个空的ViewHolder。
然后在onBindViewHolder()函数当中,直接调用holder.itemView再接上控件id的名称就可以使用它了。

这种写法确实简化了不少代码,但是这种写法对吗?

如果你的评判标准只是这段代码能不能正常工作,那么答案是肯定的,这样写确实可以正常工作。
但是这种写法我可以说是完全不正确的,为什么呢?我们只需要使用刚才的手法把这段代码反编译一下,
看看它对应的Java代码是什么样的就知道了。

同样为了方便阅读,我还是对代码进行了简化,只保留了关键部分,如下所示:

public final class FruitAdapter extends Adapter {
   ...

   public final class ViewHolder extends androidx.recyclerview.widget.RecyclerView.ViewHolder {
      public ViewHolder(@NotNull View view) {
         super(view);
      }
   }

   public void onBindViewHolder(@NotNull FruitAdapter.ViewHolder holder, int position) {
      Fruit fruit = (Fruit)this.fruitList.get(position);
      View var10000 = holder.itemView;
      ((ImageView)var10000.findViewById(id.fruitImage)).setImageResource(fruit.getImageId());
      var10000 = holder.itemView;
      TextView var4 = (TextView)var10000.findViewById(id.fruitName);
      var4.setText((CharSequence)fruit.getName());
   }

}

不知道你有没有发现问题,现在onBindViewHolder()函数当中,
每次都是调用了findViewById()来获取控件实例,这样就导致ViewHolder的作用完全失效了。

所以,上面这种写法就是kotlin-android-extensions插件在Adapter当中一种比较典型的误用方式。
同时也算是一个隐藏的“坑”,因为如果你不去将Kotlin代码进行反编译,
可能都不知道自己的ViewHolder其实根本就没有起到任何作用。 

文章中描述的很清楚,直接在ViewHolder中使用KAE的方式是通过holder.itemView.xxx,转换成java代码的话,就是每次都在onBindViewHolder()中调用findViewById() ,显然会徒增性能消耗,违背了ViewHolder的用法初衷,这也就是KAE使用在Adapter中可能出现的坑了。

3. 如何正确的在Adapter中使用KAE

KAEActivityFragment中的使用转换为Java代码,可以看到实质上是创建了一个HashMap来存储和提取已经实例化的控件,避免多次调用findViewById() 。简化的源码如下:

public final class MainActivity extends AppCompatActivity {
   private HashMap _$_findViewCache;

   protected void onCreate(@Nullable Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      this.setContentView(1300023);
      TextView var10000 = (TextView)this._$_findCachedViewById(id.textView);
      var10000.setText((CharSequence)"Hello");
   }

   public View _$_findCachedViewById(int var1) {
      if (this._$_findViewCache == null) {
         this._$_findViewCache = new HashMap();
      }
      View var2 = (View)this._$_findViewCache.get(var1);
      if (var2 == null) {
         var2 = this.findViewById(var1);
         this._$_findViewCache.put(var1, var2);
      }
      return var2;
   }
}

那么在ViewHolder中,能不能也使用HashMap来缓存控件实例呢,答案是可以的。

KAE包含一些仍在实验性的功能,比如Kotlin1.1.4之后出现的LayoutContainer接口,就可以让KAE除了支持ActivityFragmentView以外,还能把任何类转换为Android Extensions容器,从而实现控件的直接使用及缓存。

这些实验性功能需要在build.gradle中启用实验模式:

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.3"

    defaultConfig {
        ...
    }
	...
    //打开实验性功能 (主要是为了启用LayoutContainer)    
    androidExtensions {
        experimental = true
    }
}

然后将ViewHolder实现LayoutContainer接口,即可以直接使用控件Id了:

class Adapter() : RecyclerView.Adapter<Adapter.Holder>() {

    //实现LayoutContainer接口
    inner class Holder(itemView: View) : RecyclerView.ViewHolder(itemView), LayoutContainer {
        override val containerView: View = this.itemView
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.view_index_title, parent, false)
        return Holder(view)
    }

    override fun onBindViewHolder(holder: Holder, position: Int) {
        //每次都会调用itemView.findViewById()
        holder.itemView.titleTextView.text = "wrong way"
        //初次调用findViewById()之后会缓存进HashMap里
        holder.titleTextView.text = "right way"
    }

    override fun getItemCount(): Int = 0

}

在上面的实现示例里,onBindViewHolder()中使用了两种调用方式,下面将代码转成Java并简化:

public void onBindViewHolder(@NotNull Adapter.Holder holder, int position) {
   View var10000 = holder.itemView;
   AppCompatTextView var3 = (AppCompatTextView)var10000.findViewById(id.titleTextView);
   var3.setText((CharSequence)"");
   var3 = (AppCompatTextView)holder._$_findCachedViewById(id.titleTextView);
   var3.setText((CharSequence)"");
}

可以看到,确实是使用缓存与否的区别,所以可以下出结论:

如果仍然选择KAE作为View绑定框架,那么在Adapter中使用时,请尽量避免直接使用holder.itemView.xxx的形式,而是接入LayoutContainer来实现,这样会有更好的性能表现。