Kotlin 中的 Base64 解码:完整指南
你正盯着一个死活读不出来的值:SGVsbG8sIFdvcmxkIQ==。一串字母和数字,偶尔混进一个 + 或 /,末尾通常还挂着一两个 =。这就是 Base64,而本指南要讲的是如何把它还原回原本的样子 - 一句话、一张图片、一个证书、一坨二进制 - 用 Kotlin 的方式。深入之前先花一句话温习一下:Base64 把每三个字节打包成四个字符,取自一张 64 符号的字母表,结尾一小截 = 填充标记出真实数据在哪里停止。格式的完整巡礼在首页,所以这里只用一句话,外加一句:因为四个字符装的是三个字节的东西,文本形式比原始数据大约长出三分之一。
Kotlin 的好消息是:你根本不需要任何包。标准库自带 Base64 实现已经很多年了;自 Kotlin 2.2 起它完全稳定,并且运行在 Kotlin 能运行的每一个平台上,从你笔记本上的 JVM 到 Android 手机,再到 Node.js,再到 WASI 边缘函数。下面所有内容都用你项目自带的 Kotlin 就能跑。
先说好消息:你到底需要什么
Gradle 里没有叫 base64 的构件可以添加,没有 NuGet 式的包,也没有 npm 模块。你要的类是 kotlin.io.encoding.Base64,它本身就是 Kotlin 标准库的一部分。只要你会写 println,你就能解码 Base64。一个 Kotlin 项目里有三个 API 能干 Base64 的活,选对哪个是第一个真正的决定:
| API | 它运行在哪里 | 何时选它 |
|---|---|---|
kotlin.io.encoding.Base64 |
所有 Kotlin 平台:JVM、Android、JS、Native、Wasm | 默认之选。自 Kotlin 2.2 起稳定,多平台,现代 API |
java.util.Base64 |
仅限 JVM(Java 8+;Android 上需 API 26+) | 本来就活在 Java 互操作地带、只做 JVM 的代码库 |
android.util.Base64 |
仅限 Android(API 8+) | 遗留 Android 代码,或者你确实需要它的标志常量 |
有两条版本备注值得知道。第一,标准库的这个类最早出现在 Kotlin 1.8.20(2023 年 4 月),身后挡着 @ExperimentalEncodingApi 这道门;Kotlin 2.0.20 带来了 withPadding 旋钮和严格的填充规则,Kotlin 2.2.0(2025 年 6 月)让 API 稳定下来并加上了 PEM 实例。所以在 Kotlin 2.2 或更新版本上 - 包括当前稳定线 2.4.x - 你可以零注解地使用本指南的一切。第二,如果你的项目钉在 1.8 到 2.1 之间的某个 Kotlin 版本,同一个类存在但被标记为实验性,编译器不允许你在函数上不加 @OptIn 注解就用它。
有一个安装陷阱已经坑掉过不止一个下午:Debian 和 Ubuntu 仓库里的 kotlin 包是 1.3.31 版本,比标准库 Base64 API 出现得还早,所以它连本文的一个例子都编译不了。改从 GitHub 上的 Kotlin 发布页或 SDKMAN 拿编译器,在 Gradle 项目里则显式钉住插件:
plugins {
kotlin("jvm") version "2.4.10"
}
第一次解码:两行代码,一个字节结果
整个仪式装得进两条语句,而经典的 TWFu 字符串是个不错的起点:
import kotlin.io.encoding.Base64
fun main() {
val packed = "TWFu"
val bytes = Base64.decode(packed)
println(bytes.decodeToString()) // Man
}
慢慢读这段,因为里面藏着三个设计决定。第一,不带任何 .Default 的 Base64.decode(...) 不是笔误:Default 是这个类的伴生对象,所以直接在类上调函数,就是在 Base64.Default 上调它的简写。你在较旧的教程里也会看到 Base64.Default.decode(...),意思完全一样。第二,这一点看起来不起眼,其实更重要:decode 交给你的是一枚 ByteArray,绝不是 String。载荷可能是一张 JPEG、一个 X.509 证书或一句话,API 拒绝去猜是哪一个,所以字节到文本的那一跳是单独的、刻意的一步。第三,字符集的决定就住在那一步里,而大多数"我的 Base64 解出来全是乱码"的 bug 就出生在那里。我们马上就到那一步;先来一次往返,证明解码是忠实的:
import kotlin.io.encoding.Base64
fun main() {
val original = "Hello, World!".encodeToByteArray()
val packed = Base64.encode(original)
val back = Base64.decode(packed)
println(packed) // SGVsbG8sIFdvcmxkIQ==
println(back.contentEquals(original)) // true
}
四个方案,四种性格
这个类从不被实例化;你从四个现成的实例里挑一个,而每个解码时脾气都不一样:
import kotlin.io.encoding.Base64
fun main() {
val data = "Hello?".encodeToByteArray()
println(Base64.Default.encode(data)) // SGVsbG8/
println(Base64.UrlSafe.encode(data)) // SGVsbG8_
println(Base64.Mime.encode(data)) // SGVsbG8/
println(Base64.Pem.encode(data)) // SGVsbG8/
}
| 实例 | 字母表 | 它如何解码 |
|---|---|---|
Base64.Default |
A-Z a-z 0-9 + / |
严格:字母表外的任何字符都抛出异常;要求有填充 |
Base64.UrlSafe |
A-Z a-z 0-9 - _ |
严格,但针对的是 URL 字母表;输入里出现 + 或 / 就抛出异常 |
Base64.Mime |
A-Z a-z 0-9 + / |
宽容:忽略换行符和其他非字母表字符,但 = 填充之后不许再跟任何东西;要求有填充 |
Base64.Pem |
A-Z a-z 0-9 + / |
宽容,规则与 Mime 相同;这是同一字母表的 PEM/PKI 风味 |
宽容与严格这条分界线,是整篇文章里最值得记进脑子的东西。Default 和 UrlSafe 把任何外来字符都当成案发现场,立即抛出异常。Mime 和 Pem 对换行、空格和零散的标点只是耸耸肩 - 因为真实的邮件和证书文件里装的就是这些 - 但它们也不是无限的宽容:一旦有数据字符出现在填充之后,连它们也会抛出异常。稍后的失败图鉴里你会看到确切的错误消息。
性格带来的另一个后果:一个方案读不了另一个方案的输出。把一个 base64url token 喂给 Base64.Default,- 不在它的字母表里,于是你得到 IllegalArgumentException: Invalid symbol '-'(55) at index ...。拿不准一个字符串从哪来时,挑与生产者匹配的方案,而不是挑合你心情的那个。
URL 安全的 Base64 与 JWT
标准字母表里有俩字符,只要数据一踏上 URL 就会惹祸。查询串里的 + 在被任何东西读取之前,通常早就被重新解释成空格了;/ 是路径分隔符,在 URL 段里压根不能出现。RFC 4648 第 5 节的做法是交换字母表最后两个符号:+ 变成 -,/ 变成 _。你听到最多的名字是 base64url,在 Kotlin 里它就是 Base64.UrlSafe。
base64url 最大的消费者是 JSON Web Token。紧凑形式的 JWT 由三段 base64url 用点号连接:header.payload.signature。RFC 7515 规定这些部分是 不带填充的 base64url,这是它与朴素字母表的第二处不同,不只是字符不同。下面这个 token 正在被拆开检查:
import kotlin.io.encoding.Base64
fun main() {
val token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
val (header, payload, signature) = token.split(".")
val lenient = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
println(lenient.decode(header).decodeToString())
// {"alg":"HS256"}
println(lenient.decode(payload).decodeToString())
// {"sub":"1234567890","name":"John Doe"}
println(signature.length) // 43
}
有两处值得注意。token 的各段都不带填充,但开箱即用的 Base64.UrlSafe 要求有填充,所以 withPadding(PRESENT_OPTIONAL) 这一行在干实事:带填充和不带填充的输入它都收。split(".") 加上解构,也只是普通 Kotlin 在做格式要求它做的事。一条严肃的警告:拆开 JWT 是为了看一个 token,不是为了信任它。解码之后头部和载荷都只是普通数据;只有经过验证的签名才说明 token 是真的,而那需要一个真正的 JWT 库,不是手写的字符串拆分。
填充模式与严格度旋钮
在 Kotlin 里,填充不是 Base64 的固定事实,而是一个设置。每个实例都携带一个 PaddingOption,四个预设实例都从 PRESENT 起步,withPadding 会递给你一个换了设置的新实例,原来的原封不动。下面逐个选项过一遍这个旋钮:
| 选项 | 不带填充的输入 | 带正确填充的输入 |
|---|---|---|
PRESENT(到处都是默认) |
抛出异常 | 解码 |
ABSENT |
解码 | 抛出异常 |
PRESENT_OPTIONAL |
解码 | 解码 |
ABSENT_OPTIONAL |
解码 | 解码 |
import kotlin.io.encoding.Base64
fun main() {
val data = "Hello".encodeToByteArray()
println(Base64.Default.withPadding(Base64.PaddingOption.ABSENT).encode(data))
// SGVsbG8
println(Base64.Default.encode(data))
// SGVsbG8=
val eitherWay = Base64.Default.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
println(eitherWay.decode("SGVsbG8").decodeToString()) // Hello
println(eitherWay.decode("SGVsbG8=").decodeToString()) // Hello
}
解码这一侧,PRESENT_OPTIONAL 是你的安全网:它说的是"我不知道发信人垫没垫填充,而我打算继续工作"。其他组合的错误消息出乎意料地有帮助,所以当严格解码器遇到错误输入时,你一眼就能认出它们:PRESENT 下缺少填充产生 The padding option is set to PRESENT, but the input is not properly padded,ABSENT 下出现填充产生 The padding option is set to ABSENT, but the input has a pad character at index 7。有一个行为值得单独点名,因为它总会让人意外:像 SGVsbG8== 这样的双重填充不是"多来一点但没关系"。第一个 = 结束了数据,第二个出现在本该是数据的位置,所以哪怕最宽容的解码器也会拒收它。
这里还藏着一段版本史。如果你接手了针对 1.8.x 实验性 API 编写的代码,记住旧的 decode 对带填充和不带填充的输入都照收。Kotlin 2.0.20 里,Default 搬到了严格的 PRESENT 规则下,于是曾经好用的无填充输入,在升级到那个点版本之后就会抛出异常。修复只要一行:withPadding(Base64.PaddingOption.PRESENT_OPTIONAL),或者在解码前把输入归一化。
从字节到文本:字符集与 Unicode
拿到你的 ByteArray 之后,问题是它意味着什么。如果载荷是文本,默认答案是 decodeToString(),它把字节按 UTF-8 解释,在每个平台上都能工作。对于现代 API、邮件和网络数据这些常见情况,这是你唯一需要的,表情符号也算在内:
import kotlin.io.encoding.Base64
fun main() {
val original = "héllo 😀"
val packed = Base64.encode(original.encodeToByteArray())
println(packed) // aMOpbGxvIPCfmIA=
println(Base64.decode(packed).decodeToString()) // héllo 😀
}
不过,发送方一旦用了 UTF-8 以外的东西,字符集的决定就落在你头上。Kotlin 内置的文本转换是刻意只做 UTF-8:decodeToString() 没有字符集参数,带字符集参数的字符串转字节函数也不存在。在 JVM 上你要降到平台的字符集 API,它是诚实而明确的:
import kotlin.io.encoding.Base64
import java.nio.charset.Charset
fun main() {
val latinOne = "héllo".toByteArray(Charsets.ISO_8859_1)
val packed = Base64.encode(latinOne)
println(packed) // aOlsbG8=
val asUtf8 = Base64.decode(packed).decodeToString()
val asLatin = String(Base64.decode(packed), Charsets.ISO_8859_1)
println(asUtf8) // h?llo(é 字节不是有效的 UTF-8)
println(asLatin) // héllo
val byName = String(Base64.decode(packed), Charset.forName("ISO-8859-1"))
println(byName) // héllo
}
中间那行的 ? 不是字体问题;它是 U+FFFD,Unicode 替换字符,替一个构不成合法 UTF-8 的字节顶岗。解码后看到一排这样的字符,说明你的载荷没事 - 是你的字符集假设出事了。还有一个会咬人的不对称也值得注意:编码一侧有 JVM 扩展 toByteArray(charset);解码一侧对应的构造器是 String(bytes, charset)。两者都不收字符集名字;要按名字找得用 Charset.forName("..."),它会给编出来的名字抛 UnsupportedCharsetException,所以配置值里的拼写错误会快速失败,而不是默默换成另一种编码。
既然身在字节世界,再说一个 Kotlin 特有的陷阱:Char 是一个 16 位的值,对它调 toByte() 会悄悄只保留低八位。如果你从字符手工拼字节,"中".first().code.toByte() 给你 45,一个和这个字符毫无关系的数字。正确的路永远是 encodeToByteArray(),它做真正的编码工作 - 同一个字符是三个 UTF-8 字节,它的 Base64 形式是 5Lit。让标准库去编码,永远别手工把字符塞进字节。
文件、子串与大输入
Base64 数据在内存里不总是一根规整的字符串。有时它是一个文件、一个更大响应里的切片,或者大到没法一次装下。Kotlin 把三扇门都给你。
文件是最无趣的情况,好得不能再好:读字节,解码,完事。两套标准文件 API 都行,用你项目已经在用的那套:
import java.io.File
import kotlin.io.encoding.Base64
import kotlin.io.path.Path
import kotlin.io.path.readBytes
fun main() {
val fromFile = File("payload.b64").readBytes()
println(Base64.decode(fromFile.decodeToString()).size) // 解码出的字节数
val fromPath = Path("payload.b64").readBytes()
println(Base64.decode(fromPath.decodeToString()).size) // 同一个数字
}
子串是 CharSequence 重载大显身手的地方。decode 接受任何带起止索引的字符序列,所以你可以直接把一个长响应体的切片交给它,不用先把切片复制出来:
import kotlin.io.encoding.Base64
fun main() {
val body = "prefix junk SGVsbG8= trailing junk"
val bytes = Base64.decode(body, 12, 20)
println(bytes.decodeToString()) // Hello
}
如果你已经知道输出大小、想复用缓冲区,decodeIntoByteArray 会写进你选定的目标数组,并告诉你写入了多少字节。给它的缓冲区太小,它会抛 IndexOutOfBoundsException,消息里还带着所需的容量,这个错误顺便就是你的尺寸提示。
对于 JVM 上真正的大流,还有第三扇门:流式解码器。它们仍标记为实验性 - 所以有那个 opt-in 注解 - 而且只存在于 JVM,但它们一边读一边解码,而不是把一切都装进内存:
import java.io.ByteArrayInputStream
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.io.encoding.decodingWith
@OptIn(ExperimentalEncodingApi::class)
fun main() {
val stream = ByteArrayInputStream("SGVsbG8gV29ybGQh".toByteArray())
stream.decodingWith(Base64.Default).use {
println(it.readBytes().decodeToString()) // Hello World!
}
}
两个实用细节。扩展函数住在包的顶层,所以按名字导入(星号导入也行,但按名字更友好)。而且解码器把填充当成硬性刹车:如果底层流在 Base64 段之后还在继续,从解码流里读取会在 = 处结束,剩下的字节仍留在原流里可用。对于那些把 Base64 别在其他东西前面的格式,这就很干净。
实战:HTTP API 与 JSON 请求体
JSON 装不了原始字节 - 它是文本协议 - 所以需要搬运二进制(图片、证书、任意字节块)的 API 几乎总是把它包进 Base64,塞在字符串字段里。套路是:解析 JSON,取字段,解码。有了官方序列化库,JSON 这部分离你只有两个注解的距离:
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.io.encoding.Base64
@Serializable
data class ImageResponse(val name: String, val data: String)
fun main() {
val body = """{"name":"icon.png","data":"iVBORw0KGgo="}"""
val response = Json.decodeFromString<ImageResponse>(body)
val bytes = Base64.decode(response.data)
println("${response.name}: ${bytes.size} bytes") // icon.png: 8 bytes
}
这个例子需要序列化插件和库,往构建里加一次:
plugins {
kotlin("jvm") version "2.4.10"
kotlin("plugin.serialization") version "2.4.10"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
}
没有库也一样能在原始字符串上玩转这个思路,写快脚本时很顺手:用 substringBetween 把字段抠出来再解码。坑都是 API 上熟悉的那些:字段可能是一整个 data URL(带 data:image/png;base64, 前缀,本文后面会处理),载荷可能经过 MIME 换行包裹,编码后的载荷可能比原始二进制大约三分之一,所以在大型响应上盯紧你的内存预算。
实战:邮件与 MIME 换行输入
邮件是一个 7 位文本世界,RFC 2045 对二进制附件的回答是带着一转的 Base64:编码输出必须换行包裹,任何一行不得超过 76 个字符。如果你收到过被当成文本的附件,它看起来就像一列缩进的 Base64,原因在此。对于恰好是这种输入,Base64.Mime 才是对路的解码器,因为它一路忽略换行符和其他非字母表字符:
import kotlin.io.encoding.Base64
fun main() {
val wrapped = "SGVs\nbG8=\r\n"
println(Base64.Mime.decode(wrapped).decodeToString()) // Hello
val withJunk = "Y@{mFz!Z!TY}0"
println(Base64.Mime.decode(withJunk).decodeToString()) // base64
}
这份宽容是真的,但有边界。输入被换行包裹、再撒上一两个空格,没问题。但只要在最后一个 = 后面添一个数据字符,连 Mime 也会抛:Symbol 'e'(145) at index 7 is prohibited after the pad character。还要记住 Mime 仍要求填充在场且正确;一个连缺失填充都照吞的 MIME 解码器就是自找麻烦。对付杂乱邮件载荷的实用菜谱是先用 Mime 解码,如果它抛了,看消息 - 它会精确告诉你哪个符号、在哪个索引、违反了规则。
实战:图片与 data URL
data URL 是网页把文件直接内联进文档的方式:一个媒体类型、一个 base64, 标记和载荷,全塞进一个字符串。浏览器、CSS 和内嵌 UI 对小资源偏爱它们 - 图标、头像、占位图 - 因为不用发第二个请求。格式长这样:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==
在 Kotlin 里解码一个,是一次字符串操作加一次 Base64 解码。前缀没什么秘密;逗号之后全是载荷:
import kotlin.io.encoding.Base64
fun main() {
val dataUrl = "data:image/png;base64,iVBORw0KGgo="
val mediaType = dataUrl.substringBefore(";")
val packed = dataUrl.substringAfter("base64,")
val bytes = Base64.decode(packed)
println(mediaType) // data:image/png
println(bytes.size) // 8
println(bytes.contentToString()) // [-119, 80, 78, 71, ...]
}
第一个字节 -119(即 0x89),后面跟着 PNG 三个字母,就是识别 PNG 文件的魔数。解码后检查头四个或八个字节,是确认 data URL 里真有它前缀所声称内容的便宜办法。两个诚实的提醒:Base64 会让体积大约增加三分之一,所以 data URL 是你拿体积去换一次网络往返的交易;而对任何大的东西,通常更划算的是从真实 URL 提供文件,让缓存去干活。
实战:配置、环境变量与数据库
只要二进制值必须搭乘一条只走文本的通道,Base64 就会出现在配置文件和环境变量里:properties 文件里嵌的一枚小图标,容器环境变量里存的一个 token,因为 schema 比正经的二进制类型出现得早而被停放在文本列里的一坨字节。解码一侧到处都是同样两步 - 读文本,解码:
import kotlin.io.encoding.Base64
fun main() {
val line = "icon: UE5HREFUQQ=="
val packed = line.substringAfter("icon: ").trim()
val bytes = Base64.decode(packed)
println(bytes.decodeToString()) // PNGDATA
val fromEnv: String? = System.getenv("MY_ICON_B64")
if (fromEnv != null) {
println(Base64.decode(fromEnv).size)
}
}
这一整片区域的坑一句话装得下:Base64 不是加密。它是运输手法,不是锁。谁都不该读到一个 Base64 值就以为里面的数据藏起来了;它离可见只差一个函数调用,而且在你写的每一行日志里都可见。如果某个值是敏感的,让它从头到尾保持敏感 - 密钥库、加密列、你的技术栈提供什么就用什么 - 只把 Base64 用来让字节穿过文本,而不是用来保护它们。
实战:命令行
最古老的用例:把命令行上的一个 Base64 团变成文件。一个完整的工具只要八行 Kotlin,因为重活是标准库干的。用 Kotlin 编译器编译一次,它永远归你:
import java.io.File
import kotlin.io.encoding.Base64
fun main(args: Array<String>) {
val packed = if (args.isNotEmpty()) args[0] else readlnOrNull().orEmpty()
val bytes = Base64.decode(packed.trim())
File("decoded.bin").writeBytes(bytes)
println("Wrote ${bytes.size} bytes to decoded.bin")
}
带参数运行处理一次性值,或者把一个文件管道进去做批处理:程序有第一个参数就读它,没有就退回标准输入。这里的 trim() 在默默值班,因为 shell 参数和粘贴进来的值总爱带着零碎空白,严格解码器会拒收它们。如果你的载荷是 base64url,把 Base64.decode 换成 Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode,这个工具也就准备好接 token 了。
解码失败图鉴
本指南里的每个解码器失败时只抛两种异常类型,而每条消息都具体到能告诉你到底哪里错了。这是完整的地图,附标准库产生的确切消息:
| 情形 | 异常 | 消息(原文照发) |
|---|---|---|
| 字母表外的字符(空格、换行、别的方案的符号) | IllegalArgumentException |
Invalid symbol ' '(40) at index 5 |
| 填充之后的数据字符 | IllegalArgumentException |
Symbol 'e'(145) at index 7 is prohibited after the pad character |
选项是 PRESENT 时缺少填充 |
IllegalArgumentException |
The padding option is set to PRESENT, but the input is not properly padded |
选项是 ABSENT 时填充在场 |
IllegalArgumentException |
The padding option is set to ABSENT, but the input has a pad character at index 7 |
| 索引超出源的范围 | IndexOutOfBoundsException |
startIndex: 0, endIndex: 100, size: 8 |
startIndex 大于 endIndex |
IllegalArgumentException |
startIndex: 3 > endIndex: 2 |
decodeIntoByteArray 的目标缓冲区太小 |
IndexOutOfBoundsException |
The destination array does not have enough capacity, destination offset: 0, destination size: 2, capacity needed: 8 |
注意前两行的模式:消息会点名肇事的符号、括号里它的数值代码、以及它的索引。这是调试礼物。当解码在生产上抛出异常时,记下输入的前几十个字符和消息里的索引,你几乎总能几秒内揪出元凶 - 不管它是粘贴进来的换行、被截断的载荷,还是溜进标准解码器的 base64url 字符串。
专门咬 Kotlin 开发者的坑
- 假设载荷是文本。
decode返回ByteArray是故意的。因为"它八成是文本"就对 JPEG 调decodeToString(),得到的是一整面替换字符墙。转换之前先弄清这些字节是什么。 - 复制粘贴的空白。默认解码器是严格的,而从聊天消息或日志里搬来的值几乎总是带着尾随换行或前导空格。解码前先 trim,或者走
Mime解码,要么接受IllegalArgumentException并处理它。 - 从实验时代升级而来。为 1.8.x 实验性 API 写的代码带着
@OptIn(ExperimentalEncodingApi::class)注解,并且依赖填充是可选项。从 2.0.20 起,同样的输入可能抛出异常。修复是PRESENT_OPTIONAL,或者在输入到达解码器之前把它们清理干净。 - 把方案和错误生产者配错。用
Base64.Default解码 JWT 段,会败在它的-和_字符上;用UrlSafe解码标准字母表载荷,会败在+和/上。异常会点名确切的符号,但修复在于知道字符串从哪来。 - 字符集的空档。
decodeToString()只做 UTF-8,没有其他编码的重载。如果发送方用了 Latin-1 或 Windows-1252,在 JVM 上就计划用String(bytes, charset),忘了的话,U+FFFD 替换字符就是症状。 - 发行版软件包的编译器。Debian 和 Ubuntu 上
apt install kotlin给的是 1.3.31,早于这个 API 存在的时候。如果你的例子突然以"unresolved reference"为由拒绝编译,检查一下 PATH 上实际是哪个编译器。
解码最佳实践
- 先解码到字节,后解释。把
Base64.decode和文本转换保持为分开的两步。它让字符集显式化,让二进制载荷保持二进制,也让测试变得平凡:比较字节数组,而不是字符串。 - 挑与生产者匹配的实例。JWT 和注定上 URL 的数据意味着
UrlSafe;邮件和 PEM 文件意味着Mime或Pem;其他一切从Default开始。宽容解码器是给已知杂乱的输入准备的,不是通用安全网。 - 对不可信输入做一次便宜归一化。在严格解码之前
trim()一下、在格式已知干净的地方再去掉空白,能抓到的真实失败比多少 try-catch 都多。一个带PRESENT_OPTIONAL回退的小助手,是处理来源不明值的好模式。 - 分配之前先算尺寸。解码输出至多是输入长度的四分之三(四个符号装三个字节),所以快速长度检查能在解码前告诉你目标大小 - 这正是你往预分配缓冲区里填数据、或接收一个好几兆的字符串之前想要的。
- 相信错误消息。标准库会报告符号、它的代码和它的索引。对不可信输入记下那个索引附近的输入,然后别再猜了。
- 别为了藏东西而解码,也别为了证明什么而解码。Base64 是传输编码。它既不添秘密也不添完整性;如果你需要其中任何一个,那是密码学的活,不是解码器的活。
Base64 如何进入 Kotlin
Base64 比 Kotlin 年长好几十年 - 给出 76 字符行规则的 MIME 规范要追溯到 1993 年,字母表本身则要追溯到 1990 年代中期的 RFC - 但 Kotlin 专属的故事短而新。kotlin.io.encoding 包在 2023 年 4 月的 Kotlin 1.8.20 中登场,带着 Base64 和三个实例 - Default、UrlSafe 和 Mime - 挡在 @ExperimentalEncodingApi 注解之后,还有至今仍为实验性的 JVM 专属流式扩展。两年里,用它意味着每个函数里一行 opt-in,外加 API 可能挪窝的一点风险。
2025 年 6 月发布的 Kotlin 2.2.0 改变了契约。整个 API 在一个版本里全部稳定,Pem 实例加入家族(RFC 1421 的 64 字符行变体,用在 PKI 周围)。让 1.8 时代填充可选的代码在升级后需要关注的严格性,早一步到来:2.0.20 里,带着四个 PaddingOption 值的 withPadding 取代了旧的固定行为,解码器开始要求填充。2.2 这个版本还稳定了 kotlin.text 里的兄弟类 HexFormat,那个自 Kotlin 1.9 起就是实验性的十六进制格式化 API,所以字节级文本编码如今在标准库里有了安定的家。维护层面再说一句:自 Kotlin 2.4.0 起,JVM 标准库每条发布线带 18 个月支持窗口,这又多了一个理由,让运行在当前 2.4.x 线上的项目把这个 API 当作固定点,而不是移动靶。
趣闻
- 伴生对象在干活。因为
Default是Base64的伴生对象,类名兼职做默认实例:Base64.decode(x)和Base64.Default.decode(x)是同一个调用。这就是本文开头那个两行例子能保持两行的原因。 - 字符串解码在 JVM 上有速度技巧。通用解码循环在字节上工作,但 Kotlin 字符串是字符序列。JVM 实现绕开了转换:在共享循环跑起来之前,把
String的字符重新解释为单字节 ISO-8859-1 值 - 源码注释声称这个技巧比通用路径快至多十倍,这就是decode(String)在长载荷上也有秒解手感的原因。 - 包名是个暗示。这个 API 住在
kotlin.io.encoding,不在kotlin.text,因为整点在于数据是字节 - 输入输出都是 I/O 形状,文本只是结果后来被弄成的样子。 - 错误消息带字符的代码。
Symbol 'e'(145)报告肇事符号的八进制数值,而不只是字形。元凶是空白时特别好用:' '(40)让你早就知道那是个空格,而不用先对它起疑。 - PEM 到得很晚。
Base64.Pem不在最初的 1.8.20 API 里;它是随 2.2 稳定化一起登场的。如果 2023 或 2024 年的博客文章只列了三个实例,它没有错 - 只是落后了两个版本。 - 它是按平台写的,不是委托的。标准库用 expect/actual 函数为每个目标单独实现编解码器。JVM 上甚至有一段被注释掉的优化,想把活交给
java.util.Base64,因为一个未关闭的编译器问题被禁用 - 这也是为什么 Kotlin 实现的行为在每个平台上都是参考行为。
收尾
在 Kotlin 里解码 Base64,归结为一小串刻意的选择:与数据来源匹配的实例、与发送方式匹配的填充模式、与体积匹配的缓冲区或流、与含义匹配的字符集。标准库把这四样全作为无依赖的普通函数交到你手上,而它的错误消息具体到失败本身就是一份诊断,而不是谜。反方向 - 当你是生产 Base64 的那一方,要为它选对方案、填充和换行 - 有它自己的决定和陷阱,姊妹站点上的相关文章深入讲了 Kotlin 里的 Base64 编码。
最后更新: 2026-09-08
相关文章: Kotlin 中的 Base64 编码:完整指南