AwesomeGithub元件化探索之旅
之前一直聽說過元件化開發,而且面試也有這方面的提問,但都未曾有涉及具體的專案。所以就萌生了基於Github的開放Api,並使用元件化的方式來從零搭建一個Github客戶端,起名為AwesomeGithub。
在這裡對元件化開發進行一個總結,同時也希望能夠幫助別人更好的理解元件化開發。
先來看下專案的整體效果
下面是專案的結構
為何要使用元件化
- 對於傳統的開發模式,一個app下面是包含專案的全部頁面模組與邏輯。這樣專案一旦迭代時間過長,業務模組逐漸增加,相應的業務邏輯複雜度也成指數增加。模組間的互相呼叫頻繁,這樣必定會導致模組間的耦合增加,業務邏輯巢狀程度加深。一旦修改其中一個模組,可能就牽一髮動全身了。
- 傳統的開發模式不利於團隊的集體開發合作,因為每個開發者都是在同一個app模組下開發。這樣導致的問題是,不能預期每個開發者所會修改到的具體程式碼部分,即所能夠修改的程式碼區域。因為模組耦合在一起,涉及的區域不可預期,導致不同開發者會修改同一個檔案或者同一段程式碼邏輯,從而導致異常衝突。
- 傳統開發模式不利於測試,每次迭代都要將專案整體測試一遍。因為在同一個app下面程式碼是缺乏約束的,你不能保證只修改了迭代過程中所涉及的需求邏輯。
以上問題隨著專案的迭代週期的增大,會表現的越來越明顯。那麼使用元件化又能夠解決什麼問題了?
元件化能夠解決的問題
- 元件化開發是將各個相關功能進行分離,分別獨立成一個單獨可執行的app,並且元件之間不能相互直接引用。這樣就減少了程式碼耦合,達到業務邏輯分層效果。
- 元件化可以提高團隊協作能力,不同的人員可以開發不同的元件,保證不同開發人員互不影響。
- 元件化將app分成多個可單獨執行的子專案,可以用自己獨立的版本,可以獨立編譯,打包、測試與部署。這樣不僅可以提高單個模組的編譯速度,同時也可以提高測試的效率。
- 元件化可以提高專案的靈活性,app可以按需載入所要有的元件,提高app的靈活性,可以快速生成可定製化的產品。
現在我們已經瞭解了元件化的作用,但要實現元件化,達到其作用,必須解決實現元件化過程中所遇到的問題。
元件化需要解決的問題
- 元件單獨執行
- 元件間資料傳遞
- 主專案使用元件中的Fragment
- 元件間介面的跳轉
- 元件解耦
以上是實現元件化時所遇到的問題,下面我會結合
元件單獨執行
元件的建立,可以直接使用library的方式進行建立。只不過在建立完之後,要讓元件達到可以單獨執行除錯的地步,還需要進行相關配置。
執行方式動態配置
首先,當建立完library時,在build.gradle中可以找到這麼一行程式碼
applyplugin:'com.android.library'
這是gradle外掛所支援的一種構建型別,代表可以將其依賴到主專案中,構建後輸出aar包。這種方式對於我們將元件依賴到主專案中完全吻合的。
而gradle外掛的另一種構建方式,可以在主專案的build.gradle中看到這麼一行程式碼
applyplugin:'com.android.application'
這代表在專案構建後會輸出apk安裝包,是一個獨立可執行的專案。
明白了gradle的這兩種構建方式之後,我們接下需要做的事也非常明瞭:需要將這兩種方式進行動態配置,保證元件在主專案中以library方式存在,而自己單獨的時候,則以application的方式存在。
下面我以AwesomeGithub中的login元件為例。
首先我們在根專案的gradle.properties中新增addLogin變數
addLogin=true
然後在login中的build.gradle通過addLogin變數來控制構建方式
if (addLogin.toBoolean()) {
apply plugin: 'com.android.library'
} else {
apply plugin: 'com.android.application'
}
這樣就實現了對login的構建控制,可單獨執行,也可依賴於app專案。
ApplicationId與AndroidManifest
除了修改gradle的構建方式,還需要動態配置ApplicationId與AndroidManifest檔案。
有了上面的基礎,實現方式也很簡單。
可以在defaultConfig中增加對applicationId的動態配置
defaultConfig {
if (!addLogin.toBoolean()) {
applicationId "com.idisfkj.awesome.login"
}
minSdkVersion Versions.min_sdk
targetSdkVersion Versions.target_sdk
versionCode Versions.version_code
versionName Versions.version_name
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
而AndroidManifest檔案可以通過sourceSets來配置
sourceSets {
main {
if (addLogin.toBoolean()) {
manifest.srcFile 'src/main/AndroidManifest.xml'
} else {
manifest.srcFile 'src/main/manifest/AndroidManifest.xml'
}
}
}
同時addLogin也可以作用於app,讓login元件可配置依賴
這樣login元件就可以獨立於app進行單獨構建、打包、除錯與執行。
元件間的資料傳遞
由於元件與元件、專案間是不能直接使用類的相互引用來進行資料的傳遞,所以為了解決這個問題,這裡通過一個公共庫來做它們之間呼叫的橋樑,它們不直接拿到具體的引用物件,而是通過介面的方式來獲取所需要的資料。
在AwesomeGithub中我將其命名為componentbridge,各個元件都依賴於該公共橋樑,通過該公共橋樑各個元件間可以輕鬆的實現資料傳遞。
上圖圈起來的部分都是componentbridge的重點,也是公共橋樑實現的基礎。下面來分別詳細說明。
BridgeInterface
這是公共橋樑的底層介面,每一個元件要向外實現自己的橋樑都要實現這個介面。
interface BridgeInterface {
fun onClear() {}
}
內部很簡單,只有一個方式onClear(), 用來進行資料的釋放。
BridgeStore
用來做資料儲存,對橋樑針對不同的key進行快取。避免橋樑內部的例項多次建立。具體實現方式如下:
class BridgeStore {
private val mMap = HashMap<String, BridgeInterface>()
fun put(key: String, bridge: BridgeInterface) {
mMap.put(key, bridge)?.onClear()
}
fun get(key: String): BridgeInterface? = mMap[key]
fun clear() {
for (item in mMap.values) {
item.onClear()
}
mMap.clear()
}
}
Factory
橋樑的例項構建工廠,預設提供通過反射的方式來例項化不同的類。Factory介面只提供一個create方法,實現方式由子類自行解決
interface Factory {
fun <T : BridgeInterface> create(bridgeClazz: Class<T>): T
}
在AwesomeGithub中提供了通過反射方式來例項化不同類的具體實現NewInstanceFactory
class NewInstanceFactory : Factory {
companion object {
val instance: NewInstanceFactory by lazy { NewInstanceFactory() }
}
override fun <T : BridgeInterface> create(bridgeClazz: Class<T>): T = try {
bridgeClazz.newInstance()
} catch (e: InstantiationException) {
throw RuntimeException("Cannot create an instance of $bridgeClazz", e)
} catch (e: IllegalAccessException) {
throw RuntimeException("Cannot create an instance of $bridgeClazz", e)
}
}
Factory的作用是通過抽象的方式來獲取所需要類的例項,至於該類如何例項化,將通過create方法自行實現。
Provider
Provider是提供橋樑的註冊與獲取各個元件暴露的介面實現。通過register來統一各個元件向外暴露的橋樑類,最後再通過getBridge來獲取具體的橋樑類,然後呼叫所需的相關方法,最終達到元件間的資料傳遞。
來看下BridgeProviders的具體實現
class BridgeProviders {
private val mProvidersMap = HashMap<Class<*>, BridgeProvider>()
private val mBridgeMap = HashMap<Class<*>, Class<*>>()
private val mDefaultBridgeProvider = BridgeProvider(NewInstanceFactory.instance)
companion object {
val instance: BridgeProviders by lazy { BridgeProviders() }
}
fun <T : BridgeInterface> register(
clazz: Class<T>,
factory: Factory? = null,
replace: Boolean = false
) = apply {
if (clazz.interfaces.isEmpty() || !clazz.interfaces[0].interfaces.contains(BridgeInterface::class.java)) {
throw RuntimeException("$clazz must implement BridgeInterface")
}
// 1. get contract interface as key, and save implement class to map value.
// 2. get contract interface as key, and save bridgeProvider of implement class instance
// to map value.
clazz.interfaces[0].let {
if (mProvidersMap[it] == null || replace) {
mBridgeMap[it] = clazz
mProvidersMap[it] = if (factory == null) {
mDefaultBridgeProvider
} else {
BridgeProvider(factory)
}
}
}
}
fun <T : BridgeInterface> getBridge(clazz: Class<T>): T {
mProvidersMap[clazz]?.let {
@Suppress("UNCHECKED_CAST")
return it.get(mBridgeMap[clazz] as Class<T>)
}
throw RuntimeException("$clazz subClass is not register")
}
fun clear() {
mProvidersMap.clear()
mBridgeMap.clear()
mDefaultBridgeProvider.bridgeStore.clear()
}
}
每次register之後都會儲存一個BridgeProvider例項,如果沒有實現自定義的Factory,將會使用預設是mDefaultBridgeProvider,它內部使用的就是預設的NewInstanceFactory
class BridgeProvider(private val factory: Factory) {
val bridgeStore = BridgeStore()
companion object {
private const val DEFAULT_KEY = "com.idisfkj.awesome.componentbridge"
}
fun <T : BridgeInterface> get(key: String, bridgeClass: Class<T>): T {
var componentBridge = bridgeStore.get(key)
if (bridgeClass.isInstance(componentBridge)) {
@Suppress("UNCHECKED_CAST")
return componentBridge as T
}
componentBridge = factory.create(bridgeClass)
bridgeStore.put(key, componentBridge)
return componentBridge
}
fun <T : BridgeInterface> get(bridgeClass: Class<T>): T =
get(DEFAULT_KEY + "@" + bridgeClass.canonicalName, bridgeClass)
}
註冊完之後就可以在任意的元件中通過呼叫橋樑的getBridge來獲取元件向外暴露的方法,從而達到資料的傳遞。
我們來看下具體的使用示例。
AwesomeGithub專案使用的是Github Open Api,用到的介面基本都要AuthorizationBasic或者是AccessToken,而為了讓每一個元件在呼叫介面時都能夠正常獲取到AuthorizationBasic或者AccessToken,所以提供了一個AppBridge與AppBridgeInterface來向外暴露這些資料,實現如下:
interface AppBridgeInterface: BridgeInterface {
/**
* 獲取使用者的Authorization Basic
*/
fun getAuthorizationBasic(): String?
fun setAuthorizationBasic(authorization: String?)
/**
* 獲取使用者的AccessToken
*/
fun getAccessToken(): String?
fun setAccessToken(accessToken: String?)
}
class AppBridge : AppBridgeInterface {
override fun getAuthorizationBasic(): String? = App.AUTHORIZATION_BASIC
override fun setAuthorizationBasic(authorization: String?) {
App.AUTHORIZATION_BASIC = authorization
}
override fun getAccessToken(): String? = App.ACCESS_TOKEN
override fun setAccessToken(accessToken: String?) {
App.ACCESS_TOKEN = accessToken
}
}
有了上面的橋樑介面,接下來需要做的是先在App主專案中進行註冊
private fun registerBridge() {
BridgeProviders.instance.register(AppBridge::class.java, object : Factory {
override fun <T : BridgeInterface> create(bridgeClazz: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return AppBridge() as T
}
})
.register(HomeBridge::class.java)
.register(UserBridge::class.java)
.register(ReposBridge::class.java)
.register(FollowersBridge::class.java)
.register(FollowingBridge::class.java)
.register(NotificationBridge::class.java)
.register(SearchBridge::class.java)
.register(WebViewBridge::class.java)
}
在註冊AppBridge時使用的是自定義的Factory,這裡只是為了簡單展示自定義的Factory的使用,其實沒有特殊需求可以與後面的bridge一樣直接呼叫regiser進行註冊。
註冊完了之後就可以直接在需要的地方進行呼叫。首先在登入元件中將獲取到的AuthorizationBasic或者AccessToken進行儲存,以便被之後的元件進行呼叫。
以AccessToken為例,在login元件中的核心呼叫程式碼如下:
fun getAccessTokenFromCode(code: String) {
showLoading.value = true
repository.getAccessToken(code, object : RequestCallback<Response<ResponseBody>> {
override fun onSuccess(result: ResponseSuccess<Response<ResponseBody>>) {
try {
appBridge.setAccessToken(
result.data?.body()?.string()?.split("=")?.get(1)?.split("&")?.get(
0
)
)
getUser()
} catch (e: IOException) {
e.printStackTrace()
}
}
override fun onError(error: ResponseError) {
showLoading.value = false
}
})
}
如上所示,只需呼叫appBridge.setAccessToken將資料進行儲存;而appBridge可以通過如下獲取
appBridge = BridgeProviders.instance.getBridge(AppBridgeInterface::class.java)
現在已經有了AccessToken資料,為了避免每次呼叫介面都手動加入AccessToken,可以使用okhttp的Interceptor,即在network元件中進行統一加入。
class GithubApiInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val appBridge =
BridgeProviders.instance.getBridge(AppBridgeInterface::class.java)
Timber.d("intercept url %s %s %s", request.url(), appBridge.getAuthorizationBasic(), appBridge.getAccessToken())
val builder = request.newBuilder()
val authorization =
if (!TextUtils.isEmpty(appBridge.getAuthorizationBasic())) "Basic " + appBridge.getAuthorizationBasic()
else "token " + appBridge.getAccessToken()
builder.addHeader("Authorization", authorization)
val response = chain.proceed(builder.build())
Timber.d("intercept url %s, response %s ,code %d", request.url(), response.body().toString(), response.code())
return response
}
}
這樣就完成了將AccessToken從login元件到network元件間的傳遞。
單個元件中呼叫
以上是主專案中集成了login元件,login元件會提供AuthorizationBasic或者AccessToken。那麼對於單個元件(元件可以單獨執行),為了讓元件單獨執行時也能調通相關的介面,在呼叫的時候加入正確的AuthorizationBasic或者AccessToken。需要提供預設的AppBridgeInterface實現類。我這裡命名為DefaultAppBridge
class DefaultAppBridge : AppBridgeInterface {
override fun getAuthorizationBasic(): String? = BuildConfig.AUTHORIZATION_BASIC
override fun setAuthorizationBasic(authorization: String?) {
}
override fun getAccessToken(): String? = BuildConfig.ACCESS_TOKEN
override fun setAccessToken(accessToken: String?) {
}
}
裡面具體的AuthorizationBasic與AccessToken值可以通過BuildConfig獲取,而值的定義可以在local.properities中進行設定
AuthorizationBasic="xxxx"
AccessToken="xxx"
因為每個元件都會依賴與橋樑componentbridge,所以將值配置到componentbridge的build中,具體如下:
android {
compileSdkVersion Versions.target_sdk
buildToolsVersion Versions.build_tools
defaultConfig {
minSdkVersion Versions.min_sdk
targetSdkVersion Versions.target_sdk
versionCode Versions.version_code
versionName Versions.version_name
buildConfigField "String", "AUTHORIZATION_BASIC", getProperties("AuthorizationBasic") + ""
buildConfigField "String", "ACCESS_TOKEN", getProperties("AccessToken") + ""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
有了預設的元件橋樑實現,現在只需在對應的元件Application中進行註冊即可。
例如專案中的followers元件,單獨執行時使用DefaultAppBridge來達到介面的正常呼叫。
class FollowersApp : Application() {
override fun onCreate() {
super.onCreate()
SPUtils.init(this)
initTimber()
initRouter()
// register bridges
BridgeProviders.instance.register(DefaultAppBridge::class.java)
.register(DefaultWebViewBridge::class.java)
}
private fun initTimber() {
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
}
private fun initRouter() {
if (BuildConfig.DEBUG) {
ARouter.openLog()
ARouter.openDebug()
}
ARouter.init(this)
}
}
在元件單獨執行時的Application中註冊之後,單獨執行時呼叫的就是local.properities中設定的值。即保證了元件正常單獨執行。
以上是元件間資料傳遞的全部內容,即解決了元件間的資料傳遞也解決了元件單獨執行時的預設資料呼叫問題。如需瞭解全部程式碼可以檢視AwesomeGithub專案。
主專案使用元件中的Fragment
AwesomeGithub主頁有三個tab,分別是三個元件。這個三個元件是主頁viewpager中的三個fragment。前面已經說了,在主專案中不能直接呼叫各個元件,那麼元件中的fragment又該如何加入到主專案中呢?
其實也很簡單,可以將獲取fragment的例項當作為元件間的資料傳遞的一種特殊形式。那麼有了上面的元件間資料傳遞的基礎,實現在主專案中呼叫元件的fragment也瞬間簡單了許多。藉助的還是橋樑componentbridge。
下面以主頁的search為例
SearchBridgeInterface
首先在componentbridge中建立SearchBridgeInterface介面,並且實現預設的橋樑的BridgeInterface介面。
interface SearchBridgeInterface : BridgeInterface {
fun getSearchFragment(): Fragment
}
其中就一個方法,用來向外提供SearchFragment的獲取
接下來在search元件中實現SearchBridgeInterface的具體實現類
class SearchBridge : SearchBridgeInterface {
override fun getSearchFragment(): Fragment = SearchFragment.getInstance()
}
然後回到主專案的Application中進行註冊
BridgeProviders.instance.register(SearchBridge::class.java)
註冊完之後,就可以在主專案的ViewPagerAdapter中進行獲取SearchFragment例項
class MainViewPagerAdapter(fm: FragmentManager?) : FragmentPagerAdapter(fm) {
override fun getItem(position: Int): Fragment = when (position) {
0 -> BridgeProviders.instance.getBridge(SearchBridgeInterface::class.java).getSearchFragment()
1 -> BridgeProviders.instance.getBridge(NotificationBridgeInterface::class.java)
.getNotificationFragment()
else -> BridgeProviders.instance.getBridge(UserBridgeInterface::class.java).getUserFragment()
}
override fun getCount(): Int = 3
}
主專案中呼叫元件中的Fragment就是這麼簡單,基本上與之前的資料傳遞時一致的。
元件間介面的跳轉
有了上面的基礎,可能會聯想到使用處理Fragment方式來進行元件間頁面的跳轉。的確這也是一種解決方式,不過接下來要介紹的是另一種更加方便與高效的跳轉方式。
專案中使用的是ARouter,它是一個幫助App進行元件化改造的框架,支援模組間的路由、通訊與解藕。下面簡單的介紹下它的使用方式。
首先需要去官網找到版本依賴,並進行匯入。這裡不多說,然後需要在你所有用到的模組中的build.gradle中新增以下配置
kapt {
arguments {
arg("AROUTER_MODULE_NAME", project.getName())
}
}
記住只要該模組需要呼叫ARouter,就需要新增以上程式碼。配置完之後就可以開始使用。
下面我以專案中的webview元件為例,跳轉到元件中的WebViewActivity
上面已經將相關依賴配置好了,首先需要在Application中進行ARouter初始化
private fun initRouter() {
if (BuildConfig.DEBUG) {
ARouter.openLog()
ARouter.openDebug()
}
ARouter.init(this)
}
再為WebViewActivity進行path定義
object ARouterPaths {
const val PATH_WEBVIEW_WEBVIEW = "/webview/webview"
}
因為每一個ARouter進行路由的時候,都需要配置一個包含兩級的路徑,然後將定義的路徑配置到WebViewActivity中
@Route(path = ARouterPaths.PATH_WEBVIEW_WEBVIEW)
class WebViewActivity : BaseActivity<WebviewActivityWebviewBinding, WebViewVM>() {
@Autowired
lateinit var url: String
@Autowired
lateinit var requestUrl: String
override fun getVariableId(): Int = BR.vm
override fun getLayoutId(): Int = R.layout.webview_activity_webview
override fun getViewModelInstance(): WebViewVM = WebViewVM()
override fun getViewModelClass(): Class<WebViewVM> = WebViewVM::class.java
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ARouter.getInstance().inject(this)
viewModel.url.value = url
viewModel.request(requestUrl)
}
override fun addObserver() {
super.addObserver()
viewModel.backClick.observe(this, Observer {
finish()
})
}
override fun onBackPressed() {
if (viewDataBinding.webView.canGoBack()) {
viewDataBinding.webView.goBack()
return
}
super.onBackPressed()
}
}
如上所示,在進行配置時,只需在類上新增@Route註解,然後再將定義的路徑配置到path上。其中的@Autowired註解代表WebViewActivity在使用ARouter進行跳轉時,接收兩個引數,分別為url與requestUrl。
ARouter本質是解析註解,然後定位到引數,再通過原始的Intent中獲取到傳遞過來的引數值。
有了上面的準備過程,最後剩下的就是呼叫ARouter進行頁面跳轉。這裡為了統一呼叫方式,將其調加到橋樑中。
class WebViewBridge : WebViewBridgeInterface {
override fun toWebViewActivity(context: Context, url: String, requestUrl: String) {
ARouter.getInstance().build(ARouterPaths.PATH_WEBVIEW_WEBVIEW).with(
bundleOf("url" to url, "requestUrl" to requestUrl)
).navigation(context)
}
}
前面是定義的跳轉路徑,後面緊接的是頁面傳遞的引數值。剩下的就是在別的元件中呼叫該橋樑,例如followers元件中的contentClick點選:
class FollowersVHVM(private val context: Context) : BaseRecyclerVM<FollowersModel>() {
var data: FollowersModel? = null
override fun onBind(model: FollowersModel?) {
data = model
}
fun contentClick() {
BridgeProviders.instance.getBridge(WebViewBridgeInterface::class.java)
.toWebViewActivity(context, data?.html_url ?: "", "")
}
}
更多ARouter的使用方式,讀者可以自行查閱官方文件
在AwesomeGithub專案中,元件化過程中的主要難點與解決方案已經分析的差不多了。最後我們來聊聊元件間的解藕優化。
元件解耦
元件化本身就是對專案進行解藕,所以如果要進一步進行優化,主要是對元件間的依賴或者資源等方面進行解藕。而對於元件間的依賴,嘗試過在依賴的時候使用runtimeOnly。因為runtimeOnly可以避免依賴的元件在執行之前進行引用呼叫,它只會在專案執行時才能夠正常的引用,這樣就可以防止主專案中進行開發時直接引用依賴的元件。
但是,在實踐的過程中,如果專案中使用了DataBinding,此時使用runtimeOnly進行依賴元件,通過該方式依賴的元件在執行的過程中會出現錯誤。
這是由於DataBinding需要在編譯時生成對應資原始檔。使用runtimeOnly會導致其缺失,最終在程式進行執行時找不到對應資源,導致程式異常。
當然如果沒有使用DataBinding就不會有這種問題。這是元件依賴方面,下面再來說說資源相關的。
由於不同元件模組下可以引入相同命名的資原始檔,為了防止開發過程中不同元件下相同名稱的資原始檔引用錯亂,這裡可以通過在不同元件模組中的build.gradle中新增資源字首。例如login元件中
resourcePrefix代表login元件中的所有資原始檔命名都必須以login_為字首命名。如果沒有編譯器將會標紅,並提示你正確的使用方式。這種方式可以一定程度上避免資原始檔的亂用與錯亂。
以上是AwesomeGithub元件化過程中的整個探索經歷。如果你想更深入的瞭解其實現過程,強烈建議你直接檢視專案的原始碼,畢竟語言上的描述是有限的,程式設計師就應該直接看程式碼才能更快更準的理解。