Moment For Technology

Return to the original idea: Minimalist Android componentization solution -- AppJoint

Posted on Dec. 1, 2022, 1:49 p.m. by Susan Jones
Category: android Tag: android app api gradle

The concept of componentization of Android has been discussed since about two years ago. By now, the technology has slowly settled down, and more and more teams are open-source their own componentization frameworks. My team started to investigate the componentization framework last year. After understanding many componentization schemes in the community, we decided to develop the componentization scheme by ourselves. Why would you decide to build a new wheel when you already have a lot of wheels?

The main reason is that after investigating many componentization schemes, it is found that although they all have their own advantages, there are still some places that are not very satisfactory. One of the most important factors is the high cost of introducing componentization scheme, and the transformation of existing projects is too large. I think many people have the same experience. Most of the time, the refactoring of a project needs to be synchronized with the iteration of new requirements. It is almost difficult to stop and just do the componentization of the project.

On the other hand, I don't want my project to be strongly coupled to a componentization framework. Whether it's an Activity routing scheme, or synchronous or asynchronous method calls across modules, I want to be able to follow the project's existing method of calling, rather than using a componentized framework's own specific method of calling. For example, if an interface has been encapsulated as an Observable interface based on RxJava, I don't want to use the componentization framework to call the interface after another module because of componentization. I still want to use RxJava to call the interface.

Return to the beginner's mind

In my opinion, the current projects that want to be componentized can be divided into two categories:

  • A Library module that contains an Application module and some technical components (business independent).
  • In addition to the Application module, several library modules already exist that contain business library modules and technologies.

Regardless of the type of project, the problem is likely to be similar: as the project gets larger, the compilation is too slow.

On top of that, cross-module function calls are very inconvenient, and this problem is most evident in the second type of project listed above. Before componentization, the project I work for is the second type of project listed above. Application module was used to carry business logic code at the earliest. With the development of the business, maybe some developer thought, "No, the number of application module code will get out of control if the business goes on like this". Therefore, a new library module will be opened for the development of new business modules in the future, so there are about 20+ library modules (business-related modules, excluding technical modules).

This approach is in line with the idea of software engineering, but it also brings some thorny problems. Because the business functions in the Application module and the business functions in the Library module are logically equal, it is inevitable to call each other, but they are not in the same position in the level of project dependency. It's fine for application to call the library, but it's a problem to call it the other way around. In addition, the remaining 20 + library modules are not all at the same level in the dependency hierarchy, and the interdependence between library modules is also complex.

So my desired componentization solution requires a simple solution:

  • Business modules are compiled and run separately, rather than spending a lot of time compiling the entire App
  • Should cross-module calls be elegant, so that two modules can simply call each other no matter where they are in the dependency tree
  • Don't have too much learning cost, stick with the existing development approach, and avoid binding your code to a specific componentized framework
  • The process of componentization can be gradual; splitting the code immediately is not a precondition for componentization
  • Lightweight, don't introduce too many intermediate layers (such as serialization and deserialization) that lead to unnecessary performance overhead and maintenance complexity

With this in mind, we developed the AppJoint framework to help us implement componentization.

AppJoint is a very simple and effective solution, introducing AppJoint for componentization. All apis consist of only 3 annotations, plus 1 method. This is probably the simplest componentization solution available today. And the overall implementation is very simple, less than 500 lines of core source code.

Problems encountered when modules run independently

The earliest componentization scheme I contacted was DDComponentForAndroid. Learning this scheme gave me a lot of inspiration. In this scheme, the author proposed that we can add a variable isRunAlone=true in gradle. It is used to control whether a business module is integrated into the full compilation of the App as a Library module or started as an application module independently. I don't know if many people were inspired by this, but many of the componentization frameworks used similar schemes:

if (isRunAlone.toBoolean()) {    
    apply plugin: 'com.android.application'
} else {  
    apply plugin: 'com.android.library'
}
Copy the code

In my own practice, there are some drawbacks to this approach. First, some open source frameworks are used differently in the Library module than in the Application module. For example, ButterKinfe uses R.I.D.xx in the application module. Use R2.id.xxx in the Library module. If you want to componentialize, the code must be available in both cases, so you basically have to abandon ButterKnife, which can be a huge retrofit for the project.

In addition, there are some open source frameworks that can only be configured in the Application module and then work for all library modules in the project, such as bytecode modification frameworks (such as AOP), which is one case. In another case, if the original project already has multiple modules, the initialization of multiple modules may be put in the Application module, because the Application module is the god module, it can access any piece of code in the project, so it is the least trouble to do the initialization here. However, since each module needs to run independently, the module needs to be responsible for its own initialization. However, sometimes the initialization of the module can only be done in the Application module. After we place this logic in the library, how to initialize the module becomes a problem.

In both cases, if we use variables in Gradle. properties to switch between application and library, we will need to maintain two sets of logic in this module. One is the startup logic in Application mode, and the other is the startup logic in application mode. One is the startup logic in library mode. This module, which originally focused on its own business logic, now has to add a lot of other code in order to be able to launch independently as an Application. On the one hand, the build.gradle file will be filled with if-else files, and on the other hand, the Java source code will add a lot of logic to determine whether to run independently.

When the final Release App is packaged, these modules exist as libraries, but we've added a lot of code to the module for componentization to help it run independently (in Application mode) (e.g. You need a Laucher Activity that belongs to this module. Although this code doesn't work online, it should not be packaged as a neat freak. The point of all this is that if we want to use a variable to control whether the module is an Application or a library, then we have to add code to the module to maintain the difference between the two, and maybe a lot of code, and the end state of the code is not very elegant.

In addition, the androidmanifest.xml module also needs to maintain two copies:

if (isRunAlone.toBoolean()) {
    manifest.srcFile 'src/main/runalone/AndroidManifest.xml'
} else {
    manifest.srcFile 'src/main/AndroidManifest.xml'
}
Copy the code

However, XML is not code and inherits these object-oriented features, so whenever we add, modify, or remove the four components, we need to remember to make the corresponding changes in both AndroidManifest.xml. In addition to AndroidManifest.xml, resource files also have this problem. Although it's not a huge amount of work, it actually violates the principles of object-oriented design.

Finally, there is a problem with Gradle Sync every time the module switches between Application mode and Library mode. I think it is already a very slow compile project if it needs to be componentized. Even Gradle Sync requires a lot of waiting time, which is something we're not prepared for.

Create multiple Application modules

How did we finally solve the problem of separate compile-run modules? The answer is to create a new application module for each module. You might wonder: If you had an Application module for each business module to start independently, the modules would be too many and the project would look very messy. However, we can actually include all application modules that are used to independently start business modules in a directory:

projectRoot
  +--app
  +--module1
  +--module2
  +--standalone
  |  +--module1Standalone
  |  +--module2Standalone   
Copy the code

In the above project structure diagram, app module is the entry of fully compiled Application module, module1 and Module2 are two business library modules. Module1Standalone and module2Standalone are two Application modules used to independently start Module1 and Module2, respectively, which are included in the standalone folder. In fact, modules in the standalone directory rarely need to be modified, so the directory is mostly in a collapsed state and does not affect the beauty of the overall project structure.

So the code in settings.gradle at the root of the project looks like this:

// main app
include ':app'
// library modules
include ':module1'
include ':module2'
// for standalone modules
include ':standalone:module1Standalone'
include ':standalone:module2Standalone'
Copy the code

In the build.gradle file of the main App module (App module), we only need to rely on Module1 and Module2. The two standalone modules are only related to the independent startup of their respective business modules. They do not need to be dependent on the App module. So the dependencies in app module build.gradle are as follows:

dependencies {
    implementation project(':module1')
    implementation project(':module1')}Copy the code

The build.gradle file in the standalone application module has only one dependency, which is the library module that needs to be run independently. Standalone/module1Standalone, for example, its corresponding build. The gradle rely on as follows:

dependencies {
    implementation project(':module1')}Copy the code

Create a module in Android Studio. By default, the module is located under the project root directory. If you want to Move the module to a folder, right-click the module and select "Refactor -- Move" to Move it to the specified directory.

Once we have created these Application modules, we can select which module we want to run next to the Run triangle button in Android Studio:

The first thing we can see is that modules no longer need to change gradle.properties files to switch between library and application states, and they no longer need to suffer from gradle Sync wasting valuable development time. If you want to go it alone, go it alone.

Because of the standalone module, which is dedicated to launching independently, the business's Library module needs to be developed as if it were a Library module, without considering that it would become an Application module, So whether it's developing a new business module or transforming an old business module into a componentized form, the work is much easier than before. As mentioned earlier, the configuration and initialization needed for the business module to launch separately can be placed in the standalone module without worrying about the code being packaged into the final Release App. The Launcher Activity mentioned in the previous example to get the module to launch separately, simply place it in the standalone module module.

Androidmanifest.xml and resource file maintenance are also easier. The four major components only need to be modified in the business library module, do not need to maintain two Copies of AndroidManifest.xml, The AndroidManifest.xml in the standalone module only needs to include the differences between the module and the AndroidManifest.xml in the Library module (such as Launcher activities, ICONS, etc.) when the module is launched independently. The compiler tool automatically merges the two files.

It is recommended to specify an applicationId within the standalone module that is different from the main App, i.e. the App that the module launches separately and the main App can coexist within the phone.

Let's take a look at this solution. First of all, the downside compared to the original is that with the introduction of many new standalone modules, the project seems to have become more complex. However, the advantages are obvious, the componentization logic is clearer, especially in the case of old projects, less work is required, and Gradle Sync is not required frequently during development. In general, the modified componentization project is more in line with the design principles of software engineering, especially the Open for Extension (but Closed for Modification) principle.

At this point in the introduction, we have not used any AppJoint API. The reason why we have not used any componentization framework API to launch modules independently is because, as stated at the beginning of this article, we do not want the project to be strongly bound to any componentization framework, including the AppJoint framework itself. The AppJoint framework itself is designed to be loosely coupled to the project, so projects that use the AppJoint framework for componentization should in theory be able to switch to a better componentization solution in the future.

Prepare the Application for each module

Before componentization, we often put the initialization behaviors that need to be completed at startup in a project into a custom Application. According to my project experience, the initialization behaviors can be divided into the following two types:

  • Service related initialization. For example, the server pushes the establishment of a persistent connection, prepares the database, and pulls CMS configuration information from the server.
  • Initialization of non-business related technical components. For example, log tools, statistics tools, performance monitoring tools, crash collection tools, and compatibility solutions.

In the previous step, we set up standalone modules for each business module to run independently, but we cannot start the business modules independently at this time because the module initialization is not complete. When we introduced the design idea of the AppJoint, we said that we wanted componentization to be "less expensive to learn and use the existing development method", so our solution here is to create a custom Application class in each business module. To implement the initialization logic of the business module. Here, take creating a custom Application in Module1 as an example:

@ModuleSpec
public class Module1Application extends Application {

    @Override
    public void onCreate(a) {
        super.onCreate();
        // do module1 initialization
        Log.i("module1"."module1 init is called"); }}Copy the code

As shown in the code above, we create a new custom Application class in Module1 named Module1Application. Should we put all the initialization logic associated with this module in this class? Not exactly.

First, the business-related initializations for the current module mentioned above should undoubtedly go in this Module1Application class, but the initializations for the business-unrelated technical components of the aforementioned module are not a good fit here.

First of all, from the consideration on the logic, the business has nothing to do with the initialization technology components should be placed in a uniform, put them in the main App is suitable in the custom Application class, if each module in order to you can run separate compilation, all want to initialize it again, then all code last amount to compile all together, These initialization behaviors occur several times in the code, which is both unreasonable and potentially problematic.

So, if we make a judgment in Module1Application, if it is in the independent compilation running state, then the initialization of the technical component is performed, and if it is in the full compilation running state, then the initialization of the technical component is not performed, and the Application of the main App implements the logic. Is that ok? This is theoretically possible, but it runs into the same problem as maintaining a variable in Gradle. properties to control whether the module is compiled independently. We don't want to package business-independent logic (logic for business modules to start independently) into the final Release of the App.

So how do you solve this problem? The solution is similar to the previous section; didn't we prepare a module1Standalone module for the Module1 module? Since the initialization of technology-related components is not at the core of the Module1 module, and is only related to the stand-alone startup of the Module1 module, it is most appropriate to place it in the module1Standalone module, as this module will only be used during the stand-alone compilation run of Module1. None of its code will be packaged into the final Release of the App. We can define a Module1StandaloneApplication in module1Standalone class, it inherited from Module1Application:

public class Module1StandaloneApplication extends Module1Application {

    @Override
    public void onCreate(a) {
        // module1 init inside super.onCreate()
        super.onCreate();
        // initialization only used for running module1 standalone
        Log.i("module1Standalone"."module1Standalone init is called"); }}Copy the code

And we in module1Standalone module AndroidManifest. XML set Module1StandaloneApplication to Standalone App in the use of custom Application class:

    application
        android:icon="@mipmap/module1_launcher"
        android:label="@string/module1_app_name"
        android:theme="@style/AppTheme"
        android:name=".Module1StandaloneApplication"
        activity android:name=".Module1MainActivity"
            intent-filter
                action android:name="android.intent.action.MAIN"/
                category android:name="android.intent.category.LAUNCHER"/
            /intent-filter
        /activity
    /application
Copy the code

In the above code, in addition to setting the custom Application, we also set a Launcher Activity (Module1MainActivity), which is the module's launch Activity. Since it only exists during the independent compilation run of the module, the Module1MainActivity is not included when the App is fully packaged, so we can define some functions to facilitate the independent debugging of the module, such as quickly going to a page and creating Mock data.

So, as long as we run separately module1Standalone this module, use the Application class is Module1StandaloneApplication. When we need to debug Module1 separately at development time, we just need to start the module1Standalone module to debug it; When the App needs to be fully compiled, we start the original main App normally. In either case, Module1 is always in the form of a library, which means that if we want to transform the original business module into a componentization module, the amount of modification is much smaller, and our transformation process is mainly about adding code, not modifying code. This is in line with the "open closed principle" of software engineering.

At this point, we still have a problem. Module1Application except by Module1StandaloneApplication inheritance at present, has not been any references to other places. You may be wondering: How do we ensure that the initialization logic in Module1Application is called when the App is fully compiled? As you might have noticed, we defined Module1Application above with an annotation @modulespec:

@ModuleSpec
public class Module1Application extends Application {... }Copy the code

The purpose of this annotation is to inform the AppJoint framework that we need to ensure that the current module's initialization behavior in the Application is called by the main App's Application class at the time of final full compilation. Accordingly, the custom Application class of our main App module (App module) also needs to be marked with an AppSpec annotation, as follows:

@AppSpec
public class App extends Application {... }Copy the code

In the above code, App is the custom Application class corresponding to App. We will mark the @AppSpec annotation above the class so that the system will perform the initialization of the submodule's Application for the corresponding declaration period along with the App's own initialization. That is:

  • AppperformonCreateMethod, the guarantee is also executed simultaneouslyModule1ApplicationModule2ApplicationonCreateMethods.
  • AppperformattachBaseContextMethod, the guarantee is also executed simultaneouslyModule1ApplicationModule2ApplicationattachBaseContextMethods.
  • And so on, whenAppWhen executing a lifecycle method, guarantee the submodule'sApplicationThe corresponding lifecycle method is also executed.

In this way, we use @Modulespec and @AppSpec annotations on the AppJoint to establish a link between the main App's Application and the submodule's Application, ensuring that at full compile run time, The initialization behavior of all business modules is guaranteed to be performed.

So far, we have handled the initialization of the business module in both independent and full compilation run mode. There is a potential problem with the Application. Before componentization, We often save references to the current Appliction in the onCreate cycle of the Applictaion class, and then use the Application object anywhere in the Application. For example:

public class App extends Application {

    public static App INSTANCE;

    @Override
    public void onCreate(a) {
        super.onCreate();
        INSTANCE = this; }}Copy the code

After doing this, we can use the Application Context object anywhere in the project through app.instance. But now, after the modification componentization module1, for example, in a separate operation mode, the Application example of the Application object is Module1StandaloneApplication, and in full amount compile operation mode, The Application object of an Application is an instance of the App in the main App module. How can we get the currently used Application instance anywhere in the project as before?

We can change the type of the Application instance stored inside all custom applications in the project from the specific custom class to the standard Application type, taking Module1Application as an example:

@ModuleSpec
public class Module1Application extends Application {
    
    public static Application INSTANCE;

    @Override
    public void onCreate(a) {
        super.onCreate();
        INSTANCE = (Application)getApplicationContext()
        // do module1 initialization
        Log.i("module1"."module1 init is called"); }}Copy the code

As you can see, the INSTANCE type would normally be the concrete custom type Module1Application, but now we have changed it to Application. Also, the onCreate method assigns INSTANCE to INSTANCE instead of INSTANCE = this, but INSTANCE = (Application)getApplicationContext(). So after processing, can guarantee the code inside the module1, whether in the App all quantity compilation mode, or independent debugging mode, can all be Module1Application. INSTANCE access to the current Application INSTANCE. This is because the AppJoint framework ensures that when the attachBaseContext callback is called on the main App's App object, the attachBaseContext callback is also called on all componentized business modules' applications.

In that case, we used anywhere in the module1 module Module1Application. The INSTANCE can always get the Application INSTANCE correctly. Corresponding, we use the same method in module2 this module, also can use in any location Module2Application. INSTANCE correctly to obtain the Application INSTANCE, and don't need to know the state in a separate compilation or full amount compiler running state.

You must never rely on an instance of the Application class defined by a business module itself (such as an instance of Module1Application), because it may not be the actual Application instance used at run time.

We have solved the problem of how the initialization logic of business modules should be organized in separate compilation run mode and in full App compilation mode. We followed the familiar custom Application scheme to host the initialization behavior of each module, and used the Glue of AppJoint to integrate the initialization logic of each module into the final fully compiled App. The AppJoint API is just a couple of annotations. It is a good example that AppJoint is a low cost tool to learn from, and we can use our existing development methods instead of changing our original code logic and resulting in over-coupling between the project and the componentizing framework.

Calls to methods across modules

Although it is now possible for each module to be compiled and run independently, there is one important problem that we have not solved in developing a mature App, that is, cross-module method calls. Because our business modules should have the same status, whether from the perspective of business logic or the position in the dependency tree, reflected in the dependency level, these business modules should be horizontal and have no dependence on each other:

The figure above shows the final state of componentization in an ideal situation. App Module does not carry any business logic, and its function is merely to integrate the functions of N modules, Module1 ~ Module(n), into a complete App as an application shell. Module1 ~ Module(n) These N modules do not have any cross dependencies with each other and each contains only its own business logic. This approach, while achieving decoupling between business modules, presents us with a new challenge: it is a very common and reasonable requirement for business modules to call each other's functions, but because these modules are at the same level of dependency, it is obvious that they cannot be called directly.

Moreover, the form shown above is the ultimate ideal of componentization, and if we were to transform the project to achieve this state, it would no doubt cost us a lot of time. During the rapid business iteration, this is a cost we cannot afford, so we can only gradually transform the project. That is to say, the business code in the App module is gradually dismantled to form a new independent module, which means that there is still business code in the App for a long time in the componenization process. The business logic code in the disassembled module is likely to call the code in the App module. This is an awkward state in which code at the lower level of the dependency hierarchy calls code at the higher level of the dependency hierarchy.

In this case, we can easily think of creating a new module, such as router module. In this module, we define the methods that all business modules want to expose to other modules, as shown in the figure below:

projectRoot
  +--app
  +--module1
  +--module2
  +--standalone
  +--router
  |  +--main
  |  |  +--java
  |  |  |  +--com.yourPackage
  |  |  |  |  +--AppRouter.java
  |  |  |  |  +--Module1Router.java
  |  |  |  |  +--Module2Router.java
Copy the code

In the above project hierarchy, we define three interfaces under the new Router module:

  • AppRouterThe interface is declaredappModules are exposed tomodule1,module2Method of.
  • Module1RouterThe interface is declaredmodule1Modules are exposed toapp,module2Method of.
  • Module2RouterThe interface is declaredmodule2Modules are exposed tomodule1,appMethod of.

For example, the AppRouter interface is defined as follows:

public interface AppRouter {

    /** * Normal synchronous method calls */
    String syncMethodOfApp(a);

    /** * Asynchronous methods wrapped in RxJava form */
    ObservableString asyncMethod1OfApp(a);

    /** * An asynchronous method wrapped in the form Callback */
    void asyncMethod2OfApp(CallbackString callback);
}
Copy the code

We define one synchronous method and two asynchronous methods in the AppRouter interface, which the app module needs to expose to Module1 and Module2, and the app module itself needs to provide the implementation of this interface. So first we need to rely on the router module in the build.gradle files of app, Module1 and Module2:

dependencies {
    // Other dependencies. api project(":router")}Copy the code

The way to rely on the Router module here is to use the API rather than implementation in order to expose the router module's information to the standalone module that depends on the business modules. The APP module is not constrained by the above because no other module depends on it. We can write it as implementation dependencies.

Then we go back to the App module and provide an implementation for the AppRouter interface we just defined on the Router:

@ServiceProvider
public class AppRouterImpl implements AppRouter {

    @Override
    public String syncMethodOfApp(a) {
        return "syncMethodResult";
    }

    @Override
    public ObservableString asyncMethod1OfApp(a) {
        return Observable.just("asyncMethod1Result");
    }

    @Override
    public void asyncMethod2OfApp(final CallbackString callback) {
        new Thread(new Runnable() {
            @Override
            public void run(a) {
                callback.onResult("asyncMethod2Result"); } }).start(); }}Copy the code

We can see that the way we expose methods in an App module to other modules is not that different from the way we would normally write code, by declaring an interface for other modules and internally writing an implementation class for that interface. Whether it's synchronous or asynchronous, whether it's Callback or RxJava, we can use our original development methods. The only difference is that we have a @ServiceProvider annotation above the AppRouterImpl implementation class. This annotation is used to inform the AppJoint framework to establish a connection between AppRouter and AppRouterImpl. This allows other modules to find an instance of AppRouter through the AppJoint and call the methods inside.

Suppose that module1 needs to call the asyncMethod1OfApp method in the app module, and since the APP module has declared this method in the AppRouter interface of the Router module, Module1, which also relies on the Router module, has access to the AppRouter interface but not to the AppRouterImpl implementation class, which is defined in the app module. We can use the AppJoint to help Module1 get an instance of AppRouter:

AppRouter appRouter = AppJoint.service(AppRouter.class);

// Get the result of the synchronous call
String syncResult = appRouter.syncMethodOfApp();
// Make an asynchronous call
appRouter.asyncMethod1OfApp()
        .subscribe((result) - {
            // handle asyncResult
        });
// Make an asynchronous call
appRouter.asyncMethod2OfApp(new CallbackString() {
    @Override
    public void onResult(String data) {
        // handle asyncResult}});Copy the code

In the above code, we can see that in addition to the first step to get an instance of the AppRouter interface we used the AppJoint API appJoint. service, the rest of the code, the way module1 calls methods in the app module, It's not any different from the way we used to develop. AppJoint. Service is the only method in the AppJoint API.

In other words, if a module needs to provide methods for other modules to call, it needs to do the following:

  • Declare the interface inrouterIn the module
  • Implement the interface declared in the previous step within your own module, while marking the implementation class@ServiceProviderannotations

After completing these two steps, you can get an instance of the interface declared by that module in another module and call its methods in the following way:

AppRouter appRouter = AppJoint.service(AppRouter.class);
Module1Router module1Router = AppJoint.service(Module1Router.class);
Module2Router module2Router = AppJoint.service(Module2Router.class);
Copy the code

This method not only ensures that business modules in the same dependency level can call each other's methods, but also supports calling methods in the APP module from the business module. In this way, we can ensure that our componentization process can be gradual. We don't need to split all the functions in the APP module into each business module at one time. We can gradually split the functions out to ensure that our business iteration and componentization transformation can be carried out at the same time. When we have fewer and fewer methods in our AppRouter until we can safely remove the class from the project, our componentization transformation is complete.

Calls to cross-module methods in module independent compilation run mode

In the previous summary we have described a solution for cross-module method calls between business modules using the AppJoint during the full compile run of the App. During full compilation, we can use the appjoin.service method to find an instance of the interface provided by the specified module, but during the separate compilation run of the module, other modules do not participate in the compilation and their code is not packaged into the standalaone module for the module to run independently. How do we solve the problem that code that is invoked across modules still works when modules are compiled and run separately?

Take Module1 as an example. First, to make it easy to call the methods of other modules from anywhere inside Module1, we create a RouterServices class to hold instances of interfaces of other modules:

public class RouterServices {
    // Interface exposed by the APP module
    public static AppRouter sAppRouter = AppJoint.service(AppRouter.class);
    // Module2 Exposed interface of the module
    public static Module2Router sModule2Router = AppJoint.service(Module2Router.class);
}
Copy the code

After this class, we if need to call the function of other modules within the module1, we only need to use RouterServices. SAppRouter and RouterServices sModule2Router these two objects. But in the case of the Just mentioned Module1 standalone compilation run, where the application module started is module1Standalone, So RouterServices. SAppRouter and RouterServices sModule2Router this both the value of the object is null, this is because the app and module2 this two modules at this point is not to be compiled in.

If in this case we need to ensure that the existing module1 internal through RouterServices. SAppRouter and RouterServices sModule2Router across module method invocation code can still work, We need to manually assign values to these two references, that is, we need to create classes that Mock the AppRouter and Module2Router functions. Since these classes are only meaningful for a stand-alone compilation run of Module1, the most appropriate place for these classes is within the module1Standalone module. Take AppRouterMock, AppRouter's Mock class:

public class AppRouterMock implements AppRouter {
    @Override
    public String syncMethodOfApp(a) {
        return "mockSyncMethodOfApp";
    }

    @Override
    public ObservableString asyncMethod1OfApp(a) {
        return Observable.just("mockAsyncMethod1OfApp");
    }

    @Override
    public void asyncMethod2OfApp(final CallbackString callback) {
        new Thread(new Runnable() {
            @Override
            public void run(a) {
                callback.onResult("mockAsyncMethod2Result"); } }).start(); }}Copy the code

Now that we've created our Mock class, we need to replace the corresponding reference in RouterServices with the Mock class object in module1's independent compilation mode. Since this logic is only relevant to Module1's independent compilation mode, We don't want the logic are packaged into real in the Release of the App, so the most appropriate place is in the Module1StandaloneApplication:

public class Module1StandaloneApplication extends Module1Application {

    @Override
    public void onCreate(a) {
        // module1 init inside super.onCreate()
        super.onCreate();
        // initialization only used for running module1 standalone
        Log.i("module1Standalone"."module1Standalone init is called");

        // Replace instances inside RouterServices
        RouterServices.sAppRouter = new AppRouterMock();
        RouterServices.sModule2Router = newModule2RouterMock(); }}Copy the code

With the above after initialization actions, we can safely use within module1 RouterServices. SAppRouter and RouterServices sModule2Router across the two objects for module method calls, Whether you are currently in full App compilation mode or in modul1Standalone compilation run mode.

Start activities and fragments across modules

In addition to cross-module method calls, cross-module initiation of activities and cross-module reference of fragments are common requirements encountered during componentization. At present, most componentization schemes in the community use custom private protocols and url-scheme to implement the launch of cross-module activities. There are many mature schemes in this area, and some componentization schemes directly recommend using ARouter to implement this function. But the AppJoint does not use such a solution.

As described at the beginning of this article, the AppJoint API consists of only three annotations and one method, and we have covered all of these apis in the previous article. That is to say, we do not provide a dedicated API for cross-module Activity/Fragment calls.

Recall that without componentization, the recommended way to start an Activity is to first implement a static start method in the Activity being started:

public class MyActivity extends AppCompatActivity {

    public static void start(Context context, String param1, Integer param2) {
        Intent intent = new Intent(context, MyActivity.class);  
        intent.putExtra("param1", param1);  
        intent.putExtra("param2", param2);  
        context.startActivity(intent);
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState); . }}Copy the code

Then, if we start MyActivity in another Activity, we would write:

MyActivity.start(param1, param2);
Copy the code

The idea here is that the provider of the service should keep the complex logic to itself and provide the caller with a simple interface that isolates the complexity of the implementation, which is consistent with software engineering thinking.

So what if there is currently a Module1Activity in Module1, and now the Activity wants to be able to start from Module2? First, declare the method to start Module1Activity in the Module1Router of the Router module:

public interface Module1Router {.../ / start Module1Activity
    void startModule1Activity(Context context);
}
Copy the code

Then implement the method just defined in module1 in the Module1Router implementation class Module1RouterImpl:

@ServiceProvider
public class Module1RouterImpl implements Module1Router {...@Override
    public void startModule1Activity(Context context) {
        Intent intent = newIntent(context, Module1Activity.class); context.startActivity(intent); }}Copy the code

In this way, Module1Activity in Module1 can be started in Module2 in the following way.

RouterServices.sModule1Router.startModule1Activity(context);
Copy the code

A similar method is used to obtain Fragment instances across modules. We continue to declare methods in the Module1Router:

public interface Module1Router {.../ / start Module1Activity
    void startModule1Activity(Context context);

    / / get Module1Fragment
    Fragment obtainModule1Fragment(a);
}
Copy the code

Similarly, we simply implement the method in the Module1RouterImpl:

@ServiceProvider
public class Module1RouterImpl implements Module1Router {
    @Override
    public void startModule1Activity(Context context) {
        Intent intent = new Intent(context, Module1Activity.class);
        context.startActivity(intent);
    }

    @Override
    public Fragment obtainModule1Fragment(a) {
        Fragment fragment = new Module1Fragment();
        Bundle bundle = new Bundle();
        bundle.putString("param1"."value1");
        bundle.putString("param2"."value2");
        fragment.setArguments(bundle);
        returnfragment; }}Copy the code

As mentioned earlier, most componentization schemes in the community currently use custom proprietary protocols and url-scheme for cross-module page hopping, which is similar to ARouter's Scheme. Why not adopt this Scheme in AppJoint?

The reason is actually quite simple. Assuming there is no componentization requirement in the project, if we jump an Activity in the same module, we will definitely not jump in url-scheme mode, and we must create an Intent to jump by ourselves. In fact, in the final analysis, urL-scheme is the last resort to jump, it is only a means, not an end, because after componentization, the activities between modules become invisible, so we use URL-scheme to jump instead.

The AppJoint now supports jumping in code again, simply abstracting the jump logic as a method in the interface and exposing it to other modules that can invoke the method to implement the jump logic. Besides, what are the advantages of using interfaces to provide jump logic over url-scheme?

  1. Type safety. Taking advantage of the compiler checking capabilities of a statically typed language like Java, jump methods exposed through an interface, whether passing parameters or returning values, are found to have type errors at compile time, whereas jump methods exposed through url-scheme are found to have type errors only at run time.

  2. High efficiency. Even if url-scheme is used for jump, the underlying Intent is still constructed for jump, but the process of constructing and parsing the jump URL is introduced, which involves additional serialization and deserialization logic, and reduces the execution efficiency of the code. Using the jump logic provided by the interface, we directly construct the Intent to jump without any additional serialization or deserialization, which is just as efficient as our daily Activity jump logic.

  3. IDE friendly. When using URl-scheme to jump, the IDE cannot provide any intelligent hints, and only rely on perfect documents or developer's own checks to ensure the correctness of the jump logic. Providing the jump logic through the interface can maximize the intelligent hints of the IDE to ensure that our jump logic is correct.

  4. Easy to refactor. Url-scheme is used for jump. If the jump logic needs to be reconstructed, such as Activity name modification, parameter name modification, parameter number increment or deletion, it can only rely on the developer to modify the places used in the jump logic one by one, and it can not ensure that all the changes are correct. Because the compiler can't check it for us. When the jump logic code provided through the interface needs to be refactored, the compiler can automatically help us check and report errors at compile time if anything is not correct, and the IDE provides intelligent refactoring, which allows us to easily refactor the methods defined in the interface.

  5. Low cost of learning. We can follow the development pattern we are familiar with, without having to learn the URl-Scheme jump framework API. This also ensures that our jump logic is not strongly bound to a specific framework. We isolate the actual implementation of the jump logic through the interface, and even if we use the AppJoint to jump, we can switch the jump logic to other solutions, including url-scheme, at any time.

In my personal practice, at present, all page hops in the same process in the project have been implemented by the way of AppJoint. Currently, only the page launch across processes is given to url-scheme (for example, wake up a page of the App from the browser).

Finally, since cross-module launch activities follow the development of cross-module method calls, we also need to Mock these launch methods when the business module is separately compiled and run. Since we are debugging a business module independently, we certainly don't really want to jump to those pages, we can just print the Log or Toast directly in the Mock method.

Start the componentization now

This concludes the introduction to componentization using the AppJoint. The AppJoint is available on Github: github.com/PrototypeZ/... . With less than 500 lines of core code, you can quickly pick up this tool to speed up your componentized development by simply forking a copy of the code. If you don't want to import the project yourself, we also provide an out-of-the-box version that you can import directly through Gradle.

  1. At the root of the projectbuild.gradleAdd to fileAppJoint plug-inRely on:
buildscript {
    ...
    dependencies {
        ...
        classpath 'io.github.prototypez:app-joint:{latest_version}'}}Copy the code
  1. Add AppJoint dependencies to the main App module and each componentized module:
dependencies {
    ...
    implementation "io.github.prototypez:app-joint-core:{latest_version}"
}
Copy the code
  1. Apply the AppJoint plugin in the main App module:
apply plugin: 'com.android.application'
apply plugin: 'app-joint'
Copy the code

Write in the last

The AppJoint is a simple componentization solution. Simple, but straightforward, and useful, although it doesn't have as many powerful apis as other componentization solutions, but it's good enough for most small to medium sized projects. This has been our consistent design philosophy.

If you find this project helpful, I hope you can click a Star, thank you:). The article is very long, thank you for your patience. Due to my limited ability, there may be flaws in the article, welcome to corrections, thank you again!


If you are interested in my technology sharing, please follow my personal public account: Muggle Diary, and update original technology sharing from time to time. Thank you! :)

Search
About
mo4tech.com (Moment For Technology) is a global community with thousands techies from across the global hang out!Passionate technologists, be it gadget freaks, tech enthusiasts, coders, technopreneurs, or CIOs, you would find them all here.