Android使用Flow检测版本升级自动下载安装

2021-08-25 10:20:38 浏览数 (1)

本文长度为8342,预计阅读10分钟

前言

检测新的版本升级这个好几年前就做过了,不过最近项目中要移植过来,因为原来直接在别的项目中做的,不方便直接拷贝,所以准备做个Demo移植过来,并介绍下怎么实现的。《学习|Android检测并自动下载安装包(Kotlin)》以前这篇用的AsyncTask的方式下载过,现在AsyncTask慢慢废弃了,所以本篇也是初学Flow后的第一次体验使用。

实现效果

代码实现

服务端

因为我后端一直用的Windows,后端的布署也是用的IIS,所以后端这里找了一个现成的WebAPi,完全不需要代码,直接配置一下即可。

配置Android升级这块需要增加两个文件,一个是检测升级的文件,简单点直接用txt的即可,另一个就是需要下载的新的APK包。

01

创建目录及检测文件

首先在D盘的IISTest下新建一个名为Download文件夹目录

然后在Download文件夹下创建一个upgrade.txt的升级检测文件,另外再先拷贝一个apk,随便什么都可以,用于测试是否可以下载的。

upgrade.txt里面是一串JSON的数据,其中versionCode和versionName是android端的版本,appdownloadurl是APK的下载路径,所以真正的APK下载路径可以从这里再获取,instructions是更新说明,upgradetype可以自己设置,我这里0代表正常升级,1设置为强制升级

代码语言:javascript复制
{"appdownloadurl":"http://localhost:8027/download/ScanCmp47.apk",
"instructions":"2021-07-12  V1.47 更新说明:修改采购验收初始数量都为0。",
"versionName":"1.47",
"versionCode":47,
"upgradetype":0}

02

配置IIS

目录和文件创建好后,接下来就是配置IIS了,这里我直接用了前阵子《.NET5 Blazor初探》做的Demo在本机发布的网站。

添加虚拟目录,在Blazor中右键选择添加虚拟目录,

03

修改MIME类型

要实现通过网页直接访问我们创建的txt文件和android的.apk文件,需要在网站的MIME类型中进行修改

找到刚才的Blazor网站,点击右边的MIME类型

添加txt的类型,文件扩展名为.txt,MIME类型为text/plain

android安装包的添加时文件的扩展名为.apk,MIME类型为application/vnd.android

04

测试访问

添加完MIME类型后,重启一下IIS,来测试下是否可以成功了,Blazor的网站配置的端口为8027,由于是本机,所以网址直接是localhost

测试检测升级信息

http://localhost:8027/download/upgrade.txt

测试android的apk包

http://localhost:8027/download/ScanCmp47.apk

输入apk包的地址后直接弹出迅雷的下载,说明都没问题了

如果访问中出现500的情况,可以考虑txt文件和apk的那个Download的目录权限问题,修改一下权限为完全访问。

Android端代码

Andriod这里新创建了一个Project,由于整个项目的代码有点多,所以这里直接说核心的东西,完整的代码会在文章最后GitHub中列出来。

#

整体介绍

1

网络通讯用的retrofit2

2

类的JSON用的GSON

3

下载时的状态显示更新用的kotlin Flow,这也是我第一次用Flow排坑也用了些时间,不过使用起来确实感觉简单好多

01

定义版本类和下载文类

Download下载文件类

代码语言:javascript复制
package dem.vaccae.autoupgradedemo.bean

import java.io.File

/**
 * 作者:Vaccae
 * 邮箱:3657447@qq.com
 * 创建时间:19:32
 * 功能模块说明:
 */
class Download {
    //下载进度
    var processvalue = 0
    //下载状态 0:未开始  1:下载中  2:下载完  -1:异常
    var state = 0;
    //文件
    var file: File? = null
    //信息
    var msg: String = ""
}

版本检测类

代码语言:javascript复制
package dem.vaccae.autoupgradedemo.bean

/**
 * 作者:Vaccae
 * 邮箱:3657447@qq.com
 * 创建时间:11:17
 * 功能模块说明:
 */
class UpGrade {
    //版本号
    var versionCode = 1
    //版本名称
    var versionName = ""
    //更新说明
    var instructions = ""
    //更新类型 0-普通更新  1-强制更新
    var upgradetype = 0
    //APP下载地址
    var appdownloadurl = ""
}

02

retrofit2封装类

一个retrofitAPIManager类,基于retrofit2的http通讯类,这个是很久前用JAVA写的,就直接复制过来了

代码语言:javascript复制
package dem.vaccae.autoupgradedemo.net;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.TimeUnit;

import okhttp3.Cookie;
import okhttp3.CookieJar;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;

/**
 * Created by Vaccae on 2016-12-06.
 * 获取Retrofit类用于Http通信
 */

public class retrofitAPIManager<T> {
    //基本URL地址
    public static String SERVER_URL = "url";
    //Cookies类型  0-每次注册时登记   1-按每次访问的URL登记
    public static int Cookiestype = 0;
    //Cookies类型如果为每次注册登记时用到检索关键前
    public static String Cookiecontains = "login";
    //Cookies类型如果为每次注册登记时用到Key
    public static String CookiesKey = "SumSoft";

    public static<T> T provideClientApi(Class<T> tClass) {
        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl(SERVER_URL)
                .addConverterFactory(GsonConverterFactory.create())
                .client(genericClient())
                .build();
        return retrofit.create(tClass);
    }

    //获取OkHttpClient
    public static OkHttpClient genericClient() {
        OkHttpClient httpClient=new OkHttpClient.Builder()
                .cookieJar(new CookieJar() {
                    private final HashMap<String, List<Cookie>> cookieStore=new HashMap<>();

                    @Override
                    public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
                        //根据类型来判断存入的Cookies用于后面读取用
                        if (Cookiestype == 0) {
                            //判断url里面是注册的更新Key
                            if (url.toString().contains(Cookiecontains)) {
                                cookieStore.put(CookiesKey, cookies);
                            }
                        } else {
                            cookieStore.put(url.toString(), cookies);
                        }

                    }

                    @Override
                    public List<Cookie> loadForRequest(HttpUrl url) {
                        List<Cookie> cookies;
                        if (Cookiestype == 0) {
                            cookies=cookieStore.get(CookiesKey);
                        } else {
                            cookies=cookieStore.get(url.toString());
                        }
                        return cookies != null ? cookies : new ArrayList<Cookie>();
                    }
                })
                .connectTimeout(1000, TimeUnit.MILLISECONDS)
                .build();

        return httpClient;
    }
}

定义一个接口类retrofitUpGrade,存放检测版本和下载文件的两个API

代码语言:javascript复制
package dem.vaccae.autoupgradedemo.net

import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Streaming
import retrofit2.http.Url

/**
 * 作者:Vaccae
 * 邮箱:3657447@qq.com
 * 创建时间:12:46
 * 功能模块说明:
 */
interface retrofitUpGrade {
    //检测服务器系统版本号
    @GET("download/upgrade.txt")
    fun ChkUpgrade(): Call<ResponseBody>

    //下载更新包
    @Streaming
    @GET
    fun DownLoadFile(@Url fileUrl:String):Call<ResponseBody>
}

03

核心类DownloadManager

代码语言:javascript复制
package dem.vaccae.autoupgradedemo.dl

import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import dem.vaccae.autoupgradedemo.bean.Download
import dem.vaccae.autoupgradedemo.bean.UpGrade
import dem.vaccae.autoupgradedemo.net.retrofitAPIManager
import dem.vaccae.autoupgradedemo.net.retrofitUpGrade
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import okhttp3.ResponseBody
import retrofit2.Call
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStream

/**
 * 作者:Vaccae
 * 邮箱:3657447@qq.com
 * 创建时间:18:07
 * 功能模块说明:
 */
class DownloadManager {

    //获取通讯实例
    private fun GetClientAPI(url: String): retrofitUpGrade? {
        //设置通讯的BaseUrl
        retrofitAPIManager.SERVER_URL = "http://$url/"
        return retrofitAPIManager.provideClientApi(retrofitUpGrade::class.java)
    }

    suspend fun download(mUrl: String, mFilepath: String): Flow<Download> {
        //定义下载类
        var dlres = Download()

        lateinit var inputStream : InputStream
        lateinit var outputStream: OutputStream
        val fileReader = ByteArray(4096)
        val apkfile = File(mFilepath)

        return flow {
            //如果存在先删除再下载
            if (apkfile.exists()) {
                var result = apkfile.delete()
                if (!result) {
                    throw Exception("存储路径下的同名文件删除失败!")
                }
            }

            //获取通信实例
            val clientAPI: retrofitUpGrade? = GetClientAPI(mUrl)
            clientAPI?.let {
                //定义系统版本通讯Call
                val callupgrade: Call<ResponseBody> = it.DownLoadFile(mUrl);
                val body = callupgrade.execute().body()

                body?.let {

                    val fileSize: Long = it.contentLength()
                    var fileSizeDownloaded: Long = 0

                    inputStream = it.byteStream()
                    outputStream = FileOutputStream(apkfile)
                    var oldProgress = 0
                    var newprogress = 0
                    while (true) {
                        //此处加了10毫秒延时,可以防止下载没有完成的情况
                        Thread.sleep(10)
                        val read = inputStream.read(fileReader)
                        if (read == -1) {
                            break
                        }
                        outputStream.write(fileReader, 0, read)
                        fileSizeDownloaded  = read.toLong()
                        newprogress = (fileSizeDownloaded * 1.0 / fileSize * 100).toInt()
                        Log.i("process", newprogress.toString())
                        if (oldProgress != newprogress) {
                            dlres.state = 1
                            dlres.processvalue = newprogress
                            Log.i("dlresprocess", dlres.processvalue.toString())
                            //发送当前进度
                            emit(dlres)
                        }
                        oldProgress = newprogress
                    }
                    outputStream.flush()

                    inputStream.close()
                    outputStream.close()
                    //下载完成
                    dlres.state = 2
                    dlres.msg = "下载完成"
                    dlres.file = apkfile
                    emit(dlres)
                }
            }
        }.catch {
            dlres.state = -1
            dlres.msg = it.message.toString()
            emit(dlres)
            throw it
        }.conflate()
        //conflate() 对应 LATEST 策略,如果缓存池满了,新数据会覆盖老数据
    }

    //使用协程时需要加关键字suspend
    suspend fun ChkUpGrade(url: String): Flow<UpGrade?> {
        var upgrade: UpGrade? = null

        return flow {
            //获取通信实例
            val clientAPI: retrofitUpGrade? = GetClientAPI(url)
            clientAPI?.let {
                //定义系统版本通讯Call
                val callupgrade: Call<ResponseBody> = it.ChkUpgrade();
                val rsp = callupgrade.execute()
                //判断返回体是否为null,如果是空返回参数Logininfo信息
                if (rsp.body() == null) {
                    throw Exception(rsp.message())
                } else {
                    //解析收到的JSON数据
                    val json = rsp.body()!!.string()
                    upgrade = Gson().fromJson(json, object : TypeToken<UpGrade?>() {}.type)
                }
            }
            emit(upgrade)
        }.catch { throw it }
    }

}

里面两个核心函数都是用到了Flow,因为在kotlin中使用协程,所以两个方法的前面要加上suspend 。

MainActivity中调用检测升级方法

代码语言:javascript复制
   fun CheckUpGrade(url:String){
        GlobalScope.launch(Dispatchers.Main) {
            try {
                var item = DownloadManager().ChkUpGrade(url)
                    .flowOn(Dispatchers.IO)
                    .first()

                item?.let {
                    if(it.versionCode > versionCode) {
                        val updateIntent =
                            Intent(this@MainActivity, DownloadActivity::class.java)
                        updateIntent.putExtra("url", it.appdownloadurl)
                        updateIntent.putExtra("filename", dlfilename)
                        updateIntent.putExtra("msg", "新版本:${it.versionCode}")
                        startActivity(updateIntent)
                    }
                }
            } catch (e: Exception) {
                Toast.makeText(this@MainActivity,e.message.toString(),Toast.LENGTH_SHORT).show()
            }
        }

    }

上面中判断版本号大于当前程序的版本号时,直接启动Download的Activity页面进行下载。

DownloadActivity中调用下载的方法

代码语言:javascript复制
    private fun startdownload() {
        try {
            val path =
                Environment.getExternalStorageDirectory().absolutePath   File.separator   "SUM"   File.separator
            //安装包路径
            val updateDir = File(path)
            //创建文件夹
            if (!updateDir.exists()) {
                updateDir.mkdirs()
            }

            val localpath: String = path   filename

            GlobalScope.launch(Dispatchers.Main) {
                DownloadManager().download(downloadurl, localpath)
                    .flowOn(Dispatchers.IO)
                    .onStart {
                        btndo.visibility = View.GONE
                        progress.progress = 0
                    }
                    .collect {
                        when (it.state) {
                            //下载中
                            1 -> {
                                tvstatus.text =
                                    "正在下载"   downloadmsg   "rn当前进度..... ${it.processvalue}%"
                                progress.progress = it.processvalue
                            }
                            //下载完成
                            2 -> {
                                val file = it.file
                                tvstatus.text = downloadmsg   it.msg
                                btndo.visibility = View.VISIBLE
                                btndo.text = "点击安装"
                                btndo.setOnClickListener {
                                    val intent = Intent(Intent.ACTION_VIEW)
                                    val uri: Uri
                                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                                        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
                                        uri = FileProvider.getUriForFile(
                                            applicationContext,
                                            applicationContext.packageName   ".provider",
                                            file!!
                                        )
                                    } else {
                                        uri = Uri.fromFile(file)
                                    }

                                    intent.setDataAndType(
                                        uri,
                                        "application/vnd.android.package-archive"
                                    )

                                    startActivity(intent)
                                    finish()
                                }
                            }
                            //下载出错
                            -1 -> {
                                tvstatus.text = "下载"   downloadmsg   "失败!rn"   it.msg
                                btndo.visibility = View.VISIBLE
                                btndo.text = "关闭窗口"
                                btndo.setOnClickListener {
                                    finish()
                                }
                            }
                            else -> {
                                tvstatus.text = "异常"
                                btndo.visibility = View.VISIBLE
                                btndo.text = "关闭窗口"
                                btndo.setOnClickListener {
                                    finish()
                                }
                            }
                        }
                    }
            }
        } catch (e: Exception) {
            Toast.makeText(this@DownloadActivity, e.message.toString(), Toast.LENGTH_SHORT)
                .show()
        }
    }

上面的下载方法中,在循环下载的过程中通过emit来推送当前的数据进集合中,而调用的时候直接在.collect的里面显示即可,用起来确实很方便。

Flow注意事项

  1. flow 构建器函数会创建数据流;emit 函数发送新值至数据流;map函数修改数据流;collect函数收集数据流;catch函数捕获异常。
  2. map等属于中间运算符,可在应用于数据流时,设置一系列暂不执行的链式运算,留待将来使用值时执行。仅将一个中间运算符应用于数据流不会启动数据流收集。
  3. collect等终端运算符可触发数据流开始监听值。由于 collect 是挂起函数,因此需要在协程中执行。
  4. catch函数只能捕获上游的异常,无法捕获下游的异常。
  5. catch函数捕获到异常后,collect函数无法执行。可以考虑通过catch函数执行emit操作处理后续逻辑。

Flow的用法这几天也是看了不少文章,算是简单入门了,推荐《Kotlin Flow场景化学习》

04

相关配置

自动下载文件后并提示安装,需要有访问存储文件的权限,安装其它app的权限,网络权限,所以Manifest配置里要加入

代码语言:javascript复制
    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS" />

文件存储的位置在高版本的Android中还需要自己设置,所以在res下面加入了一个file_paths.xml的配置文件

代码语言:javascript复制
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <!--最终设置里面NAME和PATH都要和文件夹的名称一样,才会找到对应的文件-->
    <external-path name="SUM" path="SUM"/>
</paths>

回到Manifest中也需要在application下面加入provider

代码语言:javascript复制
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:networkSecurityConfig="@xml/network_security_config"
        android:requestLegacyExternalStorage="true"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.AutoUpgradeDemo">

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

android6.0后还需要动态申请权限,在MainActivity中加入

代码语言:javascript复制
    //region 动态申请权限
    companion object {
        private const val REQUEST_CODE_PERMISSIONS = 10
        private val REQUIRED_PERMISSIONS = arrayOf(
            Manifest.permission.WRITE_EXTERNAL_STORAGE,
            Manifest.permission.READ_EXTERNAL_STORAGE
        )
    }

    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                CheckUpGrade(url)
            } else {
                Toast.makeText(this, "未开启权限.", Toast.LENGTH_SHORT).show()
            }
        }
    }
    //endregion

这样就差不多了,下面是整个源码的目录

源码地址

https://github.com/Vaccae/AndroidAutoUpGrade.git

0 人点赞