日本免费高清视频-国产福利视频导航-黄色在线播放国产-天天操天天操天天操天天操|www.shdianci.com

學無先后,達者為師

網站首頁 編程語言 正文

一鍵移除ButterKnife并替換為ViewBinding的舊項目拯救_Android

作者:別摸我貓耳朵 ? 更新時間: 2023-03-30 編程語言

前言

眾所周知,黃油刀 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傳送門

原文鏈接:https://juejin.cn/post/7195074967275487269

欄目分類
最近更新