Author: istyras
Date: 2024-10-12
Update: 2024-10-12
本文旨在从一个下载功能的原理与简单实现上,由浅到深地阐述下载功能的原理与过程,为后续的
分析OkDownload组件
和完整开发文件下载
奠定基础。
本文是关于 文件下载
功能的系列文章的第一篇。
文件下载是一个常见的网络操作,它涉及到从服务器获取数据并保存到本地。
我们将使用Kotlin语言结合协程(Coroutine)和OkHttp库来实现一个逐步复杂的文件下载系统。
下面是每个步骤的概述以及对应的示例代码。
1. 最简单的文件下载
最简单的文件下载就是发送一个HTTP GET请求,并将响应体写入到本地文件中。
java
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.*
fun simpleDownload(url: String, targetPath: String) = runBlocking {
val client = OkHttpClient()
val request = Request.Builder().url(url).build()
withContext(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
FileOutputStream(File(targetPath)).use { fos ->
fos.write(response.body?.bytes())
}
}
}
}
2. 加入文件校验的文件下载
在下载完成后,我们可以计算下载文件的哈希值并与服务器提供的进行比较,以确保文件完整性。
java
import java.security.MessageDigest
fun downloadWithChecksum(url: String, targetPath: String, expectedChecksum: String) = runBlocking {
val client = OkHttpClient()
val request = Request.Builder().url(url).build()
withContext(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
FileOutputStream(File(targetPath)).use { fos ->
fos.write(response.body?.bytes())
}
// 计算文件的MD5校验值
val checksum = File(targetPath).readBytes().digest("MD5")
if (checksum != expectedChecksum) {
throw RuntimeException("File checksum does not match.")
}
}
}
}
// 扩展函数用于计算字节数组的摘要
fun ByteArray.digest(algorithm: String): String {
val md = MessageDigest.getInstance(algorithm)
return BigInteger(1, md.digest(this)).toString(16).padStart(32, '0')
}
3. 提前对资源作可连通性校验的文件下载
在实际下载文件之前,发送一个HEAD请求来检查URL的有效性和可用性是一个很好的做法。
HEAD请求类似于GET请求,但是服务器不会返回消息体,只返回响应头信息。
这样可以快速验证资源是否存在以及获取一些额外的信息,如内容长度(Content-Length)和最后修改时间(Last-Modified)。
java
import okhttp3.*
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.*
import java.net.HttpURLConnection
fun downloadWithConnectivityCheck(url: String, targetPath: String) = runBlocking {
val client = OkHttpClient()
// 发送HEAD请求以检查资源的可连通性
val headRequest = Request.Builder().url(url).head().build()
withContext(Dispatchers.IO) {
client.newCall(headRequest).execute().use { response ->
if (response.code != HttpURLConnection.HTTP_OK) {
throw IOException("Resource is not available. Server returned HTTP code: ${response.code}")
}
// 如果资源可用,继续下载
val getResponse = client.newCall(Request.Builder().url(url).build()).execute()
if (!getResponse.isSuccessful) {
throw IOException("Unexpected code $getResponse")
}
FileOutputStream(File(targetPath)).use { fos ->
getResponse.body?.byteStream()?.copyTo(fos)
}
}
}
}
// 使用示例
fun main() = runBlocking {
val url = "http://example.com/file.zip"
val targetPath = "path/to/your/downloaded/file.zip"
try {
downloadWithConnectivityCheck(url, targetPath)
println("File downloaded successfully.")
} catch (e: Exception) {
e.printStackTrace()
println("Failed to download file: ${e.message}")
}
}
我们首先构建了一个HEAD请求并发送出去。
如果服务器返回的状态码是200 OK,则说明资源是可用的,并且我们可以安全地继续进行GET请求来下载文件。
如果不是200状态码,我们会抛出一个异常,表示资源不可用或存在其他问题。
这段代码还包括了基本的异常处理,如果在连接或下载过程中遇到任何问题,它会捕获异常并打印错误信息。
你可以根据需要进一步扩展这个函数,比如添加日志记录、更详细的错误处理或者用户界面反馈等。
4. 根据提前校验的信息作资源校验的文件下载
在发送HEAD请求检查资源可用性时,服务器通常会返回一些有用的元数据,比如文件的大小(Content-Length)和最后修改时间(Last-Modified)。
我们可以利用这些信息来验证下载完成后的文件是否完整。
java
import okhttp3.*
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.*
import java.net.HttpURLConnection
fun downloadWithMetadataCheck(url: String, targetPath: String) = runBlocking {
val client = OkHttpClient()
// 发送HEAD请求以获取资源的元数据
val headRequest = Request.Builder().url(url).head().build()
withContext(Dispatchers.IO) {
client.newCall(headRequest).execute().use { response ->
if (response.code != HttpURLConnection.HTTP_OK) {
throw IOException("Resource is not available. Server returned HTTP code: ${response.code}")
}
// 从HEAD响应中获取文件大小
val expectedContentSize = response.header("Content-Length")?.toLongOrNull() ?: -1L
if (expectedContentSize == -1L) {
println("Warning: Content-Length header is missing or invalid.")
}
// 如果资源可用,继续下载
val getResponse = client.newCall(Request.Builder().url(url).build()).execute()
if (!getResponse.isSuccessful) {
throw IOException("Unexpected code $getResponse")
}
FileOutputStream(File(targetPath)).use { fos ->
getResponse.body?.byteStream()?.copyTo(fos)
}
// 检查下载的文件大小是否与预期相符
val downloadedFile = File(targetPath)
val actualFileSize = downloadedFile.length()
if (expectedContentSize != -1L && actualFileSize != expectedContentSize) {
throw IOException("File size mismatch. Expected: $expectedContentSize, Actual: $actualFileSize")
}
}
}
}
// 使用示例
fun main() = runBlocking {
val url = "http://example.com/file.zip"
val targetPath = "path/to/your/downloaded/file.zip"
try {
downloadWithMetadataCheck(url, targetPath)
println("File downloaded and verified successfully.")
} catch (e: Exception) {
e.printStackTrace()
println("Failed to download or verify file: ${e.message}")
}
}
在这个实现中,我们做了以下几件事:
- 发送HEAD请求:首先发送一个HEAD请求到指定的URL。
- 解析响应头:从响应头中读取Content-Length字段,该字段表示了远程文件的大小。
- 下载文件:如果HEAD请求成功,我们接着发送GET请求来下载整个文件。
- 校验文件大小:下载完成后,我们通过比较实际文件大小与HEAD请求返回的内容长度来验证文件的完整性。
注意,如果Content-Length头部缺失或者无效(例如为-1),我们会在控制台打印一条警告信息,并且不会基于此进行校验。这是因为某些服务器可能不提供这个头部信息,或者对于动态生成的内容无法预知其大小。
这样的流程可以帮助确保下载的文件是完整的,没有因为网络问题而被截断。
当然,这只是一个基本的完整性检查;更严格的验证还可以包括使用哈希值(如MD5或SHA-256)来进一步确认文件内容的一致性。
5. 对文件下载记录做持久化存储
为了对文件下载记录进行持久化存储,我们可以使用SQLite数据库。
SQLite是一个轻量级的、不需要独立运行的服务进程的数据库引擎,非常适合于移动应用和小型项目。
我们将创建一个简单的数据库来存储文件的下载状态、路径等信息。
对于Android开发,这里我们使用 Android Jecpack 的 Room 组件进行数据库存储的功能实现。
1、添加依赖
groovy
dependencies {
implementation 'androidx.room:room-runtime:2.4.2'
kapt 'androidx.room:room-compiler:2.4.2'
}
2、数据库操作实现
- 实体类定义:定义一个实体类来表示文件下载记录,并创建相应的DAO接口以及Room数据库。
java
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.ColumnInfo
@Entity(tableName = "download_records")
data class DownloadRecord(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "file_url") val fileUrl: String,
@ColumnInfo(name = "local_path") val localPath: String,
// e.g., "PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"
@ColumnInfo(name = "status") val status: String,
@ColumnInfo(name = "content_length") val contentLength: Long? = null,
@ColumnInfo(name = "bytes_downloaded") var bytesDownloaded: Long = 0
)
- 创建DAO接口
java
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
@Dao
interface DownloadRecordDao {
@Insert
suspend fun insert(record: DownloadRecord): Long
@Update
suspend fun update(record: DownloadRecord)
@Query("SELECT * FROM download_records WHERE file_url = :url")
suspend fun findByUrl(url: String): DownloadRecord?
@Query("DELETE FROM download_records")
suspend fun deleteAll()
}
- 创建Room数据库
java
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [DownloadRecord::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun downloadRecordDao(): DownloadRecordDao
}
- 初始化数据库
java
import android.content.Context
import androidx.room.Room
object DatabaseProvider {
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
if (INSTANCE == null) {
synchronized(AppDatabase::class.java) {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
).build()
}
}
}
return INSTANCE!!
}
}
3、使用数据库实现下载记录持久化
现在,可以在下载过程中使用这个数据库来保存和更新下载记录。
在下载前插入一条记录,在下载过程中更新记录,并在下载完成后更新最终的状态。
java
import okhttp3.*
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.*
import java.net.HttpURLConnection
suspend fun downloadFileWithPersistence(url: String, targetPath: String, dao: DownloadRecordDao) {
val client = OkHttpClient()
// 发送HEAD请求以获取资源的元数据
val headRequest = Request.Builder().url(url).head().build()
val response = client.newCall(headRequest).execute()
if (response.code != HttpURLConnection.HTTP_OK) {
throw IOException("Resource is not available. Server returned HTTP code: ${response.code}")
}
// 从HEAD响应中获取文件大小
val expectedContentSize = response.header("Content-Length")?.toLongOrNull() ?: -1L
// 插入下载记录
val recordId = dao.insert(DownloadRecord(fileUrl = url, localPath = targetPath, status = "PENDING", contentLength = expectedContentSize))
// 如果资源可用,继续下载
val getResponse = client.newCall(Request.Builder().url(url).build()).execute()
if (!getResponse.isSuccessful) {
throw IOException("Unexpected code $getResponse")
}
FileOutputStream(File(targetPath)).use { fos ->
val inputStream = getResponse.body?.byteStream()
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead = 0L
while (inputStream?.read(buffer)?.also { bytesRead = it } != -1) {
fos.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
// 更新已下载字节数
dao.update(DownloadRecord(id = recordId.toInt(), fileUrl = url, localPath = targetPath, status = "IN_PROGRESS", contentLength = expectedContentSize, bytesDownloaded = totalBytesRead))
}
}
// 下载完成后更新状态
dao.update(DownloadRecord(id = recordId.toInt(), fileUrl = url, localPath = targetPath, status = "COMPLETED", contentLength = expectedContentSize, bytesDownloaded = expectedContentSize ?: 0))
}
// 使用示例
fun main() = runBlocking {
val url = "http://example.com/file.zip"
val targetPath = "path/to/your/downloaded/file.zip"
val dao = DatabaseProvider.getDatabase(applicationContext).downloadRecordDao()
try {
downloadFileWithPersistence(url, targetPath, dao)
println("File downloaded and verified successfully.")
} catch (e: Exception) {
e.printStackTrace()
println("Failed to download or verify file: ${e.message}")
}
}
6. 支持文件断点续传的文件下载
为了支持文件断点续传,我们需要在HTTP请求中使用Range头来指定要下载的字节范围。
如果服务器支持范围请求(通常通过响应头Accept-Ranges: bytes来指示),那么它会返回从指定位置开始的数据。
我们还需要在数据库中跟踪已下载的部分,这样即使下载中断,我们也能够知道上次下载到了哪里,并从那个地方继续下载。
下面是结合断点续传功能的完整Kotlin代码示例:
1、更新DAO接口
确保DAO接口包含插入、更新和查找记录的方法。
java
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
@Dao
interface DownloadRecordDao {
@Insert
suspend fun insert(record: DownloadRecord): Long
@Update
suspend fun update(record: DownloadRecord)
@Query("SELECT * FROM download_records WHERE file_url = :url")
suspend fun findByUrl(url: String): DownloadRecord?
@Query("DELETE FROM download_records")
suspend fun deleteAll()
}
2、下载文件并支持断点续传
现在我们可以编写一个函数来处理文件下载,并支持断点续传。
java
import okhttp3.*
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.*
import java.net.HttpURLConnection
suspend fun downloadFileWithResumeSupport(url: String, targetPath: String, dao: DownloadRecordDao) {
val client = OkHttpClient()
// 发送HEAD请求以获取资源的元数据
val headRequest = Request.Builder().url(url).head().build()
val response = client.newCall(headRequest).execute()
if (response.code != HttpURLConnection.HTTP_OK) {
throw IOException("Resource is not available. Server returned HTTP code: ${response.code}")
}
// 从HEAD响应中获取文件大小
val expectedContentSize = response.header("Content-Length")?.toLongOrNull() ?: -1L
// 检查是否已有下载记录
val existingRecord = dao.findByUrl(url)
val recordId = if (existingRecord != null) {
existingRecord.id
} else {
// 插入新的下载记录
dao.insert(DownloadRecord(fileUrl = url, localPath = targetPath, status = "IN_PROGRESS", contentLength = expectedContentSize))
}
// 获取当前已下载的字节数
val currentBytesDownloaded = existingRecord?.bytesDownloaded ?: 0L
// 如果已经全部下载完成,则不需要再次下载
if (currentBytesDownloaded >= (expectedContentSize ?: 0L)) {
return
}
// 构建GET请求,包含Range头
val request = Request.Builder()
.url(url)
.header("Range", "bytes=${currentBytesDownloaded}-")
.build()
// 执行GET请求
val getResponse = client.newCall(request).execute()
if (!getResponse.isSuccessful) {
throw IOException("Unexpected code $getResponse")
}
FileOutputStream(File(targetPath), true).use { fos ->
val inputStream = getResponse.body?.byteStream()
val buffer = ByteArray(8192)
var bytesRead: Int
while (inputStream?.read(buffer)?.also { bytesRead = it } != -1) {
fos.write(buffer, 0, bytesRead)
currentBytesDownloaded += bytesRead
// 更新已下载字节数
dao.update(DownloadRecord(id = recordId, fileUrl = url, localPath = targetPath, status = "IN_PROGRESS", contentLength = expectedContentSize, bytesDownloaded = currentBytesDownloaded))
}
}
// 下载完成后更新状态
dao.update(DownloadRecord(id = recordId, fileUrl = url, localPath = targetPath, status = "COMPLETED", contentLength = expectedContentSize, bytesDownloaded = expectedContentSize ?: 0))
}
// 使用示例
fun main() = runBlocking {
val url = "http://example.com/file.zip"
val targetPath = "path/to/your/downloaded/file.zip"
val dao = DatabaseProvider.getDatabase(applicationContext).downloadRecordDao()
try {
downloadFileWithResumeSupport(url, targetPath, dao)
println("File downloaded and verified successfully.")
} catch (e: Exception) {
e.printStackTrace()
println("Failed to download or verify file: ${e.message}")
}
}
在这个实现中,我们首先检查数据库中是否已经有该URL的下载记录。
如果有,我们就读取已下载的字节数,并构建带有Range头的GET请求,从已下载的字节位置开始继续下载。
如果没有记录,我们就插入一个新的记录,并从头开始下载。
在下载过程中,我们会不断更新数据库中的bytes_downloaded字段,以便在下次尝试下载时可以从正确的位置继续。
当文件完全下载完毕后,我们将记录的状态更新为COMPLETED。
7. 进行资源固定分块的文件下载
为了实现将文件分成固定数量的块,并发地下载各个块,我们需要首先确定每个分块的大小。这可以通过总文件大小除以分块数量来计算。然后,我们可以并发地下载每个分块,并在所有分块下载完成后将它们合并成一个完整的文件。
同时,对资源分块下载信息作持久化存储,支持断点续传的资源分块的文件下载。
1、更新实体类
我们需要更新DownloadRecord实体类,以便存储每个分块的信息。这里我们假设每个分块都有自己的记录。
java
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.ColumnInfo
@Entity(tableName = "download_records")
data class DownloadRecord(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "file_url") val fileUrl: String,
@ColumnInfo(name = "local_path") val localPath: String,
// e.g., "PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"
@ColumnInfo(name = "status") val status: String,
@ColumnInfo(name = "content_length") val contentLength: Long? = null,
@ColumnInfo(name = "bytes_downloaded") var bytesDownloaded: Long = 0,
@ColumnInfo(name = "chunk_number") val chunkNumber: Int = 0, // 分块编号
@ColumnInfo(name = "chunk_size") val chunkSize: Long = 0L, // 分块大小
@ColumnInfo(name = "chunk_offset") val chunkOffset: Long = 0L // 分块起始位置
)
2、更新DAO接口
确保DAO接口包含插入、更新和查找记录的方法。
java
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
@Dao
interface DownloadRecordDao {
@Insert
suspend fun insert(record: DownloadRecord): Long
@Update
suspend fun update(record: DownloadRecord)
@Query("SELECT * FROM download_records WHERE file_url = :url")
suspend fun findByUrl(url: String): DownloadRecord?
@Query("SELECT * FROM download_records WHERE file_url = :url AND chunk_number = :chunkNumber")
suspend fun findByUrlAndChunk(url: String, chunkNumber: Int): DownloadRecord?
@Query("DELETE FROM download_records")
suspend fun deleteAll()
}
3、下载文件并支持固定分块数量,使用RandomAccessFile
现在我们可以编写一个函数来处理文件下载,并支持固定数量的分块并发下载,同时使用RandomAccessFile直接写入目标文件的不同位置。
java
import okhttp3.*
import java.io.RandomAccessFile
import kotlinx.coroutines.*
import java.net.HttpURLConnection
import kotlin.math.ceil
import kotlin.math.min
suspend fun downloadFileWithFixedNumberOfChunksUsingRandomAccessFile(url: String, targetPath: String, dao: DownloadRecordDao, numberOfChunks: Int) {
val client = OkHttpClient()
// 发送HEAD请求以获取资源的元数据
val headRequest = Request.Builder().url(url).head().build()
val response = client.newCall(headRequest).execute()
if (response.code != HttpURLConnection.HTTP_OK) {
throw IOException("Resource is not available. Server returned HTTP code: ${response.code}")
}
// 从HEAD响应中获取文件大小
val expectedContentSize = response.header("Content-Length")?.toLongOrNull() ?: -1L
if (expectedContentSize < 0) {
throw IOException("Unable to determine the size of the file.")
}
// 计算每个分块的大小
val chunkSize = ceil(expectedContentSize.toDouble() / numberOfChunks).toLong()
// 并发下载分块
withContext(Dispatchers.IO) {
(0 until numberOfChunks).map { chunkNumber ->
async {
val chunkOffset = chunkNumber * chunkSize
val endOffset = min(chunkOffset + chunkSize - 1, expectedContentSize - 1)
// 检查是否已有下载记录
val existingRecord = dao.findByUrlAndChunk(url, chunkNumber)
val recordId = if (existingRecord != null) {
existingRecord.id
} else {
// 插入新的下载记录
dao.insert(DownloadRecord(fileUrl = url, localPath = targetPath, status = "IN_PROGRESS", contentLength = expectedContentSize, chunkNumber = chunkNumber, chunkSize = chunkSize, chunkOffset = chunkOffset))
}
// 获取当前已下载的字节数
val currentBytesDownloaded = existingRecord?.bytesDownloaded ?: 0L
// 构建GET请求,包含Range头
val request = Request.Builder()
.url(url)
.header("Range", "bytes=${chunkOffset + currentBytesDownloaded}-${endOffset}")
.build()
// 执行GET请求
val getResponse = client.newCall(request).execute()
if (!getResponse.isSuccessful) {
throw IOException("Unexpected code $getResponse")
}
// 使用RandomAccessFile写入目标文件
RandomAccessFile(targetPath, "rw").use { raf ->
raf.seek(chunkOffset + currentBytesDownloaded)
val inputStream = getResponse.body?.byteStream()
val buffer = ByteArray(8192)
var bytesRead: Int
while (inputStream?.read(buffer)?.also { bytesRead = it } != -1) {
raf.write(buffer, 0, bytesRead)
currentBytesDownloaded += bytesRead
// 更新已下载字节数
dao.update(DownloadRecord(id = recordId, fileUrl = url, localPath = targetPath, status = "IN_PROGRESS", contentLength = expectedContentSize, bytesDownloaded = currentBytesDownloaded, chunkNumber = chunkNumber, chunkSize = chunkSize, chunkOffset = chunkOffset))
}
}
}
}.awaitAll()
}
// 更新状态为已完成
(0 until numberOfChunks).forEach { chunkNumber ->
val record = dao.findByUrlAndChunk(url, chunkNumber) ?: return@forEach
dao.update(DownloadRecord(id = record.id, fileUrl = url, localPath = targetPath, status = "COMPLETED", contentLength = expectedContentSize, bytesDownloaded = record.chunkSize, chunkNumber = chunkNumber, chunkSize = record.chunkSize, chunkOffset = record.chunkOffset))
}
}
// 使用示例
fun main() = runBlocking {
val url = "http://example.com/file.zip"
val targetPath = "path/to/your/downloaded/file.zip"
val dao = DatabaseProvider.getDatabase(applicationContext).downloadRecordDao()
val numberOfChunks = 5 // 固定分块数量
try {
downloadFileWithFixedNumberOfChunksUsingRandomAccessFile(url, targetPath, dao, numberOfChunks)
println("File downloaded and verified successfully.")
} catch (e: Exception) {
e.printStackTrace()
println("Failed to download or verify file: ${e.message}")
}
}
在这个实现中,我们做了以下几件事:
- 发送HEAD请求:首先发送一个HEAD请求到指定的URL,以获取文件的总大小。
- 计算分块大小:根据文件的总大小和固定的分块数量,计算出每个分块的大小。
- 并发下载分块:启动多个协程,每个协程负责下载一个特定范围的数据,并使用RandomAccessFile将数据直接写入目标文件的对应位置。每个分块的下载进度被持久化到数据库中。
- 更新状态:当所有分块都下载完毕后,我们将每个分块的状态更新为COMPLETED。
这种方法避免了创建和管理临时文件的复杂性,并且可以直接写入最终的目标文件。
由于RandomAccessFile允许随机访问文件,因此可以有效地在不同位置写入数据。
这样可以提高效率,尤其是在大文件下载时。
8. 进行资源动态分块的文件下载
为了实现根据文件总大小动态调整分块数量的文件下载,我们可以定义一个函数来根据文件大小确定分块数量。然后,使用RandomAccessFile直接写入目标文件的不同位置,避免创建临时文件。
下面是具体的实现步骤:
- 发送HEAD请求:获取文件的总大小。
- 确定分块数量:根据文件大小确定分块数量。
- 计算每个分块的大小:根据文件总大小和分块数量计算每个分块的大小。
- 并发下载分块:启动多个协程,每个协程负责下载一个特定范围的数据,并使用RandomAccessFile将数据直接写入目标文件的对应位置。
- 更新状态:当所有分块都下载完毕后,更新数据库中的记录状态为COMPLETED。
1、确定分块数量
定义一个函数来根据文件大小确定分块数量。 根据文件的总大小分区间限制设置分块数量,10M以内1块,10-20M2块,20-50M3块,50-80M4块,80M以上5块。
java
fun determineNumberOfChunks(fileSize: Long): Int {
return when {
fileSize <= 10 * 1024 * 1024 -> 1 // 10 MB or less
fileSize <= 20 * 1024 * 1024 -> 2 // 10-20 MB
fileSize <= 50 * 1024 * 1024 -> 3 // 20-50 MB
fileSize <= 80 * 1024 * 1024 -> 4 // 50-80 MB
else -> 5 // 80 MB or more
}
}
2、下载文件并支持动态分块数量
编写一个函数来处理文件下载,并支持根据文件大小动态调整分块数量。
java
import okhttp3.*
import java.io.RandomAccessFile
import kotlinx.coroutines.*
import java.net.HttpURLConnection
import kotlin.math.ceil
import kotlin.math.min
suspend fun downloadFileWithDynamicChunks(url: String, targetPath: String, dao: DownloadRecordDao) {
val client = OkHttpClient()
// 发送HEAD请求以获取资源的元数据
val headRequest = Request.Builder().url(url).head().build()
val response = client.newCall(headRequest).execute()
if (response.code != HttpURLConnection.HTTP_OK) {
throw IOException("Resource is not available. Server returned HTTP code: ${response.code}")
}
// 从HEAD响应中获取文件大小
val expectedContentSize = response.header("Content-Length")?.toLongOrNull() ?: -1L
if (expectedContentSize < 0) {
throw IOException("Unable to determine the size of the file.")
}
// 确定分块数量
val numberOfChunks = determineNumberOfChunks(expectedContentSize)
// 计算每个分块的大小
val chunkSize = ceil(expectedContentSize.toDouble() / numberOfChunks).toLong()
// 并发下载分块
withContext(Dispatchers.IO) {
(0 until numberOfChunks).map { chunkNumber ->
async {
val chunkOffset = chunkNumber * chunkSize
val endOffset = min(chunkOffset + chunkSize - 1, expectedContentSize - 1)
// 检查是否已有下载记录
val existingRecord = dao.findByUrlAndChunk(url, chunkNumber)
val recordId = if (existingRecord != null) {
existingRecord.id
} else {
// 插入新的下载记录
dao.insert(DownloadRecord(fileUrl = url, localPath = targetPath, status = "IN_PROGRESS", contentLength = expectedContentSize, chunkNumber = chunkNumber, chunkSize = chunkSize, chunkOffset = chunkOffset))
}
// 获取当前已下载的字节数
val currentBytesDownloaded = existingRecord?.bytesDownloaded ?: 0L
// 构建GET请求,包含Range头
val request = Request.Builder()
.url(url)
.header("Range", "bytes=${chunkOffset + currentBytesDownloaded}-${endOffset}")
.build()
// 执行GET请求
val getResponse = client.newCall(request).execute()
if (!getResponse.isSuccessful) {
throw IOException("Unexpected code $getResponse")
}
// 使用RandomAccessFile写入目标文件
RandomAccessFile(targetPath, "rw").use { raf ->
raf.seek(chunkOffset + currentBytesDownloaded)
val inputStream = getResponse.body?.byteStream()
val buffer = ByteArray(8192)
var bytesRead: Int
while (inputStream?.read(buffer)?.also { bytesRead = it } != -1) {
raf.write(buffer, 0, bytesRead)
currentBytesDownloaded += bytesRead
// 更新已下载字节数
dao.update(DownloadRecord(id = recordId, fileUrl = url, localPath = targetPath, status = "IN_PROGRESS", contentLength = expectedContentSize, bytesDownloaded = currentBytesDownloaded, chunkNumber = chunkNumber, chunkSize = chunkSize, chunkOffset = chunkOffset))
}
}
}
}.awaitAll()
}
// 更新状态为已完成
(0 until numberOfChunks).forEach { chunkNumber ->
val record = dao.findByUrlAndChunk(url, chunkNumber) ?: return@forEach
dao.update(DownloadRecord(id = record.id, fileUrl = url, localPath = targetPath, status = "COMPLETED", contentLength = expectedContentSize, bytesDownloaded = record.chunkSize, chunkNumber = chunkNumber, chunkSize = record.chunkSize, chunkOffset = record.chunkOffset))
}
}
// 使用示例
fun main() = runBlocking {
val url = "http://example.com/file.zip"
val targetPath = "path/to/your/downloaded/file.zip"
val dao = DatabaseProvider.getDatabase(applicationContext).downloadRecordDao()
try {
downloadFileWithDynamicChunks(url, targetPath, dao)
println("File downloaded and verified successfully.")
} catch (e: Exception) {
e.printStackTrace()
println("Failed to download or verify file: ${e.message}")
}
}
9. 完善每个步骤中的异常处理
在实现文件分块下载并支持断点续传的过程中,我们需要处理各种可能的异常情况,以确保程序的健壮性和可靠性。
以下是详细的步骤和每一步中需要考虑的各种异常情况及处理方式:
9.1. 初始化与前期校验
1)异常情况
- 无效URL:提供的URL不正确或无法解析。
- 网络不可用:设备没有连接到互联网。
- 目标路径无效:指定的目标文件路径不存在或权限不足。
2)处理
- 检查URL是否有效,并尝试解析它。
- 检查网络连接状态。
- 检查目标文件路径是否存在,并且应用有足够的权限写入该路径。
java
fun validateInputs(url: String, targetPath: String) {
// URL有效性检查
if (!Patterns.WEB_URL.matcher(url).matches()) {
throw IllegalArgumentException("Invalid URL: $url")
}
// 网络连接检查
if (!isNetworkAvailable(context)) {
throw IOException("No network connection available.")
}
// 目标路径检查
val targetFile = File(targetPath)
if (targetFile.parentFile?.exists() == false || !targetFile.canWrite()) {
throw IOException("Invalid target path or insufficient permissions: $targetPath")
}
}
9.2. 发送HEAD请求获取文件元数据
1)异常情况
- 服务器返回错误代码:如404 Not Found等。
- 无法确定文件大小:服务器未提供Content-Length头。
2)处理
- 检查响应代码,确保为200 OK。
- 如果Content-Length头缺失,则抛出异常。
java
suspend fun getExpectedContentSize(url: String): Long {
val client = OkHttpClient()
val headRequest = Request.Builder().url(url).head().build()
val response = client.newCall(headRequest).execute()
if (response.code != HttpURLConnection.HTTP_OK) {
throw IOException("Resource is not available. Server returned HTTP code: ${response.code}")
}
val contentLength = response.header("Content-Length")?.toLongOrNull() ?: -1L
if (contentLength < 0) {
throw IOException("Unable to determine the size of the file.")
}
return contentLength
}
9.3. 计算分块数量和每个分块的大小
1)异常情况
- 计算结果不合理:例如分块大小为0或负数。
2)处理
- 确保分块大小和数量是合理的,如果计算出错则抛出异常。
java
fun calculateChunkSizeAndNumber(expectedContentSize: Long): Pair<Long, Int> {
val numberOfChunks = determineNumberOfChunks(expectedContentSize)
val chunkSize = ceil(expectedContentSize.toDouble() / numberOfChunks).toLong()
if (chunkSize <= 0) {
throw ArithmeticException("Invalid chunk size calculated: $chunkSize")
}
return Pair(chunkSize, numberOfChunks)
}
9.4. 并发下载分块
1)异常情况
- 网络中断:下载过程中网络连接丢失。
- 服务器错误:服务器返回非206 Partial Content或其他错误代码。
- IO错误:文件写入时出现错误。
- 数据库操作失败:更新下载进度时发生错误。
2)处理
- 在每个下载协程中捕获并处理异常。
- 对于网络中断,可以设置重试机制。
- 对于其他错误,记录错误信息,并根据情况决定是否重试或终止下载。
java
suspend fun downloadChunk(url: String, targetPath: String, dao: DownloadRecordDao, chunkNumber: Int, chunkOffset: Long, chunkSize: Long, expectedContentSize: Long) {
val client = OkHttpClient()
try {
// 构建GET请求,包含Range头
val endOffset = min(chunkOffset + chunkSize - 1, expectedContentSize - 1)
val request = Request.Builder()
.url(url)
.header("Range", "bytes=${chunkOffset}-${endOffset}")
.build()
// 执行GET请求
val getResponse = client.newCall(request).execute()
if (!getResponse.isSuccessful || getResponse.code != HttpURLConnection.HTTP_PARTIAL) {
throw IOException("Unexpected response code: ${getResponse.code}")
}
// 使用RandomAccessFile写入目标文件
RandomAccessFile(targetPath, "rw").use { raf ->
raf.seek(chunkOffset)
val inputStream = getResponse.body?.byteStream()
val buffer = ByteArray(8192)
var bytesRead: Int
while (inputStream?.read(buffer)?.also { bytesRead = it } != -1) {
raf.write(buffer, 0, bytesRead)
// 更新已下载字节数
updateDownloadProgress(dao, url, chunkNumber, chunkOffset, bytesRead)
}
}
} catch (e: Exception) {
// 处理异常,比如记录日志、重试逻辑等
e.printStackTrace()
throw e // 或者重新抛出异常以便上层处理
}
}
private suspend fun updateDownloadProgress(dao: DownloadRecordDao, url: String, chunkNumber: Int, chunkOffset: Long, bytesDownloaded: Int) {
val record = dao.findByUrlAndChunk(url, chunkNumber) ?: return
val newBytesDownloaded = record.bytesDownloaded + bytesDownloaded
dao.update(DownloadRecord(id = record.id, fileUrl = url, localPath = record.localPath, status = "IN_PROGRESS", contentLength = record.contentLength, bytesDownloaded = newBytesDownloaded, chunkNumber = chunkNumber, chunkSize = record.chunkSize, chunkOffset = chunkOffset))
}
9.5. 下载完成后的确认校验
1)异常情况
- 文件大小不符:实际下载的文件大小与预期不符。
- MD5校验失败(可选):如果提供了文件的MD5值进行校验。
2)处理
- 比较实际下载的文件大小与预期大小。
- 如果提供了MD5值,进行MD5校验。
java
suspend fun verifyDownload(targetPath: String, expectedContentSize: Long, expectedMd5: String? = null) {
val downloadedFile = File(targetPath)
if (downloadedFile.length() != expectedContentSize) {
throw IOException("Downloaded file size does not match the expected size.")
}
if (expectedMd5 != null) {
val actualMd5 = calculateMd5(downloadedFile)
if (actualMd5 != expectedMd5) {
throw IOException("MD5 checksum verification failed.")
}
}
}
// MD5校验函数示例
fun calculateMd5(file: File): String {
val digest = MessageDigest.getInstance("MD5")
FileInputStream(file).use { fis ->
val buffer = ByteArray(8192)
var bytesRead: Int
while (fis.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
}
return digest.digest().fold("") { str, it -> str + "%02x".format(it) }
}
通过上述步骤,我们可以在文件分块下载过程中处理各种异常情况,确保程序的健壮性。
每一步都应有适当的异常处理逻辑,包括但不限于网络错误、IO错误、服务器错误以及文件完整性校验等。
此外,还应该考虑到用户界面反馈,确保用户能够了解下载的状态和遇到的问题。