Simple implementation of ButterKnife

Posted by ShugNorris on Sun, 09 Jun 2019 23:46:15 +0200

The last blog post wrote about implementing ButterKnife with runtime annotations: Simple implementation of ButterKnife This is about compile-time annotations for ButterKnife. If you don't know about compile-time annotations for Android Studio, please refer to my other blog post: Android compile-time annotations.

I. Overview

Runtime annotation, the implementation principle is simple, that is, through the java reflection mechanism to obtain the id of the view, and then instantiate it and inject it. But compile-time annotations, because annotations are reserved only to class es or source s, we can not operate on their specific view at run time, so we need to find other methods.
Reading the source code of ButterKnife, we find that the principle is as follows: during compilation, a class file for injection control is generated by compiling processor. During operation, the class is acquired by reflection and injection can be executed. Here I implement only BindView and OnClick annotations.

Note: What's the difference between class and source? I found a statement on the Internet that source-level annotations have two purposes: one is to supplement documents and show people, such as Override annotations, and the other is to be used as source-code generators (both java and android have annotation processor APT). Similarly, bytecode-level annotations can be used as a basis for bytecode modification, stuffing, proxy, and bytecode modification using tools such as aspectj, asm, etc. For example, some module calls, if you write code directly, will lead to coupling, at this time you can add a comment, run as a tool, insert the annotated method field in the way of generate.

II. Realization

Specific Android Studio compile-time annotation operation is not much to say, refer to the above blog, I will directly outline the steps of the code.

1. Create annotations

New Module (Java Library): annotations, and new BindView and OnClick annotations.
BindView.java

package com.example;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * BindView annotation
 * Created by DavidChen on 2017/7/26.
 */

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.FIELD)
public @interface BindView {
    int value();
}

OnClick.java

package com.example;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * OnClickListener
 * Created by DavidChen on 2017/7/26.
 */

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
public @interface OnClick {
    int[] value();
}

Nothing more to say. Note that since it's a compile-time annotation, you need to specify Retention as CLASS or SOURCE.

2. Implementing Annotation Processor

New Module (Java Library): compiler, and new annotation processor. Because all annotations in each class are generated in the same java file, an Injector Info is used to store the variables and methods contained in the newly generated injection class.

InjectorInfo.java

package com.example;

import java.util.HashMap;
import java.util.Map;

import javax.lang.model.element.Element;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.type.TypeMirror;

import static javax.lang.model.element.ElementKind.PACKAGE;

/**
 * Information about injection classes to be generated
 * Created by DavidChen on 2017/7/26.
 */

public class InjectorInfo {
    private static final String SUFFIX = "_ViewBinding";    // Injection class unified suffix
    private static final String VIEW_TYPE = "android.view.View";

    private String mPackageName;    // Package name
    private String mInjectorName;   // Injection class name
    private TypeElement mTypeElement;   // Element Class in the Original Class

    Map<Integer, VariableElement> variableElementMap = new HashMap<>(); // Variables that store BindView annotations
    Map<Integer[], ExecutableElement> executableElementMap = new HashMap<>();   // Variables that store OnClick annotations

    InjectorInfo(TypeElement typeElement) {
        this.mTypeElement = typeElement;
        this.mPackageName = getPackage(mTypeElement).getQualifiedName().toString();
        this.mInjectorName = generateClassName(mPackageName, mTypeElement);
    }

    TypeElement getTypeElement() {
        return mTypeElement;
    }

    String generateCode() {
        StringBuilder builder = new StringBuilder();
        builder.append("// Generated code from Butter Knife. Do not modify!\n");
        builder.append("package ").append(mPackageName).append(";\n\n");
        builder.append("import ").append(VIEW_TYPE).append(";\n");
        builder.append("\n");
        builder.append("public class ").append(mInjectorName).append(" implements").append(" VIEW.OnClickListener").append(" {\n");
        builder.append("private ").append(mTypeElement.getQualifiedName()).append(" target;\n");
        generateMethods(builder);
        builder.append("\n");
        implementsEvents(builder);
        builder.append("\n");
        builder.append(" }\n");
        return builder.toString();
    }

    /**
     * Generate code to implement callback method in OnClickListener event
     */
    private void implementsEvents(StringBuilder builder) {
        builder.append("public void onClick(VIEW v) {\n");
        builder.append("switch(v.getId()) {\n");

        for (Integer[] ids : executableElementMap.keySet()) {
            ExecutableElement executableElement = executableElementMap.get(ids);
            for (int id : ids) {
                builder.append("case ").append(id).append(":\n");
            }
            builder.append("target.").append(executableElement.getSimpleName()).append("(v);\n");
            builder.append("break;\n");
        }

        builder.append("}\n");
        builder.append("}\n");
    }

    /**
     * Generate code to implement View binding and click event binding in the construction method
     */
    private void generateMethods(StringBuilder builder) {
        builder.append("public ").append(mInjectorName).append("(")
                .append(mTypeElement.getQualifiedName()).append(" target, ");
        builder.append("View source) {\n");

        builder.append("this.target = target;\n");

        for (int id : variableElementMap.keySet()) {
            VariableElement variableElement = variableElementMap.get(id);
            TypeMirror typeMirror = variableElement.asType();
            String type = typeMirror.toString();
            String name = variableElement.getSimpleName().toString();
            builder.append("target.").append(name).append(" = ");
            builder.append("(").append(type).append(")");
            builder.append("source.findViewById(");
            builder.append(id).append(");\n");
        }

        for (Integer[] ids : executableElementMap.keySet()) {
            for (int id : ids) {
                builder.append("source.findViewById(").append(id).append(").setOnClickListener(this);\n");
            }
        }

        builder.append(" }\n");
    }

    /**
     * Generating File Names for Injection Classes
     *
     * @param packageName Package name
     * @param typeElement Class element
     * @return Inject a class name, such as MainActivity.java to generate a class named MainActivity_ViewBinding.java
     */
    private static String generateClassName(String packageName, TypeElement typeElement) {
        String className = typeElement.getQualifiedName().toString().substring(
                packageName.length() + 1).replace('.', '$');
        return className + SUFFIX;
    }

    /**
     * Get PackageElement
     *
     * @throws NullPointerException If element is null
     */
    private static PackageElement getPackage(Element element) {
        while (element.getKind() != PACKAGE) {
            element = element.getEnclosingElement();
        }
        return (PackageElement) element;
    }
}

The InjectorInfo class encapsulates the BindView and OnClick annotations contained in each annotation class, and generates the corresponding class and package names of the annotation class, as well as several methods of generating code. I'm sure you won't look too hard.

ButterKnifeProcessor.java

package com.example;

import com.google.auto.service.AutoService;

import java.io.IOException;
import java.io.Writer;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Filer;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.Processor;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.tools.JavaFileObject;

@AutoService(Processor.class)
public class ButterKnifeProcessor extends AbstractProcessor {

    private Filer mFiler;   // Used to generate java files

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        mFiler = processingEnv.getFiler();
    }

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> annotationTypes = new LinkedHashSet<>();
        annotationTypes.add(BindView.class.getCanonicalName());
        return annotationTypes;
    }

    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.RELEASE_7;
    }

    // A collection of annotations used to store classes and their inclusions
    private Map<String, InjectorInfo> injectorInfoMap = new HashMap<>();

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(BindView.class);
        // Traversing through all BindView annotations
        for (Element element : elements) {
            parseBindView(element);
        }

        Set<? extends Element> eventElements = roundEnv.getElementsAnnotatedWith(OnClick.class);
        // Traverse to get all OnClick annotations
        for (Element element : eventElements) {
            parseOnClick(element);
        }

        // Generate class files

        for (String qualifiedName : injectorInfoMap.keySet()) {
            InjectorInfo injectorInfo = injectorInfoMap.get(qualifiedName);
            try {
                JavaFileObject sourceFile = mFiler.createSourceFile(qualifiedName + "_ViewBinding", injectorInfo.getTypeElement());
                Writer writer = sourceFile.openWriter();
                writer.write(injectorInfo.generateCode());
                writer.flush();
                writer.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return false;
    }

    private void parseOnClick(Element element) {
        ExecutableElement executableElement = (ExecutableElement) element;
        TypeElement typeElement = (TypeElement) executableElement.getEnclosingElement();
        String qualifiedName = typeElement.getQualifiedName().toString();
        InjectorInfo injectorInfo = injectorInfoMap.get(qualifiedName);
        if (injectorInfo == null) {
            injectorInfo = new InjectorInfo(typeElement);
            injectorInfoMap.put(qualifiedName, injectorInfo);
        }
        OnClick onClick = executableElement.getAnnotation(OnClick.class);
        if (onClick != null) {
            int[] idInt = onClick.value();
            Integer ids[] = new Integer[idInt.length];
            for (int i = 0; i < idInt.length; i++) {
                ids[i] = idInt[i];
            }
            injectorInfo.executableElementMap.put(ids, executableElement);
        }
    }

    private void parseBindView(Element element) {
        VariableElement variableElement = (VariableElement) element;
        TypeElement typeElement = (TypeElement) variableElement.getEnclosingElement();
        String qualifiedName = typeElement.getQualifiedName().toString();
        InjectorInfo injectorInfo = injectorInfoMap.get(qualifiedName);
        if (injectorInfo == null) {
            injectorInfo = new InjectorInfo(typeElement);
            injectorInfoMap.put(qualifiedName, injectorInfo);
        }
        BindView bindView = variableElement.getAnnotation(BindView.class);
        if (bindView != null) {
            int id = bindView.value();
            injectorInfo.variableElementMap.put(id, variableElement);
        }
    }
}

The ButterKnifeProcessor class inherits from the AbstractProcessor class and performs the collection and storage of annotations in the process method. In order to ensure that each class has only one annotation class, the ButterKnifeProcessor class is stored in map, with key as the fully qualified class name and value as the injection information. Finally, Filer tools are used to generate injected java files.
Note that AutoService is used here to help us configure Processor-related information. Also rely on annotations.

3. Provide injection API

Create a new Module (Android Library): api and create the ButterKnife class.
ButterKnife.java

package com.example.api;

import android.app.Activity;
import android.view.View;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

/**
 * ButterKnife Tool class
 * Created by DavidChen on 2017/7/26.
 */

public class ButterKnife {
    public static void bind(Activity target) {
        View sourceView = target.getWindow().getDecorView();
        createBinding(target, sourceView);
    }

    private static void createBinding(Activity target, View source) {
        Class<?> targetClass = target.getClass();
        String className = targetClass.getName();
        try {
            Class<?> bindingClass = targetClass.getClassLoader().loadClass(className + "_ViewBinding");
            Constructor constructor = bindingClass.getConstructor(targetClass, View.class);
            constructor.newInstance(target, source);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }
}

This class only implements a bind(Activity) method. In the createBinding() method, we use java reflection to load our generated * ViewBinding class and create an instance. Since all the injection methods of the generated injection class above are in the construction method, the binding operation can be performed as long as the instance is created (consistent with the ButterKnife source code).

4. Testing

When testing in the main project, be careful to rely on annotations, api and compiler. Of course, you can also use apt tools to remove comipler when packaging. Look specifically at the blog mentioned at the beginning of the article.

package com.example.davidchen.blogdemo;

import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;

import com.example.BindView;
import com.example.OnClick;
import com.example.api.ButterKnife;

/**
 * Test activity
 * Created by DavidChen on 2017/7/25.
 */

public class TestActivity extends AppCompatActivity {

    @BindView(R.id.btn_enter)
    public Button btn_enter;

    @BindView(R.id.tv_result)
    public TextView tv_result;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_test);
        ButterKnife.bind(this);
    }

    @OnClick({R.id.btn_enter, R.id.tv_result})
    public void click(View view) {
        switch (view.getId()) {
            case R.id.btn_enter:
                tv_result.setText("Successful injection");
                break;
            case R.id.tv_result:
                Toast.makeText(TestActivity.this, "guin", Toast.LENGTH_SHORT).show();
                break;
        }
    }
    @OnClick({R.id.btn_test})
    public void click2(View view) {
        switch (view.getId()) {
            case R.id.btn_test:
                Toast.makeText(TestActivity.this, "test2", Toast.LENGTH_SHORT).show();
                break;
        }
    }
}

At this time, CleanProject Make Project again, can be in the ___________. \ You can see the generated java files in the app build generated source apt debug directory as follows:

Moreover, the compiler has compiled it into a class file, in... \ App build intermediates classes debug directory, as shown below:

The results are as follows:

5. JavaPoet

JavaPoet It is a library specially used to generate java files. When the annotation processor generates java files, it is very efficient and convenient. It avoids all kinds of missing brackets or grading caused by carelessness in manual stitching. And when the amount of generated file code is too large, it can highlight the advantages.
Take a look at InjectorInfo.java in the javapoet version

package com.example;

import com.squareup.javapoet.ClassName;
import com.squareup.javapoet.CodeBlock;
import com.squareup.javapoet.FieldSpec;
import com.squareup.javapoet.JavaFile;
import com.squareup.javapoet.MethodSpec;
import com.squareup.javapoet.ParameterizedTypeName;
import com.squareup.javapoet.TypeName;
import com.squareup.javapoet.TypeSpec;

import java.util.HashMap;
import java.util.Map;

import javax.lang.model.element.Element;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.type.TypeKind;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.type.TypeVariable;

import static javax.lang.model.element.ElementKind.PACKAGE;

/**
 * Information about injection classes to be generated
 * Created by DavidChen on 2017/7/26.
 */

public class InjectorInfo {
    private static final String SUFFIX = "_ViewBinding";    // Injection class unified suffix

    private static final ClassName ONCLICK = ClassName.get("android.view.View", "OnClickListener");
    private static final ClassName VIEW = ClassName.get("android.view", "View");

    private String mPackageName;    // Package name
    private ClassName mInjectorClassName;   // Injection class
    private ClassName mOriginClassName; // Original class

    Map<Integer, VariableElement> variableElementMap = new HashMap<>();
    Map<Integer[], ExecutableElement> executableElementMap = new HashMap<>();

    InjectorInfo(TypeElement typeElement) {
        this.mPackageName = getPackage(typeElement).getQualifiedName().toString();
        mInjectorClassName = ClassName.get(mPackageName, typeElement.getSimpleName() + SUFFIX);
        mOriginClassName = ClassName.get(mPackageName, typeElement.getSimpleName().toString());
    }

    /**
     * Get PackageElement
     *
     * @throws NullPointerException If element is null
     */
    private static PackageElement getPackage(Element element) {
        while (element.getKind() != PACKAGE) {
            element = element.getEnclosingElement();
        }
        return (PackageElement) element;
    }

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

    private TypeSpec createType() {
        // Generating classes
        TypeSpec.Builder builder = TypeSpec.classBuilder(mInjectorClassName.simpleName())
                .addModifiers(Modifier.PUBLIC);
        // Implementation interface
        builder.addSuperinterface(ONCLICK);
        builder.addField(generateTarget());
        builder.addMethod(generateConstructor());
        builder.addMethod(generateEvent());
        //

        return builder.build();
    }

    private MethodSpec generateEvent() {
        MethodSpec.Builder builder = MethodSpec.methodBuilder("onClick")
                .addModifiers(Modifier.PUBLIC)
                .addParameter(VIEW, "v")
                .returns(void.class);
        builder.beginControlFlow("switch(v.getId())");
        for (Integer[] ints : executableElementMap.keySet()) {
            ExecutableElement executableElement = executableElementMap.get(ints);
            CodeBlock.Builder code = CodeBlock.builder();
            for (int id : ints) {
                code.add("case $L:\n", id);
            }
            code.add("target.$L(v)", executableElement.getSimpleName());
            builder.addStatement("$L", code.build());
            builder.addStatement("break");
        }
        builder.endControlFlow();
        return builder.build();
    }

    private FieldSpec generateTarget() {
        FieldSpec.Builder builder = FieldSpec.builder(mOriginClassName, "target", Modifier.PRIVATE);
        return builder.build();
    }

    private MethodSpec generateConstructor() {
        MethodSpec.Builder builder = MethodSpec.constructorBuilder()
                .addModifiers(Modifier.PUBLIC)
                .addParameter(mOriginClassName, "target")
                .addParameter(VIEW, "source");
        builder.addStatement("this.target = target");
        for (int id : variableElementMap.keySet()) {
            CodeBlock.Builder code = CodeBlock.builder();
            VariableElement variableElement = variableElementMap.get(id);
            ClassName className = getClassName(variableElement);
            code.add("target.$L = ", variableElement.getSimpleName());
            code.add("($T)source.findViewById($L)", className, id);
            builder.addStatement("$L", code.build());
        }
        for (Integer[] ints : executableElementMap.keySet()) {
            for (int id : ints) {
                builder.addStatement("source.findViewById($L).setOnClickListener(this)", id);
            }
        }
        return builder.build();
    }

    private ClassName getClassName(Element element) {
        TypeMirror elementType = element.asType();
        if (elementType.getKind() == TypeKind.TYPEVAR) {
            TypeVariable typeVariable = (TypeVariable) elementType;
            elementType = typeVariable.getUpperBound();
        }
        TypeName type = TypeName.get(elementType);
        if (type instanceof ParameterizedTypeName) {
            return ((ParameterizedTypeName) type).rawType;
        }
        return (ClassName) type;
    }
}

This piece of code is actually just brew Java (), and the related api is used to generate the code, the rest is not much changed. Using JavaPoet makes code generation more object-oriented.

ButterKnifeProcessor.java

package com.example;

import com.google.auto.service.AutoService;
import com.squareup.javapoet.JavaFile;

import java.io.IOException;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Filer;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.Processor;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;

@AutoService(Processor.class)
public class ButterKnifeProcessor extends AbstractProcessor {

    private Filer mFiler;   // Used to generate java files

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        mFiler = processingEnv.getFiler();
    }

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> annotationTypes = new LinkedHashSet<>();
        annotationTypes.add(BindView.class.getCanonicalName());
        return annotationTypes;
    }

    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.RELEASE_7;
    }

    // A collection of annotations used to store classes and their inclusions
    private Map<String, InjectorInfo> injectorInfoMap = new HashMap<>();

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(BindView.class);
        // Traverse through all BindView annotations.
        for (Element element : elements) {
            parseBindView(element);
        }

        Set<? extends Element> eventElements = roundEnv.getElementsAnnotatedWith(OnClick.class);
        // Traverse to get all OnClick annotations
        for (Element element : eventElements) {
            parseOnClick(element);
        }

        // Generate class files

        for (String qualifiedName : injectorInfoMap.keySet()) {
            InjectorInfo injectorInfo = injectorInfoMap.get(qualifiedName);
            JavaFile javaFile = injectorInfo.brewJava();
            try {
                javaFile.writeTo(mFiler);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return false;
    }

    private void parseOnClick(Element element) {
        ExecutableElement executableElement = (ExecutableElement) element;
        TypeElement typeElement = (TypeElement) executableElement.getEnclosingElement();
        String qualifiedName = typeElement.getQualifiedName().toString();
        InjectorInfo injectorInfo = injectorInfoMap.get(qualifiedName);
        if (injectorInfo == null) {
            injectorInfo = new InjectorInfo(typeElement);
            injectorInfoMap.put(qualifiedName, injectorInfo);
        }
        OnClick onClick = executableElement.getAnnotation(OnClick.class);
        if (onClick != null) {
            int[] idInt = onClick.value();
            Integer ids[] = new Integer[idInt.length];
            for (int i = 0; i < idInt.length; i++) {
                ids[i] = idInt[i];
            }
            injectorInfo.executableElementMap.put(ids, executableElement);
        }
    }

    private void parseBindView(Element element) {
        VariableElement variableElement = (VariableElement) element;
        TypeElement typeElement = (TypeElement) variableElement.getEnclosingElement();
        String qualifiedName = typeElement.getQualifiedName().toString();
        InjectorInfo injectorInfo = injectorInfoMap.get(qualifiedName);
        if (injectorInfo == null) {
            injectorInfo = new InjectorInfo(typeElement);
            injectorInfoMap.put(qualifiedName, injectorInfo);
        }
        BindView bindView = variableElement.getAnnotation(BindView.class);
        if (bindView != null) {
            int id = bindView.value();
            injectorInfo.variableElementMap.put(id, variableElement);
        }
    }
}

There is not much change here, that is, the work of generating files is handed over to javaFile.writeTo(mFiler), where javaFile is generated by brewJava() method in InjectorInfo.

ok, other things don't need to change. make project is ok. Take a look at the generated java files:

You can see that all the binding code is about the same. But import is added automatically, and the mandatory conversion is no longer a fully qualified class name, more inclined to our usage habits. Of course, manual stitching can also be achieved, but it is more cumbersome.

Summary

This is just a simple implementation of the two functions of the ButterKnife framework, but the idea of implementation is the same. At compile time, annotations are acquired and processed to generate injected java files, and then at run time, the injected control and events are injected by performing binding operations on the specified injected classes through reflection. Of course, there may be a lot of lack of consideration here, please forgive me.


Attach the source address: ButterKnife


Topics: Java Android ButterKnife Google