做安卓开发这些年我接手过不少老项目几乎每个项目迭代到中后期都会被同一个需求找上门要在应用里加一个“安卓应用版本更新”功能。这个需求看着简单——后台返回个新版本号用户点一下下载安装完事。但真要是顺着这个思路做后面等着你的是一连串问题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 9API 28开始系统默认禁止明文传输 HTTP 流量。如果你的下载地址是http://而且 targetSdk 设置得又比较高下载时会直接报Cleartext HTTP traffic to xxx not permitted。解决办法有两种最好是把文件服务升级为 https一劳永逸如果短时间改不了可以临时在 manifest 里放行明文流量application android:usesCleartextTraffictrue ... 更稳妥一点的做法是用 networkSecurityConfig 只放行指定域名而不是全局放开。先建一个res/xml/network_security_config.xmlnetwork-security-config domain-config cleartextTrafficPermittedtrue domain includeSubdomainstruecdn.example.com/domain /domain-config /network-security-config再在 AndroidManifest.xml 的 application 节点引用它application android:networkSecurityConfigxml/network_security_config ... 这里要特别提醒即便临时放行了 httpAPK 在传输过程中也可能被运营商或中间节点篡改所以 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.0API 24开始应用之间共享file://Uri 会直接抛出FileUriExposedException。所以安装 APK 必须用 FileProvider把真实文件路径转换成content://Uri并临时授权给系统安装器。在 AndroidManifest.xml 里注册 FileProviderprovider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider在 res/xml/file_paths.xml 里声明可访问的路径paths external-files-path namedownload pathdownload/ / files-path nameinternal pathupdate/ / /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.0API 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 10API 29引入分区存储后公共 Download 目录不能随意写文件。更新包放在应用私有目录完全不受影响所以我的做法是一律把 APK 下载到应用私有目录安装时靠 FileProvider 授权给系统安装器不申请存储权限。Android 11API 30开始应用包可见性发生了变化。如果你要查询某个应用是否安装或者要跳转其他应用需要在 manifest 里声明queries。但如果是拉起系统安装器安装自己的 APK常规startActivity不一定受影响不过我还是建议跳转前做 try-catch防止个别 ROM 上因为 Intent 解析失败直接崩溃。Android 13API 33新增了通知运行时权限POST_NOTIFICATIONS。如果用户没有授予通知权限前台服务的通知栏可能不显示但下载服务本身还能跑。用户看不到进度会以为是坏掉了所以开始下载前最好先请求通知权限。Android 14API 34对前台服务类型有更严格要求。如果 targetSdk 为 34需要给服务声明foregroundServiceType下载场景一般用dataSync同时要在 manifest 里声明FOREGROUND_SERVICE和FOREGROUND_SERVICE_DATA_SYNC权限uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.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:foregroundServiceTypedataSync android:exportedfalse /启动服务后立刻调用 startForegroundstartForeground(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 校验。版本更新看是简单功能实际横跨网络、存储、通知、权限、系统兼容把每一步做扎实用户升级时才会少遇到问题你的崩溃上报后台也会干净很多。