利用WorkManager实现apk下载

jetpack加入WorkManager后,后台的实现基本上实现起来非常方便。

首先,我们需要获取到新版本信息,然后如果需要更新就下载新版本的apk,2个串行的worker。

app客户端部分

去年写的下载客户端实现

核验版本信息的worker示例:

class VerifyVersionWorker(context: Context, parameters: WorkerParameters) :
    CoroutineWorker(context, parameters) {
    override suspend fun doWork(): Result {
        return try {
        //根据官方推荐的mvvm架构,这里应该调用Repository发起network,然后返回livedata
        //worker本身运行于子线程,方便就直接用了
            val versionInfo = NetWork.getVersionInfo()
            // AppUtils.compareVersion()算法可以查询老的下载示例
            val outputData = if (versionInfo.versionCode > AppUtils.getAppVersionCode() ||
                AppUtils.compareVersion(versionInfo.versionName, AppUtils.getAppVersionName()) == 1
            ) {
                workDataOf(KEY_URL to versionInfo.url)
            } else {
            //公司工控屏是安卓6.0,所以还用getExternalStoragePublicDirectory
                val file =
                    File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path)
                //如果最新版本 清空下载目录,url传null给下一个(手机app别这么干)
                val walk = file.walk()
                walk.iterator().forEach { file ->
                    if (file.isFile) {
                        file.delete()
                    }
                }
                workDataOf(KEY_URL to null)
            }
            Result.success(outputData)
        } catch (e: Exception) {
            e.printStackTrace()
            //失败会启动WorkManager重试策略
            Result.failure()
        }
    }
}

下载文件worker示例:

class DownloadWorker(context: Context, parameters: WorkerParameters) :
    CoroutineWorker(context, parameters) {

    private val notificationManager =
        context.getSystemService(Context.NOTIFICATION_SERVICE) as
                NotificationManager

    override suspend fun doWork(): Result {
    //url null直接成功
        val srcUrl = inputData.getString(KEY_URL)
            ?: return Result.success()
        val fileName = srcUrl.substring(srcUrl.lastIndexOf("/"))
        val outputFileUri = """${
            Environment.getExternalStoragePublicDirectory(
                Environment.DIRECTORY_DOWNLOADS
            ).path}$fileName"""
        val file = File(outputFileUri)
        if (file.exists()){
        //用户未安装文件而已
            Log.i("DownloadWorker", "文件已下载成功")
            //这是一个全局的通知消息队列,继承自LiveData<T>,重写postValue入队
            //出队是一个可挂起的协程,延时递归清空队列显示消息。$NEW_VERSION_TIP固定字段会被监控。
            Application.msg.postValue("$NEW_VERSION_TIP$outputFileUri")
            return Result.success()
        }
        LogUtils.i("DownloadWorker", "$srcUrl to $outputFileUri")
        // Mark the Worker as important
        val progress = "Starting Download"
        setForeground(createForegroundInfo(progress))
        return withContext(Dispatchers.IO){download(srcUrl, outputFileUri)}

    }

    private suspend fun download(srcUrl: String, outputFileUri: String): Result {
        val partNameUri = outputFileUri.replace(".apk",".park")
        //.part 保存未下载完成的文件
        val file = File(partNameUri)
        val downloadedLength = if (file.exists()) {
             file.length()
        } else 0L
        //val contentLength = getContentLength(srcUrl)
        val client = OkHttpClient.Builder()
            .connectTimeout(5, TimeUnit.SECONDS)
            .readTimeout(5, TimeUnit.SECONDS)
            .build()
        val request = Request.Builder()
            .addHeader("RANGE", "bytes=$downloadedLength-")
            .url(srcUrl) //
            .build()
        //val response = client.newCall(request).execute()
        val response = client.newCall(request).execute()
        if (!response.isSuccessful) {
            return Result.retry()
        }
        response.body?.let { responseBody ->
            val contentLength = responseBody.contentLength()
            LogUtils.i("DownloadWorker", "$contentLength")
            responseBody.byteStream().use { inputStream ->
                RandomAccessFile(partNameUri, "rw").use { randomAccessFile ->
                    randomAccessFile.seek(downloadedLength)
                    val buf = ByteArray(REQUEST_BUFFER_LEN)
                    var rLen = 0
                    var total = downloadedLength
                    // kotlin IO有更优雅的写法,这里为了满足断点续传
                    while (inputStream.read(buf).also { rLen = it } != -1){
                        total += rLen;
                        randomAccessFile.write(buf, 0, rLen)
                        // setForeground 调用台频繁会导致ui卡顿
                        //setForeground(createForegroundInfo((total * 100 / contentLength).toString()))
                    }
                    file.renameTo(File(outputFileUri))
                    Application.msg.postValue("$NEW_VERSION_TIP$outputFileUri")
                }
            }
        }
        // Downloads a file and updates bytes read
        // Calls setForegroundInfo() periodically when it needs to update
        // the ongoing Notification
        return Result.success()
    }


    // Creates an instance of ForegroundInfo which can be used to update the
    // ongoing notification.
    private fun createForegroundInfo(progress: String): ForegroundInfo {
        LogUtils.i("DownloadWorker", progress)
        val id = applicationContext.getString(R.string.notification_channel_id)
        val title = applicationContext.getString(R.string.notification_title)
        val cancel = applicationContext.getString(R.string.cancel_download)
        // This PendingIntent can be used to cancel the worker
        val intent = WorkManager.getInstance(applicationContext)
            .createCancelPendingIntent(getId())

        // Create a Notification channel if necessary
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            createChannel()
        }

        val notification = NotificationCompat.Builder(applicationContext, id)
            .setContentTitle(title)
            .setTicker(title)
            .setContentText(progress)
            .setSmallIcon(R.drawable.ic_work_notification)
            .setOngoing(true)
            // Add the cancel action to the notification which can
            // be used to cancel the worker
            .addAction(android.R.drawable.ic_delete, cancel, intent)
            .build()

        return ForegroundInfo(22, notification)
    }

//8.0以上需要适配渠道
    @RequiresApi(Build.VERSION_CODES.O)
    private fun createChannel() {
        // Create a Notification channel
    }
}

附带一句 kotlin IO 中的copyTo() 可以实现非常优雅的复制文件
baseActivity观察到$NEW_VERSION_TIP时:

else if(it.contains(NEW_VERSION_TIP)) {
                val filePath = it.replace(NEW_VERSION_TIP, "")
                GlobalScope.launch(Dispatchers.IO) {
                // 没有系统签名的,需要弹窗申请安装app
                    val res = async { Jni.exec("pm install -r -d $filePath") }
                    if (!res.await()) {
                        withContext(Dispatchers.Main) {
                            installApk(filePath)
                        }
                    }
                }
            }

Jni 直接调用system执行命令即可,你也可以用java执行命令

worker 发起

private val constraintsWifi = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.UNMETERED)
        .build()

public fun verifyVersion() {
        WorkManager.getInstance(CpnirApplication.context).cancelAllWorkByTag("verify_version_worker")
        WorkManager.getInstance(CpnirApplication.context).cancelAllWorkByTag("download")
        val verifyVersion = OneTimeWorkRequestBuilder<VerifyVersionWorker>()
            .addTag("verify_version_worker")
            .setConstraints(constraintsWifi)
            .setInitialDelay(15, TimeUnit.SECONDS)
            .setBackoffCriteria(
                BackoffPolicy.LINEAR,
                OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
                TimeUnit.MILLISECONDS
            )
            .build()
        val download = OneTimeWorkRequestBuilder<DownloadWorker>()
            .addTag("download")
            .setConstraints(constraintsWifi)
            .setInputMerger(OverwritingInputMerger::class.java)
            .setInitialDelay(1, TimeUnit.SECONDS)
            .setBackoffCriteria(
                BackoffPolicy.LINEAR,
                OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
                TimeUnit.MILLISECONDS
            )
            .build()
        WorkManager.getInstance(CpnirApplication.context).beginWith(verifyVersion).then(download).enqueue()
    }

服务端部分

旧版本的服务端

旧版本采用spring boot 返回一个字符串,然后从字符串文件名读取版本信息,这样每次的文件名都很重要。新版本我们直接返回一个json,并且采用的spring cloud的项目(毕竟把公司的服务端升级成cloud分布式了)

json对象大致(你可以携带更多比如分支等信息):

/**
 * @author markrenChina
 * @param versionName 版本号
 * @param versionCode 版本码
 * @param url 静态资源地址
 */
data class ResponseVersionInfo (
    val versionName : String,
    val versionCode : Int,
    val url: String
        )

在cloud 聚合项目中整加一个微服务项目,省略一些基础步骤(记得用netty)。接着建一个RestController提供PostMapping,大致如下:

@RestController
@RefreshScope
@RequestMapping(path = ["/demo"],consumes = [APPLICATION_JSON_VALUE],produces = [APPLICATION_JSON_VALUE])
public class Demo{

   @Value("\${demot.versionName}")
   private val versionName: String = ""

   @Value("\${demot.versionCode}")
   private val versionCode: Int = 0

   @Value("\${demo.url}")
   private val url: String = ""

   @PostMapping("version")
   fun versionInfo(@RequestBody request: Mono<RequestVersionInfo>): Mono<ResponseVersionInfo> {
       return request.filter {
       //过滤条件自己设置,这里只是简单示例
           abs(System.currentTimeMillis() - it.Time) < 60000
                   && it.AppId == "XXXXXXXXXXXXXXX"
                   && it.Key == "XXXXXXXXXXXXX"
                   && it.Sign == SignUtils.encode("XXXXXXXXX")
       }.map {
           ResponseVersionInfo(
               versionName,
               versionCode,
               url
           )
       }
   }

}

配置nacos,在配置中心加入demo下的versionName,versionCode,url。配置信息多了可以上数据库,推荐nosql类型。
nacos 有个好处就是发布新版本非常方便。

配置gateway网关,在网关负载均衡并且重写url。

ok,一个自动更新项目就这样完成了!终于把以前写的代码优雅了起来!还有优化的地方,请留言!

上一篇:jetpack之workmanager的基本使用


下一篇:MySQL分库备份与分表备份