Android極簡MVVM,從一個基類庫談起
highlight: a11y-dark
我報名參加金石計劃1期挑戰——瓜分10萬獎池,這是我的第1篇文章,點選檢視活動詳情
Hello啊各位老鐵,今天帶來一個老生常談的技術,MVVM,這篇文章,主要詳細介紹如何封裝一個MVVM的基類庫,以及MVVM架構模式在實際業務中的用法,最後會把實際的封裝程式碼開源,並提供遠端依賴,方便給到大家使用以及二次修改,儘量做到細緻入微,淺顯易懂,OK,廢話不多贅述,我們進入正文。
這篇文章大概會按照以下幾個模組進行闡述,此次封裝,做到絕無第三方依賴,都是Android原生的程式碼封裝,請放心使用,如果您想直接進行使用,請直接跳到第4步,整合使用即可,此次的封裝,和目前主流的MVVM架構模式,會完美契合,讓架構模式簡單化,讓業務程式碼清晰化,必須值得推薦使用。
一、MVVM簡單概括
二、基於MVVM模式如何封裝基類庫
三、實戰封裝
四、封裝後在業務中如何使用
五、開源以及Demo檢視
溫馨提示:內容稍多,請合理安排好時間,如果不想查閱具體封裝過程,底部有開源地址,可以直接檢視。
一、MVVM簡單概括
MVVM的開發模式,相對來說低耦合,業務之間邏輯顯得也十分分明,Model層負責將請求的資料交給ViewModel層;ViewModel層負責將請求到的資料做業務邏輯處理,最後交給View層去展示,與View一一對應;View層只負責介面繪製重新整理,不處理業務邏輯,非常適合進行獨立模組開發。
三層簡單概括
1、Model:資料層,包含資料實體和對資料實體的操作。
2、View:檢視層,對應於Activity,XML,View,負責資料顯示以及使用者互動。
3、ViewModel:關聯層,將Model和View進行繫結,Model或者View更改時,實時重新整理對方。
需要注意:
1、View只做和UI相關的工作,不涉及任何業務邏輯,不涉及操作資料,不處理資料,也就是UI和資料是嚴格分開的。
2、ViewModel只做和業務邏輯相關的工作,不涉及任何和UI相關的操作,不持有控制元件引用,不更新UI。
二、基於MVVM模式如何封裝基類庫
MVVM我們已經清晰,然而針對現有的三層,我們如何進行拆解封裝呢?面對這樣的一個問題,我們也是需要從三層以及和實際的業務進行相結合,從實際業務中來,也要從實際業務中去,這是我們封裝的一個潛在因素,一旦脫離了實際,封裝的再優秀,也只是一個花瓶,中看不中用。
針對MVVM中的三層,其實,我們在封裝中,也是基於這三層,View,ViewModel和Model。View中,在實際的開發中,一般針對Activity和Fragment進行系統的抽取封裝,ViewModel一般會抽取一個父類,做一些公共的方法或屬性配置,Model層一般封裝的較少,根據實際業務,需要具體問題具體分析。
Activity和Fragment的封裝思路,其實是一致的,需要以簡單和複雜兩種方向進行抽取,一種是簡單的頁面繼承使用,一種是複雜的頁面繼承使用,這樣區分的一個目的,就是,專職專用,避免大材小用,而具體的封裝,除了使得程式碼簡潔化,更重要的拓展化,方便子類的呼叫。
在具體封裝的時候,與實際業務相結合,這個無比重要,比如實際的大部分頁面,都帶有一個標題欄,那麼標題欄就可以直接封裝父類裡面,像子類拓展出,更改標題,右側按鈕,左側按鈕等功能屬性;除了統一的標題欄,另外就是子類的檢視了,關於子類的檢視傳遞,這個是必須的,可以直接抽象出一個必須要實現的方法,其他的,比如狀態列的改變,預設頁的設定等等,也需要在父類中統一的給出。
複雜的頁面是基於簡單的頁面而來的,這裡的複雜,一般是包含很多邏輯的處理,那麼,我們就可以增加ViewModel層和Model層了,目前基於DataBinding的實現方式,無論簡單和複雜,都是必須需要考慮的,也就是說在父類中,我們就需要向子類提供出可以拿到的databinding和viewmodel,一般以泛型的方式引入,這樣子類再繼承的時候,就可以很方便的進行呼叫。
在複雜的頁面,也就是包含ViewModel層和Model層的時候,需要考慮繫結檢視variable的傳遞,也就是當前的ViewModel和那個xml進行繫結,當然這是在需要的時候,必須要操作的,除了檢視繫結,常見的,資料請求狀態,比如請求成功,請求失敗,預設頁顯示和隱藏,Dialog的顯示和隱藏,LiveData的資料回傳等等,在複雜的頁面中也是需要我們考慮的,除此之外,ViewModel中如何和View層的生命週期繫結,在實際的業務中也是不得不需要考慮的。
除了以上的常規考慮,在實際的業務中,比如事件訊息傳遞,PagerAdapter使用,狀態列透明等很多和基類的相關的功能,我們其實也可以進行封裝進去,便於子類的呼叫。
三、實戰封裝
通過第2條中的拆解和具體的封裝思路,不妨我們進行實戰一下,由於Activity和Fragment的封裝思路以及相關屬性和方法,大部分都是雷同的,所以目前只介紹Activity,更詳細的封裝,還請大家參考原始碼。
1、Activity的簡單封裝
簡單封裝,不攜帶ViewModel,只傳遞ViewDataBinding,子類必須重寫的方法只有一個initData,其他均為選擇性重寫,如果相對邏輯比較簡單的頁面,可以繼承此類。
一個很簡單的普通封裝,就是把共有的常見的,封裝到父類裡,便於子類的呼叫,具體什麼方法,什麼邏輯進行採取封裝,需要我們根據具體業務或者公司的相關情況而定,以下是原始碼。
```kotlin
abstract class BaseActivity
private var mActionBarView: ActionBarView? = null
private var mLayoutError: LinearLayout? = null
private var mLayoutId = layoutId
lateinit var mBinding: VB
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
//預設狀態列為白底黑字
darkMode(BaseConfig.statusBarDarkMode)
statusBarColor(ContextCompat.getColor(this, BaseConfig.statusBarColor))
setContentView(R.layout.activity_base)
val baseChild = findViewById<LinearLayout>(R.id.layout_base_child)
mLayoutError = findViewById(R.id.layout_empty_or_error)
mActionBarView = findViewById(R.id.action_bar)
if (mLayoutId == 0) {
mLayoutId = getLayoutId()
}
if (savedInstanceState != null && getIntercept()) {
noEmptyBundle()
return
}
val childView = layoutInflater.inflate(mLayoutId, null)
baseChild.addView(childView)
mBinding = DataBindingUtil.bind(childView)!!
initView()
initData()
} catch (e: Exception) {
e.printStackTrace()
noEmptyBundle()
}
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:獲取檢視id
*/
open fun getLayoutId(): Int {
return 0
}
open fun initView() {}
/**
* AUTHOR:AbnerMing
* INTRODUCE:初始化資料
*/
abstract fun initData()
/**
* AUTHOR:AbnerMing
* INTRODUCE:動態改變狀態列顏色和標題
*/
fun setDarkTitle(dark: Boolean, color: Int, title: String) {
try {
darkMode(dark)
statusBarColor(ContextCompat.getColor(this, color))
setBarTitle(title)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:設定標題
*/
fun setBarTitle(title: String) {
mActionBarView!!.visibility = View.VISIBLE
mActionBarView!!.setBarTitle(title)
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:隱藏左側按鈕
*/
fun hintLeftMenu() {
mActionBarView!!.hintLeftBack()
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:獲取ActionBarView
*/
fun getActionBarView(): ActionBarView {
return mActionBarView!!
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:隱藏標題欄
*/
fun hintActionBar() {
mActionBarView?.visibility = View.GONE
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:Bundle為空進行攔截,解決改變許可權後重回App崩潰問題
*/
open fun getIntercept(): Boolean {
return false
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:Bundle為空時的邏輯處理,解決改變許可權後重回App崩潰問題
*/
open fun noEmptyBundle() {}
override fun onDestroy() {
super.onDestroy()
try {
LiveDataBus.removeObserve(this)
LiveDataBus.removeStickyObserver(this)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:透明狀態列
*/
fun translucentWindow(dark: Boolean) {
try {
immersive(0, dark)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:設定預設頁
*/
fun setEmptyOrError(view: View) {
mLayoutError?.visibility = View.VISIBLE
mLayoutError?.removeAllViews()
mLayoutError?.addView(view)
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:隱藏
*/
fun hintEmptyOrErrorView() {
mLayoutError?.visibility = View.GONE
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:獲取錯誤或為空的view
*/
fun getEmptyOrErrorView(): LinearLayout {
return mLayoutError!!
}
} ```
涉及的方法概述
| 方法名 | 引數 | 概述 | | --- | --- | --- | | getLayoutId | 無參 | 子類傳遞的layout,用於載入檢視,可以通過構造方法傳遞,也可以通過此方法傳遞。 | | initView | 無參 | 初始化View,非必須重寫 | | initData | 無參 | 初始化資料 | | setDarkTitle | dark: Boolean, color: Int, title: String,1、dark: Boolean,狀態列顏色,true就是黑色,false就是白色。2、color: Int,狀態列背景顏色,3、title: String,標題欄內容 | 設定標題,狀態列背景及顏色 | | setBarTitle | title: String,標題欄內容 | 設定標題 | | hintLeftMenu | 無參 | 隱藏左側按鈕 | | getActionBarView | 無參 | 獲取標題欄View,可以操作標題欄裡的任何控制元件 | | hintActionBar | 無參 | 隱藏標題欄 | | translucentWindow | dark: Boolean,狀態列顏色,true就是黑色,false就是白色 | 透明狀態列 | | setEmptyOrError | view: View,傳遞的預設View檢視 | 設定預設檢視 | | hintEmptyOrErrorView | 無參 | 隱藏預設檢視 | | getEmptyOrErrorView | 無參 | 獲取預設檢視 |
簡單的Activity沒有什麼好說的,都是中規中矩,具體的使用請大家看第四條,具體使用即可。
2、Activity的複雜封裝
也談不上覆雜,只是在繼承簡單頁面的基礎之上多加了一個ViewModel,相對於比較複雜的頁面,就可以繼承此類,此類,拓展了ViewModel,可以在ViewModel裡進行邏輯的書寫,此類也是MVVM的標準執行,V繼承於BaseVMActivity,VM繼承於BaseViewModel,至於M,可以在VM中通過getRepository方法進行獲取。
具體程式碼邏輯如下:
BaseVMActivity繼承於BaseActivity。
```kotlin
abstract class BaseVMActivity
lateinit var mViewModel: BM
override fun initData() {
mViewModel = getViewModel()!!
val variableId = getVariableId()
if (variableId != -1) {
mBinding.setVariable(getVariableId(), mViewModel)
mBinding.executePendingBindings()
}
initVMData()
observeLiveData()
initState()
lifecycle.addObserver(mViewModel)
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:獲取繫結的xml id
*/
open fun getVariableId(): Int {
return -1
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:初始化狀態
*/
private fun initState() {
mViewModel.mStateViewLiveData.observe(this, {
when (it) {
StateLayoutEnum.DIALOG_LOADING -> {
dialogLoading()
}
StateLayoutEnum.DIALOGD_DISMISS -> {
dialogDismiss()
}
StateLayoutEnum.DATA_ERROR -> {
dataError()
}
StateLayoutEnum.DATA_NULL -> {
dataEmpty()
}
StateLayoutEnum.NET_ERROR -> {
netError()
}
StateLayoutEnum.HIDE -> {
hide()
}
}
})
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:初始化資料
*/
abstract fun initVMData()
/**
* AUTHOR:AbnerMing
* INTRODUCE:LiveData的Observer
*/
open fun observeLiveData() {
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:dialog載入
*/
open fun dialogLoading() {}
/**
* AUTHOR:AbnerMing
* INTRODUCE:dialog隱藏
*/
open fun dialogDismiss() {}
/**
* AUTHOR:AbnerMing
* INTRODUCE:資料錯誤
*/
open fun dataError() {}
/**
* AUTHOR:AbnerMing
* INTRODUCE:資料為空
*/
open fun dataEmpty() {}
/**
* AUTHOR:AbnerMing
* INTRODUCE:網路錯誤或請求錯誤
*/
open fun netError() {}
/**
* AUTHOR:AbnerMing
* INTRODUCE:隱藏某些佈局或者預設頁等
*/
open fun hide() {}
private fun getViewModel(): BM? {
//這裡獲得到的是類的泛型的型別
val type = javaClass.genericSuperclass
if (type != null && type is ParameterizedType) {
val actualTypeArguments = type.actualTypeArguments
val tClass = actualTypeArguments[1]
return ViewModelProvider(
this,
ViewModelProvider.AndroidViewModelFactory.getInstance(application)
)
.get(tClass as Class<BM>)
}
return null
}
override fun onDestroy() { super.onDestroy() try { lifecycle.removeObserver(mViewModel) } catch (e: Exception) { e.printStackTrace() } }
} ```
封裝涉及的方法概述
| 方法名 | 引數 | 概述| | --- | --- | --- | | getVariableId | 無參 | 獲取繫結的xml variable,也就是當前的xml和哪個物件進行繫結,用於xml裡直接資料繫結 | | initVMData | 無參 | 初始化資料,必須要實現的方法 | | observeLiveData | 無參 | LiveData的Observer,UI層監聽ViewModel層的資料改變 | | dialogLoading | 無參 | dialog載入 | | dialogDismiss | 無參 | dialog隱藏 | | dataError | 無參 | 資料錯誤 | | dataEmpty | 無參 | 資料為空 | | netError | 無參 | 資料錯誤 | | hide | 無參 | 隱藏預設頁等其他頁面 |
BaseViewModel
BaseViewModel相對比較簡單,只提供了一個可以獲取Repository的方法,還有一個是重新整理UI檢視的一個LiveData,就是資料請求,Dialog載入,預設頁載入的狀態。更改狀態,只需要呼叫changeStateView方法即可,子類可以重寫生命週期方法,便於生命週期的考慮。
```kotlin
open class BaseViewModel : ViewModel() , BaseObserver{
/*
* 控制狀態檢視的LiveData
/
val mStateViewLiveData = MutableLiveData
/**
* 更改狀態檢視的狀態
*/
public fun changeStateView(
state: StateLayoutEnum
) {
// 對引數進行校驗
when (state) {
StateLayoutEnum.DIALOG_LOADING -> {
mStateViewLiveData.postValue(StateLayoutEnum.DIALOG_LOADING)
}
StateLayoutEnum.DIALOGD_DISMISS -> {
mStateViewLiveData.postValue(StateLayoutEnum.DIALOGD_DISMISS)
}
StateLayoutEnum.DATA_ERROR -> {
mStateViewLiveData.postValue(StateLayoutEnum.DATA_ERROR)
}
StateLayoutEnum.DATA_NULL -> {
mStateViewLiveData.postValue(StateLayoutEnum.DATA_NULL)
}
StateLayoutEnum.NET_ERROR -> {
mStateViewLiveData.postValue(StateLayoutEnum.NET_ERROR)
}
StateLayoutEnum.HIDE -> {
mStateViewLiveData.postValue(StateLayoutEnum.HIDE)
}
}
}
/**
* AUTHOR:AbnerMing
* INTRODUCE:獲取Repository
*/
inline fun <reified R> getRepository(): R? {
try {
val clazz = R::class.java
return clazz.newInstance()
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
/* * AUTHOR:AbnerMing * INTRODUCE:生命週期初始化 / override fun onCreate() { }
/* * AUTHOR:AbnerMing * INTRODUCE:生命週期頁面可見 / override fun onStart() { }
/* * AUTHOR:AbnerMing * INTRODUCE:生命週期頁面獲取焦點 / override fun onResume() { }
/* * AUTHOR:AbnerMing * INTRODUCE:生命週期頁面失去焦點 / override fun onPause() {
}
/* * AUTHOR:AbnerMing * INTRODUCE:生命週期頁面不可見 / override fun onStop() {
}
/* * AUTHOR:AbnerMing * INTRODUCE:生命週期頁面銷燬 / override fun onDestroy() { }
} ``` 複雜的Activity,大家可以發現,其實就是標準的MVVM形式封裝,Fragment的封裝也是基於此,搞清楚上述,基本上我們這個基類庫就完成了大半,確實也沒什麼好說的,大家直接看使用吧。
四、封裝後在業務中如何使用
通過以上的封裝,我們在業務層所有的頁面就可以繼承父類進行使用,以達到程式碼的高度統一,使得架構模式簡單化,讓業務程式碼清晰化,目前的封裝,大家可以直接封裝成庫或者打成aar給到其他開發者使用,目前我已經上傳到遠端,不想麻煩的老鐵,可以直接按照下面的步驟進行使用。
1、在你的根專案下的build.gradle檔案下,引入maven。
```
allprojects { repositories { maven { url "https://gitee.com/AbnerAndroid/almighty/raw/master" } } } ```
2、在你需要使用的Module中build.gradle檔案下,引入依賴。
```
dependencies { implementation 'com.vip:base:1.0.2' } ```
通過以上的Maven倉庫依賴,我們就可以愉快的進行使用了,下面針對各個封裝的功能進行一個簡單的演示,當然,大家可以直接看原始碼中的例項,那裡相對比較全面。
1、普通的Activity的繼承
如果,你的Activity頁面邏輯比較簡單,建議繼承BaseActivity,此父類,沒有與ViewModel相結合,只包含正常且簡單的邏輯處理,目前必須重寫的只有一個initData方法,其他方法,大家可以根據業務重寫即可。
kotlin
class MainActivity : BaseActivity<ActivityMainBinding>(R.layout.activity_main) {
/**
* AUTHOR:AbnerMing
* INTRODUCE:初始化資料
*/
override fun initData() {
setBarTitle("主頁")
}
}
2、ViewModel形式Activity的繼承
View層,需要繼承BaseVMActivity
```kotlin
class TestViewModelActivity : BaseVMActivity
override fun initVMData() {
setBarTitle("ViewModel方式使用")
}
} ```
ViewModel層,需要繼承BaseViewModel
實際的業務中,遇到網路請求,預設頁展示,Dialog顯示隱藏,呼叫changeStateView方法,UI層只需要重寫對應的方法即可。
```kotlin
class TestViewModel : BaseViewModel() {
/**
* AUTHOR:AbnerMing
* INTRODUCE:獲取需要的Repository
*/
private val repository by lazy {
getRepository<TestRepository>()
}
} ``` Model層,一般根據實際需要,進行具體的封裝使用。
```kotlin
class TestRepository {
} ```
3、DataBinding形式使用
View層,繼承BaseVMActivity,返回當前檢視的繫結variable
```kotlin
class DataBindActivity :
BaseVMActivity
override fun initVMData() {
setBarTitle("DataBinding使用")
}
override fun getVariableId(): Int {
return BR.data
}
} ```
ViewModel層繼承BaseViewModel
```kotlin
class DataBindViewModel : BaseViewModel() {
var oneWayContent = "單向繫結資料測試"
var twoWayContent = "雙向繫結資料測試"
/**
* AUTHOR:AbnerMing
* INTRODUCE:獲取雙向繫結資料
*/
var clickListener = View.OnClickListener {
Toast.makeText(it.context, twoWayContent, Toast.LENGTH_SHORT).show()
}
} ```
XML檢視,直接繫結
```xml
<data>
<variable
name="data"
type="com.abner.base.bind.DataBindViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingLeft="@dimen/gwm_dp_20"
android:paddingRight="@dimen/gwm_dp_20">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="@dimen/gwm_dp_20"
android:text="@{data.oneWayContent}" />
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/gwm_dp_20"
android:hint="雙向繫結"
android:text="@={data.twoWayContent}" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="@dimen/gwm_dp_20"
android:onClick="@{data.clickListener}"
android:text="獲取雙向繫結資料" />
</LinearLayout>
```
4、Fragment的簡單使用
如果,你的Fragment頁面邏輯比較簡單,建議繼承BaseFragment,此父類,沒有與ViewModel相結合,只包含正常且簡單的邏輯處理,目前必須重寫的只有一個initData方法,其他方法,大家可以根據業務重寫即可。
```kotlin
class TestPagerFragment : BaseFragment
override fun initData() {
}
} ```
5、ViewModel形式Fragment的繼承
View層,需要繼承BaseVMFragment
```kotlin
class TestViewModelPagerFragment :
BaseVMFragment
override fun initVMData() {
}
} ``` ViewModel層,需要繼承BaseViewModel
```kotlin class TestFragmentViewModel :BaseViewModel(){
/**
* AUTHOR:AbnerMing
* INTRODUCE:獲取需要的Repository
*/
private val repository by lazy {
getRepository<TestRepository>()
}
} ``` Model層,一般根據實際需要,進行具體的封裝使用。
```kotlin
class TestRepository {
} ```
6、Fragment的DataBinding形式使用和Activity類似,就不贅述了。
7、事件訊息匯流排使用
普通事件傳送
kotlin
LiveDataBus.send("send", "我傳送了一條普通訊息")
普通傳送事件接收
```kotlin
LiveDataBus.observe(this, "send", Observer
```kotlin
LiveDataBus.sendSticky("sendSticky", "我傳送了一條粘性事件訊息") ``` 粘性事件接收
```kotlin
LiveDataBus.observeSticky(this, "sendSticky", Observer
五、開源以及Demo檢視
以上的封裝,目前已經開源,大家可以下載檢視原始碼,或者進行二次更改使用,地址是:
https://github.com/AbnerMing888/VipBase
相關Demo,大家可以down下專案,執行即可,這裡簡單貼張效果圖:
目前的封裝,沒有過多的冗餘程式碼,完全可以滿足實際的業務需要,大家可以按照這種模式試驗一番,遇到問題,可以多多交流,畢竟,技術是開放的,交流中才能不斷的進步,當然了,需要結合自己的實際業務進行使用,畢竟專案中不應存在多個架構模式,MVC也好,MVP,MVVM,MVI也罷,無論使用哪種,適合的才是最好的。
好了,各位老鐵,這篇文章就到這裡,下篇文章《元件化開發,從未如此簡單》正在撰寫中,大家敬請期待!
- Android自動生成程式碼,視覺化腳手架之基礎資訊配置
- 如何搞一個線上的Shape生成
- 簡單封裝一個易拓展的Dialog
- 整合一個以官網(微信,QQ,微博)為標準的登入分享功能
- Android打造專有Hook第四篇,實戰增量程式碼規範檢查
- Android極簡MVVM,從一個基類庫談起
- Android元件化開發,其實就這麼簡單
- Android打造專有hook,讓不規範的程式碼扼殺在萌芽之中
- Android包體積過大,真的會影響績效
- Android長按圖示展示快捷方式
- Android自動生成Shape資原始檔(下)
- Android自動生成Shape資原始檔,邁出視覺化腳手架第一步!(上)
- Android自動生成程式碼,視覺化腳手架,將大大提高開發效率
- Android自動生成程式碼,視覺化腳手架之環境搭建
- 怎麼去約束程式碼的統一性
- 沒有準備充分,先不要著急投簡歷
- Android如何生成本地或者遠端aar