整篇文章一个 interface 回调都没有,还告诉我能传进展?你甭说还真能!

前言

对小白来说,完结下载功用确实令人头大,甭说什么能断点续传的下载,断点续传能保存状况的后台下载。初来乍到的咱们首要想到的便是去各大编程交流网站去查询怎么完结,比如 C 站,一看发稿日期 2013 年、2015 年,一看用的技能,AsyncTask、Thread 等等,满是过时的,没有一个用了较新的技能,而且各种 interface,BroadcastReceiver 回调,一想到再和 RecyclerView 调配,更莫衷一是了。来看看,也根本上是推荐自己结构的,其实也不能很系统、简略的教你从零写一个马上能用的。许多人也是因此被劝退了。

首要非常感谢这篇文章给了我明晰的思路 Android原生下载(上篇)根本逻辑+断点续传 – ,但仍是有点不流畅难明,再加上作者丰富的图更会让人莫衷一是。所以我以此探索出了一套合适小白快速用 Room 数据库 搭建的办法。

本文用 Flow 比较多,当然你用 RxJava,LiveData 也能够,只是 Flow 我用惯了,思路一致。

意图

这篇文章会手搓一个我自己探索出来的简略又好用的单线程 | 断点续传 | 可后台 | 可保存状况下载功用,而不是专业的下载器。功率不一定高,可是绝对简略了解、上手,而且有根本完善的功用,合适快速整合进个人开发的 APP 中。

需求

写这个之前,咱们需求理清咱们要干什么。

  1. 文件增加后能够断点续传下载,能够暂停、持续、撤销。
  2. 分为“已下载”和“正在下载”两个区域,下载完结之后马上进入“已下载”区域。
  3. “正在下载”区域显现下载进展、速度。
  4. “已下载”区域能够删去文件。
  5. 能够后台下载。从其他一个 Activity 下载东西后,开端在后台下载,即使不在下载界面。

运用的 Jetpack 库

咱们这儿就用两个 Jetpack 库,乃至不用都能够。为了快速搭建,至少仍是用个 Room 数据库,所以标题才写了一个 Jetpack 库搞定一切。

  1. Room(数据库,可选,但建议挑选)
  2. WorkManager(类似 Service,供给后台功用,可选)

为什么说不用都能够,首要 WorkManager 便是大号 Service,完全能够替换,只不过 WorkManager 支撑协程,所以写起来便利。然后 Room 便是一般数据库,你非得用自带的也能够,不过那会费事许多,由于咱们的许多操作需求 Room 的“魔法”操作。

运用的一般库

  1. RecyclerView(担任列表显现)
  2. Flow(担任协程,可选)

其间 RecyclerView 最好用 DiffUtil,完结更好更高效的动画效果。

开端前必了解

为什么要 RandomAccessFile

让 ChatGPT 写了下关于 RandomAccessFile 的一些重要特性和它为什么合适断点续传的原因:

RandomAccessFile 是 Java 中用于读写文件的类,它支撑随机拜访文件的读取和写入操作。与传统的输入流和输出流不同,RandomAccessFile 答应你在文件中移动到任意方位并读取或写入数据,这使它非常合适完结断点续传功用。

  1. 随机拜访:RandomAccessFile 支撑随机拜访文件,这意味着你能够依据需求定位到文件的任意方位,而不用顺序读取或写入整个文件。
  2. 读写别离:RandomAccessFile 具有独立的读和写办法,你能够独自履行文件的读取和写入操作。这使得在续传时能够轻松完结文件的部分读取和写入。
  3. seek 办法:RandomAccessFile 供给了 seek(long position) 办法,该办法答应你将文件指针移动到指定的方位。通过结合 seek 办法和文件的当时巨细,你能够轻松地确定从哪里开端续传。
  4. 文件切断:RandomAccessFile 还供给了 setLength(long newLength) 办法,能够用于切断文件的巨细。这在续传时非常有用,由于你能够依据已下载的部分来更新文件的巨细,然后持续写入剩余的数据。

怎么向网站恳求断点续传

先看看支不支撑断点续传,呼应头要是包含Accept-Ranges字段且值为bytes,阐明支撑规模恳求。

然后给恳求中加个 header,字段为Range,值为bytes=%1d-%2d,这表明恳求文件的字节规模为 %1d ~ %2d,其间 %2d 可选,省略表明最终一字节。

举例:bytes=100-代表从第100个字节开端传输,一直到完毕。

还有假如规模恳求成功,呼应代码为 206

在这篇文章中,假定下载的东西都是支撑规模恳求的。

Room 到底有什么“魔法”

Room 数据库能回来 Flow,是由于内部运用了 SQLite 的内容更新告诉功用,当数据库中的任何表有变化时,会触发一个回调,然后重新履行查询语句,并通过 Flow 发射最新的数据。这样 Flow 的观察者就能够收到最新数据,并得到相应更新。

咱们就用的这个特性,将压力给到数据库上,然后简化咱们人力写 interface 等等回调的东西。

RecyclerView 为何调配 DiffUtil

DiffUtil 是用于核算两个列表之间差异的东西类,能够帮助咱们削减不用要的更新操作。

由于咱们 Flow 每次回来的是一整个全新列表,手动去处理增加删去是很费事的,所以调配 DiffUtil,能够在后台线程中自动帮你处理每个 item 的变化。

首要文件结构整理

正常为一般文件,粗体为重要文件。

  • database – 数据库
    • DownloadDao.kt – 担任 Dao 层,笼统办法集合
    • DownloadDatabase.kt – 数据库层,担任实例化数据库
    • DownloadEntity.kt – 数据实体类
  • ui – 界面
    • DownloadedFragment.kt – 下载中 Fragment
    • DownloadedRvAdapter.kt – 下载中 Fragment 中 RecyclerView 的 Adapter
    • DownloadingFragment.kt – 已下载 Fragment
    • DownloadingRvAdapter.kt – 已下载 Fragment 中 RecyclerView 的 Adapter
    • MainActivity.kt – 下载相关所处的 Activity
  • worker – WorkManager
    • DownloadWorker.kt – 担任下载的 Worker
  • App.kt – 担任供给大局 Context
  • Utils.kt – 东西函数

开搞!

注意:我为了操作简洁,运用了封装好的 RecyclerView 库 BaseRecyclerViewAdapterHelper。

跟一般 RecyclerView 运用也没太大差异,网上查查根本就会了。

显而易见的

别忘了在 manifest 里增加网络权限!

先把东西函数给咱们看看,防止之后出现的函数可能看不懂。

// Utils.kt
package com.example.easydownload
import android.app.Activity
import android.content.Context
import android.os.Environment
import android.view.View
import androidx.annotation.IdRes
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
fun <T : View> Activity.id(@IdRes id: Int): Lazy<T> {
    return lazy(LazyThreadSafetyMode.NONE) { findViewById(id) }
}
fun <T : View> Fragment.id(@IdRes id: Int): Lazy<T> {
    return lazy(LazyThreadSafetyMode.NONE) { requireView().findViewById(id) }
}
val downloadDir get() = App.context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
fun Context.suspendLaunch(block: suspend () -> Unit) =
    (this as? AppCompatActivity)?.lifecycleScope?.launch {
        block()
    }

还有 App 类,便是单纯获取一个大局 context 的,没啥其他用,图一个便利。

package com.example.easydownload
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
/**
 * @project EasyDownload
 * @author Yenaly Liew
 * @time 2023/09/12 012 11:51
 */
class App : Application() {
    companion object {
        @SuppressLint("StaticFieldLeak")
        lateinit var context: Context
    }
    override fun onCreate() {
        super.onCreate()
        context = applicationContext
    }
}

数据库构建

DownloadEntity

首要肯定是创立实体类:

package com.example.easydownload.database
import androidx.annotation.IntRange
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
 * @project EasyDownload
 * @author Yenaly Liew
 * @time 2023/09/12 012 11:22
 */
@Entity
data class DownloadEntity(
    /**
     * 文件名称
     */
    var name: String,
    /**
     * 增加日期
     */
    var addDate: Long,
    /**
     * 存储在本地的方位
     */
    var uri: String,
    /**
     * 下载地址
     */
    var url: String,
    /**
     * 长度
     */
    var length: Long,
    /**
     * 已下载长度
     */
    var downloadedLength: Long,
    /**
     * 速度
     */
    // 速度[kb/s] = (当时下载的长度[b] / 1000)[kb] / (时刻距离[ms] / 1000)[s]
    var speed: Float,
    /**
     * 是否正在下载
     */
    var isDownloading: Boolean = false,
    @PrimaryKey(autoGenerate = true)
    var id: Int = 0,
) {
    /**
     * 下载进展
     */
    @get:IntRange(from = 0, to = 100)
    val progress get() = (downloadedLength * 100 / length).toInt()
    /**
     * 是否已下载完结
     */
    val isDownloaded get() = downloadedLength == length
}

完结可保存状况的字段便是其间的lengthdownloadedLengthlength是固定值,而每次下载都会用downloadedLength记载文件下载的长度,下次下载能够通过downloadedLength来获取起始下载方位。

DownloadDao

然后便是数据库笼统办法类:

package com.example.easydownload.database
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
/**
 * @project EasyDownload
 * @author Yenaly Liew
 * @time 2023/09/12 012 11:31
 */
@Dao
abstract class DownloadDao {
    /**
     * 查询一切下载中的使命
     */
    @Query("SELECT * FROM DownloadEntity WHERE downloadedLength <> length ORDER BY id DESC")
    abstract fun loadAllDownloading(): Flow<MutableList<DownloadEntity>>
    /**
     * 查询一切已下载完结的使命
     */
    @Query("SELECT * FROM DownloadEntity WHERE downloadedLength == length ORDER BY id DESC")
    abstract fun loadAllDownloaded(): Flow<MutableList<DownloadEntity>>
    @Query("UPDATE DownloadEntity SET `isDownloading` = 0")
    abstract suspend fun pauseAll()
    @Delete
    abstract suspend fun delete(entity: DownloadEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    abstract suspend fun insert(entity: DownloadEntity)
    @Update(onConflict = OnConflictStrategy.REPLACE)
    abstract suspend fun update(entity: DownloadEntity): Int
    /**
     * 依据url查询
     */
    @Query("SELECT * FROM DownloadEntity WHERE (`url` = :url) LIMIT 1")
    abstract suspend fun findBy(url: String): DownloadEntity?
    /**
     * 依据url查询是否存在
     */
    @Transaction
    open suspend fun isExist(url: String): Boolean {
        return findBy(url) != null
    }
}

要害的要害便是前两个办法,那前两个办法分别是什么意思呢?

第一个:

SELECT * FROM DownloadEntity WHERE downloadedLength <> length ORDER BY id DESC

从 DownloadEntity 表中挑选 已下载长度 不等于 总长度 的数据,代表没下完,然后依据 id 降序。

第二个:

SELECT * FROM DownloadEntity WHERE downloadedLength == length ORDER BY id DESC

从 DownloadEntity 表中挑选 已下载长度 等于 总长度 的数据,代表下完了,然后依据 id 降序。

而且注意,他们俩回来的是Flow<MutableList<DownloadEntity>>,而不是其他。这个Flow可不一般,表修正后,能够实时回来整个表的内容,非常合适用来回调。类似能供给这种功用的也有 LiveData、RxJava 的Observable等等,想用哪个都能够。

还有注意假如是非笼统办法千万别忘了加 open 要害字!

本文暂时用下载链接 URL 作为查询依据,其实不是很恰当,可是作为演示够了。假如你有需求能够修正。

DownloadDatabase

package com.example.easydownload.database
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.example.easydownload.App
/**
 * @project EasyDownload
 * @author Yenaly Liew
 * @time 2023/09/12 012 11:45
 */
@Database(entities = [DownloadEntity::class], version = 1)
abstract class DownloadDatabase : RoomDatabase() {
    abstract val downloadDao: DownloadDao
    companion object {
        val instance by lazy {
            Room.databaseBuilder(
                App.context,
                DownloadDatabase::class.java,
                "download.db"
            ).build()
        }
    }
}

Room 数据库根本功,这儿图省劲直接传的大局 context,这个类看不懂的得去看看 Room 手册了。

这就已经完结三个类了,歇息一下,马上进入 WorkManager 相关类的编写!

下载使命(服务)构建

这儿能够说很要害。

DownloadWorker

思路

在编写之前需求理清一下思路,应该怎么做?

首要咱们需求接收参数,分别为name(文件名)、downloadUrl(下载地址)和immediate(是否当即下载)。

然后是下载初始操作。在下载之前,咱们先判别数据库中是否有存在该 url 的实体。若没有,则对下载地址进行恳求,得到它的contentLength,并创立等巨细的,以name为名的文件,将其加入到数据库中保存,若immediatetrue,则字段isDownloading设置为true,当即进行下载;反之为false,延迟下载。若存在该数据,则依据其间的字段downloadedLength进行 range 恳求下载进行。

其次是下载进行操作。在下载中,每隔特定的一段时刻,核算出当时的downloadedLengthspeed,并对数据库进行更新。假如半途中断,在中断前再次更新数据库,并把实体的字段isDownloading设置为false

代码

接下来是代码展现,我会在一些重要部分写一些注释:

package com.example.easydownload.worker
import android.content.Context
import android.util.Log
import androidx.core.net.toUri
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.example.easydownload.App
import com.example.easydownload.database.DownloadDatabase
import com.example.easydownload.database.DownloadEntity
import com.example.easydownload.downloadDir
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import java.io.File
import java.io.RandomAccessFile
import java.util.concurrent.CancellationException
fun enqueueDownloadWorker(name: String, url: String, immediate: Boolean) {
    val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED) // 只要联网才行,否则 cancel
        .build()
    val data = workDataOf(
        "name" to name,
        "download_url" to url,
        "download_imm" to immediate
    )
    val downloadRequest = OneTimeWorkRequestBuilder<DownloadWorker>()
        .addTag(DownloadWorker.TAG) // 创立的下载 Worker 都有这个 TAG,便利办理 
        .setConstraints(constraints)
        .setInputData(data)
        .build()
    WorkManager.getInstance(App.context)
    	// 以 url 作为唯一凭据,挑选其他也能够,最好是 id 之类的,这儿就图一个便利
    	// 这儿挑选的是,若重复会被掩盖
        .beginUniqueWork(url, ExistingWorkPolicy.REPLACE, downloadRequest)
        .enqueue()
}
/**
 * @project EasyDownload
 * @author Yenaly Liew
 * @time 2023/09/12 012 12:00
 */
class DownloadWorker(context: Context, params: WorkerParameters) :
    CoroutineWorker(context, params) {
    companion object {
        const val TAG = "download_worker"
        // 距离设置为 500ms
        const val RESPONSE_INTERVAL = 500L
    }
    // 创立一个 OkHttpClient 担任网络恳求
    // 也能够换成你自己的,这儿也是图便利
    private val okHttpClient = OkHttpClient()
    private val name by lazy(LazyThreadSafetyMode.NONE) {
        inputData.getString("name") ?: ""
    }
    private val downloadUrl by lazy(LazyThreadSafetyMode.NONE) {
        inputData.getString("download_url") ?: ""
    }
    private val immediate by lazy(LazyThreadSafetyMode.NONE) {
        inputData.getBoolean("download_imm", true)
    }
    override suspend fun doWork(): Result {
        // 调用 Result.retry() 重试之后就会来这儿
        // 若重试次数大于2,则宣告失利
        if (runAttemptCount > 2) {
            return Result.failure(workDataOf("failed_reason" to "下载失利三次"))
        }
        // 下载逻辑
        return download()
    }
    private suspend fun createNewRandomAccessFile(): Boolean = withContext(Dispatchers.IO) {
        val file = File(downloadDir, name)
        var raf: RandomAccessFile? = null
        var resp: Response? = null
        var body: ResponseBody? = null
        try {
            // 依据 file 创立 RAF,"rwd" 差不多是可读写的意思。
            raf = RandomAccessFile(file, "rwd")
            val req = Request.Builder().url(downloadUrl).get().build()
            resp = okHttpClient.newCall(req).execute()
            if (resp.isSuccessful) {
                body = resp.body
                body?.let {
                    // 获取 content length
                    val len = body.contentLength()
                    if (len > 0) {
                        // 将 RAF 长度设置为 content length
                        raf.setLength(len)
                        // 实体初始化
                        val entity = DownloadEntity(
                            name = name,
                            addDate = System.currentTimeMillis(),
                            uri = file.toUri().toString(),
                            url = downloadUrl,
                            length = len,
                            downloadedLength = 0,
                            speed = 0F,
                            isDownloading = false
                        )
                        // 保存到数据库中。
                        DownloadDatabase.instance.downloadDao.insert(entity)
                        return@withContext true
                    }
                }
            }
            // 创立失利
            return@withContext false
        } catch (e: Exception) {
            e.printStackTrace()
            // 若文件创立了可是没读取上 content length,则删去
            if (file.length() == 0L) file.delete()
            // 创立失利
            return@withContext false
        } finally {
            raf?.close()
            resp?.close()
            body?.close()
        }
    }
    private suspend fun download() = withContext(Dispatchers.IO) {
        val file = File(downloadDir, name)
        val isExist = DownloadDatabase.instance.downloadDao.isExist(downloadUrl)
        // 不存在则创立,创立失利重试,创立成功可是延迟下载直接回来成功,不进行下一步
        if (!isExist) {
            val isCreated = createNewRandomAccessFile()
            if (!isCreated) {
                return@withContext Result.retry()
            } else if (!immediate) {
                return@withContext Result.success()
            }
        }
        // 重新搜索,看一看有没有增加上,若没有持续重试
        val entity = DownloadDatabase.instance.downloadDao.findBy(downloadUrl)
            ?: return@withContext Result.retry()
        // 判别是否需求加 Range 头
        val needRange = entity.downloadedLength > 0
        var raf: RandomAccessFile? = null
        var response: Response? = null
        var body: ResponseBody? = null
        try {
            raf = RandomAccessFile(file, "rwd")  
            val request = Request.Builder().url(downloadUrl)
                .also { if (needRange) it.header("Range", "bytes=${entity.downloadedLength}-") }
                .get().build()
            response = okHttpClient.newCall(request).execute()
            entity.isDownloading = true
            raf.seek(entity.downloadedLength)
            // range 成功后 response code 为 206
            if ((needRange && response.code == 206) || (!needRange && response.isSuccessful)) {
                // 用于核算距离差
                var delayTime = 0L
                // 用于核算距离前后的长度差
                var prevLength = entity.downloadedLength
                body = response.body
                body?.let {
                    // 这儿便是经典的流传输,随意找个下载功用的实例根本都这么写
                    val bs = body.byteStream()
                    val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
                    var len: Int = bs.read(buffer)
                    while (len != -1) {
                        raf.write(buffer, 0, len)
                        // 给 entity 的 downloadedLength 赋新值
                        entity.downloadedLength += len
                        if (System.currentTimeMillis() - delayTime > RESPONSE_INTERVAL) {
                            val diffLength = entity.downloadedLength - prevLength
                            // 速度[kb/s] = (长度差[b] / 1000)[kb] / (时刻距离[ms] / 1000)[s]
                            entity.speed = (diffLength / 1000) / (RESPONSE_INTERVAL / 1000F)
                            Log.d(TAG, "download: ${entity.speed}")
                            DownloadDatabase.instance.downloadDao.update(entity)
                            delayTime = System.currentTimeMillis()
                            prevLength = entity.downloadedLength
                        }
                        len = bs.read(buffer)
                    }
                }
                return@withContext Result.success()
            } else {
                return@withContext Result.failure(workDataOf("failed_reason" to response.message))
            }
        } catch (e: Exception) {
            // cancellation exception block 一般是代表用户暂停,或许网络受改变
            if (e is CancellationException) {
                return@withContext Result.success()
            }
            return@withContext Result.failure(workDataOf("failed_reason" to e.message))
        } finally {
            raf?.close()
            response?.close()
            body?.close()
            // 无论进行如何,最终都再次更新,确保是最新数据。
            DownloadDatabase.instance.downloadDao.update(
                entity.copy(isDownloading = false)
            )
        }
    }
}

咱们在最初写了个顶层函数,是为了更便利快捷的敞开该 Worker。

界面构建

XML

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.MainActivity">
    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:title="@string/app_name" />
        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tab_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    </com.google.android.material.appbar.AppBarLayout>
    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/view_pager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
fragment_download.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false"
        android:padding="8dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
item_downloaded.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">
    <data>
    </data>
    <com.google.android.material.card.MaterialCardView
        style="@style/Widget.Material3.CardView.Elevated"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp">
        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="8dp">
            <TextView
                android:id="@+id/tv_title"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:maxLines="2"
                android:minLines="2"
                android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
                android:textStyle="bold"
                app:layout_constrainedHeight="true"
                app:layout_constrainedWidth="true"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                tools:text="1233333333333333" />
            <TextView
                android:id="@+id/tv_size"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                android:textSize="16sp"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/tv_title"
                tools:text="123 MB" />
            <TextView
                android:id="@+id/tv_added_time"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                android:drawablePadding="2dp"
                android:gravity="center"
                android:textSize="14sp"
                android:textStyle="bold"
                app:layout_constraintBottom_toBottomOf="@id/_barrier"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/tv_size"
                app:layout_constraintVertical_bias="0.0"
                tools:text="2021/02/03 15:12" />
            <TextView
                android:id="@+id/tv_release_date"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:gravity="center"
                android:maxLines="1"
                android:visibility="gone"
                app:layout_constrainedWidth="true"
                app:layout_constraintBaseline_toBaselineOf="@id/tv_added_time"
                app:layout_constraintBottom_toBottomOf="@id/_barrier"
                app:layout_constraintEnd_toEndOf="parent"
                tools:text="2021-02-02" />
            <androidx.constraintlayout.widget.Barrier
                android:id="@+id/_barrier"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:barrierDirection="bottom"
                app:constraint_referenced_ids="tv_added_time" />
            <com.google.android.material.button.MaterialButton
                android:id="@+id/btn_delete"
                style="@style/Widget.Material3.Button.TextButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="删去"
                app:iconGravity="end"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/_barrier" />
            <com.google.android.material.button.MaterialButton
                android:id="@+id/btn_open"
                style="@style/Widget.Material3.Button.TextButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="翻开"
                app:iconGravity="start"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintTop_toBottomOf="@id/_barrier" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </com.google.android.material.card.MaterialCardView>
</layout>
item_downloading.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">
    <data>
    </data>
    <com.google.android.material.card.MaterialCardView
        style="@style/Widget.Material3.CardView.Elevated"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp">
        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="8dp">
            <TextView
                android:id="@+id/tv_title"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:maxLines="2"
                android:minLines="2"
                android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
                app:layout_constrainedHeight="true"
                app:layout_constrainedWidth="true"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                tools:text="1233333333333333" />
            <LinearLayout
                android:id="@+id/ll_progress"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                android:gravity="center"
                android:orientation="horizontal"
                app:layout_constrainedWidth="true"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/tv_title">
                <ProgressBar
                    android:id="@+id/pb_progress"
                    style="?android:attr/progressBarStyleHorizontal"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:max="100" />
                <TextView
                    android:id="@+id/tv_progress"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="8dp"
                    tools:text="88%" />
            </LinearLayout>
            <TextView
                android:id="@+id/tv_size"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                android:gravity="center"
                app:layout_constraintBottom_toBottomOf="@id/_barrier"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/ll_progress"
                tools:text="1 MB / 7 MB" />
            <TextView
                android:id="@+id/tv_speed"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:gravity="center"
                android:maxLines="1"
                app:layout_constrainedWidth="true"
                app:layout_constraintBaseline_toBaselineOf="@id/tv_size"
                app:layout_constraintBottom_toBottomOf="@id/_barrier"
                app:layout_constraintEnd_toEndOf="parent"
                tools:text="12 MB/s" />
            <androidx.constraintlayout.widget.Barrier
                android:id="@+id/_barrier"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:barrierDirection="bottom"
                app:constraint_referenced_ids="tv_speed" />
            <com.google.android.material.button.MaterialButton
                android:id="@+id/btn_cancel"
                style="@style/Widget.Material3.Button.TextButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="撤销下载"
                app:iconGravity="end"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/_barrier" />
            <com.google.android.material.button.MaterialButton
                android:id="@+id/btn_start"
                style="@style/Widget.Material3.Button.TextButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="开端下载"
                app:iconGravity="start"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintTop_toBottomOf="@id/_barrier" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </com.google.android.material.card.MaterialCardView>
</layout>
layout_new_download.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">
    <EditText
        android:id="@+id/et_name"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="名称"
        android:maxLines="2" />
    <EditText
        android:id="@+id/et_url"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="地址"
        android:maxLines="2" />
    <androidx.appcompat.widget.SwitchCompat
        android:id="@+id/switch_imm"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="是否当即下载" />
</LinearLayout>

先简略介绍一下 BaseRecyclerViewAdapterHelper:

  • convert办法约等于原生的onBindViewHolder

  • onItemViewHolderCreated办法约等于原生的onCreateViewHolder

  • COMPARATOR相当于用于数据比较改写的一个 Callback,

    setDiffCallback办法用来设置这个 Callback,

    setDiffNewData用来通过这个 Callback 异步加载并比较改写数据。

DownloadedRvAdapter

package com.example.easydownload.ui
import android.text.format.DateFormat
import android.text.format.Formatter
import android.view.View
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.recyclerview.widget.DiffUtil
import com.chad.library.adapter.base.BaseQuickAdapter
import com.chad.library.adapter.base.viewholder.BaseDataBindingHolder
import com.example.easydownload.R
import com.example.easydownload.database.DownloadDatabase
import com.example.easydownload.database.DownloadEntity
import com.example.easydownload.databinding.ItemDownloadedBinding
import com.example.easydownload.suspendLaunch
/**
 * @project EasyDownload
 * @author Yenaly Liew
 * @time 2023/09/10 010 17:10
 */
class DownloadedRvAdapter :
    BaseQuickAdapter<DownloadEntity, DownloadedRvAdapter.ViewHolder>(R.layout.item_downloaded) {
    inner class ViewHolder(view: View) : BaseDataBindingHolder<ItemDownloadedBinding>(view)
    object COMPARATOR : DiffUtil.ItemCallback<DownloadEntity>() {
        override fun areContentsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean {
            return oldItem == newItem
        }
        override fun areItemsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean {
            return oldItem.id == newItem.id
        }
    }
    override fun convert(holder: ViewHolder, item: DownloadEntity) {
        holder.dataBinding!!.let { binding ->
            binding.tvTitle.text = item.name
            binding.tvSize.text = Formatter.formatShortFileSize(context, item.downloadedLength)
            binding.tvAddedTime.text = DateFormat.format("yyyy-MM-dd HH:mm", item.addDate)
        }
    }
    override fun onItemViewHolderCreated(viewHolder: ViewHolder, viewType: Int) {
        viewHolder.dataBinding!!.btnDelete.setOnClickListener {
            val pos = viewHolder.bindingAdapterPosition
            val item = getItem(pos)
            val file = item.uri.toUri().toFile()
            if (file.exists()) file.delete()
            context.suspendLaunch {
                DownloadDatabase.instance.downloadDao.delete(item)
            }
        }
        viewHolder.dataBinding!!.btnOpen.setOnClickListener {
            val pos = viewHolder.bindingAdapterPosition
            val item = getItem(pos)
        }
    }
}

这个比较好了解,不多说了。

DownloadingRvAdapter

package com.example.easydownload.ui
import android.annotation.SuppressLint
import android.text.format.Formatter
import android.view.View
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.recyclerview.widget.DiffUtil
import androidx.work.WorkManager
import com.chad.library.adapter.base.BaseQuickAdapter
import com.chad.library.adapter.base.viewholder.BaseDataBindingHolder
import com.example.easydownload.R
import com.example.easydownload.database.DownloadDatabase
import com.example.easydownload.database.DownloadEntity
import com.example.easydownload.databinding.ItemDownloadingBinding
import com.example.easydownload.suspendLaunch
import com.example.easydownload.worker.enqueueDownloadWorker
import com.google.android.material.button.MaterialButton
/**
 * @project EasyDownload
 * @author Yenaly Liew
 * @time 2023/09/10 010 16:22
 */
class DownloadingRvAdapter :
    BaseQuickAdapter<DownloadEntity, DownloadingRvAdapter.ViewHolder>(R.layout.item_downloading) {
    inner class ViewHolder(view: View) : BaseDataBindingHolder<ItemDownloadingBinding>(view)
    object COMPARATOR : DiffUtil.ItemCallback<DownloadEntity>() {
        override fun areContentsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean {
            return oldItem == newItem
        }
        override fun areItemsTheSame(oldItem: DownloadEntity, newItem: DownloadEntity): Boolean {
            return oldItem.id == newItem.id
        }
    }
    @SuppressLint("SetTextI18n")
    override fun convert(holder: ViewHolder, item: DownloadEntity) {
        holder.dataBinding!!.let { binding ->
            binding.tvTitle.text = item.name
            binding.tvSize.text =
                "${
                    Formatter.formatShortFileSize(context, item.downloadedLength)
                }/${
                    Formatter.formatShortFileSize(context, item.length)
                }"
            binding.tvSpeed.text = "${item.speed} kb/s"
            binding.tvProgress.text = "${item.progress}%"
            binding.pbProgress.setProgress(item.progress, false)
            binding.btnStart.handleStartButton(item.isDownloading)
        }
    }
    override fun onItemViewHolderCreated(viewHolder: ViewHolder, viewType: Int) {
        viewHolder.dataBinding!!.btnStart.setOnClickListener {
            val pos = viewHolder.bindingAdapterPosition
            val item = getItem(pos)
            // 点击之后,假如当时在下载,就暂停,并撤销
            // 假如当时暂停,则启动
            if (item.isDownloading) {
                item.isDownloading = false
                WorkManager.getInstance(context.applicationContext)
                    .cancelUniqueWorkAndPause(item)
            } else {
                item.isDownloading = true
                enqueueDownloadWorker(item.name, item.url, true)
            }
            // 重新对该 Button 进行绘制
            viewHolder.dataBinding!!.btnStart.handleStartButton(item.isDownloading)
        }
        viewHolder.dataBinding!!.btnCancel.setOnClickListener {
            val pos = viewHolder.bindingAdapterPosition
            val item = getItem(pos)
            WorkManager.getInstance(context.applicationContext)
                .cancelUniqueWorkAndDelete(item)
        }
    }
    private fun MaterialButton.handleStartButton(isDownloading: Boolean) {
        text = if (isDownloading) "暂停" else "持续"
    }
	// 撤销并删去(文件+数据库内)
    private fun WorkManager.cancelUniqueWorkAndDelete(
        entity: DownloadEntity,
        workName: String = entity.url,
    ) {
        cancelUniqueWork(workName)
        val file = entity.uri.toUri().toFile()
        if (file.exists()) file.delete()
        context.suspendLaunch {
            DownloadDatabase.instance.downloadDao.delete(entity)
        }
    }
	// 撤销(暂停)
	// item.isDownloading 在上面的逻辑写了,所以这儿直接更新就能够了
    private fun WorkManager.cancelUniqueWorkAndPause(
        entity: DownloadEntity,
        workName: String = entity.url,
    ) {
        cancelUniqueWork(workName)
        context.suspendLaunch {
            DownloadDatabase.instance.downloadDao.update(entity)
        }
    }
}

这个相对来说杂乱一点,由于咱们要控制开端和暂停还有背面的逻辑。

DownloadedFragment

package com.example.easydownload.ui
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.easydownload.R
import com.example.easydownload.database.DownloadDatabase
import com.example.easydownload.id
import kotlinx.coroutines.launch
/**
 * @project EasyDownload
 * @author Yenaly Liew
 * @time 2023/09/10 010 16:21
 */
class DownloadedFragment : Fragment(R.layout.fragment_download) {
    private val rv by id<RecyclerView>(R.id.rv)
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        rv.layoutManager = LinearLayoutManager(context)
        // 创立 adapter 并设置 Callback
        rv.adapter = DownloadedRvAdapter().apply {
            setDiffCallback(DownloadedRvAdapter.COMPARATOR)
        }
        // 加载数据库中 已下载 的数据,并让 adapter 异步改写
        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                DownloadDatabase.instance.downloadDao.loadAllDownloaded().collect {
                    (rv.adapter as DownloadedRvAdapter).setDiffNewData(it)
                }
            }
        }
    }
}

DownloadingFragment

package com.example.easydownload.ui
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.easydownload.R
import com.example.easydownload.database.DownloadDatabase
import com.example.easydownload.id
import kotlinx.coroutines.launch
/**
 * @project EasyDownload
 * @author Yenaly Liew
 * @time 2023/09/10 010 16:21
 */
class DownloadingFragment : Fragment(R.layout.fragment_download) {
    private val rv by id<RecyclerView>(R.id.rv)
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        rv.layoutManager = LinearLayoutManager(context)
        rv.adapter = DownloadingRvAdapter().apply {
            setDiffCallback(DownloadingRvAdapter.COMPARATOR)
        }
        // 让 item 的改写动画时长为 0
        rv.itemAnimator?.changeDuration = 0
        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                DownloadDatabase.instance.downloadDao.loadAllDownloading().collect {
                    (rv.adapter as DownloadingRvAdapter).setDiffNewData(it)
                }
            }
        }
    }
}

和 DownloadedFragment 并无太大差异,首要其间有一句

rv.itemAnimator?.changeDuration = 0

这一句是让 item 的改写动画时长为 0。为什么要这样呢?

loadAllDownloading().collect是很频频的,由于每隔 500 ms(假设只下载一个文件,下载多个可能比这还快),都会触发数据库的更新。已然更新,再加上咱们调用了setDiffNewData,所以每个 item 都会比较频频的闪耀。将changeDuration设为 0,可有效防止。

顺便一提,在 itemAnimator里边还有moveDurationaddDurationremoveDuration三个属性。保留这三个动画能使视觉观感更好,而且不会形成闪耀问题。

MainActivity

package com.example.easydownload.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.EditText
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SwitchCompat
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.example.easydownload.R
import com.example.easydownload.id
import com.example.easydownload.worker.enqueueDownloadWorker
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
class MainActivity : AppCompatActivity(R.layout.activity_main) {
    companion object {
        val tabArray = arrayOf("正在下载", "已下载")
    }
    private val tabLayout by id<TabLayout>(R.id.tab_layout)
    private val viewPager by id<ViewPager2>(R.id.view_pager)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setSupportActionBar(findViewById(R.id.toolbar))
        init()
    }
    private fun init() {
        // 设置 Menu
        addMenuProvider(object : MenuProvider {
            override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
                menu.add(/* groupId = */ 0, /* itemId = */ 123,
                    /* order = */ 0, /* title = */ "增加"
                )
            }
            override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
                if (menuItem.itemId == 123) {
                    val view =
                        View.inflate(this@MainActivity, R.layout.layout_new_download, null)
                    val etUrl = view.findViewById<EditText>(R.id.et_url)
                    val etName = view.findViewById<EditText>(R.id.et_name)
                    val switchImm = view.findViewById<SwitchCompat>(R.id.switch_imm)
                    // 新建使命
                    MaterialAlertDialogBuilder(this@MainActivity)
                        .setTitle("新建下载")
                        .setView(view)
                        .setPositiveButton("增加") { _, _ ->
                            val url = etUrl.text.toString()
                            val name = etName.text.toString()
                            val imm = switchImm.isChecked
                            if (url.isNotEmpty() && name.isNotEmpty()) {
                                enqueueDownloadWorker(name, url, imm)
                            }
                        }
                        .setNegativeButton("撤销", null)
                        .show()
                }
                return true
            }
        })
        viewPager.adapter = object : FragmentStateAdapter(this) {
            override fun getItemCount() = 2
            override fun createFragment(position: Int): Fragment {
                return when (position) {
                    0 -> DownloadingFragment()
                    else -> DownloadedFragment()
                }
            }
        }
        TabLayoutMediator(tabLayout, viewPager) { tab, position ->
            tab.text = tabArray[position]
        }.attach()
    }
}

这儿完结也不难。首要便是 TabLayout 和 ViewPager2 的结合和 Menu 的创立。

注意这个addMenuProvider是新的创立 Menu 的 API,个人感觉用起来比本来需求重写的那个舒服一点。

启动!

咱们以两个文件进行测试,一个是百度 APP 安装包,一个是幻塔游戏安装包,发现能正常创立并获取到文件长度。

下载也正常,多个文件能够一起下载,且可随时暂停持续。

将后台 kill 后再次进入程序,发现历史记载悉数存在未消失。(这儿忘录屏了。但一切信息都储存到数据库了,下载信息肯定不会丢掉)

小白如何快速实现简单的可保存状态断点续传后台下载?一个 Jetpack 库搞定一切!

其间一个下载完结后,很顺滑地转移到另一界面。

测试 APP 是否完好,可看到能够正常安装及运用。

小白如何快速实现简单的可保存状态断点续传后台下载?一个 Jetpack 库搞定一切!

总结

长处

长处不用多说,最初也讲了,首要便是简略实用,不用费多大脑筋就能完结一个比较完善的单线程下载功用。

缺陷

  1. 压力集中在数据库上,性能可能不高,许多功用其实能够拆分。
  2. 灵活性不高,增添一些字段显现需求对数据库进行更改。

我的教程更合适当一个跳板,作为学习运用,之后再用 interface 等等做到更杂乱、功率又高、又完善的下载功用。

本软件做的有点粗陋,可是作为演示很足够了!一切代码悉数都贴到上面了,没有夹带私货的函数或许类,仿制即可用。

教程到这儿就完毕了,希望能帮到咱们!