An overview of the
Componentization is a good organization scheme for large projects, which can well solve the coupling logic. Separate compilation of requirement modules facilitates test reuse and reduces maintenance costs. Even well-componentized projects can be painlessly transferred to plug-in. However, it also has corresponding disadvantages, such as the need for additional means of communication between modules, resulting in the complexity of writing efficiency reduced, different modules need to rely on and do additional management according to the version. In order to develop and compile plug-ins to help the project achieve the purpose of decoupling, reuse and separate compilation, the following functions are generally provided:
- Run alone (module is supported to run as App)
- Separate code resources between modules (or expose only specified interfaces)
- Resources are prefixed to reduce conflicts
- Application class handling (Application initialization for different Modules)
- Special handling
Based on the above points and AGP provided by Google, I will elaborate my componentization practice scheme and technical details in the past six months. The main technical points involved in this paper are: Gradle, AGP, Transform API, AOP, class file structure, Apk packaging process.
A single run
As we all know, AGP distinguishes a module (or project) type depending on the compiled plug-in type it depends on. If it depends on com.android.application, the module can be compiled into an APK file and installed to run. If the apply plug-in is com.android.library, AGP will compile this module into an AAR for other Modules to rely on. To run alone, make the Module rely directly on the Application Plugin and, under certain conditions, compile into an AAR file as part of any other module that will run as an app. Most of the frameworks I’ve worked with do a bit of tagging to tell which module apply plug-in to use, but this results in the need to re-sync the project with each switch for the IDE to recognize the project type correctly. Prepare two build.gradle and Androidmanifest.xml files to package into different environments. Another approach used in my practice is to determine which plugin to apply by executing the task, and to deal with configuration in build.gradle and androidmanifest.xml in the package. For example, define a task named uploadComponent, and when executing the task assume that the Module will be packaged as an AAR, apply Library Plugin, and add a task to handle androidmanifest.xml. Delete all attributes in the Application node (to prevent merge conflicts, or whitelist to retain some attributes), and delete the Intent-filter node of the MainActivity to prevent multiple entry activities after installation. In addition, you need to handle some of the agP properties after loading the build.gradle configuration, such as stripping the applicationId property and changing isShrinkResources in buildType to false (library does not support these properties). This allows you to run the uploadComponent on its own in general, but in special cases (execute uploadComponent) you can package an AAR for other Modules to rely on. In general, all modules carrying services are applications, and AGP cannot rely on the Module of application. Therefore, direct dependence is not feasible. You need to package the Module as an AAR and upload it to the public warehouse, and then rely on it in the form of packages in the warehouse. Therefore, this scenario requires an additional step of manual upload, which is also a defect found so far, but is planned to be simplified with scripting help.
Code resource isolation
The main purpose of code resource isolation is to decouple modules, eliminate direct references, and facilitate module insertion without affecting compilation. Ideally, two modules have no coupling at all. Isolation is simply code that can’t call dependent Modules at development time. It’s as simple as executing a packaged task (such as assembleRelease, bundleReleaseResource). But in practice, having the two modules in the same application without any direct application of the maintenance cost is larger, as communication between modules, if there is no direct coupling, must through the base to support the lib (Event or sinking public interface), or to define new interactive interface (similar to network interaction). The former needs to maintain all contents used for communication in the base lib, which leads to excessive redundancy of the base lib. The latter is too complex, and there is a loss of development and execution efficiency. Therefore, it is generally necessary to expose certain interfaces in a controllable range for direct interaction between modules, so as to strike a balance between decoupling and development efficiency. Continue the thought above. The compiled plug-in provides annotations for the caller to decide which interface to expose. What the plug-in does is expose the annotated class to the outside world, along with any classes referenced inside it. CONSTANT_Class_info refers to symbolic references to other classes or interfaces in a class. Code uses these constants in Attribute_info. But that’s not enough — return types, parameters, annotations, and stereotypes all have references to other classes, so you need to deal with those. If you use Javassist to parse the class structure, the CtClass#getRefClassess() method already handles the first few cases. See the Javassist source code for details. Most people may have the impression that a generic type is erased by the VIRTUAL machine at runtime to become Object, and therefore cannot be retrieved from the class file, but in some cases the actual generic type is written to the class file. These are as follows:
- The Class declaration paradigm
- The Field paradigm
- Method to return a value
- The paradigm of method parameters
The Class declaration template can be obtained by using Class#getTypeParameters(). In other cases, the generic type is recorded in the AttributeInfo of the Signature type, which can also be resolved in the class file, but it is important to note that there may be nested generic types, which need to be handled separately.
Resources prefix
The simplest way to constrain resource prefixes is to configure resourcePrefix in APG. However, this option is only checked during development and cannot be enforced. In the end, it still needs to be modified manually, so it is easy to omit and therefore cannot be adopted. Another option is to modify AAPT to assign different ids to resources in different modules. Here, however, a completely different approach is taken — prefix increment is accomplished through index and bytecode modification. This scenario deals with the resources in the project when the Module is packaged as an AAR, and the scope is only within the current Module, so a series of operations are added to the packaging of the AAR. As a general rule of thumb, there are four areas where resources need to be handled:
- The name of the resource itself
- References to resources in XML (@string/app_name)
- References to resources in code (r.string.app_name)
- R.jar/R.txt
In theory, the first step is to traverse all resources in the RES folder and record the corresponding resources according to the type. In the subsequent prefix processing, only the resource references in the record will be processed. Then iterate through the XML suffix file again, process the label in each element whose value is @xxx/ XXX, prefix it, and modify the filename to prefix it. In practice, however, it is difficult to distinguish the resources type. For example, strings. XML is a file mainly based on attribute items, so there is no need to add a prefix to its file name. In the development environment, there may be similar XML files in each folder, so it is more complicated to determine the strategy for this, so here we focus on how to use AGP. After analyzing the packaging process, it is found that the mergeResouce task merges all the resources in the Module, and the attributes like strings. XML are also merged into values.xml, which makes it much easier to process resources. Simply traverse the build directory corresponding to mergeResource and process values.xml separately. If you place the prefix processing before the task generated by R files (r.tex, r.class), the id in R files is prefixed and cannot be referenced at compile time. Put prefix after the R file generation step, and deal with the prefixes referenced by the code during the Transform process after class compilation. After discussing the first two cases, it’s time to add the prefix for resource ID references in your code. In this step, the class file is actually processed by parsing the CodeAttributeInfo to find references to all the fields in the R file. If the field is in the original collection, it is added, otherwise it is skipped. Some AOP frameworks can help simplify this, such as Javassist’s Instrument API, which makes it easy to retrieve target references. With Javassist, however, the id in R.class is handled extra because of the framework’s own compilation steps. Finally, the r.tab file provided by the AAR must also be processed. (R.tab is used by the App to generate resource ids for lib. Lib’s R.class is not packaged into class.jar.) The easiest way to generate a modified R.tab is to repeat the build step for the modified resource. In AGP 3.5.0, the corresponding class for this task is GenerateLibraryRFileTask. After relocating its output, package the result into an AAR file instead of the original R.tab. After adding prefixes, duplicate resources may occur, so we need to do resource deduplication again.
Application processing (initialization processing)
Each individual module may have its own initialization process, and it is certainly not possible to put all of this into a single module, but to handle its own initialization separately, collecting and sorting uniformly when packaged (if necessary). In order to prevent the sense of development fragmentation caused by the use of plug-ins, this practice still uses the Application class and does some processing on this. Because functions run separately may result in different initialization steps when run as an App and packaged as an AAR, two different initialization methods are provided:
abstract public class ComponentApplication extends Application {
public abstract void initAsApplication();
public abstract void initAsComponent(Application realApplication);
}
Copy the code
Using the name attribute of application Element in androidmanifest.xml, you can get the application configured by the corresponding module and record its class name in the corresponding table in the Module. When the formal packaging is run, all the previously created table information is collected and the method (Application or Component) to execute for each Application is selected based on the package name of the module that performs the APK packaging task.
The above logic is executed using a trick called Application replacement. Suppose we implement the logic class Class ComponentApplication during the packaging process, after merging the AndroidManifest.xml, You can change the Application Element’s name property to ComponentApplication. The original Application is added to the information table as the information collected in the previous step and runs in ComponentApplication. After dealing with the initialization of each module, there is another problem to consider, the initialization order. It is possible that some modules do have dependencies that need to be initialized after the initialization of another module is complete. Then it is necessary to add order to the initialization. Here is a reference to gradle Task and an article on wechat. Here you define an interface to expose the API that needs to be initialized
public interface InitTask {
void onDependency();
void onExecute();
InitTask dependsOn(Object... dep);
Set<Object> getDependsOn();
Set<InitTask> getDependencyTasks();
String getName();
}
Copy the code
The dependency method is implemented at compile time to establish a directed acyclic graph, and the final execution order is the topological ordering of the graph.
Special handling
This part is a simple one. It deals with BuildConfig references in different modules (each module builds a corresponding BuildConfig at compile time, but may rely on the main module’s BuildConfig at run time), so make an intermediate layer for it. The BuildConfig fully qualified name of the main module is injected at compile time, and the class name is used to get the specified properties at run time. Componentized, inter-module communication relies on the routing framework, which will be covered in another article.
The plugin is open source at github: github.com/nebulae-pan… , if you have any questions and suggestions for improvement, welcome to discuss here.