原创首发
使用Retrofit封装一个DownloadFileService

前言
Q:为什么要使用 Retrofit 封装 DownloadFileService ?
A:作为当前网络请求框架界的扛把子,大多数APP都会集成 Retrofit 框架,所以就对它下手啦!
Retrofit 官网
官网:传送门
添加依赖
在应用或模块的 build.gradle 文件中添加所需工件的依赖项:
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// Kotlin
implementation "androidx.activity:activity-ktx:1.1.0"
// 简化 ViewBinding 的使用
implementation 'com.dylanc:viewbinding-ktx:1.0.0'
启用 ViewBinding 功能
buildFeatures {
viewBinding true
}
项目结构目录展示
开始搞事情
- 在 AndroidManifest.xml 中的 manifest 节点下添加权限
<!--联网权限-->
<uses-permission android:name="android.permission.INTERNET" />
<!--前台服务权限-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
- 在 AndroidManifest.xml 中的 application 节点下添加使用明文传输的配置
android:usesCleartextTraffic="true"
- 在 base 包下创建一个 App 类,该类继承自 Application,然后在 AndroidManifest.xml 中的 application 节点下添加如下属性
android:name=".base.App"
App.kt
import android.app.Application
import android.content.Context
class App : Application() {
override fun onCreate() {
super.onCreate()
context = this
}
/**
* Kotlin 中的伴生对象,和 Java 中的静态变量用法差不多
*/
companion object {
lateinit var context: Context
}
}
- 在 network 包下创建一个 ServiceCreator 单例类
ServiceCreator.kt
import retrofit2.Retrofit
import java.util.concurrent.Executors
/**
* object:Kotlin 中用来声明单例对象的
*/
object ServiceCreator {
// 类似 Java 中的 final 修饰的变量(其实就是常量)
private const val BASE_URL = "https://www.baidu.com/"
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BASE_URL) //base的网络地址
.callbackExecutor(Executors.newSingleThreadExecutor()) //设置线程,如果不设置下载在读取流的时候就会报错
.build()
fun <T> create(serviceClass: Class<T>) = retrofit.create(serviceClass)
/**
* 使用内联函数 inline 关键字 和 泛型实化 reified 关键字对函数的调用进行简化
*
* @return T ServiceCreator.create<DownloadFileApi>() 中的 DownloadFileApi 就是泛型 T
*/
inline fun <reified T> create(): T = create(T::class.java)
}
- 在 download 包下定义 DownloadFileListener 接口
DownloadFileListener.kt
import java.io.File
/**
* 下载文件的事件监听
*/
interface DownloadFileListener {
/**
* 文件下载请求开始前的回调,可以指定是否继续下载(在当前调用的线程运行)
*
* @param file File
* @param fileIsExists Boolean
* @return Boolean 返回 true 表示继续下载,false 表示取消当前下载任务
*/
fun onBeforeBegin(file: File, fileIsExists: Boolean): Boolean
/**
* 文件下载进度更新的回调(在主线程中回调)
*
* @param currentLength Long 当前已下载的文件长度
* @param totalLength Long 需要下载的文件长度
* @param downloadUrl String 需要下载文件的url
*/
fun onProgressChanged(currentLength: Long, totalLength: Long, downloadUrl: String)
/**
* 下载完成回调(在主线程中回调)
*
* @param completeSize Long下载到的文件长度
* @param downloadUrl String 需要下载文件的url
* @param file File 下载成功的文件对象
*/
fun onFinished(completeSize: Long, downloadUrl: String, file: File)
/**
* 文件下载失败的回调(在主线程中回调)
*
* @param file File 下载失败的文件对象
* @param t Throwable? 下载失败抛出的异常
*/
fun onFailure(file: File, t: Throwable?)
}
- 在 download 包下定义 DownloadFileApi 接口,用于构造请求对象
DownloadFileApi.kt
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Streaming
import retrofit2.http.Url
interface DownloadFileApi {
/**
* 注解这个请求将获取数据流,此后将不会这些获取的请求数据保存到内存中,将交与你操作.
*
* @Url 注解:如果传入的是相对地址就和 BASE_URL 进行拼接,如果是绝对地址则本次请求会忽略 BASE_URL,
* 直接使用传入的 url
*/
@Streaming
@GET
fun download(@Url url: String?): Call<ResponseBody?>?
}
- 在 expand 包下创建 Long.kt 文件,对 Long 对象进行扩展
Long.kt
import android.text.format.Formatter
import cn.cqautotest.downloadfile.base.App
/**
* 通过扩展函数将 Long 类型的文件大小转换成字节,千字节,兆字节等形式
*
* @receiver Long
* @return String 将内容大小格式化为字节,千字节,兆字节等形式
*/
fun Long.toFileSize(): String {
return Formatter.formatFileSize(App.context, this)
}
- 在 expand 包下再创建一个 Intent.kt 文件,使用 内联函数 和 泛型实化来简化 Intent 的构造
Intent.kt
import android.content.Context
import android.content.Intent
/**
* 使用 内联函数 和 泛型实化来简化 Intent 的构造
*
* @param context Context 上下文对象
* @return Intent 构造后的 Intent 对象
*/
inline fun <reified T> Intent(context: Context): Intent = Intent(context, T::class.java)
- 在 download 包下创建 DownloadFileRequest 单例类并实现相关逻辑
DownloadFileRequest.kt
import android.os.Handler
import android.os.Looper
import android.util.Log
import cn.cqautotest.downloadfile.network.ServiceCreator
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.File
import java.io.FileOutputStream
import java.lang.RuntimeException
/**
* 下载文件的请求实现类(单例类)
*/
object DownloadFileRequest {
private const val TAG = "DownloadFileRequest"
// 实例化一个运行在主线程中的 Handler ,这个 Handler 接收的 Looper 对象是主线程的 Looper,所以可以用它来更新UI
private val handler = Handler(Looper.getMainLooper())
/**
* 真正进行下载文件的方法
*
* @param downloadUrl String 需要下载文件的url
* @param localFilePath String 本地文件的目录
* @param localFileName String 本地文件的名称
* @param downloadListener DownloadFileListener 文件下载的监听
*/
fun download(
downloadUrl: String,
localFilePath: String,
localFileName: String,
downloadListener: DownloadFileListener
) {
val file = File(localFilePath, localFileName)
val keepDownload = downloadListener.onBeforeBegin(file, file.exists())
// Kotlin 中的 not 函数对运算符进行了重载,运算符重载就是对已有的运算符赋予他们新的含义,重载的修饰符是 operator
if (keepDownload.not()) return
val downloadApi = ServiceCreator.create<DownloadFileApi>()
val execute = downloadApi.download(downloadUrl)
execute?.enqueue(object : Callback<ResponseBody?> {
override fun onResponse(call: Call<ResponseBody?>, response: Response<ResponseBody?>) {
val runCatching = response.body()?.runCatching {
// 需要下载文件的总大小
val totalLength = contentLength()
// 当前已下载的文件长度
var currentLength: Long = 0
val inputStream = byteStream()
val fileOutputStream = FileOutputStream(file)
// 一般都是 1024 ,我把这个值设置大一点是为了下载的速度更快,暂时没有发现什么异常情况
val bytes = ByteArray(totalLength.toInt())
var len = 0
while (len != -1) {
fileOutputStream.write(bytes, 0, len)
fileOutputStream.flush()
currentLength += len
onProgressChanged(downloadListener, currentLength, totalLength, downloadUrl)
len = inputStream.read(bytes)
}
fileOutputStream.flush()
fileOutputStream.close()
inputStream.close()
// 通知文件已经下载完成
onFinished(downloadListener, currentLength, downloadUrl, file)
return
}
runCatching?.isFailure.run {
Log.d(TAG, "onResponse: ====> 去看看打印的堆栈信息吧!")
runCatching?.exceptionOrNull()?.printStackTrace()
onFailure(downloadListener, file, runCatching?.exceptionOrNull())
}
}
override fun onFailure(call: Call<ResponseBody?>, t: Throwable) {
Log.d(TAG, "onResponse: ====> 去看看打印的堆栈信息吧!")
t.printStackTrace()
onFailure(downloadListener, file, t)
}
})
if (execute == null) {
onFailure(
downloadListener, file, RuntimeException("ExecutorCallbackCall is null!")
)
}
}
fun onProgressChanged(
downloadListener: DownloadFileListener,
currentLength: Long,
totalLength: Long,
downloadUrl: String
) {
handler.post {
downloadListener.onProgressChanged(currentLength, totalLength, downloadUrl)
}
}
fun onFinished(
downloadListener: DownloadFileListener,
currentLength: Long,
downloadUrl: String,
file: File
) {
handler.post {
downloadListener.onFinished(currentLength, downloadUrl, file)
}
}
fun onFailure(downloadListener: DownloadFileListener, file: File, t: Throwable?) {
handler.post {
downloadListener.onFailure(file, t)
}
}
}
- 在 download 包下创建 DownloadFileService 类,实现下载时前台 Service 通知显示进度
DownloadFileService.kt
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Environment
import android.os.IBinder
import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi
import cn.cqautotest.downloadfile.R
import cn.cqautotest.downloadfile.expand.toFileSize
import java.io.File
import java.text.DecimalFormat
/**
* 文件下载后台服务
*/
@RequiresApi(Build.VERSION_CODES.O)
class DownloadFileService : Service(), DownloadFileListener {
// lateinit var 关键字用于声明延迟初始化变量,使用该变量前必须确保变量已经初始化
// 可使用 ::notificationBuilder.isInitialized 这种方式判断该变量是否已经初始化
private lateinit var notificationBuilder: Notification.Builder
private lateinit var manager: NotificationManager
private lateinit var appContext: Context
private var isDownloading = false
override fun onCreate() {
super.onCreate()
appContext = applicationContext
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
var downloadPath: String
// Environment.DIRECTORY_DOWNLOADS 这个目录不需要申请存储权限,即使是 Android 11
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).run {
downloadPath = this?.path.toString()
// 如果此字符串为空或仅由空格字符组成,则返回 true
// 调用 isBlank() 函数等同于 downloadPath == null || downloadPath.length == 0 || downloadPath.trim().length == 0
if (downloadPath.isBlank()) downloadPath = cacheDir.path
}
DownloadFileRequest.download(BASE_URL + SUFFIX_URL, downloadPath, "氢流.apk", this)
return super.onStartCommand(intent, flags, startId)
}
override fun onBeforeBegin(file: File, fileIsExists: Boolean): Boolean {
Log.d(TAG, "onBeforeBegin: ===> DownloadFilePath:${file.path}")
Log.d(
TAG,
"onStarted: ====> 文件状态:${if (fileIsExists) "存在 取消下载..." else "不存在 准备开始下载..."} "
)
showNotify()
// return fileIsExists.not()
return true
}
override fun onProgressChanged(
currentLength: Long,
totalLength: Long,
downloadUrl: String
) {
isDownloading = true
// 注意:这里计算出来的是读取文件流的进度,不是实际的下载进度
val progress =
((DecimalFormat("0.00").format(currentLength / (totalLength * 1.0))).toFloat() * 100).toInt()
Log.d(
TAG,
"onProgressChanged: ====> ${progress}% 已下载文件大小:${currentLength.toFileSize()}总文件大小:${totalLength.toFileSize()}"
)
notificationBuilder.run {
setProgress(100, progress, false)
setContentText("进度:${DecimalFormat("0.00%").format((currentLength * 1.0) / (totalLength * 1.0))}")
}
// 发布要在状态栏中显示的通知。如果您的应用程序已经发布了具有相同ID的通知,但尚未取消,则该通知将由更新的信息代替。
manager.notify(1, notificationBuilder.build())
}
override fun onFinished(completeSize: Long, downloadUrl: String, file: File) {
isDownloading = false
Log.d(TAG, "onFinished: ====> 文件总长度:${completeSize.toFileSize()}")
Toast.makeText(appContext, "文件下载完成!", Toast.LENGTH_SHORT).show()
}
override fun onFailure(file: File, t: Throwable?) {
isDownloading = false
// 下载失败,删除这个文件
file.delete()
Log.d(TAG, "onFailure: ====> 文件下载失败...")
Toast.makeText(appContext, "文件下载失败!", Toast.LENGTH_SHORT).show()
}
//展示通知 成为前台服务
private fun showNotify() {
createChannel()
notificationBuilder =
Notification.Builder(this, "1").apply {
// 设置自动取消
setAutoCancel(false)
// 设置通知的标题
setContentTitle(getString(R.string.app_name))
// 设置通知的内容
setContentText("运行中...")
// 添加与通知相关的时间戳(通常是事件发生的时间),简单来说就是描述你什么时候发的通知的
setWhen(System.currentTimeMillis())
// 如果不设置为 true 则不显示上面那个
setShowWhen(true)
// 设置小图标
setSmallIcon(R.mipmap.ic_launcher_round)
// 设置大图标
setLargeIcon(BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher))
// 设置进度值
setProgress(100, 0, false)
}
// 将当前 Service 变成前台 Service
startForeground(1, notificationBuilder.build())
}
//创建channel
private fun createChannel() {
manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
"1",
getString(R.string.app_name),
NotificationManager.IMPORTANCE_LOW
)
channel.lockscreenVisibility = Notification.VISIBILITY_SECRET
// 创建通知渠道
manager.createNotificationChannel(channel)
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "onDestroy: ===> Service 结束了!")
}
companion object {
private const val TAG = "UpdateAppService"
const val BASE_URL = "https://fga1.market.xiaomi.com/"
// const val SUFFIX_URL =
// "download/AppStore/069475966fd89af02f12cc4bee5e806548b41a35b/com.lbe.parallel.apk"
const val SUFFIX_URL =
"download/AppStore/0633bb5eed249493201c30f8c47ed685da20fe05b/com.qingliu.browser.apk"
}
}
- 当然,不要忘记在 AndroidManifest.xml 中的 application 节点下注册 Service 哦~
<service android:name=".download.DownloadFileService" />
- Xml布局
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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=".MainActivity">
<Button
android:id="@+id/btnStartDownFile"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/point"
android:text="开始下载" />
<View
android:id="@+id/point"
android:layout_width="0px"
android:layout_height="0px"
android:layout_centerInParent="true"
android:layout_margin="4dp"
android:visibility="invisible" />
<Button
android:id="@+id/btnStopDownFile"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toEndOf="@id/point"
android:text="停止下载" />
</RelativeLayout>
- 在Activity中使用
MainActivity.kt
import android.os.Bundle
import android.os.Environment
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import cn.cqautotest.downloadfile.databinding.ActivityMainBinding
import cn.cqautotest.downloadfile.expand.Intent
import cn.cqautotest.downloadfile.download.DownloadFileService
import com.dylanc.viewbinding.inflate
class MainActivity : AppCompatActivity() {
private val mBinding by inflate<ActivityMainBinding>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding.btnStartDownFile.setOnClickListener {
startService(Intent<DownloadFileService>(this))
}
mBinding.btnStopDownFile.setOnClickListener {
stopService(Intent<DownloadFileService>(this))
}
}
companion object {
private const val TAG = "MainActivity"
}
}
最终实现效果
总结
在此Demo中,我使用到了Kotlin中的多种语法特性,并在代码中给出了简要的描述。至此一个 DownloadFileService 就封装好啦,同学们还可以对此进行扩展,比如支持多线程下载、断点续传......
Demo项目地址
地址:传送门
请同学们点赞、评论、打赏+关注啦~
本文由
A lonely cat
原创发布于
阳光沙滩
,未经作者授权,禁止转载