ViewBinding的多个坑; 以及升级Kotlin 1.7到 Kotlin 1.9的过程

4,773 阅读6分钟

一. 背景

最近好多我们使用的SDK, 都要求Kotlin 1.8. 比如说我想升级Firebase RemoteConfig到v21+, 这样我就能使用Firebase RemoteConfig的real-time REmote Config的功能. 但一旦我在gradle里升了版本, 那就编译失败了, 具体出错如下:

Module was compiled with an incompatible version of Kotlin.
The binary version of its metadata is 1.9.0, 
// (1.9.0是新版本Firebase在用的版本)
expected version is 1.7.1  
// (1.7.1是我们工程正在用的版本)

image.png

这不是唯一要求我们升级kotlin的SDK, 我们还有多个SDK也要求我们升级Kotlin 1.7. 这样一来, 我们就到了不得不升级的地步了.

1. 困难

升级kotlin听起来没这么麻烦, 那从kotlin 1.7 升级到 kotlin 1.8+是个特例. 这是因为kotlin 1.8正式移除了kotlin-android-extensions(简称为KAE), 这个给我们带了很多麻烦.

2. 什么是KAE

KAE是多个组件的组合. 其中常见的有:

findViewById的替代

<TextView android:id="@+id/tvAccountName"

那你在kotlin中就可以直接用viewId来代替view, 就省了你声明view变量, 以及用findViewById来赋值的过程了. 这个能力就来自于KAE

import kotlinx.android.synthetic.main.activity_main

tvAccountName.text = "hello world"

Parcelable的简写

以前写Parcelable好麻烦, 要写如何封装, 又要写如何解封, 并且顺序上还不能错. 所以KAE中还有一个便利的工具, 我们只要一个annotation就搞定了:

@Parcelize
data class Account(....)

这样一来, 再也不用写CREATOR, writeToParcel()等重复代码了, 就解放了我们程序员的双手.

3. 困局的破解之道

对于Parcelize

那其实就是去app模块的build.gradle里, 修改一下:

// 从这个:
apply plugin: "kotlin-android-extensions"

// 改为这个: 
apply plugin "kotlin-parcelize"

我修改后的结果, 左侧为修改前, 右侧为修改后:

image.png

对于viewID

那没办法, 只好使用官方推荐的ViewBinding了.

开启ViewBinding很容易, 只要去app模块的build.gradle里开启:

android {
    ...
    buildFeatures {
        viewBinding true
    }
}

[小技巧]

经过我实操, KAE可以与ViewBinding同时存在, 所以我就让二者共存, 每次提交PR就提交一些文件, 这样免得整个"remove KAE"的PR显得太大, 而无法review.

到了最后, 完全没有KAE代码了, 再去删除KAE的plugin: image.png

二. ViewBinding的各种使用案例

这一块若是已经熟悉ViewBinding的同学可以跳过了, 不太熟练的同学可以看看如何在各种场景上使用ViewBinding.

Activity与Fragment

// Activity
class LauncherActivity : BaseActivity() {
    private lateinit var vb: ActivityLauncherBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        vb = ActivityLauncherBinding.inflate(layoutInflater)
        setContentView(vb.root)     
        
        vb.tvName = "hello"

Fragment则要复杂些. 因为Fragment可能被其它fragment给replace掉, 但Fragment不会被销毁(只是fragment的view被干掉了). 所以这时我们要注意清理掉vb, 免得内存泄露.

// Fragment
class CommonErrorFragment : BaseFragment() {


    private var vb: FragmentCommonErrorBinding? = null

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        super.onCreateView(inflater, container, savedInstanceState)
        vb = FragmentCommonErrorBinding.inflate(inflater)
        return vb?.root
    }
    
    override fun onDestroyView() {
        super.onDestroyView()
        vb = null
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        vb!!.tvName.text = "hello"

自定义View


class UserCircleView @JvmOverloads constructor(
        context: Context, 
        attrs: AttributeSet? = null, 
        defStyleAttr: Int = 0) :
    FrameLayout(context, attrs, defStyleAttr) {

    private lateinit var vb: UserCircleViewBinding

    init {
        vb = UserProfileCircleViewBinding.inflate(LayoutInflater.from(context), this, true)
        vb.btnAction.setOnClickListener {...}
    }

RecyclerView的ViewHolder

因为ViewHolder的构造函数得有一个view来填充itemView, 所以我们一开始就得已经初始化好了ViewBinding. 说人话, 就是一般是Adapter中声明好了ViewBinding, 然后把view传给ViewHolder

public class BonusAdapter extends RecyclerView.Adapter<BonusViewHolder>{
   @NonNull @Override
   public BonusVH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
      LayoutInflater inflater = LayoutInflater.from(parent.getContext());
      ItemBonusVhBinding binding = ItemBonusBinding.inflate(inflater, parent, false); // R.layout.item_bonus
      return new BonusViewHolder(binding);
   }

当然, 若你有多个itemType, 那你就可以有多个ViewBinding, 以及多个ViewHolder了.

三. ViewBinding的诸多坑

这第三部分就是主讲ViewBinding的诸多坑了. 无论你是ViewBinding新手, 还是也要和我一样从KAE转到ViewBinding来, 这些避坑经验应该都能帮到你.

坑1: ViewID的引用可能变化了

当你的layout xml长这样时:

<com.xx.MyTextView 
    android:id="@+id/tv_info" />

那在KAE时代里, 我们就可以直接用 tv_info_name.text = "hello"

但在ViewBinding时代, 这变了. 是的 , ViewBinding有一个隐藏的规则, 就是它会把snake case (如: tv_info_name) 转化为 camel case (如 : tvInfoName)

所以在转KAE为ViewBinding后, 正确的使用方法就成了:

vb.tvInfoName.text = "hello"

(备注: 我的ActivityMainBinding, FragmentMainBinding之类的变量, 我都声明成vb (ViewBinding的缩写). Android官方一般写成binding. 但我觉得binding名字太长, 不方便书写. 所以用vb就方便得多)

坑2: 不这么智能的Android Studio

我发现Android Studio (简称为AS)对ViewBinding的支持还是有些缺陷的.

以前我们修改一个layout xml的名字, AS会自动把用到这个layout xml的Activity也同样进行更改, 这样代码就不会编译失败.

但是在ViewBinding时代, 当我们修改一个layout xml名字时, 我发现Activity中的ViewBinding类型不会变化, 直接提示出错.

比如我把一个activity_main.xml改成了activity_home.xml, 结果AS就报错了:

image.png

解决方法自然是要我自己手动改. 但这明显是AS对这一块的支持还不够

坑3. <merge>引发的问题

若是你的一个自定义View, 它是这样写的:

class SomeView ... : FrameLayout(...) {
    init {
        LayoutInflater.from(context).inflate(R.layout.view_user_profile, this, true)
        ...
    }
        
}

其实就是说去inflate那个layout/view_user_profile.xml文件, 然后再把这个文件的根view给加到SomeView里来, 相当于是:

val layoutRoot = inflate(R.layout.view_use_profile)
this.add(layoutRoot);

这个转成ViewBinding也容易, 我们一般使用下面的代码就行:

    init {
        vb = UserProfileViewBinding.inflate(LayoutInflater.from(context), this, true)
   }

但是, 你可能会发现, 一些view是可行的. 但一些view, 会提示你说: 找不到inflate(inflater, parent, attachToParent) 方法!

这是什么原因呢?

<merge>的坑

仍以上面的SomeView为例, 若它的layout/view_user_profile.xml文件是这样的:

<ConstraintLayout >
    <TextView .../>
    <Button .../>

那没问题, 你的ViewBinding会有三参的inflate方法, 即会有inflate(inflater, parent, attachToParent) 方法.


但是, 要是layout/view_user_profile.xml文件是这样的:

<merge tools:parentTag="ConstraintLayout">
    <TextView .../>
    <Button .../>

那你就会"惊喜"地发现, 那个自动生成的ViewUserProfileBinding类, 竟然只有两参的inflate方法, 没有三参的inflater方法. 即:

// 有这方法
fun inflate(inflater, parent)

// 但没有下面的这个方法:
fun inflate(inflater, parent, attachToParent)

那这时我们要怎么办?

解决之法

其实去看下生成的ViewBinding源码就能很快知道解决之法了.

AS生成的ViewBinding源码在app模块的build目录里, 更具体地来说是在: $project/app/build/data_binding_base_class_source_out/$flavor/out/$package目录里:

image.png

它的二参inflate方法是:

image.png

而第65行的inflate方法是这样的:

image.png

所以只要我们的parent(也就是root参数不为空), 它其实就相当于inflate(layoutResId, parent, true)了.


总结下, 就是初始化一个ViewBinding类时:

  • 普通的view可以用: vb = UserProfileViewBinding.inflate(LayoutInflater.from(context), this, true)

  • root是<merge>的view可以用 : vb = AnotherViewBinding.inflate(LayoutInflater.from(context), this)

坑4. <include>的问题

当我的Activity的layout用了include:

// activity_main.xml里有:  
<include layout="@layout/view_loading" ... />

// view_loading.xml里有:  
<ProgressBar android:id="@+id/pbLoading"  ... />
  • 那在KAE时代, 我们可以直接用: pbLoding.isVisible = false

  • 但在ViewBinding时代, 我们使用vb.pbLoading.isVisible = false会报错, 说找不到pbLoading这样一个成员

解决之道

只要给<include>加一个id, 就可以用vb的链式调用来调用到被include的layou文件中的view了:

// activity_main.xml里有:  
<include android:id="@+id/myLoading"
    layout="@layout/view_loading" .../>

// view_loading.xml里有:  
<ProgressBar android:id="@+id/pbLoading" .../>

// kotlin里就要吧用:  
vb.myLoading.pbLoading.isVisible = false //pbLoading存在于另一xml里了

为何加个id就解决了

仍是去build目录里看源码,

image.png

从这就能看出来, 当给<include>加了id后, 这个id就会变成另一个ViewBinding类, 这样就解释了上面的做法为什么行得通了

坑5: 双layout的view

现在我有一个view, 要能适用grid mode与list mode.

image.png

这样一来, 这个View的构造函数就会成这样:

// List mode时  
LayoutInflater.from(context).inflate(R.layout.card_list, this@MyCardView, true)

// Grid mode时  
LayoutInflater.from(context).inflate(R.layout.card_grid, this@MyCardView, true)

那这种复杂的View, 能用ViewBinding吗?

: 我尝试了下, 能用. 就是要初始化两个ViewBinding, 分别对应上面两个不同的layout xml.

但总的来说, 使用起来有点太麻烦, 得用:

if(listMode) vb1.tvName.text = "hello"
else vb2.tvName.text = "hello"

经过实际操作后, 我发现这种多layout的view, 并不适合用ViewBinding. 还是老老实实用findViewById吧, 这样使用view时就方便得多:

    val tvName = this.findViewById<TextView>(R.id.tv_name)
    tvName.text = "hello"

四. 升级Kotlin的过程

4.1 升级kotlin

我现在是把kotlin 1.7.21升级到最新的kotlin 1.9.24, 想一步到位, 免得以后再升. 升级一下kotlin version是简单, 只要写成:

plugins {
    id 'com.android.application' version '8.1.0' apply false
    id 'org.jetbrains.kotlin.android' version '1.9.24' apply false
  • 第一个plugin是AGP (Android-Gradle-Plugin)

  • 第二个plugin就是Kotlin了. 我把版本从v1.7.21升级到了v1.9.24

4.2 加入ViewBinding

这个简单:

android {
    ...
    buildFeatures {
        viewBinding true
    }
}

4.3 删除KAE

上面的代码经过编译后会报错: image.png

以前其实也有这个提示, 不过级别是warning, 现在直接红色的error了. 不解决就不能编译的那种. 所以我们要把KAE(kotlin-android-extensions)插件给删除掉. 然后把KAE的使用全转为ViewBinding.

parcelize插件

备注: 若是还用了KAE中的parcelize功能, 那就还得再加上kotlin-parcelize插件

image.png

更新三方库

以前 Kotlin 1.7 + target33的情形下, 我们的引用里有:

    // core-ktx 1.9.0与Android 13 (API 33)更兼容;  版本再高如1.10.0就编译出错, 因为它需要kotlin 1.8版本
    implementation 'androidx.core:core-ktx:1.9.0'
    //targetSDK = 33后, 可以从1.4.1升到最新的1.6.1
    implementation 'androidx.appcompat:appcompat:1.6.1'
    // targetSDK = 33后, 从1.6.0可升到最新的1.9.0.    // 再也没这个编译错误了: "Can't determine type for tag '<macro name="m3_comp_assist_chip_container_shape">?attr/shapeAppearanceCornerSmall</macro>'"
    implementation 'com.google.android.material:material:1.9.0'

    // targetSDK = 33后, 版本不能是最新的1.7.1与1.6.0, 不然会因为kotlin-stdlib要使用1.8而编译不能
    implementation "androidx.activity:activity-ktx:1.5.0"
    implementation "androidx.fragment:fragment-ktx:1.5.0"

    // targetSDK = 33后, 不能高到2.6去, 不然会因为kotlin-stdlib要使用1.8而编译不能
    def lifecycle_version = "2.5.1"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"

现在kotlin 1.9 + target34 可以全部升级下这些jetpack库了:

    //Jetpack and Support
    implementation 'androidx.appcompat:appcompat:1.7.0'
    implementation 'androidx.core:core-ktx:1.13.1'
    implementation 'com.google.android.material:material:1.12.0'

    implementation "androidx.activity:activity-ktx:1.9.0"
    implementation "androidx.fragment:fragment-ktx:1.7.1"

    def lifecycle_version = "2.8.1"
    ....

解决其它Kotlin升级过程中的问题

升完后build就有多个类发生错误了. 不过仔细看一下, 就能发现其实各个类的报错类似, 主要就是下面红框标的两种错误

image.png

出错就是这种:

image.png

错误说明是:

Type argument is not within its bounds.  
Expected: Any  
Found: T

其实说白了, 这个错误就是以前<T>不加限制时就是表示T可以是任意对象. 但现在Kotlin 1.9里不是了, 你得明确指明这个T就是个Any的类型才可以.

所以正确的修复方法就是:

image.png

五. 总结

其实本文就是被迫升级kotlin 1.7到kotlin1.9过程中的诸多坑与解决方法. 包括泛型的修复, 包括删除KAE, 包括改用ViewBinding. 要是你也有如此需求, 希望本文对你有用.