做安卓开发这些年,我接手过不少老项目,几乎每个项目迭代到中后期都会被同一个需求找上门:要在应用里加一个“安卓应用版本更新”功能。这个需求看着简单——后台返回个新版本号,用户点一下下载安装,完事。但真要是顺着这个思路做,后面等着你的是一连串问题:Android 7 直接打开 file:// 崩溃、Android 8 安装权限弹不出来、Android 9 明文 http 被拦、Android 13 通知不显示,甚至不同手机厂商的 ROM 还会再给你补几刀。
这篇文章我把完整演示做法拆开讲,从方案选型、接口设计、下载模块,再到安装适配、通知栏进度和真实故障排查,把每步“为什么这么做”都写清楚。不管你是刚入门的安卓初学者,还是在老项目里加更新功能的新手,看完至少能避开我踩过的大部分坑。
1. 版本更新不是“下载再安装”那么简单,先想清楚四件事
1.1 一个完整的更新闭环由哪四段组成
很多人一提起版本更新,脑子里浮现的就是“下载 APK 然后安装”。但同一个用户在真实场景里经历了完整流程后,你会发现它其实是四段组成的闭环:
- 更新检查:客户端向服务端请求最新版本信息,服务端返回 versionCode、下载地址、是否强制更新等字段。这里要先处理“已经是最新”“可选更新”“强制更新”三种状态,不能一上来就弹框。
- 用户确认:可选更新弹窗可以关闭,最好有“以后再说”的本地记录;强制更新弹窗不能关闭,或者只能给“退出应用”按钮。这个交互做不对,产品上线后会被用户骂。
- 下载安装包:可以跳转应用市场下载,也可以应用内下载。应用内下载要处理网络、存储、通知、前台服务、进程存活和一堆 Android 版本兼容问题,是整个环节里技术含量最高的部分。
- 安装与回跳:调用系统安装器安装 APK,安装完成后决定是否直接打开新版本,或者留在通知栏让用户手动点击。
这个闭环里最容易被低估的是“下载和安装”。很多项目把更新检查写得很热闹,真到下载阶段才发现各种问题:下载地址是明文 http、下载完成没有校验文件、安装时 FileProvider 路径配错了、Android 8 以上忘记申请安装未知应用权限。所以方案选型一定要先想清楚,别急着写代码。
1.2 自建下载通道还是借用应用市场,怎么搭配最省事
如果你的应用已经上架到常用安卓应用市场,完全可以优先拉起市场更新页。通过应用包名跳转到应用市场详情页,让市场帮你处理下载、签名校验、安装权限这些事,用户信任度也高。缺点是某些应用市场没收录你的应用,或者某个用户手机上就是没装你指定的市场,这时候跳转会直接抛 ActivityNotFoundException。
自建下载通道更适合官网下载、企业分发、内测包、强制更新这些场景。自己下载 APK 的最大优势是可控:能拿到精确下载进度,能做 MD5 校验,能按自己的策略提示用户。代价是开发成本和适配成本都更高,需要处理 Android 7 的 FileProvider、Android 8 的安装权限、Android 9 的明文流量限制、Android 13 的通知权限等等。
我在真实项目里常用的是“双通道”策略:后端返回一个updateSource字段,客户端优先尝试跳转应用市场,用户没装对应市场或跳转失败时,再走内置下载通道。两个通道都保住,用户在任何一台设备上都至少有一条路能更新。
1.3 强制更新和可选更新的交互设计要区分清楚
强制更新不能只靠前端弹窗,否则老版本用户一直点“取消”,还是能继续用错误版本的接口请求。真正稳妥的做法是在服务端做版本号限制:旧版本访问业务接口时,后端直接返回“当前版本过低,请升级”,同时客户端在启动流程里弹强制更新框,用户点“退出”就退到桌面,点“立即更新”就进入下载安装流程。
可选更新则要克制。用户正在用 App,突然弹全屏更新框很烦。我习惯把它做成普通弹窗,提供“立即更新”和“暂不更新”,同时用 SharedPreferences 记录用户选择“暂不更新”的时间,未来三天内不再弹,避免每次启动都骚扰用户。如果产品一定要提高更新率,可以在版本说明里多写点用户能感知的变化,而不是只写“修复若干 bug”。
2. 更新检查接口这样设计,后面会省很多事
2.1 接口字段:少了哪一个后面都会补到哭
更新检查接口不复杂,但字段一定要给全。我的个人习惯是让后端返回下面这组 JSON:
{ "code": 0, "data": { "versionCode": 20250101, "versionName": "3.2.1", "downloadUrl": "https://cdn.example.com/app/xxx_3.2.1.apk", "md5": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "fileSize": 52428800, "forceUpdate": false, "updateLog": "修复了登录偶现闪退的问题;优化了首页加载速度。", "publishTime": 1735689600000 } }逐个说下为什么需要这些字段。versionCode是程序内部比较版本用的整数,versionName是给用户看的人类可读版本号,两者都必须有。downloadUrl一定要是 https,明文 http 在 Android 9 以上默认会被拦截。fileSize用于进度百分比和流量提示,下载前告诉用户“这个安装包有 50MB”,比下载到一半才发现要好得多。md5是下载完成后的文件校验依据,少了它,下载损坏时只能靠用户反馈“安装包解析失败”。forceUpdate控制强制/可选更新,updateLog用于展示更新说明,publishTime可以用于判断是否需要重新检查。
2.2 版本比较:千万不要拿 versionName 做大小判断
versionName是给用户看的字符串,比如1.9.9。如果拿字符串比较,用户当前版本是 1.9.9,新版本是 1.10.0,你会发现字符串比较认为9比10大,永远提示用户已经是最新版本。这类问题特别隐蔽,因为测试阶段往往只测 1.0.0 到 1.0.1 这种表面比较。
正确做法是用versionCode。它是一个整数,每次发版必须比上一个版本大,比较逻辑可以简写为:返回的versionCode > 当前应用的 versionCode时提示更新,小于等于则视为已是最新版。需要注意发版时 versionCode 不能回退,否则分分钟出现线上“更新按钮消失”的故障。完整代码大致是这样:
fun checkUpdate(remoteVersionCode: Int): Boolean { val localVersionCode = packageManager .getPackageInfo(packageName, 0) .versionCode return remoteVersionCode > localVersionCode }2.3 检查更新的频率和缓存策略
如果每次 App 启动都请求检查更新,日活百万级的产品后端会收到巨大的无效请求量。并不是每个用户打开 App 都希望立即弹窗,也不是每次启动都需要重新拉取接口。我一般建议这么设计:
- 进入主界面后异步请求检查,不阻塞启动流程;
- 本地记录上次检查更新时间,距离现在不足 24 小时时,直接沿用上次的检查结果;
- 设置页里的“手动检查更新”不受 24 小时限制,用户主动点击时必须实时请求;
- 检查接口请求失败时静默处理,不弹错误提示,因为更新检查属于非关键路径,不能让网络波动影响用户正常使用。
前端还要处理一种常见情况:用户已经是最新版,但接口因为缓存返回了旧数据。这时候本地要再判断一次versionCode,不能无条件相信接口。接口只负责返回“当前最新版本”,是否提示更新,客户端必须以本机 versionCode 为准。
3. 下载APK:写一个不轻易崩的下载模块
3.1 DownloadManager 和自己写下载服务,怎么选
安卓自带的 DownloadManager 可以处理下载任务、系统通知、断点续传,接入成本很低。但它有几个痛点:进度监听需要通过 ContentObserver 或轮询数据库,实时性不够;下载完成的通知栏样式不能完全控制;没法直接校验 MD5,下载坏了只能靠安装时才知道;在部分定制 ROM 上,DownloadManager 的表现还不太稳定。
自己写下载服务,一般基于 OkHttp 或 HttpURLConnection 实现,能做到精确进度回调、断点续传、MD5 校验、自定义通知栏、不同失败原因分别处理。代价是代码量多一些。我的建议很明确:产品只要求“能下载能装”,用 DownloadManager;需要展示进度、校验文件、做强制更新、统计下载来源,自己写一个前台服务。
下面是我常用的对比表,可以直接拿去跟同事讨论方案:
| 对比项 | DownloadManager | 自写Service |
|---|---|---|
| 实现成本 | 低 | 中高 |
| 进度监听 | 延迟明显 | 回调精准 |
| 自定义通知 | 较弱 | 完全可控 |
| 断点续传 | 系统自带,细节不易掌控 | 自己写 Range,可控 |
| 文件校验 | 不带 MD5 校验 | 可加 |
| 被杀恢复 | 系统下载管理接管 | 自己恢复任务 |
3.2 为了下载,先把网络与安全配置搞定
Android 9(API 28)开始,系统默认禁止明文传输 HTTP 流量。如果你的下载地址是http://,而且 targetSdk 设置得又比较高,下载时会直接报Cleartext HTTP traffic to xxx not permitted。解决办法有两种:最好是把文件服务升级为 https,一劳永逸;如果短时间改不了,可以临时在 manifest 里放行明文流量:
<application android:usesCleartextTraffic="true" ... >更稳妥一点的做法是用 networkSecurityConfig 只放行指定域名,而不是全局放开。先建一个res/xml/network_security_config.xml:
<network-security-config> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">cdn.example.com</domain> </domain-config> </network-security-config>再在 AndroidManifest.xml 的 application 节点引用它:
<application android:networkSecurityConfig="@xml/network_security_config" ... >这里要特别提醒:即便临时放行了 http,APK 在传输过程中也可能被运营商或中间节点篡改,所以 MD5 校验在这种场景下更应该做。能上 https 就尽快上,不能上也得保证文件完整性校验。
3.3 用 OkHttp 实现一个带进度和断点续传的下载
我一般用 OkHttp 做下载请求,重点代码结构大概是这样的:
val client = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build() val request = Request.Builder() .url(downloadUrl) .header("Range", "bytes=$downloadedLength-") .build() client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { // 失败后保存 currentLength,下次续传 } override fun onResponse(call: Call, response: Response) { val input = response.body?.byteStream() ?: return val file = File(filesDir, "update/app_v3.2.1.apk") var savedLength = downloadedLength val buffer = ByteArray(8 * 1024) var bytesRead: Int input.use { ins -> file.outputStream().use { outs -> while (ins.read(buffer).also { bytesRead = it } != -1) { outs.write(buffer, 0, bytesRead) savedLength += bytesRead notifyProgress(savedLength, totalLength) } } } } })几点经验:
- 用
Range: bytes=当前已下载长度-实现断点续传,但前提是服务端支持 Range 请求。如果服务端返回的是 200 而不是 206,说明它没当断点续传来处理,这时文件必须从头开始写,否则会把内容拼错。 - 进度回调不要每次都立刻刷通知栏,可以每 200ms 或每 1% 更新一次,否则下载过程会明显卡顿。
- 下载到本地文件时,建议先下载到临时文件名,比如
app.apk.tmp,下载完成并校验 MD5 通过后,再重命名为app.apk。这样可以避免下载到一半的文件被误当成完整安装包。 - 下载目录用
context.getExternalFilesDir()或context.getFilesDir(),这两个目录不需要申请存储权限。把 APK 放到公共 Download 目录虽然更直观,但 Android 10 分区存储之后权限问题更多,没必要给自己找麻烦。
3.4 校验、重命名、以及安装前五分钟
下载完成后立刻算一次文件的 MD5,跟服务端返回的 md5 对比,不一致就删除文件重新下载,同时提示用户“下载内容异常,已自动重试”。这个环节能做掉很多线上问题。
MD5 计算代码不复杂,但要注意大 APK 计算耗时,必须在后台线程执行:
fun md5(file: File): String { val digest = MessageDigest.getInstance("MD5") file.inputStream().use { input -> val buffer = ByteArray(8192) while (true) { val len = input.read(buffer) if (len == -1) break digest.update(buffer, 0, len) } } return digest.digest().joinToString("") { "%02x".format(it) } }重命名之后还有一个容易忽略的点:检查设备剩余存储空间。APK 往往几十 MB,下载过程还要写临时文件,如果用户手机只剩 100MB,下载到一半就会文件写入失败。下载开始前可以检查一次StatFs,空间不足时提前拦截,别等下载到 99% 才报错。
4. 安装APK:不同Android版本的适配是重灾区
4.1 Android 7 以后,file:// 这条路走不通了
Android 7.0(API 24)开始,应用之间共享file://Uri 会直接抛出FileUriExposedException。所以安装 APK 必须用 FileProvider,把真实文件路径转换成content://Uri,并临时授权给系统安装器。在 AndroidManifest.xml 里注册 FileProvider:
<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>在 res/xml/file_paths.xml 里声明可访问的路径:
<paths> <external-files-path name="download" path="download/" /> <files-path name="internal" path="update/" /> </paths>发起安装的 Intent 写法:
val apkFile = File(context.filesDir, "update/app.apk") val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", apkFile ) val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "application/vnd.android.package-archive") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(intent)这里最常见的坑是 file_paths 配置的根路径和 APK 实际所在目录对不上。如果 APK 放在filesDir/update/下,却只配了external-files-path,安装时就会报Failed to find configured root。遇到这种问题,先检查配置的 path 是否覆盖了真实文件目录。
4.2 Android 8 以后,“允许安装未知来源应用”要单独申请
Android 8.0(API 26)把“允许安装未知来源”从系统全局设置改成了应用级权限。你的应用要调用系统安装器装 APK,自己必须先拥有“安装未知应用”权限,否则startActivity没反应或者直接失败。检查权限并跳转设置:
@RequiresApi(Build.VERSION_CODES.O) fun canInstall(context: Context): Boolean { return context.packageManager.canRequestPackageInstalls() } fun goToInstallSetting(context: Context) { val intent = Intent( Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}") ) context.startActivity(intent) }用户从设置页回来之后,一定要再次检查权限是否真的授予了。很多项目只跳转一次设置页,结果用户开了权限但没点“返回”回来,或者开完直接杀掉了设置进程,安装时依然失败。非要揪原因的话,可以在这里打点统计,看有多少用户走到安装那一步却因为权限原因卡住。
4.3 从 Android 10 到 Android 14,几个必须注意的版本点
Android 10(API 29)引入分区存储后,公共 Download 目录不能随意写文件。更新包放在应用私有目录完全不受影响,所以我的做法是一律把 APK 下载到应用私有目录,安装时靠 FileProvider 授权给系统安装器,不申请存储权限。
Android 11(API 30)开始,应用包可见性发生了变化。如果你要查询某个应用是否安装,或者要跳转其他应用,需要在 manifest 里声明<queries>。但如果是拉起系统安装器安装自己的 APK,常规startActivity不一定受影响,不过我还是建议跳转前做 try-catch,防止个别 ROM 上因为 Intent 解析失败直接崩溃。
Android 13(API 33)新增了通知运行时权限POST_NOTIFICATIONS。如果用户没有授予通知权限,前台服务的通知栏可能不显示,但下载服务本身还能跑。用户看不到进度会以为是坏掉了,所以开始下载前最好先请求通知权限。
Android 14(API 34)对前台服务类型有更严格要求。如果 targetSdk 为 34,需要给服务声明foregroundServiceType,下载场景一般用dataSync,同时要在 manifest 里声明FOREGROUND_SERVICE和FOREGROUND_SERVICE_DATA_SYNC权限:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />4.4 安装完成之后,如何“打开新版本”
安装完成的瞬间,用户不一定想直接打开新版本。我通常的做法是在通知栏的“下载完成”通知里放两个按钮:一个“安装”,一个“取消”。用户点击安装之后,最好在通知栏再放一个“打开”按钮,安装完成后点击“打开”按钮,直接启动新版本应用。
启动自己应用的新版本时,代码可以这样写:
val launchIntent = packageManager.getLaunchIntentForPackage(packageName) if (launchIntent != null) { startActivity(launchIntent) } else { // 处理异常,比如跳到应用市场 }注意getLaunchIntentForPackage在 Android 11 上如果目标是第三方应用,可能受包可见性限制返回 null。如果只是打开自己的应用,一般没问题;但如果你是做“应用商店”或者“分发平台”这类工具,需要拉起第三方应用时,记得在 manifest 里用<queries>声明目标包名,或者在跳转前 try-catch,避免崩溃。
5. 通知栏进度、前后台切换和弱网下的体验优化
5.1 为什么下载必须放前台服务
Android 8 以后,后台 Service 很容易被系统回收。用户如果下载过程中切到后台,或者干脆把任务卡片划掉,普通 Service 可能几秒内就被系统杀掉,下载进度随之丢失。APK 动辄几十 MB,下载过程通常要持续很久,所以必须在“前台服务”里运行,同时给通知栏一个常驻通知,让用户知道下载还在继续,系统也不会轻易回收。
在 AndroidManifest 里注册下载服务:
<service android:name=".update.UpdateDownloadService" android:foregroundServiceType="dataSync" android:exported="false" />启动服务后立刻调用 startForeground:
startForeground(UPDATE_NOTIFY_ID, createNotification(0))这里要提醒:startForeground 必须尽早调用,最好在服务的 onCreate 里马上执行。Android 12 之后对前台服务启动时机有更严格要求,如果服务启动后超过一定时间没有调用 startForeground,会直接抛出 ForegroundServiceDidNotStartInTimeException。所以不要在启动服务后再慢慢初始化其他东西。
5.2 通知栏进度条的正确姿势
通知渠道和进度更新代码大概这样:
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val channel = NotificationChannel( "update_channel", "版本更新下载", NotificationManager.IMPORTANCE_LOW ) manager.createNotificationChannel(channel) val notification = NotificationCompat.Builder(this, "update_channel") .setSmallIcon(R.drawable.ic_update) .setContentTitle("正在下载新版本") .setContentText("已下载 25.6MB / 50MB") .setProgress(100, 51, false) .setOnlyAlertOnce(true) .build() manager.notify(UPDATE_NOTIFY_ID, notification)几个经验:
setOnlyAlertOnce(true)很关键,不然每次进度更新都会触发通知声音或震动,用户会直接把你应用的通知关掉。- 进度刷新频率不要太高,每 200ms 一次已经足够顺滑。甚至每 1% 更新一次就行,没必要每个字节都刷新通知栏。
- 下载完成的通知要改成“下载完成,点击安装”,并把进度条去掉,同时调用
stopForeground让服务退出前台状态。 - 如果应用申请了通知权限但用户拒绝,前台服务还是会运行,但通知不显示。这种情况要保留一条引导路径,在界面内展示下载状态,别让用户完全看不到进度。
5.3 弱网、断网、切换网络怎么处理才不挨骂
弱网环境最容易出现的问题是:下载到 80% 突然断网,用户重试后又从 0% 开始。所以断点续传必须实现,同时每次下载任务的状态都要持久化保存。
我一般会在 SharedPreferences 里维护一份下载任务状态:url、targetPath、currentLength、totalLength、md5、status。每次下载开始、进度更新、失败、成功都会同步更新。App 启动时检查有没有未完成的任务,如果有且文件长度小于服务器返回的 totalLength,就断点续传;如果服务端不支持断点续传,就重新下载。
还有一个容易忽略的场景:用户下载过程中从 WiFi 切到移动网络,或者反过来。大 APK 一定要在切换网络时做提示,避免用户流量被吃光。可以监听 ConnectivityManager 的网络回调,检测到网络类型变化时暂停下载,弹窗让用户确认是否继续。如果产品急着上线,至少也要在下载开始前判断当前网络类型,移动网络下弹一次“当前为移动网络,可能消耗较多流量,是否继续”。
应用进程被杀掉后,由于前台服务通常不会被立刻回收,下载还会继续。但如果用户在系统设置里强行停止应用,前台服务也会被停掉,这时候下次启动时读取持久化状态,恢复未完成任务就很有必要。
6. 真实项目中踩过的坑和上线前检查清单
6.1 常见异常速查表
下面这份表是我维护更新模块时整理出来的,基本覆盖了线上反馈最多的几类问题:
| 现象 | 可能原因 | 排查建议 |
|---|---|---|
| 点击安装后秒退/无反应 | FileProvider 路径未配置或 Uri 权限缺失 | 检查 file_paths 是否覆盖文件目录,检查 Intent 是否带 FLAG_GRANT_READ_URI_PERMISSION |
| 提示“安装包解析失败” | 下载文件损坏、MD5 不一致、APK 与设备系统不匹配 | 服务端计算 MD5,客户端下载后校验;校验失败自动重试 |
| Android 9 下载时提示 Cleartext HTTP | 下载地址是 http 且未放行明文 | 升级 https,或配置 networkSecurityConfig 放行指定域名 |
| Android 8 以上没有“安装未知来源”弹窗 | 发起安装的应用没有被授予安装权限 | 跳转 ACTION_MANAGE_UNKNOWN_APP_SOURCES 申请权限 |
| 通知栏不显示进度 | 通知权限未授予,或通知渠道被关闭 | Android 13 以上请求 POST_NOTIFICATIONS,检查渠道是否可用 |
| 下载到一半 App 被杀,重新打开不续传 | 没有保存断点信息 | 用 SharedPreferences/DB 保存 currentLength,启动时恢复任务 |
| 跳转市场后提示无法处理 Intent | 设备没有安装对应市场 | try-catch 异常,回退到内置下载 |
6.2 每个更新版本上线前,按这个清单过一遍
我在 Android Studio 里接入或改造更新模块时,都会要求测试同学重点覆盖以下场景,你直接拿去用:
- 用目标 targetSdk 版本编译,至少覆盖 Android 7、8、9、10、11、12、13/14 各一台真机,或者用云真机补足机型差异。
- 检查服务端 versionCode 是否大于线上版本,否则会出现“明明更新了却提示已最新”的尴尬。
- 下载地址用 https 且在浏览器里能直接下载;如果 CDN 配置了防盗链或跨域限制,App 内下载会一直失败,浏览器里却正常。
- 在弱网、飞行模式切回、WiFi 切移动网络等场景各测一遍。
- 强制更新开关测两遍:旧版本弹,新版本不弹。
- 安装完成后的“打开”动作,在主流厂商 ROM 上都要验证,尤其是带有定制安全中心的手机。
6.3 不想自己写全套,可以偷懒但不能乱偷
如果项目工期紧,可以先把应用市场跳转链路做好。拿到应用包名后,用market://details?id=包名跳转应用市场详情页,用户直接在市场里更新。这个方案省下下载、存储、权限、通知等一整套适配工作,很多体量不小的应用都在用。
如果必须应用内下载,可以考虑成熟的开源更新库或分发平台。但要注意很多第三方更新 SDK 已经停止维护,接入前先看下 GitHub 最后提交时间、issue 活跃度和隐私合规声明。其实自建方案的代码量并不多,核心模块控制在几百行以内,长期维护成本不一定比第三方高。自己写还能去掉多余权限,减少隐私合规风险。
我个人做过的更新模块,最崩溃的一次是客户反馈“所有用户都更新不了”,最后发现是 CDN 配了跨域限制。普通浏览器打开下载地址正常,App 内下载却一直 403。从那以后我要求更新包地址必须是简单 GET 就能下载的静态资源,不能用带签名过期时间的动态 URL,签名字段一过期,老用户就再也更新不了,线上排查非常痛苦。
如果你要给老项目加版本更新,建议从“检查更新 + 跳转市场”做起,先跑通完整闭环,再逐步加应用内下载、断点续传、MD5 校验。版本更新看是简单功能,实际横跨网络、存储、通知、权限、系统兼容,把每一步做扎实,用户升级时才会少遇到问题,你的崩溃上报后台也会干净很多。