写一个MVVM快速开发框架(四)优雅的数据处理

1,105 阅读9分钟

前言

之前的文章介绍了网络封装、组件化、基础工具等,有兴趣的可以查看:

写一个MVVM快速开发框架(一)基础类封装

写一个MVVM快速开发框架(二)组件化改造

写一个MVVM快速开发框架(三)单Activity+多Fragment模式

还有一些关于UI的基础工具:

封装一个好看的吐司工具

一点也不炫酷的Navigation动画

不知道从什么说起,就记录一些关于数据的那些事吧,与数据打交道是我们工作中最常见的事情。

这里主要介绍了一些开发中与数据有关工具,大家可以自己动手实践,也可以查看mvvm_develop

  • 事件通信工具
  • key-value存储工具
  • 数据库
  • 网络缓存实现

事件通信总线

为什么要从事件通信说起?最开始的mvc模式开发,数据与UI混杂在一起,数据库和网络请求这些都乱糟糟的,自从EventBus出现之后,给Android开发带来不一样的思路,采用观察者/订阅者模式来处理数据通信。

历史中的事件通信框架:

EventBus

Android事件发布/订阅框架,通过解耦发布者和订阅者简化Android事件传递,这里的事件可以理解为消息。事件传递既可以用于Android四大组件间通讯,也可以用于异步线程和主线程间通讯等。

RxBus

收益与RxJava强大的异步处理能力,我们可以轻易生成一个观察者模式的事件通信框架。也是我之前项目中用到最多的。

LiveData

LiveData不是为了事件通信而生的,但是其天生的属性(可观察属性和生命周期监听属性)我们很难不利用,同样其配合viewModel+Lifecycle似乎是我们mvvm模式下的不二之选。

至于三者之间的优缺点,参考:tech.meituan.com/2018/07/26/…

使用LiveData封装一个事件通信工具

LiveData天生具有生命周期监听和可观察属性,只需要10行核心代码就可以完成:

object LiveDataBus{

    private val bus: MutableMap<String, MutableLiveData<Any>> = HashMap()

    fun <T> getChannel(target: String, type: Class<T>): MutableLiveData<T> {
        if (!bus.containsKey(target)) {
            bus[target] = MutableLiveData()
        }
        return bus[target] as MutableLiveData<T>
    }

}

发布消息:

LiveDataBus.getChannel<String>("test").postValue("hahahah")

观察消息:

LiveDataBus.getChannel<String>("test").observe(viewLifecycleOwner,Observer {
    xLog.d(it)
})

我们只是创建了一个Map管理LiveData,至此一个跨组件的事件通信组件就完事了,是不是很简单!🤣

但是其还有一些缺点,比如先发布消息,后创建的订阅者依旧能收到消息,以及重复收到消息等,是不是头疼😎

我们可以自定义LiveData实现黏性事件和重复性事件的管理。 这里可以参考大佬们的代码,具体代码也就一个类: github.com/fmtjava/Liv…

优雅的数据处理

我们看一下Google推荐的应用架构指南

final-architecture.png

整个架构无非就是围绕数据View去构建,想要优雅的处理数据,我们需要关注几个点:

  • 小数据,key-value存储
  • 大数据,数据库存储
  • 离线缓存与网络请求数据协同
  • 数据依赖注入
  • 数据与View的绑定

以及其他:

  • 文件管理

我们接下来一步一步探索吧

Key-Value存储

我们一般用这种方式存储一些临时数据和配置文件,以键值对为存储方式。

Google提供的有SharedPreferences,sp是我们常用的了,sp缺点很多:性能差、线程不安全,所以有了MMKV的替代方案。

目前我经常使用的就是mmkv的方案,这个网上也有很多介绍的了,官方文档对其原理和使用介绍的非常清除,我们只需要封装一下就可以愉快的使用了。

import android.app.Application
import android.os.Parcelable
import com.tencent.mmkv.MMKV
import java.util.*

/**
 * @ClassName mmkvUtils
 * @Description mmkv存储工具类,参考:https://github.com/Tencent/MMKV/wiki/android_tutorial_cn
 * @Author AlexLu_1406496344@qq.com
 * @Date 2020/12/14 16:06
 */
enum class MMKV_TYPE{
    USER,
    APP
}

class MMKVUtil {

    companion object{
        
        @JvmField
        val instance = MMKVUtil()

        //对于app和用户可以设置不同的mmkv
        @Volatile
        var mmkv: MMKV ?= null

        //用户相关
        private val userMMKV by lazy {
            MMKV.mmkvWithID("user",MMKV.MULTI_PROCESS_MODE)
        }

        //app配置相关
        private val appMMKV by lazy {
            MMKV.mmkvWithID("app",MMKV.MULTI_PROCESS_MODE)
        }

        //初始化
        fun init(app:Application){
            MMKV.initialize(app)
            mmkv = appMMKV
        }

        @Synchronized
        fun get(type: MMKV_TYPE):MMKVUtil = instance.apply{
            mmkv = when(type){
                MMKV_TYPE.USER -> {
                    userMMKV
                }
                MMKV_TYPE.APP -> {
                    appMMKV
                }
            }
        }
    }
    
    /*-------------Encode----------*/

    fun encode(key: String, value: Any?) {
        when (value) {
            is String -> mmkv?.encode(key, value)
            is Float -> mmkv?.encode(key, value)
            is Boolean -> mmkv?.encode(key, value)
            is Int -> mmkv?.encode(key, value)
            is Long -> mmkv?.encode(key, value)
            is Double -> mmkv?.encode(key, value)
            is ByteArray -> mmkv?.encode(key, value)
            is Nothing -> return
        }
    }

    fun <T : Parcelable> encode(key: String, t: T?) {
        if(t == null){
            return
        }
        mmkv?.encode(key, t)
    }

    fun encode(key: String, sets: Set<String>?) {
        if(sets ==null){
            return
        }
        mmkv?.encode(key, sets)
    }

    /*------------Decode-----------*/

    fun decodeInt(key: String): Int? {
        return mmkv?.decodeInt(key)
    }

    fun decodeString(key: String): String?{
        return mmkv?.decodeString(key)
    }

    fun decodeDouble(key: String): Double? {
        return mmkv?.decodeDouble(key)
    }

    fun decodeLong(key: String): Long? {
        return mmkv?.decodeLong(key)
    }

    fun decodeBoolean(key: String): Boolean? {
        return mmkv?.decodeBool(key)
    }

    fun decodeFloat(key: String): Float? {
        return mmkv?.decodeFloat(key)
    }

    fun decodeByteArray(key: String): ByteArray? {
        return mmkv?.decodeBytes(key)
    }

    fun <T : Parcelable> decodeParcelable(key: String, tClass: Class<T>): T? {
        return mmkv?.decodeParcelable(key, tClass)
    }

    fun decodeStringSet(key: String): Set<String>? {
        return mmkv?.decodeStringSet(key, Collections.emptySet())
    }

    /*------------Delete-----------*/

    //删除key
    fun removeKey(key: String) {
        mmkv?.removeValueForKey(key)
    }

    //删除所有
    fun clearAll() {
        mmkv?.clearAll()
        mmkv = null
    }

}

需要注意的点:对于app参数和用户参数应该使用不同的mmkv文件,这里通过类型MMKV_TYPE设置,你也可以自行修改

使用步骤:

  1. application中初始化:
MMKVUtil.init(this)
  1. 写入
写入app mmkv文件
MMKVUtil.get(MMKV_TYPE.APP).encode(key,"this is mmkv_app params")

写入user mmkv文件
MMKVUtil.get(MMKV_TYPE.USER).encode(key,"this is mmkv_user params")
  1. 读取:
MMKVUtil.get(MMKV_TYPE.APP).decodeString(key)
  1. 删除:
全部删除:
MMKVUtil.get(MMKV_TYPE.USER).clearAll()

删除一个key:
MMKVUtil.get(MMKV_TYPE.APP).removeKey(key)

数据库:

很早之前的SQLite异常难用,之后Google推出了Room,Room是jetpcak组件最早的其中之一,其本身是对SQLite的一些封装,能够使我们更流畅的增删改查

快速了解Room

Room架构图: image

其使用方法也很简单,主要是三步:

  • 给Bean类添加@Entity注解,生成表文件
  • 通过@Dao注解设置数据获取接口
  • 通过@DataBase注解生成数据库类

对于单一module,我们只需创建一个数据库即可,通过单例创建避免消耗过多资源:

@Database(entities = Test::class,version = 1)
abstract class Database : RoomDatabase(){

    companion object {

        const val dbName = "Home.db"
        @Volatile
        private var INSTANCE: Database? = null

        fun getInstance(): Database = INSTANCE ?: synchronized(this) {
            buildDatabase().also { INSTANCE = it }
        }

        private fun buildDatabase() =
            Room.databaseBuilder(
                BaseApp.getContext(),
                Database::class.java, dbName)
                .addMigrations(MIGRATION_1_2)
                .build()
    }
}

关于具体的使用方法请参考:

Room官方文档:
developer.android.com/jetpack/and…

Room Demo:
github.com/android/arc…

Android Jetpack ROOM数据库用法介绍:
juejin.cn/post/684490…

踩坑指南:

Cannot figure out how to save this field into database. You can consider adding a type converter for it.

room不能一张表中的字段不能直接保存数据,可以通过gson转String再存储,或者创建多个表格通过关键字连接 参考: juejin.cn/post/684490…

Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number. You can simply fix this by increasing the version number.

数据库升级问题,参考:www.jianshu.com/p/fae0245cf…

数据库调试:

高版本的AndroidStudio可以直接通过App Inspection查看,如下:

image.png

或者直接通过File Explorer文件管理器导出.db数据库文件,其路径为:/data/data/com.example.package/databases

QQ截图20210825101656.png

网络请求缓存

我查看了下手机上目前大部分APP都没有很好的缓存策略,对于有些APP确实没必要缓存,但是对于掘金APP首页全是文章博客,浏览体验要求比较高的app竟然没有很好的缓存策略,断网的情况下基本就是显示网络错误沸点的缓存是还是很久很久以前的数据。

目前网上关于网络缓存的介绍都是在HttpClient中添加自定义Interceptor,如下:

  1. 设置一个缓存文件:
//添加Cache拦截器,有网时添加到缓存中,无网时取出缓存
var file: File = File(FileUtil.getAppCachePath())
var cache = Cache(file, 1024 * 1024 * 100)

private val okHttpClientBuilder: OkHttpClient.Builder by lazy {
    OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS)
        .writeTimeout(10, TimeUnit.SECONDS)
        .readTimeout(10, TimeUnit.SECONDS)
        .retryOnConnectionFailure(true)
        .cache(cache)
        .addInterceptor(getLogInterceptor())
        .addInterceptor(getCacheInterceptor())
}
  1. 自定义CacheInterceptor
/**
 * 设置缓存
 */
private fun getCacheInterceptor():Interceptor = Interceptor { chain ->
    var request = chain.request()
    //当没有网络时
    if (!isNetworkConnected()) {
        request = request.newBuilder() //CacheControl.FORCE_CACHE; //仅仅使用缓存
            //CacheControl.FORCE_NETWORK;// 仅仅使用网络
            .cacheControl(CacheControl.FORCE_CACHE)
            .build()
    }

    val proceed = chain.proceed(request)
    if (isNetworkConnected()) {
        //有网络时
        proceed.newBuilder() //清除头信息
            .header("Cache-Control", "public, max-age=" + 60)
            .removeHeader("Progma")
            .build()
    } else {
        //没网络时
        val maxTime = 4 * 24 * 60 * 60 //离线缓存时间:4周
        proceed.newBuilder()
            .header("Cache-Control", "public, only-if-cached, max-stale=$maxTime")
            .removeHeader("Progma")
            .build()
    }
}

/**
 * 网络状态判断
 */
private fun isNetworkConnected(): Boolean {
    val mConnectivityManager = BaseApp.getContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    val mNetworkInfo = mConnectivityManager.activeNetworkInfo
    if (mNetworkInfo != null) {
        return mNetworkInfo.isAvailable
    }
    return false
}

这种方法只能缓存GET请求,因为GET一般用来获取数据,其他涉及加密和操作的没有必要缓存

我的想法是通过数据库缓存,网络请求的时候自动缓存返回成功的数据,再次请求失败的时候返回上一次请求成功的数据,简单的实现就是手动判断接口请求状态来返回数据:

private val homeDao = HomeDatabase.getInstance().homeDao()

/**
 * ROOM数据库与网络请求结合使用
 */
fun getHomeArticle(page:Int,resultLiveData: ResultLiveData<Article>){
    launch(
        block = {
            mService.getHomeArticle(page)
        },
        response = {
            if (it.state == NetState.STATE_SUCCESS){
                //数据存入数据库
                it.data?.let { article ->
                    homeDao.apply {
                        //删除上一次数据
                        deleteAll()
                        //插入新的数据
                        insert(article)
                    }
                }
            }
            if (it.state == NetState.STATE_ERROR){
                //从数据库读取上一次数据
                it.data = homeDao.getAllData()[0]
            }
            resultLiveData.postValue(it)
        }
    )
}

这里关于网络请求的封装请查看第一篇文章:写一个MVVM快速开发框架(一)基础类封装

但是这样子每一个请求都需要手动设置,而且会涉及不同的数据结构,确实很麻烦。

思路一:将返回数据转为json统一保存

一般返回数据都有一层包装,如下:


class ApiResponse<T> : Serializable {

    var code : Int = 0

    var data : T ?= null

    var message : Any ?= null

    var state : NetState = NetState.STATE_UNSTART

    var error : String = ""

}

val json = Gson().toJson(response)

val response = Gson().fromJson(json,ApiResponse<Article>().javaClass)

因为这里用到了泛型,如果直接用Gson解析会得到如下报错:

java.lang.ClassCastException: com.google.gson.internal.LinkedTreeMap cannot be cast to com.xlu.module_tab1.bean.Article

正确姿势如下:

val type: Type = object : TypeToken<ApiResponse<Article>>() {}.type
val response: ApiResponse<Article> = Gson().fromJson(json, type)

我们在Base模块中创建一个基础数据库,创建NetCacheDao用来专门存储网络缓存:

@Entity(tableName = "NetCache")
data class NetCache(

    @PrimaryKey
    val md:String,

    val response:String
)

md代表网络请求地址的md5值,response代表网络返回数据转json的数据

所以最后代码如下:

fun test(){
        launch(
            block = {
                mService.getHomeArticle(1)
            },
            response = {
                val md = "test"

                when(it.state){
                    NetState.STATE_SUCCESS -> {
                        val json = Gson().toJson(it.data())
                        val netCache = NetCache(md,json)
                        cacheDao.insert(netCache)
                    }
                    NetState.STATE_FAILED,NetState.STATE_ERROR -> {
                        val netCache = cacheDao.query(md)

                        val type: Type = object : TypeToken<Article>() {}.type
                        it.data = Gson().fromJson(netCache.response, type)
                    }
                }

            }
        )
}

虽说完成了网络数据缓存的任务,但总感觉不太完美,如果大家有好的思路可以提出来。


最后附上mvvm_develop项目地址,文章代码略有缺失,完整请查看demo,项目整体还在完善中,欢迎大佬们指点。卑微Androider在线求个Star😅

欢迎大家点赞关注和提出问题,个人博客:BugMaker