☰
Unity安卓端APK整包更新完整实践:版本检测、下载与自动安装
2026/10/20 16:18:35 网站建设 项目流程

做Unity安卓端开发的人,迟早都会碰到版本更新这件事。游戏包从1.0升到1.1,总不能指望着用户自己去官网下载;走应用商店审核又慢,中小团队根本等不起。所以从很早开始,我就在项目里自己搭了一套基于Unity的APK整包更新流程:启动时请求远程版本接口,比对本地版本号,有新版就弹窗,用户确认后调用系统DownloadManager下载APK,下载完自动拉起系统安装器。整套方案覆盖了国内安卓分发最常见的使用场景,也解决了“发版后老用户迟迟看不到新功能”这个最实际的问题。

这篇文章适合正在接手Unity安卓更新需求的开发,也适合刚把Unity打包流程跑通、准备做正式版分发的新手。我会把方案选型、核心原理、完整代码、Manifest配置和踩过的坑全部梳理一遍,保证你能照着抄作业。

1. 更新方案选型:为什么我选择自建整包更新

1.1 三类更新方式对比:热更新、差分更新与整包更新

Unity安卓端的更新,主流做法无非三类。第一类是代码级热更新,Unity游戏圈用得最多,常见的是Lua脚本热更、ILRuntime这类C#解释器方案,配合AssetBundle做资源热更。优点是用户不用重装App就能拿到新逻辑,发版频率可以做到一天几次,也不用过商店审核。缺点也很明显:热更新框架本身有学习成本,脚本与原生代码的边界要划清楚,而且涉及系统级能力变更时,比如新增原生SDK、改AndroidManifest,热更新根本覆盖不了,只能发整包。

第二类是差分更新,用bsdiff这类工具对比新旧APK生成差分包,用户下载补丁后本地合成APK。好处是下载体积小,适合大包产品;坏处是需要维护基线版本,做差分校验,出错概率比普通下载高不少,对开发和运维的要求都上去了。中小团队没有专门的发版平台,做差分属实是给自己找麻烦。

第三类就是我这次要讲的整包更新,服务端放一个最新APK,客户端启动时查版本接口,发现有新版就下载完整APK再覆盖安装。包体大是它的缺点,但实现思路直接,依赖的系统组件少,不引入额外框架,几乎不会出现“更新到一半包坏了”这种状态。说白了一句话:越简单的方案,线上越不容易出大乱子。

1.2 自建整包更新的适用场景与边界

自建更新模块不是什么项目都适合,但下面这几类场景确实是我的首选。第一类是内部测试分发,给产品、测试、老板装测试包,每次发版都让人手动清掉旧包再装新包,不现实,一台机子装十个版本也容易装混。第二类是官网下载或企业内部分发,没有应用商店统一管理,更新通道只能自己做。第三类是还没上商店的海外App,审核遥遥无期,又急需给已安装用户推新功能,整包更新就是最快能落地的路子。

这里需要泼盆冷水:如果你的产品已经上了国内主流应用商店,并且有运营同学天天盯着商店更新,那自建整包更新不是必须的,商店自带更新能力反而更省事。另外,如果你的包体动不动就是1GB往上,整包更新对用户的流量和等待耐心都是巨大考验,这种情况我更建议先考虑AssetBundle热更,把整包更新留到大版本或者原生能力变更时再用。

2. 核心原理拆解:Unity与Android原生协作的关键机制

2.1 版本号体系:versionCode、versionName与Unity的版本字段

做版本更新的第一步是搞清楚“怎么判断有新版本”。Android应用有两个版本维度,一个是versionCode,整数类型,系统用它判断“哪个包更新”,而且必须单调递增;另一个是versionName,字符串类型,用于给人看,比如“1.0.2”。逻辑判断只认versionCode,versionName只负责在弹窗里展示。

Unity工程里,这两个值在Player Settings的Other Settings面板中配置。versionName对应Bundle Version,默认是1.0;versionCode对应Bundle Version Code,默认是1。特别提醒:很多人以为改了Bundle Version等于升了版本,但Android的覆盖安装是靠versionCode判断的,所以升级时必须同时提升Bundle Version Code,否则系统会认为你这个包比旧包还老,直接不给装。

在Unity脚本内读取这两个值有个隐蔽坑。Application.version能拿到Bundle Version,也就是versionName,但拿不到versionCode。想要在运行时读取versionCode,只能通过JNI调用PackageManager:

public static int GetLocalVersionCode() { try { using (AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (AndroidJavaObject activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) using (AndroidJavaObject pm = activity.Call<AndroidJavaObject>("getPackageManager")) using (AndroidJavaObject info = pm.Call<AndroidJavaObject>("getPackageInfo", activity.Call<string>("getPackageName"), 0)) { return info.Get<int>("versionCode"); } } catch (System.Exception e) { Debug.LogError("读取versionCode失败: " + e.Message); return -1; } }

注意Android官方在新的SDK里已经把getPackageInfo标记为弃用,推荐走PackageManager.getPackageInfo(String, PackageInfoFlags),但Unity打包的targetSdkVersion如果不是特别激进的新版本,老写法依然能用,兼容性最稳。这套代码我项目里跑了两年,没出过问题。

2.2 JNI桥接:AndroidJavaClass与Java插件怎么配合

Unity C#跑在Mono或IL2CPP上,不能直接调Android的Java API,但Unity提供了AndroidJavaClass和AndroidJavaObject这两个包装类,本质上是帮你封装好了JNI调用。AndroidJavaClass用来调用静态方法或访问静态字段,AndroidJavaObject用来调用实例方法和实例字段,底层就是反射进去再invoke。

这两种方式各有适用的场景。如果更新逻辑足够简单,我早期也试过完全不用Java插件,直接在C#里用AndroidJavaObject拼DownloadManager的调用。结果代码写了一堆,每次改下载逻辑都要跟Java API反射较劲。后来我换成现在这种写法:Java侧专门放一个UpdateHelper类,把下载、文件路径转换、安装Intent这些原生逻辑全部封装成静态方法,C#侧只负责调入口方法,不需要关心Java实现细节。分工清楚之后,排查问题也方便多了。

Java侧回调Unity侧,用的是UnityPlayer.UnitySendMessage:

UnityPlayer.UnitySendMessage("UpdateManager", "OnDownloadFinished", downloadIdStr);

这个方法有三个参数:目标场景中GameObject的名称、要调用的方法名、传给方法的字符串参数。这里有几个硬性约束,全踩过一遍才会长记性:目标GameObject必须存在,且挂在场景里的物体不能是场景切换后被销毁的;方法必须是public;参数只支持字符串。Unity侧方法签名必须是void OnDownloadFinished(string msg)这种形式,不然调用没反应还不报错。

2.3 Android 7.0 / 8.0 / 9.0:三个关键API限制与设计逻辑

自建APK更新绕不开三个系统层的限制,而且都是Android一步步收紧安全策略的结果,理解它们背后的动机,你配置时才不会觉得是玄学。

第一个是Android 7.0的FileProvider。从7.0开始,App之间传递file://URI会被系统直接拒绝,抛FileUriExposedException。原因是之前用file://暴露本地文件路径给其他App,容易被恶意App猜路径获取隐私数据。所以跨App共享文件必须用content://URI加临时读权限,这就需要配置FileProvider。

第二个是Android 8.0的“未知来源安装”限制。以前用户装App只需要在设置里打开“允许未知来源”这一个开关,8.0之后变成了按应用维度授权:每个App必须单独申请REQUEST_INSTALL_PACKAGES权限,并且用户要在系统设置里允许你这个应用安装未知应用。不处理的话,下载完拉起安装界面会直接提示“不允许安装应用”。

第三个是Android 9.0的明文流量限制。从9.0开始,系统默认禁止App使用不带TLS的HTTP请求,如果你的版本接口或APK下载地址还是http://开头,那就会莫名其妙地出现“请求报错”“下载失败200”。要么把服务器全部升级成HTTPS,要么在Manifest的application节点上显式声明android:usesCleartextTraffic="true"。上线项目我强烈建议用HTTPS,明文HTTP在抓包和篡改面前就是裸奔,更新接口被篡改比报错可怕得多。

3. 从零到一:完整实现步骤与源码

3.1 服务端版本接口设计

更新模块的起点不在客户端,在服务端。接口不需要多复杂,一个静态JSON文件就能跑通:

{ "status": 0, "data": { "versionCode": 1003, "versionName": "1.0.3", "url": "https://your-server.com/apps/demo_v1.0.3.apk", "md5": "8fd9b1c9ba7a1b5a5a2dab9f3a5a5f1a", "size": 46858240, "force": false, "message": "修复了登录偶现闪退问题,新增导出功能" } }

这里我建议把versionCode设计成与Unity的Bundle Version Code一致的整数,比如1003对应1.0.3,方便人脑对应。客户端只用versionCode做大小判断,versionName拿来展示。force字段表示是否强制更新,true时弹窗不可关闭;message字段显示在弹窗内,告诉用户这个版本改了什么。

如果你的产品需要区分渠道或灰度,可以在请求URL带上渠道参数或者设备标识,服务端对不同参数返回不同版本信息。这个阶段不必做太重的后台,先跑起来,等更新次数多了再考虑做管理界面。

3.2 C#侧:版本检测、弹窗与下载入口

客户端流程分为四步:拉取远程版本信息、比对版本号、弹窗、触发下载。主控代码放在一个UpdateManager单例里:

using System.Collections; using UnityEngine; using UnityEngine.Networking; using UnityEngine.UI; public class UpdateManager : MonoBehaviour { public static UpdateManager Instance; private const string VersionUrl = "https://your-server.com/version.json"; [SerializeField] private GameObject updateDialog; [SerializeField] private Text versionText; [SerializeField] private Text messageText; [SerializeField] private Button downloadBtn; [SerializeField] private Button cancelBtn; [SerializeField] private Slider progressBar; private string apkUrl; private string apkMd5; private long currentVersionCode; private void Awake() { Instance = this; DontDestroyOnLoad(gameObject); currentVersionCode = GetLocalVersionCode(); } private void Start() { StartCoroutine(CheckVersion()); } private IEnumerator CheckVersion() { using (UnityWebRequest request = UnityWebRequest.Get(VersionUrl)) { request.timeout = 10; yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { Debug.LogWarning("版本检查失败: " + request.error); yield break; } VersionResponse response = JsonUtility.FromJson<VersionResponse>(request.downloadHandler.text); if (response == null || response.data == null) { Debug.LogWarning("版本接口返回格式异常"); yield break; } if (response.data.versionCode > currentVersionCode) { ShowUpdateDialog(response.data); } } } private void ShowUpdateDialog(VersionData data) { versionText.text = "发现新版本 v" + data.versionName; messageText.text = data.message; apkUrl = data.url; apkMd5 = data.md5; downloadBtn.onClick.AddListener(delegate { StartDownload(); }); cancelBtn.onClick.AddListener(HideDialog); if (data.force) { cancelBtn.gameObject.SetActive(false); } updateDialog.SetActive(true); progressBar.gameObject.SetActive(false); } private void StartDownload() { updateDialog.SetActive(false); progressBar.gameObject.SetActive(true); UpdateHelperBridge.StartDownload(apkUrl); } public void OnDownloadFinished(string msg) { progressBar.gameObject.SetActive(false); updateDialog.SetActive(true); versionText.text = "下载完成,准备安装"; } } [System.Serializable] public class VersionResponse { public int status; public VersionData data; } [System.Serializable] public class VersionData { public int versionCode; public string versionName; public string url; public string md5; public long size; public bool force; public string message; }

这里有个小坑:Unity的JsonUtility不支持直接把JSON根节点当成数组,也不支持字段名带下划线自动映射,所以我用了VersionResponse和VersionData两个序列化类对应接口结构。如果你的服务端字段命名习惯是驼峰,注意统一大小写或用[SerializeField]特性做映射,不然解析出来全是默认值。

弹窗UI我直接用UGUI搭的,一个半透明全屏遮罩加一个居中对话框。强制更新时隐藏取消按钮,这是产品上的硬性要求,否则用户点取消后继续用旧版,绕过强制策略。

3.3 Java侧:下载、权限检查与安装

接下来是Java插件,这是整套更新方案的真正核心。我把它命名为UpdateHelper.java,放在Unity工程的Assets/Plugins/Android目录下:

package com.example.update; import android.app.DownloadManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.provider.Settings; import com.unity3d.player.UnityPlayer; import java.io.File; public class UpdateHelper { private static Context context; private static DownloadManager downloadManager; // 从C#侧调用,开始下载 public static void StartDownload(String url) { if (context == null) { context = UnityPlayer.currentActivity; } String fileName = "app_update_" + System.currentTimeMillis() + ".apk"; DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url)); request.setTitle("应用更新"); request.setDescription("正在下载最新版本..."); request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); request.setDestinationInExternalFilesDir(context, "update", fileName); downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); long downloadId = downloadManager.enqueue(request); // 存储downloadId,供后续安装时使用 context.getSharedPreferences("update_pref", Context.MODE_PRIVATE) .edit().putLong("download_id", downloadId).apply(); } }

下载完成的通知是靠BroadcastReceiver收的。DownloadManager下载完成后会发一条ACTION_DOWNLOAD_COMPLETE广播,我们在初始化时注册接收器,收到广播后拿到下载ID和文件路径,再执行安装逻辑:

public class UpdateHelper { private static BroadcastReceiver downloadReceiver = new BroadcastReceiver() { @Override public void onReceive(Context ctx, Intent intent) { long downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1); long savedId = ctx.getSharedPreferences("update_pref", Context.MODE_PRIVATE) .getLong("download_id", -1); if (downloadId == savedId) { InstallApk(ctx, downloadId); } } }; public static void Init() { if (context == null) { context = UnityPlayer.currentActivity; } IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE); context.registerReceiver(downloadReceiver, filter); } private static void InstallApk(Context ctx, long downloadId) { DownloadManager.Query query = new DownloadManager.Query(); query.setFilterById(downloadId); android.database.Cursor cursor = downloadManager.query(query); if (cursor.moveToFirst()) { String filePath = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)); cursor.close(); Uri apkUri = Uri.parse(filePath); File file = new File(apkUri.getPath()); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { Uri contentUri = FileProvider.getUriForFile(ctx, ctx.getPackageName() + ".fileprovider", file); Intent installIntent = new Intent(Intent.ACTION_VIEW); installIntent.setDataAndType(contentUri, "application/vnd.android.package-archive"); installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); ctx.startActivity(installIntent); } else { Intent installIntent = new Intent(Intent.ACTION_VIEW); installIntent.setDataAndType(apkUri, "application/vnd.android.package-archive"); installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); ctx.startActivity(installIntent); } } } // 检查8.0及以上系统是否允许安装未知来源应用 public static boolean CanInstallUnknownApps() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return context.getPackageManager().canRequestPackageInstalls(); } return true; } // 跳转系统设置,允许安装未知应用 public static void OpenInstallSetting() { Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + context.getPackageName())); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); } }

安装逻辑里有一个关键的版本分支:Android 7.0以下可以直接用file://URI调起安装器,7.0及以上必须先用FileProvider转换成content://URI,并且加上FLAG_GRANT_READ_URI_PERMISSION,否则系统直接拒绝安装。我早期在这里栽过好几次,症状是“解析包时出现问题”,后来才发现是FileProvider的路径映射配得不全。

关于未知来源安装,我在C#侧会在启动下载前先检查一次,不够就先跳去授权设置页,授权回来后再开始下载。不提前处理的话,等APK下载完再发现不能装,用户得重来一遍,体验很差。

3.4 Manifest / Gradle / FileProvider配置

Java插件写好了还得让Unity把它打包进APK,同时把权限、组件声明全部配好。我在Assets/Plugins/Android下放了一份AndroidManifest.xml:

<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.game"> <uses-permission android:name="android.permission.INTERNET"/> <application android:usesCleartextTraffic="false"> <provider android:name="androidx.core.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>

注意这里我用了${applicationId}.fileprovider作为authority,这样在构建时系统会自动替换成你实际的包名,避免多个插件之间authority冲突。如果你使用的Unity版本比较老,不支持这种占位符替换,那就只能写死包名,但一定要保证和后续安装时拼接的authority完全一致。

接着是file_paths.xml,路径映射要跟Java里DownloadManager设置的目标目录对应。前面代码里我用了setDestinationInExternalFilesDir(context, "update", fileName),这会写到/Android/data/包名/files/update/,而对应外部应用文件目录的XML标签是external-files-path:

<?xml version="1.0" encoding="utf-8"?> <paths> <external-files-path name="update" path="update/" /> </paths>

path标签的路径是从/storage/emulated/0/Android/data/<包名>/files/作为根往下算的,所以只写update/就够了。有个常见误区是把path写成整个绝对路径,那样FileProvider会匹配不到文件,安装时一直报解析错误。Android原生的DownloadManager默认下载目录是公共Download目录,对应external-path标签,文件写在那里确实也能装,但Android 10及以上的分区存储对公共目录写入有额外限制,反而容易出乱子,我最推荐的做法就是下载到应用专属目录,既安全又不需要申请存储权限。

Gradle依赖方面,我使用的是Unity 2021 LTS默认模板,FileProvider直接引用androidx.core的类即可。Unity自带dependencies里面已经包含androidx.core,不需要额外在mainTemplate.gradle里加。但你如果用很老的Unity版本且工程还没迁移到AndroidX,那要把androidx.core.content.FileProvider换成android.support.v4.content.FileProvider,同时Manifest里的引用也要同步换。

4. 踩坑实录:常见问题与排查技巧

4.1 检查不到新版本:先看接口,再看明文流量

“明明服务端已经发了新包,客户端就是没反应”——这是更新模块出镜率最高的问题。我的排查顺序是固定的:先用浏览器或Postman访问版本接口,确认JSON能正常返回;再确认接口返回的versionCode确实大于本地版本号;最后才看客户端报错。

有一次我调试半天发现是Unity的JsonUtility解析服务端返回字符串失败,因为服务端在JSON里塞了个BOM头。还有一次是本地包versionCode写成了0开头,比如0123,Java里面解析成八进制直接变成83了,导致更新判断永远不成立。这种细节问题丧心病狂,但真会让你耗掉一个下午。

第二个高发点是Android 9.0的明文HTTP限制。如果版本接口是http://且没在Manifest里加usesCleartextTraffic="true",UnityWebRequest会收到一个网络错误。解决办法不是无脑开明文,而是尽快把接口和APK下载地址都迁到HTTPS。现在申请免费SSL证书并不难,别再纠结那点成本。

4.2 下载没反应:权限、路径、网络三层排查

如果版本检测通过、弹窗也出现了,点“下载”之后却没有动静,按优先级排查:有没有授予INTERNET权限,下载地址是否能在浏览器中直接打开,DownloadManager有没有把文件下载到目标目录。

一个很容易被忽略的坑:我在测试机上点下载后,去/Android/data/包名/files/update/目录看,发现文件确实存在但大小为0,那是因为目标目录的父级不存在时,部分机型的DownloadManager会静默失败。所以我在StartDownload方法里先做了目录创建:

File updateDir = new File(context.getExternalFilesDir(null), "update"); if (!updateDir.exists()) { updateDir.mkdirs(); }

还有些定制ROM会在系统层面对DownloadManager做限制,下载请求直接进队列不执行,或者下载完不广播完成事件。遇到这种情况,我建议在后端加上一个“下载进度查询”接口,前端定期查询文件是否真实存在并校验大小,不依赖系统广播。

4.3 APK无法安装或解析失败:FileProvider与签名冲突

“下载成功,点击安装,系统弹出解析包错误”——这一类问题十有八九出在FileProvider配置或签名上。

FileProvider问题可以细分成三种:第一种是Manifest里漏配了provider,安装代码执行到FileProvider.getUriForFile时会报IllegalArgumentException: Couldn't find meta-data,这个报错信息很清楚,直接去Manifest补provider块;第二种是file_paths.xml里路径写错,报Failed to find configured root;第三种是authority不匹配,Java里的包名前缀跟Manifest里配置的不一致,报PackageManager: Package ... has no privileges。排查思路就是对号入座,看日志里抛的是哪一层异常。

签名冲突是另一种更隐蔽的问题。如果你的新APK是用A证书签名的,而设备上已装的旧包是用B证书签名的,那执行安装时系统会提示“与已安装应用签名不一致”或“更新包与现有版本不兼容”。这种问题常见于从开发环境换到发布环境、或者同事电脑签出的包直接覆盖安装。解决方法是保证测试机和生产机的签名保持一致,并且签名的keystore要按团队规范统一管理。

4.4 更新体验优化:进度提示、强制更新与重复点击保护

更新模块跑通之后,真正的挑战在体验细节。我强烈建议在UI上加上下载进度显示,别让用户干等。DownloadManager本身不提供跨进程的事件流,但可以用Query接口轮询获取已下载字节数,我封装了一个简单方法:

public static int GetDownloadProgress() { long downloadId = context.getSharedPreferences("update_pref", Context.MODE_PRIVATE) .getLong("download_id", -1); DownloadManager.Query query = new DownloadManager.Query(); query.setFilterById(downloadId); Cursor cursor = downloadManager.query(query); if (cursor.moveToFirst()) { int bytesDownloaded = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); int bytesTotal = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); if (bytesTotal > 0) { return (int) (bytesDownloaded * 100f / bytesTotal); } } return 0; }

C#侧每隔一秒调用一次这个静态方法刷新进度条,下载完成后再隐藏进度显示。我在C#侧的StartDownload里加了一个布尔标志位isDownloading,防止用户连点下载按钮启动多个下载任务,这个保护在弱网环境下尤其重要。

强制更新弹窗的关闭按钮要隐藏掉,并且要把弹窗的关闭动画和Android返回键拦截掉。最简单粗暴的方法是Override OnBackButton:

private void Update() { if (updateDialog.activeSelf && Application.platform == RuntimePlatform.Android) { if (Input.GetKeyDown(KeyCode.Escape)) { // 如果是强制更新,拦截返回键 } } }

如果是非强制更新,用户取消后至少要记录一下“今天已经弹过”,否则每次进游戏都弹窗会让用户很烦躁。

5. 让更新模块更结实的几条实践

5.1 MD5校验能救回很多脏数据

下载完成的APK,最好是先校验一下MD5再调安装器,尤其在国内复杂的网络环境下,下载文件被劫持、被断流导致文件损坏的概率不低。服务端版本接口里那个md5字段就是干这个的。C#侧在收到下载完成回调后,先读文件算MD5再跟接口返回的比对,不一致就删掉重下:

public static string GetFileMD5(string filePath) { using (var fileStream = System.IO.File.OpenRead(filePath)) using (var md5 = System.Security.Cryptography.MD5.Create()) { byte[] hash = md5.ComputeHash(fileStream); StringBuilder sb = new StringBuilder(); foreach (byte b in hash) { sb.Append(b.ToString("x2")); } return sb.ToString(); } }

算MD5对大文件会有几百毫秒的耗时,放在协程或者线程里跑,别直接卡主线程。比对失败时,我建议把下载产生的缓存文件清掉,再弹一次“下载失败,请重试”,而不是让用户拿着坏包去点安装。

5.2 用应用专属目录,绕开存储权限泥潭

前面我在代码里特意用了setDestinationInExternalFilesDir,这是为了绕开Android运行时存储权限那一堆破事。如果你把APK下载到公共Download目录,在Android 6.0以上就需要运行时动态申请WRITE_EXTERNAL_STORAGE权限,在Android 10及以上还得处理分区存储的兼容问题,权限弹窗本身又会打断整个更新流程。下载到/Android/data/包名/files/update/这个目录,既不需要申请权限,卸载时文件也会跟着清干净,是安卓端自更新最推荐的落点。

它的缺点是其他文件管理器默认看不到这个目录,但这一点对普通用户并没有影响,因为最终安装动作是通过FileProvider走的,用户不需要自己去翻文件。测试阶段想确认文件有没有下载成功,可以用Android Studio的Device Explorer直接查看。

5.3 服务端接口多做一步容错,收益远超成本

版本接口虽然简单,但别把它做成一个裸的静态JSON就完了。我建议至少在接口响应里增加status字段和错误码,客户端先判断status再做后续操作。这样后续如果服务端要做渠道限制或者灰度下发,不用改客户端协议,只调整服务端返回逻辑就行。

同样,如果公司有自己的后端框架,版本接口最好加上版本号缓存、CDN加速。APK文件放OSS或对象存储,下载地址用带签名的临时链接,到期自动失效,这样就算地址被爬走也不会被人拿去无限刷流量。我见过不少团队因为省这点事,把下载链接固定写死,最后服务器被刷崩溃的。

回到开头那句话,更新模块看着不起眼,但它和启动模块一样,是用户接触到你产品时最先跑通的链路。一个稳定、省心、不容易出幺蛾子的更新流程,省下的运维时间远超写它的那几天。你只需要记住:版本号判断逻辑是命根子,FileProvider是Android 7.0以上的安全红线,下载到应用专属目录能避开半数的权限坑,最后再给下载文件加一道MD5校验。把这四件事做好,这个模块就能在绝大多数安卓设备上平稳跑很长时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询