美文网首页Android网络Kotlin
Android & Kotlin:MVVM + Retrofit

Android & Kotlin:MVVM + Retrofit

作者: 风起于山巅 | 来源:发表于2021-07-08 18:15 被阅读0次

    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项目,非常推荐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

        本文链接:https://www.haomeiwen.com/subject/mxmtpltx.html