vicent

学习积累

知识的积累需要时间,那就一步一步记录下来吧

Android Component-Based Architecture Design

Why Componentization is Needed#

Small projects do not require componentization. When a project has dozens of developers, compiling the project takes 10 minutes, and fixing a bug may affect other functionalities, even minor changes require regression testing. If this is the case, then we need to implement componentization.

Componentization vs. Modularization#

In the evolution of technical architecture, modularization appears before componentization because componentization addresses the issues of modularization.

Modular Architecture#

After creating a Project, multiple Modules can be created, and these Modules are what we refer to as modules. A simple example is that when writing code, we might separate the home, messages, and my modules, where the content contained in each tab is a module. This can reduce the amount of code in the module, but there will definitely be page transitions and data passing between each module. For instance, if Module A needs data from Module B, we would depend on Module B in Module A's gradle file through implementation project(':B'). However, if Module B needs to transition to a certain page in Module A, then Module B also depends on Module A. This development model still does not achieve decoupling; fixing one bug can still affect many modules and does not solve the problems of large projects.

Componentized Architecture#

Here are a few concepts: the components we develop for daily business requirements are called business components. If this business requirement can be universally reused, it is called a business basic component. Framework components like image loading and network requests are referred to as basic components. The app component that builds all components is called the shell component/project.

Here are a few concepts: the components we develop for daily business requirements are called business components. If this business requirement can be universally reused, it is called a business basic component. Framework components like image loading and network requests are referred to as basic components. The app component that builds all components is called the shell component/project. Next, let's look at an architecture diagram: image

Solid lines represent direct dependencies, while dashed lines represent indirect dependencies. For example, the shell project must depend on business basic components, business components, and the module_common public library. Business components depend on business basic components, but not directly; instead, they achieve indirect calls through "sinking interfaces". The dependencies between business components are also indirect. Finally, the common component depends on all required basic components, and common also belongs to basic components; it just unifies the versions of the basic components while also providing some abstract base classes for applications, such as BaseActivity, BaseFragment, and basic component initialization.

Advantages of Componentization#

Accelerated Compilation Speed: Each business component can run and debug independently, increasing speed several times. For example, the compilation time for the video component alone is 3 seconds because AS only runs the video component and the tasks of the components it depends on. In contrast, if integrated, the compilation time is 10 seconds, and all tasks of the components referenced by the app will execute. Thus, efficiency is improved by 3 times.

Improved Collaboration Efficiency: Each component has a dedicated maintainer, and there is no need to worry about how other components are implemented; they only need to expose the data needed by each other. Testing does not require a full regression; only the modified components need to be tested.

Function Reuse: Code written once can be reused everywhere, eliminating the need to copy code. Especially for basic components and business basic components, callers can integrate and use them with a single click based on documentation.

It was mentioned earlier that non-large projects generally do not undergo componentization, but as noted above, this advantage of function reuse is not limited to large projects. We can fully adopt the idea of componentization when writing requirements or libraries, writing them as a basic component or business basic component. When the second project comes along and also needs this component, we save the time of extracting this component (because writing requirements can easily lead to significant coupling, and subsequent splitting can take time), such as login components, sharing components, etc., which can all be written as components from the start.

Problems to Solve in Componentization#

How to achieve independent debugging of business components?

How to implement page transitions between business components without dependencies?

How to achieve data communication between business components without dependencies?

How to dispatch the Application lifecycle of the shell project?

Independent Debugging#

Single Project Solution#

The so-called single project solution is to place all components under one project. Let's first look at the overall directory:

image

ps: Modules starting with module_ represent basic components, those with fun_ prefix represent business basic components, biz_ prefix represents business components, and export_ prefix represents interfaces exposed by business components.

Analysis of the pros and cons of a single project:

  • Pros: After modifying a module, it only needs to be compiled once, and other modules that depend on it can immediately perceive the changes.
  • Cons: It does not achieve complete responsibility separation; each module's developer has permission to modify other modules.

First, declare a variable in the gradle.properties file:

// gradle.properties
isModule = true

When isModule is true, it indicates that the component can run as an APK; false means the component can only act as a library. We can change this value as needed and sync the gradle.

Then, in the build.gradle file of a certain module, use this variable to make three judgments:

// build.gradle

// Distinguish between application and library
if(isModule.toBoolean()) {
	apply plugin: 'com.android.application'
}else {
	apply plugin: 'com.android.library'
}

android {
	defaultConfig {
		// If it's an application, specify applicationId
		if(isModule.toBoolean()) {
			applicationId "com.xxx.xxx"
		}
	}
	sourceSets {
		main {
			// Distinguish between application and library AndroidManifest files
			if(isModule.toBoolean()) {
				manifest.srcFile 'src/main/debug/AndroidManifest.xml'
			}else {
				manifest.srcFile 'src/main/AndroidManifest.xml'	
			}
		}
	}
}

Since libraries do not require an Application and a launch Activity page, we need to distinguish this file; the path specified for the application manifest is not specific, and we can create it anywhere. In the application AndroidManifest.xml, we need to set the launch page:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.sun.biz_home">
    <application
        android:allowBackup="true"
        android:label="@string/home_app_name"
        android:supportsRtl="true"
        android:theme="@style/home_AppTheme">
        <activity android:name=".debug.HomeActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

The library's AndroidManifest.xml does not need these:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.sun.biz_home">
</manifest>

There are two main ways for gradle to depend on modules:

  • implementation: A implementation B, B implementation C, but A cannot access C's elements.
  • api: A api B, B api C, A can access C's elements.

Generally, we only need to use implementation; api can lengthen project compilation time and introduce unnecessary functionalities from that module, leading to severe coupling between codes. However, module_common is a public library that unifies the versions of basic components, so all components should depend on it and have the capability of basic components:

dependencies {
	implementation project(':module_common')
}

And the common component should depend on basic components using api because it passes the capabilities of basic components to upper-level business components:

dependencies {
	api project(':module_base')
	api project(':module_util')
}

Multi-Project Solution#

Multi-project means that each component is its own project. For example, after creating a project, the app acts as the shell component, and it depends on biz_home to run, so there is no need for isModule to control independent debugging; it is inherently a project that can be debugged independently.

The pros and cons of multi-project are the opposite of single project:

  • Pros: Achieves complete responsibility separation, making it easier to reuse in other projects, with a direct line of dependency introduction.
  • Cons: After modification, it needs to be uploaded to the maven repository, and other projects must compile again to perceive the changes, adding upload and compilation time.

Multi-project component dependencies need to use the maven repository. Upload each component's AAR to the company's internal maven repository, and then depend on it like this:

implementation 'com.xxx.xxx:module_common:1.0.0'

We manage third-party libraries uniformly in config.gradle:

ext {
    dependencies = [
            "glide": "com.github.bumptech.glide:glide:4.12.0",
            "glide-compiler": "com.github.bumptech.glide:compiler:4.12.0",
            "okhttp3": "com.squareup.okhttp3:okhttp:4.9.0",
            "retrofit": "com.squareup.retrofit2:retrofit:2.9.0",
            "retrofit-converter-gson"  : "com.squareup.retrofit2:converter-gson:2.9.0",
            "retrofit-adapter-rxjava2" : "com.squareup.retrofit2:adapter-rxjava2:2.9.0",
            "rxjava2": "io.reactivex.rxjava2:rxjava:2.2.21",
            "arouter": "com.alibaba:arouter-api:1.5.1",
            "arouter-compiler": "com.alibaba:arouter-compiler:1.5.1",
            // our lib
            "module_util": "com.sun.module:module_util:1.0.0",
            "module_common": "com.sun.module:module_common:1.0.0",
            "module_base": "com.sun.module:module_base:1.0.0",
            "fun_splash": "com.sun.fun:fun_splash:1.0.0",
            "fun_share": "com.sun.fun:fun_share:1.0.0",
            "export_biz_home": "com.sun.export:export_biz_home:1.0.0",
            "export_biz_me": "com.sun.export:export_biz_me:1.0.0",
            "export_biz_msg": "com.sun.export:export_biz_msg:1.0.0",
            "biz_home": "com.sun.biz:biz_home:1.0.0",
            "biz_me": "com.sun.biz:biz_me:1.0.0",
            "biz_msg": "com.sun.biz:biz_msg:1.0.0"
    ]
}

This makes version management convenient, and then in the root directory's build.gradle, we import:

apply from: 'config.gradle'

Finally, in each module, we can introduce dependencies, for example, in module_common, we can introduce dependencies like this:

dependencies {
	api rootProject.ext.dependencies["arouter"]
    kapt rootProject.ext.dependencies["arouter-compiler"]
    api rootProject.ext.dependencies["glide"]
    api rootProject.ext.dependencies["okhttp3"]
    api rootProject.ext.dependencies["retrofit"]
    api rootProject.ext.dependencies["retrofit-converter-gson"]
    api rootProject.ext.dependencies["retrofit-adapter-rxjava2"]
    api rootProject.ext.dependencies["rxjava2"]
    api rootProject.ext.dependencies["module_util"]
    api rootProject.ext.dependencies["module_base"]
}

I personally think multi-project is suitable for "very large" projects, where each business component may require a group to develop, similar to an app like Taobao. However, this is only for business components; the modification frequency of business basic components and basic components will not be very high, so it is best to upload them as single projects to the maven repository for use. The example in this article is written together for convenience; the best approach is to split all components starting with fun_ and module_ into independent single projects, while writing business components in one project.

Page Transition#

After isolating the components, the most obvious problem that arises is the issue of page transitions and data communication. Generally, page transitions are done using startActivity, which is not applicable in componentized projects. Implicit transitions can be used, but each Activity must write an intent-filter, which can be cumbersome, so the best way is to use a routing framework.

In fact, there are already mature routing frameworks on the market specifically designed for componentization, such as Meituan's WMRouter and Alibaba's ARouter. This example uses the ARouter framework; let's look at the basic operations for page transitions with ARouter.

First, we must introduce dependencies. Taking module_common as an example for introducing ARouter, build.gradle should add:

android {
	defaultConfig {
		javaCompileOptions {
       annotationProcessorOptions {
          arguments = [AROUTER_MODULE_NAME: project.getName()]
     	 }
    }
	}
	compileOptions {
      sourceCompatibility JavaVersion.VERSION_1_8
      targetCompatibility JavaVersion.VERSION_1_8
  }
}
dependencies {
	api rootProject.ext.dependencies["arouter"]
    kapt rootProject.ext.dependencies["arouter-compiler"]
}

Kapt annotation dependencies cannot be passed, so we inevitably need to declare these configurations in each module, except for the line api rootProject.ext.dependencies["arouter"]. Then we need to globally register ARouter, which I do in module_common.

class AppCommon: BaseApp{
    override fun onCreate(application: Application) {
        MLog.d(TAG, "BaseApp AppCommon init")
        initARouter(application)
    }

    private fun initARouter(application: Application) {
        if(BuildConfig.DEBUG) {
            ARouter.openLog()
            ARouter.openDebug()
        }
        ARouter.init(application)
    }
}

Next, we declare a routing table in the module_common module for unified path management.

// RouterPath.kt
class RouterPath {
    companion object {
        const val APP_MAIN = "/app/MainActivity"
        const val HOME_FRAGMENT = "/home/HomeFragment"
        const val MSG_FRAGMENT = "/msg/MsgFragment"
        const val ME_FRAGMENT = "/me/MeFragment"
        const val MSG_PROVIDER = "/msg/MsgProviderImpl"
    }
}

Then, we annotate the MainActivity class file:

@Route(path = RouterPath.APP_MAIN)
class MainActivity : AppCompatActivity() {
}

Any module can simply call ARouter.getInstance().build(RouterPath.APP_MAIN).navigation() to achieve the transition. If we want to add data transmission, it is also very convenient:

ARouter.getInstance().build(RouterPath.APP_MAIN)
            .withString("key", "value")
            .withObject("key1", obj)
            .navigation()

Then, in MainActivity, we use dependency injection to receive the data:

class MainActivity : AppCompatActivity() {
    @Autowired
    String key = ""
}

Arouter Solution#

In the export_biz_msg component, declare IMsgProvider, which must implement the IProvider interface:

interface IMsgProvider: IProvider {
    fun onCountFromHome(count: Int = 1)
}

Then implement this interface in the biz_msg component:

@Route(path = RouterPath.MSG_PROVIDER)
class MsgProviderImpl: IMsgProvider {
    override fun onCountFromHome(count: Int) {
    	  // This just dispatches the data; objects listening for the count will receive it
        MsgCount.instance.addCount(count)
    }
    override fun init(context: Context?) {
        // Called when the object is initialized
    }
}

In the biz_home home component, send the count:

val provider = ARouter.getInstance().build(RouterPath.MSG_PROVIDER).navigation() as IMsgProvider
provider.onCountFromHome(count)

As you can see, the method is essentially the same as for page transitions, including how to obtain Fragment instances. ARouter uses a single API to implement all communication methods, making it very easy for users to get started.

#

Application Lifecycle Dispatch#

When the app shell project starts the Application initialization, it needs to notify other components to initialize some functionalities. Here is a simple way to do this.

First, we declare an interface BaseApp in the module_common public library:

interface BaseApp {
    fun onCreate(application: Application)
}

Then each component must create an App class that implements this interface, for example, the biz_home component:

class HomeApp: BaseApp {
    override fun onCreate(application: Application) {
     		// Initialize everything here
        MLog.d(TAG, "BaseApp HomeApp init")
    }
}

The last step is to dispatch the application lifecycle from the app shell project, using reflection:

val moduleInitArr = arrayOf(
    "com.sun.module_common.AppCommon",
    "com.sun.biz_home.HomeApp",
    "com.sun.biz_msg.MsgApp",
    "com.sun.biz_me.MeApp"
)
class App: Application() {
    override fun onCreate() {
        super.onCreate()
        initModuleApp(this)
    }
    private fun initModuleApp(application: Application) {
        try {
            for(appName in moduleInitArr) {
                val clazz = Class.forName(appName)
                val module = clazz.getConstructor().newInstance() as BaseApp
                module.onCreate(application)
            }
        }catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

We only need to know the fully qualified names of each class that implements the BaseApp interface and write them into the moduleInitArr array. Then, we use reflection to obtain the Class object to create an instance through the constructor, and finally call the BaseApp's onCreate method to pass the application. Each Application lifecycle method can be passed in this way.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.