厭倦了MVP?不妨來看看Android View Component 架構

爲什麼要重構?

  • 項目當前採用的DataBinding框架嚴重限制了編譯速度,並且DataBinding框架存在着出錯提示混亂的毛病,在出錯的時候大幅度降低了開發效率(當然沒錯的時候還是很快的)
  • 在嘗試爲Freeline適配最新的DataBinding時候遇到了巨大的阻力,實現的可能性很低了,只能做到局部兼容,因此需要多長全量編譯,開發效率低下
  • 爲Freeline適配kotlin增量成功,因此開始使用kotlin語言開發(kapt不敢用除外),準備大規模遷移至kotlin開發語言
  • 一些之前的邏輯存在着混亂的毛病,模塊間耦合關係有待進一步梳理

做什麼?

  • 使用自己的觀察者框架代替Google自帶的DataBinding實現數據流
  • 使用kotlin寫重構代碼,並且局部替換Java代碼
  • 去除一些不痛不癢的註解處理框架,在大幅度應用之前去除AROUTER,Butterknife

先思考 => 什麼架構

我應該用什麼架構 MVP MVVM ?

  • MVP 作爲android應用很火的架構,因爲充分的解耦被業界廣泛使用,蛋疼之處在於需要些大量的接口來規範每一層的行爲,來進行進一步的解耦。接口也可以被用於單元測試,目前的項目中還並沒有足夠的精力去寫單元測試,也不存在替換model或其他某層的需求,因此可以使用只抽象View接口版的MVP架構(如果你有MVP情節的話)
  • MVVM架構隨着DataBinding架構的提出而在android上被慢慢推廣,ViewModel作爲數據渲染層,承接着講model渲染到view上的重任,同時使用數據綁定的方式將其與view相關聯,MVVM的設計原則是ViewModel層不持有View的引用,加之DataBinding功能有限和某些部分及其蛋疼,可以做到高效開發但是某些時候及其蛋疼,當然我個人而言是非常喜歡MVVM架構以及數據綁定思維的。

那麼兩種架構都有自己蛋疼的地方,可不可以有一種新的架構設計方式呢

前些時間接觸了React設計思維,React框架將各個UI組件稱爲Component,每個Component內部維護了自己的view和一些邏輯,並且將狀態而非是view暴露在外,通過改變Component的狀態來實現這個組件UI和其他細節的改變,Component暴露在外的狀態是可以確定全局狀態的最小狀態,即狀態的最小集,Component的其他剩餘狀態都可以通過暴露狀態的改變所推倒出來,當然這個推倒應該是響應式的,自動的。

當然android上無法寫類似於JSX的東西,如果最類似的話,那就是Anko的DSL佈局框架了,這樣子就可以將view寫在Component裏面。

不過View寫在Xml裏面,然後在Component的初始化時候來find來那些view去操作也是ok的(因爲anko的DSL寫法依然是一種富有爭議的佈局方式,儘管我定製的Freeline已經可以做到kotlin修改的10s內增量編譯,DSL還是有很多坑)

說了這麼多,這個架構到底具體是什麼樣子呢?

  • 所有的view組件抽象成Component
  • 每個Component內維護自己的view,對外暴露可以推倒出全局狀態的最小狀態集,view均爲private,不可被外部訪問到,只可以修改Component的狀態而不可訪問component的view
  • Component內部維護自己view與狀態之間的關係,推薦使用響應式數據流的方式來進行綁定,某些數據發生變化的時候對應的view也發生自己的改變

可見,Component本身是高內聚的,對外暴露最小狀態,所以外部只需修改最小的狀態(集)就可以完成Component行爲/view的變化,因此外部調用極其方便而且也不存在邏輯之間的相互干擾

怎麼做?

  • Component怎麼分?
  • Component需要傳入什麼?
  • Component放在哪裏?
  • Component內部數據流怎麼寫?
  • Component對外暴露什麼?怎麼暴露?
  • Component內部狀態怎麼管理?

先看一個圖來解釋

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

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

他們的xml佈局因爲極其簡單就跳過不談,Component的設計部分我們可以從最小的item說起

因爲它沒有被放在Recyclerview裏面,所以它繼承ViewHolder與否都是隨意的,但是爲了統一性,就繼承RecyclerView.ViewHolder好了(事實上是否繼承它都是隨意的)

先來看這個Component對應的數據Model部分

public class Book {

    /**
     * barcode : TD002424561
     * title : 設計心理學.2,與複雜共處,= Living with complexity
     * author : (美) 唐納德·A·諾曼著
     * callno : TB47/N4(5) v.2
     * local : 北洋園工學閱覽區
     * 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生命週期用,避免組件的內存泄漏問題 Android Architecture Components

下來就是聲明可觀察的數據(也可以成爲狀態)

    private val bookData = MutableLiveData<Book>()

因爲此Component邏輯簡單,只需要觀測Book類即可推斷確定其狀態,因此它也是這個Component的最小狀態集合

插播一條補充知識:

LiveData<T>,MutableLiveData<T>也都來自於Android Architecture Components的組件,是生命週期感知的可觀測動態數據組件

Sample:

LiveData<BigDecimal> myPriceListener = ...;
        myPriceListener.observe(this, price -> {
            // Update the UI. 
        });

當然用kotlin給它寫了一個簡單的函數式拓展

/**
 * 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的可觀測數據/狀態綁定起來了

    init {
        bookData.bind(lifecycleOwner) {
            it?.apply {
                name.text = this.title
                setBookCoverDrawable(book = this)
                returntimeText.text = "應還日期: ${this.returnTime}"
            }
        }
    }

//這裏是剛剛調用的函數 寫了寫動態塗色的細節   
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>來實現Component狀態的改變,因此只需要修改Book就可以實現該Component的相關一系列改變

然後我們只需要把相關函數暴露出來

    fun render(): View = itemView

    fun bindBook(book: Book){
        bookData.value = book
    }

然後在需要的時候創建調用它就可以了

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顯示錯誤的的圖片

  1. 這個Item要放在Recyclerview裏面,所以要繼承ViewHolder

    class HomeLibItemComponent(private val lifecycleOwner: LifecycleOwner, itemView: View) : RecyclerView.ViewHolder(itemView) {
    }
    
    
  2. 聲明該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)
    
    
  3. 聲明Component裏面的可觀測數據流

    private val loadMoreBtnText = MutableLiveData<String>()
    private val loadingState = MutableLiveData<Int>()
    private val message = MutableLiveData<String>()
    private var isExpanded = false
    
    
  4. 聲明一些其他的用到的東西

    //對應barcode和book做查詢
    private val bookHashMap = HashMap<String, Book>()
    private val bookItemViewContainer = mutableListOf<View>() //緩存的LinearLayout裏面的view 摺疊提高效率用
    private val libApi = RetrofitProvider.getRetrofit().create(LibApi::class.java)
    
    
  5. 建立綁定關係

        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
                }
            }
        }
    
    
  6. 再寫一個OnBindViewHolder的回調(到時候手動調用就可以了,會考慮使用接口規範這部分內容)

    fun onBind() {
            refreshBtn.setOnClickListener {
                refresh(true)
            }
            refresh()
            renewBooksBtn.setOnClickListener {
                renewBooksClick()
            }
            loadMoreBooksBtn.setOnClickListener { view: View ->
                if (isExpanded) {
                    // LinearLayout remove的時候會數組順延 所以要從後往前遍歷
                    (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
                }
            }
        }
    
    
  7. 剩下的就是方法的具體實現了這個看個人喜歡的處理方式來處理,比如說我喜歡協程處理網絡請求,然後用LiveData處理多種請求的映射

    比如說一個簡單的網絡請求以及緩存的封裝

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

小總結:狀態綁定,數據觀測

在圖書館的這個Component的開發中,只需要在發起各種任務以及處理任務返回信息的時候,改變相關的狀態值和可觀測數據流即可,便可實現Component一系列狀態的改變,因爲所有邏輯不依賴外部,所有目前該Component不對外暴露任何狀態和view。實現了模塊內的數據流和高內聚。
模塊內數據流可以大幅度簡化代碼,避免某種程度上對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 = "網絡超時...很絕望"
                }
                else -> {
                    loadingState.value = WARNING
                    this.message.value = "粗線蜜汁錯誤"
                }
            }
        }
    }

在收到相關錯誤碼的時候,修改state和message的觀測值,相關的數據流會根據最初的綁定關係自動通知到相關的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)

                }
            }
        }

這個架構比較適合的場景就是,多個業務模塊作爲Card出現的時候。(或者說是Feed流裏面的item,或者是你喜歡使用Recyclerview作爲頁面組件的容器)等等... 對於單頁場景,其實一頁就可以認爲是一個Component,在頁面的內部管理可觀察數據流即可。
架構不是死的,思維也不是。大家還是要根據自己的業務場景適當發揮啊~

學習分享,共勉

題外話,我從事Android開發已經五年了,此前我指導過不少同行。但很少跟大家一起探討,正好最近我花了一個多月的時間整理出來一份包括不限於高級UI、性能優化、移動架構師、NDK、混合式開發(ReactNative+Weex)微信小程序、Flutter等全方面的Android進階實踐技術,今天暫且開放給有需要的人,若有關於此方面可以轉發+關注+點贊後領取,或者評論與我一起交流探討。

資料免費領取方式:轉發+關注+點贊後,加入點擊鏈接加入羣聊:Android高級開發交流羣(818520403)即可獲取免費領取方式!

重要的事說三遍,關注!關注!關注!

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