引言
做這個起初的目的是為了學(xué)習(xí)Kotlin協(xié)程,以及JetPack中的相關(guān)組件,并且老項目準備重構(gòu),于是打算徹徹底底的進行換血.
其實中間寫了很多版,包括用RxJava也寫過,不過寫到一半兒,看了一篇Rxjava已過時的文章直接放棄了,其實我沒有在項目中"真正"用過Rxjava,正打算用,就看到了這種言論,不過說得其實也有道理,Rxjava的出現(xiàn)是當時Java環(huán)境的需要,但是Kotlin以及kotlin協(xié)程的穩(wěn)定可能真的是Rxjava消亡的契機吧.但是真正讓我放棄的是Rxjava的復(fù)雜性.Rxjava為了突顯鏈式調(diào)用,定義了很多云里霧里的函數(shù)(至少讓我云里霧里),我覺得很多函數(shù)是沒必要的或者說不應(yīng)該做為Rxjava標準庫的一部分,它擴展的功能太多了.
官方本身也為我們提供了一個用于管理下載的組件DownloadManager,但是它針對的是所有的Android app,所以它對app自身的切合度太低了,想要實現(xiàn)自定義上的操作,過于復(fù)雜了.如果我們要做一個擁有下載管理功能的app,那么這個功能還是應(yīng)該自己來實現(xiàn)的.
這個Demo本來就是在學(xué)習(xí)中誕生的,可能代碼中很多不足,或者出現(xiàn)了某些錯誤,還請各位大佬予以指正和提示
預(yù)覽
按鈕沒有做狀態(tài)選擇器,看不出來點擊步驟,抱歉
1 Room 用于存儲下載數(shù)據(jù)
Room想要配合協(xié)程使用,必須加入room-ktx依賴,導(dǎo)入room-ktx之后同時也會導(dǎo)入Kotlin協(xié)程相關(guān)的部分
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用于保存下載數(shù)據(jù)的數(shù)據(jù)類
@Entity
@TypeConverters(Converters::class)
data class DownloadInfo(
@PrimaryKey
var url: String = "",
var path: String? = null,
var data: Serializable? = null,//跟下載相關(guān)的數(shù)據(jù)信息
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 //無狀態(tài)
const val WAITING = 1 //等待中
const val LOADING = 2 //下載中
const val PAUSE = 3 //暫停
const val ERROR = 4 //錯誤
const val DONE = 5 //完成
}
/**
* 重置任務(wù)
*/
fun reset() {
currentLength = 0
contentLength = -1
status = NONE
lastRefreshTime = 0
}
}
Serializable字段我們需要為其添加類型轉(zhuǎn)換器,詳情參考官網(wǎng)
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
定義了訪問下載數(shù)據(jù)的方法
@Dao
interface DownloadDao {
/**
* 獲取所有
*/
@Query("select * from DownloadInfo")
suspend fun queryAll(): MutableList<DownloadInfo>
/**
* 通過狀態(tài)查詢?nèi)蝿?wù)
*/
@Query("select * from DownloadInfo where status =:status")
suspend fun queryByStatus(status: Int): MutableList<DownloadInfo>
/**
* 查詢正在下載的任務(wù)
*/
@Query("select * from DownloadInfo where status != ${DownloadInfo.DONE} and status != ${DownloadInfo.NONE}")
suspend fun queryLoading(): MutableList<DownloadInfo>
/**
* 查詢正在下載的任務(wù)的url
*/
@Query("select url from DownloadInfo where status != ${DownloadInfo.DONE} and status != ${DownloadInfo.NONE}")
suspend fun queryLoadingUrls(): MutableList<String>
/**
* 查詢下載完成的任務(wù)
*/
@Query("select * from DownloadInfo where status == ${DownloadInfo.DONE}")
suspend fun queryDone(): MutableList<DownloadInfo>
/**
* 查詢下載完成的任務(wù)的url
*/
@Query("select url from DownloadInfo where status == ${DownloadInfo.DONE}")
suspend fun queryDoneUrls(): MutableList<String>
/**
* 通過url查詢,每一個任務(wù)他們唯一的標志就是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<Long>
/**
* 刪除
*/
@Delete
suspend fun delete(downloadDao: DownloadInfo)
}
1.3 AppDataBase
數(shù)據(jù)庫持有者
@Database(entities = [DownloadInfo::class], version = 1)
abstract class AppDataBase : RoomDatabase() {
abstract fun downloadDao(): DownloadDao
}
1.4 RoomClient
構(gòu)建AppDataBase,請忽略createMigrations方法,這里暫時沒有數(shù)據(jù)庫升級的需求
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<Migration> {
return arrayOf()
}
}
2 下載邏輯
下載邏輯包含以下幾部分
1.DownloadService
2.RetrofitDownload
3.DownloadScope
4.AppDownload
2.1 DownloadService
定義了斷點下載的方法,Retrofit2.6之后直接支持配合協(xié)程使用
interface DownloadService {
@Streaming
@GET
suspend fun download(@Header("RANGE") start: String? = "0", @Url url: String?): Response<ResponseBody>
}
2.2 RetrofitDownload
構(gòu)建Retrofit,baseurl的填寫滿足http://或者https://開頭且后面有內(nèi)容就可以了
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[核心]
代表一個下載任務(wù),實際的下載也在里面進行
/**
* 代表一個下載任務(wù)
* url將做為下載任務(wù)的唯一標識
* 不要直接在外部直接創(chuàng)建此對象,那樣就可能無法同一管理下載任務(wù),請通過[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<DownloadInfo>()
init {
launch(Dispatchers.Main) {
val downloadInfoDeferred = async(Dispatchers.IO) {
RoomClient.dataBase.downloadDao().queryByUrl(url)
}
var downloadInfo = downloadInfoDeferred.await()
//數(shù)據(jù)庫中并沒有任務(wù),這是一個新的下載任務(wù)
if (downloadInfo == null)
downloadInfo = DownloadInfo(url = url, path = path, data = data)
//將原本正在下載中的任務(wù)恢復(fù)到暫停狀態(tài),防止意外退出出現(xiàn)的狀態(tài)錯誤
if (downloadInfo.status == DownloadInfo.LOADING)
downloadInfo.status = DownloadInfo.PAUSE
downloadData.value = downloadInfo
}
}
/**
* 獲取[DownloadInfo]
*/
fun downloadInfo(): DownloadInfo? {
return downloadData.value
}
/**
* 添加下載任務(wù)觀察者
*/
fun observer(lifecycleOwner: LifecycleOwner, observer: Observer<DownloadInfo>) {
downloadData.observe(lifecycleOwner, observer)
}
/**
* 開始任務(wù)的下載
* [DownloadInfo]是在協(xié)程中進行創(chuàng)建的,它的創(chuàng)建會優(yōu)先從數(shù)據(jù)庫中獲取,但這種操作是異步的,詳情請看init代碼塊
* 我們需要通過觀察者觀察[DownloadInfo]來得知它是否已經(jīng)創(chuàng)建完成,只有當他創(chuàng)建完成且不為空(如果創(chuàng)建完成,它一定不為空)
* 才可以交由[AppDownload]進行下載任務(wù)的啟動
* 任務(wù)的開始可能并不是立即的,任務(wù)會受到[AppDownload]的管理
*/
fun start() {
var observer: Observer<DownloadInfo>? = 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)
}
/**
* 啟動協(xié)程進行下載
* 請不要嘗試在外部調(diào)用此方法,那樣會脫離[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")
//下載的文件是否已經(jīng)被刪除
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)
//創(chuàng)建File,如果已經(jīng)指定文件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)
//再次驗證下載的文件是否已經(jīng)被刪除
if (startPosition > 0 && !file.exists())
throw IOException("File does not exist")
//再次驗證斷點有效性
if (startPosition > downloadInfo.contentLength)
throw IOException("Start position greater than content length")
//驗證下載完成的任務(wù)與實際文件的匹配度
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保證任務(wù)能被及時取消
) {
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()
}
})
/**
* 更新任務(wù)
* @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
}
/**
* 暫停任務(wù)
* 只有等待中的任務(wù)和正在下載中的任務(wù)才可以進行暫停操作
*/
fun pause() {
cancel(CancellationException("pause"))
val downloadInfo = downloadData.value
downloadInfo?.let {
if (it.status == DownloadInfo.LOADING || it.status == DownloadInfo.WAITING)
change(DownloadInfo.PAUSE)
}
}
/**
* 刪除任務(wù),刪除任務(wù)會同時刪除已經(jīng)在數(shù)據(jù)庫中保存的下載信息
*/
fun remove() = launch(Dispatchers.Main) {
this@DownloadScope.cancel(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],將會中斷正在進行的下載任務(wù)
*/
private fun cancel(cause: CancellationException) {
downloadJob?.cancel(cause)
}
/**
* 是否是等待任務(wù)
*/
fun isWaiting(): Boolean {
val downloadInfo = downloadData.value
downloadInfo ?: return false
return downloadInfo.status == DownloadInfo.WAITING
}
/**
* 是否是正在下載的任務(wù)
*/
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<String, DownloadScope> by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
ConcurrentHashMap<String, DownloadScope>()
}
private val taskScopeMap: ConcurrentHashMap<String, DownloadScope> by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
ConcurrentHashMap<String, DownloadScope>()
}
/**
* 請求一個下載任務(wù)[DownloadScope]
* 這是創(chuàng)建[DownloadScope]的唯一途徑,請不要通過其他方式創(chuàng)建[DownloadScope]
* 首次任務(wù)調(diào)用此方法獲取[DownloadScope]并不會在數(shù)據(jù)庫中生成數(shù)據(jù)
* 首次任務(wù)只有調(diào)用了[DownloadScope.start]并且成功進入[DownloadInfo.WAITING]狀態(tài)才會在數(shù)據(jù)庫中生成數(shù)據(jù)
* 首次任務(wù)的判斷依據(jù)為數(shù)據(jù)庫中是否保留有當前的任務(wù)數(shù)據(jù)
*/
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恢復(fù)任務(wù)
*
* @param urls 需要恢復(fù)任務(wù)的連接,url請通過DownloadDao進行獲取
*/
fun restore(urls: List<String>): MutableList<DownloadScope> {
val downloadScopes = mutableListOf<DownloadScope>()
for (url in urls) {
var downloadScope = scopeMap[url]
if (downloadScope == null) {
downloadScope = DownloadScope(url = url)
scopeMap[url] = downloadScope
}
downloadScopes.add(downloadScope)
}
return downloadScopes
}
/**
* 暫停所有的任務(wù)
* 只有任務(wù)的狀態(tài)為[DownloadInfo.WAITING]和[DownloadInfo.LOADING]才可以被暫停
* 暫停任務(wù)會先暫停[DownloadInfo.WAITING]的任務(wù)而后再暫停[DownloadInfo.LOADING]的任務(wù)
*/
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()
}
}
/**
* 移除所有的任務(wù)
* 移除任務(wù)會先移除狀態(tài)不為[DownloadInfo.LOADING]的任務(wù)
* 而后再移除狀態(tài)為[DownloadInfo.LOADING]的任務(wù)
*/
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()
}
}
/**
* 啟動下載任務(wù)
* 請不要直接使用此方法啟動下載任務(wù),它是交由[DownloadScope]進行調(diào)用
*/
fun launchScope(scope: DownloadScope) {
if (taskScopeMap.size >= MAX_SCOPE) return
if (taskScopeMap.contains(scope.url)) return
taskScopeMap[scope.url] = scope
scope.launch()
}
/**
* 啟動下一個任務(wù),如果有正在等待中的任務(wù)的話
* 請不要直接使用此方法啟動下載任務(wù),它是交由[DownloadScope]進行調(diào)用
* @param previousUrl 上一個下載任務(wù)的下載連接
*/
fun launchNext(previousUrl: String) {
taskScopeMap.remove(previousUrl)
for (entrySet in scopeMap) {
val downloadScope = entrySet.value
if (downloadScope.isWaiting()) {
launchScope(downloadScope)
break
}
}
}
}
全部代碼
總結(jié)
1.由于沒有新建一個module,所以很多不應(yīng)該暴露出來的方法都暴露出來了,不過不應(yīng)該暴露出來的都已經(jīng)注釋注明了.
2.pauseAll和removeAll這兩個方法并沒有經(jīng)過實際的測試
3.部分邏輯參考了開源項目OkGo,感謝!!!