跳转至

上传、下载与进度

小文件上传

小文件可以直接使用 Multipart ByteArray:

@Multipart
@POST("/v1/avatar")
fun uploadAvatar(
    @Part(value = "file", fileName = "avatar.jpg", contentType = "image/jpeg") bytes: ByteArray,
    @IdempotencyKey key: String,
): NetworkCall<AvatarDto>
api.uploadAvatar(bytes, uploadId)
    .timeout(120)
    .progress { progress -> render(progress) }
    .await()

这种方式适合头像、表单附件等有明确内存上限的正文。

大文件上传与断点续传

大文件使用 network-transfer。平台 Engine 从文件流式读取,默认以 4 MiB 分片发送;持久状态同时记录 bytesUploaded(网络上已经发送)和 committedBytes(服务端已经确认),失败恢复只使用后者:

val store = PersistentUploadStore(filesDir.resolve("network/uploads.json").path)
val repository = DefaultNetworkUploadRepository(store)

val id = repository.enqueue(
    UploadRequest(
        id = UploadId("video-draft-42"),
        url = "https://uploads.example.com/v1/files",
        source = filesDir.resolve("draft.mp4").path,
        totalBytes = filesDir.resolve("draft.mp4").length(),
        expectedSha256 = manifest.sha256,
        method = UploadHttpMethod.PUT,
        protocol = UploadProtocol.CONTENT_RANGE,
    ),
)
repository.observe(id).collect(::renderUpload)

分片请求包含 Content-Range、Upload-Id、Upload-Offset、Upload-Length、Upload-Complete,配置摘要时 还会携带 Upload-Checksum: sha256 …。 服务端可以返回 2xx 确认当前分片;返回 308 时必须携带 Upload-Offset(或 Range)。同一 Upload-Id 与偏移的重复分片必须幂等,否则应用被终止后无法安全重放最后一个未确认分片。

pause、resume、cancel、retry 与 recover 都会持久化,源文件长度发生变化时任务会以 SOURCE_CHANGED 失败,不会继续发送错误偏移。

小文件下载

@Streaming
@GET("/v1/files/{id}")
fun download(@Path("id") id: String): NetworkCall<ByteArray>
val result = api.download(id)
    .responseLimit(20L * 1024 * 1024)
    .progress { progress -> render(progress) }
    .await()

当前 @Streaming 仍返回内存 ByteArray,只是跳过 JSON 解码并提供字节进度,不等于无限大文件落盘流。

大文件下载与断点续传

大文件使用 network-transfer,正文会直接写入 .part 文件,不进入内存:

val store = PersistentDownloadStore(filesDir.resolve("network/downloads.json").path)
val repository = DefaultNetworkDownloadRepository(store)

val id = repository.enqueue(
    DownloadRequest(
        id = DownloadId("offline-map-v4"),
        url = "https://downloads.example.com/map-v4.zip",
        destination = filesDir.resolve("map-v4.zip").path,
        expectedSha256 = manifest.sha256,
        expectedBytes = manifest.bytes,
    ),
)
repository.observe(id).collect(::renderDownload)

模块使用 Range 与 If-Range 恢复传输,校验长度和可选 SHA-256,通过同目录 .part 文件完成后再发布。 暂停、取消和失败是三个独立状态;进程重启后使用 recover(autoResume = true) 恢复未完成任务。下载和重定向默认 拒绝私有/回环地址;仅受信任的内网场景可以显式设置 allowPrivateAddresses = true。

Android 后台任务

需要跨进程后台执行时,将 Repository 设置为 autoStart = false,安装对应 Registry,并通过 WorkManager Scheduler 入队。上传和下载 Worker 都遵守计量网络/漫游约束,维护前台通知,并提供暂停、恢复和取消操作。

val repository = DefaultNetworkDownloadRepository(store, autoStart = false)
DownloadWorkerRegistry.install(repository, repository)

repository.enqueue(request)
DownloadWorkScheduler(applicationContext).enqueue(request)

val uploads = DefaultNetworkUploadRepository(uploadStore, autoStart = false)
UploadWorkerRegistry.install(uploads, uploads)
uploads.enqueue(uploadRequest)
UploadWorkScheduler(applicationContext).enqueue(uploadRequest)

Android 13 及以上由宿主在入队前申请 POST_NOTIFICATIONS;模块已经合并前台服务权限与不可导出的通知操作 Receiver。

iOS 后台 NSURLSession

向 Engine 传入稳定的后台 Session 前缀;实际 identifier 会追加任务 ID,因此上传和下载任务不会共享同一个 delegate。后台上传始终使用文件任务,应用重启后会重新绑定系统中同一 taskDescription,下载也会接管已存在任务:

val downloads = DefaultNetworkDownloadRepository(
    downloadStore,
    engine = UrlSessionDownloadEngine("com.example.network.download"),
)
val uploads = DefaultNetworkUploadRepository(
    uploadStore,
    engine = UrlSessionUploadEngine("com.example.network.upload"),
)

宿主收到系统回调后先保存 completion handler,再用相同前缀重建 Repository/Engine,并执行 recover(true):

func application(
    _ application: UIApplication,
    handleEventsForBackgroundURLSession identifier: String,
    completionHandler: @escaping () -> Void
) {
    IosBackgroundTransferEvents.shared.registerCompletionHandler(
        identifier: identifier,
        completionHandler: completionHandler
    )
    TransferRuntime.shared.recoverPersistedTasks()
}

iOS 后台重定向由系统接管,因此后台任务拒绝 Authorization、Cookie 等敏感 Header。需要鉴权时使用短期、 限定对象与域名的签名 HTTPS URL;前台 Engine 仍按白名单检查重定向并在跨 Origin 时剥离凭据。

进度模型

data class NetworkTransferProgress(
    val bytesTransferred: Long,
    val totalBytes: Long?,
    val direction: NetworkTransferDirection,
)

当服务端没有 Content-Length 时 totalBytes 为 null,此时 UI 应显示不确定进度,而不是除以零。

fun render(progress: NetworkTransferProgress) {
    val ratio = progress.totalBytes
        ?.takeIf { it > 0L }
        ?.let { progress.bytesTransferred.toDouble() / it }
    if (ratio == null) showIndeterminate() else showPercent(ratio)
}

取消与重试

  • 取消收集/协程会取消平台任务。
  • 进度回调异常不会中断传输。
  • 普通 NetworkCall<ByteArray> 上传重试必须提供幂等键;network-transfer 使用稳定 Upload-Id 与服务端确认偏移。
  • NetworkCall<ByteArray> 重试会重新开始;network-transfer 会从安全的 .part 文件断点续传。

Base URL 与 CDN

服务端返回签名 CDN 地址时使用 @Url:

@Streaming
@GET
fun downloadSigned(@Url httpsUrl: String): NetworkCall<ByteArray>

动态 URL 只接受 HTTPS 且禁止用户信息和 fragment。跨域地址还必须加入 NetworkOriginPolicy.allowedHosts; Authorization、Cookie 等敏感 Header 不会被转发到其他 Origin。对于固定上传/下载域名,更推荐命名 Client:

@NetworkService(client = "upload")
interface UploadApi

这样 TLS、超时、Header 与限流策略可以和主 API 分开配置。

HarmonyOS

HAR Bridge 提供 uploadFile、downloadFile、cancelTransfer 和原有 httpRequest:

{
  "transferId": "video-42",
  "url": "https://uploads.example.com/v1/files",
  "sourcePath": "/data/storage/el2/base/files/video.mp4",
  "totalBytes": 73400320,
  "offset": 0,
  "chunkSizeBytes": 4194304,
  "contentType": "video/mp4"
}

上传通过 RCP UploadFromStream 分片读取,不把完整文件放入内存;回调连续返回 progress,只有服务端确认后才 推进 committedBytes。下载写入应用私有目录中的分段临时文件,使用 Range/If-Range 恢复,校验成功后再发布目标文件,不向业务暴露半文件。 文件路径仅允许 filesDir 或 cacheDir,地址仍必须是公网 HTTPS。页面销毁时调用 cancelTransfer 或 cancelAll;真正离开应用进程继续执行的 Harmony 任务应由宿主把同一协议接到系统 request.agent。