Android View Component 架構(gòu)設計思維
重構(gòu)記事
為什么要重構(gòu)?
- 項目當前采用的DataBinding框架嚴重限制了編譯速度,并且DataBinding框架存在著出錯提示混亂的毛病,在出錯的時候大幅度降低了開發(fā)效率(當然沒錯的時候還是很快的)
- 在嘗試為Freeline適配最新的DataBinding時候遇到了巨大的阻力,實現(xiàn)的可能性很低了,只能做到局部兼容,因此需要多長全量編譯,開發(fā)效率低下
- 為Freeline適配kotlin增量成功,因此開始使用kotlin語言開發(fā)(kapt不敢用除外),準備大規(guī)模遷移至kotlin開發(fā)語言
- 一些之前的邏輯存在著混亂的毛病,模塊間耦合關系有待進一步梳理
做什么?
- 使用自己的觀察者框架代替Google自帶的DataBinding實現(xiàn)數(shù)據(jù)流
- 使用kotlin寫重構(gòu)代碼,并且局部替換Java代碼
- 去除一些不痛不癢的注解處理框架,在大幅度應用之前去除AROUTER,Butterknife
先思考 => 什么架構(gòu)
我應該用什么架構(gòu) MVP MVVM ?
- MVP 作為android應用很火的架構(gòu),因為充分的解耦被業(yè)界廣泛使用,蛋疼之處在于需要些大量的接口來規(guī)范每一層的行為,來進行進一步的解耦。接口也可以被用于單元測試,目前的項目中還并沒有足夠的精力去寫單元測試,也不存在替換model或其他某層的需求,因此可以使用只抽象View接口版的MVP架構(gòu)(如果你有MVP情節(jié)的話)
- MVVM架構(gòu)隨著DataBinding架構(gòu)的提出而在android上被慢慢推廣,ViewModel作為數(shù)據(jù)渲染層,承接著講model渲染到view上的重任,同時使用數(shù)據(jù)綁定的方式將其與view相關聯(lián),MVVM的設計原則是ViewModel層不持有View的引用,加之DataBinding功能有限和某些部分及其蛋疼,可以做到高效開發(fā)但是某些時候及其蛋疼,當然我個人而言是非常喜歡MVVM架構(gòu)以及數(shù)據(jù)綁定思維的,所以它也是重構(gòu)前微北洋(我的項目名字)主模塊的架構(gòu)
那么兩種架構(gòu)都有自己蛋疼的地方,可不可以有一種新的架構(gòu)設計方式呢
有!
前些時間接觸了React設計思維,React框架將各個UI組件稱為Component,每個Component內(nèi)部維護了自己的view和一些邏輯,并且將狀態(tài)而非是view暴露在外,通過改變Component的狀態(tài)來實現(xiàn)這個組件UI和其他細節(jié)的改變,Component暴露在外的狀態(tài)是可以確定全局狀態(tài)的最小狀態(tài),即狀態(tài)的最小集,Component的其他剩余狀態(tài)都可以通過暴露狀態(tài)的改變所推倒出來,當然這個推倒應該是響應式的,自動的。
當然android上無法寫類似于JSX的東西,如果最類似的話,那就是Anko的DSL布局框架了,這樣子就可以將view寫在Component里面。
不過View寫在Xml里面,然后在Component的初始化時候來find來那些view去操作也是ok的(因為anko的DSL寫法依然是一種富有爭議的布局方式,盡管我定制的Freeline已經(jīng)可以做到kotlin修改的10s內(nèi)增量編譯,DSL還是有很多坑)
說了這么多,這個架構(gòu)到底具體是什么樣子呢?
- 所有的view組件抽象成
Component - 每個
Component內(nèi)維護自己的view,對外暴露可以推倒出全局狀態(tài)的最小狀態(tài)集,view均為private,不可被外部訪問到,只可以修改Component的狀態(tài)而不可訪問component的view -
Component內(nèi)部維護自己view與狀態(tài)之間的關系,推薦使用響應式數(shù)據(jù)流的方式來進行綁定,某些數(shù)據(jù)發(fā)生變化的時候?qū)膙iew也發(fā)生自己的改變
可見,Component本身是高內(nèi)聚的,對外暴露最小狀態(tài),所以外部只需修改最小的狀態(tài)(集)就可以完成Component行為/view的變化,因此外部調(diào)用極其方便而且也不存在邏輯之間的相互干擾
怎么做?
-
Component怎么分? -
Component需要傳入什么? -
Component放在哪里? -
Component內(nèi)部數(shù)據(jù)流怎么寫? -
Component對外暴露什么?怎么暴露? -
Component內(nèi)部狀態(tài)怎么管理?
先看一個圖來解釋

圖示部分的頁面,是使用Recyclerview作為頁面容器,里面的每個Item,就可以作為一個Component來對待

進一步的,此Component里面的那幾個圖書詳情item,又可以作為子Component來對待

他們的xml布局因為極其簡單就跳過不談,Component的設計部分我們可以從最小的item說起
因為它沒有被放在Recyclerview里面,所以它繼承ViewHolder與否都是隨意的,但是為了統(tǒng)一性,就繼承RecyclerView.ViewHolder好了(事實上是否繼承它都是隨意的)
先來看這個Component對應的數(shù)據(jù)Model部分
public class Book {
/**
* barcode : TD002424561
* title : 設計心理學.2,與復雜共處,= Living with complexity
* author : (美) 唐納德·A·諾曼著
* callno : TB47/N4(5) v.2
* local : 北洋園工學閱覽區(qū)
* type : 中文普通書
* loanTime : 2017-01-09
* returnTime : 2017-03-23
*/
public String barcode;
public String title;
public String author;
public String callno;
public String local;
public String type;
public String loanTime;
public String returnTime;
/**
* 距離還書日期還有多少天
* @return
*/
public int timeLeft(){
return BookTimeHelper.getBetweenDays(returnTime);
// return 20;
}
/**
* 看是否超過還書日期
* @return
*/
public boolean isOverTime(){
return this.timeLeft() < 0;
}
public boolean willBeOver(){
return this.timeLeft() < 7 && !isOverTime();
}
}
我們的需求是:在這個view里面有 書的名字,應還日期,書本icon的涂色方案隨著距離還書日期的長短而變色
首先聲明用到的view和Context什么的
class BookItemComponent(lifecycleOwner: LifecycleOwner,itemView: View) : RecyclerView.ViewHolder(itemView) {
private val mContext = itemView.context
private val cover: ImageView = itemView.findViewById(R.id.ic_item_book)
private val name: TextView = itemView.findViewById(R.id.tv_item_book_name)
private val returntimeText: TextView = itemView.findViewById(R.id.tv_item_book_return)
}
LifecycleOwner是來自Android Architecture Components的組件,用來管理android生命周期用,避免組件的內(nèi)存泄漏問題 Android Architecture Components
下來就是聲明可觀察的數(shù)據(jù)(也可以成為狀態(tài))
private val bookData = MutableLiveData<Book>()
因為此Component邏輯簡單,只需要觀測Book類即可推斷確定其狀態(tài),因此它也是這個Component的最小狀態(tài)集合
插播一條補充知識:
LiveData<T>,MutableLiveData<T>也都來自于Android Architecture Components的組件,是生命周期感知的可觀測動態(tài)數(shù)據(jù)組件Sample:
LiveData<BigDecimal> myPriceListener = ...; myPriceListener.observe(this, price -> { // Update the UI. });當然用kotlin給它寫了一個簡單的函數(shù)式拓展
/** * LiveData 自動綁定的kotlin拓展 再也不同手動指定重載了hhh */ fun <T> LiveData<T>.bind(lifecycleOwner: LifecycleOwner, block : (T?) -> Unit) { this.observe(lifecycleOwner,android.arch.lifecycle.Observer<T>{ block(it) }) }
好了,回到正題,然后我們就該把view和Component的可觀測數(shù)據(jù)/狀態(tài)綁定起來了
init {
bookData.bind(lifecycleOwner) {
it?.apply {
name.text = this.title
setBookCoverDrawable(book = this)
returntimeText.text = "應還日期: ${this.returnTime}"
}
}
}
//這里是剛剛調(diào)用的函數(shù) 寫了寫動態(tài)涂色的細節(jié)
private fun setBookCoverDrawable(book: Book) {
var drawable = ContextCompat.getDrawable(mContext, R.drawable.lib_book)
val leftDays = book.timeLeft()
when {
leftDays > 20 -> DrawableCompat.setTint(drawable, Color.rgb(0, 167, 224)) //blue
leftDays > 10 -> DrawableCompat.setTint(drawable, Color.rgb(42, 160, 74)) //green
leftDays > 0 -> {
if (leftDays < 5) {
val act = mContext as? Activity
act?.apply {
Alerter.create(this)
.setTitle("還書提醒")
.setBackgroundColor(R.color.assist_color_2)
.setText(book.title + "剩余時間不足5天,請盡快還書")
.show()
}
}
DrawableCompat.setTint(drawable, Color.rgb(160, 42, 42)) //red
}
else -> drawable = ContextCompat.getDrawable(mContext, R.drawable.lib_warning)
}
cover.setImageDrawable(drawable)
}
通過觀測LiveData<Book>來實現(xiàn)Component狀態(tài)的改變,因此只需要修改Book就可以實現(xiàn)該Component的相關一系列改變
然后我們只需要把相關函數(shù)暴露出來
fun render(): View = itemView
fun bindBook(book: Book){
bookData.value = book
}
然后在需要的時候創(chuàng)建調(diào)用它就可以了
val view = inflater.inflate(R.layout.item_common_book, bookContainer, false)
val bookItem = BookItemComponent(life cycleOwner = lifecycleOwner, itemView = view)
bookItem.bindBook(it)
bookItemViewContainer.add(view)
來點復雜的?
來看主頁的圖書館模塊

圖書館模塊本身也是一個Component。
需求:第二行的圖標在刷新的時候顯示progressbar,刷新成功后顯示imageview(對勾),刷新錯誤的時候imageview顯示錯誤的的圖片
-
這個Item要放在Recyclerview里面,所以要繼承ViewHolder
class HomeLibItemComponent(private val lifecycleOwner: LifecycleOwner, itemView: View) : RecyclerView.ViewHolder(itemView) { } -
聲明該Component里面用到的view
class HomeLibItemComponent(private val lifecycleOwner: LifecycleOwner, itemView: View) : RecyclerView.ViewHolder(itemView) { private val context = itemView.context private val stateImage: ImageView = itemView.findViewById(R.id.ic_home_lib_state) private val stateProgressBar: ProgressBar = itemView.findViewById(R.id.progress_home_lib_state) private val stateMessage: TextView = itemView.findViewById(R.id.tv_home_lib_state) private val bookContainer: LinearLayout = itemView.findViewById(R.id.ll_home_lib_books) private val refreshBtn: Button = itemView.findViewById(R.id.btn_home_lib_refresh) private val renewBooksBtn: Button = itemView.findViewById(R.id.btn_home_lib_renew) private val loadMoreBooksBtn: Button = itemView.findViewById(R.id.btn_home_lib_more) -
聲明Component里面的可觀測數(shù)據(jù)流
private val loadMoreBtnText = MutableLiveData<String>() private val loadingState = MutableLiveData<Int>() private val message = MutableLiveData<String>() private var isExpanded = false -
聲明一些其他的用到的東西
//對應barcode和book做查詢 private val bookHashMap = HashMap<String, Book>() private val bookItemViewContainer = mutableListOf<View>() //緩存的LinearLayout里面的view 折疊提高效率用 private val libApi = RetrofitProvider.getRetrofit().create(LibApi::class.java) -
建立綁定關系
init { //這里bind一下 解個耦 message.bind(lifecycleOwner) { message -> stateMessage.text = message } loadingState.bind(lifecycleOwner) { state -> when (state) { PROGRESSING -> { stateImage.visibility = View.INVISIBLE stateProgressBar.visibility = View.VISIBLE message.value = "正在刷新" } OK -> { stateImage.visibility = View.VISIBLE stateProgressBar.visibility = View.INVISIBLE Glide.with(context).load(R.drawable.lib_ok).into(stateImage) } WARNING -> { stateImage.visibility = View.VISIBLE stateProgressBar.visibility = View.INVISIBLE Glide.with(context).load(R.drawable.lib_warning).into(stateImage) } } } loadMoreBtnText.bind(lifecycleOwner) { loadMoreBooksBtn.text = it if (it == NO_MORE_BOOKS) { loadMoreBooksBtn.isEnabled = false } } } -
再寫一個OnBindViewHolder的回調(diào)(到時候手動調(diào)用就可以了,會考慮使用接口規(guī)范這部分內(nèi)容)
fun onBind() { refreshBtn.setOnClickListener { refresh(true) } refresh() renewBooksBtn.setOnClickListener { renewBooksClick() } loadMoreBooksBtn.setOnClickListener { view: View -> if (isExpanded) { // LinearLayout remove的時候會數(shù)組順延 所以要從后往前遍歷 (bookContainer.childCount - 1 downTo 0) .filter { it >= 3 } .forEach { bookContainer.removeViewAt(it) } loadMoreBtnText.value = "顯示剩余(${bookItemViewContainer.size - 3})" isExpanded = false } else { (0 until bookItemViewContainer.size) .filter { it >= 3 } .forEach { bookContainer.addView(bookItemViewContainer[it]) } loadMoreBtnText.value = "折疊顯示" isExpanded = true } } } -
剩下的就是方法的具體實現(xiàn)了這個看個人喜歡的處理方式來處理,比如說我喜歡協(xié)程處理網(wǎng)絡請求,然后用LiveData處理多種請求的映射
比如說一個簡單的網(wǎng)絡請求以及緩存的封裝
object LibRepository { private const val USER_INFO = "LIB_USER_INFO" private val libApi = RetrofitProvider.getRetrofit().create(LibApi::class.java) fun getUserInfo(refresh: Boolean = false): LiveData<Info> { val livedata = MutableLiveData<Info>() async(UI) { if (!refresh) { val cacheData: Info? = bg { Hawk.get<Info>(USER_INFO) }.await() cacheData?.let { livedata.value = it } } val networkData: Info? = bg { libApi.libUserInfo.map { it.data }.toBlocking().first() }.await() networkData?.let { livedata.value = it bg { Hawk.put(USER_INFO, networkData) } } } return livedata } }
8.與其他Component的組合
使用簡單的方法即可相互集成,傳入inflate好的view和對應的LifecycleOwener即可
data?.books?.forEach {
bookHashMap[it.barcode] = it
val view = inflater.inflate(R.layout.item_common_book, bookContainer, false)
val bookItem = BookItemComponent(lifecycleOwner = lifecycleOwner, itemView = view)
bookItem.bindBook(it)
bookItemViewContainer.add(view)
}
小總結(jié):狀態(tài)綁定,數(shù)據(jù)觀測
在圖書館的這個Component的開發(fā)中,只需要在發(fā)起各種任務以及處理任務返回信息的時候,改變相關的狀態(tài)值和可觀測數(shù)據(jù)流即可,便可實現(xiàn)Component一系列狀態(tài)的改變,因為所有邏輯不依賴外部,所有目前該Component不對外暴露任何狀態(tài)和view。實現(xiàn)了模塊內(nèi)的數(shù)據(jù)流和高內(nèi)聚。
模塊內(nèi)數(shù)據(jù)流可以大幅度簡化代碼,避免某種程度上對view直接操作所造成的混亂,例如異常處理方法
private fun handleException(throwable: Throwable?) {
//錯誤處理時候的卡片顯示狀況
throwable?.let {
Logger.e(throwable, "主頁圖書館模塊錯誤")
when (throwable) {
is HttpException -> {
try {
val errorJson = throwable.response().errorBody()!!.string()
val errJsonObject = JSONObject(errorJson)
val errcode = errJsonObject.getInt("error_code")
val errmessage = errJsonObject.getString("message")
loadingState.value = WARNING
message.value = errmessage
} catch (e: IOException) {
e.printStackTrace()
} catch (e: JSONException) {
e.printStackTrace()
}
}
is SocketTimeoutException -> {
loadingState.value = WARNING
this.message.value = "網(wǎng)絡超時...很絕望"
}
else -> {
loadingState.value = WARNING
this.message.value = "粗線蜜汁錯誤"
}
}
}
}
在收到相關錯誤碼的時候,修改state和message的觀測值,相關的數(shù)據(jù)流會根據(jù)最初的綁定關系自動通知到相關的view
比如說loadingstate的觀測:
loadingState.bind(lifecycleOwner) { state ->
when (state) {
PROGRESSING -> {
stateImage.visibility = View.INVISIBLE
stateProgressBar.visibility = View.VISIBLE
message.value = "正在刷新"
}
OK -> {
stateImage.visibility = View.VISIBLE
stateProgressBar.visibility = View.INVISIBLE
Glide.with(context).load(R.drawable.lib_ok).into(stateImage)
}
WARNING -> {
stateImage.visibility = View.VISIBLE
stateProgressBar.visibility = View.INVISIBLE
Glide.with(context).load(R.drawable.lib_warning).into(stateImage)
}
}
}
最近更新的
這個架構(gòu)比較適合的場景就是,多個業(yè)務模塊作為Card出現(xiàn)的時候。(或者說是Feed流里面的item,或者是你喜歡使用Recyclerview作為頁面組件的容器)等等... 對于單頁場景,其實一頁就可以認為是一個Component,在頁面的內(nèi)部管理可觀察數(shù)據(jù)流即可。
架構(gòu)不是死的,思維也不是。大家還是要根據(jù)自己的業(yè)務場景適當發(fā)揮啊~