為什么要組件化
- 代碼隔離
- 功能復用
- 單獨編譯
- 應用安全
組件化前提
- 避免循環依賴
- 組件之間完全平等
- 組件層次清晰
組件化分層結構
- App 殼工程:負責管理各個業務組件和打包 APK,沒有具體的業務功能
- 業務組件層:根據不同的業務構成獨立的業務組件,其中每個業務組件包含一個對外暴露的接口,以及對應的接口實現
- 功能組件層:對上層提供基礎功能服務,如日志服務,網絡服務等
- 組件基礎設施:頁面路由服務、消息總線、組件功能加載器等
組件化分類
- 單工程方案:App 殼工程和組件在相同的工程中。本文重點講這個,懂個這個,就能覆蓋大多數場景了
- 多工程方案:App 殼工程和組件在不同的工程中,此方案本文不講,懂了"單工程方案"就懂了這個了
組件化實現過程
1. 殼工程與業務組件
組件化,也可以叫做模塊化。每個新建的 Android 工程默認有一個 app module,然后還可以通過 File -> New -> New Module 選項新建一個 module。我們可以把 app module 稱為殼工程,把新建的 module 稱為業務組件。
在 Android Studio 中,使用 Gradle 構建時,Android Gradle 中提供了三種插件,在開發中可以通過配置不同的插件來配置不同的module類型。我們使用 Application 和 Library 兩種插件。
- Application 插件:id: com.android.application。作用是配置一個 Android App 工程,項目構建后輸出一個 APK 安裝包
- Library 插件:id: com.android.library。作用是配置一個 Android Library 工程,構建后輸出 ARR 包
顯然,App 殼工程就是配置 Application 插件,業務組件就是配置 Library 插件。
注意點:
-
值得一提的是,新建 module 時,Android Studio 會根據選擇的不同,自動分配不同的插件:
- 如果選擇的是 1,則 Android Studio 會自動使用 Application 插件,並生成對應的包結構
- 如果選擇的是 2,則 Android Studio 會自動使用 Library 插件,並生成對應的包結構
-
Android Studio 新建 Module 時,會自動向 settings.gradle 文件中添加 Module。如果我們不使用 Android Studio 提供的 New Module 選項,而是手動創建 Module,那么我們需要向 settings.gradle 中手動添加創建好的 module,以便 gradle 能正確識別生效的 module。
殼工程與業務組件創建好后,工程包結構如下:
-
如果有多個業務模塊,那么可以收斂到一個業務包 lib 下,創建時可以使用下圖的 module 名:
創建好后,項目結構長這樣:
settings.gradle 里的結構是這樣,包名為 ":lib:module_chat"/":lib:module_mine":
2. 單獨編譯與變量定義
想要實現業務組件的單獨編譯,就需要把配置改為 Application 插件;而調試完成后,又需要變回 Library 插件以進行集成調試。如何讓組件在這兩種調試模式之間自動轉換呢?當然可以手動修改組件的 gralde 文件,但是如果項目有幾十個組件,那一個個的改可就太讓人難受了。所以我們需要尋找另外一種方法。下面直接說結論。
Gradle 支持三種 Properties, 這三種 Properties 的作用域和初始化階段都不一樣:
- System Properties(Root Project Properties):
- 可通過 gradle.properties 文件,環境變量 或 命令行 -D 參數 設置
- 可在 setting.gradle 或 build.gradle 中動態修改,在 setting.gradle 中的修改對 buildscript 配置塊可見
- 所有工程可見,不建議在 build.gradle 中修改
- 多子工程項目中,子工程的 gradle.properties 會被忽略掉,只有 root 工程的 gradle.properties 有效
- Project Properties:
- 可通過 gradle.properties 文件,環境變量 或 命令行 -P 參數 設置
- 可在 build.gradle 中動態修改,但引用不存在的 project properties 會立即拋錯
- 動態修改過的 project properties 對 buildscript 配置塊中不可見
- Project ext properties:
- 可在項目的 build.gradle 中聲明和使用,本工程和子工程可見
- 不能在 setting.gradle 中訪問
- Other properties:
- 自定義 properties 文件
- 訪問方式比較特別(下面講解)
注意:buildscript 優先於 build.gradle 中的其他內容執行,注意變量的使用范圍。
- System Properties 方式定義變量
根據上面的描述,我們可以在 System Properties 中定義調試切換開關,即在項目根目錄下的 gradle.properties 文件中定義變量,在所有業務組件子項目中引用。
// 文件名:gradle.properties
// 組件獨立調試開關, 每次更改值后要同步工程
isAPK = false
- Other properties 方式定義變量
我們可以自定義一個 properties 文件,然后手動讀取里面的屬性並賦值。以 local.properties 文件為例。每個 Android 項目中都會有一個 local.properties 文件,但是該文件並不會納入到 git 管理中,因為這是 Android Studio(IDEA) 動態生成的文件。如果我們不做改動,那么里面默認定義了 sdk.dir 屬性,該屬性表示 Android SDK 的目錄。我們可以在這個目錄中額外定義屬性,然后手動讀取它。如果我們希望部分項目中定義的變量不放入到 git 倉庫中(比如 release 包簽名,比如私有 maven 倉庫賬號密碼),那么就可以放入到 local.properties 文件中定義(gradle.properties 文件會跟隨 git 管理)。
首先定義 isAPK 屬性,不定義則為 false
在 Root Project 的 build.gradle >>> buildscript 閉包中,我們可以讀取屬性,必將其賦值給 Root Project 的 ext。
// 文件:Root Project 的 build.gradle 文件
buildscript {
Properties properties = new Properties()
// 加載 local.properties 文件
properties.load(project.rootProject.file('local.properties').newDataInputStream())
// 讀取 isAPK 屬性,ext 為 Root Project 的 ext
ext.isAPK = properties.get("isAPK") == "true"
}
在業務組件的 build.gradle 中聲明插件
// 文件名:業務組件的 build.gradle
// 注意 gradle.properties 中的數據類型都是 String 類型,使用其他數據類型需要自行轉換
if (isAPK.toBoolean()){
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}
// 如果變量定義在 local.properties 中,則需要使用 rootProject.isAPK 進行判斷
// if (rootProject.isAPK){
// apply plugin: 'com.android.application'
// } else {
// apply plugin: 'com.android.library'
// }
每個 App 都是需要一個 ApplicationId 的 ,而組件在單獨編譯時也是一個 App,所以也需要一個 ApplicationId。另外每個 APP 也有一個啟動頁,啟動頁聲明在 AndroidManifest 文件中。所以這兩個也需要單獨配置。
// 文件名:業務組件的 build.gradle
android {
defaultConfig {
// 使用 applicationId
if (isAPK.toBoolean()) {
// 單獨編譯時添加 applicationId
applicationId "com.example.xxx"
}
}
sourceSets {
main {
// 單獨編譯時使用不同的清單文件
if (isAPK.toBoolean()) {
manifest.srcFile 'src/main/apk/AndroidManifest.xml'
} else {
manifest.srcFile 'src/main/module/AndroidManifest.xml'
}
}
}
}
兩個清單文件的內容如下,一個指定了 application 和啟動頁(可單獨編譯),一個沒有指定(不能單獨編譯)。
// apk/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.xxx" >
<application android:name=".XXXApplication"
android:allowBackup="true"
android:label="XXX"
android:theme="@style/Theme.AppCompat">
<activity android:name=".XXXMainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
// module/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.xxx">
<application>
<activity android:name=".XXXActivity"></activity>
</application>
</manifest>
這樣配置以后,我們就可以在 Android Studio 中選擇需要運行的 APK 了。
注意:Android Gradle 中,可以為每個 module 設置不同的 applicationIdSuffix(在 ProductFlavor 中設置)。該字段表示:在不改變默認的包名的情況下,為其添加后綴。比如應用包名是com.example.demo,但你想為 chat 模塊設置不同的包名,這個時候將applicationIdSuffix設置為.chat,那么你的應用程序對應的包名就變成了com.example.demo.chat。設置 applicationIdSuffix 可以實現不為各模塊手動設置 applicationId,但各 demo 工程包名不同的效果。applicationIdSuffix 具體說明見:https://developer.android.com/studio/build/application-id?hl=zh-cn
3. 組件下沉
上面講過,組件化里的方案里,其實有一個多工程的方案選項。這個方案我們其實可以和單工程方案結合起來使用。比如一些很基礎的組件,就可以下沉到一個單獨的項目中,作為二方庫使用,通常而言,二方庫是對三方庫的再一次封裝,當然也有完全自己實現功能的。比如日志庫、圖片庫、網絡庫等十分基礎的常用的組件和功能,就可以下沉為二方庫。就公司層面而言,組件下沉有幾方面的好處:
- 代碼隔離:降低因代碼改動帶來的風險,單工程組件化可能存在解耦不徹底的風險,從而導致一些問題
- 權限管理:下沉為一個單獨的項目,可以管理不同成員的權限了,比如項目部署在 gitlab 上,而有些核心項目,是不想讓部分成員看見的,就可屏蔽掉(比如 IM 庫,不想讓外包人員了解,就可以不給其分配權限)
- 功能復用:想日志、網絡等基礎功能,封裝好組件庫后,一個公司內的所有 APK 都可以導入使用,避免了重復造輪子帶來的浪費
組件下沉后該如何使用呢?首先,我們知道,Android 項目使用 Gradle 構建,Gradle 可以依賴本地包或者遠程包,這些包可以是 aar 包或者 jar 包,對於 APP 殼工程,可以使用這種方法依賴二方庫。aar 包和 jar 的區別,可以看看這篇文章:jar 包與 aar 包的區別。簡單的講,aar 包可以包含資源,jar 包不行。
然后,對於組件,就公司層面而言,一般我們會把下沉的組件放到服務器上,方便公司的其他項目也一起使用。這就涉及到了組件項目的編譯和上傳過程。Google 提供的 library 插件可以把項目打包成一個 aar 包,那么編譯這一塊我們就不必費神了。我們只需要關注如何將編譯好的 aar 包上傳到服務器即可。
第 1 種屬性定義方式
首先我們在項目根目錄下新建一個 maven_info.properties 文件,這個文件用於記錄 maven 倉庫的所需信息
# 文件名:maven_info.properties
# 用戶名
user=android
# 密碼
password=android123
# release 倉庫地址
url.release=url:port/nexus/content/repositories/android-release/
# dev 倉庫地址
url.dev=url:port/nexus/content/repositories/android-dev/
# POM 的名稱,給用戶提供的更為友好的項目名
# POM 全稱是Project Object Model,即項目對象模型,它是 Maven 中工作的基本組成單位
pom.name=android
# 項目描述,在 maven 文檔中保存
pom.description=example chat lib
# 項目組的編號,這在組織或項目中通常是獨一無二的。
# 例如,一家銀行集團 com.company.bank 擁有所有銀行相關項目。
pom.groupId=com.example.test
# 項目的 ID。這通常是項目的名稱
pom.artifactId=lib-chat
# 項目打包方式
pom.packaging=aar
# RELEASE 版本號
pom.version.release=1.0.0
# DEV 版本號
pom.version.dev=1.0.0-Dev
# 是否上傳 dev 版本
isDev=false
然后新建一個 maven_upload.gradle 文件,定義上傳任務。
// 文件名:maven_upload.gradle
// 使用 maven 插件
apply plugin: 'maven'
// 讀取配置文件 maven_info.properties
Properties properties = new Properties()
properties.load(project.rootProject.file('maven_info.properties').newDataInputStream())
def userName = properties.getProperty("user")
def userPassword = properties.getProperty("password")
def releaseUrl = properties.getProperty("url.release")
def devUrl = properties.getProperty("url.dev")
def isDev = properties.getProperty("isDev").toBoolean()
def pomName = properties.getProperty("pom.name")
def pomDescription = properties.getProperty("pom.description")
def pomGroupId = properties.getProperty("pom.groupId")
def pomArtifactId = properties.getProperty("pom.artifactId")
def pomPackaging = properties.getProperty("pom.packaging")
def pomVersionRelease = properties.getProperty("pom.version.release")
def pomVersionDev = properties.getProperty("pom.version.dev")
def repoUrl
def pomVersion
// uploadArchives 是一個 task
uploadArchives {
if(isDev) {
repoUrl = devUrl
pomVersion = pomVersionDev
} else {
repoUrl = releaseUrl
pomVersion = pomVersionRelease
}
// maven 部署器
repositories.mavenDeployer {
// 指定用戶名,密碼
repository(url: repoUrl) {
authentication(userName: userName, password: userPassword)
}
// 調用 gradle 的 uploadArchives 的 task 就可以上傳了
// 對這部分感興趣的可以搜索下 pom.xml
pom.project {
name pomName
description pomDescription
url repoUrl
groupId pomGroupId
artifactId pomArtifactId
version pomVersion
packaging pomPackaging
}
}
}
定義好 maven 任務后,在 library 的 build.gradle 項目中引入:
// library 的 build.gradle 文件
if (isAPK.toBoolean()) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}
// 引用本地的 gradle 文件
apply from: "${rootDir}/maven_upload.gradle"
點擊 gradle 任務中的 task 即可上傳到指定的 maven 倉庫。
第 2 種屬性定義方式
注意:maven_info.properties 這個文件中的內容,其實也可以定義在 gradle 中。比如在根目錄下定義一個 config.gradle 文件。
// 文件名:config.gradle
ext{
// 用戶名
user="android"
// 密碼
password="android123"
// release 倉庫地址
url.release="url:port/nexus/content/repositories/android-release/"
// dev 倉庫地址
url.dev="url:port/nexus/content/repositories/android-dev/"
// POM 的名稱,給用戶提供的更為友好的項目名
// POM 全稱是Project Object Model,即項目對象模型,它是 Maven 中工作的基本組成單位
pom.name="android"
// 項目描述,在 maven 文檔中保存
pom.description="example chat lib"
// 項目組的編號,這在組織或項目中通常是獨一無二的。
// 例如,一家銀行集團 com.company.bank 擁有所有銀行相關項目。
pom.groupId="com.example.test"
// 項目的 ID。這通常是項目的名稱
pom.artifactId="lib-chat"
// 項目打包方式
pom.packaging="aar"
// RELEASE 版本號
pom.version.release="1.0.0"
// DEV 版本號
pom.version.dev="1.0.0-Dev"
// 是否上傳 dev 版本
isDev=false
}
然后 maven_upload.gradle 文件改成這樣即可。
// 文件名:maven_upload.gradle
// 使用 maven 插件
apply plugin: 'maven'
apply from: "${rootDir}/config.gradle"
def repoUrl
def pomVersion
// uploadArchives 是一個 task
uploadArchives {
if(isDev) {
repoUrl = url_dev
pomVersion = pom_version_dev
} else {
repoUrl = url_release
pomVersion = pom_version_release
}
// maven 部署器
repositories.mavenDeployer {
// 指定用戶名,密碼
if(isDev) {
// Dev 作為快照版本
snapshotRepository(url: repoUrl) {
authentication(userName: user, password: password)
}
} else {
// 非 Dev 作為正式版本
repository(url: repoUrl) {
authentication(userName: user, password: password)
}
}
// 調用 gradle 的 uploadArchives 的 task 就可以上傳了
// 對這部分感興趣的可以搜索下 pom.xml
pom.project {
name pom_name
description pom_description
url repoUrl
groupId pom_groupId
artifactId pom_artifactId
version pomVersion
packaging pom_packaging
}
}
}
然后正常引入任務即可:
// library 的 build.gradle 文件
if (isAPK.toBoolean()) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}
// 引用本地的 gradle 文件
apply from: "${rootDir}/maven_upload.gradle"
為 aar 包添加源碼和注釋
如果想要上傳的包中帶有源碼和注釋,則需要定義額外的任務,用來將源碼和注釋打包。在生成 aar 的同時,在 build 目錄會有一個 libs 目錄,里面放着源碼和文檔的 jar 包,如果上傳到 maven 私服,會自動同時提交。如果使用本地aar,需要單獨引入。在 maven_upload.gradle 文件中新增如下任務:
// 文件名:maven_upload.gradle
// 代碼是固定模版
// 打包注釋,生成 javadoc.jar
// 使用 .sourceFiles 時,gradle 篩選了 .java 類型的文件進行打包
// 而使用 .getSrcDirs() 把整個目錄作為參數時,gradle 不再排查文件后綴,把所有目錄下所有文件都打包進來了。
task androidJavadocsJar(type: Jar, dependsOn: Javadoc) {
// classifier = 'javadoc'
// from android.sourceSets.main.java.sourceFiles
// 指定文檔名稱
archiveClassifier.set('javadoc')
from android.sourceSets.main.java.getSrcDirs()
}
// 打包源碼,生成 sources.jar
task androidSourcesJar(type: Jar) {
// classifier = 'sources'
// from android.sourceSets.main.java.sourceFiles
archiveClassifier.set('sources')
from android.sourceSets.main.java.getSrcDirs()
}
// 處理 Maven 需要上傳的包
artifacts {
if (isDev) {
archives androidSourcesJar
archives androidJavadocsJar
}
}
上傳成功后清除緩存
在得到源碼 aar 包后,再加上上傳成功后刪除本地緩存的邏輯。
// 文件名:maven_upload.gradle
task cleanDir(type:Delete) {
delete buildDir
}
// 上傳后,執行清除任務
uploadArchives.mustRunAfter 'cleanDir'
對於 APP 殼工程而言,使用下面的代碼,可以引用二方庫上傳到 Maven 的 aar 包或者 jar 包。
第 1 步,添加 Maven 倉庫地址到依賴中。
allprojects {
repositories {
google()
jcenter()
//私有 maven 倉庫地址
maven {
url 'http://xxx'
}
}
}
第 2 步,在 APP 殼工程中添加依賴庫。
dependencies {
implementation 'com.example.test:xxx:xxx'
// ......
}
上面的代碼就將組件下沉以及依賴引入的主體流程講完了。下面講下其他關鍵點。
4. 界面跳轉
組件化的核心之一就是代碼解耦,所以組件間是不能有直接依賴的,那么如何實現組件間的頁面跳轉呢?這里就有兩種方法了:一種是顯式指定頁面,一種是隱式啟動頁面。顯式指定頁面就是比如說直接指定具體的某個頁面(包名+類名);而隱式啟動頁面就是按類型啟動,比如啟動音樂界面、登錄界面等,我們不關心是哪個界面,我們只需要知道你有這個功能就夠了。這和 Android 系統的 Intent 跳轉有點類似。當然,這兩個啟動都有一定的缺陷。首先是顯式指定頁面的方式,這種方式會導致比較強的耦合,與組件化的初衷有所背離。而隱式啟動頁面界面,又會導致管理較集中,不便於多方協作。
綜上,頁面跳轉方式的設計肯定是有取舍折中的。但是組件化的初衷就是解耦,所以實際實現中,我們通常還是會采取隱式啟動頁面的方式。而其具體的實現,又有兩種途徑,一種是使用第三方開源庫,一種則是自定義。下面將分別講解,其中第三方開源庫的實現方式會采用阿里的 ARouter。
1. common 組件
組件化中,通常都會存在一個公共的核心組件,我們可以稱之為 common 組件。其他所有的組件,都依賴了這個 common 組件,包括 APP 殼工程。有了這么一個公共組件,我們就可以做很多事了,比如管理頁面跳轉。
2. 第三方開源庫 ARouter 實現頁面跳轉
ARouter 是阿里 Android 技術團隊開源的一款路由框架。這款路由框架可以為我們的應用開發提供更好更豐富的跳轉方案。比如支持解析標准 URL 進行跳轉,並自動注入參數到目標頁面中;支持添加多個攔截器,自定義攔截順序(滿足攔截器設置的條件才允許跳轉,所以這一特性對於某些問題又提供了新的解決思路)。使用 ARouter 具有以下優勢:
- 通過 URL 索引跳轉就可以解決類依賴的問題
- 通過分布式管理頁面配置可以解決隱式 intent 中集中式管理 Path 的問題
- 自己實現的整個路由過程可以擁有良好的擴展性
- 可以通過 AOP 的方式解決跳轉過程無法控制的問題,與此同時也能夠提供非常靈活的降級方式
下面開始介紹使用 ARouter 實現頁面跳轉
1. 引入 ARouter
ARouter 可以在所有組件中使用,則需要在 Common 組件中添加 Arouter 的依賴。另外,其它組件共同依賴的庫也要都放到 Common 中統一依賴(避免重復依賴,以及可能由此帶來的依賴包版本不一致問題)。使用下面的配置引入 ARouter:
// common 組件的 build.gradle 文件
android {
defaultConfig {
javaCompileOptions.annotationProcessorOptions {
arguments = [AROUTER_MODULE_NAME: getProject().getName()]
}
}
}
dependencies {
// 替換成最新版本, 需要注意的是api
// 要與compiler匹配使用,均使用最新版可以保證兼容
implementation 'com.alibaba:arouter-api:1.5.1'
annotationProcessor 'com.alibaba:arouter-compiler:1.5.1'
}
2. 初始化 ARouter
阿里官方建議我們在 Application 里面進行 ARouter 初始化:
public class TestApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
// 這兩行必須寫在 init 之前,否則這些配置在 init 過程中將無效
if (BuildConfig.DEBUG) {
// 打印日志
ARouter.openLog();
// 開啟調試模式。如果在 InstantRun 模式下運行,必須開啟調試模式!
// 而線上版本需要關閉,否則有安全風險
ARouter.openDebug();
}
// 盡可能早,推薦在 Application 中初始化
ARouter.init(this);
}
}
3. ARouter 添加路由地址
// 1. 在 Activity/Fragment 類上面定義路由
@Route(path = "/app/activity_main")
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 2. 在 Activity/Fragment 類里面進入 Arouter 注入
ARouter.getInstance().inject(this);
findViewById(R.id.tv).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 3. 構建路由,進行跳轉
ARouter.getInstance().build("/app/activity_second").navigation();
}
});
}
}
ARouter 的基本使用有三點:
- 在 Activity/Fragment 類上面定義路由,路由至少需要有兩級,形如:/xx/xx
- 在 Activity/Fragment 類里面進入注入,也就是:ARouter.getInstance().inject(this)。因為能跳轉的頁面都需要注入。所以建議此處將注入邏輯放入基礎 Activity/Fragment 中,如在 BaseActivity/BaseFragment 的 onCreate 中注入
- 使用目標頁面的路由進行跳轉。建議將頁面的路由放到一個統一的地方,集中進行管理
4. ARouter 攜帶參數跳轉
上面的代碼,頁面跳轉時沒有攜帶參數,ARouter 也支持攜帶參數的跳轉,使用方式如下:
ARouter.getInstance()
.build("/app/activity_second")
.withString("name", "zhang") //攜帶參數 1
.withInt("age", 3) //攜帶參數 2
.navigation();
而在目標界面 SecondActivity 中,我們需要使用 Autowired 注解以獲取對應的參數值(自動獲取,不需要再手動賦值):
@Route(path = "/app/activity_second")
public class SecondActivity extends AppCompatActivity {
@Autowired
private String name;
@Autowired(name = "age")
private int age;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
ARouter.getInstance().inject(this);
}
}
5. ARouter 路由回調
在使用 ARouter 進行界面跳轉時,可以設置回調,以監聽路由狀態.回調接口 NavigationCallback 有一個抽象類的實現:NavCallback。圖方便的話,可以使用 NavCallback:
ARouter.getInstance().build(path)
.navigation(context, new NavigationCallback() {
@Override
public void onFound(Postcard postcard) {
// 找到了要打開的 activity
}
@Override
public void onLost(Postcard postcard) {
// 找不到要打開的 activity
}
@Override
public void onArrival(Postcard postcard) {
// 已經打開了目標activity
}
@Override
public void onInterrupt(Postcard postcard) {
// 跳轉請求被攔截了
}
});
6. ARouter 界面跳轉動畫
調用 withTransition,里面傳入兩個動畫的資源 id(如 R.anim.xxx),即可實現 Activity 的轉場動畫。當然,更復雜的,還是建議自己實現(如共享元素動畫):
ARouter.getInstance().build("app/activity_second")
.withTransition(enterAnimId, exitAnimId)
.navigation();
ARouter 的用法,就先講這么多吧。更多用法可以參考阿里的官方文檔:ARouter 的使用
3. 自定義實現頁面跳轉
第三方開源庫的跳轉方式講了,那么如何自定義實現頁面跳轉呢?首先,我們不能顯式的指定類名來啟動界面,那么就只能隱式的打開界面了。一般而言,我們通過接口來打開頁面。比如我們定義了一個 Login 組件,那么我們可以定義下面的 api 跳轉登錄頁,然后在其他地方使用LoginApi.startLoginPage(activity)
即可:
// 文件名:LoginApi.java
// 啟動登錄頁
public static void startLoginPage(Activity activity) {
// 具體實現省略
}
那么問題來了,api 類應該定義在哪里呢?首先,API 的含義就是組件向外暴露的接口,當然可以被其他模塊使用。我們知道,common 組件是被所有組件單向依賴的,那么定義在 common 中,就可以被所有組件使用了。然后 API 方法的具體實現肯定是放在各個組件中(否則會出現找不到類的錯誤)。這樣就形成了 "聲明在 common 組件,實現在業務組件" 的布局。這種場景還可以優化,即把 "聲明和實現都放在業務組件,編譯時將聲明拷到 common 組件"。當然,這樣做會導致類重復定義的問題(包名相同,類名相同)。所以還需要做點優化,即做到 "業務組件中類的聲明不可被編譯器識別" 就 OK 了。事實上,Android Studio 提供了這樣的一個能力,可以把非 Java 文件當做 Java 文件處理,但是 Java 編譯器只會識別 .java 文件。這么一來我們就能通過以下方法暴露出業務組件的接口聲明到 common 組件中,同時不暴露接口的具體實現,做到代碼隔離,並且不會編譯出錯了。
- 在業務組件中定義接口和類聲明
- 拷貝接口和類聲明到 common 組件中
- 在業務組件中實現接口和類
- 在其他組件中使用 Api 等暴露出來的接口
1. 定義接口聲明
Android Studio 中,提供了將非 Java 文件當做 Java 文件處理的能力。這里以文件名后綴名為 .api 的文件為例,使用如下的設置后,Android Studio 可以將 api 文件當做 java 文件處理。
在 Android Studio 中選擇 File ---> Settings ---> Editor ---> File Types ---> Java,然后在 Registered Patterns 中添加 *.api,Android Studio 就會將后綴名為 .api 的文件當做 java 文件處理了。
然后我們定義一個接口文件:
// 文件名:LoginApi.api
public class LoginApi {
public static void startLoginPage(Context context) {
// 實現省略
}
}
2. 拷貝聲明到 common 組件中
在我們聲明了接口文件之后,怎么將文件拷貝到 common 組件中呢?自然不可能手動拷,我們可以寫個腳本,在 Android Studio 開始編譯前,將 api 文件從源目錄拷到目標目錄,然后拷貝的過程中,給 common 中的聲明改個名,將 .api 的后綴改為 .java 的后綴。當然,為了減少掃描目錄的時間開銷,我們需要把源文件和目標文件都限定在一個特定目錄中。
拷貝腳本可以定義為一個 gradle task,然后源文件(.api)和目標文件(.java)的 root 目錄,可以限定為 com.xxx.xxx.api.module_name 包名,各個組件拷貝后,可以放到 api 包下,比如登錄組件的接口,包名就叫 com.xxx.xxx.api.login,而聊天組件的接口,包名就叫 com.xxx.xxx.api.chat。
// 同步任務(Sync)繼承自復制任務(Copy),當它執行時,它會復制源文件到目標目錄中,然后從目標目錄中的刪除所有非復制的文件
task copyApiToJava(type: Sync) {
// 第 1 步,獲取所有子模塊
Set<Project> projects = project.subprojects
if(projects == null || projects.size() <= 0) {
return
}
// 第 2 步,獲取 common 模塊
// 注:project 的 path 是在 name 前加上 :,表示相對路徑
Project comProject = project.rootProject.findProject(":common")
// 拷貝所有組件的接口到 common 組件中
projects.each {
from "${rootDir}/${it.name}/src/main/java/com/xxx/xxx/api"
into "${rootDir}/${comProject.name}/src/main/java/com/xxx/xxx/api/${it.name}"
//排除所有的.java文件
exclude '**/*.java'
//包括所有的.api文件
include '**/*.api'
rename { String fileName ->
fileName.replace(".api", ".java")
}
}
}
//在preBuild之前,必須先運行 copyApiToJava
preBuild.dependsOn copyApiToJava
拷貝后,我們就能在 common 組件的 api 目錄下看見 java 文件了。這種拷貝還有一個好處,那就是 common 組件中的接口可以動態生成,sync 拷貝時,會清空 common 組件中的接口,再把新的接口添加上。各個組件可以自己維護需要暴露給其他組件的接口。從 git 的角度講,那就是可以把業務組件中的接口和實現文件使用 git 管理,而 common 組件中的接口就不用使用 git 管理,編譯前動態拷貝一份即可。
3. 業務組件中實現接口
當把聲明拷貝到 common 組件中之后,我們就可以在業務組件中實現 common 組件中的接口了。比如 Login 模塊有個 Login 的功能。那么我們可以在 login 模塊中定義兩個 api 文件:
-
LoginApi.api 文件
// 文件名:LoginApi.api public class LoginApi { public static void startLoginPage(Context context) { ILoginService service = new LoginServiceImpl(); service.startLoginPage(context); } }
-
ILoginService.api 文件
// 文件名:ILoginService.api public interface ILoginService { void startLoginPage(Context context); }
然后執行 copyApiToJava task,將在 login 模塊中的 api 文件,拷貝到 common 組件中。再在 login 組件中實現 ILoginService。
-
LoginServiceImpl.java 文件
// 文件名:LoginServiceImpl.java public class LoginServiceImpl implements ILoginService { @Override public void startLoginPage(Context context) { // 具體實現省略 } }
這下就實現了 代碼隔離 + 接口暴露。在其他模塊中,調用LoginApi.startLoginPage
方法就可以啟動登錄頁了
自定義路由步驟總結
從上面的講解中,我們可以看出,自定義路由需要經過以下步驟:
- 定義 api 文件
- 拷貝 api 文件到 common 組件中
- 實現接口聲明
- 在其他組件中使用定義好的 api 接口
上面的幾個步驟,其實第 2 步和第 3 步可以互換。即使先實現再拷貝也是可行的。因為 Android Studio 此時已經把 api 文件當做了 java 文件,使用 api 文件編輯器不會報錯。而實現后再拷貝,是保證編譯器不會報錯。所以,上面定義的順序,是邏輯順序,和實際使用時的順序,可以有所不同。這點需要清楚。否則后面項目其他成員來實現組件化,按照實際使用流程理解的話,可能會陷入死胡同。
5. 組件通信
上面講了,組件化的一個核心就是代碼解耦。在組件化開發的時候,組件之間是相互獨立的沒有依賴關系,A 不能顯式調用 B 組件的方法,也就不能直接通知 B 組件了。那么 A 組件要如何通知 B 組件,並且攜帶上參數呢?
首先講下通信架構,一般的通信架構如下。通知方和被通知方都只和通信管理器打交道,而通信管理器和通信總線打交道,而通知總線和通知方、被通知方打交道。即通知方、被通知方依賴於通信管理器,通信管理器依賴於通信總線,而通信總線依賴於通知方、被通知方。
- 通知方通過通信管理器發起通知,而通信管理器通過通信總線將通知發送被通知方
- 被通知方通過通信管理器發起通知響應,而通信管理器通過通信總線將通知響應發送通知方
上面的架構圖,一定程度上借鑒了電腦架構里的總線設計。總線代表了具體的實現(如第三方 SDK 實現和自定義實現),而圖中的通知方和被通知方,都是邏輯結構。這樣一來,就可以實現代碼的解耦。后續也可以方便的切換總線。
而組件間通信的具體實現思路其實和頁面跳轉差不多,主要用兩種方式實現,如下。以 Login 組件通知 Chat 組件為例,登錄成功后,可以聊天了。
- 第三方 SDK 實現組件間通信,以 ARouter 為例,可以在 Bundle 中加入參數,但是 Bundle 中的參數大小是有限制的。所以還是需要另外定義通信邏輯
- 自定義實現組件間通信
綜上,下面講解下如何自定義組件通信。
1. 定義邏輯結構
按照上面的思路,我們先定義一下邏輯結構。定義會盡量簡單,否則就會扯到通知,通知成功,響應,響應成功等一系列復雜概念,就和 TCP 的 三次握手/四次揮手 類似了。
通信請求和通信響應
通信請求和通信響應的類定義如下。
// 通知
public class NotifyRequest {
// 代碼省略
}
// 通知響應
public class NotifyResponse {
// 代碼省略
}
通知方和被通知方
通知方和被通知方的類定義如下。
// 通知方
public interface NotifyParty {
// 收到被通知方的通知響應
void onReceivedResponse(NotifyResponse response);
}
// 被通知方
public interface NotifiedParty {
// 收到通知
void onReceivedNotify(NotifyRequest notify);
}
通知管理器和通信總線
// 通知管理器
public class NotifyManager {
// 發送通知
public boolean sendNotify(NotifyRequest notify) {
boolean isSendSuccess = false;
// 通過通信總線發送通知
return isSendSuccess;
}
// 發送通知響應
public boolean sendResponse(NotifyResponse response) {
boolean isSendSuccess = false;
// 通過通信總線發送通知響應
return isSendSuccess;
}
}
// 通信總線
public class NotifyBus {
// 下發通知
public boolean dispatchNotify(NotifyRequest notify) {
boolean isSendSuccess = false;
// 通過通信總線發送通知
return isSendSuccess;
}
// 下發通知響應
public boolean dispatchResponse(NotifyResponse response) {
boolean isSendSuccess = false;
// 通過通信總線發送通知響應
return isSendSuccess;
}
}
講下上面定義的方法的使用邏輯。
- 通知方(NotifyParty) 調用 通知管理器(NotifyManager) 發送通知(調用 sendNotify 方法)
- 通知管理器(NotifyManager) 調用 通知總線(NotifyBus) 發送通知(調用 dispatchNotify 方法)
- 通知總線(NotifyBus) 發送通知到 被通知方(NotifiedParty),被通知方(NotifiedParty) 收到通知(調用 onReceivedNotify 方法)
- 被通知方(NotifiedParty) 處理通知
- 被通知方(NotifiedParty) 在處理了通知后,調用 通知管理器(NotifyManager) 發送通知響應(調用 sendResponse 方法)
- 通知管理器(NotifyManager) 調用 通知總線(NotifyBus) 發送通知響應(調用 dispatchResponse 方法)
- 通知總線(NotifyBus) 發送通知響應到 通知方(NotifyParty),通知方(NotifyParty) 收到通知響應(調用 onReceivedResponse 方法)
- 通知方(NotifyParty) 處理通知響應
2. 具體實現
按照上面的定義,我們實現一下通信過程,此處省略了抽象層,簡化了邏輯,實際開發中,應該把抽象層加入,並且后期切換類庫太過於麻煩(舉個切身例子:深刻教訓,前期為封裝圖片框架,直接使用 Fresco,導致后來項目無法切換 glide)。
具體的邏輯就不講了,很簡單。要點如下:
- 通知和通知響應,可以攜帶三個字段:群組,類型,數據塊。定義群組是因為不同模塊里的類別,可能有重復,在實際開發中,群組即模塊。數據塊字段可以使用泛型定義。
- 通知方和接收方在發送和接收通知前,應該將自己支持接收的通知類型注冊到通知管理器中,不用時就解注冊(通常是在 Activity 的 onCreate 和 onDestroy 中)。通知管理器不具體維護通知類型的映射表,這個工作交給通信總線。通信總線負責維護映射表,並執行具體的通知工作。
6. 組件初始化
快速過完了 組件通信 的要點,下面講解一下組件的初始化。組件初始化需要着重考慮幾個方面:線程的同步問題、組件的依賴問題。組件依賴問題又可以涉及到一個概念:組件的生命周期。
組件的依賴是非常重要的一個環節,部分組件之間可能存在邏輯上的依賴關系。舉個例子,Login 模塊和 Chat 模塊雖然彼此間代碼解耦,但是仍然需要登錄成功了,才能開始聊天,這就產生了一個邏輯上的依賴關系。所以,不是所有組件都必須同時初始化,組件之間的初始化順序可能存在先后關系。
APP 在啟動時,通常會做一些比較耗時的操作,比如網絡請求、文件 IO、數據庫讀寫。這些操作不能放在主線程中,只能放在異步線程里操作。此時就涉及到一個線程同步的問題了。主線程的操作如何保證在異步線程之后呢?這里有幾種辦法:
- 延遲初始化:顧名思義,不重要的任務延后加載,這么做不僅可以保證時序,也可以縮短開機時間、減少開機時的工作量
- 顯示開屏頁:打開 APK 時,如果想要主線程執行在異步線程之后,那么可以在異步任務開始時,播放動畫,或者廣告。但是這么做,可能會導致 APK 打開時間增加
- 線程優先級:當自定義線程池時,可以給線程設置優先級,這也能一定程度上解決線程的時序問題
組件是多種多樣的,而組件間依賴關系也不可能是線性的。舉個例子:假如組件 A 依賴於組件 B,組件 B 依賴於組件 C,在可表示成 A ---> B ---> C,假設組件 E 依賴於組件 B,則可表示成 E ---> B。
解決組件的依賴關系,有兩種辦法:一是使用 Gradle,二是使用 Java 代碼。Gradle 是編譯期解決,Java 代碼是運行期解決。
如果使用 Gradle,可以使用下面的步驟:
- 各模塊在 Gradle 文件中聲明依賴的模塊
- 在 settings.gradle 文件中讀取各模塊聲明的依賴(settings.gradle 先於 build.gradle 執行),生成一個根據模塊區分的依賴映射表
- 各模塊在 build.gradle 中讀取 settings.gradle 存儲的模塊的依賴
- 各模塊將讀取的依賴信息通過 buildConfigField 寫入 BuildConfig 文件中
- APK 在啟動時,讀取存儲在 BuildConfig 文件中的依賴信息
如果使用的 Java,則可以使用下面的方式:
定義依賴解析器,加入 A ---> B ---> C,E ---> B,那么我們可以以 B 作為 key,然后以 A、E 作為 value,B 執行了,就去讀取 A 執行,A 執行完后,又去讀取依賴於 A 的組件繼續執行,直到最終沒有組件(一個深度遍歷的過程),才返回 B 的那一層,調用 E 執行,重復深度遍歷的過程,直到所有的組件都初始化 OK。
- 定義組件接口,代表組件
- 定義根組件,所有組件默認都依賴於它
- 定義組件父類,所有組件都必須繼承自它,並且調用方法添加上 根組件 的依賴
- 定義組件管理器,注冊所有組件(否則我們並不清楚項目中有哪些組件)
- 編譯組件,解析依賴。依賴管理器解析出來的依賴項,以被依賴的組件(A)作為 key,以依賴於 A 的組件為 value
- 使用依賴管理器解析出來的依賴項進行初始化,一個深度遍歷的過程
- 深度遍歷初始化過程中,可以考慮線程同步的問題
- 深度遍歷的過程中,可能存在組件重復初始化的問題。那么可以加入組件的生命周期進行管理。比如設置 注冊、解注冊、配置、執行、初始化完畢 等步驟。未執行的步驟才執行,執行過的就不執行了
下面的內容講解了組件初始化的思想以及生命周期的概念,線程同步沒講。主要是線程的同步問題會占很大篇幅,建議找專門的文章了解。
定義組件能力
// 該接口表示組件的執行流程
public interface IModuleTask {
// 第 1 步:建立組件依賴
void dependency();
// 第 2 步:配置組件
void configure();
// 第 3 步:組件執行
void execute();
}
定義根組件
// 所有組件默認依賴於根組件
public class RootModule extends Module {
@Override
public void dependency() { }
@Override
public void configure() { }
@Override
public void execute() { }
}
定義組件父類
// 所有組件的父類
public abstract class Module implements IModuleTask {
// 當前的生命周期狀態
private ModuleLifecycle lifecycle;
@Override
public void dependency() {
dependOn(RootModule.class);
}
public void dependOn(Class<? extends Module> clsParent) {
ModuleManager.dependOn(clsParent, getClass());
}
public ModuleLifecycle getLifecycle() {
return lifecycle;
}
public Module setLifecycle(ModuleLifecycle lifecycle) {
this.lifecycle = lifecycle;
return this;
}
}
定義生命周期枚舉
// 組件的生命周期
public enum ModuleLifecycle {
// 組件已注冊
REGISTERED,
// 組件已配置
CONFIGURED,
// 組件已執行
EXECUTED
}
定義組件
組件依賴:A ---> B ---> C,D ---> B
// A 組件依賴於 B 組件和 Root 組件
public class AModule extends Module {
@Override
public void dependency() {
super.dependency();
dependOn(BModule.class);
}
@Override
public void configure() { }
@Override
public void execute() { }
}
// B 組件依賴於 C 組件和 Root 組件
public class BModule extends Module {
@Override
public void dependency() {
super.dependency();
dependOn(CModule.class);
}
@Override
public void configure() { }
@Override
public void execute() { }
}
// C 組件依賴於 Root 組件
public class CModule extends Module {
@Override
public void dependency() {
super.dependency();
}
@Override
public void configure() { }
@Override
public void execute() { }
}
// D 組件依賴於 B 組件和 Root 組件
public class DModule extends Module {
@Override
public void dependency() {
super.dependency();
dependOn(BModule.class);
}
@Override
public void configure() { }
@Override
public void execute() { }
}
定義組件管理器
定義組件管理器,並負責初始化。核心方法是 init 方法,init 方法中定義組件初始化的所有流程
// 組件管理器
public class ModuleManager {
// key 是被依賴的組件(A),value 是依賴於 A 的組件
private static LinkedHashMap<Class<? extends Module>, Dependency> dependencies = new LinkedHashMap<>();
// 當前已注冊的組件的實例緩存
private static LinkedHashMap<Class<? extends Module>, Module> modules = new LinkedHashMap<>();
// 組件初始化
public static void init() {
// 1. 注冊組件
registerModules();
// 2. 構建 Root 組件節點
Dependency rootDependency = new Dependency();
rootDependency.father = RootModule.class;
rootDependency.children = new LinkedHashSet<>();
dependencies.put(rootDependency.father, rootDependency);
// 3. 遍歷組件,生成依賴關系
for(Module module: modules.values()) {
module.dependency();
}
// 4. 深度遍歷,配置組件
deepTraverse(rootDependency, new Task() {
@Override
public void doTask(Module module) {
// 已配置過了
if(module.getLifecycle() == ModuleLifecycle.CONFIGURED) {
return;
}
// 配置組件,並更新組件的生命周期狀態
module.configure();
module.setLifecycle(ModuleLifecycle.CONFIGURED);
}
});
// 4. 深度遍歷,組件執行
deepTraverse(rootDependency, new Task() {
@Override
public void doTask(Module module) {
// 已執行過了
if(module.getLifecycle() == ModuleLifecycle.EXECUTED) {
return;
}
// 組件執行,並更新組件的生命周期狀態
module.execute();
module.setLifecycle(ModuleLifecycle.EXECUTED);
}
});
}
// 注冊組件
private static void registerModules() {
registerModule(AModule.class);
registerModule(BModule.class);
registerModule(CModule.class);
registerModule(DModule.class);
registerModule(RootModule.class);
}
// 注冊單個組件
private static void registerModule(Class<? extends Module> cls) {
Module component = modules.get(cls);
if (component != null) {
return;
}
try {
Module module = cls.newInstance();
module.setLifecycle(ModuleLifecycle.REGISTERED);
modules.put(cls, module);
} catch (Exception e) {
e.printStackTrace();
}
}
// 添加依賴關系
static void dependOn(Class<? extends Module> clsFather, Class<? extends Module> clsChild) {
Dependency existDependency = findDependency(clsFather);
if (existDependency != null) {
existDependency.children.add(clsChild);
return;
}
existDependency = new Dependency();
existDependency.father = clsFather;
existDependency.children = new LinkedHashSet<>();
existDependency.children.add(clsChild);
dependencies.put(existDependency.father, existDependency);
}
private static Dependency findDependency(Class<?> clsFather) {
if (clsFather == null) {
return null;
}
return dependencies.get(clsFather);
}
private static void deepTraverse(Dependency root, Task task) {
if (root == null) {
return;
}
// 執行被依賴項 A
Module module = modules.get(root.father);
task.doTask(module);
// 執行依賴於 A 的組件
for (Class<? extends Module> child : root.children) {
Dependency dependency = findDependency(child);
// 無依賴項,說明深度遍歷到底了
if (dependency == null) {
// 調用 子組件的方法
module = modules.get(child);
task.doTask(module);
} else {
// 有依賴項,繼續遞歸的去深度遍歷
deepTraverse(dependency, task);
}
}
}
// 依賴關系封裝類
private static class Dependency {
// 被依賴的組件
Class<? extends Module> father;
// 依賴於 father 組件的組件列表
LinkedHashSet<Class<? extends Module>> children;
}
// 初始化過程中需要執行的任務
private interface Task {
void doTask(Module module);
}
}
該初始化流程肯定有改進空間,比如組件依賴慮重。當然,這屬於優化項,不屬於必須項,所以這里就刪去了
7. 組件混淆
混淆是組件化過程中必須注意的問題。一般的混淆模版代碼如下:
defaultConfig {
consumerProguardFiles 'consumer-rules.pro'
}
buildTypes {
release {
minifyEnabled false
// 存在多個混淆文件
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
consumerProguardFiles 和 proguardFiles 命令的區別:
- consumerProguardFiles 配置的 proguard 會被打進 aar 包中,而 proguardFiles 配置的 proguard 不會被打進 aar 中
- proguardFiles 配置的 proguard 文件只作用於庫文件代碼,只在編譯發布 aar 的時候有效,將庫文件作為一個模塊添加到 App 模塊中后,庫文件中 consumerProguardFiles 配置的 proguard 文件會追加到 app 模塊的 Proguard 配置文件中,作用於整個 app 代碼。即 proguardFiles 的作用范圍為組件內,而 consumerProguardFiles 的作用范圍為整個 App
明白了上面的區別后,我們就可以對混淆文件進行解耦了。我們可以通過 consumerProguardFiles 命令在各個組件模塊中配置各個模塊自己的混淆規則,因為這種方式配置的混淆規則最終都會追加到 app 模塊的混淆規則中,並最終統一混淆。比如存在 A、B、APP 三個組件,那么配置過程如下:
A 組件的混淆配置較簡單,直接在 defaultConfig 使用 consumerProguardFiles 配置項配置了一個混淆文件,defaultConfig 中的設置項默認會應用到所有的構建變體中
// A 組件的混淆配置
android {
defaultConfig {
// A 組件的混淆配置是 proguard-rules.pro
consumerProguardFiles 'proguard-rules.pro'
}
}
B 組件額外加入了一個二方庫混淆文件
// B 組件的混淆配置
android {
defaultConfig {
consumerProguardFiles 'proguard-rules.pro', proguard-second.pro
}
}
app 組件的默認混淆文件是 proguard-android-optimize.txt,該文件路徑是 {$ANDROID_SDK_PATH}/tools/proguard/proguard-android-optimize.txt。getDefaultProguardFile 表示用於獲取 SDK 目錄下的混淆配置文件。值得一提的是,Android Gradle Plugin 2.2及其之后的版本(2.2+),都不推薦使用 proguard-android.txt/proguard-android-optimize.txt,因為這兩個文件不再維護了。
另外 app 組件也會配置工程中系統組件、二方庫、三方庫的混淆配置。
// app 組件的混淆配置
android {
defaultConfig {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-system.pro', 'proguard-second.pro', 'proguard-third.pro'
}
}
proguard-system.pro 這個文件需要單獨說明下,因為 proguard-android.txt/proguard-android-optimize.txt 這兩個文件不再維護了,所以部分系統組件的混淆配置,需要我們單獨加下。具體可見:通用混淆配置。混淆配置選項說明,可以看這篇文章:混淆配置參數
Android 組件化的相關知識點和注意事項,就先講這么多吧