Flutter Android 下载实现:DownloadManager 恢复校验
Flutter 页面里的下载进度很容易绑到 Dart Future,但它不能代表系统级任务。用户点击后切到后台、系统回收 Flutter 进程或网络中断,下一次打开页面时内存状态已经消失,而 Android 下载可能仍在排队、运行、暂停、失败或完成。若页面只显示“未下载”,用户会重复点击,留下重复任务和无法解释的流量。
本文只处理一个问题:把应用自有离线资料的长下载交给 Android DownloadManager,并让 Flutter 在冷启动或回到前台时依据持久化任务 ID 核验真实状态。它不实现自定义多线程下载器、私有断点协议或通用文件浏览器。Android 的 DownloadManager API 负责长 HTTP 下载、网络变化后的重试和设备重启后的继续;应用仍要决定哪些任务值得追踪、哪些结果可展示。
先划清文件和产品边界
这个方案适用于课程包、离线 JSON、受控文档和主要由当前应用消费的媒体资源。示例环境为 Flutter stable、Dart 3、Kotlin Android 工程和 minSdk 23,在 Android 10、Android 11 与较新真机或模拟器上验证。发布前记录真实环境,避免把示例的边界误写成版本承诺:
flutter --version
flutter doctor -v
cd android
.\gradlew --version
示例目标路径是 getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),即应用专属外部目录。当前应用访问它不需要向用户申请存储权限;但是 Android 11 及以上其他应用不能读取它,卸载本应用时文件也会被删除。若产品要求文件在卸载后保留、由办公软件打开,或出现在用户管理的公共下载目录,应改用共享存储、MediaStore 或 Storage Access Framework。Android 的存储使用场景说明明确区分了应用专属资料和需要对其他应用开放的资料,不能为了省事混用两种语义。
准备输入也要最小化:下载地址必须是 HTTPS;测试服务器应稳定返回内容类型和内容长度;业务层提供不含密钥的 contentKey,例如 lesson-42-v3。不要把完整带签名 URL、Cookie 或响应内容写进 logcat。排查恢复问题时,脱敏业务键、任务 ID、状态、reason 和字节数已经足够。
先定义可恢复状态和任务记录
Flutter 侧把原生查询收敛成 queued、running、paused、failed、success、missing 六类。前五类映射系统状态;missing 不是异常兜底,而是可解释结果:本地保存的 ID 已不在系统下载库中,可能是用户从下载界面删除、设备策略清理,或应用数据恢复不完整。paused 和 failed 保留 Android reason 码用于日志,但 UI 不直接展示数字。它可以提示“等待网络”“空间不足”或“请重试”,不能擅自断言网络故障。
不要只保存 downloadId。每项记录至少含 contentKey、downloadId、预期文件名、内容版本和创建时间。这样新版内容出现时,客户端才能判断旧 ID 是继续追踪、作废还是替换;用户连续点击时也能识别重复任务。若业务规则是只保留最新一份,先查询旧 ID,仍处于 queued/running/paused 才调用 remove(oldId),随后把替换操作写进日志再入队。不要先删除再检查,否则一次短暂的页面重建就可能丢失正常下载。
推荐的目录边界很小:
lib/features/offline_download/
download_gateway.dart # Dart 通道契约与领域状态
download_controller.dart # 页面状态、重复点击与前台核验
android/app/src/main/kotlin/.../
DownloadBridge.kt # DownloadManager 入队和查询
PendingDownloadStore.kt # SharedPreferences 任务记录
这也让下载与业务同步保持分离。若项目已有受网络约束、可延后执行的服务端同步,可继续沿用 Flutter Android 后台同步配置:验证 WorkManager 约束重试 的链路;用户主动下载不应因为同样在后台执行,就和 WorkManager 的业务任务共用一个状态机。
Android 侧只处理控制面
Manifest 只需要网络权限。本例不要额外加入旧式读写外部存储权限,也不要把 requestLegacyExternalStorage 当成恢复失败的补丁。桥接层在 native 侧验证 URL、允许域名和文件名,而不是把任意 Dart 字符串交给系统服务。允许域名可以来自环境配置或服务端签名响应;文件名采用白名单,避免路径分隔符和超长名称。setDestinationInExternalFilesDir 在外部目录无法创建或挂载时会失败,因此 MethodChannel 必须转成明确错误,不能让调用长期等待。
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application android:label="example" />
</manifest>
class DownloadBridge(private val context: Context) {
private val manager = context.getSystemService(DownloadManager::class.java)
private val prefs = context.getSharedPreferences("pending_downloads", Context.MODE_PRIVATE)
fun enqueue(contentKey: String, rawUrl: String, filename: String): Map<String, Any> {
val uri = Uri.parse(rawUrl)
require(uri.scheme == "https" && uri.host == "cdn.example.com")
require(filename.matches(Regex("[A-Za-z0-9._-]{1,96}")))
val request = DownloadManager.Request(uri)
.setTitle("正在准备离线资料")
.setDescription(contentKey)
.setMimeType("application/zip")
.setAllowedOverMetered(false)
.setAllowedOverRoaming(false)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, filename)
val id = manager.enqueue(request)
prefs.edit().putLong("id:$contentKey", id).apply()
Log.i("DownloadBridge", "enqueue key=$contentKey id=$id")
return mapOf("id" to id, "state" to "queued")
}
fun inspect(contentKey: String): Map<String, Any?> {
val id = prefs.getLong("id:$contentKey", -1L)
if (id < 0) return mapOf("state" to "missing")
manager.query(DownloadManager.Query().setFilterById(id)).use { cursor ->
if (!cursor.moveToFirst()) return mapOf("id" to id, "state" to "missing")
val status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
val reason = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
val bytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
val total = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
val state = when (status) {
DownloadManager.STATUS_PENDING -> "queued"
DownloadManager.STATUS_RUNNING -> "running"
DownloadManager.STATUS_PAUSED -> "paused"
DownloadManager.STATUS_SUCCESSFUL -> "success"
else -> "failed"
}
Log.i("DownloadBridge", "inspect key=$contentKey id=$id state=$state reason=$reason bytes=$bytes/$total")
return mapOf("id" to id, "state" to state, "reason" to reason, "downloaded" to bytes, "total" to total)
}
}
}
在 MainActivity.configureFlutterEngine 注册通道时,enqueue 和 inspect 只接受受控字段,并把 IllegalArgumentException、SecurityException 和目录错误转换为可诊断结果。不要在 onMethodCall 中下载字节、解压或计算 hash;平台通道只负责控制面。Flutter 的平台通道文档要求从 Android 回调 Flutter 时回到主线程,本例不从后台任务直接驱动 Dart UI。
Flutter 在恢复时查询一次
Dart 接口保持窄:页面只认识领域状态,controller 在 resumed 时调用一次 reconcile。不要在每次 build 时查询,更不要在 Android 返回 total=-1 时伪造百分比。
class DownloadGateway {
static const _channel = MethodChannel('com.example.app/downloads');
Future<DownloadSnapshot> reconcile(String contentKey) async {
final raw = Map<String, dynamic>.from(
await _channel.invokeMethod('inspect', {'contentKey': contentKey}),
);
return DownloadSnapshot.fromMap(raw);
}
}
class OfflineDownloadController with WidgetsBindingObserver {
OfflineDownloadController(this.gateway, this.contentKey);
final DownloadGateway gateway;
final String contentKey;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) unawaited(gateway.reconcile(contentKey));
}
}
success 仅说明传输完成,并不等于内容可用。通过 DownloadManager.getUriForDownloadedFile(id) 取得可读 URI 后,仍应校验文件大小、压缩包清单和服务端提供的 SHA-256;不通过就移除当前任务并回到可重试状态。对 APK、动态代码或可执行内容尤其如此:下载成功不是可信可加载的证明。服务器若没有 Content-Length,总大小可能未知,UI 应显示不确定进度而非 0%。
验证恢复而不是验证一次成功
准备仅测试环境可访问、响应头稳定的 HTTPS 文件。先开始下载,再强制结束应用而非取消系统任务。Windows 可用下列命令收集日志证据:
flutter run --profile
adb logcat -c
adb shell am force-stop com.example.myapp
adb shell monkey -p com.example.myapp 1
adb logcat -d -s DownloadBridge:I
验收条件不是“每次重启前都下载完成”,而是重新启动后同一 contentKey 仍能找到同一任务 ID,并返回 queued、running、paused、failed 或 success 中的真实状态。继续覆盖关闭网络再恢复、从系统下载界面删除、模拟空间不足、服务端返回 403 和未知内容长度五种边界。每条测试记录设备 API 级别、网络动作、任务 ID、状态、reason、字节数和页面文案。
用例矩阵和观测口径
正常 Wi-Fi 下载应记录从入队到完成的时间,并确认重启后不会重复排队。网络被临时关闭时,记录进入暂停前后的状态和 reason,网络恢复后只观察系统是否继续,不承诺恢复耗时。流量网络与漫游限制要验证 setAllowedOverMetered(false) 和 setAllowedOverRoaming(false) 时页面说明是否符合产品选择。服务端 403、404 必须落到 failed,内容长度未知必须出现不确定进度。用户在系统下载页删除文件后,应用恢复时应显示 missing 并提供明确的重新开始按钮。
日志字段应可关联且不泄露信息:contentKey 用内部版本键,downloadId 只用于本机排查,状态和 reason 记录原始值,下载字节和总字节用于观察异常分布。指标可先统计入队数、success/failed/missing 比例、从入队到完成的耗时和完整性校验失败数。不要把签名 URL、Authorization 请求头、用户标识或文件内容放进埋点。发生失败时,先以任务 ID 和状态还原系统视角,再检查网络、磁盘和服务端响应;先改 UI 文案或盲目增加权限通常无法解决根因。
若所有状态都是 missing,优先检查 ID 是否被放在临时 UI 状态、替换逻辑是否过早调用 remove、以及应用重装后是否错误地当作正常恢复。若状态为 success 而文件不可用,先检查 MIME、包校验、内容版本和服务端头;新增权限不能修复损坏或错版本的文件。
发布前复盘
上线前确认允许域名和文件名在 native 层校验;重复点击不产生重复入队;前台恢复只做一次查询;未知总长度有诚实 UI;失败原因有用户提示与日志;成功后有完整性验证;产品已说明应用专属文件在卸载时删除。下一阶段再把离线包清单、版本过期、回收和完整性策略移入 Repository 层,不要顺手在这一改动加入后台同步、推送或文件选择器。最终目标很具体:Flutter 恢复的是一个可查询、可验证的系统任务,而不是一段早已消失的 Dart 异步调用。
交付前的人工检查
发布候选包前,开发者还应在测试设备上比较同一内容的旧版和新版:确认版本键变化时旧任务不会覆盖新任务,确认用户主动取消后页面不会把取消误报为成功,确认应用数据被清除后只显示可重新下载而不引用失效文件。对受登录保护的资料,下载地址过期时应回到重新获取短期地址的业务流程,而不是把长期令牌存入偏好设置。把这些检查写入测试记录,才能在监控出现异常分布时复现具体设备和网络条件。