Swift 中的 Base64 解码:完整指南
在你的管道里,某个地方,数据正戴着伪装:一个塞进 HTTP 头里的令牌,一个藏在 JSON 字段里的头像,一个你上周就答应要看的 .b64 文件,还有一封以一整墙字母形式到达的邮件附件。在 Swift 里把这些伪装一一卸下,是这门语言里最让人舒坦的工作之一:一个框架、一个初始化器,外加一本短到可以写在便签纸上的规则手册。
本站首页已经讲过这个格式本身(64 个可打印字符、每个字符承载 6 个比特、最后一组最多两个 = 填充字符),所以这里不再复述那段故事。只要把两个事实装进口袋就够了。第一,base64 是一种把字节打扮成文本的方式,而不是一把锁。第二,Swift 里的每一次 base64 之旅都经过同一个类型 Data,而解码器就以可失败初始化器的身份住在它上面。正是这一个事实塑造了这篇文章的其余部分,因为一个可失败的初始化器会改变你写下此后每一行的方式。
一个类型承包整份活
Swift 不会把 base64 助手撒得到处都是,也不用你安装任何东西。解码器就是 Foundation 里的 Data(base64Encoded:options:),而且它从框架的最早期起就是平台的一部分(Apple 标注该初始化器自 iOS 8.0、macOS 10.10、tvOS 9.0、watchOS 2.0 和 visionOS 1.0 起可用;编码侧的行长选项甚至能追溯到 iOS 7.0)。在 Linux 和 Windows 上,同一个 Foundation 随开源工具链一起发布,所以下面的代码在 iPhone 应用、服务器 worker 和你终端里的脚本中表现完全一致。
还有一个兄弟初始化器 Data(base64Encoded: Data, options:),用来应对你的 base64 以原始 ASCII 字节而非字符串形式到达的情况。两者都接受一个默认值为 [] 的 options 参数。而两者共享一个比任何选项都更要紧的性格特征:它们都是可失败的。
import Foundation
let packed = "SGVsbG8sIFN3aWZ0IQ=="
if let data = Data(base64Encoded: packed) {
let text = String(data: data, encoding: .utf8)
print(text ?? "not text after all")
} else {
print("that was not base64")
}
// Hello, Swift!
Apple 为这个初始化器写的文档漂亮得毫不绕弯:当"输入不被识别为有效的 Base-64"时,它就"返回 nil"。没有异常,没有抛出的错误,没有日志刷屏。只有一个安静的 nil,以及"这对你的用户意味着什么"这份由你承担的判断责任。如果你只想记住 Swift 中 base64 的一件事,就记住这个:解码器从不崩溃,也从不抱怨。它只是婉拒。
解码器的裁决:一张是与否的表
那么对这个解码器来说,"有效"到底意味着什么?事实证明,这是一小串硬性规则,而这串规则正是"演示里能跑"和"生产里活下来"之间的分界线。下表中的每一行都是该初始化器在当前工具链上的真实行为,所以你可以把它们直接引用进你的错误消息:
| 输入 | 裁决 | 原因 |
|---|---|---|
TWFu |
Man |
完整的一组四个字符完全不需要填充 |
TQ== |
M |
一个字节加两个填充,教科书式的例子 |
SGVsbG8h |
Hello! |
八个字符是四的倍数,所以不需要填充 |
==== |
空的 Data |
全是填充、后无数据,这是合法的,解码出零个字节 |
| 空字符串 | 空的 Data |
没有输入就没有输出,可选值依然成功 |
TQ |
nil |
长度为二:承诺了四个字符的一组,却从未兑现 |
T |
nil |
一个字符只承载 6 个比特,而一个字节需要 8 个 |
SGVsbG8hTQ |
nil |
十个字符:最后一组悬在那里,缺少它的填充 |
TQ=== |
nil |
三个填充:第三个已经没什么可填了 |
TQ==TQ |
nil |
填充之后还有数据,是硬性的不行 |
SGVs bG8h |
nil |
一个空格就是字母表之外的字符,而严格模式毫不留情 |
SGVsbG8h 外加一个末尾换行 |
nil |
你刚读的那个文件末尾的换行也算噪声 |
有三行值得再看一眼。==== 那一行意味着 if let 检查会通过,你的代码带着零个字节继续航行,所以如果空的 payload 在你的应用里不是一个有效状态,就在解码之后立刻检查计数。空字符串那一行是同一招,只是少了点化妆。而末尾换行那一行,是一个 base64 文件早上编码得好好的、下午却拒绝解码的最常见原因:路上的某个环节加了一个行尾符,而严格解码器把这当成了针对它本人的冒犯。
还有一个著名的软肋,这张表展示不出来。比较一下 TQ== 和 TS==:两者解码出同一个字节 M,因为最后一个字符最低的两个比特在被人检视之前就已经被丢掉了。换成 Tg== 喂给它,你得到的就是 N,而且毫无异议。解码器把守的是字符,却放任尾随比特。这种宽松不是 bug,但它确实意味着两个不同的字符串可以表示同一份数据,而一旦你的系统要比较、去重或缓存 base64 值,它就开始变得要紧(安全部分还会再谈)。
当输入比你想的更嘈杂时
现实中的 base64 很少以一行干干净净的文本形式到达。邮件附件按 76 个字符换行,每行之后跟着回车加换行,这是从 1996 年的 MIME 规范继承来的习惯;证书文件则按 64 个字符换行。解码器有且仅有一个选项来处理这种噪声,而且是大招:
import Foundation
let mimeBody = "SGVs\r\nbG8sIG1h\naWwgbm9pc2Uu"
if let data = Data(base64Encoded: mimeBody, options: .ignoreUnknownCharacters) {
print(String(data: data, encoding: .utf8) ?? "")
}
// Hello, mail noise.
.ignoreUnknownCharacters 的文档把它描述为一个"忽略未知的非 Base-64 字节(包括行结束字符)"的解码器,就这项工作而言,它是趁手的工具:噪声被删掉,字母表幸存,payload 完整无缺地出来。但这个选项有一个盲点,而它恰恰是最狠地咬到 Swift 开发者的那种:它会删掉每一个字母表之外的字符,包括 base64url 的 - 和 _。它不会把它们翻译成 + 和 /,而是直接扔掉。删完之后剩下什么,决定了你的结局:要么得到 nil(当幸存者凑不成完整的组时),要么更糟,得到一个自信满满、字节数却错误的答复。一个编码了 12 个字节的 16 字符 base64url 字符串,从宽松解码器那里回来时可以是 9 个完全不同的字节,没有错误,也没有道歉。
要记住的规则:.ignoreUnknownCharacters 是给传输噪声用的(换行、复制粘贴带来的杂散空格),永远不是给字母表差异用的。如果 payload 可能是 base64url,就自己先转换字符,方法正好和下一节展示的一样,然后交给解码器一个干净的标准字符串。
URL 字母表
RFC 4648 的第 5 节定义了你一直在打交道的标准字母表的表亲:base64url。在这里 + 变成 -,/ 变成 _,= 填充通常被丢掉。原因和你保持 URL 诚实的原因是同一个:在查询字符串里,+ 会被表单解析读成空格,/ 是路径分隔符,= 用来分隔键和值。RFC 对两者关系的说法毫不含糊:URL 变体"不应被视为与 base64 编码相同"。JWT、Web Push 消息、YouTube 视频 ID 和大多数现代 API 标识符说的都是 base64url,所以做好第一天就碰上它的准备。
在解码一侧,配方有两个动作:先翻译字母表,再把填充补上,因为严格解码器依然要它的四的倍数。
import Foundation
extension String {
func dataFromBase64URL() -> Data? {
var fixed = self
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let missing = fixed.count % 4
if missing > 0 {
fixed += String(repeating: "=", count: 4 - missing)
}
return Data(base64Encoded: fixed)
}
}
let tokenPart = "0S__zMWaTC-iVgJ-"
if let bytes = tokenPart.dataFromBase64URL() {
print(bytes.count) // 12
}
取模那一行就是全部诀窍:base64url 的 payload 通常不带填充到达,而一两个 = 字符(绝不可能是三个)就能恢复解码器期望的那组四个字符。你会在多得令人惊讶的 Swift 代码库里看到这个只有五行的 extension 的某个版本,而且理由正当。它将来会变短也有一个原因:最新的 Apple SDK(截至本文写作时的 26.4 及以后)为编码器长出了一个原生的 .base64URLAlphabet 选项,而与之对应的解码选项还在开源 Foundation 里继续成熟,藏在一个面向更晚工具链的可用性标记后面。在这些选项到达你的最低部署目标之前,这个 extension 就是可移植的答案,而且由于构造上的原因,它会在每个版本上继续工作。
先有字节,后有文字
下面是解码器无法替你做的决定:它递给你一个 Data,一袋字节,对原始 payload 是用哪个字符集写的一无所知。如果 payload 是文本,选择字符集就是你的活儿,而 Swift 给了你两扇走出字节世界的门,脾气截然不同。
String(data:encoding:)是严格之门。它返回一个可选值,当字节在你指定的编码下无效时回答nil。理想用于验证,危险则在于你会强解那个答复。String(decoding:as:)是从不拒绝之门。它总是返回一个字符串,对任何它无法理解的东西都换上 U+FFFD 替换字符。理想用于日志和预览,危险则在于你把结果存下来,还管它叫数据。
import Foundation
let bytes = Data([0xC3, 0xA5]) // 带圈字母 a 的 UTF-8 拼法
print(String(data: bytes, encoding: .utf8) ?? "?") // 带圈的 a,读对了
print(String(data: bytes, encoding: .isoLatin1) ?? "?") // 两个糊涂的字母,同样的字节
print(String(decoding: bytes, as: UTF8.self)) // 带圈的 a,而且它从不崩溃
几乎涵盖一切的配方:先试严格的 UTF-8,因为现代 API 几乎总是指它;只有当约定保持沉默、而你又宁愿要"可读但错了"也不要一片死寂时,才回退到 ISO Latin-1;把从不拒绝之门留给调试输出。还有一个要查的隐形入侵者:如果 payload 以 UTF-8 BOM 开头(三个字节 EF BB BF),严格转换会把它原样保留,你的字符串现在以一个隐形的 U+FEFF 字符开头,它会悄悄搞坏相等性检查和 JSON 往返。当规范没有承诺 BOM 时,用前缀检查把它剥掉。
打开文件
"有个 .b64 文件,把它藏着的东西给我"这类活儿,是一次读取、一次修剪、一次解码、一次写入。修剪不是装饰品;它决定了一个文件是能打开还是返回 nil,因为工具、邮件客户端和编辑器都爱在末尾留一个换行:
import Foundation
let inbox = URL(fileURLWithPath: "Downloads/avatar.b64")
let outbox = URL(fileURLWithPath: "Downloads/avatar.png")
let raw = try String(contentsOf: inbox, encoding: .utf8)
if let data = Data(base64Encoded:
raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
try data.write(to: outbox)
} else {
print("the file was not base64 after all")
}
如果文件是 MIME 换行的(每 76 个字符一个换行),你有两条干净的逃生路:用 .ignoreUnknownCharacters 解码,让那个选项吃掉行尾符;或者在严格解码之前自己用 replacingOccurrences 剥掉它们。两者各只需一行。对于单纯就是大的文件,改用对齐的组来解码,而不是整读:每四个字符一组都能独立解码,所以你只需把当前组加一小段余量带过读取边界。
import Foundation
func decodeBase64Chunks(_ stream: InputStream, into result: inout Data) throws {
let chunkSize = 65_536
var buffer = [UInt8](repeating: 0, count: chunkSize)
var leftover = ""
result = Data()
stream.open()
defer { stream.close() }
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: chunkSize)
if read < 0 { throw CocoaError(.fileReadUnknown) }
if read == 0 { break }
var text = String(decoding: buffer[0..<read], as: UTF8.self)
text = text.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: "\n", with: "")
text = leftover + text
if text.count % 4 != 0 {
let whole = text.count - (text.count % 4)
leftover = String(text.suffix(text.count - whole))
text = String(text.prefix(whole))
} else {
leftover = ""
}
guard !text.isEmpty else { continue }
guard let part = Data(base64Encoded: text) else {
throw CocoaError(.fileReadCorruptFile)
}
result.append(part)
}
if !leftover.isEmpty {
guard let part = Data(base64Encoded: leftover) else {
throw CocoaError(.fileReadCorruptFile)
}
result.append(part)
}
}
不管文件多大,内存都保持平坦:一个读取缓冲区、一个剩余片段,加上你正在构建的结果。同一个循环还能对付以 base64 形式走线到达的下载、其实是一条编码流的日志文件,或者任何大到握不住手的 payload。
JWT:读懂三个点
一个紧凑格式的 JSON Web Token 是三段用点连起来的 base64url,其中前两部分是穿着风衣的普通 JSON。它们不带填充到达,而这恰恰是严格解码器一眼就会拒绝的组合,所以你在 URL 一节里那个 dataFromBase64URL() 助手就承担了全部重活:
import Foundation
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
func openPart(_ part: String) -> String? {
var fixed = part
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let missing = fixed.count % 4
if missing > 0 {
fixed += String(repeating: "=", count: 4 - missing)
}
guard let data = Data(base64Encoded: fixed) else { return nil }
return String(data: data, encoding: .utf8)
}
let pieces = token.split(separator: ".")
print(openPart(String(pieces[0])) ?? "?")
// {"alg":"HS256","typ":"JWT"}
print(openPart(String(pieces[1])) ?? "?")
// {"sub":"1234567890","name":"John Doe"}
还有两句顺带的提醒。JWT 是签名的,不是加密的:头和 payload 都是公开信息,这正是密码永远不该出现在里面的原因(加密的表亲 JWE 完全是另一份规范)。而第三段点分隔的部分是一个密码学签名,不是文档,所以解码第一、二部分,剩下的别去碰。
Data URI:逗号后面的文件
Web API 喜欢用 data: 方案把二进制藏在文本里:资料字段里的 PNG、CSS 大块里的字体、配置文件里的二维码。格式是 data:{mime};base64,{payload},把 payload 剥下来只需一次 split:
import Foundation
let uri = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
let payload = uri.components(separatedBy: ",").last ?? ""
if let bytes = Data(base64Encoded: payload) {
print(String(decoding: bytes.prefix(6), as: UTF8.self)) // GIF89a
print(bytes.count) // 42
} else {
print("not a base64 data uri")
}
例子用的是那个著名的 42 字节透明 GIF,这个格式里最小的图像,所以它的开头字符比互联网上几乎任何其他 base64 字符串都出现在更多的代码库里。在 Apple 平台上,流水线以一行代码收尾:你刚解码出来的同一个 Data 直接喂给 UIImage(data:) 或 NSImage(data:),这就是"从 API 显示一个头像"是小功能而不是项目的原因。
HTTP:Basic 头及其朋友们
老派的 Authorization: Basic 头是用户名和密码用冒号连起来,用标准 base64 打包上路(不是 URL 方言:它住在头里,而头里 + 和 / 完全无害)。解开它是一次 split 加一次解码:
import Foundation
let header = "Basic ZWRpdG9yOnMzY3JldA=="
let packed = header.replacingOccurrences(of: "Basic ", with: "")
if let creds = Data(base64Encoded: packed) {
print(String(data: creds, encoding: .utf8) ?? "") // editor:s3cret
} else {
print("malformed header")
}
把安全脚注放得响亮些,因为它适用于你将遇到的每一个 base64:这是打包,不是保护。Basic 认证只有在 HTTPS 上才可接受,在那里 TLS 才是真正站岗的,base64 只是防止字节破坏头部语法。同样的道理也适用于 Authorization: Bearer 令牌:令牌本身是一个 JWT,所以 JWT 一节的解码配方可以原封不动地用在它身上。
邮件:76 字符的习惯
以 base64 编码的邮件附件按 76 个字符换行、带 CRLF 行尾,这正是宽松选项存在所要对付的那种噪声。原始 MIME 头会告诉你发送方用了哪个字母表、哪种换行(Content-Transfer-Encoding: base64),而修复方法就是一个标志:
import Foundation
let attachment = "VGhpcyBhdHRhY2htZW50IHN1cnZpdmVk\r\nIHRoZSA3Ni1jaGFyYWN0ZXIgaGFiaXQu"
if let data = Data(base64Encoded: attachment, options: .ignoreUnknownCharacters) {
print(String(data: data, encoding: .utf8) ?? "")
}
// This attachment survived the 76-character habit.
如果你是在写邮件功能而不是读邮件功能,要记住 76 字符换行同样让你付出代价:每 76 个字符一个换行,编码后的文本会落在原始大小的 137% 附近,所以老派邮件工程师目测附件大小的捷径是"原始大小乘以 1.37,再加上大约 800 字节的头"。这个数字如今已是传说,但算术还是那个算术。
被包了两层的 payload
在 base64 的世界里,最常见的"我的数据损坏了"工单,是数据被打包了两次:一个集成层编码了它,第二个没读过文档的层又把结果编码了一遍。防御性的动作是:解码一次,看看你得到了什么;如果结果本身是一个干净的、看起来像 base64 的字符串(长度对、字母表对、没有出格的字符),就再刻意地解码一次,然后停下。不要写一个"解码到失败为止"的循环。这种循环会高高兴兴地吃掉一个完全正常、只是内容碰巧长得像 base64 的文件,等它跑完,没人能说出原始数据从哪儿开始。
import Foundation
func unwrapOnce(_ packed: String) -> Data? {
let cleaned = packed.trimmingCharacters(in: .whitespacesAndNewlines)
return Data(base64Encoded: cleaned)
}
let suspicious = "WVdKag==" // 看起来已经打包过了
if let first = unwrapOnce(suspicious) {
let inner = String(data: first, encoding: .utf8) ?? ""
if let second = unwrapOnce(inner) {
print("it was wrapped twice:", String(data: second, encoding: .utf8) ?? "?")
}
}
// it was wrapped twice: abc
两次解开,两次有意识的决定,最终 payload 又只是 abc。
让 nil 说点人话
正因为解码器回答 nil 而不是抛异常,你的 base64 代码采用什么错误处理风格是你自己的选择,而日后会让你庆幸当初做对的选择,是一个小小的包装器,把沉默的婉拒变成响亮、具体的错误:
import Foundation
enum Base64Failure: Error, CustomStringConvertible {
case notBase64(Int)
var description: String {
switch self {
case .notBase64(let length):
return "input of \(length) characters is not valid base64"
}
}
}
func decodeStrict(_ text: String) throws -> Data {
let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let data = Data(base64Encoded: cleaned) else {
throw Base64Failure.notBase64(cleaned.count)
}
return data
}
do {
let bytes = try decodeStrict("c3ludGF4IGVycm")
print(String(data: bytes, encoding: .utf8) ?? "?")
} catch {
print(error) // input of 14 characters is not valid base64
}
这个包装器还成为归一化逻辑唯一居住的地方:修剪、任何字母表翻译、任何填充补齐。调用方拿到一个函数、一个对失败的含义,视野里没有 !。强解 Data(base64Encoded:)! 正是坏 payload 变成崩溃应用的方式,而包装器就是防它的廉价保险。同样的模式在命令行上也好使:一个用 CommandLine.arguments 加 FileHandle 写入的脚本,能让"从 shell 解码这个文件"成为五行小工具,而不是一次绕道网站复制粘贴的旅程。
用字节衡量的安全
- 它不是加密。Base64 是一种可逆的、瞬间可读的重新打包。如果你的威胁模型里包括一个有浏览器和五秒钟的人,你的保护为零,而且每一个 JWT 头每天都在证明这一点。
- 比较之前先归一化。正因为
TQ==和TS==解码出同样的字节,两个系统可以保存同一份数据的不同拼法。2022 年的一篇论文 "实践中的 Base64 可塑性" 记录了那个失效的唯一性保证在现实中所为:日志不匹配、拒绝服务攻击、重复的数据库条目。如果你的 Swift 应用会缓存、去重或比较 base64 值,就在门口跑一次规范解码(或一次规范重编码)。 - 解码之前先给输入设上限。解码 N 个字符会分配大约四分之三 N 的字节,而你手里还攥着输入字符串。一个恶意客户端可以发送 100 兆字节的字母
A,然后看着你的内存一路攀升,直到解码器开口说"不"。先廉价地检查长度,把太大的拒掉。 - 小心把宽松选项当过滤器用。
.ignoreUnknownCharacters会删字符。让它跑一遍"消毒",可以把一个合法的 base64url payload 变成另一份数据,还没有错误。它是换行的噪声过滤器,不是校验器。 - 能不进 URL 就不进。查询字符串或路径里的大 base64 payload 会撑爆舒服的 URL 长度,还被代理搞得面目全非。改放到请求体、文件或令牌里。
性能,简而言之
解码器就是在查找表上走一趟:每个字符去查一张小表,几个比特移位、按或进输出字节。在当前工具链上,对任何装得进内存的东西来说,这都快得绰绰有余,而你要记住的数字是输出比例:解码出的字节约为输入长度的四分之三,所以一个 4 兆字节的字符串,会在你已经握着的字符串之外,再花掉大约 3 兆字节的结果。如果你走的是 Foundation 本身都不被允许的路(深度嵌入式目标、WebAssembly 包),社区包 swift-extras-base64 是值得一提的替代:纯 Swift、不依赖 Foundation,一套符合 RFC 4648 的编码器和解码器,带 base64url 和填充选项,基准测试显示它比 Foundation 快好几倍。这个包更早的实现甚至就装在 swift-nio 的 WebSocket 支持里,作为副项目来说,这已经接近生产级了。对普通应用或脚本,它是多余的行李;对 Swift 的受限角落,它是标准答案。
解包这十年
Swift 没有发明这里的任何东西,而知道工具箱里每件东西从何而来是值得的:
- 1980 年代,同一台机器的时代。这个家族最早的编码(UNIX 上的 uuencode、TRS-80 和经典 Mac 上的 BinHex)在机器之间搬文件,而这些机器默认另一端和自己长得一样。uuencode 用大写字母、数字和标点组成的字母表,它的字符位于连续的 ASCII 位置上,所以编码就是加 32,连查找表都不用。这个时代的解码器可以放心地假设很多东西,而数据一旦跨越生态,它们就栽了。
- 1987,字母表拿到门牌。RFC 989(Privacy-Enhanced Mail,1987 年 2 月)标准化了 64 字符字母表,把行宽固定在正好 64 个字符,用
=做填充,用*标记已编码但未加密的数据。每一个 PEM 风格的块都是那份文档的后代。 - 1996,开明的时代。MIME(RFC 2045)把字母表用于邮件,把换行移到 76 个字符,并告诉合格的解码器忽略字母表之外的任何字符,比如 CRLF 换行。就是这个时代训练了一代人期待宽容的解码器,也是 Swift 的严格默认值故意打破的期待。
- 2003 到 2006,规则变硬。RFC 3548(2003 年)第一次尝试统一这个家族;RFC 4648(2006 年 10 月)把事情定了,把填充规则写进条文,还加了 URL 安全字母表。它的解码器段落正是 Swift 遵循的那一段:拒绝字母表之外的字符,除非你服务的格式明确说要忽略它们,就像 MIME 那样。
- 2013 到 2014,API 候场就绪。Apple 的
NSData类打包和解包 base64 已经好几年了,基于选项的 API 连同它的解码选项在 2013 年的 iOS 7 落地,比 Swift 的诞生还早一年。当 Swift 1.0 在 2014 年 9 月 9 日发布时,解码器随语言一起走了进来,从此一直保持同样的性格:严格内核、一个宽松旋钮、可失败初始化器。 - 2015 年 12 月 3 日,Linux 有了解码器。Swift 在那天开源,Foundation 的 base64 也随之跨到了 Linux,后来又跨到了 Windows。"在非 Apple 机器上用 Swift 做 base64 解码"满打满算才不过十年:一个迟到的客人,赴的还是 1987 年那场派对。
- 2023 到 2026,重写。Foundation 重写(swift-foundation 项目)把
Data移进了纯 Swift 内核,2025 年一个社区提案加了原生的 base64url 和省略填充选项。截至本文写作时,最新的 SDK beta 和开源工具链发布的是编码选项,解码选项还在开源工具链里继续成熟,所以手写的 extension 在期间仍然是通用答案。
小小的奇妙
====是合法输入。四个填充、没有数据,解码出空的Data,这是唯一一个全部内容都是"这里什么都没有"的 base64 字符串,而 Swift 表示同意。- 解码器的字符警察不管比特警察的活:
TS==和TQ==都递给你一个M,而Tg==递给你一个N。同样的语法,不同的比特,不问为什么。 - Swift 的
Data能解码以字节而非字符串形式到达的 base64,用的就是Data(base64Encoded: Data)这个变体,所以一个以 ASCII 走过线的 payload 可以完全跳过字符串往返。 - 从测试向量诞生起就在被 base64 的那个词是
foobar,它打包成Zm9vYmFy。如果你曾在野外见过 base64 例子,foobar 参与其中的可能性相当高。 - 那个著名的 1x1 透明 GIF 正好 42 字节,以魔法词
GIF89a开头,所以它的前八个编码字符比地球上几乎任何其他 base64 前缀都出现在更多的代码库里。 - 现代开源解码器做非法字符检查只用一次比较:它把四个按位置的查表值按或起来,再和一个哨兵值比较,所以一个分支就决定了一整组四个字符的命运。更早的实现干同样的活用的是 128 字节的表,表里任何大于或等于 0x80 的值都表示"不是字母"。
- UTF-8 BOM 是隐形的:payload 开头的
EF BB BF变成一个 U+FEFF 字符,它能挺过严格转换,然后在几行代码之后搞坏相等性检查。 - Swift 比它解码的字母表年轻 27 岁。这门语言 2014 年发布;它经手的 64 个字母 1987 年标准化,此后再未变过。
这就是完整的解码工具箱:一个带短规则手册的可失败初始化器、一个有文档记载盲点的宽松旋钮、一个五行 base64url 助手、一个属于你的字符集决定、一个大文件的分块循环,还有一个让 nil 说人话的包装器。解码正是 base64 咬人的地方,而你现在叫得出每一颗牙的名字。当活儿反过来,你开始为上路打包字节而不是解包时,大约 33% 的附加费接手登场,换行选项也冒出来。相关的编码文章完整覆盖往返的这一半,等你准备好朝反方向发货时,就过去看看。
最后更新: 2026-09-08
相关文章: Swift 中的 Base64 编码:完整指南