OkHttp3/EventBus 实现断点续传/下载

OkHttp3/EventBus 实现断点续传/下载_第1张图片

断点续传/下载,在网络情况不好的时候,可以在断开连接以后,仅继续获取部分内容。假如手机在下载文件的时候下载了80%,某些原因断网了,如果不支持范围请求,那就只有被迫重头开始下载。但是如果有范围请求的加持,就只需要下载最后 5% 的资源,避免重新下载。

记录 App更新的几个主要功能模块,包含

  • Apk文件下载和断点续传
  • Apk安装,需要兼容 android 7.0
  • Android 8.0 未知权限授权
  • 拓展

基本原理

java.io.RandomAccessFile

断点续传/下载需要使用到 java.io.RandomAccessFile 类,RandomAccessFile 的实例支持读取和写入随机访问文件,它也可以 seek(long pos) 设置从此文件的开头开始测量的文件指针偏移量,在该位置进行下一次读取或写入操作。简单点说就是可以通过seek(long pos)方法跳过pos字节开始写入字节。

HTTP 的范围请求

是否支持范围请求

同时还需要,在HTTP/1.1中,可以声明了一个响应头部 Access-Ranges 来标记是否支持范围请求,它只有一个可选参数 bytes,
OkHttp3/EventBus 实现断点续传/下载_第2张图片
例如这里给了一个下载APK的响应头,可以看到它是有 Accept-Ranges:bytes来标记的,有此 标记标识当前资源支持范围请求。

使用范围请求

如果已经确定双端都支持范围请求,我们就可以在请求资源的时候使用它。

所有的文件最终都是存储在磁盘或者内存中的字节,对于待操作的文件可以将其以字节为单位分割。这样只需要 HTTP 支持请求该文件从 n 到 n+x 这个范围内的资源,就可以实现范围请求了。

HTTP/1.1 中定义了一个 Ranges 的请求头,来指定请求实体的范围。它的范围取值是在 0-Content-Length 之间,使用 - 分割。。

例如已经下载了 1000 bytes 的资源内容,想接着继续下载之后的资源内容,只要在 HTTP 请求头部,增加 Ranges:bytes=1000- 就可以了。
Range 还有几种不同的方式来限定范围,可以根据需要灵活定制:

  • 500-1000:指定开始和结束的范围,一般用于多线程下载。
  • 500- :指定开始区间,一直传递到结束。这个就比较适用于断点续传、或者在线播放等等。
  • -500:无开始区间,只意思是需要最后 500 bytes 的内容实体。
  • 100-300,1000-3000:指定多个范围,这种方式使用的场景很少,了解一下就好了。

来通过一个实例来验证一下

断点续传/下载

请求指定范围数据

几个地方为重点,网络请求添加了一个.addHeader("Range", "bytes=$currentFileLength-"),currentFileLength为当前文件字节大小。比如当前需要下载的文件大小为1000个字节,.addHeader("Range", "bytes=500-")表示只请求服务器500-1000的字节,后续回调中的response.body().contentLength()大小为 500

val request = Request.Builder()
                .addHeader("Range", "bytes=$currentFileLength-")
                .url(apkUrl)
                .build()
// 创建RandomAccessFile实例,并设置跳过currentFileLength个字节
val randomAccessFile = RandomAccessFile(apkFile, "rw")
randomAccessFile.seek(currentFileLength)
// apkFile为下载文件的绝对路径

数据写入

var inputStream: InputStream? = null
var bufferedInputStream: BufferedInputStream? = null
var randomAccessFile: RandomAccessFile? = null
try {
    inputStream = responseBody.byteStream()
    bufferedInputStream = BufferedInputStream(inputStream, size)

    // 创建RandomAccessFile实例,并设置跳过currentFileLength个字节
    randomAccessFile = RandomAccessFile(apkFile, "rw")
    randomAccessFile.seek(currentFileLength)

    var len = 0
    while (bufferedInputStream.read(buffer).apply { len = this } != -1) {
      randomAccessFile.write(buffer, 0, len)
      currentFileLength += len.toLong()
      // 通过EventBus回调下载进度
      callbackProgress(currentFileLength, totalLength)
    }
    // 通过EventBus回调下载进度
    callbackProgress(currentFileLength, totalLength)
    Log.d(TAG, "下载完成: ${apkFile.absoluteFile}")
} catch (e: IOException) {
    e.printStackTrace()
    Log.e(TAG, e.message, e)
} finally {
    bufferedInputStream?.close()
    inputStream?.close()
    randomAccessFile?.close()
    responseBody.close()
    call.cancel()
}

回调数据

/**
 * 通过EventBus回调下载进度
 */
private fun callbackProgress(currentLength: Long, totalLength: Long) {
    val progress = (currentLength * 1.0f / totalLength * 100).toInt()

    val hasSubscriberEvent = EventBus.getDefault().hasSubscriberForEvent(Progress::class.java)
    if (hasSubscriberEvent) {
      EventBus.getDefault().post(Progress(progress, currentLength, totalLength))
    }
    Log.d(TAG, "--->>> progress = $progress  $currentLength / $totalLength")
}

// 在需要显示进度的地方,通过EventBus注册和反注册
EventBus.getDefault().register(this)
EventBus.getDefault().unregister(this)

// 接收回调
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEventDownloadProgress(progress: Progress) {
    progress_bar.progress = progress.progress
    progress_text.text = "${progress.progress}%"
}

// Progress实体类
data class Progress(val progress: Int, val currentLength: Long, val totalLength: Long)

权限配置

首先一些必要权限需要添加在AndroidManifest.xml中,在Android 6.0之后一些危险权限需要动态申请,Android 8.0还有一个安装未知应用权限需要请求打开。Android 7.0后 SDCard文件访问需要在AndroidManifest.xml配置......,需要在res/目录下新创建xml目录

// 分别是 网络、SDCard读、SDCard写、API
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

<provider
    android:name="android.support.v4.content.FileProvider"
    android:authorities="${applicationId}.fileProvider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

// file_paths.xml,添加到 res/xml/file_paths.xml
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path
        name="external-path"
        path="." />

    <external-cache-path
        name="external_cache"
        path="." />

    <cache-path
        name="cache"
        path="." />

    <files-path
        name="files_path"
        path="." />

    <external-files-path
        name="external_files_path"
        path="." />

</paths>
interface InstallPermissionCallBack {
    // 已授权
    fun onGranted()
    // 未授权
    fun onDenied()
}

/**
 * 检查权限
 */
fun checkInstallPermission(context: Context, callback: InstallPermissionCallBack) {
    if (hasInstallPermission(context)) {
      callback.onGranted()
    } else {
      openInstallPermissionSetting(context, callback)
    }
}

/**
  * 是否有安装未知来源应用的权限
  */
fun hasInstallPermission(context: Context): Boolean {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
      return context.packageManager.canRequestPackageInstalls()
    } else false
}

/**
 * 检查 android 8.0 及以上是否有未知应用安装权限,无则打开未知应用安装权限授权界面
 */
fun openInstallPermissionSetting(context: Context, callback: InstallPermissionCallBack) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
      val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + context.packageName))
      // 封装startActivityForResult和onActivityResult,用于请求后能接收到回调
      ActivityLauncher.init(context as FragmentActivity).startActivityForResult(intent, object : ActivityLauncher.Callback {
        override fun onActivityResult(resultCode: Int, data: Intent?) {
          if (resultCode == Activity.RESULT_OK) {
            callback.onGranted()
          } else {
            callback.onDenied()
          }
        }
      })
    } else {
      callback.onGranted()
    }
}

/**
  * 安装apk,兼容7.0
  */
fun installApk(context: Context, file: File) {
    val intent = Intent(Intent.ACTION_VIEW)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
      val apkUri = FileProvider.getUriForFile(context, "${context.packageName}.fileProvider", file)
      intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
      intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
    } else {
      intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive")
    }
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    context.startActivity(intent)
}

Activity中onActivityResult封装

import android.support.v4.app.Fragment
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.util.SparseArray
import java.util.*

class RouterFragment : Fragment() {

    private val mCallbacks = SparseArray<ActivityLauncher.Callback>()
    private val mCodeGenerator = Random()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        retainInstance = true
    }

    fun startActivityForResult(intent: Intent, callback: ActivityLauncher.Callback) {
        val requestCode = makeRequestCode()
        mCallbacks.put(requestCode, callback)
        startActivityForResult(intent, requestCode)
    }

    private fun makeRequestCode(): Int {
        var requestCode: Int
        var tryCount = 0
        do {
            requestCode = mCodeGenerator.nextInt(0x0000FFFF)
            tryCount++
        } while (mCallbacks.indexOfKey(requestCode) >= 0 && tryCount < 10)
        return requestCode
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        Log.d("zz", "requestCode = $requestCode  resultCode = $resultCode")
        val callback = mCallbacks.get(requestCode)
        Log.d("zz", "callback = $callback")
        mCallbacks.remove(requestCode)
        callback?.onActivityResult(resultCode, data)
    }

    companion object {
        fun newInstance(): RouterFragment = RouterFragment()
    }
}
import android.content.Context
import android.content.Intent
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentActivity

class ActivityLauncher private constructor(private val context: Context) {

    companion object {
        private const val KEY_FRAGMENT_TAG = "ActivityLauncher"
        fun init(activity: FragmentActivity): ActivityLauncher {
            return ActivityLauncher(activity)
        }
    }

    private fun getRouterFragment(activity: FragmentActivity): Fragment {
        return activity.supportFragmentManager.findFragmentByTag(KEY_FRAGMENT_TAG) ?: let {
            val routerFragment = RouterFragment.newInstance()
            val fragmentManager = activity.supportFragmentManager
            fragmentManager.beginTransaction()
                    .add(routerFragment, KEY_FRAGMENT_TAG)
                    .commitAllowingStateLoss()
            fragmentManager.executePendingTransactions()
            return routerFragment
        }
    }

    fun startActivityForResult(intent: Intent, callback: Callback) {
        (getRouterFragment(context as FragmentActivity) as RouterFragment)
                .startActivityForResult(intent, callback)
    }

    interface Callback {
        fun onActivityResult(resultCode: Int, data: Intent?)
    }

}

怎么使用?

ActivityLauncher.init(context as FragmentActivity).startActivityForResult(intent, object : ActivityLauncher.Callback {
    override fun onActivityResult(resultCode: Int, data: Intent?) {
        if (resultCode == Activity.RESULT_OK) {
          	
        } else {
          	
        }
    }
})

完整代码

Progress

// 用于EventBus回调下载进度
data class Progress(val progress: Int, val currentLength: Long, val totalLength: Long)

Activity onActivityResult()方法回调封装

ActivityLauncher

import android.content.Context
import android.content.Intent
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentActivity

class ActivityLauncher private constructor(private val context: Context) {

    companion object {
        private const val KEY_FRAGMENT_TAG = "ActivityLauncher"
        fun init(activity: FragmentActivity): ActivityLauncher {
            return ActivityLauncher(activity)
        }
    }

    private fun getRouterFragment(activity: FragmentActivity): Fragment {
        return activity.supportFragmentManager.findFragmentByTag(KEY_FRAGMENT_TAG) ?: let {
            val routerFragment = RouterFragment.newInstance()
            val fragmentManager = activity.supportFragmentManager
            fragmentManager.beginTransaction()
                    .add(routerFragment, KEY_FRAGMENT_TAG)
                    .commitAllowingStateLoss()
            fragmentManager.executePendingTransactions()
            return routerFragment
        }
    }

    fun startActivityForResult(intent: Intent, callback: Callback) {
        (getRouterFragment(context as FragmentActivity) as RouterFragment)
                .startActivityForResult(intent, callback)
    }

    interface Callback {
        fun onActivityResult(resultCode: Int, data: Intent?)
    }
}

RouterFragment

import android.support.v4.app.Fragment
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.util.SparseArray
import java.util.*

class RouterFragment : Fragment() {

    private val mCallbacks = SparseArray<ActivityLauncher.Callback>()
    private val mCodeGenerator = Random()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        retainInstance = true
    }

    fun startActivityForResult(intent: Intent, callback: ActivityLauncher.Callback) {
        val requestCode = makeRequestCode()
        mCallbacks.put(requestCode, callback)
        startActivityForResult(intent, requestCode)
    }

    private fun makeRequestCode(): Int {
        var requestCode: Int
        var tryCount = 0
        do {
            requestCode = mCodeGenerator.nextInt(0x0000FFFF)
            tryCount++
        } while (mCallbacks.indexOfKey(requestCode) >= 0 && tryCount < 10)
        return requestCode
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        Log.d("zz", "requestCode = $requestCode  resultCode = $resultCode")
        val callback = mCallbacks.get(requestCode)
        Log.d("zz", "callback = $callback")
        mCallbacks.remove(requestCode)
        callback?.onActivityResult(resultCode, data)
    }

    companion object {
        fun newInstance(): RouterFragment = RouterFragment()
    }
}

UpdateManager

import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.support.v4.app.FragmentActivity
import android.support.v4.content.FileProvider
import android.util.Log
import android.widget.Toast
import com.wazing.myapplication.utils.ActivityLauncher
import okhttp3.*
import org.greenrobot.eventbus.EventBus
import java.io.*

object UpdateManager {

    // 模拟下载地址
    //    const val APK_URL = "http://a7.pc6.com/cx6/pc6_market.apk";
    const val APK_URL = "http://a.gdown.baidu.com/data/wisegame/aaae681f1d26c241/linghunmianju_1013.apk"

    private const val TAG = "UpdateManager"

    private val okHttpClient: OkHttpClient by lazy { OkHttpClient().newBuilder().build() }

    fun downloadFile(context: Context, apkUrl: String) {
        // 根据app包名创建一个目录
        val file = File(Environment.getExternalStorageDirectory(), context.packageName)
        if (!file.exists()) {
            file.mkdir()
        }
        // 根据url指定下载文件的位置,文件名取url / 最后边的名字,并拿到当前文件的大小
        val apkFile = File(file, apkUrl.substring(apkUrl.lastIndexOf("/") + 1))
        var currentFileLength: Long = apkFile.length()

        // 给当前请求添加一个header
        val request = Request.Builder()
                .addHeader("Range", "bytes=$currentFileLength-")
                .url(apkUrl)
                .build()
        // 发起网络请求
        okHttpClient.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {
                Log.e(TAG, "onFailure: " + e.message, e)
            }

            override fun onResponse(call: Call, response: Response) {
                val responseBody = response.body() ?: return

                val size = 128 * 8 * 2
                val totalLength = responseBody.contentLength() + currentFileLength
                val buffer = ByteArray(size)

                var inputStream: InputStream? = null
                var bufferedInputStream: BufferedInputStream? = null
                var randomAccessFile: RandomAccessFile? = null

                try {
                    inputStream = responseBody.byteStream()
                    bufferedInputStream = BufferedInputStream(inputStream, size)

                    // 创建RandomAccessFile实例,并设置跳过currentFileLength个字节
                    randomAccessFile = RandomAccessFile(apkFile, "rw")
                    randomAccessFile.seek(currentFileLength)

                    var len = 0
                    while (bufferedInputStream.read(buffer).apply { len = this } != -1) {
                        randomAccessFile.write(buffer, 0, len)
                        currentFileLength += len.toLong()

                        callbackProgress(currentFileLength, totalLength)
                    }
                    callbackProgress(currentFileLength, totalLength)
                    Log.d(TAG, "下载完成: ${apkFile.absoluteFile}")

                    (context as Activity).runOnUiThread {
                        checkInstallPermission(context, object : InstallPermissionCallBack {
                            override fun onGranted() {
                                installApk(context, apkFile)
                            }

                            override fun onDenied() {
                                Toast.makeText(context, "未授权", Toast.LENGTH_SHORT).show()
                            }
                        })
                    }
                } catch (e: IOException) {
                    e.printStackTrace()
                    Log.e(TAG, e.message, e)
                } finally {
                    bufferedInputStream?.close()
                    inputStream?.close()
                    randomAccessFile?.close()
                    responseBody.close()
                    call.cancel()
                }
            }
        })
    }

    /**
     * 通过EventBus回调下载进度
     */
    private fun callbackProgress(currentLength: Long, totalLength: Long) {
        val progress = (currentLength * 1.0f / totalLength * 100).toInt()

        val hasSubscriberEvent = EventBus.getDefault().hasSubscriberForEvent(Progress::class.java)
        if (hasSubscriberEvent) {
            EventBus.getDefault().post(Progress(progress, currentLength, totalLength))
        }
        Log.d(TAG, "--->>> progress = $progress  $currentLength / $totalLength")
    }

    fun checkInstallPermission(context: Context, callback: InstallPermissionCallBack) {
        if (hasInstallPermission(context)) {
            callback.onGranted()
        } else {
            openInstallPermissionSetting(context, callback)
        }
    }

    /**
     * 是否有安装未知来源应用的权限
     */
    fun hasInstallPermission(context: Context): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            return context.packageManager.canRequestPackageInstalls()
        } else false
    }

    /**
     * 检查 android 8.0 及以上是否有未知应用安装权限,无则打开未知应用安装权限授权界面
     */
    fun openInstallPermissionSetting(context: Context, callback: InstallPermissionCallBack) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + context.packageName))
            ActivityLauncher.init(context as FragmentActivity).startActivityForResult(intent, object : ActivityLauncher.Callback {
                override fun onActivityResult(resultCode: Int, data: Intent?) {
                    if (resultCode == Activity.RESULT_OK) {
                        callback.onGranted()
                    } else {
                        callback.onDenied()
                    }
                }
            })
        } else {
            callback.onGranted()
        }
    }

    /**
     * 安装apk,兼容7.0
     */
    fun installApk(context: Context, file: File) {
        val intent = Intent(Intent.ACTION_VIEW)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            val apkUri = FileProvider.getUriForFile(context, "${context.packageName}.fileProvider", file)
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
            intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
        } else {
            intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive")
        }
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        context.startActivity(intent)
    }

    interface InstallPermissionCallBack {
        fun onGranted()
        fun onDenied()
    }
}

MainActivity

import android.Manifest
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.support.v4.app.ActivityCompat
import android.content.pm.PackageManager
import android.support.v4.content.ContextCompat
import kotlinx.android.synthetic.main.activity_main.*
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (ContextCompat.checkSelfPermission(this,
                        Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this,
                    arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE), 1)
        }

        download_file.setOnClickListener {
            UpdateManager.downloadFile(this, UpdateManager.APK_URL)
        }
    }

    @Subscribe(threadMode = ThreadMode.MAIN)
    fun onEventDownloadProgress(progress: Progress) {
        progress_bar.progress = progress.progress
        progress_text.text = "${progress.progress}%"
    }

    override fun onResume() {
        super.onResume()
        EventBus.getDefault().register(this)
    }

    override fun onPause() {
        super.onPause()
        EventBus.getDefault().unregister(this)
    }
}

AndroidManifest.xml


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.wazing.myapplication">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:networkSecurityConfig="@xml/network"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme"
        tools:ignore="GoogleAppIndexingWarning,UnusedAttribute">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            intent-filter>
        activity>

        <provider
            android:name="android.support.v4.content.FileProvider"
            android:authorities="${applicationId}.fileProvider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        provider>
    application>
manifest>

参考:

https://github.com/square/okhttp

https://segmentfault.com/a/1190000015732010

你可能感兴趣的:(Android,Kotlin)