引言

前面的常规网络请求,我们大部分处理的是:

DTO
↓
kotlinx.serialization
↓
JSON
↓
HTTP Body

例如:

client.post("/user") {
    setBody(
        CreateUserRequest(
            name = "Tom",
            age = 18,
        )
    )
}

最终发送:

{
    "name": "Tom",
    "age": 18
}

但真实项目很快就会遇到另一类需求:

上传头像
上传维修照片
上传 PDF
上传视频
文件 + 普通参数
文件 + JSON 数据
多个文件同时上传

这时候普通:

application/json

就不够了。

我们需要进入:

multipart/form-data

当前 Ktor 3.5.x 官方提供两种常见 Multipart 上传方式:

submitFormWithBinaryData()
或者
post()
+
MultiPartFormDataContent

对于小文件,可以直接使用 ByteArray;对于较大的文件,官方更推荐 MultiPartFormDataContent + InputProvider 进行流式读取,并且可以配合 onUpload 监听上传进度。


一、普通 JSON POST 和 Multipart 到底有什么区别?

普通 POST:

POST /user
Content-Type:
application/json

Body:

{
    "name": "Tom",
    "age": 18
}

本质:

一个 Request
↓
一个 Body
↓
整个 Body 是 JSON

Multipart:

POST /upload
Content-Type:
multipart/form-data

Body 不再只是一个 JSON。

而是:

Part 1
↓
description
Part 2
↓
file
Part 3
↓
其它字段

也就是说:

Multipart 的核心,就是一个 HTTP Body 中包含多个独立 Part


二、什么叫 Part?

例如:

description = 用户头像

是一个 Part。

文件:

avatar.png

也是一个 Part。

最终可以理解成:

Multipart Body
├── Part
│   name = description
│   value = 用户头像
│
├── Part
│   name = userId
│   value = 1001
│
└── Part
name = file
filename = avatar.png
Content-Type = image/png
binary data = ...

所以 Multipart 很适合:

文字
+
数字
+
JSON
+
文件

同时发送。


三、Boundary 是什么?

既然一个 Body 中有很多 Part:

Part A
Part B
Part C

HTTP 就必须知道:

前一个 Part 在哪里结束,下一个 Part 从哪里开始?

于是需要:

boundary

例如:

Content-Type:
multipart/form-data;
boundary=WebAppBoundary

Body 大概可以理解成:

--WebAppBoundary
Content-Disposition:
form-data; name="description"
Ktor logo
--WebAppBoundary
Content-Disposition:
form-data;
name="image";
filename="ktor_logo.png"
Content-Type:
image/png
[二进制文件]
--WebAppBoundary--

所以:

boundary

就是:

Multipart 中不同 Part 之间的分隔符。


四、为什么不要自己随便写 Content-Type?

很多人第一次上传会直接:

header(
    HttpHeaders.ContentType,
    "multipart/form-data"
)

但 Multipart 的 Content-Type 通常还需要:

boundary

例如:

Content-Type:
multipart/form-data;
boundary=abc123

如果:

Header boundary

和:

Body 真正使用的 boundary

不一致,服务器就无法正确拆分 Part。

所以通常:

MultiPartFormDataContent 自己生成并管理 Multipart 的 Content-Type 与 boundary,不要手动只写一个裸的 multipart/form-data

MultiPartFormDataContent 本身就是 Ktor 用于生成 multipart/form-data OutgoingContent 的类型,并会携带相应 boundary。


五、最简单的小文件上传

假设我们已经拿到了:

val imageBytes:
    ByteArray

可以:

val response = client.submitFormWithBinaryData(
        url = "/upload",
        formData =
            formData {
            append(
                "description",
                "User avatar",
            )

            append(
                "image",
                imageBytes,
                Headers.build {

                    append(
                        HttpHeaders.ContentType,
                        "image/png",
                    )

                    append(
                        HttpHeaders.ContentDisposition,
                        "filename=\"avatar.png\"",
                    )
                },
            )
        },
)

这里:

description

是普通文本 Part。

而:

image

是二进制 Part。

Ktor 官方当前就提供 submitFormWithBinaryData() 作为简单 Multipart 上传方式,并指出它适合文件可以安全读入内存的场景。


六、formData {} 到底是什么?

注意:

formData {
    ...
}

它不是直接发送 Request。

它做的是:

创建 Multipart Part
↓
组成 List<PartData>

例如:

formData {
append(
    "name",
    "Tom",
)

append(
    "age",
    18,
)
}

可以理解成:

Part #1
name = name
value = Tom
Part #2
name = age
value = 18

当前 Ktor formData {} 返回的就是用于构建 Multipart 的 List<PartData>


七、文件为什么还需要 filename?

例如:

append(
    "image",
    imageBytes,
    Headers.build {
    append(
        HttpHeaders.ContentDisposition,
        "filename=\"avatar.png\"",
    )
},
)

这里有两个名字:

image

和:

avatar.png

它们不是一回事。

image

Form Field Name

对应后端可能写:

@RequestPart("image")

而:

avatar.png

是:

File Name

服务器获得文件后,可以知道上传文件叫什么名字。

可以记:

name
↓
这个 Part 在接口里叫什么
filename
↓
这个文件本身叫什么

八、文件还应该有 Content-Type

例如图片:

image/png
image/jpeg

PDF:

application/pdf

普通二进制:

application/octet-stream

例如:

Headers.build {
append(
    HttpHeaders.ContentType,
    "image/jpeg",
)

append(
    HttpHeaders.ContentDisposition,
    "filename=\"photo.jpg\"",
)
}

这样服务器就知道:

这个 Part
↓
是 JPEG

而不是一段没有类型的二进制数据。


九、更推荐理解 MultiPartFormDataContent

相比:

submitFormWithBinaryData()

我们前面的网络架构更适合理解:

client.post("/upload") {
setBody(
    MultiPartFormDataContent(
        formData {
            ...
        }
    )
)
}

例如:

val response =
    client.post(
        "/upload"
    ) {
    setBody(
        MultiPartFormDataContent(
            formData {

                append(
                    "description",
                    "User avatar",
                )

                append(
                    "image",
                    imageBytes,
                    Headers.build {

                        append(
                            HttpHeaders.ContentType,
                            "image/png",
                        )

                        append(
                            HttpHeaders.ContentDisposition,
                            "filename=\"avatar.png\"",
                        )
                    },
                )
            }
        )
    )
}

执行关系:

formData
↓
创建 Part
MultiPartFormDataContent
↓
把所有 Part
编码成 multipart/form-data
setBody
↓
成为真正 Request Body
HttpClient
↓
Engine
↓
HTTP

十、文件 + 普通参数

例如上传维修图片时,同时需要:

repairOrderId
remark
file

就可以:

formData {
append(
    "repairOrderId",
    "10001",
)

append(
    "remark",
    "电梯门异常",
)

append(
    "file",
    imageBytes,
    Headers.build {

        append(
            HttpHeaders.ContentType,
            "image/jpeg",
        )

        append(
            HttpHeaders.ContentDisposition,
            "filename=\"fault.jpg\"",
        )
    },
)
}

最终就是:

Multipart
├ repairOrderId
├ remark
└ file

这正是 Multipart 最典型的场景。


十一、多个文件也只是多个 Part

例如:

file1.jpg
file2.jpg
file3.jpg

可以:

formData {
files.forEach { file -&gt;

    append(
        "files",
        file.bytes,
        Headers.build {

            append(
                HttpHeaders.ContentType,
                file.contentType,
            )

            append(
                HttpHeaders.ContentDisposition,
                "filename=\"${file.fileName}\"",
            )
        },
    )
}
}

最终:

files = file1
files = file2
files = file3

具体后端要求:

同一个 field name

还是:

file1 / file2 / file3

要看接口协议。

Multipart 本身并不限制。


十二、文件 + JSON 怎么办?

例如接口要求:

metadata
↓
JSON
file
↓
图片

Metadata:

@Serializable
data class UploadMetadata(
    val orderId: Long,
    val remark: String,
)

先:

val metadataJson =
    json.encodeToString(
        UploadMetadata(
            orderId = 1001,
            remark = "电梯故障",
        )
    )

然后:

formData {
append(
    "metadata",
    metadataJson,
    Headers.build {

        append(
            HttpHeaders.ContentType,
            ContentType.Application.Json
                .toString(),
        )
    },
)

append(
    "file",
    imageBytes,
    Headers.build {

        append(
            HttpHeaders.ContentType,
            "image/jpeg",
        )

        append(
            HttpHeaders.ContentDisposition,
            "filename=\"fault.jpg\"",
        )
    },
)
}

于是:

Multipart
│
├ metadata
│    ↓
│   JSON
│
└ file
     ↓
   JPEG

十三、为什么这里不能简单 setBody(metadata)?

普通请求:

setBody(
    metadata
)

整个 Request Body 都是:

metadata JSON

而 Multipart:

一个 Request Body
↓
里面有很多 Part

所以 JSON 只是:

其中一个 Part

需要先把:

UploadMetadata
↓
JSON String

然后再:

append
↓
Multipart Part

所以这里要区分:

整个 Request Body 序列化

和:

Multipart 中某一个 Part 的内容

不是一个层级。


十四、小文件可以 ByteArray,但大文件要小心

例如:

头像 100KB

先:

readBytes()
↓
ByteArray

通常问题不大。

但如果:

视频 500MB

你做:

File
↓
readBytes()
↓
500MB ByteArray
↓
放内存
↓
再上传

显然非常不合理。

可能导致:

巨大内存占用
GC 压力
OOM
上传开始前还需要等待整个文件读取完

所以:

ByteArray 适合小文件;大文件应该使用流式读取

Ktor 当前官方也明确区分了这两类场景:submitFormWithBinaryData + readBytes() 更适合较小文件,而 MultiPartFormDataContent + InputProvider 更适合大文件或动态内容。


十五、大文件:InputProvider

例如 JVM:

val file =
    File(
        "video.mp4"
    )

可以:

val response =
    client.post(
        "/upload"
    ) {
    setBody(
        MultiPartFormDataContent(
            formData {

                append(
                    "file",
                    InputProvider(
                        size =
                            file.length()
                    ) {

                        file
                            .inputStream()
                            .asInput()
                            .buffered()
                    },
                    Headers.build {

                        append(
                            HttpHeaders.ContentType,
                            "video/mp4",
                        )

                        append(
                            HttpHeaders.ContentDisposition,
                            "filename=\"video.mp4\"",
                        )
                    },
                )
            }
        )
    )
}

这里不是:

先把整个文件变成 ByteArray

而是:

File
↓
Input
↓
Ktor 一边读取
↓
一边发送

这就是:

Streaming Upload

InputProvider 当前是一个可重复创建 Input 的 Multipart 数据源,并且可以提供文件大小估计。


十六、KMP 为什么不能直接使用 java.io.File?

这是 KMP 上传真正需要考虑的问题。

如果你在:

commonMain

直接:

java.io.File

那:

Android / JVM

能用。

但是:

iOS
Web
Wasm

没有同一个 Java File API。

所以:

commonMain 的上传接口不要把 java.io.File 当成公共模型。

否则:

KMP 网络层

直接被 JVM 类型绑死。


十七、KMP 文件的核心应该是什么?

网络层真正需要的其实不是:

File 类

而是:

文件名
Content-Type
文件大小
文件内容来源

也就是:

Upload File
│
├ fileName
├ contentType
├ size
└ content

至于:

Android
↓
Uri / File
iOS
↓
NSURL / NSData
Web
↓
File / Blob

应该在平台层转成网络层能够读取的内容。

这和前十二篇的思想完全一样:

commonMain 定义能力和协议,平台层处理平台文件来源。


十八、现在 Ktor 已经支持 kotlinx-io

Ktor 官方当前 Multipart 示例还提供了 Multiplatform 文件系统写法:

InputProvider {
SystemFileSystem
    .source(
        Path(
            "ktor_logo.png"
        )
    )
    .buffered()
}

也就是说,在支持文件系统访问的平台,可以使用 kotlinx-io

Path
↓
SystemFileSystem
↓
Source
↓
InputProvider
↓
Multipart

而不必把:

java.io.File

带进 commonMain。


十九、但 Web 文件仍然需要单独考虑

浏览器里的文件通常来自:

<input type="file">
File
Blob

它与:

本地文件系统 Path

不是完全同一种模型。

当前 Ktor Multipart API 在 Web 平台也提供了:

appendBlob(...)

用于把浏览器 Blob 添加为 Multipart Part。

所以 KMP 实际项目里很可能形成:

commonMain
↓
Upload 抽象
Android / iOS
↓
File / Path / Source
Web
↓
Blob

不要为了追求:

所有平台一模一样

而硬把不同平台文件模型揉成一个假的 File


二十、上传进度怎么监听?

Ktor 当前直接提供:

onUpload { bytesSentTotal, contentLength ->
...
}

例如:

client.post(
    "/upload"
) {
setBody(
    multipartBody
)

onUpload {
        bytesSent,
        totalBytes,
    -&gt;

    println(
        "uploaded=$bytesSent " +
            "total=$totalBytes"
    )
}
}

onUploadHttpRequestBuilder 当前提供的上传进度监听入口。


二十一、计算百分比

例如:

onUpload {
        bytesSent,
        totalBytes,
    ->
if (
    totalBytes != null &amp;&amp;
    totalBytes &gt; 0
) {

    val progress =
        bytesSent
            .toFloat() /
            totalBytes

    println(
        "progress=$progress"
    )
}
}

例如:

bytesSent = 50MB
contentLength = 100MB

那么:

progress
=
0.5
=
50%

注意:

contentLength

可能是:

null

因为并不是所有上传内容都能提前知道最终大小。

所以不能:

bytesSent / totalBytes!!

无脑计算。


二十二、进度应该由 NetworkClient 处理吗?

可以提供:

进度通道

但 NetworkClient 不应该知道:

进度条
百分比文字
Dialog
Compose State

例如可以:

suspend fun upload(
    ...,
    onProgress:
        (Long, Long?) -> Unit,
)

NetworkClient:

只报告
bytesSent / totalBytes

ViewModel:

再转成
0% ~ 100%

UI:

显示 ProgressBar

仍然遵守:

网络层报告事实,UI 决定怎么展示。


二十三、上传 Timeout 通常和普通 API 不一样

普通接口:

GET /orders
↓
15 秒

上传:

500MB Video

显然不能也简单:

15 秒

所以可以:

client.post(
    "/upload"
) {
timeout {

    requestTimeoutMillis =
        120_000
}

setBody(
    multipartBody
)
}

这样:

apiClient
↓
仍然长期复用
当前 upload Request
↓
单独覆盖 Timeout

这正是前面讲过的:

Client 默认配置
+
Request 单次覆盖

二十四、是不是上传就必须创建 uploadClient?

不一定。

如果:

BaseUrl 一样
Auth 一样
公共 Header 一样
只是 Timeout 更长

完全可以:

复用 apiClient
+
Request Timeout Override

没必要:

上传
↓
立刻新建一个 HttpClient

二十五、什么时候 uploadClient 才值得独立?

如果:

上传域名不同
认证方式不同
Timeout 策略完全不同
并发控制不同
Retry Policy 不同
日志策略不同
TLS 配置不同

这时候:

uploadClient

就成为一个真正独立:

网络责任域

于是拆 Client 才有意义。

还是第十二篇的原则:

一个明确的网络配置域,对应一个长期复用的 HttpClient。


二十六、上传和 Logging 还有一个很重要的关系

补充篇 9.1 已经讲过:

Multipart
Binary
↓
通常不要打印完整 Body

想象:

上传 500MB 视频
+
Logging = ALL

如果日志系统试图读取:

整个 Multipart Body

既没意义,也可能增加巨大性能开销。

所以上传 Client / Request 的 Logging 策略应该:

URL
Method
Headers(脱敏)
文件名
文件大小
Status
耗时

而不是:

完整文件二进制内容

这正是 bodyFilter 适合:

Multipart / Binary
↓
Skip

的场景。


二十七、文件上传能不能自动 Retry?

这里要非常谨慎。

比如:

POST /upload
↓
上传完成
↓
Response 返回途中断网

Client 看到:

Network Error

但服务器可能:

已经收到文件

如果自动 Retry:

再上传一次

服务器可能产生:

两个文件

所以:

Multipart Upload 本质仍然是 POST,不能因为 Network Error / Timeout 就无脑 Retry。

仍然要考虑:

接口是否幂等
服务端有没有 FileId
有没有 UploadId
有没有 Idempotency-Key
Body 是否能够重新读取

二十八、InputProvider 还有一个 Retry 相关细节

注意:

InputProvider {
    ...
}

的思想是:

每次需要内容
↓
重新提供一个 Input

所以如果真的需要:

Retry

数据源必须:

可以重新打开

而不是:

已经被读完的单次 Input

这也是为什么:

Request Body 可重放性

会影响 Retry 安全性。


二十九、Multipart 和 ContentNegotiation 是什么关系?

Response 仍然完全可以:

ContentNegotiation
↓
JSON
↓
ApiResponse<T>

例如:

Request
↓
multipart/form-data
Response
↓
application/json

两者完全没问题。

所以:

Multipart

只是在改变:

Request Body

的表达方式。

并不会意味着:

整个 NetworkClient

的响应处理逻辑都要重写。


三十、把 Multipart 加进我们的 NetworkClient

前十二篇已经有:

get
post
put
delete

现在可以增加:

upload

例如:

suspend inline fun <
    reified T
> upload(
    path: String,
    formData:
        List<PartData>,
    noinline onProgress:
        ((Long, Long?) -> Unit)? =
            null,
    noinline block:
        HttpRequestBuilder.() -> Unit = {},
): T {
return execute {

    client.post(
        path
    ) {

        setBody(
            MultiPartFormDataContent(
                formData
            )
        )

        if (
            onProgress != null
        ) {

            onUpload {
                    sent,
                    total,
                -&gt;

                onProgress(
                    sent,
                    total,
                )
            }
        }

        block()
    }
}
}

这样:

NetworkClient.upload
↓
仍然复用原来的:
Connectivity
ExceptionMapper
ApiResponse<T>
Auth
Logging
Timeout

只是:

Request Body

从:

JSON

换成了:

Multipart

三十一、ApiService 就可以很干净

例如维修照片上传:

class RepairApiService(
    private val networkClient:
        NetworkClient,
) {
suspend fun uploadPhoto(
    repairOrderId: Long,
    fileName: String,
    bytes: ByteArray,
    onProgress:
        (Long, Long?) -&gt; Unit,
): UploadResult {

    val parts =
        formData {

            append(
                "repairOrderId",
                repairOrderId,
            )

            append(
                "file",
                bytes,
                Headers.build {

                    append(
                        HttpHeaders.ContentType,
                        "image/jpeg",
                    )

                    append(
                        HttpHeaders.ContentDisposition,
                        "filename=\"$fileName\"",
                    )
                },
            )
        }

    return networkClient.upload(
        path =
            "/repair/upload",

        formData =
            parts,

        onProgress =
            onProgress,
    ) {

        timeout {

            requestTimeoutMillis =
                120_000
        }
    }
}
}

调用:

RepairApiService
↓
NetworkClient.upload
↓
apiClient
↓
Multipart
↓
Engine
↓
HTTP

整个原有网络架构不需要推翻。


三十二、小文件和大文件最好区分

可以简单记:

小文件
↓
ByteArray
↓
简单
大文件
↓
InputProvider / Source
↓
流式读取

不要为了统一 API:

所有文件
↓
先读 ByteArray

否则大文件场景会非常危险。


三十三、如果接口只上传纯二进制,还需要 Multipart 吗?

不一定。

例如服务器要求:

POST /upload
Content-Type:
application/octet-stream
[整个 Body 就是文件]

这时候根本没有:

多个 Part

也就不需要 Multipart。

Ktor 可以直接:

Binary Stream
↓
setBody(...)

当前官方同样支持直接把二进制 Channel 作为 Request Body 上传。

所以要区分:

纯文件 Body
↓
application/octet-stream

和:

文件 + 参数 / 多文件
↓
multipart/form-data

三十四、什么时候应该使用 Multipart?

可以简单判断:

一个 Request
↓
需要同时携带多个独立内容?

例如:

文件 + 文本
文件 + JSON
多个文件

使用:

multipart/form-data

如果:

整个 Body 就是一段 JSON

继续:

application/json

如果:

整个 Body 就是一个纯二进制文件

可以:

application/octet-stream

不要看到:

上传

就默认一定 Multipart。


三十五、一个完整 Multipart 上传例子

把前面的知识组合起来:

suspend fun uploadRepairFile(
    orderId: Long,
    remark: String,
    fileName: String,
    contentType: String,
    fileBytes: ByteArray,
    onProgress:
        (Float) -> Unit,
): UploadResult {
val parts =
    formData {

        append(
            "orderId",
            orderId,
        )

        append(
            "remark",
            remark,
        )

        append(
            "file",
            fileBytes,
            Headers.build {

                append(
                    HttpHeaders.ContentType,
                    contentType,
                )

                append(
                    HttpHeaders.ContentDisposition,
                    "filename=\"$fileName\"",
                )
            },
        )
    }

return networkClient.upload(
    path =
        "/repair/files",

    formData =
        parts,

    onProgress = {
            sent,
            total,
        -&gt;

        if (
            total != null &amp;&amp;
            total &gt; 0
        ) {

            onProgress(
                sent.toFloat() /
                    total
            )
        }
    },
) {

    timeout {

        requestTimeoutMillis =
            120_000
    }
}
}

整个流程:

业务参数
+
文件
↓
formData
↓
PartData
↓
MultiPartFormDataContent
↓
multipart/form-data
↓
onUpload
↓
Engine
↓
HTTP
↓
ApiResponse<UploadResult>
↓
NetworkClient
↓
UploadResult

三十六、KMP 文件上传最终应该这样理解

不要把问题理解成:

Ktor 怎么上传 File?

因为 KMP 里:

File

本身就是平台相关概念。

更准确的问题是:

不同平台如何把自己的文件对象,转换成 Ktor Multipart 可以读取的数据源?

于是:

Android
Uri / File
      ↓
      ┐
iOS   │
URL   ├→ 文件内容来源
      │      ↓
Web   │   Multipart
Blob  ┘      ↓
             HTTP

而:

filename
contentType
size

这些信息可以统一进入 common 网络层。


三十七、本篇最容易踩的几个坑

坑一:上传文件全部 readBytes()

小文件可以。

大文件:

readBytes()
↓
整个文件进入内存
↓
容易 OOM

应该考虑流式读取。


坑二:commonMain 暴露 java.io.File

这样:

Android 能用
iOS / Web
↓
直接被 JVM 类型卡住

公共网络接口应该围绕:

文件信息
+
内容来源

设计。


坑三:手动写裸 multipart/form-data Header

Multipart 需要:

boundary

最好让:

MultiPartFormDataContent

管理。


坑四:Logging 打完整 Multipart Body

尤其上传:

图片
视频
PDF

通常应该:

Binary / Multipart
↓
Skip Body Logging

坑五:上传失败自动 Retry

Client 没收到 Response

不代表:

Server 没收到文件

必须考虑幂等性和 Body 可重放性。


坑六:把上传 Progress 做进 UI 逻辑

NetworkClient 只应该:

bytesSent
contentLength

ViewModel / UI 再决定:

百分比
进度条
文字

坑七:看到上传就创建 uploadClient

如果只是:

Timeout 不一样

使用:

Request Override

即可。

只有形成独立网络配置域以后,再考虑拆 Client。


三十八、本篇总结

普通 JSON Request:

DTO
↓
ContentNegotiation
↓
JSON
↓
HTTP Body

Multipart:

普通字段
+
JSON
+
文件
+
多个文件
↓
formData
↓
多个 Part
↓
MultiPartFormDataContent
↓
multipart/form-data

小文件:

ByteArray

简单直接。

大文件:

InputProvider
+
Source / Input

更适合流式上传。Ktor 当前官方也把 MultiPartFormDataContent + InputProvider 作为大文件和动态内容的推荐方式,并支持 onUpload 监听进度。

KMP 最重要的一点则是:

commonMain
↓
不要依赖 java.io.File

而应该考虑:

文件名
Content-Type
大小
内容来源

平台:

Android
iOS
Web

各自把自己的:

File / Uri / URL / Blob

转换成上传数据源。

对于我们前十二篇建立的架构,Multipart 并不需要推翻任何东西。

只是从:

NetworkClient.post()
↓
JSON Body

增加:

NetworkClient.upload()
↓
Multipart Body

原来的:

Auth
Timeout
Logging
ExceptionMapper
Connectivity
ApiResponse<T>
Provider
Engine

依然继续工作。

这正是之前把网络基础架构搭完整的价值:

新增“文件上传”只是增加一种 Request Body 能力,而不是重新设计一套网络层。


下一篇

(KMP-Net进阶)第三篇:文件下载与 Progress——大文件为什么不能直接 body<ByteArray>()?

下一篇会从:

GET /file
↓
Response Body

继续深入:

小文件下载
大文件流式下载
ByteReadChannel
onDownload
Content-Length
下载进度
取消下载
写入本地文件
Android / iOS / Web 文件保存差异
为什么大文件不能一次性全部读进内存
下载是否应该单独设计 DownloadClient

也就是把这一篇的:

Upload Stream

反过来理解:

Download Stream

真正进入 Ktor 大数据流式传输。

Logo

一站式 AI 云服务平台

更多推荐