溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點(diǎn)擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

怎樣淺析ButterKnife

發(fā)布時間:2021-12-20 15:15:02 來源:億速云 閱讀:131 作者:柒染 欄目:大數(shù)據(jù)

怎樣淺析ButterKnife,針對這個問題,這篇文章詳細(xì)介紹了相對應(yīng)的分析和解答,希望可以幫助更多想解決這個問題的小伙伴找到更簡單易行的方法。

不管是Android開發(fā)的老司機(jī)也好,新司機(jī)也罷,想必大家都對findViewById這種樣板代碼感到了厭倦,特別是進(jìn)行復(fù)雜的UI界面開發(fā)的時候,這種代碼就會顯的非常的臃腫,既影響開發(fā)時的效率,又影響美觀。
俗話說,不想偷懶的程序猿不叫工程師,那有什么方法可以讓我們寫這樣的代碼更加的有效率呢?

使用依賴注入框架

如果你不想寫那些無聊的樣板代碼,那么你可以嘗試一下現(xiàn)有的依賴注入庫。ButterKnife作為Jake Wharton大神寫的開源框架,號稱在編譯期間就可以實現(xiàn)依賴注入,沒有用到反射,不會降低程序性能等。那么問題來了,它到底是怎么做到的呢?

初探ButterKnife

ButterKnife是Jake Wharton寫的開源依賴注入框架,它和Android Annotations比較類似,都是用到了Java Annotation Tool來在編譯期間生成輔助代碼來達(dá)到View注入的目的。

注解處理器是Java1.5引入的工具,它提供了在程序編譯期間掃描和處理注解的能力。它的原理就是在編譯期間讀取Java代碼,解析注解,然后動態(tài)生成Java代碼。下圖是Java編譯代碼的流程,可以看到,我們的注解處理器的工作在Annotation Processing階段,最終通過注解處理器生成的代碼會和源代碼一起被編譯成Java字節(jié)碼。不過比較遺憾的是你不能修改已經(jīng)存在的Java文件,比如在已經(jīng)存在的類中添加新的方法,所以通過Java Annotation Tool只能通過輔助類的方式來實現(xiàn)View的依賴注入,這樣會略微增加項目的方法數(shù)和類數(shù),不過只要控制好,不會對項目有太大的影響

怎樣淺析ButterKnife

ButterKnife在業(yè)務(wù)層的使用我就不介紹了,各位老司機(jī)肯定是輕車熟路。假如是我們自己寫類似于ButterKnife這樣的框架,那么我們的思路是這樣:定義注解,掃描注解,生成代碼。同時,我們需要用到以下這幾個工具:JavaPoet(你當(dāng)然可以直接用Java Annotation Tool,然后直接通過字符串拼接的方式去生成java源碼,如果你生無可戀的話),Java Annotation Tool以及APT插件。為了后續(xù)更好的閱讀ButterKnife的源碼,我們先來介紹一下JavaPoet的基礎(chǔ)知識。

JavaPoet生成代碼

JavaPoet是一個可以生成.java源代碼的開源項目,也是出自JakeWharton之手,我們可以結(jié)合注解處理器在程序編譯階段動態(tài)生成我們需要的代碼。先介紹一個使用JavaPoet最基本的例子:
怎樣淺析ButterKnife
其中:

  • MethodSpec:代表一個構(gòu)造函數(shù)或者方法聲明

  • TypeSpec:代表一個類、接口或者枚舉聲明

  • FieldSpec:代表一個成員變量聲明

  • JavaFile:代表一個頂級的JAVA文件

運(yùn)行結(jié)果:
怎樣淺析ButterKnife

是不是很神奇?我們的例子只是把生成的代碼寫到了輸出臺,ButterKnife通過Java Annotation Tool的Filer可以幫助我們以文件的形式輸出JAVA源碼。問:那如果我要生成下面這段代碼,我們會怎么寫?

怎樣淺析ButterKnife

很簡單嘛,依葫蘆畫瓢,只要把MethodSpec替換成下面這段:
怎樣淺析ButterKnife

然后代碼華麗的生成了:
怎樣淺析ButterKnife

唉,等等,好像哪里不對啊,生成代碼的格式怎么這么奇怪!難道我要這樣寫嘛:
怎樣淺析ButterKnife

這樣寫肯定是能達(dá)到我們的要求,但是未免也太麻煩了一點(diǎn)。其實JavaPoet提供了一個addStatement接口,可以自動幫我們換行以及添加分號,那么我們的代碼就可以寫成這個樣子:
怎樣淺析ButterKnife

生成的代碼:
怎樣淺析ButterKnife

好吧,其實格式也不是那么好看對不對?而且還要addStatement還需要夾雜addCode一起使用。為什么寫個for循環(huán)都這么難(哭泣臉)。其實JavaPoet早考慮到這個問題,它提供了beginControlFlow() + endControlFlow()兩個接口提供換行和縮進(jìn),再結(jié)合負(fù)責(zé)分號和換行的addStatement(),我們的代碼就可以寫成這樣子:
怎樣淺析ButterKnife
生成的代碼相當(dāng)?shù)捻樠郏?br/>怎樣淺析ButterKnife
其實JavaPoet還提供了很多有用的接口來幫我們更方便的生成代碼。更加詳細(xì)的用法請訪問https://github.com/square/javapoet,這里我就不贅述了。

Java Annotation Tool

那么ButterKnife又是怎么通過Java Annotation Tool來生成我們的輔助代碼呢?讓我們以ButterKnife最新版本8.4.0的源代碼為例。假如是我們自己寫ButterKnife這樣的框架,那么第一步肯定得先定義自己的注解。在ButterKnife源碼的butterknife-annotations包中,我們可以看到ButterKnife自定義的所有的注解,如下圖所示。
怎樣淺析ButterKnife
有了自定義注解,那我們的下一步就是實現(xiàn)自己的注解處理器了。我們結(jié)合ButterKnifeButterKnifeProcessor類來學(xué)習(xí)一下注解處理器的相關(guān)知識。為了實現(xiàn)自定義注解處理器,必須先繼承AbstractProcessor類。ButterKnifeProcessor通過繼承AbstractProcessor,實現(xiàn)了四個方法,如下圖所示:
怎樣淺析ButterKnife
怎樣淺析ButterKnife

  • init(ProcessingEnvironment env)
    通過輸入ProcessingEnvironment參數(shù),你可以在得到很多有用的工具類,比如Elements,Types,Filer等。
    Elements是可以用來處理Element的工具類,可以理解為Java Annotation Tool掃描過程中掃描到的所有的元素,比如包(PackageElement)、類(TypeElement)、方法(ExecuteableElement)等
    Types是可以用來處理TypeMirror的工具類,它代表在JAVA語言中的一種類型,我們可以通過TypeMirror配合Elements來判斷某個元素是否是我們想要的類型
    Filer是生成JAVA源代碼的工具類,能不能生成java源碼就靠它啦

  • getSupportedAnnotationTypes()
    代表注解處理器可以支持的注解類型,由前面的分析可以知道,ButterKnife支持的注解有BindViewOnClick等。

  • getSupportedSourceVersion()
    支持的JDK版本,一般使用SourceVersion.latestSupported(),這里使用Collections.singleton(OPTION_SDK_INT)也是可以的。

  • process(Set<? extends TypeElement> elements, RoundEnvironment env)
    process是整個注解處理器的重頭戲,你所有掃描和處理注解的代碼以及生成Java源文件的代碼都寫在這里面,這個也是我們將要重點(diǎn)分析的方法。

ButterKnifeProcessorprocess方法看起來很簡單,實際上做了很多事情,大致可以分為兩個部分:

  1. 掃描所有的ButterKnife注解,并且生成以TypeElement為Key,BindingSet為鍵值的HashMap。TypeElement我們在前面知道屬于類或者接口,BindingSet用來記錄我們使用JavaPoet生成代碼時的一些參數(shù),最終把該HashMap返回。這些邏輯對應(yīng)于源碼中的findAndParseTargets(RoundEnvironment env)方法

  2. 生成輔助類。輔助類以_ViewBinding為后綴,比如在MainActivity中使用了ButterKnife注解,那么最終會生成MainActivity_ViewBinding輔助類。MainActivity_ViewBinding類中最終會生成對應(yīng)于@BindView的findViewById等代碼。
    第一步,我們先來分析findAndParseTargets(RoundEnvironment env)源碼。由于方法太長,而且做的事情都差不多,我們只需要分析一小段即可

private Map<TypeElement, BindingClass> findAndParseTargets(RoundEnvironment env) {
    Map<TypeElement, BindingSet.Builder> builderMap = new LinkedHashMap<>();
    Set<TypeElement> erasedTargetNames = new LinkedHashSet<>();
    --- 省略部分代碼---

    for (Element element : env.getElementsAnnotatedWith(BindView.class)) {
      if (!SuperficialValidation.validateElement(element)) continue;
      try {
        //遍歷所有被BindView注解的類
        parseBindView(element, targetClassMap, erasedTargetNames);
      } catch (Exception e) {
        logParsingError(element, BindView.class, e);
      }
    }
    --- 省略部分代碼---

     // Try to find a parent binder for each.
    for (Map.Entry<TypeElement, BindingClass> entry : targetClassMap.entrySet()) {
      TypeElement parentType = findParentType(entry.getKey(), erasedTargetNames);
      if (parentType != null) {
        BindingClass bindingClass = entry.getValue();
        BindingClass parentBindingClass = targetClassMap.get(parentType);
        bindingClass.setParent(parentBindingClass);
      }
    }

    return targetClassMap;
  }

遍歷找到被注解的Element之后,通過parseBindView(Element element, Map<TypeElement, BindingSet.Builder> builderMap,Set<TypeElement> erasedTargetNames)方法去解析各個Element。在parseBindView方法中,首先會去檢測被注解的元素是不是View或者Interface,如果滿足條件則去獲取被注解元素的注解的值,如果相應(yīng)的的BindingSet.Builder沒有被綁定過,那么通過getOrCreateBindingBuilder方法生成或者直接從targetClassMap中獲取(為了提高效率,生成的BindingSet.Builder會被存儲在targetClassMap中)。getOrCreateBindingBuilder方法比較簡單,我就不貼代碼了,生成的BindingSet.Builder會記錄一個值binderClassName,ButterKnife最終會根據(jù)binderClassName作為輔助類的類名。

private void parseBindView(Element element, Map<TypeElement, BindingSet.Builder> builderMap,
      Set<TypeElement> erasedTargetNames) {
    TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();

    // Start by verifying common generated code restrictions.
    boolean hasError = isInaccessibleViaGeneratedCode(BindView.class, "fields", element)
        || isBindingInWrongPackage(BindView.class, element);

    // Verify that the target type extends from View.
    TypeMirror elementType = element.asType();
    --- 省略類型校驗邏輯的代碼---

     // 獲取注解的值
    int id = element.getAnnotation(BindView.class).value();

    BindingSet.Builder builder = builderMap.get(enclosingElement);
    if (builder != null) {
      ViewBindings viewBindings = builder.getViewBinding(getId(id));
      if (viewBindings != null && viewBindings.getFieldBinding() != null) {
        FieldViewBinding existingBinding = viewBindings.getFieldBinding();
        error(element, "Attempt to use @%s for an already bound ID %d on '%s'. (%s.%s)",
            BindView.class.getSimpleName(), id, existingBinding.getName(),
            enclosingElement.getQualifiedName(), element.getSimpleName());
        return;
      }
    } else {
       //如果沒有綁定過,那么通過該方法獲得對應(yīng)的builder并且返回。這里的targetClassMap會存儲已經(jīng)生成的builder,必要的時候提高效率   
      builder = getOrCreateBindingBuilder(builderMap, enclosingElement);
    }

    String name = element.getSimpleName().toString();
    TypeName type = TypeName.get(elementType);
    boolean required = isFieldRequired(element);

    builder.addField(getId(id), new FieldViewBinding(name, type, required));

    erasedTargetNames.add(enclosingElement);
  }

parseBindView以及findAndParseTargets的解析工作完成后,所有的解析結(jié)果都會存放在targetClassMap中作為結(jié)果返回。我們現(xiàn)在來看process第二步的處理過程:遍歷targetClassMap中所有的builder,并且通過Filer生成JAVA源文件。

---代碼省略---
 for (Map.Entry<TypeElement, BindingSet> entry : bindingMap.entrySet()) {
      TypeElement typeElement = entry.getKey();
      BindingSet binding = entry.getValue();

      JavaFile javaFile = binding.brewJava(sdk);
      try {
        javaFile.writeTo(filer);
      } catch (IOException e) {
        error(typeElement, "Unable to write binding for type %s: %s", typeElement, e.getMessage());
      }
    }

那么生成的代碼都長什么樣子呢?讓我們打開BindingSetbrewJava(int sdk)方法一探究竟。

  JavaFile brewJava(int sdk) {
    return JavaFile.builder(bindingClassName.packageName(), createType(sdk))
        .addFileComment("Generated code from Butter Knife. Do not modify!")
        .build();
  }

怎樣淺析ButterKnife
納尼,竟然這么簡單?我們觀察到JavaFile的靜態(tài)方法builder(String packageName, TypeSpec typeSpec)第二個參數(shù)為TypeSpec,前面提到過TypeSpec是JavaPoet提供的用來生成類的接口,打開createType(int sdk),霍霍,原來控制將要生成的代碼的邏輯在這里:

private TypeSpec createType(int sdk) {
     // 生成類名為bindingClassName的類
    TypeSpec.Builder result = TypeSpec.classBuilder(bindingClassName.simpleName())
        .addModifiers(PUBLIC);
     //ButterKnife的BindingSet初始化都是通過BindingSet的build方法初始化的,所以isFinal一般被初始化為false
    if (isFinal) {
      result.addModifiers(FINAL);
    }


    if (parentBinding != null) {
       //如果有父類的話,那么注入該子類的時候,也會順帶注入其父類
      result.superclass(parentBinding.bindingClassName);
    } else {
       //如果沒有父類,那么實現(xiàn)Unbinder接口(所以所有生成的輔助類都會繼承Unbinder接口)
      result.addSuperinterface(UNBINDER);
    }
     //增加一個變量名為target,類型為targetTypeName的成員變量
    if (hasTargetField()) {
      result.addField(targetTypeName, "target", PRIVATE);
    }

    if (!constructorNeedsView()) {
      // Add a delegating constructor with a target type + view signature for reflective use.
      result.addMethod(createBindingViewDelegateConstructor(targetTypeName));
    }
    //核心方法,生成***_ViewBinding方法,我們控件的綁定比如findViewById之類的方法都在這里生成
    result.addMethod(createBindingConstructor(targetTypeName, sdk));

    if (hasViewBindings() || parentBinding == null) {
     //生成unBind方法
      result.addMethod(createBindingUnbindMethod(result, targetTypeName));
    }

    return result.build();
  }

接下來讓我們看看核心語句createBindingConstructor*_ViewBinding方法內(nèi)到底干了什么:

private MethodSpec createBindingConstructor(TypeName targetType, int sdk) {
    //方法修飾符為PUBLIC,并且添加注解為UiThread
    MethodSpec.Builder constructor = MethodSpec.constructorBuilder()
        .addAnnotation(UI_THREAD)
        .addModifiers(PUBLIC);

    if (hasMethodBindings()) {
       //如果有OnClick注解,那么添加方法參數(shù)為targetType final target
      constructor.addParameter(targetType, "target", FINAL);
    } else {
       //如果沒有OnClick注解,那么添加方法參數(shù)為targetType target
      constructor.addParameter(targetType, "target");
    }

    if (constructorNeedsView()) {
       //如果有注解的View控件,那么添加View source參數(shù)
      constructor.addParameter(VIEW, "source");
    } else {
      //否則添加Context source參數(shù)
      constructor.addParameter(CONTEXT, "context");
    }

    if (hasUnqualifiedResourceBindings()) {
      constructor.addAnnotation(AnnotationSpec.builder(SuppressWarnings.class)
          .addMember("value", "$S", "ResourceType")
          .build());
    }

     //如果有父類,那么會根據(jù)不同情況調(diào)用不同的super語句
    if (parentBinding != null) {
      if (parentBinding.constructorNeedsView()) {
        constructor.addStatement("super(target, source)");
      } else if (constructorNeedsView()) {
        constructor.addStatement("super(target, source.getContext())");
      } else {
        constructor.addStatement("super(target, context)");
      }
      constructor.addCode("\n");
    }

     //如果有綁定過Field(不一定是View),那么添加this.target = target語句
    if (hasTargetField()) {
      constructor.addStatement("this.target = target");
      constructor.addCode("\n");
    }

    if (hasViewBindings()) {
      if (hasViewLocal()) {

        // Local variable in which all views will be temporarily stored.
        constructor.addStatement("$T view", VIEW);
      }
      for (ViewBindings bindings : viewBindings) {
        //View綁定的最常用,也是最關(guān)鍵的語句,生成類似于findViewById之類的代碼
        addViewBindings(constructor, bindings);
      }
      /**
       * 如果將多個view組成一個List或數(shù)組,然后進(jìn)行綁定,
       * 比如@BindView({ R.id.first_name, R.id.middle_name, R.id.last_name })
       * List<EditText> nameViews;會走這段邏輯
       */
      for (FieldCollectionViewBinding binding : collectionBindings) {
        constructor.addStatement("$L", binding.render());
      }

      if (!resourceBindings.isEmpty()) {
        constructor.addCode("\n");
      }
    }
---省略一些綁定resource資源的代碼---
}

addViewBindings我們簡單看看就好。需要注意的是:

  • 因為生成代碼時確實要根據(jù)不同條件來生成不同代碼,所以使用了CodeBlock.Builder接口。CodeBlock.Builder也是JavaPoet提供的,該接口提供了類似字符串拼接的能力

  • 生成了類似于target.fieldBinding.getName() = .findViewById(bindings.getId().code)或者target.fieldBinding.getName() = .findRequiredView(bindings.getId().code)之類的代碼,我們可以清楚的看到,這里沒有用到反射,所以被@BindView注解的變量的修飾符不能為private。

private void addViewBindings(MethodSpec.Builder result, ViewBindings bindings) {
  if (bindings.isSingleFieldBinding()) {
    // Optimize the common case where there's a single binding directly to a field.
    FieldViewBinding fieldBinding = bindings.getFieldBinding();
    /**
     * 這里使用了CodeBlock接口,顧名思義,該接口提供了類似字符串拼接的接口
     * 另外,從target.$L 這條語句來看,我們就知道為什么使用BindView注解的
     * 變量不能為private了
     */
    CodeBlock.Builder builder = CodeBlock.builder()
        .add("target.$L = ", fieldBinding.getName());

    boolean requiresCast = requiresCast(fieldBinding.getType());
    if (!requiresCast && !fieldBinding.isRequired()) {
      builder.add("source.findViewById($L)", bindings.getId().code);
    } else {
      builder.add("$T.find", UTILS);
      builder.add(fieldBinding.isRequired() ? "RequiredView" : "OptionalView");
      if (requiresCast) {
        builder.add("AsType");
      }
      builder.add("(source, $L", bindings.getId().code);
      if (fieldBinding.isRequired() || requiresCast) {
        builder.add(", $S", asHumanDescription(singletonList(fieldBinding)));
      }
      if (requiresCast) {
        builder.add(", $T.class", fieldBinding.getRawType());
      }
      builder.add(")");
    }
    result.addStatement("$L", builder.build());
    return;
  }

  List<ViewBinding> requiredViewBindings = bindings.getRequiredBindings();
  if (requiredViewBindings.isEmpty()) {
    result.addStatement("view = source.findViewById($L)", bindings.getId().code);
  } else if (!bindings.isBoundToRoot()) {
    result.addStatement("view = $T.findRequiredView(source, $L, $S)", UTILS,
        bindings.getId().code, asHumanDescription(requiredViewBindings));
  }

  addFieldBindings(result, bindings);
   // 監(jiān)聽事件綁定
  addMethodBindings(result, bindings);
}

addMethodBindings(result, bindings)實現(xiàn)了監(jiān)聽事件的綁定,也通過MethodSpec.Builder來生成相應(yīng)的方法,由于源碼太長,這里就不貼源碼了。

小結(jié):createType方法到底做了什么?

  • 生成類名為className_ViewBing的類

  • className_ViewBing實現(xiàn)了Unbinder接口(如果有父類的話,那么會調(diào)用父類的構(gòu)造函數(shù),不需要實現(xiàn)Unbinder接口)

  • 根據(jù)條件生成className_ViewBing構(gòu)造函數(shù)(實現(xiàn)了成員變量、方法的綁定)以及unbind方法(解除綁定)等

如果簡單使用ButterKnife,比如我們的MainActivity長這樣
怎樣淺析ButterKnife

那么生成的最終MainActivity_ViewBinding類的代碼就長下面這樣子,和我們分析源碼時預(yù)估的樣子差不多。
怎樣淺析ButterKnife

需要注意的是,Utils.findRequiredViewAsTypeUtils.findRequiredView、Utils.castView的區(qū)別。其實Utils.findRequiredViewAsType就是Utils.findRequiredView(相當(dāng)于findViewById)+Utils.castView(強(qiáng)制轉(zhuǎn)型,class類接口)。

  public static <T> T findRequiredViewAsType(View source, @IdRes int id, String who,Class<T> cls) {
    View view = findRequiredView(source, id, who);
    return castView(view, id, who, cls);
  }

MainActivity_ViewBinding類的調(diào)用過程就比較簡單了。MainActivity一般會調(diào)用ButterKnife.bind(this)來實現(xiàn)View的依賴注入,這個也是ButterKnife和Google親兒子AndroidAnnotations的區(qū)別:AndroidAnnotations不需要自己手動調(diào)用ButterKnife.bind(this)等類似的方法就可以實現(xiàn)View的依賴注入,但是讓人蛋疼的是編譯的時候會生成一個子類,這個子類是使用了AndroidAnnotations類后面加了一個_,比如MainActivity你就要使用MainActivity_來代替,比如Activity的跳轉(zhuǎn)就必須這樣寫:startActivity(new Intent(this,MyActivity_.class)),這兩個開源庫的原理基本差不多,哪種方法比較好看個人喜好去選擇吧。
言歸正傳,輔助類生成后,最終的調(diào)用過程一般是ButterKnife.bind(this)開始,查看ButterKnife.bind(this)源碼,最終會走到createBinding以及findBindingConstructorForClass這個方法中,源碼如下圖所示,這個方法就是根據(jù)你傳入的類名找到對應(yīng)的輔助類,最終通過調(diào)用constructor.newInstance(target, source)來實現(xiàn)View以及其他資源的綁定工作。這里需要注意的是在findBindingConstructorForClass使用輔助類的時候,其實是有用到反射的,這樣第一次使用的時候會稍微降低程序性能,但是ButterKnife會把通過反射生成的實例保存到HashMap中,下一次直接從HashMap中取上次生成的實例,這樣就極大的降低了反射導(dǎo)致的性能問題。當(dāng)然ButterKnife.bind方法還允許傳入其他不同的參數(shù),原理基本差不多,最終都會用到我們生成的輔助類,這里就不贅述了。
怎樣淺析ButterKnife
怎樣淺析ButterKnife

執(zhí)行注解處理器

注解處理器已經(jīng)有了,比如ButterKnifeProcessor,那么怎么執(zhí)行它呢?這個時候就需要用到android-apt這個插件了,使用它有兩個目的:

  1. 允許配置只在編譯時作為注解處理器的依賴,而不添加到最后的APK或library

  2. 設(shè)置源路徑,使注解處理器生成的代碼能被Android Studio正確的引用

這里把使用ButterKnifeandroid-apt的配置作為例子,在工程的build.gradle中添加android-apt插件

buildscript {
  repositories {
    mavenCentral()
   }
  dependencies {
    classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8'
  }
}

在項目的build.gradle中添加

apply plugin: 'android-apt'

android {
  ...
}

dependencies {
  compile 'com.jakewharton:butterknife:8.4.0'
  apt 'com.jakewharton:butterknife-compiler:8.4.0'
}

ButterKnife作為一個被廣泛使用的依賴注入庫,有很多優(yōu)點(diǎn):

  • 沒有使用反射,而是通過Java Annotation Tool動態(tài)生成輔助代碼實現(xiàn)了View的依賴注入,提升了程序的性能

  • 提高開發(fā)效率,減少代碼量

當(dāng)然也有一些不太友好的地方:

  • 會額外生成新的類和方法數(shù),主要是會加速觸及65535方法數(shù),當(dāng)然,如果App已經(jīng)有分dex了可以不用考慮

  • 也不是完全沒有用到反射,比如第一次調(diào)用ButterKnife.bind(this)語句使用輔助類的時候就用到了,會稍微影響程序的性能(但是也僅僅是第一次)

關(guān)于怎樣淺析ButterKnife問題的解答就分享到這里了,希望以上內(nèi)容可以對大家有一定的幫助,如果你還有很多疑惑沒有解開,可以關(guān)注億速云行業(yè)資訊頻道了解更多相關(guān)知識。

向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI