Android Complete Componentization Solution Practice

Android Complete Componentization Solution Practice

1. Modularization, componentization and plug-inization

When a project develops to a certain extent, with the increase of personnel, the code becomes more and more bloated, and then it must be split into modules. In my opinion, modularization is a guiding concept, and its core idea is to divide and conquer and reduce coupling. There are currently two ways to implement it in Android projects, which are also two major schools of thought: one is componentization and the other is plug-inization.

When it comes to the difference between componentization and plug-inization, there is a very vivid picture:

The above picture looks clear, but it is easy to cause some misunderstandings. There are several small problems that may not be clearly explained in the picture:

Is componentization a whole? Can it still exist without the head and arms? In the left picture, it seems that componentization is an organic whole that requires all organs to be healthy. In fact, one of the goals of componentization is to reduce the dependency between the whole (app) and the organs (components). The app can exist and run normally without any organ.

Can the head and arms exist independently? The left picture does not explain it clearly, but the answer should be yes. Each organ (component) can survive independently after supplementing some basic functions. This is the second goal of componentization: components can operate independently.

Can both componentization and plug-inization be represented by the right picture? If the answers to the above two questions are both YES, the answer to this question is also YES. Each component can be regarded as a separate whole, which can be integrated with other components (including the main project) as needed to form an app.

Can the little robot in the right picture be added and modified dynamically? If both componentization and plug-inization are represented by the right picture, then the answer to this question is different. For componentization, the answer to this question is partially yes, that is, it can be added and modified dynamically at compile time, but it cannot be done at runtime. For plug-inization, the answer to this question is very straightforward, that is, it is completely yes, whether it is at compile time or runtime!

This article mainly focuses on the implementation ideas of componentization. The technical details of plug-inization are not discussed. We just summarize a conclusion from the above questions and answers: the biggest difference between componentization and plug-inization (probably the only difference) is that componentization does not have the function of dynamically adding and modifying components at runtime, but plug-inization can.

Putting aside the "moral" criticism of plug-inization, I think plug-inization is indeed a blessing for an Android developer, which will give us great flexibility. However, there is no completely suitable and fully compatible plug-in solution (RePlugin's hunger marketing is very good, but the effect has not yet been seen), especially for a mature product with hundreds of thousands of codes, it is very dangerous to apply any plug-in solution. So we decided to start with componentization, and refactor the code in the spirit of making the most thorough componentization solution. The following is the result of recent thinking, and everyone is welcome to put forward suggestions and opinions.

2. How to implement componentization

To achieve componentization, no matter what technical path is adopted, the following issues need to be considered:

  • Code decoupling. How to split a huge project into an organic whole?
  • Components run independently. As mentioned above, each component is a complete whole. How to run and debug it independently?
  • Data transfer. Since each component provides services to other components, how to transfer data between the host and components, and between components?
  • UI jump. UI jump can be considered as a special kind of data transfer. What are the differences in implementation ideas?
  • Component life cycle. Our goal is to enable on-demand and dynamic use of components, which involves the component loading, unloading, and dimensionality reduction life cycle.
  • Integration debugging. How to compile components on demand during the development phase? In one debugging session, only one or two components may be integrated, which greatly reduces the compilation time and improves development efficiency.
  • Code isolation. If the interaction between components is still directly referenced, then there is no decoupling between components. How to fundamentally avoid direct references between components? In other words, how to fundamentally prevent coupling? Only by doing this can we achieve complete componentization.

2.1 Code Decoupling

Android studio can provide good support for splitting huge codes. Using the multiple module function in IDE, we can easily split the code. Here we distinguish between two modules:

  • One is the basic library, which is directly referenced by other components. For example, the network library module can be considered a library.
  • The other type is called a Component, which is a complete functional module. For example, a reading or sharing module is a Component.

For convenience, we call library as dependency library and component as component. The componentization we are talking about is mainly for this type of component. The module responsible for assembling these components to form a complete app is generally called the main project, main module or host. For convenience, we also call it the main project.

After a simple thought, we may be able to split the code into the following structure:

Componentization and simple decomposition

This kind of split is relatively easy to do. From the diagram, we can see that reading, sharing, etc. have been split into components and rely on a common dependency library (only one is drawn for simplicity), and then these components are referenced by the main project. There is no direct connection between reading, sharing, etc. components, so we can think that the components have been decoupled. However, there are several problems with this diagram that need to be pointed out:

  • From the above figure, we seem to think that components can only be used after being integrated into the main project. In fact, we hope that each component is a whole and can be run and debugged independently. So how can we debug it separately?
  • Can the main project directly reference components? In other words, can we directly use compile project(:reader) to reference components? If so, the coupling between the main project and the component is not eliminated. We said above that components can be managed dynamically. If we delete the reader component, the main project cannot be compiled. How can we talk about dynamic management? Therefore, the main project cannot directly reference components. However, our reading component will eventually be included in the apk. Not only the code must be merged into claases.dex, but the resources must also be merged into the apk resources through the meage operation. How can we avoid this contradiction?
  • Is it true that there is no mutual reference or interaction between components? The reading component also calls the sharing module, but this is not reflected in the diagram at all. So how do components interact with each other?

We will solve these problems one by one later. First, let's look at the effect of code decoupling. Directly referencing and using the classes in the above will definitely not work. Therefore, we believe that the primary goal of code decoupling is to completely isolate components. Not only can we not directly use the classes in other components, but we may not even understand the implementation details. Only this level of decoupling is what we need.

2.2 Individual Debugging of Components

In fact, single debugging is relatively simple. You only need to switch apply plugin: 'com.android.library' to apply plugin: 'com.android.application'. However, we also need to modify the AndroidManifest file because a single debugging requires an entry activity.

We can set a variable isRunAlone to mark whether separate debugging is needed at present. According to the value of isRunAlone, we can use different gradle plug-ins and AndroidManifest files, and even add Java files such as Application, so as to perform initialization operations.

To avoid duplication of resource names between different components, add resourcePrefix "xxx_" to the build.gradle of each component to fix the resource prefix of each component. The following is an example of the build.gradle of the reading component:

  1. if(isRunAlone.toBoolean()){
  2. apply plugin: 'com.android.application'  
  3. } else {
  4. apply plugin: 'com.android.library'  
  5. }
  6. .....
  7. resourcePrefix "readerbook_"  
  8. sourceSets {
  9. main {
  10. if (isRunAlone.toBoolean()) {
  11. manifest.srcFile 'src/main/runalone/AndroidManifest.xml'  
  12. java.srcDirs = [ 'src/main/java' , 'src/main/runalone/java' ]
  13. res.srcDirs = [ 'src/main/res' , 'src/main/runalone/res' ]
  14. } else {
  15. manifest.srcFile 'src/main/AndroidManifest.xml'  
  16. }
  17. }
  18. }

With this additional code, we built a test host for the component so that the component code can run in it, so we can further optimize our framework diagram above.

Supports separate debugging of components

2.3 Component Data Transfer

As mentioned above, the main project and components, or components and components cannot directly use mutual references of classes to interact with each other. So how do we achieve this isolation? Here we use the interface + implementation structure. Each component declares the services it provides. These services are abstract classes or interfaces. The components are responsible for implementing these services and registering them in a unified router. If you want to use the function of a component, you only need to request the implementation of this service from the router. We don't care about the specific implementation details at all, as long as we can return the results we need. This is very similar to the C/S architecture of Binder.

Because data transfer between our components is based on interface programming, the interface and implementation are completely separated, so the components can be decoupled, and we can dynamically manage components such as replacement and deletion. There are a few small issues that need to be clarified:

How do components expose the services they provide? For simplicity, we have created a componentservice dependency library in the project, which defines the services and some public models provided by each component. All component services are integrated together to make the operation easier in the early stage of splitting, and they need to be generated in an automated way later. This dependency library needs to strictly follow the open-closed principle to avoid version compatibility and other issues.

The specific implementation of the service is registered in the Router by the component to which it belongs. So when is it registered? This involves the life cycle of component loading, etc., which we will introduce later.

A small mistake that is easy to make is to pass data through persistence methods, such as file, sharedpreference, etc. This needs to be avoided.

The following is the architecture diagram after adding the data transmission function:

Data transfer between components

2.4 UI jump between components

It can be said that UI jump is also a special service provided by the component, which can be attributed to the above data transmission. However, we usually handle UI jump separately, usually through the short chain method to jump to the specific Activity. Each component can register the schme and host of the short chain it can handle, and define the format of the transmitted data. Then register it in the unified UIRouter, which is responsible for distributing routes through the matching relationship between schme and host.

The specific implementation of the UI jump part is to add annotations to each Activity, and then form specific logic codes through apt. This is also the mainstream implementation method of UI routing in Android.

2.5 Component Lifecycle

Since we want to manage components dynamically, we add several life cycle states to each component: loading, unloading, and dimensionality reduction. To this end, we add an ApplicationLike class to each component, which defines two life cycle functions: onCreate and onStop.

Loading: As mentioned above, each component is responsible for registering its own service implementation into the Router, and its specific implementation code is written in the onCreate method. Then the main project calling this onCreate method is called component loading, because once the onCreate method is executed, the component registers its own service into the Router, and other components can directly use this service.

Uninstall: Uninstall is basically the same as loading, except that the onStop method of ApplicationLike is called, in which each component unregisters its service implementation from the Router. However, this usage scenario may be relatively rare and is generally suitable for some components that are only used once.

Dimensionality reduction: Dimensionality reduction is used in rare scenarios. For example, if a component has a problem, we want to change the component from local implementation to a wap page. Dimensionality reduction generally requires background configuration to take effect. You can check the online configuration in onCreate. If dimensionality reduction is required, jump all UI to the configured wap page.

A small detail is that the main project is responsible for loading components. Since the main project and components are isolated, how does the main project call the lifecycle method of the component ApplicationLike? Currently, we use a compile-time bytecode insertion method to scan all ApplicationLike classes (which have a common parent class), and then use javassisit to insert the code that calls ApplicationLike.onCreate in the onCreate of the main project.

Let's optimize the componentized architecture diagram:

Component lifecycle

2.6 Integrated debugging

The fact that each component has been debugged individually does not mean that there are no problems when integrated together. Therefore, in the later stage of development, we need to integrate several components into one app for verification. Since the above mechanism ensures the isolation between components, we can arbitrarily select several components to participate in the integration. This on-demand loading mechanism can ensure great flexibility in integration debugging and can greatly speed up the compilation speed.

Our approach is that after each component is developed, a relevant AAR is published to a public repository, usually a local Maven repository. Then the main project can configure the components to be integrated through parameters. So we slightly change the connection line between the component and the main project, and the final componentized architecture diagram is as follows:

Final structure diagram

2.7 Code Isolation

Now, looking back at the three questions we raised when we first split the components, we have found solutions, but there is still a hidden danger that has not been solved, that is, can we use compile project (xxx: reader.aar) to introduce components? Although we used the interface + implementation architecture in the data transmission chapter, components must be programmed for the interface, but once we introduce reader.aar, we can directly use the implementation class in it, so our specification for interface programming becomes a dead letter. A thousand-mile dam is destroyed by an ant hole. As long as there is code (whether intentional or unintentional) that does this, our previous work will be in vain.

We hope to only import aar when assembleDebug or assembleRelease, and in the development stage, all components are invisible, so as to fundamentally eliminate the problem of referencing implementation classes. We hand this problem over to gradle to solve it. We create a gradle plug-in, and then apply this plug-in to each component. The configuration code of the plug-in is also relatively simple:

  1. // Add various component dependencies according to the configuration, and automatically generate component loading code
  2. if (project.android instanceof AppExtension) {
  3. AssembleTask assembleTask = getTaskInfo(project.gradle.startParameter.taskNames)
  4. if (assembleTask.isAssemble
  5. && (assembleTask.modules. contains ( "all" ) || assembleTask.modules. contains (module))) {
  6. // Add component dependencies
  7. project.dependencies.add ( " compile " , "xxx:reader-release@aar" )
  8. // The bytecode insertion part is also implemented here
  9. }
  10. }
  11.  
  12. private AssembleTask getTaskInfo(List<String> taskNames) {
  13. AssembleTask assembleTask = new AssembleTask();
  14. for (String task : taskNames) {
  15. if (task.toUpperCase(). contains ( "ASSEMBLE" )) {
  16. assembleTask.isAssemble = true ;
  17. String[] strs = task.split( ":" )
  18. assembleTask.modules.add (strs.length > 1 ? strs[strs.length - 2] : "all" );
  19. }
  20. }
  21. return assembleTask
  22. }

3. Component-based decomposition steps and dynamic requirements

3.1 Split Principles

Component-based decomposition is a huge project, especially when it comes to decomposing a large project with hundreds of thousands of lines of code. There are many things to consider. For this reason, I think it can be divided into three steps:

From product demand to development stage to operation stage, functions with clear boundaries began to be split, such as reading module, live broadcast module, etc. These began to be split out in batches

During the split, the modules that cause the component to rely on the main project are further split out, such as the account system, etc.

The final main project is a Host, which contains small functional modules (such as startup images) and the splicing logic between components.

3.2 Dynamic Requirements for Componentization

At the beginning, we mentioned that the ideal code organization form is plug-in, which will have complete runtime dynamics. In the process of migrating to plug-in, we can achieve the following centralized method to improve compilation speed and dynamic update.

For fast compilation, incremental compilation at the component level is used. Before extracting components, you can use code-level incremental compilation tools such as freeline (but databinding support is poor), fastdex, etc.

In terms of dynamic updates, major functional improvements such as adding new components are not supported for the time being. You can temporarily use tools such as method-level hot fixes or function-level Tinker, but the access cost of Tinker is relatively high.

IV. Conclusion

This article is a summary of some ideas I have in the design of the componentization of "Get App". At the beginning of the design, I referred to the existing componentization and plug-in solutions. Standing on the shoulders of giants, I added some of my own ideas, mainly in terms of componentization life cycle and complete code isolation. Especially for the isolation of *** code, there must be not only normative constraints (for interface programming), but also mechanisms to ensure that developers do not make mistakes. I think only by doing this can it be considered a thorough componentization solution.

<<:  Android advanced obfuscation and code protection technology

>>:  Code to handle iOS horizontal and vertical screen rotation

Recommend

To be a good operator, you need to know these 157 tools!

When you first enter operations , your leader ass...

The formula for creating popular educational short video content

As an account operator, I pray for the birth of a...

Advertising strategies for the Internet automobile industry

There has always been a saying circulating in the...

What changes have taken place in social software? Where will it go next?

Boss, I want to buy this mobile phone. Is QQ inst...

Sago is not rice, it’s “coconut”!

How can we miss the icy cold desserts in the hot ...

14 top development communities frequented by foreign programmers

[[146603]] Let's take a look at the 14 top de...

Are the long, thin worms found in Sichuan dragon worms?

Recently, some netizens took photos of long white...