如何規範的進行 Android 組件化開發?

現在大廠的開發基本上都是組件化的,所以,還不會組件化的朋友可以多學下。今天和大家分享的就是關於Android 組件化的規範開發。

原文地址:https://www.jianshu.com/p/7bc170d29ef9

正文

進行組件化開發有一段時間了,不久後就要開始一個新項目了,爲此整理了目前項目中使用的組件化開發規範,方便在下一個項目上使用。本文的重點是介紹規範和項目架構,僅提供示例代碼舉例,目前不打算提供示例Demo。如果你還不瞭解什麼是組件化以及如何進行組件化開發的話,建議請先看下面這個文章。

Android組件化:我們到底該怎樣學習和運用組件化?

Android組件化初探【含Demo】

定義

組件是 Android 項目中一個相對獨立的功能模塊,是一個抽象的概念,moduleAndroid 項目中一個相對獨立的代碼模塊。

在組件化開發的早期,一個組件就只有一個 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 有一個類 Cmodule-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 中會編譯失敗。

解決循環依賴的方案就是將 CD 其中的一個,或者兩個都下沉到 common 組件中,因爲 module-a-apimodule-b-api 都依賴了 common 組件,至於具體下沉幾個,這個根據具體的情況而定,但是原則是下沉到 common 組件的東西越少越好。

上面的舉的例子是代碼,資源文件同樣也可能會有這個問題。

module代碼結構

一個組件通常含有一個或多個功能點,比如對於用戶組件,它有關於界面、意見反饋、修改賬戶密碼等功能點,在 module 中爲每一個功能點創建一個路徑,裏面放實現該功能的代碼,比如 ActivityDialogAdapter 等。除此之外,爲了集中管理組件內部資源和統一編碼習慣,特地將一部分的通用功能路徑固定下來。這些路徑包括 apiprovidertool 等。

一般情況下 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
}

其中 nameicon 是從服務器獲取的字段,而 countisSelected 是客戶端自己定義的字段。

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
    }
    ...
}

示例

這裏介紹如何在外部 moduleuser-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"
}

注意:

  1. 緩存Key的命名必須用組件名作爲前綴,防止緩存Key重複。
  2. 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-apimodule-impl 都存在工具類,則 module-api 中的命名爲 xxToolmodule-impl 中的命名爲 xxTool2

組件單獨調試

在開發過程中,爲了查看運行效果,需要運行整個App,比較麻煩,而且可能依賴的其他組件也在開發中,App可能運行不到當前開發的組件。爲此可以採用組件單獨調試的模式進行開發,減少其他組件的干擾,等開發完成後再切換回 library 的模式。

在組件單獨調試模式下,可以增加一些額外的代碼來方便開發和調試,比如新增一個入口 Actvity,作爲組件單獨運行時的第一個界面。

示例

這裏介紹在 user-impl 中進行組件單獨調試。

在項目根目錄下的 gradle.properties 文件中新增變量 isDebugModule,通過該變量控制是否進行組件單獨調試:

# 組件單獨調試開關,爲ture時進行組件單獨調試
isDebugModule = false

user-implbuild.gradle 的頂部增加以下代碼來控制 user-implApplicatonLibrary 之間進行切換:

if (isDebugModule.toBoolean()) {
    apply plugin: 'com.android.application'
} else {
    apply plugin: 'com.android.library'
}

user-implsrc/main 的目錄下創建兩個文件夾 releasedebugrelease 中放 library 模式下的 AndroidManifest.xmldebugapplication 模式下的 AndroidManifest.xml、代碼和資源,如下圖所示:

user-implbuild.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-implbuild.gradle 中配置 applicationId

android {
    defaultConfig {
        if (isDebugModule.toBoolean()) {
            applicationId "cc.tarylorzhang.demo"
        }
        ...
    }
}

注意:如果碰到65536的問題,在 user-implbuild.gradle 中新增以下配置:

android {
    defaultConfig {
        ...
        if (isDebugModule.toBoolean()) {
            multiDexEnabled true
        }
    }
}

以上工作都完成後,將 isDebugModule 的值改爲 true,則可以開始單獨調試用戶組件。

命名規範

module名

組件名如果是單個單詞的,直接使用該單詞 + apiimpl 的後綴作爲 module 名,如果是多個單詞的,多個單詞小寫使用 - 字符作爲連接符,然後在其基礎上加 apiimpl 的後綴作爲 module 名。

示例

用戶組件(User),它的 module 名爲 user-apiuser-impl;會員卡組件(MembershipCard),它的 module 名爲 membership-card-apimembership-card-impl

包名

在應用的 applicationId 的基礎上增加組件名後綴作爲組件基礎包名。

在代碼中的包名 module-apimodule-impl 都直接使用基礎包名即可,但是在 Android 中項目 AndroidManifest.xml 文件中的 package 不能重複,否則編譯不通過。所以 module-impl 中的 package 使用基礎包名,而 module-impl 中的 package 使用基礎包名 + api 後綴。

package 重複的時候,會報 Type package.BuildConfig is defined multiple times 的錯誤。

示例

應用的 applicationIdcc.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

commontool 路徑下創建一個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-implbuild.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組件化:我們到底該怎樣學習和運用組件化?

Android組件化初探【含Demo】

Android組件化:得到APP,代碼隔離也難不倒組件的按序初始化

或者,大家也可以去B站看這個阿婆主的搬運視頻:Android開發駱駝

長風破浪會有時,直掛雲帆濟滄海。加油~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章