Simple analysis of Butter Knife source

1. Introduction to Butter Knife

Butter Knife is an open source dependency injection framework that primarily serves as a syntax sugar effect, such as Button button = (Button) findViewById(R.id.button);, which can be simplified to @BindView(R.id.button) Button button;. Here Obviously, you can see that this is done with annotations. As to whether they are compile-time or run-time annotations, the analysis will begin below. The version used here is 8.6.0. For simplicity, this article only analyzes the injection of View.

2. Injection Principle Analysis

Here is an activity

public class LoginActivity extends Activity {
    @BindView(R.id.loginButton)
    Button button;
    
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.login_layout);
        ButterKnife.bind(this);
    }
    ......
}

So how was it injected? Feeling should be related to ButterKnife.bind(this);
The method is as follows

@NonNull @UiThread
public static Unbinder bind(@NonNull Activity target) {
    View sourceView = target.getWindow().getDecorView();
    return createBinding(target, sourceView);
}

Continue Point Open createBinding

private static Unbinder createBinding(@NonNull Object target, @NonNull View source) {
    Class<?> targetClass = target.getClass();
    if (debug) Log.d(TAG, "Looking up binding for " + targetClass.getName());
    Constructor<? extends Unbinder> constructor = findBindingConstructorForClass(targetClass);

    if (constructor == null) {
      return Unbinder.EMPTY;
    }

    //noinspection TryWithIdenticalCatches Resolves to API 19+ only type.
    try {
      return constructor.newInstance(target, source);
    } catch (IllegalAccessException e) {
      throw new RuntimeException("Unable to invoke " + constructor, e);
    } catch (InstantiationException e) {
      throw new RuntimeException("Unable to invoke " + constructor, e);
    } catch (InvocationTargetException e) {
      Throwable cause = e.getCause();
      if (cause instanceof RuntimeException) {
        throw (RuntimeException) cause;
      }
      if (cause instanceof Error) {
        throw (Error) cause;
      }
      throw new RuntimeException("Unable to create binding instance.", cause);
    }
}

Simplify and that's it

private static Unbinder createBinding(Object target, View source) {
    Class<?> targetClass = target.getClass();
    Constructor<? extends Unbinder> constructor = findBindingConstructorForClass(targetClass);
    
    if (constructor == null) {
      return Unbinder.EMPTY;
    }
    return constructor.newInstance(target, source);
    // Exception handling...
}

Unbinder Is an interface, Declare as follows

public interface Unbinder {
  @UiThread void unbind();

  Unbinder EMPTY = new Unbinder() {
    @Override public void unbind() { }
  };
}

Probably the logic is to find the constructor of the class to bind and use reflection to create an instance, where the declaration is Constructor<? Extends Unbinder>, so an Unbinder instance is returned. If no constructor is found, a new Unbinder is returned.

Continue Point Open findBindingConstructorForClass

@Nullable @CheckResult @UiThread
private static Constructor<? extends Unbinder> findBindingConstructorForClass(Class<?> cls) {
    Constructor<? extends Unbinder> bindingCtor = BINDINGS.get(cls);
    if (bindingCtor != null) {
      if (debug) Log.d(TAG, "HIT: Cached in binding map.");
      return bindingCtor;
    }
    String clsName = cls.getName();
    if (clsName.startsWith("android.") || clsName.startsWith("java.")) {
      if (debug) Log.d(TAG, "MISS: Reached framework class. Abandoning search.");
      return null;
    }
    try {
      Class<?> bindingClass = cls.getClassLoader().loadClass(clsName + "_ViewBinding");
      //noinspection unchecked
      bindingCtor = (Constructor<? extends Unbinder>) bindingClass.getConstructor(cls, View.class);
      if (debug) Log.d(TAG, "HIT: Loaded binding class and constructor.");
    } catch (ClassNotFoundException e) {
      if (debug) Log.d(TAG, "Not found. Trying superclass " + cls.getSuperclass().getName());
      bindingCtor = findBindingConstructorForClass(cls.getSuperclass());
    } catch (NoSuchMethodException e) {
      throw new RuntimeException("Unable to find binding constructor for " + clsName, e);
    }
    BINDINGS.put(cls, bindingCtor);
    return bindingCtor;
  }

Simplify a little

private static Constructor<? extends Unbinder> findxxx(Class<?> cls) {
    // First look in the cache
    Constructor<? extends Unbinder> bindingCtor = BINDINGS.get(cls);
    if (bindingCtor != null) { return bindingCtor; }
    
    // Not in Cache
    String clsName = cls.getName();
    // Is it a file in the android source?
    if (clsName.startsWith("android.") || clsName.startsWith("java.")) {
      if (debug) Log.d(TAG, "MISS: Reached framework class. Abandoning search.");
      return null;
    }
    try {
      Class<?> bindingClass = 
                    cls.getClassLoader().loadClass(clsName + "_ViewBinding");
      bindingCtor = (Constructor<? extends Unbinder>)
                    bindingClass.getConstructor(cls, View.class);
    } catch (ClassNotFoundException e) {
      // Continue to look in the parent class
      bindingCtor = findBindingConstructorForClass(cls.getSuperclass());
    }
    // exception handling
    
    // Put in Cache
    BINDINGS.put(cls, bindingCtor);
    return bindingCtor;
  }

BINDINGS Is a map, Declare as follows

static final Map<Class<?>, Constructor<? extends Unbinder>> BINDINGS = 
       new LinkedHashMap<>();

Acts as a constructor for the cache class

So the main process for findBindingConstructorForClass is as follows:

  1. Find the constructor for a class in the cache

    1. Has->Returns the constructor

    2. No - > Rotate 2

  2. Is it a file in the android source?

    1. Yes->Return null

    2. No - > Rotate 3

  3. Gets the constructor for a class named clsName +'_ViewBinding'

  4. If an exception occurs, the parameter is replaced by a parent class, returning 1

  5. Cache the constructor

  6. Return Constructor

There are three things to note here:

  1. Maps are used as caches to speed up the process because the efficiency of reflection is low and mobile devices require high performance

  2. The reason to add step 2 is to look in the parent class, possibly in the android source.

  3. The constructor you've been looking for for for a long time here is not an incoming class. (Ah, isn't that nonsense), it's a clsName_ViewBinding class. In this case, it should be called LoginActivity_ViewBinding.java

Eventually we found something related to View injection. Now there's a question, what is it?
In intellij idea, you can find this file by using the ctrl + shift + n key combination as follows: app\build\generatedsource\aptdebug\...LoginActivity_ViewBinding.java

public class LoginActivity_ViewBinding implements Unbinder {
  private LoginActivity target;
  private View view2131558546;

  @UiThread
  public LoginActivity_ViewBinding(LoginActivity target) {
    this(target, target.getWindow().getDecorView());
  }

  @UiThread
  public LoginActivity_ViewBinding(final LoginActivity target, View source) {
    this.target = target;

    View view;
    // findRequiredView uses findViewById to find view
    view = Utils.findRequiredView(source, R.id.loginButton, "field 'button' and method 'login'");
    // Use cls.cast(view) in castView; force view to button
    target.button = Utils.castView(view, R.id.loginButton, "field 'button'", Button.class);
    view2131558546 = view;

    // other
  }

  @Override
  @CallSuper
  public void unbind() {
    LoginActivity target = this.target;
    if (target == null) throw new IllegalStateException("Bindings already cleared.");
    this.target = null;
    
    target.button = null;
    view2131558546 = null;
  }
}

Let's do it now

  1. The constructor.newInstance(target, source) that was called earlier; in fact, the LoginActivity_ViewBinding(final LoginActivity target, View source) was called, and the returned Unbinder instance is also the LoginActivity_ViewBinding instance, so the initial ButterKnife.bind(this); the return value of the method is this instance, which has only one method, unbind(), to undo whenBinding of the previous activity.

  2. In the construction method, find the corresponding view with findViewById and convert it to a button, then assign it directly to target.button, so when using Butter Knife, the annotated content cannot be private, otherwise the member variable will not be taken.

So the way Butter Knife implements Dependent Injection is by generating an extra class, writing findViewById and other methods in its constructor that we have been lazy about writing, and adding a unbound method.
ButterKnife.bind(xxx) is called; when a method generates an instance of an additional class using reflection, the binding is complete.

Now that we have explained how to implement Dependent Injection, let's look at how this class is generated.

3. Generation of auxiliary classes

1. Information Construction

Since additional auxiliary class generation is required, it is certainly done with compile-time annotations. Looking directly at the definition of BindView, you will find that it is CLASS-level, so of course it is handled with an annotation processor. For compile-time annotations, see Previous It mentions the next important element s.

Looking at the source code for Butter Knife on github, you can see that the directory structure is as follows

  • butterknife // The code analyzed above is all here

  • butterknife-annotations //custom annotations

  • butterknife-compiler //comment handling

  • butterknife-gradle-plugin // gradle plugin

  • butterknife-lint // lint check

Under the butterknife-compiler package is the ButterKnifeProcessor.java file, which is the annotation processor we are looking for.

The class declaration is as follows: public final class ButterKnifeProcessor extends AbstractProcessor
Let's start with the init() method

private Elements elementUtils; // Elementary auxiliary class
private Types typeUtils; // Auxiliary classes for manipulating TypeMirror
private Filer filer; // File Operation Auxiliary Class
@Override public synchronized void init(ProcessingEnvironment env) {
    super.init(env);

    String sdk = env.getOptions().get(OPTION_SDK_INT);
    if (sdk != null) {
      try {
        this.sdk = Integer.parseInt(sdk);
      } catch (NumberFormatException e) {
        env.getMessager()
            .printMessage(Kind.WARNING, "Unable to parse supplied minSdk option '"
                + sdk
                + "'. Falling back to API 1 support.");
      }
    }

    elementUtils = env.getElementUtils();
    typeUtils = env.getTypeUtils();
    filer = env.getFiler();
    try {
      trees = Trees.instance(processingEnv);
    } catch (IllegalArgumentException ignored) {
    }
}

You can see that some auxiliary classes are primarily initialized.

Next, look at the most important process methods

@Override public boolean process(Set<? extends TypeElement> elements, RoundEnvironment env) {
    // Resolve each comment
    // TypeElement represents an element at the class or interface level
    // BindingSet is a custom class that represents a collection of information for all elements that need to be bound
    Map<TypeElement, BindingSet> bindingMap = findAndParseTargets(env);

    for (Map.Entry<TypeElement, BindingSet> entry : bindingMap.entrySet()) {
      TypeElement typeElement = entry.getKey();
      BindingSet binding = entry.getValue();
      
      // Generate a java file using javapoet, which is xxx_ViewBinding.java
      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());
      }
    }
    return false;
}

Use here javapoet For code generation, this is another open source code generation framework written by the same author.
Regardless of this, look down at the findAndParseTargets implementation

private Map<TypeElement, BindingSet> findAndParseTargets(RoundEnvironment env) {
    // The key of this map is an element of a class or interface type, and the value is the corresponding builder, which stores some information about the auxiliary classes to be generated
    Map<TypeElement, BindingSet.Builder> builderMap = new LinkedHashMap<>();
    Set<TypeElement> erasedTargetNames = new LinkedHashSet<>();
    scanForRClasses(env);
    
    // Handling of other notes    

    // Handle each Element labeled with a BindView annotation, where the Element is Field level
    for (Element element : env.getElementsAnnotatedWith(BindView.class)) {
      try {
        parseBindView(element, builderMap, erasedTargetNames);
      } catch (Exception e) {
        logParsingError(element, BindView.class, e);
      }
    }
    
    // Associate superclass binders with their subclass binders. This is a queue-based tree walk
    // which starts at the roots (superclasses) and walks to the leafs (subclasses).
    Deque<Map.Entry<TypeElement, BindingSet.Builder>> entries =
        new ArrayDeque<>(builderMap.entrySet());
    Map<TypeElement, BindingSet> bindingMap = new LinkedHashMap<>();
    while (!entries.isEmpty()) {
      Map.Entry<TypeElement, BindingSet.Builder> entry = entries.removeFirst();

      TypeElement type = entry.getKey();
      BindingSet.Builder builder = entry.getValue();

      TypeElement parentType = findParentType(type, erasedTargetNames);
      if (parentType == null) {
        bindingMap.put(type, builder.build());
      } else {
        BindingSet parentBinding = bindingMap.get(parentType);
        if (parentBinding != null) {
          builder.setParent(parentBinding);
          bindingMap.put(type, builder.build());
        } else {
          // Has a superclass binding but we haven't built it yet. Re-enqueue for later.
          entries.addLast(entry);
        }
      }
    }    

    return bindingMap;
}

The main logic is written in parseBindView.

private void parseBindView(Element element, Map<TypeElement, BindingSet.Builder> builderMap, Set<TypeElement> erasedTargetNames) {
    // Get the parent element, which is generally an activity class
    TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();

    // Check if the element is reachable (private? public? Written in the class member variable?) or not in the wrong package (Android source?)
    boolean hasError = isInaccessibleViaGeneratedCode(BindView.class, "fields", element) || isBindingInWrongPackage(BindView.class, element);

    // Type checking omitted here

    if (hasError) {
      return;
    }

    // Get the id of the view
    int id = element.getAnnotation(BindView.class).value();
    // Check the cache for this activity
    BindingSet.Builder builder = builderMap.get(enclosingElement);
    // QualifiedId is the id with the information about the package name where the activity is located
    QualifiedId qualifiedId = elementToQualifiedId(element, id);
    // Check if this id is already bound
    if (builder != null) {
      String existingBindingName =
              builder.findExistingBindingName(getId(qualifiedId));
      // Return directly if no null indicates that it has been bound
      if (existingBindingName != null) {
        error(element, "Attempt to use @%s for an already bound ID %d on '%s'. (%s.%s)", BindView.class.getSimpleName(), id, existingBindingName, enclosingElement.getQualifiedName(), element.getSimpleName());
        return;
      }
    } else {
      // If builder does not exist, create a new one and place it in the map
      builder = getOrCreateBindingBuilder(builderMap, enclosingElement);
    }
    
    String name = simpleName.toString();
    TypeName type = TypeName.get(elementType);
    boolean required = isFieldRequired(element);
    
    // Add new field information to the builder, that is, information about bindings
    builder.addField(getId(qualifiedId), new FieldViewBinding(name, type, required));

    // Add the type-erased version to the valid binding targets set.
    erasedTargetNames.add(enclosingElement);
  }

What's more important here is the builder creation process, getOrCreateBindingBuilder

private BindingSet.Builder getOrCreateBindingBuilder(
      Map<TypeElement, BindingSet.Builder> builderMap, TypeElement enclosingElement) {
    BindingSet.Builder builder = builderMap.get(enclosingElement);
    if (builder == null) {
      // Pass in parent element, class information to create builder
      builder = BindingSet.newBuilder(enclosingElement);
      builderMap.put(enclosingElement, builder);
    }
    return builder;
}

Here's BindingSet.newBuilder

static Builder newBuilder(TypeElement enclosingElement) {
    // Detailed type information for elements can be obtained using typemirror
    TypeMirror typeMirror = enclosingElement.asType();

    boolean isView = isSubtypeOfType(typeMirror, VIEW_TYPE);
    boolean isActivity = isSubtypeOfType(typeMirror, ACTIVITY_TYPE);
    boolean isDialog = isSubtypeOfType(typeMirror, DIALOG_TYPE);

    TypeName targetType = TypeName.get(typeMirror);
    if (targetType instanceof ParameterizedTypeName) {
      targetType = ((ParameterizedTypeName) targetType).rawType;
    }
    
    String packageName = getPackage(enclosingElement).getQualifiedName().toString();
    String className = enclosingElement.getQualifiedName().toString().substring(
        packageName.length() + 1).replace('.', '$');
    // Here is the syntax for javapoet, and the last parameter is the class name, which is the xxx_ViewBinding we are looking for
    ClassName bindingClassName = ClassName.get(packageName, className + "_ViewBinding");

    boolean isFinal = enclosingElement.getModifiers().contains(Modifier.FINAL);
    return new Builder(targetType, bindingClassName, isFinal, isView, isActivity, isDialog);
}

To sum up, for each element of the @BindView annotation, the following process occurs

  1. Gets the parent element of this element, typically an activity

  2. Legality check

  3. Check if there is a value for the parent element in the builderMap (builerMap for caching, key for elements of class or interface type, value for builder, builder for storing the class name of the auxiliary class to be generated, elements that need to be bound such as field, and some other information)

  4. If not, create a new key-value pair, and the builder contains some information about the class itself, such as the class name, is it final, is it active, and so on?

  5. If so, get the id and check if it is bound, which will cause an error

  6. Add information about this element to the resulting builder that generates code for finding and assigning values in the constructor of xxx_ViewBinding.

  7. The builderMap undergoes some processing (adjustment of parent-child class relationships, etc.) and eventually becomes a BindingSet, which is the Map <TypeElement, BindingSet> bindingMap that we saw at the beginning

This completes the construction of class information, and the last step, code generation

2. Code Generation

Code generation is like this: JavaFile javaFile = binding.brewJava(sdk); the resulting javaFile contains information about the entire class, point into brewJava to see.

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

The important thing here is createType

// Use the information in the bindingset to generate the final auxiliary class
private TypeSpec createType(int sdk) {
    // Add public modifiers
    // TypeSpec is used to generate classes
    TypeSpec.Builder result = TypeSpec.classBuilder(bindingClassName.simpleName())
        .addModifiers(PUBLIC);
    // Is it final?
    if (isFinal) {
      result.addModifiers(FINAL);
    }
   
    if (parentBinding != null) {
      result.superclass(parentBinding.bindingClassName);
    } else {
      // The code that inherits Unbinder is here
      result.addSuperinterface(UNBINDER);
    }

    if (hasTargetField()) {
      result.addField(targetTypeName, "target", PRIVATE);
    }
    
    // Create corresponding constructor
    if (isView) {
      result.addMethod(createBindingConstructorForView());
    } else if (isActivity) {
      result.addMethod(createBindingConstructorForActivity());
    } else if (isDialog) {
      result.addMethod(createBindingConstructorForDialog());
    }
    if (!constructorNeedsView()) {
      // Add a delegating constructor with a target type + view signature for reflective use.
      result.addMethod(createBindingViewDelegateConstructor());
    }
    result.addMethod(createBindingConstructor(sdk));

    if (hasViewBindings() || parentBinding == null) {
      result.addMethod(createBindingUnbindMethod(result));
    }

    return result.build();
}

Here is the generation of the constructor

private MethodSpec createBindingConstructor(int sdk) {
    MethodSpec.Builder constructor = MethodSpec.constructorBuilder()
        .addAnnotation(UI_THREAD)
        .addModifiers(PUBLIC);

    if (hasMethodBindings()) {
      constructor.addParameter(targetTypeName, "target", FINAL);
    } else {
      constructor.addParameter(targetTypeName, "target");
    }

    if (constructorNeedsView()) {
      constructor.addParameter(VIEW, "source");
    } else {
      constructor.addParameter(CONTEXT, "context");
    }

    if (hasUnqualifiedResourceBindings()) {
      // Aapt can change IDs out from underneath us, just suppress since all will work at runtime.
      constructor.addAnnotation(AnnotationSpec.builder(SuppressWarnings.class)
          .addMember("value", "$S", "ResourceType")
          .build());
    }

    if (hasOnTouchMethodBindings()) {
      constructor.addAnnotation(AnnotationSpec.builder(SUPPRESS_LINT)
          .addMember("value", "$S", "ClickableViewAccessibility")
          .build());
    }

    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");
    }
    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 (ViewBinding binding : viewBindings) {
        addViewBinding(constructor, binding);
      }
      for (FieldCollectionViewBinding binding : collectionBindings) {
        constructor.addStatement("$L", binding.render());
      }

      if (!resourceBindings.isEmpty()) {
        constructor.addCode("\n");
      }
    }

    if (!resourceBindings.isEmpty()) {
      if (constructorNeedsView()) {
        constructor.addStatement("$T context = source.getContext()", CONTEXT);
      }
      if (hasResourceBindingsNeedingResource(sdk)) {
        constructor.addStatement("$T res = context.getResources()", RESOURCES);
      }
      for (ResourceBinding binding : resourceBindings) {
        constructor.addStatement("$L", binding.render(sdk));
      }
    }

    return constructor.build();
}

Because the generated code is very similar, pick addViewBinding here; take a look

private void addViewBinding(MethodSpec.Builder result, ViewBinding binding) {
    if (binding.isSingleFieldBinding()) {
      // Optimize the common case where there's a single binding directly to a field.
      FieldViewBinding fieldBinding = binding.getFieldBinding();
      CodeBlock.Builder builder = CodeBlock.builder()
          .add("target.$L = ", fieldBinding.getName());

      boolean requiresCast = requiresCast(fieldBinding.getType());
      if (!requiresCast && !fieldBinding.isRequired()) {
        builder.add("source.findViewById($L)", binding.getId().code);
      } else {
        builder.add("$T.find", UTILS);
        builder.add(fieldBinding.isRequired() ? "RequiredView" : "OptionalView");
        if (requiresCast) {
          builder.add("AsType");
        }
        builder.add("(source, $L", binding.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<MemberViewBinding> requiredBindings = binding.getRequiredBindings();
    if (requiredBindings.isEmpty()) {
      result.addStatement("view = source.findViewById($L)", binding.getId().code);
    } else if (!binding.isBoundToRoot()) {
      result.addStatement("view = $T.findRequiredView(source, $L, $S)", UTILS,
          binding.getId().code, asHumanDescription(requiredBindings));
    }

    addFieldBinding(result, binding);
    addMethodBindings(result, binding);
}

The code is simple, you can see that view = $T.findRequiredView(source, $L, $S) represents view = Utils.findRequiredView (source, R.id.loginButton,'field'button'and method'login') in the generated code;

The code generation section has a strong relationship with javapoet, so you need to know javapoet before you can get a better understanding of it, so you won't continue to analyze it here.

In summary, Butter Knife uses compile-time annotations and javapoet to dynamically generate auxiliary classes at compile time, and uses reflection to create instances of auxiliary classes at bind method runtime, which has the effect of grammatical sugar==.

Keywords: Android SDK ButterKnife Java

Added by kroll on Tue, 18 Jun 2019 20:42:25 +0300