最新消息:欢迎访问Android开发中文站!商务联系微信:loading_in

2020年关于Android开发架构,我们还能做些什么?

热点资讯 loading 66浏览 0评论
Android开发架构已经由由最最初的Activity架构(MVC),发展到到现在主流的MVP、MVVM架构了。社区也有不少优秀的实践。今天笔者想结合自己的经验谈一谈,一个合理的Android架构应该是怎么样的呢?

MVC、MVP、MVVM三种分层架构

MVC,上帝模型

相信一些经验丰富的开发者,都经历过面向Activity(Fragment)编程的时代,也就是所谓的MVC架构时代。那个时代,在开发的时候,会把我们的业务模块氛围三层。如下图:

MVC

如果我们能严格按照上图来开发我们的架构的话,那么这会是一种比较好的实现。但是这种架构在Android中的实践存在一个非常严重的问题。在划分View层的时候,我们是要把layout作为View层,还是把Activity/Fragment 加layout文件一起作为View层呢?如果是后者,那么我们应该还要引入一个新的Control层。在比较初期(15、14年前)的Android开发中,一般情况下都没有考虑这么多,直接把layout文件作为View层,Activity/Fragment作为所谓的Control层了。

把xml文件作为View层会带来什么问题呢?xml文件的控制力是非常弱的,它只能在开发更改UI,一旦发布了,xml的UI就是固定的了。如果你要在App运行期间修改UI(文本更新)的话,那么我们就需要借助Java代码了。一般我们会在Activity/Fragment中通过代码更新UI。

那么这就带来一个新的问题,Activity/Fragment作为一个C层,同时承载了C层和V层的业务。所以MVC在Android中的实现一般会变成VC模型。而这种模型,在业务复杂的场景中,造就了大量的上帝,也就所谓的God Class。一般来说,我们的上千行的Activity都是这种模型的结果。

MVP,万物皆回调

在经历了维护God Class的痛苦时代,所以我们又选择了新的MVP架构,其实MVP架构和MVC架构也是很像的。在Android中的MVP就是把layout文件和Activity/Fragment作为了View层,这里面只包含UI相关的代码,不包含业务逻辑的。而Presenter就是作为逻辑层,这个其实和传统的MVC架构有点像,但是和MVC又有一点不同。MVP架构中,V层是不能直接查询M层的数据的(MVC中是允许的)。另外一个就是,不同层级之间数据交互的方式是基于Callback的异步回调模式的,所以MVP的具体实现中,会包含大量的Interface。

MVP的架构图如下:

MVP

图片也印证了,MVP和MVC在架构上来说其实是非常像的。最大的不同点就是在MVP中,V层和M层是不能直接交互的。MVP虽然能把我们的业务分割成三个相互独立的部分,但是MVP的缺点也非常明显。模版代码非常多、基于接口的通讯方式会导致我们定义大量一次性接口。并且,P层和V层的生命周期是不一致的,会存在P层还存活,V层被回收的情况。这样会导致UI更新失败,很容易Crash。

为了分割逻辑,增加这么多代码与一次性接口是否值得,这是一个需要充分考虑的问题。

MVVM,数据驱动模型

因为MVP样板代码太多的原因,所以我们又开始尝试MVVM架构了。Google也推出了不少辅助工具帮助我们在Android中实现MVVM架构,如:DataBinding,LiveData,ViewModel系列等。MVVM架构的核心就是数据驱动,数据驱动的意思就是,数据更新的时候,自动刷新UI。

打个比喻:

我们把View比作一个罪犯,更新View的行为比作是把罪犯送进监狱的话。那么在MVC、MVP中,我们C,和P就是警察,他要负责把罪犯送进监狱。而在MVVM中,罪犯犯罪(数据变化)后,它自己会走进监狱里面。

采用MVVM架构会帮我们节省大量的更新UI的代码,并且数据更新后主动出发UI更新这种方式,更难出错,鲁棒性更强。我们不需要关注数据变化的时机,是需要关注数据变化的结果即可。MVVM架构图如下:

MVVM

MVVM架构,因为有官方Jetpack的加持,所以逐渐成为了架构中的主流,如果是新的项目开发的话,可以考虑采用MVVM架构。而针对老业务的新模块,也可以尝试过度到MVVM架构中。

不止于分层

通过分析,上面三种架构都是为了解决一个问题的,就是把UI、逻辑、数据层分开,实现解耦。这三种架构其实没有优劣之分,实现的方式才有优劣之分。MVVM架构因为有官方组件加持,所以实现中一般推荐使用MVVM架构。那么架构单单只限于分层吗?

不是的,因为实际开发中,我们面对的业务场景是非常复杂的,除了把业务分层之外,我们还需要关注:缓存、限流、分页、领域设计(本文不会过于关注)等。下面先简单介绍下缓存和限流这两个场景。

缓存

我们先来关注一个Android中经常遇到的一个问题,缓存问题。在Android开发中,大部分场景中,我们并不会缓存数据,就算要缓存数据,大部分场景我们都会使用SharedPreferences来实现。无论是不缓存数据,还是大量使用SharedPreferences缓存数据,其实都会带来一些问题。

大部分情况下,我们从服务器中请求的数据在某个时间段内都是一样的。例如,我们请求个人信息接口,现在请求和一分钟后请求,很可能数据都是一样的。所以如果我们能把第一次请求的数据缓存下来,那么第二次数据我们就没必要重复请求了。这个就是缓存的作用,当然缓存还存在超时时间,这个超时时间的需要根据具体业务来设置。而且缓存还能让我们在网络异常的时候,让用户看到他上一次看到的数据。所以一个设计良好的APP,它肯定会考虑到缓存的设计的。
而SharedPreferences的问题就是它的效率问题,因为它是基于文件的实现数据持久化的,并且读取/写入数据的时候都是全量读写的,所以大量使用SharedPreferences实现缓存也是不合适的。

限流

限流的主要作用是,限制客户端在短时间内发起大量重复请求,导致后台的流量洪峰问题。一般来说,我们可以通过限制重复点击的时间间隔来实现限流。但是这种方案一是会侵入我们的UI实现,二是有部分场景的请求不是由UI发起的。

我们在解决上述问题的时候,可以说是一千个人眼中有一千个哈姆雷特了,每个人的可能都实现都不一样。而且有些实现可以说是比较糟糕的。那么我们能不能制定个比较统一的机制来解决上述问题呢?答案是可以的,笔者在阅读了Google的某个开源项目后,发现其实官方很早就提供了一个非常优秀的实现机制。下面我们就来分享下这一套方法论,这个机制是基于MVVM架构的。

在Android上实现一个比较合理的MVVM架构

RepoRepository

通过上文分析我们知道,Repository就是我们的数据仓库,即数据提供者。我们可以把缓存和限流的逻辑放到这里来。那么我们有没有必要对大部分的数据都缓存起来呢?我认为大部分请求过的数据我们都可以缓存起来,因为这样能让我们的APP体验更好。已经请求过的数据,没有更新的情况下我们没必要再请求一次。并且我们能在离线或请求大量数据的时候能让客户先看到上一次成功的数据。

客户端其实是没办法知道数据有没有更新的,但是我们可以通过限流这一功能设置缓存的有效期。这里限流的作用非常类似我们Http协议中的cache-control字段。还在有效期内的数据,直接使用缓存即可。这个限流的时间阀值我们可以根据具体业务来设置。

我们并不是要把全部请求过的数据都缓存起来,要避免缓存大量重复的数据,要定义合理的数据库表来实现数据的管理。我们可以通过后台返回的id来作为我们本地数据的id,从而实现数据的更新。

我们先来看看一个满足上述功能的Repository的流程图是怎样的:

Repository

可以看到,一个满足我们要求的Repository还是非常复杂的,如果要在每一个Repository都实现这么一套逻辑其实是不现实。不过这种通用的逻辑我们可以封装起来,我们先来看看封装好后的Rpository是怎么样的。

class RepoRepository constructor(
        private val db: GithubDb,
        private val githubService: GithubService) {

    val repoRateLimiter = RateLimiter<String>(15, TimeUnit.SECONDS)

    fun search(query: String): LiveData<Resource<List<Repo>>> {
        return object : NetworkBoundResource<List<Repo>, RepoSearchResponse>() {

            override fun saveCallResult(item: RepoSearchResponse) {
                val repoIds = item.items.map { it.id }
                val repoSearchResult = RepoSearchResult(
                        query = query,
                        repoIds = repoIds,
                        totalCount = item.total,
                        next = item.nextPage
                )
                db.runInTransaction {
                    db.repoDao().insertRepos(item.items)
                    db.repoDao().insert(repoSearchResult)
                }
            }

            override fun shouldFetch(data: List<Repo>?) =
                    data == null || repoRateLimiter.shouldFetch(query)

            override fun loadFromDb(): LiveData<List<Repo>> {
                return Transformations.switchMap(db.repoDao().search(query)) { searchData ->
                    if (searchData == null) {
                        AbsentLiveData.create()
                    } else {
                        db.repoDao().loadOrdered(searchData.repoIds)
                    }
                }
            }

            override fun createCall() = githubService.searchRepos(query)

        }.asLiveData()
    }
}

这里用到了Room框架来管理数据库,Retrofit2来调用Github的接口,这里不对这两个框架作过多说明,有兴趣的朋友直接阅读源码MVVMRecurve,demo源码在sample目录下。

我们把具体的调度逻辑都放到NetworkBoundResource这个类里面了,这样所有的业务都能服用这一套逻辑。细心的读者可能会发现,其实Repository只承担了很少一部分工作的,网络请求是通过Retrofit2实现的,而数据缓存是通过Room实现的,Repository只做了资源调度的工作。这是一种比较优秀的设计,实现了比较彻底的解耦,而且天然支持单元测试。

在Repository中是通过RateLimiter限流类实现的,它的创建很简单,就是接收一个时间单位。它的作用是针对同一查询参数,它的请求时间间隔是多少,在上面的例子中是15秒。

NetworkBoundResource

NetworkBoundResource这个类其实就是封装了Repository流程图里面的数据调度内容,我们在实现新的业务的时候,不需要重复实现调度逻辑,只需要关注业务本身即可。附上源码:

abstract class NetworkBoundResource<ResultType, RequestType>
@MainThread constructor() {

    protected val result = MediatorLiveData<Resource<ResultType>>()

    init {
        result.value = Resource.loading(null)
        @Suppress("LeakingThis")
        val dbSource = loadFromDb()

        result.addSource(dbSource) { data ->
            result.removeSource(dbSource)
            if (shouldFetch(data)) {
                fetchFromNetwork(dbSource)
            } else {
                result.addSource(dbSource) { newData ->
                    setValue(Resource.success(newData))
                }
            }
        }
    }

    @MainThread
    protected fun setValue(newValue: Resource<ResultType>) {
        if (result.value != newValue) {
            result.value = newValue
        }
    }

    private fun fetchFromNetwork(dbSource: LiveData<ResultType>) = runBlocking{
        val apiResponse = createCall()
        // we re-attach dbSource as a new source, it will dispatch its latest value quickly
        result.addSource(dbSource) { newData ->
            setValue(Resource.loading(newData))
        }
        result.addSource(apiResponse) { response ->
            result.removeSource(apiResponse)
            result.removeSource(dbSource)
            when (response) {
                is ApiSuccessResponse -> {
                    val ioResult = com.recurve.coroutines.io { saveCallResult(processResponse(response)) }
                    ioResult{
                        result.addSource(loadFromDb()){
                            setValue(Resource.success(it))
                        }
                    }

                }
                is ApiEmptyResponse -> {
                    result.addSource(loadFromDb()) { newData
                        -> setValue(Resource.success(newData))
                    }
                }
                is ApiErrorResponse -> {
                    onFetchFailed()
                    result.addSource(dbSource) { newData ->
                        setValue(Resource.error(response.errorMessage, newData))
                    }
                }
            }
        }
    }

    protected open fun onFetchFailed() {}

    fun asLiveData() = result

    @WorkerThread
    protected open fun processResponse(response: ApiSuccessResponse<RequestType>) = response.body

    @WorkerThread
    protected abstract fun saveCallResult(item: RequestType)

    @MainThread
    protected abstract fun shouldFetch(data: ResultType?): Boolean

    @MainThread
    protected abstract fun loadFromDb(): LiveData<ResultType>

    @MainThread
    protected abstract fun createCall(): LiveData<ApiResponse<RequestType>>
}

SearchRepoViewModel

class SearchRepoViewModel : ViewModel(){

    var repoRepository: RepoRepository? = null

    private val _query = MutableLiveData<String>()
    val query : LiveData<String> = _query

    val results = Transformations
            .switchMap<String, Resource<List<Repo>>>(_query) { search ->
                if (search.isNullOrBlank()) {
                    AbsentLiveData.create()
                } else {
                    repoRepository?.search(search)
                }
            }

    fun setQuery(originalInput: String) {
        val input = originalInput.toLowerCase(Locale.getDefault()).trim()
        if (input == _query.value) {
            return
        }
        _query.value = input
    }
}

可以看到,ViewModel的逻辑非常简单,它包含一个方法,setQuery方法接受查询参数,而results则是Repository返回的LiveData对象(关于LiveData的使用这里不作介绍)。如果我们需要处理回调数据的话,直接使用LiveData的变换功能就行了。

View

View层我们就不多做介绍了,你可以通过观察LiveData的数据变化来手动更新数据,也可以通过DataBinding来实现自动更新数据。根据自己的习惯来实现就行了。下面我们来看下例子中的关键代码:

 private lateinit var binding: FragmentSearchRepoBinding
    private val searchViewModel by lazy { ViewModelProviders.of(this).get(SearchRepoViewModel::class.java)}
    private lateinit var creator: SearchRepoCreator

    override fun onCreateBinding(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): ViewDataBinding {
        binding = DataBindingUtil.inflate(
                inflater, R.layout.fragment_search_repo, container, false)

        initViewRecyclerView(binding.repoList)
        creator = SearchRepoCreator()
        addItemCreator(creator)
        return binding
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.lifecycleOwner = this
        binding.query = searchViewModel.query
        initSearchInputListener()
        initRepository()

        binding.searchResult = searchViewModel.results
        searchViewModel.results.observe(viewLifecycleOwner, Observer { result ->
            result?.data?.let {
                creator.setDataList(it)
            }
        })

    }
}

快速开始

其实说了这么多,上面演示的只是一个架构中的一个简单的功能,如果要在自己的项目中重新实现一遍是不现实的。所以笔者其实已经把这个框架封装了,框架的名字叫:MVVMRecurve。一切基础架构相关的都封装好了,你要做的只是依赖下项目即可。

buildscript {
  ext.recurve_version = '1.0.1'
}

//modules build.gradle
implementation "com.recurve:recurve.core:$recurve_version"
implementation "com.recurve:recurve-retrofit2-support:$recurve_version"
implementation "com.recurve:recurve-module-adapter:$recurve_version"
implementation "com.recurve:coroutines-ktx:$recurve_version"

//如果你想支持更多功能的话,可以依赖下面的库
implementation "com.recurve:recurve-apollo-support:$recurve_version"
implementation "com.recurve:recurve-dagger2-support:$recurve_version"
implementation "com.recurve:recurve-module-paging-support:$recurve_version"
implementation "com.recurve:recurve-glide-support:$recurve_version"
implementation "com.recurve:viewpager2-navigation-ktx:$recurve_version"
implementation "com.recurve:bottom-navigation-ktx:$recurve_version"
implementation "com.recurve:viewpager2-tablayout-ktx:$recurve_version"
implementation "com.recurve:navigation-dialog:$recurve_version"

小结

MVVMRecurve致力于打造一个还算可用的Android开发框架,并且会积极跟进官方的新技术。如果你想了解一个开发架构要如何设计的话,可以多多阅读源码,大家一起交流。笔者也在尝试使用这个框架开发一个高可用的项目,该项目是:GitHubRecurve。

对于MVVMRecurve,这篇文章直接少了其中一小部分,后面我会持续推出其它的一些技术模块和设计思想的,欢迎大家关注。如果大家觉得本文还不错的话,可以点个star。

作者:Tangpj
链接:https://juejin.im/post/5e3f92d46fb9a07ca80a9b64

转载请注明:Android开发中文站 » 2020年关于Android开发架构,我们还能做些什么?

您必须 登录 才能发表评论!