Kotlin 中的 Base64 编码:完整指南
每一场 Base64 对话都分两半:一半是读,把打包好的数据读回字节;另一半 - 你正好在对的站点上 - 是怎么一开始就生产出那个打包数据。你的应用里总有些字节要穿过一扇只认文本的门:一个 JSON 字符串、一个 HTTP 头、一封邮件、一个 URL、一份配置文件。Base64 是经典答案,而 Kotlin 在标准库里就有一等公民级答案:kotlin.io.encoding 里的 Base64 类,自 Kotlin 2.2 起稳定。
本指南带你过一遍当你是生产 Base64 那一方的时候实际需要什么:四个预设方案、填充旋钮、邮件和证书奉为圭臬的换行规则、URL 安全字母表、字节从哪里来,再加一组每个都带 Kotlin 的真实场景。格式本身 - 3 个字节如何变成 4 个字符、= 从哪来 - 首页已经讲透,所以这里直接进 Kotlin。
一个类,四个预设
整个 API 就是一个类:kotlin.io.encoding 包里的 Base64。没有你要构造的 encoder 对象,也没有 builder。取而代之,类自带四个预设实例,每个 RFC 方案一个,外加一个伴生对象,它不动声色地顶替最常用的那个:
| 实例 | 字母表 | 编码时换行 | 编码时填充 | 用它来 |
|---|---|---|---|---|
Base64.Default | + 和 / | 不换行 | 输出 = | 通用场景、API、data URL |
Base64.UrlSafe | - 和 _ | 不换行 | 输出 =(可以关掉) | URL、token、JWT |
Base64.Mime | + 和 / | 每 76 个字符一个 CRLF | 输出 = | 邮件正文和附件 |
Base64.Pem | + 和 / | 每 64 个字符一个 CRLF | 输出 = | 证书和私钥 |
命名里那个绊人的细节:这些是实例,不是工厂。每个实例都是不可变值,改变它的行为,比如填充,返回的是一个新实例,而不是改动旧的那个。这让预设可以放心地在线程间共享、存在对象里,也是整个类能当简单值类型、不带任何内部状态的原因。
第一次编码:字节进,字符串出
这里是本站最小的有用程序:五个字节进去,一个八字符的字符串出来。输入永远是一个 ByteArray(或它的一个切片),结果是一根普通 String,可以放到任何允许文本的地方:
import kotlin.io.encoding.Base64
fun main() {
val bytes = "Hello".encodeToByteArray()
val packed = Base64.encode(bytes)
println(packed) // SGVsbG8=
}
这一行干的活比看起来多。Kotlin 给你同一种操作的几种形状,读起来都像函数名说的那样:
encode(bytes)返回String,就是上面的形状。encodeToByteArray(bytes)返回一串 ASCII 字符的ByteArray,当打包形式本身还要进另一个缓冲区时很顺手。encodeIntoByteArray(bytes, destination)写进你已经分配好的ByteArray,在热路径上省掉一次分配。encodeToAppendable(bytes, builder)追加到任何实现了Appendable的东西,比如StringBuilder,当你在拼装一个更大的文档时,这是最自然的搭配。
四个都接受同样的可选 startIndex 和 endIndex 范围,所以你可以打包一个大缓冲区里的切片,而不用先把它复制出来。又因为 Base64.Default 是伴生对象,你也可以省掉实例,直接写 Base64.encode(bytes) 当糖;两种形式是同一个调用。
4/3 规则:会膨胀多长?
在你上线一个编码器之前,值得精确知道输出会大多少,因为 Base64 会把字符花掉在它已经知道的信息上。数学是严格的:每 3 个输入字节恰好变成 4 个输出字符,所以剩下的 1 或 2 个字节照样吃掉一整组 4 个,用 = 把组填满。前几个尺寸的结果:
| 输入字节 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| 输出字符 | 4 | 4 | 4 | 8 | 8 | 8 | 12 | 12 |
表背后的公式是 4 * ceil(bytes / 3)。最坏情况下,一个字节变成 4 个字符,300% 的附加费;从三个字节起,它收敛到线上大约多出三分之一的数据。这就是全部的成本模型;实例之间没有差异,也正是你应该刻意对待大载荷的 Base64 化、而不是下意识伸手去拿的原因。
填充是设置,不是宿命
编码这一侧,= 字符是一个策略决定,而 Kotlin 把它做成一等公民。每个实例都携带一个 PaddingOption,withPadding 递给你一个把旋钮转过来的新实例。四个预设都从 PRESENT 起步,这就是为什么 "Hello" 出来是 SGVsbG8= 而不是 SGVsbG8:
import kotlin.io.encoding.Base64
fun main() {
val bytes = "Hello".encodeToByteArray()
val noPad = Base64.Default.withPadding(Base64.PaddingOption.ABSENT)
println(Base64.encode(bytes)) // SGVsbG8=
println(noPad.encode(bytes)) // SGVsbG8
}
旋钮上有四个档位。名字的第一个词决定编码器输出什么;后半部分决定当你(或对方)稍后把它反过来用时,同一实例的解码器有多严格:
| PaddingOption | 编码器输出 = | 解码器接受 = |
|---|---|---|
PRESENT | 是 | 必须,否则失败 |
ABSENT | 否 | 禁止,杂散填充失败 |
PRESENT_OPTIONAL | 是 | 都可以 |
ABSENT_OPTIONAL | 否 | 都可以 |
编码时最常见的选择是 UrlSafe 字母表配 ABSENT,这正是 JSON Web Token 和许多 URL 方案期望的形状。你马上会再见到它。
Base64url:URL、token 与 JWT
经典字母表里有 + 和 /,俩在 URL 里都是灾难:查询串里的 + 常被读成空格,/ 则是路径分隔符。RFC 4648 第 5 节定义了 URL 安全变体,换入 - 和 _,Base64.UrlSafe 就是这个方案。把经典字母表里会产出 / 的那几个字节编码一遍,就能看到这次交换的实际效果:
import kotlin.io.encoding.Base64
fun main() {
val bytes = "Hello?".encodeToByteArray()
println(Base64.encode(bytes)) // SGVsbG8/
println(Base64.UrlSafe.encode(bytes)) // SGVsbG8_
}
教科书级的现实用户是 JWT:头部和载荷是不带填充的 base64url,用点号连接。下面是构建它时的编码半边,哪怕最终 token 由库来签名,这个形状你也该懂:
import kotlin.io.encoding.Base64
fun main() {
val header = """{"alg":"HS256","typ":"JWT"}"""
val payload = """{"sub":"1234567890","name":"John Doe"}"""
val noPad = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
val h = noPad.encode(header.encodeToByteArray())
val p = noPad.encode(payload.encodeToByteArray())
val token = "$h.$p.Ym9nVXNlZlNpZ25hdHVyZUZvckRlbW8"
println(token)
}
输出:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Ym9nVXNlZlNpZ25hdHVyZUZvckRlbW8
两条警告属于这里。第一,第三段是签名,造一个真的签名需要真正的密码学(JCA/JCE 签名器或一个 JWT 库),绝不可能靠手拼字节;上面的片段只是演示编码形状。第二,如果你在 JVM 上凭习惯伸手去拿 java.util.Base64.getUrlEncoder(),注意它默认带填充,所以 JWT 式输出在那边需要 .withoutPadding();Kotlin 预设开箱即用地大声输出填充,你改用一个 withPadding 调用来退出。
换行:Mime 与 Pem 预设
四个预设里有俩会把输出折成短行,原因是历史性的。老的邮件传输会弄坏长行,所以 RFC 2045 第 6.8 节规定 MIME base64 每行不得超过 76 个字符;PKI 工具遵循更老的 PEM 传统,用 64。Kotlin 把两条规则都烤进预设本身:行分隔符是 CRLF,断点恰好落在限额上,末尾没有多余的分隔符。一个 200 字节的载荷穿过各自包装器后长这样:
import kotlin.io.encoding.Base64
fun main() {
val data = ByteArray(200) { (it % 251).toByte() }
println(Base64.Mime.encode(data).lines().maxOf { it.length }) // 76
println(Base64.Pem.encode(data).lines().maxOf { it.length }) // 64
}
对这个输入,Mime 产出 4 行,Pem 产出 5 行。第一行 Mime 是这样的:
AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4
要记住的陷阱在你正在写的方向的背面:带换行的输出不是一行。如果你把 Mime 的输出喂给一个严格的单行消费者,那些 CRLF 会变成解码错误,所以按通道选包装器,而不是按方便。对 API、data URL 和一切现代的东西,Default 是对的默认,而换行是邮件和证书的故事。
字节从哪里来?
编码器有多诚实,取决于你交给它的字节有多诚实,而真正有意思的决定发生在调用 encode 之前的那一步。最常见的来源是文本,最常见的错误是放任字符集悄悄做主:
text.encodeToByteArray()永远是 UTF-8,在每个平台上都是。它是 JSON、邮件和网络数据的正确选择;但如果文本是 Latin-1 或 UTF-16 且对方按相应编码解码,它就是错的。- 在 JVM 上你可以用内联扩展
text.toByteArray(charset)显式选择,它自 Kotlin 1.0 起就在标准库里,是 Kotlin 这边对 JavagetBytes(charset)的回答。kotlin.String上没有getBytes,所以如果你在 Kotlin 字符串上写text.getBytes(),编译器会告诉你;扩展才是那条路。
import kotlin.io.encoding.Base64
fun main() {
val text = "héllo"
println(Base64.encode(text.encodeToByteArray())) // aMOpbGxv
println(Base64.encode(text.toByteArray(Charsets.ISO_8859_1))) // aOlsbG8=
}
同样的五个字母,两种不同的打包形式,因为在任何 Base64 登场之前,字节就已经不同了。如果解码器后来假设 UTF-8,Latin-1 版本会解成乱码,而任何一端的 Base64 技巧都修不好字符集不匹配。
字节的其他来源都是同样的形状。文件是 file.readBytes() 或 path.readBytes() 然后 encode。预分配的缓冲区用 encodeIntoByteArray(bytes, destination)。正在构建的文档用 encodeToAppendable(bytes, builder),它返回目标,所以调用能像 builder 方法一样链起来:
import kotlin.io.encoding.Base64
fun main() {
val sb = StringBuilder("prefix-")
Base64.encodeToAppendable("Hello".encodeToByteArray(), sb)
println(sb) // prefix-SGVsbG8=
}
JVM 上还有一种流式形式,给那些装不进内存的输入用,仍标记为实验性,可以按自己的名字导入。命名里有个反转:encodingWith 包的是输出流,所以通过它写入的内容出来就是 base64,base64 字节落在底层流里:
import java.io.ByteArrayOutputStream
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.io.encoding.encodingWith
@OptIn(ExperimentalEncodingApi::class)
fun main() {
val raw = ByteArray(10_000) { (it % 251).toByte() }
val packed = ByteArrayOutputStream()
packed.encodingWith(Base64.Default).use { encoded ->
encoded.write(raw)
}
println(packed.size()) // 13336
}
经验法则:装得下的全用内存里的 encode,装不下的流用 encodingWith,只要字节其实是文本,字符集就始终显式化。
现场笔记:HTTP Basic 认证
HTTP Basic 认证是互联网上最古老的 Base64 用例,如今在服务间流量里仍无处不在。RFC 7617 定义了方案:取用户名和密码,用一个冒号连接,把结果 base64,然后作为 Basic 加一个空格加打包串放进 Authorization 头。用 Kotlin 写:
import kotlin.io.encoding.Base64
fun main() {
val credentials = "alice:s3cr3t"
val header = "Basic " + Base64.encode(credentials.encodeToByteArray())
println(header) // Basic YWxpY2U6czNjcjN0
}
为什么这里用 Base64 而不是更强的东西?因为头的值必须是一个可打印的 token,而 Base64 保证这一点。诚实的警告:Base64 是编码,不是加密。任何客户端都能一步把 YWxpY2U6czNjcjN0 反回 alice:s3cr3t,所以 Basic 认证只属于 TLS 连接,最好配 token 凭据而不是人类密码。解析这样的头时,在冒号上恰好切一次,因为密码里可能合法地含有冒号。
现场笔记:图片与 data URL
data URL 把二进制资源直接嵌进 HTML、CSS 和 JSON,免得浏览器再发一个请求。形状是:一个媒体类型、一个逗号、单词 base64、又一个逗号、打包后的字节:
import kotlin.io.encoding.Base64
fun main() {
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D.toByte(), 0x0A.toByte(), 0x1A.toByte(), 0x0A.toByte())
val dataUrl = "data:image/png;base64," + Base64.encode(png)
println(dataUrl) // data:image/png;base64,iVBORw0KGgo=
}
上面的字节是一个 PNG 文件的前八个字节,也就是每个解码器都会检查的魔数。为什么 Base64 合适:载荷必须是标记内一个 URL 安全的文本 token,而 Base64 是唯一被广泛支持、文法又稳定的二进制到文本方案。坑在体积。一个 300 千字节的 logo 变成大约 400 千字节的标记,多出的每一个千字节都会在每次加载包含它的页面时被支付一次。data URL 是图标、头像和小精灵图的利器,是视频的酷刑,连给一张大照片用都只能算勉强。内联之前先量一下。
现场笔记:邮件附件
SMTP 是一个比"二进制"这个概念还老的文本协议,所以你在每封邮件里收到的每个附件都是 Base64,按 76 个字符换行,并用一个 Content-Transfer-Encoding: base64 头声明。一个带小二进制附件的最小 MIME 部分长这样,其中 Kotlin 生成的正文槽位装的是一个 5 字节 %PDF- 头的编码结果:
From: sender@example.com
To: receiver@example.com
Subject: report
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="cut-here"
--cut-here
Content-Type: text/plain; charset="utf-8"
The quarterly report follows as an attachment.
--cut-here
Content-Type: application/pdf
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="report.pdf"
JVBERi0=
--cut-here--
(正文 JVBERi0= 是五个字节 %PDF- 的 base64;一份真正的报告会在许多 76 字符行之间换行。)当你手里有文件字节时,Kotlin 这边只要一行:
import kotlin.io.encoding.Base64
fun main() {
val pdf = byteArrayOf(0x25, 0x50, 0x44, 0x46, 0x2D) // "%PDF-"
println(Base64.Mime.encode(pdf)) // JVBERi0=
}
坑在于通道纪律。正文用 Mime,别用 Default,因为严格的 MIME 解析器期待换行,而一条 10,000 字符的朴素 Default 行会被某些传输拒收或弄坏。Content-Transfer-Encoding 行里的大小写保持恰好是 base64,并记住换行是格式的一部分:未换行和已换行的输出是同一组字节的两种表示,另一端的解析器必须知道自己吃的是哪一种。
现场笔记:JSON API 与上传
当一个 API 想在 JSON 文档里放二进制时,惯例是放一个装着 base64 的字符串字段,而这是生态里最省事的模式之一,因为 JSON 本来就有文本的家。用 kotlinx.serialization,往返很直接:
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.io.encoding.Base64
@Serializable
data class UploadRequest(val name: String, val payload: String)
fun main() {
val icon = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47)
val request = UploadRequest("icon.png", Base64.encode(icon))
val json = Json.encodeToString(UploadRequest.serializer(), request)
println(json) // {"name":"icon.png","payload":"iVBORw=="}
}
为什么这里用 Base64:JSON 没有二进制类型,所以载荷必须是文本,而 Base64 是 API 消费者不看文档也能认出的、最不出意外的二进制文法。坑在规模。4/3 的附加费在每次请求和每次响应里都要付,一个 10 兆字节的上传变成 13.3 兆字节的 JSON 字符串,你的解析器必须在内存里持有它、转义它、校验它。大文件几乎总是应该用 multipart/form-data 或二进制主体,那才是更好的线上格式;把 JSON 里的 base64 留给缩略图、图标、签名和小的字节块,那里的便利压过了这笔税。
现场笔记:配置与命令行
最后两个模式是那种出现在每个代码库里的小的:配置值、token、许可密钥,有时还有点小秘密,经常以 base64 的形式穿过环境变量和属性文件,因为传输只走文本,而值里可能含有引号或换行。读回来是同样的两步舞,倒着走:System.getenv 或属性查找,然后解码。在命令行上,为一个传输或检查目的编码一个文件,是个十行程序:
import java.io.File
import kotlin.io.encoding.Base64
fun main(args: Array<String>) {
require(args.isNotEmpty()) { "usage: b64encode <file>" }
val bytes = File(args[0]).readBytes()
val encoded = Base64.encode(bytes)
File(args[0] + ".b64").writeText(encoded)
println("Wrote ${encoded.length} characters to ${args[0]}.b64")
}
在一个装着十个字节 hello file 的文件上运行,它写出 16 个字符:aGVsbG8gZmlsZQ==。两种情况的坑是同样两个:配置里的 Base64 不是保险库,这个值离明文只有一步之遥,不管如何都应把它当作线上的秘密对待;而一个手写的 CLI 工具应该刻意决定自己的字母表,因为把输出管道进 URL 的用户需要的是 UrlSafe,不是 Default。
编码时可能出什么错
编码对内容是宽容的:任何字节序列都是合法输入,所以不存在解码器那种"非法符号"失败。真正会抛的是几何,而消息精确到有用:
| 情形 | 异常 | 消息 |
|---|---|---|
endIndex 超出数组末尾 | IndexOutOfBoundsException | startIndex: 0, endIndex: 100, size: 5 |
startIndex 越过 endIndex | IllegalArgumentException | startIndex: 3 > endIndex: 2 |
目标数组对 encodeIntoByteArray 来说太小 | IndexOutOfBoundsException | The destination array does not have enough capacity, destination offset: 0, destination size: 2, capacity needed: 8 |
与这些并排坐着两个 Kotlin 特有的陷阱。第一个是经典的 int + String 错误:bytes.size + " bytes" 编译不过,因为 Int 上的加号不拼接字符串;插值形式 "${bytes.size} bytes" 才是 Kotlin 的方式。第二个是字符集查找:对 JVM 不认识的名字,比如 Charset.forName("utf-9"),它会抛 UnsupportedCharsetException,所以字符集名字里的拼写错误是运行时异常,不是编译错误,而且它冒泡在编码器运行的地方,而不是名字被敲出来的地方。
Kotlin 风格的坑
下面的陷阱专门咬第一次伸手找标准库的 Kotlin 开发者:
- 放任
encodeToByteArray()替你挑字符集。它永远是 UTF-8,而且是默默的;Latin-1 或 UTF-16 的源会被打包成解码器读不回来的字节。字符集要刻意决定,不是 UTF-8 时在 JVM 上用toByteArray(charset)。 - 凭肌肉记忆伸手去拿
java.util.Base64。它的getUrlEncoder()默认带填充,对 JWT 来说是错的形状,除非你记得.withoutPadding();Kotlin 预设让两边都把选择摆到明面上。 - 把
Mime或Pem的换行输出喂给单行消费者。CRLF 是表示的一部分,会让期待一行的严格解码器失败;只有通道期待换行时才换行。 - 在 Kotlin 字符串上写
text.getBytes()。Java 的方法在kotlin.String上不可见;自 Kotlin 1.0 就存在的内联toByteArray(charset)扩展是替代品。 - 跑老工具链。某些发行版上的系统 Kotlin 还是 1.3,比标准库
Base64出现得早;这个类需要 1.8.20 才存在,2.0.20 才有填充控制,2.2 才稳定。 - 把 Base64 当安全层。它是传输编码,有公开的、一步的反函数。任何秘密都应该在打包之前加密,而不是只打包。
选得对:快速决策指南
拿不准时,决定几乎总是由通道做出,而不是由内容做出。简短版:
Base64.Default用于 API、JSON、data URL 和一切本质上就是一行文本的东西。带填充的输出是线上兼容性最好的形状。Base64.UrlSafe配ABSENT填充,用于 token、JWT 和一切落在 URL 段或查询参数里的东西。Base64.Mime用于邮件正文和附件,那里 76 字符行是格式的硬性要求。Base64.Pem用于证书和私钥,那里 64 字符行是每个 PKI 工具都期望的。
再加两条贯穿始终的习惯:只要输入是文本,字符集就显式化;盯住 4/3 的附加费,让大载荷走二进制通道而不是 base64 通道。
通往标准的道路
标准库走向 Base64 的路新到你会遇到没有它的老 Kotlin。这个类最早出现在 2023 年 4 月的 Kotlin 1.8.20,标记为实验性,三个实例,更简单的表面:编码永远带填充,也没有办法要求更少。如果你见过 1.8 时代用 removeSuffix 把字符串末尾的 = 剥掉的代码,那是那个时代对付无填充输出的唯一工具,而现在是个值得丢掉的毛病。2025 年 6 月发布的 Kotlin 2.2 稳定了 API,并补上最后一块:Pem 实例。PaddingOption 旋钮和 withPadding 早在 2.0 线里就到了,确切地说是 2.0.20,那是第一个能双向控制填充的一等手段。流式助手 encodingWith 和 decodingWith 仍是实验性且仅限 JVM,这就是标准库标记那些还想再攒点实战经验才肯冻结的 API 的方式。自 2.4.0 线起,语言还给标准库换上了 18 个月的支持窗口,所以钉在某个 2.4.x 编译器上的项目,比如撰写本文时现行的 2.4.10 稳定版,在整个支持周期内都能用上完整的 Base64 API。
小惊喜
知道之后会让这个类更有意思的几处细节:
- 不带实例的
Base64.encode(bytes)能工作,是因为Base64.Default定义在伴生对象上;伴生对象就是默认方案,所以糖和带名字的写法是字面意义上的同一个对象。 encodeToAppendable函数是 builder 风格:它返回目标 appendable,所以文档里的模式是忽略返回值,继续用你的 builder。- 填充永远不会填满一整组:base64 字符串以零个、一个或两个
=结尾,数一数填充就知道原始数据在最后一个三元组里剩了几个字节。 Pem是四个预设里最年轻的,2.2 才加入;64 字符换行是比它旁边那条 RFC 2045 规则更老的 PKI 惯例。- 在 JVM 上,标准库刻意不委托给
java.util.Base64;两个实现是分开的,这让行为在各平台保持一致,代价是一段被注释掉的优化,Kotlin 团队把它留在代码树里,留给将来 Java API 允许的那一天。 - 同一个稳定了
Base64的 2.2 版本也稳定了HexFormat,kotlin.text里自 Kotlin 1.9 起就是实验性的十六进制格式化类,所以字节级文本编码如今在标准库里有了安定的家。
收尾与下一步
在 Kotlin 里生产 Base64 是一小串刻意的选择:按通道挑预设、刻意决定填充、输入是文本时保持字符集显式、载荷大时尊重 4/3 的附加费。其他一切 - 文件、缓冲区、appendable、流 - 都是围着同样四个实例的薄包装。另一个方向,把那段打包文本拿回字节,有它自己的严格规则、自己的失败模式和自己的陷阱,姊妹站点上的相关文章深入讲了 Kotlin 里的 Base64 解码。
最后更新: 2026-09-08
相关文章: Kotlin 中的 Base64 解码:完整指南