0. 由浅到深地阐述文件下载原理


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错误、服务器错误以及文件完整性校验等。

此外,还应该考虑到用户界面反馈,确保用户能够了解下载的状态和遇到的问题。

到此为止,我们对于文件下载由浅到深的理解与实现就完成了。

相关推荐
南宫生7 分钟前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石15 分钟前
12/21java基础
java
拭心15 分钟前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
李小白6623 分钟前
Spring MVC(上)
java·spring·mvc
GoodStudyAndDayDayUp36 分钟前
IDEA能够从mapper跳转到xml的插件
xml·java·intellij-idea
装不满的克莱因瓶1 小时前
【Redis经典面试题六】Redis的持久化机制是怎样的?
java·数据库·redis·持久化·aof·rdb
n北斗1 小时前
常用类晨考day15
java
骇客野人1 小时前
【JAVA】JAVA接口公共返回体ResponseData封装
java·开发语言
yuanbenshidiaos2 小时前
c++---------数据类型
java·jvm·c++
向宇it2 小时前
【从零开始入门unity游戏开发之——C#篇25】C#面向对象动态多态——virtual、override 和 base 关键字、抽象类和抽象方法
java·开发语言·unity·c#·游戏引擎