引言
做这个起初的目的是为了学习Kotlin协程,以及JetPack中的相关组件,并且老项目准备重构,于是打算彻彻底底的进行换血.
其实中间写了很多版,包括用RxJava也写过,不过写到一半儿,看了一篇Rxjava已过时的文章直接放弃了,其实我没有在项目中"真正"用过Rxjava,正打算用,就看到了这种言论,不过说得其实也有道理,Rxjava的出现是当时Java环境的需要,但是Kotlin以及kotlin协程的稳定可能真的是Rxjava消亡的契机吧.但是真正让我放弃的是Rxjava的复杂性.Rxjava为了突显链式调用,定义了很多云里雾里的函数(至少让我云里雾里),我觉得很多函数是没必要的或者说不应该做为Rxjava标准库的一部分,它扩展的功能太多了.
官方本身也为我们提供了一个用于管理下载的组件DownloadManager,但是它针对的是所有的Android app,所以它对app自身的切合度太低了,想要实现自定义上的操作,过于复杂了.如果我们要做一个拥有下载管理功能的app,那么这个功能还是应该自己来实现的.
这个Demo本来就是在学习中诞生的,可能代码中很多不足,或者出现了某些错误,还请各位大佬予以指正和提示
预览
按钮没有做状态选择器,看不出来点击步骤,抱歉
1 Room 用于存储下载数据
Room想要配合协程使用,必须加入room-ktx依赖,导入room-ktx之后同时也会导入Kotlin协程相关的部分
implementation 'androidx.room:room-runtime:2.2.5'
implementation 'androidx.room:room-ktx:2.2.5'
kapt 'androidx.room:room-compiler:2.2.5'
同时需要加入对kotlin注解编译器的支持
apply plugin: 'kotlin-kapt'
1.1 DownloadInfo用于保存下载数据的数据类
@Entity
@TypeConverters(Converters::class)
data class DownloadInfo(
@PrimaryKey
var url: String = "",
var path: String? = null,
var data: Serializable? = null,//跟下载相关的数据信息
var fileName: String? = null,
var contentLength: Long = -1,
var currentLength: Long = 0,
var status: Int = NONE,
var lastRefreshTime: Long = 0
) {
companion object Status {
const val NONE = 0 //无状态
const val WAITING = 1 //等待中
const val LOADING = 2 //下载中
const val PAUSE = 3 //暂停
const val ERROR = 4 //错误
const val DONE = 5 //完成
}
/**
* 重置任务
*/
fun reset() {
currentLength = 0
contentLength = -1
status = NONE
lastRefreshTime = 0
}
}
Serializable字段我们需要为其添加类型转换器,详情参考官网
class Converters {
@TypeConverter
fun toByteArray(serializable: Serializable?): ByteArray? {
serializable ?: return null
var byteArrayOutputStream: ByteArrayOutputStream? = null
var objectOutputStream: ObjectOutputStream? = null
try {
byteArrayOutputStream = ByteArrayOutputStream()
objectOutputStream = ObjectOutputStream(byteArrayOutputStream)
objectOutputStream.writeObject(serializable)
objectOutputStream.flush()
return byteArrayOutputStream.toByteArray()
} catch (e: Exception) {
e.printStackTrace()
} finally {
byteArrayOutputStream?.close()
objectOutputStream?.close()
}
return null
}
@TypeConverter
fun toSerializable(byteArray: ByteArray?): Serializable? {
byteArray ?: return null
var byteArrayOutputStream: ByteArrayInputStream? = null
var objectInputStream: ObjectInputStream? = null
try {
byteArrayOutputStream = ByteArrayInputStream(byteArray)
objectInputStream = ObjectInputStream(byteArrayOutputStream)
return objectInputStream.readObject() as Serializable
} catch (e: Exception) {
e.printStackTrace()
} finally {
byteArrayOutputStream?.close()
objectInputStream?.close()
}
return null
}
}
1.2 DownloadDao
定义了访问下载数据的方法
@Dao
interface DownloadDao {
/**
* 获取所有
*/
@Query("select * from DownloadInfo")
suspend fun queryAll(): MutableList
/**
* 通过状态查询任务
*/
@Query("select * from DownloadInfo where status =:status")
suspend fun queryByStatus(status: Int): MutableList
/**
* 查询正在下载的任务
*/
@Query("select * from DownloadInfo where status != ${DownloadInfo.DONE} and status != ${DownloadInfo.NONE}")
suspend fun queryLoading(): MutableList
/**
* 查询正在下载的任务的url
*/
@Query("select url from DownloadInfo where status != ${DownloadInfo.DONE} and status != ${DownloadInfo.NONE}")
suspend fun queryLoadingUrls(): MutableList
/**
* 查询下载完成的任务
*/
@Query("select * from DownloadInfo where status == ${DownloadInfo.DONE}")
suspend fun queryDone(): MutableList
/**
* 查询下载完成的任务的url
*/
@Query("select url from DownloadInfo where status == ${DownloadInfo.DONE}")
suspend fun queryDoneUrls(): MutableList
/**
* 通过url查询,每一个任务他们唯一的标志就是url
*/
@Query("select * from DownloadInfo where url like:url")
suspend fun queryByUrl(url: String): DownloadInfo?
/**
* 插入或替换
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrReplace(vararg downloadData: DownloadInfo): List
/**
* 删除
*/
@Delete
suspend fun delete(downloadDao: DownloadInfo)
}
1.3 AppDataBase
数据库持有者
@Database(entities = [DownloadInfo::class], version = 1)
abstract class AppDataBase : RoomDatabase() {
abstract fun downloadDao(): DownloadDao
}
1.4 RoomClient
构建AppDataBase,请忽略createMigrations方法,这里暂时没有数据库升级的需求
object RoomClient {
private const val DATA_BASE_NAME = "download.db"
val dataBase: AppDataBase by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
Room
.databaseBuilder(
App.instance.applicationContext,
AppDataBase::class.java,
DATA_BASE_NAME
)
.build()
}
private fun createMigrations(): Array {
return arrayOf()
}
}
2 下载逻辑
下载逻辑包含以下几部分
1.DownloadService
2.RetrofitDownload
3.DownloadScope
4.AppDownload
2.1 DownloadService
定义了断点下载的方法,Retrofit2.6之后直接支持配合协程使用
interface DownloadService {
@Streaming
@GET
suspend fun download(@Header("RANGE") start: String? = "0", @Url url: String?): Response
}
2.2 RetrofitDownload
构建Retrofit,baseurl的填写满足http://或者https://开头且后面有内容就可以了
object RetrofitDownload {
val downloadService: DownloadService by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
val okHttpClient = createOkHttpClient()
val retrofit = createRetrofit(okHttpClient)
retrofit.create(DownloadService::class.java)
}
private fun createOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder().build()
}
private fun createRetrofit(client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("http://download")
.client(client)
.build()
}
}
2.3 DownloadScope[核心]
代表一个下载任务,实际的下载也在里面进行
/**
* 代表一个下载任务
* url将做为下载任务的唯一标识
* 不要直接在外部直接创建此对象,那样就可能无法同一管理下载任务,请通过[AppDownload.request]获取此对象
*/
class DownloadScope(
var url: String,
var path: String? = null,
private val data: Serializable? = null
) : CoroutineScope by CoroutineScope(EmptyCoroutineContext) {
private var downloadJob: Job? = null
private val downloadData = MutableLiveData()
init {
launch(Dispatchers.Main) {
val downloadInfoDeferred = async(Dispatchers.IO) {
RoomClient.dataBase.downloadDao().queryByUrl(url)
}
var downloadInfo = downloadInfoDeferred.await()
//数据库中并没有任务,这是一个新的下载任务
if (downloadInfo == null)
downloadInfo = DownloadInfo(url = url, path = path, data = data)
//将原本正在下载中的任务恢复到暂停状态,防止意外退出出现的状态错误
if (downloadInfo.status == DownloadInfo.LOADING)
downloadInfo.status = DownloadInfo.PAUSE
downloadData.value = downloadInfo
}
}
/**
* 获取[DownloadInfo]
*/
fun downloadInfo(): DownloadInfo? {
return downloadData.value
}
/**
* 添加下载任务观察者
*/
fun observer(lifecycleOwner: LifecycleOwner, observer: Observer) {
downloadData.observe(lifecycleOwner, observer)
}
/**
* 开始任务的下载
* [DownloadInfo]是在协程中进行创建的,它的创建会优先从数据库中获取,但这种操作是异步的,详情请看init代码块
* 我们需要通过观察者观察[DownloadInfo]来得知它是否已经创建完成,只有当他创建完成且不为空(如果创建完成,它一定不为空)
* 才可以交由[AppDownload]进行下载任务的启动
* 任务的开始可能并不是立即的,任务会受到[AppDownload]的管理
*/
fun start() {
var observer: Observer? = null
observer = Observer { downloadInfo ->
downloadInfo?.let {
observer?.let { downloadData.removeObserver(it) }
when (downloadInfo.status) {
DownloadInfo.PAUSE, DownloadInfo.ERROR, DownloadInfo.NONE -> {
change(DownloadInfo.WAITING)
AppDownload.launchScope(this@DownloadScope)
}
}
}
}
downloadData.observeForever(observer)
}
/**
* 启动协程进行下载
* 请不要尝试在外部调用此方法,那样会脱离[AppDownload]的管理
*/
fun launch() = launch {
try {
download()
change(DownloadInfo.DONE)
} catch (e: Throwable) {
Log.w("DownloadScope", "error:${e.message}")
when (e) {
!is CancellationException -> change(DownloadInfo.ERROR)
}
} finally {
AppDownload.launchNext(url)
}
}.also { downloadJob = it }
private suspend fun download() = withContext(context = Dispatchers.IO, block = {
change(DownloadInfo.LOADING)
val downloadInfo = downloadData.value
downloadInfo ?: throw IOException("Download info is null")
val startPosition = downloadInfo.currentLength
//验证断点有效性
if (startPosition < 0) throw IOException("Start position less than zero")
//下载的文件是否已经被删除
if (startPosition > 0 && !TextUtils.isEmpty(downloadInfo.path))
if (!File(downloadInfo.path).exists()) throw IOException("File does not exist")
val response = RetrofitDownload.downloadService.download(
start = "bytes=$startPosition-",
url = downloadInfo.url
)
val responseBody = response.body()
responseBody ?: throw IOException("ResponseBody is null")
//文件长度
if (downloadInfo.contentLength < 0)
downloadInfo.contentLength = responseBody.contentLength()
//保存的文件名称
if (TextUtils.isEmpty(downloadInfo.fileName))
downloadInfo.fileName = UrlUtils.getUrlFileName(downloadInfo.url)
//创建File,如果已经指定文件path,将会使用指定的path,如果没有指定将会使用默认的下载目录
val file: File
if (TextUtils.isEmpty(downloadInfo.path)) {
file = File(AppDownload.downloadFolder, downloadInfo.fileName)
downloadInfo.path = file.absolutePath
} else file = File(downloadInfo.path)
//再次验证下载的文件是否已经被删除
if (startPosition > 0 && !file.exists())
throw IOException("File does not exist")
//再次验证断点有效性
if (startPosition > downloadInfo.contentLength)
throw IOException("Start position greater than content length")
//验证下载完成的任务与实际文件的匹配度
if (startPosition == downloadInfo.contentLength && startPosition > 0)
if (file.exists() && startPosition == file.length()) {
change(DownloadInfo.DONE)
return@withContext
} else throw IOException("The content length is not the same as the file length")
//写入文件
val randomAccessFile = RandomAccessFile(file, "rw")
randomAccessFile.seek(startPosition)
downloadInfo.currentLength = startPosition
val inputStream = responseBody.byteStream()
val bufferSize = 1024 * 8
val buffer = ByteArray(bufferSize)
val bufferedInputStream = BufferedInputStream(inputStream, bufferSize)
var readLength: Int
try {
while (bufferedInputStream.read(
buffer, 0, bufferSize
).also {
readLength = it
} != -1 && downloadInfo.status == DownloadInfo.LOADING && isActive//isActive保证任务能被及时取消
) {
randomAccessFile.write(buffer, 0, readLength)
downloadInfo.currentLength += readLength
val currentTime = System.currentTimeMillis()
if (currentTime - downloadInfo.lastRefreshTime > 300) {
change(DownloadInfo.LOADING)
downloadInfo.lastRefreshTime = currentTime
}
}
} finally {
inputStream.close()
randomAccessFile.close()
bufferedInputStream.close()
}
})
/**
* 更新任务
* @param status [DownloadInfo.Status]
*/
private fun change(status: Int) = launch(Dispatchers.Main) {
val downloadInfo = downloadData.value
downloadInfo ?: return@launch
downloadInfo.status = status
withContext(Dispatchers.IO) {
RoomClient.dataBase.downloadDao().insertOrReplace(downloadInfo)
}
downloadData.value = downloadInfo
}
/**
* 暂停任务
* 只有等待中的任务和正在下载中的任务才可以进行暂停操作
*/
fun pause() {
cancel(CancellationException("pause"))
val downloadInfo = downloadData.value
downloadInfo?.let {
if (it.status == DownloadInfo.LOADING || it.status == DownloadInfo.WAITING)
change(DownloadInfo.PAUSE)
}
}
/**
* 删除任务,删除任务会同时删除已经在数据库中保存的下载信息
*/
fun remove() = launch(Dispatchers.Main) {
[email protected](CancellationException("remove"))
val downloadInfo = downloadData.value
downloadInfo?.reset()
downloadData.value = downloadInfo
withContext(Dispatchers.IO) {
downloadInfo?.let {
RoomClient.dataBase.downloadDao().delete(it)
//同时删除已下载的文件
it.path?.let { path ->
val file = File(path)
if (file.exists()) file.delete()
}
}
}
}
/**
* 取消[downloadJob],将会中断正在进行的下载任务
*/
private fun cancel(cause: CancellationException) {
downloadJob?.cancel(cause)
}
/**
* 是否是等待任务
*/
fun isWaiting(): Boolean {
val downloadInfo = downloadData.value
downloadInfo ?: return false
return downloadInfo.status == DownloadInfo.WAITING
}
/**
* 是否是正在下载的任务
*/
fun isLoading():Boolean {
val downloadInfo = downloadData.value
downloadInfo ?: return false
return downloadInfo.status == DownloadInfo.LOADING
}
}
2.4 AppDownload[核心]
对DownloadScope的管理,同时也是获取DownloadScope的唯一途径
object AppDownload {
private const val MAX_SCOPE = 3
val downloadFolder: String? by lazy {
Environment.getExternalStorageState()
App.instance.applicationContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?.absolutePath
}
private val scopeMap: ConcurrentHashMap by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
ConcurrentHashMap()
}
private val taskScopeMap: ConcurrentHashMap by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
ConcurrentHashMap()
}
/**
* 请求一个下载任务[DownloadScope]
* 这是创建[DownloadScope]的唯一途径,请不要通过其他方式创建[DownloadScope]
* 首次任务调用此方法获取[DownloadScope]并不会在数据库中生成数据
* 首次任务只有调用了[DownloadScope.start]并且成功进入[DownloadInfo.WAITING]状态才会在数据库中生成数据
* 首次任务的判断依据为数据库中是否保留有当前的任务数据
*/
fun request(url: String?, data: Serializable? = null, path: String? = null): DownloadScope? {
if (TextUtils.isEmpty(url)) return null
var downloadScope = scopeMap[url]
if (downloadScope == null) {
downloadScope = DownloadScope(url = url!!, data = data, path = path)
scopeMap[url] = downloadScope
}
return downloadScope
}
/**
* 通过url恢复任务
*
* @param urls 需要恢复任务的连接,url请通过DownloadDao进行获取
*/
fun restore(urls: List): MutableList {
val downloadScopes = mutableListOf()
for (url in urls) {
var downloadScope = scopeMap[url]
if (downloadScope == null) {
downloadScope = DownloadScope(url = url)
scopeMap[url] = downloadScope
}
downloadScopes.add(downloadScope)
}
return downloadScopes
}
/**
* 暂停所有的任务
* 只有任务的状态为[DownloadInfo.WAITING]和[DownloadInfo.LOADING]才可以被暂停
* 暂停任务会先暂停[DownloadInfo.WAITING]的任务而后再暂停[DownloadInfo.LOADING]的任务
*/
fun pauseAll() {
for (entry in scopeMap) {
val downloadScope = entry.value
if (downloadScope.isWaiting())
downloadScope.pause()
}
for (entry in scopeMap) {
val downloadScope = entry.value
if (downloadScope.isLoading())
downloadScope.pause()
}
}
/**
* 移除所有的任务
* 移除任务会先移除状态不为[DownloadInfo.LOADING]的任务
* 而后再移除状态为[DownloadInfo.LOADING]的任务
*/
fun removeAll() {
for (entry in scopeMap) {
val downloadScope = entry.value
if (!downloadScope.isLoading())
downloadScope.remove()
}
for (entry in scopeMap) {
val downloadScope = entry.value
if (downloadScope.isLoading())
downloadScope.pause()
}
}
/**
* 启动下载任务
* 请不要直接使用此方法启动下载任务,它是交由[DownloadScope]进行调用
*/
fun launchScope(scope: DownloadScope) {
if (taskScopeMap.size >= MAX_SCOPE) return
if (taskScopeMap.contains(scope.url)) return
taskScopeMap[scope.url] = scope
scope.launch()
}
/**
* 启动下一个任务,如果有正在等待中的任务的话
* 请不要直接使用此方法启动下载任务,它是交由[DownloadScope]进行调用
* @param previousUrl 上一个下载任务的下载连接
*/
fun launchNext(previousUrl: String) {
taskScopeMap.remove(previousUrl)
for (entrySet in scopeMap) {
val downloadScope = entrySet.value
if (downloadScope.isWaiting()) {
launchScope(downloadScope)
break
}
}
}
}
全部代码
https://gitee.com/tomato_wl/CoroutineDownlaod.git
总结
1.由于没有新建一个module,所以很多不应该暴露出来的方法都暴露出来了,不过不应该暴露出来的都已经注释注明了.
2.pauseAll和removeAll这两个方法并没有经过实际的测试
3.部分逻辑参考了开源项目OkGo,感谢!!!