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。代码都是人编的,怎么舒服怎么来。
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
注意:
- pic.json是自定义的json数据,和jar保持同级目录,文件是utf-8格式的txt修改后缀而来,若要自定义数据,可以修改json数据
- 请确保服务和app在同一个局域网,第一次进入app需要输入服务所在的ip地址(例如192.168.X.X),本人只在模拟器上试过,真机应该没有问题