Android & Kotlin:MVVM + Retrofit + Flow(Coroutine协程) + Moshi + Hilt框架项目

1.简介

本项目是一个Android Kotlin框架项目,目的是为Android原生开发者提供一个快速开发的框架。主要功能是网络数据请求以及文件断点下载。
项目链接:https://gitee.com/hepta/PersonPicture

2.网络请求Retrofit + Flow

2.1 操作手册, 超级简单

在viewmodel中发送请求;person是一个MutableLiveData对象

  fun getImage() {
     request(repository.getImages(), person)
  }

在activity或者fragment中接收数据

  addObserve(viewModel.person) {
     adapter.addData(it.data)
  }

2.2 整体设计

请求结构

2.2.1 converter

这个是ConverterFactory,配合Retrofit和Moshi,Moshi是一款空安全的解析库。json中缺失bean中的变量,或者将null赋值给非空变量,将解析失败。
目的:

  • 为了下载和请求使用一套retrofit
  • 为了在创建Service接口时,不用附带NetResult,直接获取data: T

从方块公司官方库中copy并进行了修改,因为官方的库类都是final类😂
主要修改部分如下:

  • 修改MoshiConverterFactory中responseBodyConverter方法内创建jsonAdapter的方法,对下载和请求进行区分。主要是通过Types.newParameterizedType(Result::class.java, type),将NetResult套在外层。
  • 修改了MoshiResponseBodyConverter中convert方法,拦截服务端code

数据外壳:

data class NetResult<T>(
    val status: Int = 0,
    val msg: String = "",
    val data: T?, // +? 防止空安全序列化失败
    val count: Int = 0
)

MoshiConverterFactory类

val resultAdapter: JsonAdapter<NetResult<*>>? = when (type.rawType) {
            // 下载文件
            ResponseBody::class -> null
            else -> {
                if (BuildConfig.RESULT_FORMAT) {
                    // 服务端Result格式数据
                    val newType = Types.newParameterizedType(NetResult::class.java, type)
                    moshi.adapter(newType, jsonAnnotations(annotations))
                } else {
                    null
                }
            }
        }

return MoshiResponseBodyConverter(adapter, resultAdapter)

MoshiResponseBodyConverter类

    resultAdapter?.run {
        // 不为空
        val rawResult = fromJson(reader)
        rawResult?.run {
            //todo 处理服务端自定义异常并抛出
            when (status) {
                // e.g
                101 -> {
                    // 101 异常
                    throw ServerException(this)
                }
                else -> {
                    data?.run {
                        result = this as T
                    }
                }
            }
        }
    }
    // 为空直接解析
    resultAdapter ?: run {
        result = adapter.fromJson(reader)
    }

2.2.2 Launch和ResponseSource

  • Launch中封装了request请求
fun <T> CoroutineScope.request(
    flow: Flow<T>,
    liveData: MutableLiveData<Resource<T>>,
    witch: Int = 0
) 

此处需要开发者根据业务处理逻辑

 is ServerException -> {
      // todo 处理服务端自定义code
     val se = it as ServerException
     errors.getError(se.code(), se.message())
 }
  • 如果您没有使用本地数据可以简化此目录,去掉local和remote。代码都是人编的,怎么舒服怎么来。


    image.png

Repository中的的flow

    flow {
        emit(remoteData.getImages())
    }.flowOn(ioDispatcher)
  • ResponseSource主要处理返回结果,需要在接收数据的activity或者fragment中实现
    可以使用witch区分请求,确保那个请求需要显示loading,重要!!!witch需要在Launch中传给request
    fun start(witch: Int) {
        // 您可以在此处显示loading
    }

    fun success(witch: Int, result: Any) {

    }

    fun error(witch: Int, error: Pair<Int, String>) {

    }

    fun complete(witch: Int) {

    }
  • 向activity中添加一个监听
    您也可以在addObserve中加入start,error, complete等函数
/**
 * BaseActivity扩展
 * 添加数据监听
 */
fun <T, VB : ViewBinding> BasicActivity<VB>.addObserve(
    liveData: MutableLiveData<Resource<T>>,
    success: ((T) -> Unit)? = null
) {
    liveData.observe(this) {
        handleResult(it, success)
    }
}
  • 如果您在addObserve中加入了更多的函数,handle方法中需要模仿success编写,避免多次调用
  // 成功
  // 执行全局的回调
 success?.invoke(data)
  // 执行方法内回调
 success ?: success(resource.which, data)

3.断点下载

此功能在download目录下,适配了Android Q(10)。由于本人没有10的手机,如果有人测出10有问题可以联系本人,或者自己处理。

3.1 操作手册

用法基本和请求类似
支持文件名只传一个后缀,必须加“.”
在viewmodel中

fun download() {
     download("https://img2.baidu.com/it/u=2102736929,2417598652&fm=26&fmt=auto&gp=0.jpg", img)

fun downloadImg() {
      download("http://gank.io/images/7fa98787d009465a9d196fbff6b0a5d7", img, ".jpg")
   }
}

在activity中接收结果

addObserve(viewModel.img, {
            XLog.e(it)
        }) {
            XLog.e(it)
}

注意如果您用GlobalScope去加载一个下载请求,如果不想下载了,建议调用cancel取消请求

fun cancel() {
        Singleton.get<NetSource>().getTaskManager().cancel("https://img2.baidu.com/it/u=2102736929,2417598652&fm=26&fmt=auto&gp=0.jpg")
    }

3.2 代码简介

思路:首先将文件下载到临时文件中,下载完成后改名,如果已经存在改名后的文件,自动生成一串文件名。
下载方法

fun CoroutineScope.download(
    url: String,
    liveData: MutableLiveData<Resource<Uri>>? = null,
    saveName: String = "",
    savePath: String = ""
)

viewmodel扩展

fun ViewModel.download(
    url: String,
    liveData: MutableLiveData<Resource<Uri>>? = null,
    saveName: String = "",
    savePath: String = ""
)

断点实现,需要告诉服务端下载起始位置

val data = service.download(url, mapOf("Range" to "bytes=$completedSize-"))

获取断点位置

// Q以下可以做直接读取文件长度
private fun fetchCompletedSize(): Long {
    ...
    val size = file.length()
    ...
}
// Q以上需要先获取uri,再拿到文件大小
@RequiresApi(Build.VERSION_CODES.Q)
private fun fetchCompletedSizeQ(): Long {
    ...
   return App.getContext().contentResolver.openFileDescriptor(this, "r")?.statSize
                        ?: 0L
    ...
}

判断服务端是否支持断点,文件续传

private fun isAppend(res: Response<ResponseBody>): Boolean {
        var append = true
        XLog.e("临时文件地址: $savePath${File.separator}$tempFileName")
        //服务器不支持断点下载时重新下载
        if (res.headers()["Content-Range"].isNullOrEmpty()) {
            // 服务器不支持断点续传
            completedSize = 0
            append = false
        }
        return append
    }
// Q以下
FileOutputStream(file, isAppend(res))
// Q
App.getContext().contentResolver.openOutputStream(uri, if (isAppend(res)) "wa" else "w")

进度回调

private suspend fun progress(flow: FlowCollector<Resource<Uri>>) {
        if (System.currentTimeMillis() - time >= interval) {
            time = System.currentTimeMillis()
            val percent = (completedSize.toFloat()) / contentLength
            flow.emit(value = Resource.Progress(percent = percent))
        }
    }

4.Moshi简介

  • @JsonClass(generateAdapter = true)注解,将会参与到序列化\反序列化的进程中。它帮助Moshi使用代码自动生成而非使用将会降低速度的反射
  • @Json(name = "_id") json别名
@JsonClass(generateAdapter = true)
@Entity(tableName = "person")
@TypeConverters(StringListConverter::class)
data class Person(
    // json别名
    @Json(name = "_id")
    // 主键
    @PrimaryKey
    var id: String,
    var author: String,
    var category: String,
    // 数据库别名
    @ColumnInfo(name = "created_at")
    var createdAt: String,
    var desc: String,
    // 忽略,使用Ignore并不能忽略List<String>
//    @Ignore
    var images: List<String>,
    @ColumnInfo(name = "like_counts")
    var likeCounts: Long,
    @ColumnInfo(name = "published_at")
    var publishedAt: String,
    var stars: Long,
    var title: String,
    var type: String,
    var url: String,
    var views: Long,
)

5.Hilt

目前发现Hilt唯一的缺点就是singleton的实例不能想在哪里获取就在哪里获取,好在我找到了一个方法,下面会提到,如果您不想用Hilt可以用object,自己手撸单例
Hilt实际上是在dagger的基础上开发的,就像他的含义一样,为匕首按上剑柄,大大简化了dagger繁琐的di,如果对原理感兴趣,可以去研究下Java IoC,Aop
使用时需要注意的点:

  • 项目中必须自定义一个Application,并注解@HiltAndroidApp
@HiltAndroidApp
class App : MultiDexApplication()
  • @AndroidEntryPoint只能作用在ComponentActivity, (support) Fragment, View, Service, 以及 BroadcastReceiver
  • Component有两种实现方式一种是@Provides,还有一种是本项目没用到的@Binds。component和scope是一一对应的,需要匹配上。
@Module
@InstallIn(SingletonComponent::class)
class AppModule {
    @Singleton
    @Provides
    fun provideMoshi(): Moshi = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .build()
}
  • 如何在项目任何地方获取singleton(如果您有更好的方法可以告诉我,手撸单例除外),需要自定义一个EntryPoint
@EntryPoint
@InstallIn(SingletonComponent::class)
interface NetSource 

通过EntryPoints.get来获取实例

inline fun <reified T> get(): T {
    return EntryPoints.get(ContextProvider.context, T::class.java)
}

6.Room

Room网上的文章一大堆,这里就不细说了,只提一点,怎么保存List<String>

  • 首先需要新建一个转化类
class StringListConverter {

    private val adapter : JsonAdapter<List<String>> by lazy {
        val moshi = Singleton.get<NetSource>().getMoshi()
        val type = Types.newParameterizedType(
            List::class.java,
            String::class.java
        )
        moshi.adapter(type)
    }

    @TypeConverter
    fun getListFromString(value: String): List<String> {
        return adapter.fromJson(value) as List<String>
    }

    @TypeConverter
    fun saveListToString(list: List<String>): String {
        return adapter.toJson(list)
    }
}
  • 在有需要的类中添加注解,注意添加在类的上面。
@JsonClass(generateAdapter = true)
@Entity(tableName = "person")
@TypeConverters(StringListConverter::class)
data class Person

7.源码

项目链接

8.后记

鄙人也是看了很多源码以及博客才有了这个项目,感谢巨人的肩膀,thanks!!!
如果觉得Hilt难用,可以替换掉所有的Hilt。
说实话Kotlin并不好用。
有什么问题可以在下方留言,或者在gitee留言。
小建议:接收Room的数据不要用Flow,Flow会在数据库变动持续发射数据,影响上层数据处理。

9.2022年5月11日更新

由于远程的api不稳定,决定将服务迁移至本地,需要您更新代码重新编译,并下载一个spring boot项目:

链接:https://pan.baidu.com/s/1foI48MgVBdVHfQ9fh60EdA?pwd=0d9i
提取码:0d9i
复制这段内容后打开百度网盘手机App,操作更方便哦

运行服务

// 请先安装jdk并配置环境变量
java -jar picture-0.0.1.jar

注意:

  1. pic.json是自定义的json数据,和jar保持同级目录,文件是utf-8格式的txt修改后缀而来,若要自定义数据,可以修改json数据
  2. 请确保服务和app在同一个局域网,第一次进入app需要输入服务所在的ip地址(例如192.168.X.X),本人只在模拟器上试过,真机应该没有问题
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容