React设计思维的启发 - Android View Component 架构

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内部状态怎么管理?

先看一个图来解释

Screen Shot 2017-10-30 at 11.32.46 AM.png

图示部分的页面,是使用Recyclerview作为页面容器,里面的每个Item,就可以作为一个Component来对待

Screen Shot 2017-10-30 at 11.34.29 AM.png

进一步的,此Component里面的那几个图书详情item,又可以作为子Component来对待

Screen Shot 2017-10-30 at 11.36.31 AM.png

他们的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)

来点复杂的?

来看主页的图书馆模块


Screen Shot 2017-10-30 at 11.34.29 AM.png

图书馆模块本身也是一个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,在页面的内部管理可观察数据流即可。
架构不是死的,思维也不是。大家还是要根据自己的业务场景适当发挥啊~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,723评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,003评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,512评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,825评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,874评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,841评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,812评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,582评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,033评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,309评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,450评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,158评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,789评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,409评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,609评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,440评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,357评论 2 352

推荐阅读更多精彩内容