現在大廠的開發基本上都是組件化的,所以,還不會組件化的朋友可以多學下。今天和大家分享的就是關於Android 組件化的規範開發。
正文
進行組件化開發有一段時間了,不久後就要開始一個新項目了,爲此整理了目前項目中使用的組件化開發規範,方便在下一個項目上使用。本文的重點是介紹規範和項目架構,僅提供示例代碼舉例,目前不打算提供示例Demo。如果你還不瞭解什麼是組件化以及如何進行組件化開發的話,建議請先看下面這個文章。
定義
組件是 Android
項目中一個相對獨立的功能模塊,是一個抽象的概念,module
是 Android
項目中一個相對獨立的代碼模塊。
在組件化開發的早期,一個組件就只有一個 module
,導致很多代碼和資源都會下沉到 common
中,導致 common
會變得很臃腫。有的文章說,專門建立一個 module
來存放通用資源,我感覺這樣是治標不治本,直到後面看到微信Android模塊化架構重構實踐這篇文章,裏面的"模塊的一般組織方式"一節提到一個模塊應該有多個工程,然後開始在項目對 module
進行拆分。
一般情況下,一個組件有兩個 module
,一個輕量級的 module
提供外部組件需要和本組件進行交互的接口方法及一些外部組件需要的資源,另一個重量級的 module
完成組件實際的功能和實現輕量級 module
定義的接口方法。
module
的命名規範請參考module名,在下文中使用 module-api
代表輕量級的 module
,使用 module-impl
代表重量級的 module
。
common組件
common
是一個特殊的組件,不區分輕量級和重量級,它是項目中最底層的組件,基本上所有的其他組件都會依賴 common
組件,common
中放項目中所有弱業務邏輯的代碼和解決循環依賴的代碼和資源。
一個完整的項目的架構如下:
弱業務邏輯代碼
何爲弱業務邏輯代碼?簡單來說,就是有一定的業務邏輯,但是這個業務邏輯對於項目中其他組件來說通用的。
比如在 common
組件集成網絡請求庫,創建一個 HttpTool
工具類,負責初始化網絡請求框架,定義網絡請求方法,實現組裝通用請求參數以及處理全局通用錯誤等,對於其他組件直接通過這個工具類進行網絡請求就可以了。
比如定義界面基類,處理一些通用業務邏輯,比如接入統計分析框架。
解決循環依賴的代碼和資源
何爲解決循環依賴的代碼和資源?比如說 module-a-api
有一個類 C
,module-b-api
中有一個類 D
,在 module-a-api
中需要使用 D
,在 module-b-api
中需要使用 C
,這樣就會造成 module-a-api
需要依賴 module-b-api
,而 module-b-api
也會依賴 module-a-api
,這就造成了循環依賴,在 Android Studio
中會編譯失敗。
解決循環依賴的方案就是將 C
和 D
其中的一個,或者兩個都下沉到 common
組件中,因爲 module-a-api
和 module-b-api
都依賴了 common
組件,至於具體下沉幾個,這個根據具體的情況而定,但是原則是下沉到 common
組件的東西越少越好。
上面的舉的例子是代碼,資源文件同樣也可能會有這個問題。
module代碼結構
一個組件通常含有一個或多個功能點,比如對於用戶組件,它有關於界面、意見反饋、修改賬戶密碼等功能點,在 module
中爲每一個功能點創建一個路徑,裏面放實現該功能的代碼,比如 Activity
、Dialog
、Adapter
等。除此之外,爲了集中管理組件內部資源和統一編碼習慣,特地將一部分的通用功能路徑固定下來。這些路徑包括 api
、provider
、tool
等。
一般情況下 module
的代碼架構如下圖:
api
該路徑下放 module
內部使用到的所有網絡請求路徑和方法,一般使用一個類就夠了,比如:UserApi
:
object UserApi {
/**
* 獲取個人中心數據
*/
fun getPersonCenterData(): GetRequest {
return HttpTool.get(ApiVersion.v1_0_0 + "authUser/myCenter")
}
}
ApiVersion
全局管理目前項目中使用的所有 api
版本,應當定義在 common
組件的 api
路徑下:
object ApiVersion {
const val v1_0_0 = "v1/"
const val v1_1_0 = "v1_1/"
const val v1_2_2 = "v1_2_2/"
}
entity
該路徑下放 module
內部使用到的所有實體類(網絡請求返回的數據類)。
對於所有從服務器獲取的字段,全部定義在構造函數中,且實體類應當實現 Parcelable
,並使用 @Parcelize
註解。對於客戶端使用而自己定義的字段,基本上定義爲普通成員字段,並使用 @IgnoredOnParcel
註解,如果需要在界面間傳遞客戶端定義的字段,可以將該字段定義在構造函數中,但是必須註明是客戶端定義的字段。
示例如下:
@Parcelize
class ProductEntity(
// 產品名稱
var name: String = "",
// 產品圖標
var icon: String = "",
// 產品數量(客戶端定義字段)
var count: Int = 0
) : Parcelable {
// 用戶是否選擇本產品
@IgnoredOnParcel
var isSelected = false
}
其中 name
和 icon
是從服務器獲取的字段,而 count
和 isSelected
是客戶端自己定義的字段。
event
該路徑下放 module
內部使用的事件相關類。對於使用了 EventBus
及類似框架的項目,放事件類,對於使用了 LiveEventBus
的項目,裏面只需要放一個類就好,比如:UserEvent
:
object UserEvent {
/**
* 更新用戶信息成功事件
*/
val updateUserInfoSuccessEvent: LiveEventBus.Event<Unit>
get() = LiveEventBus.get("user_update_user_info_success")
}
注意:對於使用
LiveEventBus
的項目,事件的命名必須用組件名作爲前綴,防止事件名重複。
route
該路徑下放 module
內部所使用到的界面路徑和跳轉方法,一般使用一個類就夠了,比如:UserRoute
:
object UserRoute {
// 關於界面
const val ABOUT = "/user/about"
// 常見問題(H5)
private const val FAQ = "FAQ/"
/**
* 跳轉至關於界面
*/
fun toAbout(): RouteNavigation {
return RouteNavigation(ABOUT)
}
/**
* 跳轉至常見問題(H5)
*/
fun toFAQ(): RouteNavigation? {
return RouteUtil.getServiceProvider(IH5Service::class.java)
?.toH5Activity(FAQ)
}
}
注意:對於組件內部會跳轉的H5界面鏈接也應當寫在路由類中。
provider
該路徑下放對外部 module
提供的服務,一般使用一個類就夠了。在 module-api
中是一個接口類,在 module-impl
中是該接口類的實現類。
目前採用 ARouter 作爲組件化的框架,爲了解耦,對其進行了封裝,封裝示例代碼如下:
typealias Route = com.alibaba.android.arouter.facade.annotation.Route
object RouteUtil {
fun <T> getServiceProvider(service: Class<out T>): T? {
return ARouter.getInstance().navigation(service)
}
}
class RouteNavigation(path: String) {
private val postcard = ARouter.getInstance().build(path)
fun param(key: String, value: Int): RouteNavigation {
postcard.withInt(key, value)
return this
}
...
}
示例
這裏介紹如何在外部 module
和 user-impl
跳轉至用戶組件中的關於界面。
準備工作
在 user-impl
中創建路由類,編寫關於界面的路由和服務路由及跳轉至關於界面方法:
object UserRoute {
// 關於界面
const val ABOUT = "/user/about"
// 用戶組件服務
const val USER_SERVICE = "/user/service"
/**
* 跳轉至關於界面
*/
fun toAbout(): RouteNavigation {
return RouteNavigation(ABOUT)
}
}
在關於界面使用路由:
@Route(path = UserRoute.ABOUT)
class AboutActivity : MyBaseActivity() {
...
}
在 user-api
中定義跳轉界面方法:
interface IUserService : IServiceProvider {
/**
* 跳轉至關於界面
*/
fun toAbout(): RouteNavigation
}
在 user-impl
中實現跳轉界面方法:
@Route(path = UserRoute.USER_SERVICE)
class UserServiceImpl : IUserService {
override fun toAbout(): RouteNavigation {
return UserRoute.toAbout()
}
}
界面跳轉
在 user-impl
中可以直接跳轉到關於界面:
UserRoute.toAbout().navigation(this)
假設 module-a
需要跳轉到關於界面,那麼先在 module-a
中配置依賴:
dependencies {
...
implementation project(':user-api')
}
在 module-a
中使用 provider
跳轉到關於界面:
RouteUtil.getServiceProvider(IUserService::class.java)
?.toAbout()
?.navigation(this)
module依賴關係
此時各個 module
的依賴關係如下:
common:基礎庫、第三方庫
user-api:common
user-impl:common、user-api
module-a:common、user-api
App殼:common、user-api、user-impl、module-a、...
tool
該路徑下放 module
內部使用的工具方法,一般一個類就夠了,比如:UserTool
:
object UserTool {
/**
* 該用戶是否是會員
* @param gradeId 會員等級id
*/
fun isMembership(gradeId: Int): Boolean {
return gradeId > 0
}
}
cache
該路徑下放 module
使用的緩存方法,一般一個類就夠了,比如:UserCache
:
object UserCache {
// 搜索歷史記錄列表
var searchHistoryList: ArrayList<String>
get() {
val cacheStr = CacheTool.userCache.getString(SEARCH_HISTORY_LIST)
return if (cacheStr == null) {
ArrayList()
} else {
JsonUtil.parseArray(cacheStr, String::class.java) ?: ArrayList()
}
}
set(value) {
CacheTool.userCache.put(SEARCH_HISTORY_LIST, JsonUtil.toJson(value))
}
// 搜索歷史記錄列表
private const val SEARCH_HISTORY_LIST = "user_search_history_list"
}
注意:
- 緩存Key的命名必須用組件名作爲前綴,防止緩存Key重複。
CacheTool.userCache
並不是指用戶組件的緩存,而是用戶的緩存,即當前登錄賬號的緩存,每個賬號會單獨存一份數據,相互之間沒有干擾。與之對應的是CacheTool.globalCache
,全局緩存,所有的賬號會共用一份數據。
兩種module的區別
module-api
中放的都是外部組件需要的,或者說外部組件和 module-impl
都需要的,其他的都應當放在 module-impl
中,對於外部組件需要的但是能通過 provider
方式提供的,都應當把具體的實現放在 module-impl
中,module-api
中只是放一個接口方法。
下表列舉項目開發中哪些東西能否放 module-api
中:
類型 | 能否放 module-api
|
備註 |
---|---|---|
功能界面(Activity、Fragment、Dialog) | 不能 | 通過 provider 方式提供使用 |
基類界面 | 部分能 | 外部 module 需要使用的可以,其他的放 module-impl 中 |
adapter | 部分能 | 外部 module 需要使用的可以,其他的放 module-impl 中 |
provider | 部分能 | 只能放接口類,實現類放 module-impl 中 |
tool | 部分能 | 外部 module 需要使用的可以,其他的放 module-impl 中 |
api、route、cache | 不能 | 通過 provider 方式提供使用 |
entity | 部分能 | 外部 module 需要使用的可以,其他的放 module-impl 中 |
event | 部分能 | 對使用 EventBus 及類似框架的項目,外部組件需要的可以,其他還是放 module-impl 中 |
對於使用了 LiveEventBus 的項目不能,通過 provider 方式提供使用 | ||
資源文件和資源變量 | 部分能 | 需要在 xml 文件中使用的可以, 其他的通過 provider 方式提供使用 |
注意:如果僅在
module-impl
中存在工具類,則該工具類命名爲xxTool
。如果module-api
和module-impl
都存在工具類,則module-api
中的命名爲xxTool
,module-impl
中的命名爲xxTool2
。
組件單獨調試
在開發過程中,爲了查看運行效果,需要運行整個App,比較麻煩,而且可能依賴的其他組件也在開發中,App可能運行不到當前開發的組件。爲此可以採用組件單獨調試的模式進行開發,減少其他組件的干擾,等開發完成後再切換回 library
的模式。
在組件單獨調試模式下,可以增加一些額外的代碼來方便開發和調試,比如新增一個入口 Actvity
,作爲組件單獨運行時的第一個界面。
示例
這裏介紹在 user-impl
中進行組件單獨調試。
在項目根目錄下的 gradle.properties
文件中新增變量 isDebugModule
,通過該變量控制是否進行組件單獨調試:
# 組件單獨調試開關,爲ture時進行組件單獨調試
isDebugModule = false
在 user-impl
的 build.gradle
的頂部增加以下代碼來控制 user-impl
在 Applicaton
和 Library
之間進行切換:
if (isDebugModule.toBoolean()) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}
在 user-impl
的 src/main
的目錄下創建兩個文件夾 release
和 debug
,release
中放 library
模式下的 AndroidManifest.xml
,debug
放 application
模式下的 AndroidManifest.xml
、代碼和資源,如下圖所示:
在 user-impl
的 build.gradle
中配置上面的創建的代碼和資源路徑:
android {
...
sourceSets {
if (isDebugModule.toBoolean()) {
main.manifest.srcFile 'src/main/debug/AndroidManifest.xml'
main.java.srcDirs += 'src/main/debug'
main.res.srcDirs += 'src/main/debug'
} else {
main.manifest.srcFile 'src/main/release/AndroidManifest.xml'
}
}
}
注意:完成上述配置後,在
library
模式下,debug
中的代碼和資源不會合併到項目中。
最後在 user-impl
的 build.gradle
中配置 applicationId
:
android {
defaultConfig {
if (isDebugModule.toBoolean()) {
applicationId "cc.tarylorzhang.demo"
}
...
}
}
注意:如果碰到65536的問題,在 user-impl
的 build.gradle
中新增以下配置:
android {
defaultConfig {
...
if (isDebugModule.toBoolean()) {
multiDexEnabled true
}
}
}
以上工作都完成後,將 isDebugModule
的值改爲 true
,則可以開始單獨調試用戶組件。
命名規範
module名
組件名如果是單個單詞的,直接使用該單詞 + api
或 impl
的後綴作爲 module
名,如果是多個單詞的,多個單詞小寫使用 -
字符作爲連接符,然後在其基礎上加 api
或 impl
的後綴作爲 module
名。
示例
用戶組件(User
),它的 module
名爲 user-api
和 user-impl
;會員卡組件(MembershipCard
),它的 module
名爲 membership-card-api
和 membership-card-impl
。
包名
在應用的 applicationId
的基礎上增加組件名後綴作爲組件基礎包名。
在代碼中的包名 module-api
和 module-impl
都直接使用基礎包名即可,但是在 Android
中項目 AndroidManifest.xml
文件中的 package
不能重複,否則編譯不通過。所以 module-impl
中的 package
使用基礎包名,而 module-impl
中的 package
使用基礎包名 + api
後綴。
package 重複的時候,會報 Type package.BuildConfig is defined multiple times 的錯誤。
示例
應用的 applicationId
爲 cc.taylorzhang.demo
,對於用戶組件(user
),組件基礎包名爲 cc.taylorzhang.demo.user
,則實際包名如下表:
代碼中的包名 | AndroidManifest.xml中的包名 | |
---|---|---|
user-api |
cc.taylorzhang.demo.user |
cc.taylorzhang.demo.userapi |
user-impl |
cc.taylorzhang.demo.user |
cc.taylorzhang.demo.user |
對於多單詞的會員卡組件(MembershipCard
),其組件基礎包名爲 cc.taylorzhang.demo.membershipcard
。
資源文件和資源變量
所有的資源文件:佈局文件、圖片等全部要增加組件名作爲前綴,所有的資源變量:字符串、顏色等也全部要增加組件名作爲前綴,防止資源名重複。
示例
- 用戶組件(
User
),關於界面佈局文件命名爲:user_activity_about.xml
; - 用戶組件(
User
),關於界面標題字符串命名爲:user_about_title
; - 會員卡組件(
MembershipCard
),會員卡詳情界面佈局文件,文件名爲:membership_card_activity_detail
; - 會員卡組件(
MembershipCard
),會員卡詳情界面標題字符串,文件名爲:membership_card_detail_title
;
類名
對於類名沒必要增加前綴,比如 UserAboutActivity
,因爲對資源文件和資源變量增加前綴主要是爲了避免重複定義資源導致資源被覆蓋的問題,而上面的包名命名規範已經避免了類重複的問題,直接命名 AboutActivity
即可。
全局管理App環境
App
環境一般分爲開發、測試和生產環境,不同環境下使用的網絡請求地址大概率是不一樣的,甚至一些UI都不一樣,在打包的時候手動修改很容易有遺漏,產生不必要的 BUG
。應當使用 buildConfigField
在打包的時候將當前環境寫入 App
中,在代碼中根據讀取環境變量,根據不同的環境執行不同的操作。
示例
準備工作
在 App
殼 的 build.gradle
中給每個buildType
都配置 APP_ENV
:
android {
...
buildTypes {
debug {
buildConfigField "String", "APP_ENV", '\"dev\"'
...
}
release {
buildConfigField "String", "APP_ENV", '\"release\"'
...
}
ctest {
initWith release
buildConfigField "String", "APP_ENV", '\"test\"'
matchingFallbacks = ['release']
}
}
}
注意:測試環境的
buildType
不能使用test
作爲名字,Android Studio
會報ERROR: BuildType names cannot start with 'test'
,這裏在test
前增加了一個c
。
在 common
的 tool
路徑下創建一個App環境工具類:
object AppEnvTool {
/** 開發環境 */
const val APP_ENV_DEV = "dev"
/** 測試環境 */
const val APP_ENV_TEST = "test"
/** 生產環境 */
const val APP_ENV_RELEASE = "release"
/** 當前App環境,默認爲開發環境 */
private var curAppEnv = APP_ENV_DEV
fun init(env: String) {
curAppEnv = env
}
/** 當前是否處於開發環境 */
val isDev: Boolean
get() = curAppEnv == APP_ENV_DEV
/** 當前是否處於測試環境 */
val isTest: Boolean
get() = curAppEnv == APP_ENV_TEST
/** 當前是否處於生產環境 */
val isRelease: Boolean
get() = curAppEnv == APP_ENV_RELEASE
}
在 Application
中初始化App環境工具類:
class DemoApplication : Application() {
override fun onCreate() {
super.onCreate()
// 初始化App環境工具類
AppEnvTool.init(BuildConfig.APP_ENV)
...
}
}
使用App環境工具類
這裏介紹根據App環境使用不同的網絡請求地址:
object CommonApi {
// api開發環境地址
private const val API_DEV_URL = "https://demodev.taylorzhang.cc/api/"
// api測試環境地址
private const val API_TEST_URL = "https://demotest.taylorzhang.cc/api/"
// api生產環境地址
private const val API_RELEASE_URL = "https://demo.taylorzhang.cc/api/"
// api地址
val API_URL = getUrlByEnv(API_DEV_URL, API_TEST_URL, API_RELEASE_URL)
// H5開發環境地址
private const val H5_DEV_URL = "https://demodev.taylorzhang.cc/m/"
// H5測試環境地址
private const val H5_TEST_URL = "https://demotest.taylorzhang.cc/m/"
// H5生產環境地址
private const val H5_RELEASE_URL = "https://demo.taylorzhang.cc/m/"
// H5地址
val H5_URL = getUrlByEnv(H5_DEV_URL, H5_TEST_URL, H5_RELEASE_URL)
private fun getUrlByEnv(devUrl: String, testUrl: String, releaseUrl: String): String {
return when {
AppEnvTool.isDev -> devUrl
AppEnvTool.isTest -> testUrl
else -> releaseUrl
}
}
}
打包
通過不同的命令打包,打出對應的App環境包:
# 打開發環境包
./gradlew clean assembleDebug
# 打測試環境包
./gradlew clean assembleCtest
# 打生產環境包
./gradlew clean assembleRelease
全局管理版本信息
項目中的 module
變多之後,如果要修改第三方庫和App使用的SDK版本是一件很蛋疼的事情。應當建立一個配置文件進行管理,其他地方使用配置文件中設置的版本。
示例
在項目根目錄下創建一個配置文件 config.gradle
,裏面放版本信息:
ext {
compile_sdk_version = 28
min_sdk_version = 17
target_sdk_version = 28
arouter_compiler_version = '1.2.2'
}
在項目根目錄下的 build.gradle
文件中的最上方使用以下代碼引入配置文件:
apply from: "config.gradle"
創建 module
後,修改該 module
中的 build.gradle
文件,將 SDK
版本默認值換成配置文件中的變量,按需添加第三方依賴,並使用 $
+ 配置文件中的變量作爲第三方庫的版本:
android {
...
compileSdkVersion compile_sdk_version
defaultConfig {
...
minSdkVersion min_sdk_version
targetSdkVersion target_sdk_version
}
}
dependencies {
...
kapt "com.alibaba:arouter-compiler:$arouter_compiler_version"
}
混淆
混淆文件不應該在 App
殼中集中定義,應當在每個 module
中各自定義自己的混淆。
示例
這裏介紹配置 user-impl
的混淆,先在 user-impl
的 build.gradle
中配置消費者混淆文件:
android {
defaultConfig {
...
consumerProguardFiles 'proguard-rules.pro'
}
}
在 proguard-rules.pro
文件中寫入該 module
的混淆:
# 實體類
-keepclassmembers class cc.taylorzhang.demo.user.entity.** { *; }
總結
組件化開發應當遵守"高內聚,低耦合"的原則,儘量少的對外暴露細節。如果用一句話來總結的話,就是代碼和資源能放 module-impl
裏面的就都放在 module-impl
,因爲代碼隔離問題實在不能放 module-impl
裏面的才放 module-api
,最後因爲涉及到循環依賴問題的才往 common
中放。
最後
想要進行Android組件化專項學習的朋友可以觀摩下前面寫的兩篇文章:
Android組件化:得到APP,代碼隔離也難不倒組件的按序初始化
或者,大家也可以去B站看這個阿婆主的搬運視頻:Android開發駱駝
長風破浪會有時,直掛雲帆濟滄海。加油~