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(
    val status: Int = 0,
    val msg: String = "",
    val data: T?, // +? 防止空安全序列化失败
    val count: Int = 0
)

MoshiConverterFactory类

val resultAdapter: JsonAdapter>? = 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  CoroutineScope.request(
    flow: Flow,
    liveData: MutableLiveData>,
    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) {

    }

    fun complete(witch: Int) {

    }
  • 向activity中添加一个监听
    您也可以在addObserve中加入start,error, complete等函数
/**
 * BaseActivity扩展
 * 添加数据监听
 */
fun  BasicActivity.addObserve(
    liveData: MutableLiveData>,
    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().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>? = null,
    saveName: String = "",
    savePath: String = ""
)

viewmodel扩展

fun ViewModel.download(
    url: String,
    liveData: MutableLiveData>? = 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): 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>) {
        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
//    @Ignore
    var images: List,
    @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  get(): T {
    return EntryPoints.get(ContextProvider.context, T::class.java)
}

6.Room

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

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

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

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

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

7.源码

项目链接

8.后记

鄙人也是看了很多源码以及博客才有了这个项目,感谢巨人的肩膀,thanks!!!
如果觉得Hilt难用,可以替换掉所有的Hilt。
第一次写Kotlin项目,非常推荐Kotlin。
简洁明了才是最好的代码。
有什么问题可以在下方留言,或者在gitee留言。
代码还会更新的。

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),本人只在模拟器上试过,真机应该没有问题

你可能感兴趣的:(Android & Kotlin:MVVM + Retrofit + Flow(Coroutine协程) + Moshi + Hilt框架项目)