用內(nèi)更新全流程:從下載到安裝的兼容性實(shí)現(xiàn)與避坑指南)
1. 項(xiàng)目概述從需求到實(shí)現(xiàn)的完整閉環(huán)在移動(dòng)應(yīng)用開(kāi)發(fā)與維護(hù)的日常工作中有一個(gè)場(chǎng)景幾乎每個(gè)開(kāi)發(fā)者都會(huì)遇到應(yīng)用內(nèi)版本更新。用戶(hù)不可能每次都去應(yīng)用商店手動(dòng)下載新版本一個(gè)流暢、可靠、用戶(hù)無(wú)感的應(yīng)用內(nèi)更新體驗(yàn)是提升產(chǎn)品口碑和用戶(hù)留存的關(guān)鍵。這個(gè)需求的核心就是實(shí)現(xiàn)“Android下載APK并安裝APK”。聽(tīng)起來(lái)簡(jiǎn)單不就是從網(wǎng)絡(luò)拉個(gè)文件然后點(diǎn)一下安裝嗎但如果你真這么想那踩坑之路就在眼前了。從Android 7.0的FileProvider適配到Android 8.0的未知來(lái)源應(yīng)用安裝權(quán)限再到Android 11的Scoped Storage和包可見(jiàn)性以及后臺(tái)下載的穩(wěn)定性、安裝過(guò)程的兼容性每一步都藏著細(xì)節(jié)和“玄學(xué)”。我經(jīng)歷過(guò)因?yàn)镕ileProvider的authorities配置錯(cuò)誤導(dǎo)致安裝閃退也處理過(guò)在部分國(guó)產(chǎn)定制系統(tǒng)上權(quán)限彈窗不彈出的詭異問(wèn)題。所以今天我想拋開(kāi)那些簡(jiǎn)單的Demo代碼從一個(gè)需要實(shí)際上線、面對(duì)海量用戶(hù)和各種奇奇怪怪手機(jī)型號(hào)的視角來(lái)完整拆解這個(gè)功能。我們會(huì)涵蓋從網(wǎng)絡(luò)請(qǐng)求、文件下載、存儲(chǔ)路徑選擇、運(yùn)行時(shí)權(quán)限動(dòng)態(tài)申請(qǐng)、不同Android版本的安裝適配到錯(cuò)誤處理和用戶(hù)體驗(yàn)優(yōu)化的全流程。目標(biāo)不僅是讓你能寫(xiě)出代碼更是讓你理解每一行代碼背后的“為什么”以及當(dāng)出現(xiàn)問(wèn)題的時(shí)候你該如何像老中醫(yī)一樣“望聞問(wèn)切”。2. 核心思路與架構(gòu)設(shè)計(jì)在動(dòng)手寫(xiě)代碼之前我們先得把思路理清楚。一個(gè)健壯的更新功能不能是“一次性”的腳本它應(yīng)該是一個(gè)有狀態(tài)、可管理、善始善終的完整流程。2.1 功能流程拆解整個(gè)流程可以抽象為以下幾個(gè)核心階段它們構(gòu)成了一個(gè)清晰的有限狀態(tài)機(jī)檢查更新向你的服務(wù)器接口請(qǐng)求比對(duì)當(dāng)前版本與最新版本信息。這部分涉及網(wǎng)絡(luò)請(qǐng)求和版本號(hào)解析注意不要簡(jiǎn)單比較字符串要使用VersionCode這類(lèi)整型值。用戶(hù)交互與確認(rèn)彈出更新對(duì)話框展示新版本特性、更新包大小等信息等待用戶(hù)確認(rèn)。這里可以設(shè)計(jì)“強(qiáng)制更新”和“可選更新”兩種模式。下載任務(wù)管理用戶(hù)確認(rèn)后啟動(dòng)下載任務(wù)。這里的關(guān)鍵是后臺(tái)服務(wù)與通知欄進(jìn)度的結(jié)合。用戶(hù)切到后臺(tái)甚至鎖屏下載仍需繼續(xù)并且要讓用戶(hù)能清晰地看到進(jìn)度。文件存儲(chǔ)與校驗(yàn)APK文件下載到設(shè)備的哪個(gè)位置下載完成后如何校驗(yàn)文件的完整性例如比對(duì)MD5或文件大小存儲(chǔ)路徑的選擇直接影響后續(xù)的安裝步驟。安裝觸發(fā)與兼容性處理這是最復(fù)雜的一步。你需要構(gòu)建一個(gè)指向APK文件的Uri然后發(fā)送一個(gè)ACTION_INSTALL_PACKAGE或ACTION_VIEW的Intent。但如何構(gòu)建這個(gè)Uri在Android不同版本上截然不同。安裝結(jié)果回調(diào)與清理安裝完成后無(wú)論是成功還是用戶(hù)取消都應(yīng)該有相應(yīng)的回調(diào)處理。成功則可能引導(dǎo)用戶(hù)打開(kāi)新版本失敗則給出提示。最后別忘了清理臨時(shí)文件。2.2 技術(shù)方案選型圍繞上述流程我們需要做出幾個(gè)關(guān)鍵的技術(shù)選擇網(wǎng)絡(luò)請(qǐng)求庫(kù)OkHttp是絕對(duì)的主流選擇。它穩(wěn)定、高效天然支持進(jìn)度回調(diào)是我們實(shí)現(xiàn)下載進(jìn)度監(jiān)聽(tīng)的基礎(chǔ)。后臺(tái)任務(wù)執(zhí)行雖然WorkManager是官方推薦的持久化后臺(tái)任務(wù)解決方案但對(duì)于文件下載這種需要精確控制如暫停、繼續(xù)且與通知欄強(qiáng)關(guān)聯(lián)的場(chǎng)景我更傾向于使用Service特別是ForegroundService前臺(tái)服務(wù)。從Android 8.0開(kāi)始后臺(tái)服務(wù)限制變多但前臺(tái)服務(wù)可以持續(xù)運(yùn)行并提供常駐通知完美契合下載需求。文件存儲(chǔ)這是適配的重災(zāi)區(qū)。Android 10 (API 29) 以下我們可以使用應(yīng)用私有目錄如getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)。這個(gè)路徑不需要存儲(chǔ)權(quán)限。Android 10 (API 29) 及以上由于Scoped Storage應(yīng)用私有目錄仍然是首選。但如果你希望用戶(hù)能在“文件管理”應(yīng)用中看到這個(gè)APK可能需要使用MediaStoreAPI但這會(huì)引入額外的復(fù)雜性。對(duì)于更新功能我強(qiáng)烈建議只使用應(yīng)用私有目錄這樣最安全、權(quán)限問(wèn)題最少。安裝適配這是版本適配的核心。Android 7.0 (API 24) 及以上禁止使用file://Uri直接傳遞文件必須使用FileProvider生成一個(gè)content://Uri。Android 8.0 (API 26) 及以上安裝未知來(lái)源應(yīng)用需要?jiǎng)討B(tài)申請(qǐng)REQUEST_INSTALL_PACKAGES權(quán)限不再是設(shè)置中的一個(gè)靜態(tài)開(kāi)關(guān)。Android 11 (API 30) 及以上除了上述兩點(diǎn)還引入了包可見(jiàn)性限制。如果你的targetSdkVersion 30默認(rèn)無(wú)法查詢(xún)到其他應(yīng)用的信息也無(wú)法直接安裝其他應(yīng)用除非是應(yīng)用商店。你需要在中聲明queries或者使用PackageInstallerAPI進(jìn)行更底層的安裝。基于以上分析我們的架構(gòu)藍(lán)圖就清晰了使用OkHttp在ForegroundService中執(zhí)行下載將APK保存到應(yīng)用私有目錄下載完成后根據(jù)系統(tǒng)版本動(dòng)態(tài)處理權(quán)限和Uri最后發(fā)送安裝Intent。3. 核心實(shí)現(xiàn)與代碼詳解理論說(shuō)完我們進(jìn)入實(shí)戰(zhàn)環(huán)節(jié)。我會(huì)把代碼分成幾個(gè)關(guān)鍵模塊并解釋每一處的設(shè)計(jì)考量。3.1 基礎(chǔ)配置與聲明首先在AndroidManifest.xml中我們需要添加必要的權(quán)限、組件和Provider聲明。!-- 網(wǎng)絡(luò)權(quán)限 -- uses-permission android:nameandroid.permission.INTERNET / !-- 寫(xiě)入外部存儲(chǔ)在Android 10以下如果存到公共目錄可能需要 -- uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion28 / !-- 僅對(duì)Android 9及以下生效 -- !-- 安裝未知來(lái)源應(yīng)用的權(quán)限Android 8.0 -- uses-permission android:nameandroid.permission.REQUEST_INSTALL_PACKAGES/ !-- 前臺(tái)服務(wù)類(lèi)型聲明Android 9.0 -- uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / !-- 聲明FileProvider -- application ... provider 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 !-- 聲明我們的下載服務(wù) -- service android:name.service.ApkDownloadService android:enabledtrue android:exportedfalse / /application接下來(lái)創(chuàng)建res/xml/file_paths.xml文件定義FileProvider可以共享的文件路徑。?xml version1.0 encodingutf-8? paths xmlns:androidhttp://schemas.android.com/apk/res/android !-- 對(duì)應(yīng) Context.getExternalFilesDir() 返回的路徑 -- external-files-path nameexternal_files path. / !-- 對(duì)應(yīng) Context.getFilesDir() 返回的路徑 -- files-path nameinternal_files path. / !-- 對(duì)應(yīng) Environment.getExternalStorageDirectory() 返回的路徑謹(jǐn)慎使用 -- external-path nameexternal_storage_root path. / /paths注意authorities屬性必須全局唯一通常使用${applicationId}你的應(yīng)用包名作為前綴后面加上.fileprovider這樣可以有效避免與其他應(yīng)用沖突。grantUriPermissionstrue是必須的它允許我們臨時(shí)授權(quán)給安裝程序訪問(wèn)Uri的權(quán)限。3.2 下載服務(wù)ForegroundService與OkHttp我們創(chuàng)建一個(gè)ApkDownloadService它繼承自Service并在開(kāi)始下載時(shí)啟動(dòng)為前臺(tái)服務(wù)。// 使用Kotlin編寫(xiě)邏輯更清晰 class ApkDownloadService : Service() { private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } private val channelId apk_download_channel private val notificationId 1 private var downloadCall: Call? null // 用于取消下載 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { intent?.getStringExtra(EXTRA_DOWNLOAD_URL)?.let { url - startForegroundDownload(url) } // 如果服務(wù)被殺死不自動(dòng)重啟避免重復(fù)下載 return START_NOT_STICKY } private fun startForegroundDownload(downloadUrl: String) { // 1. 創(chuàng)建通知渠道Android 8.0 if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { val channel NotificationChannel( channelId, 應(yīng)用更新, NotificationManager.IMPORTANCE_LOW ).apply { description 下載新版本安裝包 setSound(null, null) // 下載通知通常不需要聲音 lockscreenVisibility Notification.VISIBILITY_PUBLIC } notificationManager.createNotificationChannel(channel) } // 2. 構(gòu)建初始通知 val notification NotificationCompat.Builder(this, channelId) .setContentTitle(正在下載更新) .setContentText(準(zhǔn)備中...) .setSmallIcon(android.R.drawable.stat_sys_download) // 使用系統(tǒng)圖標(biāo) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) // 持續(xù)通知 .setOnlyAlertOnce(true) // 僅首次提醒 .build() // 3. 啟動(dòng)為前臺(tái)服務(wù) startForeground(notificationId, notification) // 4. 開(kāi)始異步下載 downloadApk(downloadUrl) } private fun downloadApk(url: String) { // 確定存儲(chǔ)路徑和文件名 val apkDir getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) // 文件名可以從URL解析或服務(wù)器返回這里簡(jiǎn)單處理 val apkName update_${System.currentTimeMillis()}.apk val apkFile File(apkDir, apkName) val request Request.Builder().url(url).build() val client OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() downloadCall client.newCall(request) downloadCall?.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { // 下載失敗更新通知并停止前臺(tái)服務(wù) updateNotification(下載失敗: ${e.message}, false) stopForeground(true) // 可以通過(guò)廣播等方式通知Activity更新UI sendDownloadFailedBroadcast(e.message) } override fun onResponse(call: Call, response: Response) { if (!response.isSuccessful) { onFailure(call, IOException(服務(wù)器錯(cuò)誤: ${response.code})) return } response.body?.let { body - val totalLength body.contentLength() var downloadedLength: Long 0 val buffer ByteArray(8192) // 8K緩沖區(qū) var inputStream: InputStream? null var outputStream: FileOutputStream? null try { inputStream body.byteStream() outputStream FileOutputStream(apkFile) var read: Int while (inputStream.read(buffer).also { read it } ! -1) { outputStream.write(buffer, 0, read) downloadedLength read.toLong() // 計(jì)算進(jìn)度更新通知避免更新太頻繁 val progress (downloadedLength * 100 / totalLength).toInt() runOnUiThread { // 通知更新需在主線程 updateNotification(下載中 $progress%, true, progress) } } outputStream.flush() // 下載成功 runOnUiThread { updateNotification(下載完成點(diǎn)擊安裝, false) stopForeground(false) // 停止前臺(tái)服務(wù)但保留通知 // 發(fā)送下載成功廣播攜帶文件路徑 sendDownloadSuccessBroadcast(apkFile.absolutePath) } } catch (e: IOException) { apkFile.delete() // 刪除不完整的文件 onFailure(call, e) } finally { inputStream?.close() outputStream?.close() } } } }) } private fun updateNotification(contentText: String, ongoing: Boolean, progress: Int 0) { val builder NotificationCompat.Builder(this, channelId) .setContentTitle(應(yīng)用更新) .setContentText(contentText) .setSmallIcon(android.R.drawable.stat_sys_download) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(ongoing) .setOnlyAlertOnce(true) if (ongoing progress in 0..100) { builder.setProgress(100, progress, false) // 確定性進(jìn)度條 } notificationManager.notify(notificationId, builder.build()) } override fun onDestroy() { downloadCall?.cancel() // 服務(wù)銷(xiāo)毀時(shí)取消網(wǎng)絡(luò)請(qǐng)求 super.onDestroy() } override fun onBind(intent: Intent?): IBinder? null companion object { const val EXTRA_DOWNLOAD_URL extra_download_url // 啟動(dòng)服務(wù)的方法 fun start(context: Context, downloadUrl: String) { val intent Intent(context, ApkDownloadService::class.java).apply { putExtra(EXTRA_DOWNLOAD_URL, downloadUrl) } if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { context.startForegroundService(intent) } else { context.startService(intent) } } } }實(shí)操心得在onStartCommand中返回START_NOT_STICKY非常重要。如果下載服務(wù)因?yàn)橄到y(tǒng)資源緊張被殺死我們不希望它自動(dòng)重啟因?yàn)檫@可能導(dǎo)致重復(fù)下載或狀態(tài)混亂。更好的做法是由前臺(tái)的Activity或一個(gè)持久化的任務(wù)調(diào)度器如WorkManager來(lái)監(jiān)聽(tīng)網(wǎng)絡(luò)狀態(tài)當(dāng)服務(wù)意外停止時(shí)由它們決定是否重新觸發(fā)下載流程。此外進(jìn)度更新不要每次寫(xiě)入都刷新通知可以設(shè)置一個(gè)閾值比如進(jìn)度每增加5%更新一次或者使用Handler進(jìn)行延遲更新以減少性能開(kāi)銷(xiāo)。3.3 安裝適配權(quán)限、Uri與Intent下載完成后我們得到了APK文件的路徑。接下來(lái)就是最關(guān)鍵的安裝步驟。我們需要一個(gè)統(tǒng)一的安裝入口它能處理所有Android版本的差異。object ApkInstaller { /** * 安裝APK文件 * param context Context * param apkFilePath APK文件的絕對(duì)路徑 */ fun installApk(context: Context, apkFilePath: String) { val apkFile File(apkFilePath) if (!apkFile.exists()) { Toast.makeText(context, 安裝文件不存在, Toast.LENGTH_SHORT).show() return } // 步驟1: 檢查并申請(qǐng)安裝未知來(lái)源權(quán)限 (Android 8.0) if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { if (!context.packageManager.canRequestPackageInstalls()) { // 沒(méi)有權(quán)限跳轉(zhuǎn)設(shè)置頁(yè)面申請(qǐng) val intent Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { data Uri.parse(package:${context.packageName}) } // 這里需要在一個(gè)Activity中啟動(dòng)并處理onActivityResult // 通常我們會(huì)先彈窗提示用戶(hù)然后在用戶(hù)確認(rèn)后啟動(dòng)這個(gè)Intent // 為了示例清晰這里假設(shè)調(diào)用者是一個(gè)Activity并會(huì)處理權(quán)限回調(diào) (context as? Activity)?.startActivityForResult(intent, REQUEST_CODE_INSTALL_PERMISSION) return // 等待權(quán)限申請(qǐng)結(jié)果 } } // 步驟2: 獲取APK文件的Uri (適配Android 7.0) val apkUri getApkUri(context, apkFile) // 步驟3: 構(gòu)建安裝Intent并啟動(dòng) val installIntent Intent(Intent.ACTION_VIEW).apply { setDataAndType(apkUri, application/vnd.android.package-archive) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // 授予臨時(shí)讀取權(quán)限 // Android 11 可能需要添加此Flag以允許安裝完成后返回原應(yīng)用 if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } } // 步驟4: 為目標(biāo)Intent選擇Activity (處理包可見(jiàn)性Android 11) val resolveInfoList context.packageManager.queryIntentActivities( installIntent, if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()) } else { PackageManager.MATCH_DEFAULT_ONLY } ) if (resolveInfoList.isEmpty()) { // 沒(méi)有應(yīng)用能處理安裝Intent這幾乎不可能發(fā)生除非系統(tǒng)被嚴(yán)重修改 Toast.makeText(context, 無(wú)法找到安裝程序, Toast.LENGTH_SHORT).show() return } // 步驟5: 啟動(dòng)安裝頁(yè)面 context.startActivity(installIntent) } /** * 根據(jù)Android版本獲取正確的Uri */ private fun getApkUri(context: Context, apkFile: File): Uri { return if (Build.VERSION.SDK_INT Build.VERSION_CODES.N) { // Android 7.0 使用FileProvider FileProvider.getUriForFile( context, ${context.packageName}.fileprovider, // 必須與manifest中聲明的authorities一致 apkFile ) } else { // Android 7.0以下使用傳統(tǒng)的file:// Uri Uri.fromFile(apkFile) } } const val REQUEST_CODE_INSTALL_PERMISSION 10001 }在調(diào)用installApk的Activity中你需要處理權(quán)限申請(qǐng)的回調(diào)class MainActivity : AppCompatActivity() { ... private var pendingApkPath: String? null // 保存等待安裝的文件路徑 fun onDownloadComplete(apkPath: String) { pendingApkPath apkPath ApkInstaller.installApk(this, apkPath) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode ApkInstaller.REQUEST_CODE_INSTALL_PERMISSION) { // 用戶(hù)從設(shè)置頁(yè)面返回再次檢查權(quán)限 if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { if (packageManager.canRequestPackageInstalls()) { // 用戶(hù)已授權(quán)繼續(xù)安裝 pendingApkPath?.let { ApkInstaller.installApk(this, it) } } else { Toast.makeText(this, 需要授權(quán)才能安裝應(yīng)用, Toast.LENGTH_SHORT).show() } } pendingApkPath null } } }注意事項(xiàng)FileProvider.getUriForFile的第二個(gè)參數(shù)authorities必須與AndroidManifest.xml中provider標(biāo)簽下定義的android:authorities屬性完全一致包括大小寫(xiě)。這是最常見(jiàn)的錯(cuò)誤來(lái)源之一會(huì)導(dǎo)致安裝時(shí)出現(xiàn)“解析包出錯(cuò)”或直接閃退。另外在Android 11上即使你正確申請(qǐng)了REQUEST_INSTALL_PACKAGES權(quán)限如果targetSdkVersion 30默認(rèn)也無(wú)法通過(guò)queryIntentActivities查到系統(tǒng)安裝器。你需要在AndroidManifest.xml的queries標(biāo)簽內(nèi)聲明intent或者使用PackageInstallerAPI。對(duì)于大多數(shù)應(yīng)用聲明queries更簡(jiǎn)單manifest ... queries !-- 為了啟動(dòng)系統(tǒng)安裝界面 -- intent action android:nameandroid.intent.action.INSTALL_PACKAGE / data android:mimeTypeapplication/vnd.android.package-archive / /intent !-- 也可以聲明VIEW intent更通用 -- intent action android:nameandroid.intent.action.VIEW / category android:nameandroid.intent.category.DEFAULT / data android:mimeTypeapplication/vnd.android.package-archive / /intent /queries ... /manifest3.4 整合與UI交互現(xiàn)在我們將檢查更新、下載、安裝串聯(lián)起來(lái)并設(shè)計(jì)一個(gè)友好的用戶(hù)界面。1. 檢查更新通常你有一個(gè)服務(wù)器接口返回類(lèi)似這樣的JSON{ latestVersionCode: 20240501, latestVersionName: 2.1.0, downloadUrl: https://your-cdn.com/app/update_2.1.0.apk, updateLog: 1. 修復(fù)了已知問(wèn)題\n2. 提升了性能, forceUpdate: false, fileSize: 50485760 // 單位字節(jié) }在客戶(hù)端解析后與當(dāng)前版本BuildConfig.VERSION_CODE比較決定是否彈出更新對(duì)話框。2. 更新對(duì)話框使用AlertDialog或自定義Dialog展示版本信息、更新日志和文件大小。提供“立即更新”和“稍后再說(shuō)”按鈕如果是強(qiáng)制更新則只保留“立即更新”并不可取消。3. 啟動(dòng)下載用戶(hù)點(diǎn)擊“立即更新”后首先檢查必要的運(yùn)行時(shí)權(quán)限如Android 13的POST_NOTIFICATIONS通知權(quán)限然后啟動(dòng)我們的ApkDownloadService。// 在Activity或ViewModel中 fun startDownload(downloadUrl: String) { // 檢查通知權(quán)限 (Android 13) if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ! PackageManager.PERMISSION_GRANTED) { // 請(qǐng)求權(quán)限在回調(diào)中再啟動(dòng)服務(wù) requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_CODE_NOTIFICATION) return } } // 啟動(dòng)下載服務(wù) ApkDownloadService.start(context, downloadUrl) // 可以在這里顯示一個(gè)進(jìn)度條在UI上與服務(wù)通過(guò)LiveData或廣播通信 }4. 監(jiān)聽(tīng)下載進(jìn)度與完成ApkDownloadService通過(guò)LocalBroadcastManager或更好的方式如LiveData在ViewModel中將下載進(jìn)度和完成事件通知給UI。UI層更新進(jìn)度條并在下載完成后調(diào)用ApkInstaller.installApk。4. 避坑指南與進(jìn)階優(yōu)化實(shí)現(xiàn)基本功能后我們來(lái)看看那些容易踩坑的地方和一些優(yōu)化技巧。4.1 常見(jiàn)問(wèn)題排查表問(wèn)題現(xiàn)象可能原因解決方案點(diǎn)擊安裝沒(méi)反應(yīng)1. Uri構(gòu)建錯(cuò)誤FileProvider配置問(wèn)題。2. Android 8.0未動(dòng)態(tài)申請(qǐng)REQUEST_INSTALL_PACKAGES權(quán)限。3. Android 11包可見(jiàn)性問(wèn)題。1. 檢查file_paths.xml和authorities是否匹配使用adb logcat查看相關(guān)錯(cuò)誤。2. 在安裝前調(diào)用PackageManager.canRequestPackageInstalls()檢查并引導(dǎo)用戶(hù)授權(quán)。3. 在AndroidManifest.xml中添加queries聲明。安裝時(shí)提示“解析包時(shí)出現(xiàn)問(wèn)題”1. APK文件下載不完整或損壞。2. APK文件與當(dāng)前CPU架構(gòu)不兼容如x86設(shè)備安裝了arm包。3. 簽名不一致覆蓋安裝時(shí)。1. 下載完成后校驗(yàn)文件MD5或大小。2. 服務(wù)器應(yīng)提供適配不同ABI的APK或提供通用包universal APK。3. 確保用于更新的APK簽名與已安裝版本一致。通知欄不顯示下載進(jìn)度1. Android 8.0未創(chuàng)建通知渠道。2. 前臺(tái)服務(wù)未正確啟動(dòng)startForeground沒(méi)調(diào)用或調(diào)用太晚。3. 通知被用戶(hù)關(guān)閉或系統(tǒng)限制。1. 確保在startForeground前創(chuàng)建NotificationChannel。2. 在onStartCommand中盡快調(diào)用startForeground。3. 檢查應(yīng)用的通知權(quán)限是否被關(guān)閉。下載到一半中斷無(wú)法續(xù)傳使用OkHttp的普通Call不支持?jǐn)帱c(diǎn)續(xù)傳。使用OkHttp的Interceptor記錄已下載大小并在請(qǐng)求頭中添加Range: bytes已下載大小-?;蛘呤褂酶鼘?zhuān)業(yè)的下載庫(kù)如aria2c的封裝或FileDownloader。在部分國(guó)產(chǎn)ROM如MIUI、EMUI上安裝失敗廠商定制系統(tǒng)增加了額外的權(quán)限管理或后臺(tái)限制。1. 引導(dǎo)用戶(hù)將應(yīng)用加入“自啟動(dòng)”和“后臺(tái)運(yùn)行”白名單。2. 嘗試使用PackageInstallerAPI直接安裝繞過(guò)系統(tǒng)安裝界面需要系統(tǒng)權(quán)限或特殊適配不通用。4.2 進(jìn)階優(yōu)化技巧文件校驗(yàn)與安全服務(wù)器在提供下載鏈接時(shí)同時(shí)提供APK文件的MD5或SHA256值。下載完成后在本地計(jì)算文件的哈希值進(jìn)行比對(duì)確保文件未被篡改或損壞。fun verifyFile(file: File, expectedMd5: String): Boolean { val digest MessageDigest.getInstance(MD5) file.inputStream().use { input - val buffer ByteArray(8192) var read: Int while (input.read(buffer).also { read it } ! -1) { digest.update(buffer, 0, read) } } val actualMd5 digest.digest().joinToString() { %02x.format(it) } return actualMd5.equals(expectedMd5, ignoreCase true) }智能重試與網(wǎng)絡(luò)環(huán)境判斷下載失敗后不要立即無(wú)腦重試。可以設(shè)計(jì)一個(gè)指數(shù)退避的重試機(jī)制。在開(kāi)始下載前判斷網(wǎng)絡(luò)環(huán)境。如果是移動(dòng)數(shù)據(jù)且APK文件很大可以提示用戶(hù)是否繼續(xù)或者設(shè)置為僅在Wi-Fi下自動(dòng)下載。使用DownloadManager備選方案對(duì)于不需要精細(xì)控制下載過(guò)程如暫停、續(xù)傳的場(chǎng)景可以考慮使用系統(tǒng)自帶的DownloadManager。它自動(dòng)處理通知、網(wǎng)絡(luò)切換和重試但缺點(diǎn)是回調(diào)不夠靈活且在部分定制系統(tǒng)上行為不一致。安裝完成后的回調(diào)發(fā)送安裝Intent后我們無(wú)法直接知道用戶(hù)是安裝了還是取消了。一個(gè)變通的方法是在發(fā)送Intent前記錄當(dāng)前版本號(hào)稍后例如應(yīng)用回到前臺(tái)時(shí)再次檢查版本號(hào)。如果版本號(hào)變了說(shuō)明安裝成功可以提示用戶(hù)新特性。清理舊安裝包每次更新都會(huì)產(chǎn)生新的APK文件。可以在成功安裝后或應(yīng)用啟動(dòng)時(shí)掃描下載目錄刪除除最新安裝包外的所有歷史APK文件避免占用用戶(hù)存儲(chǔ)空間。4.3 針對(duì)特定場(chǎng)景的補(bǔ)充系統(tǒng)應(yīng)用或Root環(huán)境如果你開(kāi)發(fā)的是系統(tǒng)應(yīng)用或擁有root權(quán)限安裝過(guò)程會(huì)簡(jiǎn)單很多可以直接使用pm install命令無(wú)需處理權(quán)限和FileProvider。但這屬于特殊場(chǎng)景不適用于普通應(yīng)用。插件化或熱更新本文討論的是完整的APK安裝更新。如果是插件化框架需要將APK下載后通過(guò)DexClassLoader等機(jī)制動(dòng)態(tài)加載安裝步驟完全不同。Google Play發(fā)布如果應(yīng)用上架了Google Play強(qiáng)烈建議使用其內(nèi)置的In-app updatesAPI它提供了更原生、更流暢的更新體驗(yàn)包括靈活更新和即時(shí)更新兩種模式能繞過(guò)未知來(lái)源安裝的限制。5. 總結(jié)與個(gè)人體會(huì)實(shí)現(xiàn)一個(gè)健壯的Android應(yīng)用內(nèi)更新功能就像在錯(cuò)綜復(fù)雜的迷宮中鋪設(shè)一條穩(wěn)定可靠的軌道。它不僅僅是“下載安裝”兩個(gè)動(dòng)作的拼接而是網(wǎng)絡(luò)、存儲(chǔ)、權(quán)限、系統(tǒng)版本、用戶(hù)交互和異常處理等多個(gè)領(lǐng)域的交集。從我自己的踩坑經(jīng)驗(yàn)來(lái)看兼容性永遠(yuǎn)是第一位的。你永遠(yuǎn)不知道用戶(hù)手里拿著的是哪個(gè)品牌、哪個(gè)版本、經(jīng)過(guò)怎樣魔改的系統(tǒng)。因此代碼不能只在自己和測(cè)試人員的手機(jī)上跑通就完事必須充分考慮邊界情況低存儲(chǔ)空間怎么辦下載中途來(lái)電話斷網(wǎng)怎么辦用戶(hù)拒絕安裝權(quán)限后又反悔怎么辦其次用戶(hù)體驗(yàn)的細(xì)節(jié)決定成敗。一個(gè)帶清晰進(jìn)度條的通知一個(gè)下載完成后自動(dòng)彈出的安裝提示一句對(duì)文件大小的貼心說(shuō)明都能讓用戶(hù)感受到產(chǎn)品的用心。相反如果下載過(guò)程毫無(wú)反饋安裝時(shí)又跳出一堆令人困惑的系統(tǒng)彈窗更新率必然會(huì)大打折扣。最后安全與穩(wěn)定是底線。校驗(yàn)文件完整性可以防止傳輸錯(cuò)誤或惡意劫持。使用FileProvider和正確的權(quán)限申請(qǐng)流程是遵循Android安全規(guī)范的基本要求。后臺(tái)下載服務(wù)的保活與資源回收也需要仔細(xì)權(quán)衡不能為了下載而過(guò)度消耗電量或流量。把這份代碼和思路應(yīng)用到你的項(xiàng)目中時(shí)建議你先在一個(gè)簡(jiǎn)單的Demo里跑通整個(gè)流程然后逐步將它集成到你的主應(yīng)用里并加上完善的日志記錄。這樣當(dāng)線上用戶(hù)反饋更新失敗時(shí)你才能快速定位問(wèn)題所在。記住沒(méi)有一勞永逸的代碼只有對(duì)細(xì)節(jié)的不斷打磨和對(duì)不同設(shè)備環(huán)境的持續(xù)敬畏才能做出讓用戶(hù)滿意的更新體驗(yàn)。