前言

版本的升级和更新是一个线上App所必备的功能,App的升级安装包主要通过 应用商店 或者 应用内下载 两种方式获得,大部分app这两种方式都会具备,应用商店只需要上传对应平台审核通过即可,而应用内更新一般是通过以下几种方式:

1.集成第三方库如 appupdateX、bugly 的更新功能
2.手动实现

这里自己从网上找了一些资料,使用 Kotlin 结合自己的想法,完整地实现了一个应用内在线更新的功能,该功能使用 DownloadManager 下载安装包,适配 Android6.0 以上所有版本,现也已经成功应用到自己公司平台上了。如果这不能满足大家的高级需求,也能提供思路和方向,万变不离其宗,清晰的思路永远胜过简单的搬运,下面是具体实现:


一、实现思路

1、通过接口获取版本号和安装包下载地址:完美一点的是应该是解析出安装包里面的版本号
2、比较线上的版本和本地版本,弹出升级弹窗:可在这里设置强制更新,不更新退出
3、下载 APK 安装包:显示进度条,通过 DownloadManager 下载,同时会在手机顶部通知栏显示下载进度,也可通过三方框架(比如 Volley、OkHttp、IntentService )的文件下载功能
4、安装升级包:获取权限和不同版本适配

UI效果:

在这里插入图片描述

二、服务端接口

服务端需要提供一个接口,返回下载安装包地址、版本号等信息,Json字符串:

{
  "result": {
    "id": 1,
    "publishTime": "发布时间",
    "name": "app名称",
    "version": "版本号",
    "updateMessage": "更新内容:1.xxx \n 2.xxx",
    "downloadUrl": "下载地址(https://www...com/app名称v4.0.0.apk)"
  },
  "success": true,
  "error": null,
}

对应bean数据类:UpgradeResponse.kt

import androidx.annotation.Keep
/**
 * version: 4.0.0
 * publishTime: 当前时间
 * updateMessage: 1....\n 2....
 */
@Keep
data class UpgradeResponse(
    val id:Int,
    //更新日期
    val publishTime: String?,
    // app名字
    val name: String,

    //服务器版本
    val version: String,

    //app最新版本地址
    val downloadUrl: String,

    //升级信息
    val updateMessage: String?,
)

三、UI页面

主要添加版本号、发布时间、更新内容、进度条、操作按钮等内容,进度条是 Android 自带的控件,默认隐藏,在点击更新后,隐藏按钮,显示进度条,并动态更新进度。好看的样式都可以自己 DIY,例如找一些火箭发射的专用背景图。

弹窗:dialog_upgrade.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="280dp"
    android:layout_height="wrap_content"
    android:background="@drawable/bg_dialog"
    android:orientation="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:orientation="vertical"
        android:background="@drawable/bg_dialog_top"
        android:paddingLeft="20dp"
        android:paddingTop="8dp"
        android:paddingRight="20dp"
        android:paddingBottom="8dp">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="发现新版本"
            android:textColor="@color/white"
            android:textSize="20sp" />


        <TextView
            android:id="@+id/tv_version"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:lineSpacingMultiplier="1.2"
            android:text="版本号:"
            android:textColor="@color/white"
            android:textSize="15sp" />
        <TextView
            android:id="@+id/tv_date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="5dp"
            android:lineSpacingMultiplier="1.2"
            android:text="发布时间:"
            android:textColor="@color/white"
            android:textSize="15sp" />

    </LinearLayout>

    <TextView
        android:id="@+id/tv_feature"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:maxHeight="350dp"
        android:padding="20dp"
        android:text="版本特性:"
        android:textSize="15sp" />

    <View
        android:layout_width="match_parent"
        android:layout_height="0.6dp"
        android:background="@color/api_date_text_color_1"/>
    <LinearLayout
        android:id="@+id/fl_progress"
        android:layout_width="280dp"
        android:layout_height="wrap_content"
        android:gravity="center_vertical"
        android:orientation="horizontal"
        android:padding="10dp"
        android:visibility="gone"
        tools:visibility="visible">

        <!--android:max="100"-->
        <ProgressBar
            android:id="@+id/progressBar"
            style="?android:attr/progressBarStyleHorizontal"
            android:layout_width="210dp"
            android:layout_height="wrap_content"
            android:padding="@dimen/dp_10"
            android:value="0" />

        <TextView
            android:id="@+id/tv_progress"
            android:layout_width="45dp"
            android:gravity="center"
            android:layout_height="wrap_content"
            android:layout_marginStart="5dp"
            android:text="0%" />

    </LinearLayout>

    <LinearLayout
        android:id="@+id/ll_actions"
        android:layout_width="280dp"
        android:layout_height="wrap_content"
        android:gravity="center_vertical"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/tv_cancel"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:padding="20dp"
            android:gravity="center"
            android:text="下次再说"
            android:textSize="16dp" />

        <View
            android:layout_width="0.6dp"
            android:layout_height="match_parent"
            android:background="@color/api_date_text_color_1"/>

        <TextView
            android:id="@+id/tv_upgrade"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:padding="20dp"
            android:gravity="center"
            android:text="立即更新"
            android:textColor="#42cba6"
            android:textSize="16dp" />

    </LinearLayout>
</LinearLayout>

整体白色圆角背景:bg_dialog.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <corners android:radius="6dp"/>
    <solid android:color="@color/api_white"/>
</shape>

上半部分绿色圆角背景:bg_dialog_top.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <corners android:topRightRadius="6dp" android:topLeftRadius="6dp"/>
    <solid android:color="#42cba6"/>
</shape>

三、工具类实现

封装一个用于版本更新的工具类 UpgradeUtil.kt ,单例设计,这时候就体现出了 Kotlin 的简便,只用一个 companion object {} 即可,包含操作方法,如果是 Java 引用 Kotlin 方法,方法前面需要加上 @JvmStatic 注解。

1.检查版本号

@JvmStatic //Java使用该方法
fun checkVersion(apkInfo:UpgradeResponse?) :Boolean{
	if (apkInfo == null) {
	    return false
	}
	//完美一点就是先判断包名是否一致,再判断版本号
	val oldVersion = AppVersionUtils.getVersionCode()//本地版本号
	//本地版本号 = BaseApplication.getContext().getPackageManager().getPackageInfo(BaseApplication.getContext().getPackageName(), PackageManager.GET_CONFIGURATIONS).versionCode
	
	val version=apkInfo.version.filter { it.isDigit() }.toInt()  //filter过滤器过滤字符,isDigit()只提取数字,防止其他字符混入
	if ( version > oldVersion) {
	    return true
	}
	return false
}

获取本地版本号的工具类(Java):

AppVersionUtils.java

import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import com.auroral.api.BaseApplication;

public class AppVersionUtils {

    private static PackageInfo mPackageInfo;

    /**
     * get app version name.
     *
     * @return version name.
     */
    public static String getVersionName() {
        getPackageInfo();
        return mPackageInfo.versionName;
    }

    private static void getPackageInfo() {
        if (mPackageInfo == null) {
            try {
                mPackageInfo = BaseApplication.getContext().getPackageManager()
                        .getPackageInfo(BaseApplication.getContext().getPackageName(), PackageManager.GET_CONFIGURATIONS);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * get app version code.
     *
     * @return version code.
     */
    public static int getVersionCode() {
        getPackageInfo();
        return mPackageInfo.versionCode;
    }
}

---------------------2023年7月11日 更新--------------------------

BaseApplication类:

public class public class BaseApplication extends Application {
    private static Context context;
    private static MMKV mmkv;

    public static int statusBarHeight = 0;

    @Override
    public void onCreate() {
        super.onCreate();
        context = getApplicationContext();
        MMKV.initialize(this);
        mmkv = MMKV.defaultMMKV();
    }

    public static Context getContext() {
        return context;
    }

    public static MMKV getMMKV() {
        return mmkv;
    }
} 

2.下载apk

DownloadManager 是Android系统自带的下载管理工具,可以很好地进行调度下载。 其下载任务会对应唯一个ID, 此id可以用来去查询下载内容的相关信息,获取下载进度。而跳转安装一般是通过 uri 跳转,uri 主要分为以下两类,两种类型都要考虑进去。

  • content://: 系统提供商的media、downloads,第三方的 fileprovider
  • file:// :旧式file类型的uri

在下载之前先判断是否已经下载过,下载过直接跳转,没下载过下载安装后删除下载任务和文件。主要流程:

1、判断是否下载过apk:下载过直接安装
2、DownloadManager配置
3、获取到下载id
4、动态更新下载进度
5、安装apk:两种uri

具体代码中介绍得很详细:

//下载id
private var downloadId=-1L

//下载apk
@JvmStatic
fun upgradeApk(context: Context, upgradeInfo: UpgradeResponse,view: View,dialog: Dialog){
	//设置apk下载地址:本机存储的download文件夹下
    val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
    //找到该路径下的对应名称的apk文件,有可能已经下载过了
    val file = File(dir, "${upgradeInfo.name}v${upgradeInfo.version}.apk")
    //开辟线程
    MainScope().launch {
        val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
        // 1、判断是否下载过apk
        if (file.exists()) {
            val authority: String = context.applicationContext.packageName+ ".fileProvider"
            // "content://" 类型的uri   --将"file://"类型的uri变成"content://"类型的uri
            val uri = FileProvider.getUriForFile(context, authority, file)
            dialog.dismiss()
            // 5、安装apk, content://和file://路径都需要
            installAPK(context,uri,file)
        }else{
        	// 2、DownloadManager配置
            val request = DownloadManager.Request(Uri.parse(encodeGB( upgradeInfo.downloadUrl)))  //处理中文下载地址
            // 设置下载路径和下载的apk名称
            request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "${upgradeInfo.name}v${upgradeInfo.version}.apk")
            // 下载时在通知栏内显示下载进度条
            request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
            // 设置MIME类型,即设置下载文件的类型, 如果下载的是android文件, 设置为application/vnd.android.package-archive
            request.setMimeType("application/vnd.android.package-archive")
            // 3、获取到下载id
            downloadId = downloadManager.enqueue(request)
            
			// 隐藏按钮显示进度条
            view.ll_actions.visibility= View.GONE
            view.tv_progress.text = "0%"
            view.progressBar.progress = 0
            view.fl_progress.visibility = View.VISIBLE
            
            // 开辟IO线程
            MainScope().launch(Dispatchers.IO) {
            	// 4、动态更新下载进度
                val success = checkDownloadProgress(
                    downloadManager,
                    downloadId,
                    view.progressBar,
                    view.tv_progress,
                    file
                )
                MainScope().launch {
                    if (success) {
                    	// 下载文件"content://"类型的uri ,DownloadManager通过downloadId
                        val uri = downloadManager.getUriForDownloadedFile(downloadId)
                        // 通过downLoadId查询下载的apk文件转成"file://"类型的uri
                        val file= queryDownloadedApk(context, downloadId)
                        dialog.dismiss()
                        // 5、安装apk
                        installAPK(context, uri,file)
                    } else {
                        TastyToast.makeText(context, "下载失败",
                            TastyToast.LENGTH_SHORT, TastyToast.WARNING)
                        if (file.exists()) {
                            // 当不需要的时候,清除之前的下载文件,避免浪费用户空间
                            file.delete()
                        }
                        // 删除下载任务和文件
                        downloadManager.remove(downloadId)
                        // 隐藏进度条显示按钮,重新下载
                        view.fl_progress.visibility = View.GONE
                        view.ll_actions.visibility = View.VISIBLE
                    }
                    cancel()
                }
                cancel()
            }
        }
        cancel()
    }
}

中文路径可能导致乱码找不到下载路径,需要转成GB编码

//中文路径转成GB编码
fun encodeGB(string: String): String{
    //转换中文编码
    val split = string.split("/".toRegex()).toTypedArray()
    for (i in 1 until split.size) {
        try {
            split[i] = URLEncoder.encode(split[i], "GB2312")
        } catch (e: UnsupportedEncodingException) {
            e.printStackTrace()
        }
        split[0] = split[0] + "/" + split[i]
    }
    split[0] = split[0].replace("\\+".toRegex(), "%20") //处理空格
    return split[0]
}

3.安装apk

跳转安装 apk 需要适配不同的安卓版本,Android 6.0-7.0 需要老式的 “file://” 的路径,Android 7.0 以上需要 “content://” 的路径

//调用系统安装apk
private fun installAPK(context: Context, apkUri: Uri,apkFile: File?) {
    val intent = Intent()
  
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    	//安卓7.0版本以上安装
        intent.action = Intent.ACTION_VIEW
        intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
    } else {
    	//安卓6.0-7.0版本安装
        intent.action = Intent.ACTION_DEFAULT
        intent.addCategory(Intent.CATEGORY_DEFAULT)
        intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        apkFile?.let {
            intent.setDataAndType(Uri.fromFile(apkFile), "application/vnd.android.package-archive")
        }
    }
    try {
        context.startActivity(intent)
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

通过 downloadId 获取到 “file://” 的路径

private fun queryDownloadedApk(context: Context, downloadId: Long): File? {
    var targetApkFile: File? = null
    val downloader = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
    if (downloadId != -1L) {
        val query = DownloadManager.Query()
        query.setFilterById(downloadId)
        query.setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL)
        val cur: Cursor? = downloader.query(query)
        if (cur != null) {
            if (cur.moveToFirst()) {
                val uriString: String =
                    cur.getString(cur.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
                if (!TextUtils.isEmpty(uriString)) {
                    targetApkFile = Uri.parse(uriString).path?.let { File(it) }
                }
            }
            cur.close()
        }
    }
    return targetApkFile
}

4.实时更新下载进度

在线程中使用的方法需要带表示 suspend 挂起函数的关键字,通过while循环去读取,监控任务的状态,待状态变成Fail或Success

//检查下载进度
suspend fun checkDownloadProgress(
    manager: DownloadManager,
    downloadId: Long,
    progressBar: ProgressBar,
    progressText: TextView,
    file: File
): Boolean {
    //循环检查,直到状态变成Fail或Success
    while (true) {
        val q = DownloadManager.Query()
        q.setFilterById(downloadId)
        val cursor = manager.query(q)
        if(cursor.moveToFirst()){
            val bytes_downloaded =
                cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
            val bytes_total =
                cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
            val dl_progress = (bytes_downloaded * 100 / bytes_total).toInt()
            progressBar.post {
                progressBar.progress = dl_progress
                progressText.text = "${dl_progress}%"
            }
            when (cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))) {
                DownloadManager.STATUS_SUCCESSFUL -> {
                    return true
                }
                DownloadManager.STATUS_RUNNING, DownloadManager.STATUS_PENDING -> {
                    delay(500)
                }
                else -> {
                    if (file.exists()) {
                        //当不需要的时候,清除之前的下载文件,避免浪费用户空间
                        file.delete()
                    }
                    manager.remove(downloadId)
                    return false
                }
            }
        }else{
            if (file.exists()) {
                //当不需要的时候,清除之前的下载文件,避免浪费用户空间
                file.delete()
            }
            manager.remove(downloadId)
            return false
        }

    }
}

5.完整代码

import android.app.Dialog
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.text.TextUtils
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.FileProvider
import com.auroral.api.utils.AppVersionUtils
import com.sdsmdg.tastytoast.TastyToast
import com.vickn.main.upgrade.bean.UpgradeResponse
import kotlinx.android.synthetic.main.dialog_upgrade.view.*
import kotlinx.coroutines.*
import java.io.File
import java.io.UnsupportedEncodingException
import java.net.URLEncoder

class UpgradeUtil {

    companion object {
    	private var downloadId=-1L
    	
    	// 上述各类方法
    	// ...
	}
}

三、外部使用

最后通过网络接口获取到数据后进行版本判断,显示弹窗。接口请求数据和数据监听这里就不列出来了,其次,下载之前必须先对权限进行检查或获取

具体使用:MainActivity中

private val upgradeDialog by lazy{ Dialog(this, R.style.xxx) } //最原生的Dialog, 对应风格样式
private val view by lazy { LayoutInflater.from(this).inflate(R.layout.dialog_upgrade, null) }

val isNewVersion = checkVersion(data, this@MainActivity)
if (isNewVersion) {
    showUpgradeDialog(data)
}

//显示版本更新弹窗
private fun showUpgradeDialog(upgradeInfo: UpgradeResponse) {
    view.tv_version.text = "版本号:${upgradeInfo.version}"
    upgradeInfo.publishTime?.let {
        val index = TextUtils.lastIndexOf(it, ':')
        val date = it.substring(0, index).replace("T", " ")
        view.tv_date.text = "发布时间:$date"
    }
    //当文本被封装到一个类中的某个属性时在传递时会在所有转义字符前加一个"\",例如"\n"变成"\\n"
    view.tv_feature.text = "版本特性:\n\n${upgradeInfo.updateMessage}".replace("\\n", "\n")
    view.tv_cancel.setOnClickListener { upgradeDialog.dismiss() }
    //点击更新
    view.tv_upgrade.setOnClickListener { //权限申请
        AndPermission.with(this@MainActivity)
            .runtime()
            .permission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
            .rationale{ context, data, executor ->
                //显示权限获取的弹窗
                //.....
            }
            .onDenied{
                TastyToast.makeText(
                    this@MainActivity,
                    "未获得存储权限,无法下载", TastyToast.LENGTH_SHORT, TastyToast.ERROR
                )
            }
            .onGranted{
                upgradeApk(this@MainActivity, upgradeInfo, view, upgradeDialog)
            }
            .start()
    }
    upgradeDialog.setContentView(view)
    upgradeDialog.setCancelable(false)
    upgradeDialog.show()
}

权限获取使用的是 com.yanzhenjie.permission.AndPermission 的开源第三方包,获取权限的弹窗自己添加。


总结

到这里就全部结束了,不容易呀😭这算是自己稍微有点技术含量的功能吧,毕竟能拿得出手的不多😂,这必然也会有一些设计上的缺陷和冗余,但无伤大雅。

有问题的也可以在下面评论,我看到都会回复的。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐