Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Demystifying Butterknife

DZone's Guide to

Demystifying Butterknife

Learn about Butterknife, it's components, and it's API in this breakdown of what it is and how it works for Android development.

· Mobile Zone ·
Free Resource

As an Android developer, you're surely heard of Butterknife, maybe even used it in countless projects yourself. Have you ever wondered what is behind the magical API of Butterknife? In this post, we unveil the magic and deconstruct its code.

Butterknife is mainly composed of three pieces:

  • Annotations

  • Annotation processor

  • Runtime library

Annotations

Annotations defines all the BindView annotation and the like that you use on fields and methods in view code. You can see them all here.

Bindings Listeners
BindAnim OnCheckedChanged
BindArray OnClick
BindBitmap OnEditorAction
BindBool OnFocusChange
BindColor OnItemClick
BindDimen OnItemLongClick
BindDrawable OnItemSelected
BindFloat OnLongClick
BindFont OnPageChange
BindInt OnTextChanged
BindString OnTouch
BindView
BindViews



Annotation Processor

This is the most interesting part of Butterknife. The implementation is simple(ish). The processor says it's interested in all the Butterknife annotations. Then the compiler calls the processor with all the elements annotated with these annotations. It creates the XActivity_ViewBinding class.

Here is a sample _ViewBinding class generated by the processor.

...
public class MainActivity_ViewBinding implements Unbinder {
  private MainActivity target;

  @UiThread
  public MainActivity_ViewBinding(MainActivity target) {
    this(target, target.getWindow().getDecorView());
  }

  @UiThread
  public MainActivity_ViewBinding(MainActivity target, View source) {
    this.target = target;

    target.myTextView = Utils.findRequiredViewAsType(source, R.id.my_text_view, "field 'myTextView'", TextView.class);
  }

  @Override
  @CallSuper
  public void unbind() {
    ...
    this.target = null;

    target.myTextView = null;
  }
}

Let's focus on the BindView annotation; others behave similarly (listeners are slightly different). The crux of parseBindView is in lines 475-499.

private void parseBindView(Element element, Map<TypeElement, BindingSet.Builder> builderMap,
    Set<TypeElement> erasedTargetNames) {
  TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();
  ...
  // Assemble information on the field.
  int id = element.getAnnotation(BindView.class).value();

  BindingSet.Builder builder = builderMap.get(enclosingElement);
  QualifiedId qualifiedId = elementToQualifiedId(element, id);
  if (builder != null) {
    String existingBindingName = builder.findExistingBindingName(getId(qualifiedId));      
    ...
  } else {
    builder = getOrCreateBindingBuilder(builderMap, enclosingElement);
  }

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

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

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

Listeners are implemented with little more complexity, mainly in parseListenerAnnotation, specifically lines 1139-1164 and 1204-1213. If we skip all the error-checking, the method parseListenerAnnotation matches the parameters from the Android listener which are used in the bound method and passes off the required params to BindingSet.Builder#addMethod.

private void parseListenerAnnotation(Class<? extends Annotation> annotationClass, Element element,
    Map<TypeElement, BindingSet.Builder> builderMap, Set<TypeElement> erasedTargetNames)
    throws Exception {
  ...
  ExecutableElement executableElement = (ExecutableElement) element;
  TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();

  // Assemble information on the method.
  Annotation annotation = element.getAnnotation(annotationClass);
  Method annotationValue = annotationClass.getDeclaredMethod("value");
  ...

  int[] ids = (int[]) annotationValue.invoke(annotation);
  String name = executableElement.getSimpleName().toString();
  boolean required = isListenerRequired(executableElement);
  ...
  ListenerClass listener = annotationClass.getAnnotation(ListenerClass.class);
  ...
  ListenerMethod method;
  ListenerMethod[] methods = listener.method();
  if (methods.length > 1) {
    ...
  } else if (methods.length == 1) {
    ...
    method = methods[0];
  } else {
    Method annotationCallback = annotationClass.getDeclaredMethod("callback");
    Enum<?> callback = (Enum<?>) annotationCallback.invoke(annotation);
    Field callbackField = callback.getDeclaringClass().getField(callback.name());
    method = callbackField.getAnnotation(ListenerMethod.class);
    ...
  }

  // Verify that the method has equal to or less than the number of parameters as the listener.
  List<? extends VariableElement> methodParameters = executableElement.getParameters();
  ...
  Parameter[] parameters = Parameter.NONE;
  if (!methodParameters.isEmpty()) {
    parameters = new Parameter[methodParameters.size()];
    BitSet methodParameterUsed = new BitSet(methodParameters.size());
    String[] parameterTypes = method.parameters();
    for (int i = 0; i < methodParameters.size(); i++) {
      VariableElement methodParameter = methodParameters.get(i);
      TypeMirror methodParameterType = methodParameter.asType();
      if (methodParameterType instanceof TypeVariable) {
        TypeVariable typeVariable = (TypeVariable) methodParameterType;
        methodParameterType = typeVariable.getUpperBound();
      }

      for (int j = 0; j < parameterTypes.length; j++) {
        if (methodParameterUsed.get(j)) {
          continue;
        }
        if ((isSubtypeOfType(methodParameterType, parameterTypes[j])
                && isSubtypeOfType(methodParameterType, VIEW_TYPE))
            || isTypeEqual(methodParameterType, parameterTypes[j])
            || isInterface(methodParameterType)) {
          parameters[i] = new Parameter(j, TypeName.get(methodParameterType));
          methodParameterUsed.set(j);
          break;
        }
      }
      ...
    }
  }

  MethodViewBinding binding = new MethodViewBinding(name, Arrays.asList(parameters), required);
  BindingSet.Builder builder = getOrCreateBindingBuilder(builderMap, enclosingElement);
  for (int id : ids) {
    QualifiedId qualifiedId = elementToQualifiedId(element, id);

    builder.addMethod(getId(qualifiedId), listener, method, binding)
    ...
  }

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

Both these methods collect all the bindings into a BindingSet per type. Once a Type is processed, all these bindings are flushed to a JavaFileX_ViewBinding.java

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

private TypeSpec createType(int sdk, boolean debuggable) {
  TypeSpec.Builder result = TypeSpec.classBuilder(bindingClassName.simpleName())
      .addModifiers(PUBLIC);
  if (isFinal) {
    result.addModifiers(FINAL);
  }

  if (parentBinding != null) {
    result.superclass(parentBinding.bindingClassName);
  } else {
    result.addSuperinterface(UNBINDER);
  }

  if (hasTargetField()) {
    result.addField(targetTypeName, "target", PRIVATE);
  }

  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, debuggable));

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

  return result.build();
}

See Jake Wharton's talk on Annotation Processor to get more details of how one is built.

Runtime Library

This is a utility library and some sugar. The utilities are used from the generated code and the Butterknife class. When you call Butterknife.bind(), it reflectively loads the processor-generated class, which is named your-view-class-name + "_ViewBinding" and constructs an instance of the class.

private static Unbinder createBinding(@NonNull Object target, @NonNull View source) {
  Class<?> targetClass = target.getClass();
  ...
  Constructor<? extends Unbinder> constructor = findBindingConstructorForClass(targetClass);
  ...
  try {
    return constructor.newInstance(target, source);
  }
  ...
}

@Nullable @CheckResult @UiThread
private static Constructor<? extends Unbinder> findBindingConstructorForClass(Class<?> cls) {
  Constructor<? extends Unbinder> bindingCtor;
  ... // Removed cache code
  String clsName = cls.getName();
  ...
  try {
    Class<?> bindingClass = cls.getClassLoader().loadClass(clsName + "_ViewBinding");
    ...
    bindingCtor = (Constructor<? extends Unbinder>) bindingClass.getConstructor(cls, View.class);
    ...
  }
  ...
  return bindingCtor;
}

The Butterknife class is not really required to make use of the binder. In fact, it adds runtime reflection cost. You can instead use the generated class directly. This is the approach Dagger 2 has taken as well.

public class MyActivity extends Activity {
  @BindView(R.id.my_text_view) TextView myTextView;
  private Unbinder unbinder;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    // instead of reflection using
    unbinder = Butterknife.bind(this);
    // you can write
    unbinder = new MyActivity_ViewBinding(this);

    myTextView.setText("Ta-da!");
  }
}

Bonus: The Gradle Plugin

The Gradle plugin has a very simple function. It lets you use Butterknife in library modules. But why can't you use Butterknife in library modules like regular application modules? Because the element value in an annotation can only be a constant value for primitive types. But the R class generated by Android Gradle plugin doesn't make the ID values constant in library modules. If libraries had their resource IDs final, then the IDs could collide when building the final APK. So your compile step will complain loudly if you write

@BindView(R.id.viewid) View idView;

You could not use Butterknife in library modules earlier, that is until Gautam Korlam contributed a Gradle plugin which clones R as R2 with final values for resources. Now you can use @BindView(R2.id.viewid) View idView in library modules without worry. Most of the interesting work is done in the class FinalRClassBuilder.

public final class FinalRClassBuilder {
  ...
  private static final String[] SUPPORTED_TYPES = {
      "anim", "array", "attr", "bool", "color", "dimen", "drawable", "id", "integer", "layout", "menu", "plurals",
      "string", "style", "styleable"
  };
  ...
  public static void brewJava(File rFile, File outputDir, String packageName, String className)
      throws Exception {
    CompilationUnit compilationUnit = JavaParser.parse(rFile);
    TypeDeclaration resourceClass = compilationUnit.getTypes().get(0);

    TypeSpec.Builder result =
        TypeSpec.classBuilder(className).addModifiers(PUBLIC).addModifiers(FINAL);

    for (Node node : resourceClass.getChildNodes()) {
      if (node instanceof ClassOrInterfaceDeclaration) {
        addResourceType(Arrays.asList(SUPPORTED_TYPES), result, (ClassOrInterfaceDeclaration) node);
      }
    }

    JavaFile finalR = JavaFile.builder(packageName, result.build())
        ...
        .build();

    finalR.writeTo(outputDir);
  }

  private static void addResourceType(List<String> supportedTypes, TypeSpec.Builder result,
      ClassOrInterfaceDeclaration node) {
    ...
    String type = node.getNameAsString();
    TypeSpec.Builder resourceType = TypeSpec.classBuilder(type).addModifiers(PUBLIC, STATIC, FINAL);

    for (BodyDeclaration field : node.getMembers()) {
      if (field instanceof FieldDeclaration) {
        FieldDeclaration declaration = (FieldDeclaration) field;
        // Check that the field is an Int because styleable also contains Int arrays which can't be
        // used in annotations.
        if (isInt(declaration)) {
          addResourceField(resourceType, declaration.getVariables().get(0),
                  getSupportAnnotationClass(type));
        }
      }
    }

    result.addType(resourceType.build());
  }
  ...
  private static void addResourceField(TypeSpec.Builder resourceType, VariableDeclarator variable,
      ClassName annotation) {
    String fieldName = variable.getNameAsString();
    String fieldValue = variable.getInitializer().map(Node::toString).orElse(null);
    FieldSpec.Builder fieldSpecBuilder = FieldSpec.builder(int.class, fieldName)
        .addModifiers(PUBLIC, STATIC, FINAL)
        .initializer(fieldValue);
    ...
    resourceType.addField(fieldSpecBuilder.build());
  }
  ...
}

I hope you're now comfortable venturing into the source code of Butterknife armed with this knowledge.

Tip: Install Insight.io for GitHub to add IDE-like navigation capabilities to GitHub for pleasant code reading.

Look out for the next post where we would be building our own version of Butterknife from scratch.

Thanks to Yashasvi Girdhar for reading drafts of this post.

Topics:
android ,butterknife ,annotation processors ,mobile ,mobile app development

Published at DZone with permission of

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}