ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android后台下载实战:前台服务、WorkManager与断点续传全解析

Android后台下载实战:前台服务、WorkManager与断点续传全解析

简介:面向Android开发者的后台下载示例工程,集中演示IntentService、AsyncTask与JobScheduler三种主流实现方式,并给出兼容旧版本设备的适配思路,适合需要理解后台任务调度与多版本兼容的初中级开发者。压缩包共67个文件,以Java源码、XML配置为主,另有WebP图片、Gradle构建脚本及混淆配置,整体仅376KB,可直接在Android Studio中导入运行。已有518人浏览学习。通过该项目可清晰看到三种方案的编码差异与取舍:IntentService适合一次性任务,AsyncTask便于更新UI但易被中断,JobScheduler则能结合网络与电量条件灵活调度。同时附带完整工程结构与构建配置,方便读者基于真实项目复现和验证不同下载策略。

1. 为什么 Android 后台下载总做不对:90% 的失败发生在“进程活着”这个前提之前

做 Android 后台下载,最常见的翻车不是网速不够、不是服务器限速,而是任务刚切到后台不到三分钟,系统就把你的 Service 杀了。新手写出一个能跑的下载功能很容易,在线程里开个 HttpURLConnection 往文件里写就行;但“能跑”和“能在后台稳定跑完几百 MB 的大文件”之间,隔着一整套 Android 后台执行限制的演进史。从 8.0 的后台服务限制,到 12 的 ForegroundServiceStartNotAllowedException,再到 13 的 POST_NOTIFICATIONS 运行时权限,每一步都在收紧“应用在后台自由干活”这条路。

这篇文章不打算从零教你怎么用 OkHttp 下载文件,那是入门教程的事。我要讲的是把“后台下载”当成一个系统工程来落地的思路:前台服务保活、系统下载管理器的取舍、WorkManager 的边界、断点续传与文件校验的坑。适合已经能写基础下载代码、但被系统限制和“下载到一半通知没了”这类玄学问题折磨过的开发者。先立住一个结论:在 Android 上做后台下载,选择哪个执行容器直接决定成功率,而这又取决于你的应用类型和系统版本的目标分布。

2. 后台执行的三个层级:Service、DownloadManager 与 WorkManager 的选型逻辑

2.1 为什么不能直接用普通 Service 做下载

很多人的第一版实现长这样:一个 Service 里开线程,循环读流、写文件、更新进度。本地测试没问题,一旦真机切后台,过一会儿 Service 就被回收了。原因在于 Android 对“后台”的定义是严格的:应用进后台后,如果没有绑定前台通知,系统随时可能因为内存压力回收进程,各厂商 ROM 的激进策略更是会直接冻结后台应用。这不是 Service 本身有 bug,而是它在系统眼中的优先级太低。

解决这件事的标准答案是“前台服务”:在 Service 启动后 5 秒内调用 startForeground(),传一个通知渠道和常驻通知,把进程优先级拉高。但这个方案有一个长期被误解的点:前台服务不是免死金牌,它只是让系统在清理进程时把你列入“尽量别动”的名单。真到了内存极度紧张的时候,系统依然会杀前台服务,你得靠 onTaskRemoved 和 START_STICKY 做补位工作。做下载这种有明确终态的任务,自杀式重启不如手动恢复:记录任务状态到数据库,下次启动时扫描未完成任务并续传。

我一般会给自己定一条铁律:凡是下载任务持续超过 1 分钟,直接用前台服务,不要指望后台 Service 能寿终正寝。内存与资源的竞争从应用启动就开始了,你可以在 manifest 里声明 android:stopWithTask="false" 来避免用户划掉任务卡片时服务跟着死掉,但这是最后一道保险,不是主要依靠。

2.2 系统自带 DownloadManager:最稳定的“黑匣子”,但也有四条硬约束

DownloadManager 是 Android 系统内置的下载管理服务,它在独立进程里运行,由系统负责网络请求、文件写入、通知展示和失败重试。对开发者来说,这是后台下载的最可靠路径,因为它不依赖你的应用进程存活。但“可靠”是用“不可控”换来的。你拿不到原始 InputStream,不能自定义请求头到任意程度,不能精确控制存储路径和文件名策略,也不能拦截重定向做业务判断。

它的四条硬约束常常在选型时被忽略:

第一,下载文件默认写入系统公共下载目录(或你通过 setDestinationInExternalPublicDir / setDestinationInExternalFilesDir 指定的目录),应用卸载后文件残留,敏感资料的清理是个麻烦。第二,对于需要携带 Cookie、动态签名的下载地址,DownloadManager 支持有限,能用但不够灵活。第三,通知栏进度条样式和支持度在不同厂商 ROM 上有差异,这块属于黑匣子,控制不了。第四,断点续传依赖服务端支持 Range 头,如果服务端无视 Range 请求直接返回 200,DownloadManager 会从头开始重下,虽然没有损坏文件,但浪费流量。

适合用 DownloadManager 的场景我通常这么判断:应用生命周期内下载内容量不大、文件不需要做复杂的业务加密、服务器允许简单鉴权或文件本身公开可访问。如果你的下载地址需要每次签临时 Token,而且 Token 半小时就失效,建议放弃 DownloadManager,走前台服务方案。

2.3 WorkManager:把“后台下载”重新定义为“延迟保证执行的最小单元”

WorkManager 解决的是异步延迟任务问题,它不保证“任务立刻执行”,但保证“在条件满足后会执行”。这正好适合大文件下载之外的场景:把任务排队、失败重试、设备重启后恢复。但很多人误用 WorkManager 做长时下载,结果发现它跑的时机不受控——系统会聚合多个应用的 Job 批量执行,你的下载任务可能被推迟到“网络变为不限流量”或者“充电状态”才启动。

我见过一个实际案例(来自 A 同学的一个项目),他用 WorkManager 下载 50 MB 更新包,等了两小时任务都没触发,用户以为应用坏了。后来查日志发现系统在等设备“空闲”状态到达才执行。所以我的判断是:WorkManager 适合把下载拆成小批次(比如离线地图按瓦片分包下载)或做“先下载、后校验、再通知”的编排;不适合跑单个几百 MB、用户盯着进度条等结果的任务。

如果非要用 WorkManager 控制下载时机,可以传入 Constraints 限定网络类型和充电状态,但不要传 PeriodicWorkRequest 去做计划任务式的下载,系统对周期任务的执行窗口非常不友好。真要定期检查更新包,更好的路径是 App 启动时用一个 OneTimeWorkRequest 触发检查,而不是依赖周期调度。

3. 前台服务下载的完整实现:从通知渠道到断点续传

3.1 最小可跑的代码骨架:权限、前台服务类型与通知渠道

如果你确定要走前台服务路线,Android 14(targetSdk 34)引入了一个必须处理的变更:前台服务必须声明具体类型,并且在启动时匹配这个类型。下载场景对应的是 dataSync(数据同步)或者特化后的 download 类型——具体使用哪个,取决于服务的主要工作是否被系统归类为“用户可感知的下载行为”。这里给出一个可直接落地的骨架,targetSdk 34 兼容写法。

先看 Manifest 声明:

<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <service android:name=".DownloadService" android:exported="false" android:foregroundServiceType="dataSync" />

FOREGROUND_SERVICE_DATA_SYNC 是 Android 14 新增的权限,不声明会在启动前台服务时直接抛 SecurityException。POST_NOTIFICATIONS 用于 Android 13+ 的通知运行时权限,它决定你的常驻通知是否可见——注意,前台服务通知可以不需要授权也能出现,但会被折叠进“正在运行”区域,不可见时用户感知不到下载进度,所以最好在首次下载前主动申请。

接下来是通知渠道和启动前台服务的代码。通知渠道的初始化建议放在 Application 里完成,避免重复创建渠道导致系统忽略更新:

class DownloadApp : Application() { override fun onCreate() { super.onCreate() createDownloadChannel() } private fun createDownloadChannel() { val channel = NotificationChannel( CHANNEL_ID, "下载任务", NotificationManager.IMPORTANCE_LOW ).apply { setShowBadge(false) description = "显示后台下载任务的进度" } getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } companion object { const val CHANNEL_ID = "download_channel" } }

IMPORTANCE_LOW 是关键参数,IMPORTANCE_DEFAULT 会让通知发出提示音和横幅,干扰用户操作;LOW 只在通知栏出现,适合静默下载场景。setShowBadge(false) 避免下载通知产生角标,这两个都是新手容易忽视、老手会直接写进去的细节。

3.2 服务内部结构:单线程、队列与状态持久化

下载服务内部我推荐用一个串行执行器(单线程 ExecutorService),不要开多个线程同时写文件。原因有二:多线程并发下载同一批文件时会争抢带宽,导致每个任务都变慢;而且断点续传的元数据如果没做好并发保护,很容易出现“两个线程同时写同一个文件的临时片段”这种数据损坏问题。对于单个大文件,串行下载+合理分片才是稳定路径;并发下载属于工程量比较大的优化,不在第一版考虑范围内。

核心结构用状态机来抽象:每个下载任务是一个持久化实体,字段包括文件 URL、存储路径、已下载字节数、总字节数、状态、失败原因。每下载 2 秒或每写入一块数据就回写一次进度到数据库和通知栏。不要每次都更新通知进度,系统对通知更新频率有限制,频繁更新反而会被丢进“繁复通知”里的风险。

实现断点续传的关键是让服务端支持 Range。你的 HTTP 客户端在每次启动下载任务前,先读取数据库里已下载字节数,然后构造带 Range 头的请求:

val request = Request.Builder() .url(url) .header("Range", "bytes=$downloaded-") .build()

服务器响应如果是 206,说明支持断点续传,从已有字节数继续写文件;如果返回 200,说明服务端忽略了 Range 头,此时必须将文件指针重置,删掉已下载的临时文件重新开始。这里有个常见的坑:临时文件的后缀要固定且业务层不可见,比如xxx.mp4.download,下载完成后做原子重命名。不要直接写在最终文件名上,否则下载到一半用户跑到文件管理器里看到半个文件,会以为程序坏了。

写文件时的 IO 缓冲建议用 32 KB 到 64 KB 的缓冲区,太小导致频繁 IO 系统调用,太大又可能造成内存峰值。下载线程每写完一个缓冲块,做一次计数累加,并通过 Handler 或协程回调更新 UI。使用协程时注意:不要把下载循环放进 withContext(Dispatchers.Main),要切到 IO 调度器上跑,回调进度时再回主线程更新。

3.3 前台服务启动时机的踩坑:StartForeground 的 5 秒硬限制

Android 12(API 31)引入了一个让很多人头疼的异常:ForegroundServiceStartNotAllowedException。当应用在后台时,直接调用 startForegroundService() 会抛这个错。它意味着:你的下载任务不能在“应用已退到后台”的状态下从后台发起,用户必须通过某种“用户可见操作”来触发,比如点击通知栏按钮、点击 App 内按钮,或者由系统广播(如网络切换)间接触发。

规避方式有两条路。第一种是引导用户操作——通知栏上放一个“开始下载”的 Action,用户点这个 Action 时,应用进程是“可见前台”的,启动前台服务合法。第二种是利用 WorkManager 的特殊前台机制:WorkManager 内部替开发者处理了前台服务启动的时序问题,它允许从 WorkManager 中触发前台服务执行,由系统在组件内部完成合法性判断。我一般把这类任务拆成:用户点击“下载”后先启动一个普通的 Activity 或弹一个 Dialog(让进程持有“可见前台”的能力),再在这个可见窗口期内启动前台服务。

另外一个高频崩溃点是忘记在 onStartCommand 里及时调用 startForeground()。系统规定:Service 启动后 5 秒内必须调用 startForeground(),否则抛 ForegroundServiceDidNotStartInTimeException。某些低端机型上,onCreate 里做复杂初始化(比如创建 OkHttpClient、打开数据库)可能导致超过 5 秒,所以必须把 startForeground() 放到线程安全的早期位置,到 onStartCommand 里第一个动作就执行。初始化任务放后台线程做,先把前台服务身份锁住,再慢慢干活。

4. DownloadManager 实战落地:查询、监听与文件访问的完整链路

4.1 发起下载请求并绑定唯一 ID

转到 DownloadManager 阵营时,代码会精简不少。核心做法是构建 DownloadManager.Request,设置网络类型约束、通知可见性、目标路径,然后调用 enqueue() 得到 downloadId,这个 ID 是后续查询进度和监听状态的唯一线索,必须持久化保存(建议存到 DataStore 或数据库)。

一个带业务校验的发起示例:

val request = DownloadManager.Request(Uri.parse(url)).apply { setAllowedOverMetered(false) // 非计费网络才允许 setAllowedOverRoaming(false) // 漫游禁止 setTitle("更新包") setDescription("正在下载 v2.3.0 更新包") setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) setDestinationInExternalPublicDir( Environment.DIRECTORY_DOWNLOADS, "app_update_v2.3.0.apk" ) setMimeType("application/vnd.android.package-archive") } val downloadId = downloadManager.enqueue(request)

setDestinationInExternalPublicDir 的第二个参数是相对路径下的子路径,不要传入完整路径,否则会在外部存储里套多层错误目录。MIME 类型设置为 APK 包类型有助于系统在下载完成后给出正确提示。但 targetSdk 29 以上,即使你指定了公共享目录,应用卸载后这些文件也依然留在设备上,敏感内容的清理逻辑要自己兜底。

4.2 查询进度与监听完成广播

DownloadManager 的进度查询是轮询式的,没有主动回调。常见做法是自己维护一个 Handler 循环,每秒从 ContentResolver 查询 downloadId 对应的列,取出 bytes_so_far 和 total_size 更新 UI。完成事件则由系统广播通知:ACTION_DOWNLOAD_COMPLETE,Intent 里带 extra 字段 DownloadManager.EXTRA_DOWNLOAD_ID,根据 ID 判断是哪条任务完成。

查询信息一个稍微隐蔽的坑:total_size 未知时会返回 -1。在服务端不返回 Content-Length、使用分块传输时,进度条永远停在“未知大小”状态。此时 UI 上不要显示“xMB / yMB”,改成“已下载 180 MB,总大小未知”或者转圈动画。

监听广播的注册要区分组件和动态注册。如果只是页面存活期间更新进度,动态注册就行;如果想要应用进程被杀后还能收到下载完成通知并做后续处理,需要定义 manifest 静态广播接收器,但实际上 Android 8.0 开始限制了隐式广播的静态注册,下载完成广播属于特例吗?并不完全。更可靠的做法是下载完成后用户打开 App 时主动扫描 downloadId 状态,与服务器对账,而不是依赖后台广播存活。

4.3 打开文件:FileUriExposedException 与 DownloadManager 的 ContentUri

下载完成后要打开 APK 安装或查看文件,新手常犯一个错误:用公开目录的绝对路径直接构建 Uri,然后调 Intent 打开,结果在 targetSdk 24 以上直接抛 FileUriExposedException。正确做法是通过 DownloadManager 拿 Content Uri:

val uri = downloadManager.getUriForDownloadedFile(downloadId)

这个 Uri 是 content:// 形式,由 DownloadManager 提供,应用可以直接读取。但要注意权限:如果下载文件属于你当前应用,通常有访问权,但系统下载目录里的文件,如果另一个应用要读取,需要 Intent.FLAG_GRANT_READ_URI_PERMISSION。这是应用间文件共享的基本规则,很容易被忽略。对于 APK 的安装,getUriForDownloadedFile 后直接转 FileProvider 再安装是更通用、更可控的方案,因为 DownloadManager 返回的 Uri 在某些 ROM 上对安装器不可见。

5. 避坑手册:后台下载最常见的 5 个“玄学”问题排查

这一章是血泪经验的合集。后台下载的坑往往不是代码逻辑错,而是系统行为与预期不一致。

5.1 切后台就暂停:为什么我的下载任务追不上网速

现象:应用退到后台几秒钟,下载速度从 10 MB/s 掉到 200 KB/s,甚至完全停止。前台服务运行正常,通知也在,但速率就是上不去。

原因:这不是系统杀掉了你的服务,而是厂商 ROM 的省电策略对后台网络访问做了限速。某些机型上,即使前台服务存在,后台应用的网络优先级也低于前台应用。

解决:确认你确实调用了 startForeground() 且通知可见。部分 ROM(国内厂商为主)需要用户在电池优化白名单中手动添加应用,你有两条路:引导用户开启“不优化电池”选项,跳转 ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS 申请豁免;或者在 App 内做一个后台运行能力的检测,当检测到用户未授权时,在下载页提示“关闭电池优化以获得稳定下载速度”。这条路属于产品决策,但技术侧要把检测能力做出来。

5.2 下载到 99% 突然失败:断点续传的假成功陷阱

现象:大文件下载到 99% 时,通知栏显示成功,但文件打开损坏。重复多次,断点续传总是“看起来成功,实际上文件不完整”。

原因:服务端返回的 Content-Length 在每次 Range 请求中可能不同,或服务器使用的是 gzip 压缩传输。压缩传输时,服务端返回的字节数是压缩后的大小,而你本地计算进度用的是解压后的字节数,两者对不上。下载结束后你算出来的“已下载字节数”恰好等于“Content-Length”,但文件还没写完。

解决:不要以“已下载字节数 == Content-Length”作为完成判据,而要以“服务器告知下载完成(即当前请求返回的字节流结束)”作为判据。在写入完成、关闭输出流之后做一次文件大小与最终预期值比对,如果不符,清理临时文件重新下载。此外,HTTP 客户端要显式设置 Accept-Encoding: identity,避免透明压缩导致长度不匹配。

5.3 应用更新后旧任务全没了:下载任务与进程生命周期脱节

现象:用户下载一半强制杀应用,再打开 App,下载记录消失了,只能从头下载。

原因:下载状态只存在内存中,没有在每次写入数据后持久化到磁盘。进程被杀后进度信息全部丢失。

解决:设计上把“下载状态”当数据库实体。每次写文件更新进度时同步写一条数据库记录,记录包含任务 ID、临时文件路径、已下载字节数、总字节数、创建时间。下次启动时扫描所有 status 为“下载中”的任务,自动发起 Range 续传。这个方案还能顺手解决一个用户体验问题:自定义下载列表的“暂停/继续”功能本质上只是停止更新该任务进度,再重新带着 Range 头启动一次。

数据库表结构建议至少包含:id、url、file_path、temp_file_path、downloaded_bytes、total_bytes、status、create_time、update_time。不要再单独维护一个内存 Map 保存进度,多个入口更新时极其容易出并发覆盖。

5.4 通知权限拒绝后:前台服务“不可见”但进程还在跑

现象:Android 13 及以上,用户拒绝了通知权限。此时你启动前台服务,不崩溃、服务也还在跑,但通知完全不显示。用户以为下载已经停止,反复点按钮触发重复下载。

原因:POST_NOTIFICATIONS 权限被拒绝后,前台服务通知会被系统折叠且不展示。服务本身不受影响,但用户体验上等于“失效”。

解决:在请求权限之前先判断是否需要,不要一进门就弹权限框。权限被拒后,UI 上明确显示“通知权限未开启,下载进度不可见”,并提供跳转设置页的入口。同时,前台服务的通知渠道要设置成 IMPORTANCE_LOW 而不是 NONE,IMPORTANCE_NONE 渠道的通知即使有权限也可能不显示,这个是很多人忽略的前置条件。

5.5 WorkManager 的“立即执行”请求为什么等了半小时

现象:用户点了下载按钮,你通过 OneTimeWorkRequest 把任务交给了 WorkManager,结果日志显示它几分钟后甚至半小时后才开始执行。

原因:WorkManager 的执行时机受 Doze 模式和应用待机分组影响,分区更低的 App 执行窗口被大幅延长。

解决:当任务标期待确定性时,不要使用 WorkManager。它适合“不会立刻执行也行”的场景。如果必须用它做下载,请显式设置 expedited 工作——WorkManager 2.9 以上提供 setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) 这些策略,能提高启动优先级。但底层的保底措施仍然是:把真正的下载动作放到前台服务里,WorkManager 只负责“决定何时触发前台服务”。

6. 进阶验证技巧:用网络模拟与进程杀死测试给下载功能“上刑”

写完了功能,你要学会“故意弄坏它”。在开发和 QA 阶段做这四组测试,可以暴露出后台下载 80% 以上的隐藏问题。

第一组是弱网模拟。用 Android 自带的网络限制工具或代理工具(比如某抓包软件边的限速功能),将网络限制到 100 KB/s 左右,下载一个 200 MB 文件,观察:进度条是否正常推进、断点续传发生时临时文件的大小是否为预期、失败重试时是否重复下载已完成的文件块。弱网下最容易暴露的问题是“超时重试时 Range 头没有带上当前进度”,导致重试=从头下载,流量浪费且进度倒退。

第二组是进程杀死测试。启动下载,在 50%、80%、95% 三个点分别通过开发者选项里的“后台进程限制”强行杀掉应用(设置→开发者选项→后台进程限制→不允许后台进程),再重新打开 App,看任务是否能恢复。重点观察:恢复后临时文件是否还在、续传是否从正确的字节开始、数据库里有没有残留的“下载中但实际已死”的僵尸任务。

第三组是权限组合矩阵。在 Android 13 + 真机上循环测试以下排列:通知权限允许/拒绝/未询问、电池优化白名单/未加白名单、前台服务类型声明正确/未声明。每次组合下执行一次短小下载,确认 UI 提示和行为是否符合预期。这个矩阵能提前发现“通知被折叠导致用户重复点击”“后台下载被厂商省电策略限速”这两个高频投诉源。

第四组是版本兼容冒烟。分别用模拟器和真机测试 Android 8.0、Android 10、Android 12、Android 14 四个版本上的启动流程。Android 8.0 以后对后台 Service 的限制内容不同,12 与 14 对前台服务启动有新的异常抛出。最好写一个自动遍历脚本,把每次启动结果(成功/异常码)打印到日志里,方便对比回归。

最后一件事是我个人的习惯:所有的下载完成回调,不只在回调里处理业务,一定会额外写一条日志记录“文件大小/耗时/是否续传/服务端是否支持 Range”。线上用户反馈“下载不了”时,这条日志能省下大量的猜谜时间。后台下载不是一次写完就永久收工的代码,厂商 ROM 更新一次就可能把行为改掉;把这些验证手段固化到 CI 和回归流程里,才能保证用户在任何一台设备上下载都稳定。希望这些方案和踩坑记录能帮到你,少走我走过的弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进