Why write this series of blogs?
Because in the Android development process, generics, reflection, annotations will be used, almost all frameworks will use at least one or two of the above knowledge, such as Gson, generics, reflection, annotations, Retrofit also used generics, reflection, annotations. It is very important for us to learn these knowledge well, especially when we read the open source framework source code or develop the open source framework ourselves.
Detailed explanation of java Type
Detailed explanation of java reflection mechanism
Introduction to the Use of Annotations (I)
Android custom compile-time annotation 1 - simple example
Android Compile-time Annotations - Syntax Details
Take you to read ButterKnife's source code
Preface
ButterKnife, an open source repository, has been on fire for some time. At first, its implementation principle was based on reflection, and its performance was poor. In later versions, annotations + radiation are gradually used to achieve better performance.
ButterKnife Based on the compile-time framework, it can help us reduce the trouble of writing FindViewById every time. By 2017.5.1, the start on github has exceeded 15,000.
The source code of ButterKnife to be analyzed in this blog includes three parts, version number is 8.5.1.
- butterknife-annotations
- butterknife-compiler
- butterknife
butterknife-annotations library is mainly used to store custom annotations; butterknife-compiler is mainly used to scan where to use our custom annotations, and to process and generate template code; butterknife is mainly used to inject our code.
Let's start with how to use butterknife:
Basic use of ButterKnife
Adding dependencies to moudle build.gradle
dependencies { compile 'com.jakewharton:butterknife:8.5.1' annotationProcessor 'com.jakewharton:butterknife-compiler:8.5.1' }
public class SimpleActivity extends Activity { private static final ButterKnife.Action<View> ALPHA_FADE = new ButterKnife.Action<View>() { @Override public void apply(@NonNull View view, int index) { AlphaAnimation alphaAnimation = new AlphaAnimation(0, 1); alphaAnimation.setFillBefore(true); alphaAnimation.setDuration(500); alphaAnimation.setStartOffset(index * 100); view.startAnimation(alphaAnimation); } }; @BindView(R2.id.title) TextView title; @BindView(R2.id.subtitle) TextView subtitle; @BindView(R2.id.hello) Button hello; @BindView(R2.id.list_of_things) ListView listOfThings; @BindView(R2.id.footer) TextView footer; @BindViews({ R2.id.title, R2.id.subtitle, R2.id.hello }) List<View> headerViews; private SimpleAdapter adapter; @OnClick(R2.id.hello) void sayHello() { Toast.makeText(this, "Hello, views!", LENGTH_SHORT).show(); ButterKnife.apply(headerViews, ALPHA_FADE); } @OnLongClick(R2.id.hello) boolean sayGetOffMe() { Toast.makeText(this, "Let go of me!", LENGTH_SHORT).show(); return true; } @OnItemClick(R2.id.list_of_things) void onItemClick(int position) { Toast.makeText(this, "You clicked: " + adapter.getItem(position), LENGTH_SHORT).show(); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.simple_activity); ButterKnife.bind(this); // Contrived code to use the bound fields. title.setText("Butter Knife"); subtitle.setText("Field and method binding for Android views."); footer.setText("by Jake Wharton"); hello.setText("Say Hello"); adapter = new SimpleAdapter(this); listOfThings.setAdapter(adapter); } }
Calling the gradle build command, we will see the generation of code like this in the corresponding directory.
public class SimpleActivity_ViewBinding<T extends SimpleActivity> implements Unbinder { protected T target; private View view2130968578; private View view2130968579; @UiThread public SimpleActivity_ViewBinding(final T target, View source) { this.target = target; View view; target.title = Utils.findRequiredViewAsType(source, R.id.title, "field 'title'", TextView.class); target.subtitle = Utils.findRequiredViewAsType(source, R.id.subtitle, "field 'subtitle'", TextView.class); view = Utils.findRequiredView(source, R.id.hello, "field 'hello', method 'sayHello', and method 'sayGetOffMe'"); target.hello = Utils.castView(view, R.id.hello, "field 'hello'", Button.class); view2130968578 = view; view.setOnClickListener(new DebouncingOnClickListener() { @Override public void doClick(View p0) { target.sayHello(); } }); view.setOnLongClickListener(new View.OnLongClickListener() { @Override public boolean onLongClick(View p0) { return target.sayGetOffMe(); } }); view = Utils.findRequiredView(source, R.id.list_of_things, "field 'listOfThings' and method 'onItemClick'"); target.listOfThings = Utils.castView(view, R.id.list_of_things, "field 'listOfThings'", ListView.class); view2130968579 = view; ((AdapterView<?>) view).setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> p0, View p1, int p2, long p3) { target.onItemClick(p2); } }); target.footer = Utils.findRequiredViewAsType(source, R.id.footer, "field 'footer'", TextView.class); target.headerViews = Utils.listOf( Utils.findRequiredView(source, R.id.title, "field 'headerViews'"), Utils.findRequiredView(source, R.id.subtitle, "field 'headerViews'"), Utils.findRequiredView(source, R.id.hello, "field 'headerViews'")); } @Override @CallSuper public void unbind() { T target = this.target; if (target == null) throw new IllegalStateException("Bindings already cleared."); target.title = null; target.subtitle = null; target.hello = null; target.listOfThings = null; target.footer = null; target.headerViews = null; view2130968578.setOnClickListener(null); view2130968578.setOnLongClickListener(null); view2130968578 = null; ((AdapterView<?>) view2130968579).setOnItemClickListener(null); view2130968579 = null; this.target = null; } }
ButterKnife's execution process
Generally speaking, it can be divided into the following steps:
- Scanning annotations at compilation time, and doing corresponding processing, generate Java code, generate Java code is called javapoet Library generated.
- When we call ButterKnife.bind(this); when a method is called, it finds the corresponding code and executes it according to the fully qualified type of the class. Complete findViewById and setOnClick, setOnLongClick and other operations.
Step 1: Scan annotations at compilation time and do corresponding processing to generate java code. This step can be divided into several small steps:
- Define our annotations, state whether our annotations are saved in java doc, which areas can be acted on (Filed, Class, etc.), and whether they are source-time annotations, compile-time annotations, or run-time annotations, etc.
- Inherit AbstractProcessor to indicate which types of annotations are supported and which versions are supported.
- Rewrite the process method, process related annotations, and store them in the Map collection
- Based on the scanned annotation information (that is, the Map collection), call javapoet The library generates Java code.
butterknife-annotations
We know that ButterKnife customizes a lot of annotations, such as BindArray, BindBitmap, BindColor, BindView, etc. Here we take BindView as an example to explain OK, and the others are basically similar, so we will not explain here.
//Compile-time annotations @Retention(CLASS) //Member variables (including enum constants) @Target(FIELD) public @interface BindView { /** View ID to which the field will be bound. */ @IdRes int value(); }
Processor parser description
Let's first look at some basic methods: get some auxiliary tool classes in the init method, which has the advantage of ensuring that the tool class is singleton, because the init method will only be invoked at initialization time. If you don't know the commentary, I suggest reading this blog first. Android Compile-time Annotations - Syntax Details
public synchronized void init(ProcessingEnvironment env) { super.init(env); --- //Auxiliary Tool Class elementUtils = env.getElementUtils(); typeUtils = env.getTypeUtils(); filer = env.getFiler(); --- }
Then rewrite the getSupportedAnnotationTypes method to return the annotation types we support.
@Override public Set<String> getSupportedAnnotationTypes() { Set<String> types = new LinkedHashSet<>(); for (Class<? extends Annotation> annotation : getSupportedAnnotations()) { types.add(annotation.getCanonicalName()); } //Returns the type of supporting annotations return types; } private Set<Class<? extends Annotation>> getSupportedAnnotations() { Set<Class<? extends Annotation>> annotations = new LinkedHashSet<>(); annotations.add(BindArray.class); annotations.add(BindBitmap.class); annotations.add(BindBool.class); annotations.add(BindColor.class); annotations.add(BindDimen.class); annotations.add(BindDrawable.class); annotations.add(BindFloat.class); annotations.add(BindInt.class); annotations.add(BindString.class); annotations.add(BindView.class); annotations.add(BindViews.class); annotations.addAll(LISTENERS); return annotations; }
Next, let's look at our focus, the process method. What we do is probably to get all our annotation information, store it in the map set, traverse the map set, do the corresponding processing, and generate java code.
@Override public boolean process(Set<? extends TypeElement> elements, RoundEnvironment env) { // Get all the annotation information, TypeElement as key, BindingSet as value Map<TypeElement, BindingSet> bindingMap = findAndParseTargets(env); // Traverse all the information in the map and generate java code 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()); } } return false; }
Here we go into the find AndParseTargets method to see how annotation information is stored in the map collection.
findAndParseTargets method for each custom annotation (BindArray, BindBitmap, BindColor, BindView) has been processed, here we focus on @BindView processing can be. The same is true of other annotations.
Let's first look at the first half of the findAndParseTargets method, traverse the env. getElements Annotated With (BindView. class) collection, and invoke the parseBindView method to transform.
private Map<TypeElement, BindingSet> findAndParseTargets(RoundEnvironment env) { Map<TypeElement, BindingSet.Builder> builderMap = new LinkedHashMap<>(); Set<TypeElement> erasedTargetNames = new LinkedHashSet<>(); scanForRClasses(env); // Process each @BindView element. for (Element element : env.getElementsAnnotatedWith(BindView.class)) { // we don't SuperficialValidation.validateElement(element) // so that an unresolved View type can be generated by later processing rounds try { parseBindView(element, builderMap, erasedTargetNames); } catch (Exception e) { logParsingError(element, BindView.class, e); } } --- // The latter part, I'll talk about it later. }
You can see that the main logic of the stumbling part is in the parseBindView method, which mainly does the following steps:
- Judging whether a member variable modified by the annotation @BindView is legal or not, private ly or static ally, is wrong.
private void parseBindView(Element element, Map<TypeElement, BindingSet.Builder> builderMap, Set<TypeElement> erasedTargetNames) { TypeElement enclosingElement = (TypeElement) element.getEnclosingElement(); // An error occurs when judging whether an attribute is annotated, if it is modified by private or static // If the package name begins with "android" or "java", an error will occur. boolean hasError = isInaccessibleViaGeneratedCode(BindView.class, "fields", element) || isBindingInWrongPackage(BindView.class, element); // Verify that the target type extends from View. TypeMirror elementType = element.asType(); if (elementType.getKind() == TypeKind.TYPEVAR) { TypeVariable typeVariable = (TypeVariable) elementType; elementType = typeVariable.getUpperBound(); } Name qualifiedName = enclosingElement.getQualifiedName(); Name simpleName = element.getSimpleName(); // Determine whether the element is View and its subclasses or Interface if (!isSubtypeOfType(elementType, VIEW_TYPE) && !isInterface(elementType)) { if (elementType.getKind() == TypeKind.ERROR) { note(element, "@%s field with unresolved type (%s) " + "must elsewhere be generated as a View or interface. (%s.%s)", BindView.class.getSimpleName(), elementType, qualifiedName, simpleName); } else { error(element, "@%s fields must extend from View or be an interface. (%s.%s)", BindView.class.getSimpleName(), qualifiedName, simpleName); hasError = true; } } // If there is an error, return it directly if (hasError) { return; } // Assemble information on the field. int id = element.getAnnotation(BindView.class).value(); // Find builder based on the class element BindingSet.Builder builder = builderMap.get(enclosingElement); QualifiedId qualifiedId = elementToQualifiedId(element, id); // If the corresponding builder already exists if (builder != null) { // Verify that the ID has been bound String existingBindingName = builder.findExistingBindingName(getId(qualifiedId)); // Binded, Error, Return 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 there is no corresponding builder, it needs to be regenerated and not stored in the builder Map. 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); }
After parseBindView method has been analyzed, let's look back at the second half of findAndParseTargets method. The main task is to reorder the bindingMap.
private Map<TypeElement, BindingSet> findAndParseTargets(RoundEnvironment env) { // Omit the first half // 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(); //Get TypeElement of the parent class of type TypeElement parentType = findParentType(type, erasedTargetNames); // Empty, stored in map if (parentType == null) { bindingMap.put(type, builder.build()); } else { // Get the BindingSet of parentType 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. // Empty, add to the end of the queue, wait for the next processing entries.addLast(entry); } } } return bindingMap; }
So far, we have analyzed how ButterKnifeProcessor handles annotations and stored them in the map collection. Now let's go back to the process method and see how to generate java template code.
public boolean process(Set<? extends TypeElement> elements, RoundEnvironment env) { // Get all the annotation information, TypeElement as key, BindingSet as value Map<TypeElement, BindingSet> bindingMap = findAndParseTargets(env); // Traverse all the information in the map and generate java code for (Map.Entry<TypeElement, BindingSet> entry : bindingMap.entrySet()) { TypeElement typeElement = entry.getKey(); BindingSet binding = entry.getValue(); // Generating javaFile objects JavaFile javaFile = binding.brewJava(sdk); try { // Generate java template code javaFile.writeTo(filer); } catch (IOException e) { error(typeElement, "Unable to write binding for type %s: %s", typeElement, e .getMessage()); } } return false; }
There are only a few lines at the core of the generated code
// Generating javaFile objects JavaFile javaFile = binding.brewJava(sdk); try { // Generate java template code javaFile.writeTo(filer); } catch (IOException e) { error(typeElement, "Unable to write binding for type %s: %s", typeElement, e .getMessage()); }
Track in and find that it's calling square's open source library javapoet Open to generate code. Reference to the use of javaPoet Official address
JavaFile brewJava(int sdk) { return JavaFile.builder(bindingClassName.packageName(), createType(sdk)) .addFileComment("Generated code from Butter Knife. Do not modify!") .build(); } private TypeSpec createType(int sdk) { 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 it's a View or a subclass of View, add a constructor if (isView) { result.addMethod(createBindingConstructorForView()); } else if (isActivity) { // If it's an Activity or a subclass of Activity, add a constructor result.addMethod(createBindingConstructorForActivity()); } else if (isDialog) { // If it's a Dialog or a subclass of Dialog, add a constructor result.addMethod(createBindingConstructorForDialog()); } // If the constructor does not need the View parameter, add the constructor that needs the View parameter 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) { //Generating unBind Method result.addMethod(createBindingUnbindMethod(result)); } return result.build(); }
Then let's take a look at the createBindingConstructor(sdk) method. Probably what we do is
- Determine if there is a listener, and if there is a listener, set View to final
- Traversing through viewBindings, call addViewBinding to generate code in the form of findViewById.
private MethodSpec createBindingConstructor(int sdk) { MethodSpec.Builder constructor = MethodSpec.constructorBuilder() .addAnnotation(UI_THREAD) .addModifiers(PUBLIC); // If you have method bindings, such as @onClick, add a targetTypeName type method parameter target, which is final type if (hasMethodBindings()) { constructor.addParameter(targetTypeName, "target", FINAL); } else { // If not, it's not final type constructor.addParameter(targetTypeName, "target"); } //If there is a commented View, add the VIEW type source parameter if (constructorNeedsView()) { constructor.addParameter(VIEW, "source"); } else { // Add context parameters of Context type 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 @OnTouch binds View, add @SuppressLint ("Clickable View Accessibility") if (hasOnTouchMethodBindings()) { constructor.addAnnotation(AnnotationSpec.builder(SUPPRESS_LINT) .addMember("value", "$S", "ClickableViewAccessibility") .build()); } // If parentBinding is not empty, call the constructor of the parent class 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"); } // Add member variables 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); } // Traversing through viewBindings to generate source.findViewById($L) code 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(); }
Let's take a look at how the addViewBinding method generates code.
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(); // Note that the target. form is used directly here, so the attribute must not be 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)", 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; }
How ButterKnife Implements Code Injection
People who have used ButterKnife basically know that we use the bind method to achieve injection, that is, automatically help us find ViewById, liberate our hands, improve work efficiency. Let's take a look at how the bind method implements injection.
@NonNull @UiThread public static Unbinder bind(@NonNull Activity target) { View sourceView = target.getWindow().getDecorView(); return createBinding(target, sourceView); }
You can see that the bind method is very simple, and the logic is basically left to the createBinding method to complete. Let's go into the createBinding method and see what we've done.
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()); // Find constructor from Class Constructor<? extends Unbinder> constructor = findBindingConstructorForClass(targetClass); if (constructor == null) { return Unbinder.EMPTY; } //noinspection TryWithIdenticalCatches Resolves to API 19+ only type. try { // Reflection instantiation construction method 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); } }
In fact, for createBinding, the main thing is to do these things.
- Input class, instantiate constructor through findBindingConstructorForClass method
- Initialization of constructor objects by reflection
- Failure to initialize constructor throws an exception
Let's take a look at how the findBindingConstructorForClass method is implemented.
private static Constructor<? extends Unbinder> findBindingConstructorForClass(Class<?> cls) { // Read the cache, if not empty, return directly Constructor<? extends Unbinder> bindingCtor = BINDINGS.get(cls); if (bindingCtor != null) { if (debug) Log.d(TAG, "HIT: Cached in binding map."); return bindingCtor; } // If it's Android, Java Native file, not processed 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 // Find the original class 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()); // In the original class search, the search can not be found, to the parent class to find bindingCtor = findBindingConstructorForClass(cls.getSuperclass()); } catch (NoSuchMethodException e) { throw new RuntimeException("Unable to find binding constructor for " + clsName, e); } // Store in LinkedHashMap Cache BINDINGS.put(cls, bindingCtor); return bindingCtor; }
Its realization idea is as follows:
- Read the cache, if the cache hits, return directly, which is conducive to improving efficiency. As you can see from the code, caching is achieved by storing in the map collection.
- Whether it's our target file, yes, process it, if not, return it directly and print the corresponding log.
- Class loader is used to load the class file generated by ourselves, and get its construction method, get it, and return it directly. If we can't get it, an exception will be thrown. In the handling of the exception, we will look for it from the parent class of the current class file. The results are stored in the map set and cached.
So far is our analysis of ButterKnife.
Off-topic remarks
This blog mainly analyses the main principle and implementation of ButterKnife, but does not analyze in detail some implementation details of ButterKnife. But it's enough for us to understand the code. In the next series, we will mainly explain the implementation principle of Coordinator Layout and how to customize the behavior of Coordinator Layout to achieve the effect of page discovery imitating Sina Weibo. Please look forward to it.
Relevant Blog Recommendations
Detailed explanation of java Type
Detailed explanation of java reflection mechanism
Introduction to the Use of Annotations (I)
Android custom compile-time annotation 1 - simple example
Android Compile-time Annotations - Syntax Details
Take you to read ButterKnife's source code
Sweep it out. Welcome to my Wechat public number stormjun94 (Xugong Code Word). Now I am a programmer. I not only share the knowledge of Android development, but also share the growth process of technicians, including personal summary, career experience, interview experience, etc. I hope you can take less detours.