一键移除ButterKnife并替换为ViewBinding的旧项目拯救

前言

众所周知,黄油刀 ButterKnife 已经废弃了,并且已经不再维护了,而一些老项目估计还有一堆这样的代码,相信大家多多少少都有过被 @BindView 或者 @OnClick 支配的恐惧,而如果想要一个页面一个页面的移除的话,工作量也是非常大的,而这也是笔者写这个插件的原因了(这里不讲解插件开发的相关知识)。

注:由于每个项目的封装的多样性、以及 layout 布局的初始化有各种各样的写法,还有涉及到一些语法语义的联系,代码无法做到精准转换(后面会举一些例子),所以插件无法做到百分百转换成功,在转换后建议手动检查一下是否出错。

本文对于没有插件开发以及 PSI 基础的人可能会看不下去,可以直接 github传送门 跳 github 链接并 clone 代码运行,一键完成 ButterKnife 的移除并替换成 ViewBinding 。

支持的语言与类

目前仅支持 Java 语言,因为相信如果项目中使用的是 Kotlin ,那肯定首选 KAE 或者 ViewBinding 了(优选 ViewBinding ,如今 KAE 也已经被移除了)。

该插件中目前对不同的类有不同的转换方式

  • Activity、Fragment、自定义 View 是移除 ButterKnife 并转换成 ViewBinding
  • ViewHolder、Dialog 是移除 ButterKnife 并转换成 findViewById 形式

由于 Activity 与 Fragment 对于布局的塞入是比较统一的,所以可以做到比较精准的转换为 ViewBinding,而自定义 View 虽然布局的写法也各式各样,但是笔者也尽量修改统一了,而 ViewHolder 与 Dialog 比较复杂,直接修改成 findViewById 比较不容易出错(如果对自己的项目写法的统一很有信心的,也可以按照自己项目的写法试着修改一下代码,都改成 ViewBinding 会更好),毕竟谁也不希望修改后的代码一团糟是吧~

思路讲解

研究代码

首先我们需要研究一下使用了 ButterKnife 的代码是怎么样的,如果是自己使用过该插件的同学肯定是很了解、它的写法的,而对于笔者这种没使用过,但是公司的老项目中 java 的部分全是使用了 ButterKnife 的就很难受了,然后列出我们需要关心的注解。

  • @BindView:用于标记 xml 里的各种属性
  • @OnClick:用于标记 xml 中属性对应的点击事件
  • @OnLongClick:用于标记 xml 中属性对应的长按事件
  • @OnTouch:用于标记 xml 中属性对应的 touch 事件

这里不做过多讲解,毕竟又不是教大家怎么用 ButterKnife 是吧~

捋清思路

上面说到的相关注解是我们需要移除的,我们要针对我们转换的不同方式对这些注解标记的变量与方法做不同的操作。

  • 对于修改成 findViewById 形式的类,我们只需要记录下来该注解以及注解对应的变量或者方法名称,然后新增 initView() 方法用于初始化记录下来的变量,新增 initListener() 方法用于点击事件的编写。
  • 对于修改成 ViewBinding 形式的类,我们不仅需要记录该注解与对应的变量和方法,并且还需要遍历类中的全部代码,在检索到该标记的变量后,需要把这些变量都修改成 mBinding.xxx 的形式,注意:一般大家xml的id命名喜欢用_下划线,但是ViewBinding使用的使用是需要自动改成驼峰式命名的。

除此之外,我们需要移除的还有 ButterKnife 的 import 语句、绑定语句 bind()、以及解绑语句 unbind()。我们需要增加的有:layout 对应的 ViewBinding 类的初始化语句、import 语句。

了解完这些我们就可以开始写插件啦~

代码编写

对于代码的编写笔者这里也会分几个步骤去阐述:分别是 PSI 相关知识、文件处理、编写举例、注意事项。

PSI相关知识

PSI 的全称是 Program Structure Interface(程序结构接口),我们要分析代码以及修改代码的话,是离不开 PSI 的,文档传送门

一个 Class 文件结构分别包含字段表、属性表、方法表等,每个字段、方法也都有属性表,但在 PSI 中,总体上只有 PsiFilePsiElement

  • PsiFile 是一个接口,如果文件是一个 java 文件,那么解析生成的 PsiFile 就是 PsiJavaFile 对象,如果是一个 Xml 文件,则解析后生成的是 XmlFile 对象
  • 而对应 Java 文件的 PsiElement 种类有:PsiClass、PsiField、PsiMethod、PsiCodeBlock、PsiStatement、PsiMethodCallExpression 等等

其中,PsiJavaFile、PsiClass、PsiField、PsiMethod、PsiStatement 是我们本文涉及到的,大家可以先去看看文档了解一下。

文件处理

我们在选择多级目录的时候,会有很多的文件,而我们需要在这些文件中筛选出 java 文件,以及筛选出 import 语句中含有 butterknife 的,因为如果该类使用了 ButterKnife ,则肯定需要 import 相关的类。

筛选 java 文件的这部分代码在这里就不贴出来了,很简单的,大家可以直接去看代码就好。

判断该类是否需要进行 ButterKnife 移除处理:

/**
 * 检查是否有import butterknife相关,若没有引入butterknife,则不需要操作
 */
private fun checkIsNeedModify(): Boolean {
 val importStatement = psiJavaFile.importList?.importStatements?.find {
 it.qualifiedName?.lowercase(Locale.getDefault())?.contains("butterknife") == true
 }
 return importStatement != null
}

在这里需要先来一些前置知识,我们的插件在获取文件的的时候,拿到的是 VirtualFile,当该文件是java文件时,VirtualFile 可以通过 PSI 提供的api转换成 PsiJavaFile,然后我们可以通过 PsiFile 拿到 PsiClass,其中,importList 是属于 PsiFile 的,而上面说到那些 PsiElement 都是属于 PsiClass 的。

下面贴一下这部分代码:

private fun handle(vFile: VirtualFile) {
 if (vFile.isDirectory) {
 handleDirectory(vFile)
 } else {
 // 判断是否是java类型
 if (vFile.fileType is JavaFileType) {
 // 转换成psiFile
 val psiFile = PsiManager.getInstance(project!!).findFile(vFile)
 // 转换成psiClass
 val psiClass = PsiTreeUtil.findChildOfAnyType(psiFile, PsiClass::class.java)
 handleSingleVirtualFile(vFile, psiFile, psiClass)
 }
 }
}

这里只需要了解的就是添加了注释的那几行代码。

编写举例

我们需要对 PsiClass 进行分类,这里目前是只能按照大部分人对类的命名习惯来进行分析,如果有一些特殊的命名习惯的人,可以把代码 clone 下来自行修改一下再运行。

private fun checkClassType(psiClass: PsiClass) {
 val superType = psiClass.superClassType.toString()
 if (superType.contains("Activity")) {
 ActivityCodeParser(project, vFile, psiJavaFile, psiClass).execute()
 } else if (superType.contains("Fragment")) {
 FragmentCodeParser(project, vFile, psiJavaFile, psiClass).execute()
 } else if (superType.contains("ViewHolder") || superType.contains("Adapter<ViewHolder>")) {
 AdapterCodeParser(project, psiJavaFile, psiClass).execute()
 } else if (superType.contains("Adapter")) {
 // 这里的判断是为了不做处理,因为adapter的xml属性是在viewHolder中初始化的
 } else if (superType.contains("Dialog")) {
 DialogCodeParser(project, psiJavaFile, psiClass).execute()
 } else { 
 // 自定义View
 CustomViewCodeParser(project, vFile, psiJavaFile, psiClass).execute()
 }
}

我们通过拿到 PsiClass 继承的父类的类型来进行判断,这里的不足是代码中只拿了当前类的上一级继承的父类的类型,并没有去判断父类是否还有父类,因为笔者认为只要命名规范,这就不是什么大问题。举个例子,如果有人喜欢封装一个名为 BaseFragment 的实则是一个 Activity 的基类,然后由 MainActivity 去继承,那这个插件就不适用了😂

这里要注意的是,我们此时只是判断了外部类,而一个 class 中可能会有多个内部类,如 Adapter 中的 ViewHolder 就是一个很好的例子了,所以我们还需要遍历每一个 class 中的 innerClass,然后进行同样的操作:

// 内部类处理
psiClass.innerClasses.forEach {
 checkClassType(it)
}

由于涉及到的类别太多,所以这里只挑两个例子出来解释,分别是 ButterKnife 转换为 ViewBinding 的 Activity、ButterKnife 转换为 findViewById 的 ViewHolder,因为涉及到使用 PSI 分析并修改代码,为了方便统一分析管理,所以这里抽了个基类。

下面先来看一下基类中两个比较重要的方法,理解了这两个方法后面的代码才更容易理解: BaseCodeParser

private val bindViewFieldLists = mutableListOf<Pair<String, String>>() // 使用@BindView的属性与单个字段
private val bindViewListFieldLists = mutableListOf<Triple<String, String, MutableList<String>>>() // 使用@BindView的属性与多个字段
protected val innerBindViewFieldLists = mutableListOf<Pair<String, String>>() // 需要使用fvb形式的类 -- @BindView的属性与单个字段
/**
 * 遍历所有字段并找到@BindView注解
 * @param isDelete 是否删除@BindView注解的字段 true -> 删除字段 false -> 仅删除注解
 */
fun findBindViewAnnotation(isDelete: Boolean = true) {
 psiClass.fields.forEach {
 it.annotations.forEach { psiAnnotation ->
 // 找到了@BindView注解
 if (psiAnnotation.qualifiedName?.contains("BindView") == true) {
 // 判断该注解中的value个数,若为多个,则用另外的方式记录处理
 if ((psiAnnotation.findAttributeValue("value")?.text?.getAnnotationIds()?.size ?: 0) > 1) {
 val first = it.name
 val second = mutableListOf<String>()
 psiAnnotation.findAttributeValue("value")?.text?.getAnnotationIds()?.forEach { id ->
 second.add(id)
 }
 bindViewListFieldLists.add(Triple(it.type.toString(), first, second))
 writeAction{
 // 只删除注解,不删除字段
 psiAnnotation.delete()
 }
 } else {
 // 否则直接记录注解标记的变量名称与注解中的value,也就是xml中的id
 val first = it.name
 val second = psiAnnotation.findAttributeValue("value")?.lastChild?.text.toString()
 if (isDelete) {
 bindViewFieldLists.add(Pair(first, second))
 } else {
 innerBindViewFieldLists.add(Pair(first, second))
 }
 writeAction {
 if (isDelete) {
 it.delete()
 } else {
 psiAnnotation.delete()
 }
 }
 }
 }
 }
 }
}
/**
 * 遍历所有方法并找到@OnClick / @OnLongClick / @OnTouch注解
 */
fun findOnClickAnnotation() {
 psiClass.methods.forEach {
 it.annotations.forEach { psiAnnotation ->
 // 找到了被@OnClick或@OnLongClick或@OnTouch标记的方法
 if (psiAnnotation.qualifiedName?.contains("OnClick") == true || psiAnnotation.qualifiedName?.contains("OnLongClick")
 == true || psiAnnotation.qualifiedName?.contains("OnTouch") == true) {
 // 遍历该注解中的所有value并保存
 psiAnnotation.findAttributeValue("value")?.text?.getAnnotationIds()?.forEach { id ->
 var second = "${it.name}("
 // 获取该方法中的所有参数,跟方法名一起拼接起来,方便后面直接调用
 it.parameterList.parameters.forEachIndexed { index, params ->
 // 为了适配各种不同的命名,所以这里使用统一的命名
 // 因为这三个注解只会存在这几个类型的参数
 if (params.type.toString() == "PsiType:View") {
 second += "view"
 } else if (params.type.toString() == "PsiType:MotionEvent") {
 second += "event"
 }
 if (index != it.parameterList.parameters.size - 1) {
 second += ", "
 }
 }
 second += ")"
 if (psiAnnotation.qualifiedName?.contains("OnClick") == true) {
 onClickMethodLists.add(Pair(id, second))
 } else if (psiAnnotation.qualifiedName?.contains("OnLongClick") == true) {
 onLongClickMethodLists.add(Pair(id, second))
 } else if (psiAnnotation.qualifiedName?.contains("OnTouch") == true) {
 onTouchMethodLists.add(Pair(id, second))
 }
 }
 writeAction {
 // 删除@OnClick注解
 psiAnnotation.delete()
 }
 }
 }
 }
}
/**
 * 代码写入,修改的代码统一使用该方法进行修改写入
 */
private fun writeAction(commandName: String = "RemoveButterKnifeWriteAction", runnable: Runnable) {
 WriteCommandAction.runWriteCommandAction(project, commandName, "RemoveButterKnifeGroupID", runnable, psiJavaFile)
}

这里的代码可能会让人有点懵,下面来解释一下这些代码,先解释第一个方法:该方法是保存所有使用了 @BindView 注解标记的变量,可以看到代码中是分了 if else 去处理的,原因是有些代码的 @BindView 中的 value 只有一个,有些的会有多个,多个 value 的场景一般是使用 List 或者数组 Object[] 来进行修饰的,如下例子:

如果注解中只有单个 value,我们是可以直接改成 mBindind.xxx,而如果是 List 或者数组的形式的话,我们需要另外处理,这里笔者**使用的方式是记录一个变量若对应多个 xml 属性,则把这些属性都添加进该变量中,如 mTabViews.add(mBinding.xxx) **,要保证不影响原本的使用方式。

而第二个方法是保存所有使用了 @OnClick、@OnLongClick、@OnTouch 标记的方法,同上,多个属性的点击事件可能会是同一个方法,如下例子:

看完了基类的两个重要方法,下面我们来看一下对于我们的 Activity 要怎么转换:

ActivityCodeParser

class ActivityCodeParser(
 project: Project,
 private val vFile: VirtualFile,
 psiJavaFile: PsiJavaFile,
 private val psiClass: PsiClass
) : BaseCodeParser(project, psiJavaFile, psiClass) {
 init {
 findBindViewAnnotation()
 findOnClickAnnotation()
 }
 override fun findViewInsertAnchor() {
 // 找到onCreate方法
 val onCreateMethod = psiClass.findMethodsByName("onCreate", false)[0]
 onCreateMethod.body?.statements?.forEach { statement ->
 // 判断布局在哪个statement中,并拿到R.layout.后面的名字
 if (statement.text.trim().contains("R.layout.")) {
 val layoutRes = statement.text.trim().getLayoutRes()
 // 把布局名称转换成Binding实例名称。如activity_record_detail -> ActivityRecordDetailBinding
 val bindingName = layoutRes.underLineToHump().withViewBinding()
 val afterStatement = elementFactory.createStatementFromText(statement.text.toString().replace("R.layout.$layoutRes", "mBinding.getRoot()"), psiClass)
 // 以下四个方法都在基类BaseCodeParser中,后面再解释
 addBindingField("private $bindingName mBinding = $bindingName.inflate(getLayoutInflater());\n")
 addBindViewListStatement(onCreateMethod, statement)
 changeBindingStatement(onCreateMethod, statement, afterStatement)
 addImportStatement(vFile, layoutRes)
 }
 }
 // 遍历Activity中的所有方法并遍历方法中的所有statement
 psiClass.methods.forEach {
 it.body?.statements?.forEach { statement ->
 // 把所有原本使用@BindView标记的变量改为mBinding.xxx
 changeBindViewStatement(statement)
 }
 }
 // 内部类也可能使用外部类的变量
 psiClass.innerClasses.forEach {
 it.methods.forEach { method ->
 method.body?.statements?.forEach { statement ->
 changeBindViewStatement(statement)
 }
 }
 }
 }
 override fun findClickInsertAnchor() {
 // 在onCreate中添加initListener方法,并把保存下来的监听事件写入该方法中
 val onCreateMethod = psiClass.findMethodsByName("onCreate", false)[0]
 insertOnClickMethod(onCreateMethod)
 }
}

对于我们的 Activity,思路就是先找到 OnCreate() 方法,众所周知,Activity 的 layout 布局是写在 onCreate 中的 setContentView() 中的,所以我们需要找到这句 statement,拿到布局名称,再转换为驼峰式 + 首字母大写,并在后面加上 Binding,这就是 ViewBinding 给我们布局生成的类名称,不多做解释,熟悉使用 ViewBinding 的人都会清楚的。

这里需要注意的是,上面的写法只是常规的 layout 布局写法,还有一些项目喜欢自行封装的,比如喜欢把布局名称写在 getLayoutId() 中,然后在基类统一写成 setContentView(getLayoutId())。使用这种写法或者是其他封装方式的童鞋可以自行修改一下代码再运行,因为封装的方式太多了,这里无法做适配。

现在再来看一下上面未做解释的几个方法,首先来看一下 addBindingField() ,这是一个给class添加字段的方法:

val elementFactory = JavaPsiFacade.getInstance(project).elementFactory
/**
 * 添加mBinding变量
 */
protected fun addBindingField(fieldStr: String) {
 psiClass.addAfter(elementFactory.createFieldFromText(fieldStr, psiClass), psiClass.allFields.last())
}

elementFactory 是一个 PsiElementFactory 对象,用于创建 PsiElement,也就是上面所介绍的各种 PsiElement 。这里我们需要先创建一个 mBinding 变量,对于 Activity 我们可以直接通过 private bindingName mBinding = bindingName.inflate(getLayoutInflater()); 去实例化 mBinding 。

下面来看一下 addBindViewListStatement()

/**
 * 为使用这种形式的@BindViews({R.id.layout_tab_equipment, R.id.layout_tab_community, R.id.layout_tab_home})添加list
 */
protected fun addBindViewListStatement(psiMethod: PsiMethod, psiStatement: PsiStatement) {
 bindViewListFieldLists.forEachIndexed { index, triple ->
 writeAction {
 if (triple.first.contains("PsiType:List")) {
 psiMethod.addAfter(elementFactory.createStatementFromText("${triple.second} = new ArrayList<>();\n", psiClass), psiStatement)
 } else {
 psiMethod.addAfter(elementFactory.createStatementFromText("${triple.second} = new ${triple.first.substring(8, triple.first.length - 1)}${triple.third.size}];\n", psiClass), psiStatement)
 }
 psiMethod.body?.statements?.forEach { statement ->
 // 初始化变量并添加保存下来的所有xml属性
 if (statement.text.trim() == "${triple.second} = new ArrayList<>();" || statement.text.trim() == "${triple.second} = new ${triple.first.substring(8, triple.first.length - 1)}${triple.third.size}];") {
 triple.third.asReversed().forEachIndexed { index, name ->
 if (triple.first.contains("PsiType:List")) {
 psiClass.addAfter(elementFactory.createStatementFromText("${triple.second}.add(mBinding.${name.underLineToHump()});\n", psiClass), statement)
 } else {
 psiClass.addAfter(elementFactory.createStatementFromText("${triple.second}[${triple.third.size - 1 - index}] = mBinding.${name.underLineToHump()};\n", psiClass), statement)
 }
 }
 }
 }
 }
 }
}

上面的注释解释得很清楚,我们的 @BindView 可能会引用很多个 xml 属性,而该注解标记的字段可能是 List 也可能是数组,所以我们需要先判断该字段是属于哪种类型,并进行初始化。这里需要注意的是:在遍历添加字段的时候需要逆序添加,因为我们在添加一句 statement 的时候只有一个唯一参照物就是 new ArrayList<>() 或者是 new Objetc[] ,我们新添加的 statement 只能在这句代码后面添加,所以实际上添加完后的代码顺序是倒过来的,需要逆序。

接下来看一下 changeBindingStatement() 方法:

/**
 * 修改mBinding的初始化语句
 * @param method 需要修改的语句所在的方法
 * @param beforeStatement 修改前的语句
 * @param afterStatement 修改后的语句
 */
protected fun changeBindingStatement(method: PsiMethod, beforeStatement: PsiStatement, afterStatement: PsiStatement) {
 writeAction {
 method.addAfter(afterStatement, beforeStatement)
 beforeStatement.delete()
 }
}

这个方法没什么好说的,结合上面的使用,就是把原本的 setContentView(R.layout.xxx) 改成 setContentView(mBinding.getRoot()) 而已。

最后再来看一下 addImportStatement() 方法,这个方法是最复杂的,众所周知,我们在使用 ViewBinding 自动生成的类时需要导包,但是这个包的路径怎样才能得到呢?由于我们一个项目中肯定会有多个 module 以及多个目录,我们无法确定当前处理的文件所属的是哪个 module ,也无法确定当前 module 中使用的 xml 文件是否是别的 module 的(毕竟 xml 文件是可以跨 module 使用的),由于不确定性太多导致无法正确拿到该 Binding 类的包名路径进行导包,所以我们需要采取别的措施。

我们都知道在开启 ViewBinding 的开关的时候,我们每个 xml 都会自动生成对应的 Binding 类,位于 build/generated/data_binding_base_class_source_out/debug/out 目录中,这里我们只是带过,我们真正需要的文件不在这里,我们真正需要拿的是每个 Binding 类与所处的包名路径的映射文件,位于 build/intermediates/data_binding_base_class_log_artifact/debug/out 中的一个 json 文件,如下图所示:

而这个 json 文件只有在项目编译过后才会生成,我们也可以通过执行 task 去生成该文件,具体步骤后面会给出。

我们只需要解析这个 json 文件,然后通过上面拿到的 Binding 名称,再去拿对应的 module_package ,就能拿到当前的 Binding 类的路径了,最后再通过 import 语句直接导包就好了。思路给了,由于代码太长篇幅有限,有兴趣的可以直接去看代码~

接下来我们来看一下如何把原本使用 @BindView 标记的字段统一改成 mBinding.xxx 形式:

changeBindViewStatement

/**
 * 把原本使用@BindView的属性修改为mBinding.xxx
 * @param psiStatement 需要修改的statement
 */
protected fun changeBindViewStatement(psiStatement: PsiStatement) {
 var replaceText = psiStatement.text.trim()
 bindViewFieldLists.forEachIndexed { index, pair ->
 if (replaceText.isOnlyContainsTarget(pair.first) && !replaceText.isOnlyContainsTarget("R.id.${pair.first}")) {
 replaceText = replaceText.replace("\\b${pair.first}\\b".toRegex(), "mBinding.${pair.second.underLineToHump()}")
 }
 if (index == bindViewFieldLists.size - 1) {
 if (replaceText != psiStatement.text.trim()) {
 val replaceStatement = elementFactory.createStatementFromText(replaceText, psiClass)
 writeAction {
 psiStatement.addAfter(replaceStatement, psiStatement)
 psiStatement.delete()
 }
 }
 }
 }
}

当我们匹配到我们记录下来的字段以及对应的 xml 属性时,我们就把匹配到的 statement 中含有该匹配值的地方替换成 mBinding.xxx ,这里需要注意的是:要考虑相似的单词,如我们要匹配的是 view ,这时如果 statement 中含有 viewModel ,我们不能对它进行处理,所以笔者这里用到了正则去判断,对于项目中用到的一些方法都封装在 StringExpand 中,有兴趣的可以自行查看。

本来还想示例说明一下如何添加监听事件的,但是由于篇幅太长了,这里就不贴代码说明了,待会直接进传送门看吧~

好了,说完了 Activity 的处理,现在我们来看一下对于转换为 findViewById 的 ViewHolder 我们怎么处理吧~

class AdapterCodeParser(project: Project, psiJavaFile: PsiJavaFile, private val psiClass: PsiClass) : BaseCodeParser(project, psiJavaFile, psiClass) {
 init {
 findBindViewAnnotation(false)
 findOnClickAnnotation()
 }
 private var resultMethod: PsiMethod? = null
 private var resultStatement: PsiStatement? = null
 override fun findViewInsertAnchor() {
 findMethodByButterKnifeBind()
 val parameterName = findMethodParameterName()
 resultMethod?.let {
 innerBindViewFieldLists.forEach { pair ->
 resultStatement?.let { statement ->
 if (parameterName.isNotEmpty()) {
 addMethodStatement(it, statement, elementFactory.createStatementFromText("${pair.first} = $parameterName.findViewById(R.id.${pair.second});", psiClass))
 } else {
 addMethodStatement(it, statement, elementFactory.createStatementFromText("${pair.first} = itemView.findViewById(R.id.${pair.second});", psiClass))
 }
 }
 }
 }
 }
 /**
 * 找到ViewHolder构造函数的参数名称
 */
 private fun findMethodParameterName(): String {
 var parameterName = ""
 resultMethod?.let {
 it.parameterList.parameters.forEach { parameter ->
 if (parameter.type.toString() == "PsiType:View") {
 parameterName = parameter.name
 return@forEach
 }
 }
 }
 return parameterName
 }
 /**
 * 找到ButterKnife.bind的绑定语句所在的方法
 */
 private fun findMethodByButterKnifeBind() {
 run jump@{
 psiClass.methods.forEach { method ->
 method.body?.statements?.forEach { statement ->
 if (statement.text.trim().contains("ButterKnife.bind(")) {
 if (method.isConstructor) {
 resultMethod = method
 resultStatement = statement
 return@jump
 }
 }
 }
 }
 }
 }
 override fun findClickInsertAnchor() {
 val parameterName = findMethodParameterName()
 resultMethod?.let {
 if (parameterName.isNotEmpty()) {
 insertOnClickStatementByFVB(it, parameterName)
 } else {
 insertOnClickStatementByFVB(it, "itemView")
 }
 }
 }
}

我们首先是要找到 ViewHolder 中的 ButterKnife.bind 的绑定语句所处的位置,一般是处于构造函数中,然后我们需要拿到构造函数中参数类型为 View 的参数名称,因为有些人喜欢命名为 view ,有些人喜欢命名为 itemView ,所以我们要拿到参数名称后才可以添加 findViewById 语句,如 text = itemView.findViewById(R.id.text) ,这里还有一种别的情况就是构造函数里可能没有参数类型为 View 的参数,这时我们只需要统一使用 itemView 就可以了。

ViewHolder 的转换很简单,该解释的方法上面也解释了,没解释到的只能怪笔者太懒了😂,懒得贴那么多代码哈哈哈~

到这里我们已经看完了 ButterKnife 分别转换为 ViewBinding 、 findViewById 这两种形式的代表类了,最后需要注意的是我们要修改并删除完 ButterKnife 相关注解的时候,也要把相关的 ButterKnife.bind() 语句以及 import 语句删掉

/**
 * 删除ButterKnife的import语句、绑定语句、解绑语句
 */
private fun deleteButterKnifeBindStatement() {
 writeAction {
 psiJavaFile.importList?.importStatements?.forEach {
 if (it.qualifiedName?.lowercase()?.contains("butterknife") == true) {
 it.delete()
 }
 }
 psiClass.methods.forEach {
 it.body?.statements?.forEach { statement ->
 if (statement.text.trim().contains("ButterKnife.bind(")) {
 statement.delete()
 }
 }
 }
 val unBinderField = psiClass.fields.find {
 it.type.canonicalText.contains("Unbinder")
 }
 if (unBinderField != null) {
 psiClass.methods.forEach {
 it.body?.statements?.forEach { statement ->
 if (statement.firstChild.text.trim().contains(unBinderField.name)) {
 statement.delete()
 }
 }
 }
 unBinderField.delete()
 }
 }
}

注意事项

在前言说到的涉及到一些语法语义的联系,代码无法做到精准转换的时候说了后面会举例说明,这里举几个常见的例子:

  • 相关回调的参数名称与 xml 中的属性名称一样
@BindView(R.id.appBar)
AppBarLayout appBar;
@Override
protected void onCreate(Bundle savedInstanceState) {
 super.onCreate(savedInstanceState);
 ...
 appBar.addOnOffsetChangedListener((appBar, verticalOffset) -> {
 ...
 });
}

可以看到这里有两个 appBar ,一个是上面 @BindView 标记的 appBar ,另一个是回调监听中的参数,所以这里会不可避免的把两个 appBar 都修改成 mBinding.xxx ,但是在修改回调参数的 appBar 时,这个类会报错,所以后面在查看出错的类时会看到这个错误。这种情况可以通过修改回调参数的名称解决,修改之后再重新执行一次就可以了。

  • @BindView 标记的字段是 layout 中某个自定义 View 里的 xml 属性

这个就不贴代码举例子了,总的来说就是假设 MainActivity 中的布局是 activity_main ,该布局中含有一个 CustomView ,而 CustomView 中有一个布局 layout_custom_view ,而 layout_custom_view 中有一个 TextView 的 id 是 tv_content ,而这个 tv_content 是可以通过 ButterKnife 直接在 MainActivity 中使用的,但是修改成 ViewBinding 之后是拿不到这个 mBinding.tvContent 的(不知道我这么说大家能不能理解😂)

  • Activity 中通过 if else 判断 setContentView 需要塞入哪个布局
@Override
protected void onCreate(Bundle savedInstanceState) {
 super.onCreate(savedInstanceState);
 ...
 if (xxx > 0) {
 setContentView(R.layout.layout1);
 } else {
 setContentView(R.layout.layout2);
 }
 }

这种情况真的是不知道该实例化哪个 Binding 类,还是老老实实的手动修改成 findViewById 吧。

使用步骤

  • 在项目中开启 ViewBinding
android {
 viewBinding {
 enabled = true
 }
 }
  • 生成 ViewBinding 相关的类

在项目目录下执行 ./gradlew dataBindingGenBaseClassesDebug 生成 ViewBinding 相关的类与映射文件

  • 执行代码转换

右键需要转换的文件目录(支持单个文件操作或多级目录操作),点击 RemoveButterKnife 开始转换,如果文件很多的话需要等待的时候会久一点。

  • 等待执行结果

结果如下所示,有异常的文件可以手动检查并自行解决。

注意:转换完之后一定一定一定要检查一遍,最好打包让测试也重新测一遍!!!

github传送门

作者:别摸我猫耳朵

%s 个评论

要回复文章请先登录注册