Dart 中的 Base64 编码:完整指南
你手里有字节,需要的是一个字符串。载荷可能是一个文件、一个认证凭证、一个配置令牌,或者一个藏在 JSON 文档里的二进制块,而通道只收文本。Base64 就是解决这件事的那笔交易:每三个输入字节变成取自 64 字符字母表的四个字符,所以输出总是 4 的干净倍数,在纯文本世界里永远安全。价格固定为多出 33%的字符,格式还会在最后一个块偏短时,于末尾加上一或两个 = 填充字符。本指南就是正确完成这笔交易的 Dart 配方。
没有任何东西需要安装。自 2015 年的 Dart 1.13 起,Base64 就随 dart:convert 一起发布了,API 自那以后一直稳定;标准字母表和 URL 安全字母表两套都可用,已经十多年了。主页会深入讲解这个格式;这里是编码这一侧的内容:完整的 API 面、能避开最常见 bug 的 "先定字节" 纪律、填充与字母表的选择,还有现实里的工作:JWT、data URI、文件上传、HTTP 头、MIME、配置、流和命令行。反方向,解码,有它自己的指南,链接在末尾。
一个 import,两套字母表,一条填充规则
编码的全部公开接口都住在 dart:convert 里:
| 入口 | 字母表 | 什么时候用它 |
|---|---|---|
base64Encode(bytes) |
标准:A-Z a-z 0-9 + /,带填充 |
API、MIME、Basic 认证、大多数消费者 |
base64UrlEncode(bytes) |
URL 安全:A-Z a-z 0-9 - _,仍然带填充 |
URL、文件名、JWT、对象 ID |
base64.encode(bytes) |
标准,与顶层调用相同 | 流转换与编解码器管道 |
Base64Encoder().convert(bytes) |
标准 | 当你想要一个指名道姓的编码器实例时 |
两条规则覆盖全部四行。第一,输入必须是字节值列表,0 到 255 的整数;任何其他东西,包括负数或 256 及以上,都会抛出一个点名坏掉的下标的 ArgumentError。第二,输出永远带填充:没有任何标志、构造函数或选项能产出不带填充的输出,因为格式的填充是数据本身的属性,想要去掉它的规范会把剥离当作一个独立的、有文档记录的步骤。最小例子,从头到尾:
import 'dart:convert';
void main() {
final text = 'Dart is open source';
final bytes = utf8.encode(text);
final encoded = base64Encode(bytes);
print(encoded); // RGFydCBpcyBvcGVuIHNvdXJjZQ==
}
先定字节:救你一命的顺序
Dart base64 里最常见的 bug 根本和 base64 无关。它是关于操作顺序的。Dart 的 String 是一串 UTF-16 码元,调用 base64Encode(text.codeUnits) 打包的是那些 16 位码元,而不是接收方期望的字节。对纯 ASCII 来说两者碰巧一致,这就是为什么这个 bug 能一直藏到第一个带重音的字符、emoji 或中日韩文本出现。然后编码器会拒绝这项工作,因为像 0x4e16 这样的码元不是字节值:
import 'dart:convert';
void main() {
final message = 'Héllo Wörld 世界';
print(utf8.encode(message).length); // 20
print(message.codeUnits.length); // 14
print(base64Encode(utf8.encode(message)));
try {
base64Encode(message.codeUnits);
} on ArgumentError catch (e) {
print(e);
}
}
ArgumentError 会指向确切的出错下标,所以失败是响亮的,不是无声的。要守住的纪律是:在和 base64 打交道之前,先想清楚字节是什么。文本先过命名编码,现代数据用 utf8.encode,得到的 List<int> 才是被打包的对象。来自文件或网络套接字的字节已经是 Uint8List,这正是编码器的正确输入形状,不需要任何转换。
填充:编码器的工作
Base64 把每三个字节一组映射为四个字符,所以长度不是 3 的倍数的载荷会在末尾留下一个不完整的组。格式用 = 字符标记这个缺口:一个输入字节变成四个字符加两个填充,两个字节变成四个字符加一个填充,三个字节正好四个字符。Dart 编码器无条件地替你做了这件事:
import 'dart:convert';
void main() {
print(base64Encode([0x41])); // QQ==
print(base64Encode([0x41, 0x42])); // QUI=
print(base64Encode([0x41, 0x42, 0x43])); // QUJD
}
这种无条件行为是一种特性:输出永远是合法的、自描述的 base64 字符串。当规范要求不带填充的变体时,JWT 是最常见的原因,剥离是你显式的、看得见的一步,不是库的设置:
base64UrlEncode(bytes).replaceAll('=', '')
把剥离放在规范边界处,给它命名,写进文档。这笔交易解码那一侧的内容,包括如何修复受损或被剥离的输入,会在解码指南里讲解。
URL 安全的 Base64
标准字母表里有 +、/ 和 =,这三个字符与 URL 语法冲突:查询分隔符、路径分隔符和参数定界符。URL 安全字母表在 RFC 4648 里被标准化为 base64url,把 + 换成 -,把 / 换成 _,这样输出可以原样坐在路径段、查询值或文件名里,不需要转义。下面这段字节同时用到了两个被替换的字符,看看差别:
import 'dart:convert';
void main() {
final tricky = [0xfb, 0xff, 0xfe, 0xf9];
print(base64Encode(tricky)); // +//++Q==
print(base64UrlEncode(tricky)); // -__--Q==
}
按消费者选,不按口味选。如果值要住在 URL、JWT 或文件名里,用 base64UrlEncode 编码,规范说不带填充就剥掉填充。如果值是 MIME 主体、Basic 认证头,或 API 契约里写着 "base64" 的字段,就用标准字母表,因为不加限定的 base64 指的就是标准字母表。在严格消费者眼里,两套字母表不能互换:一个期望标准 base64 的服务器,可能会对一个包含 - 的载荷回 400,而且没有任何更有用的提示。
字符集:你打包的是哪些字节?
当输入是文本时,编码步骤决定了 base64 看到的是哪些字节,而消费者在另一端会假设一个字符集。如果你的假设和消费者的假设不同,输出就是错误字节的完美合法 base64,这是最糟糕的一类 bug,因为什么都不会抛错。对任何现代交互,UTF-8 是默认值;其他单字节编码为遗留数据而存在:
| 编码 | 用于 | 用……编码 |
|---|---|---|
utf8 |
现代文本、JSON、网络上一切 | utf8.encode(text) |
latin1 |
遗留的西式单字节数据 | latin1.encode(text) |
ascii |
纯 7 位文本 | ascii.encode(text) |
import 'dart:convert';
void main() {
final modern = base64Encode(utf8.encode('Héllo'));
final legacy = base64Encode(latin1.encode('Héllo'));
print(modern); // SMOpbGxv
print(legacy); // SOlsbG8=
}
同一个词,不同的字节,不同的 base64。注意长度:UTF-8 需要六个字节装 Héllo,因为重音是双字节序列,而 Latin-1 五个字节就够。如果消费者用你没有使用的那个编码来解码,他们得到的是乱码,看起来像数据在传输途中损坏了,而实际上是意图就错了。
JWT:写出令牌
一个 JSON Web Token 是由点连接的三个 base64url 部分:头部、载荷、签名。RFC 7515 钉死两个细节:字母表是 URL 安全的,填充省略,因为令牌被设计成坐在 URL 和头里。HS256 算法的签名是 header.payload 的 HMAC-SHA256,它本身是不带填充的 base64url。用 crypto 包手写它只有几行,而且比看上去更透明:
import 'dart:convert';
import 'package:crypto/crypto.dart';
String base64UrlNoPadding(List<int> bytes) {
return base64UrlEncode(bytes).replaceAll('=', '');
}
String createJwt(Map<String, dynamic> header, Map<String, dynamic> payload,
List<int> secretKey) {
final signingInput =
'${base64UrlNoPadding(utf8.encode(jsonEncode(header)))}.'
'${base64UrlNoPadding(utf8.encode(jsonEncode(payload)))}';
final mac = Hmac(sha256, secretKey).convert(utf8.encode(signingInput));
final signature = base64UrlNoPadding(mac.bytes);
return '$signingInput.$signature';
}
void main() {
final token = createJwt(
{'alg': 'HS256', 'typ': 'JWT'},
{'sub': 'user-42', 'exp': 1893456000},
utf8.encode('a-32-byte-secret-key-0123456789'),
);
print(token);
}
签名是对打包的确切字节计算的,所以只要你签发的就是签的那串字符串,另一侧的验证就是重复同样的步骤。三个警告。pub.dev 上老的 jwt 包出自 2014 年,早于空安全;生态里能用的答案是像上面演示的那样,用 crypto 来做。永远不要签发 alg: none 的令牌,也永远不要让客户端选择算法。还要记住载荷对任何人都是可读的,所以只放令牌本要证明的东西。
data URI:把文件装进文本里寄出去
data URI 由 RFC 2397 定义,是一种载荷就是数据本身的 URL。data URI 里的二进制内容会被 base64 编码,所以这个格式出现在一切文本文档需要内嵌图片、字体或附件的地方:HTML 属性、CSS、JSON、配置文件。Dart 能原生构建这些 URI,不需要任何 URI 包:
import 'dart:convert';
import 'dart:io';
Future<void> main() async {
final png = await File('icon.png').readAsBytes();
final imageUri = Uri.dataFromBytes(png, mimeType: 'image/png');
print(imageUri); // data:image/png;base64,iVBOR...
final note = Uri.dataFromString('Hello, Dart!');
print(note); // data:,Hello,%20Dart!
}
Uri.dataFromBytes 默认做 base64 编码(另一种形式有 percentEncoded: true 这个可选开关),这对二进制来说是正确的编码。Uri.dataFromString 默认做百分号编码,因为短文本这样更短,当你想要字节打包形式时它也接受 base64: true 标志。实际的坑是规模:载荷跟着文档一起走,带着 33%的开销,所以 data URI 是为小资源准备的,图标和缩略图,不是用来通过 CSS 寄出几兆字节的。
文件:为文本通道打包字节
日常的工作:一个必须通过 JSON、配置文件或任何纯文本传输旅行的文件。模式是读字节、编码、嵌入:
import 'dart:convert';
import 'dart:io';
Future<void> main() async {
final image = await File('photo.jpg').readAsBytes();
final encoded = base64Encode(image);
final upload = jsonEncode({
'name': 'photo.jpg',
'size': image.length,
'data': encoded,
});
print('payload ${upload.length} chars for ${image.length} bytes');
}
要记在脑子里的数字是膨胀:一个 2,000 字节的文件变成 2,668 个 base64 字符,JSON 键名加进来后还要再多一点。两个坑。第一,检查你的输入是否已经被编码过:对已经是 base64 的字符串再做 base64 编码是经典的二次编码 bug,它能 "成功" 解码成另一堵 base64 墙。第二,如果通道能带二进制(multipart/form-data 就是为此存在的),就带二进制:它小四分之一,base64 税是纯粹的浪费。
HTTP 与 API:头和载荷
HTTP 里最熟悉的编码工作是 Authorization: Basic 头:Basic 这个词、一个空格,然后 username:password 的标准字母表 base64:
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<void> main() async {
final credentials = base64Encode(utf8.encode('octocat:secret'));
final client = http.Client();
final response = await client.get(
Uri.parse('https://httpbin.org/basic-auth/octocat/secret'),
headers: {'Authorization': 'Basic $credentials'},
);
print(response.statusCode);
client.close();
}
用 http 包,一条 dart pub add http 就能装上,头就是请求里的一个字符串。坑是安全框架:这里的 base64 是混淆,不是保护。任何人都能一步反转它,这正是 Basic 认证只配出现在 TLS 连接上的原因,在那里做保护的是传输层,不是编码。对 API 载荷字段,跟着契约走:如果它说 base64,那就是带填充的标准字母表,URL 安全变体是另一回事,严格消费者会拒绝它。
电子邮件与 MIME:在 76 处折行
MIME 是让电子邮件能携带二进制的系统,它把 base64 用作内容传输编码,RFC 2045 规定编码后的行不得超过 76 个字符,行与行之间用 CRLF。这个上限是 MIME 的惯例 - 76 加上 CRLF 在 80 列的显示上放得很舒服 - 每个合规的编码器都会折行。Dart 的编码器产出一条不断开的字符串,所以折行是一个很短的后处理步骤:
import 'dart:convert';
String wrapForMime(String base64Text, [int lineLength = 76]) {
final buffer = StringBuffer();
for (var i = 0; i < base64Text.length; i += lineLength) {
final end = i + lineLength > base64Text.length
? base64Text.length
: i + lineLength;
buffer
..write(base64Text.substring(i, end))
..write('\r\n');
}
return buffer.toString();
}
void main() {
final encoded = base64Encode(utf8.encode('Hello from an email attachment'));
print(wrapForMime(encoded));
}
对完成的字符串折行,包括填充,最后一行有多长就让它多长,上限 76。唯一不能做的是在折行前剥掉填充,指望省一个字符:填充是编码内容的一部分,一个会重新拼合行的消费者,没有填充会拒绝这个结果。
配置:把机密变成一行
包含引号、换行或其他别扭字符的令牌、密钥和凭证,有时会被 base64 编码,让它们能干净地待进一个配置行或 CI 变量。先把诚实的说法摆出来:这是混淆,不是加密,任何到达过仓库或日志的东西都是公开的。用这个模式为了整齐,绝不为保密。编码这个值是一次调用:
import 'dart:convert';
String forEnvFile(String secret) {
return base64Encode(utf8.encode(secret));
}
void main() {
final line = 'API_TOKEN_B64=${forEnvFile('sk-live-abc123')}';
print(line); // API_TOKEN_B64=c2stbGl2ZS1hYmMxMjM=
}
这个值随后就待在 .env 文件、CI 机密或编译期 define 里,一次解码后以纯文本形式回来。如果机密需要在传输或存储中受保护,去拿密钥管理器或加密库;base64 在这里的工作是让管道的文本处理保持简单,仅此而已。
流:跨过分块边界编码
当字节分块到达,一次网络读取、按块处理的文件,编码器能应付,你不需要对齐任何东西。编解码器把不完整的组带到分块边界之外,所以分块大小不需要是 3 的倍数:
import 'dart:convert';
import 'dart:typed_data';
Future<void> main() async {
final data = Uint8List(100000);
for (var i = 0; i < data.length; i += 31) {
data[i] = i % 256;
}
final chunks = <List<int>>[data.sublist(0, 777), data.sublist(777)];
final encoded = await Stream.fromIterable(chunks)
.transform(base64.encoder)
.join();
print('in: ${data.length}, out: ${encoded.length}'); // in: 100000, out: 133336
}
两个大小别扭的分块,777 字节和 99,223 字节,产出一条正确的 133,336 字符字符串,因为编码器把每个不完整组的剩余比特存起来,等下一个分块到达,并且只在最后发出填充。如果你更喜欢 sink,base64.encoder.startChunkedConversion 给你同一台状态机,作为 ByteConversionSink(你喂它字节分块,它发出字符串),这是把大输出写进文件或套接字的自然搭配,永远不用拼成一个巨大的字符串。
大数据:吞吐与内存
大小数学是精确的,值得记住:输出长度是输入长度除以 3、向上取整、再乘以 4。一、二或三个字节都花四个字符;从那以后就是固定的 33%开销。公式如下,供你预留缓冲区或报告进度时使用:
import 'dart:convert';
int encodedLength(int n) => (n + 2) ~/ 3 * 4;
void main() {
print(encodedLength(100000)); // 133336
}
速度不是约束;编码器是一次表查找遍历,毫秒级处理兆字节。约束是大小税本身,在传输线上和内存里都要缴,以及编码形式是字符串这一事实。在规模化时把这两点都放在心上:对可能长得很大的载荷,像上面那样流式编码,而不是攒一个巨大的列表和一个巨大的字符串;对反复传输同一份数据,问问通道有没有二进制模式,因为 33%是永久附加费,没有任何算法能退给你。
命令行编码器
VM 能让编码器变成一个干净的 CLI。这个工具读取文件参数或标准输入,打印标准字母表编码:
import 'dart:convert';
import 'dart:io';
Future<void> main(List<String> args) async {
final bytes = await _read(args);
stdout.writeln(base64Encode(bytes));
}
Future<List<int>> _read(List<String> args) async {
if (args.isNotEmpty) {
return File(args[0]).readAsBytes();
}
final all = <int>[];
await for (final chunk in stdin) {
all.addAll(chunk);
}
return all;
}
把它存成 bin/encode.dart,运行 dart run bin/encode.dart photo.jpg > photo.b64,或者用管道 cat config | dart run bin/encode.dart。它的搭档,一个会读取并展平的解码器,是解码指南里的第一个例子,两个脚本合起来就是一个小而真正好用的工具集,用来让二进制通过文本通道旅行。
出门路上咬人的坑
- codeUnits 陷阱。
base64Encode(text.codeUnits)打包的是 UTF-16 码元,不是字节;它对 ASCII 有效,遇到第一个超过 255 的码元就抛出ArgumentError。永远先用命名编码把文本编码。 - 字母表不匹配。把 URL 安全的输出喂给期望标准字母表的消费者,是在等待一个 400。从规范里定字母表,编码一次,事后不要转换。
- 填充假设。Dart 永远带填充。规范想要不带填充的,就在边界处用
replaceAll('=', '')作为显式步骤剥掉,并在契约里说明。 - 字符集漂移。为一个解码 UTF-8 的消费者编码 Latin-1 字节,得到的是错误数据的合法 base64。什么都不会抛错;文本只是错了。
- 二次编码。对一个已经是 base64 的值,比如从另一个配置里复制出来的令牌,再做 base64 编码,是经典的 "解码出另一堵 base64 墙" bug。
- 隐私错觉。Base64 是格式,不是密码。如果威胁模型里有一个读者,答案是加密,不是编码。
- 过时的包。pub.dev 上存在已久的
jwt包早于空安全;做 JWT 工作时,crypto加上上面那几行才是有维护的路。
什么时候该拿别的东西
- HTTP 文件上传。用
multipart/form-data;它携带原始字节,你可以完全跳过 33%的税。 - 大或重复的载荷。先压缩,再编码:gzip 后文本的 base64 比文本的 base64 小得多,解压那一侧本来就知道格式。
- URL 里的短文本。百分号编码对几个字符更短,还保持值对人可读;data URI 甚至默认就替你这么做。
- 调试输出和日志。十六进制比 base64 长 50%(原始大小的两倍,base64 只有三分之四),但好扫读、好 diff、好递给同事;日志里的二进制片段它通常赢。
最佳实践:编码器清单
- 编码字节,永远不编码码元;文本先过命名编码。
- 写调用之前,先按消费者的规范定字母表。
- 只在规范说不带填充的地方剥填充,作为边界处看得见的一步。
- 在契约里显式写明字符集;对另一端什么都别假设。
- 任何可能长大的东西都用流。
- 把 base64 当作纯文本通道的格式,永远不要当作敏感数据的保护。
两套字母表的简史
你刚用过的这个格式比每一个 Dart 版本都老,而你可用的字母表选择早在 Dart 到来之前几十年就标准化了。简版是这样的:
- 1993 年,RFC 1521:MIME 把 base64 作为电子邮件的内容传输编码引入,带着标准 64 字符字母表和本文折行所用的 76 字符行上限。格式的使命,让二进制通过文本通道旅行,从这里算起。
- 1996 年,RFC 2045:取代旧 MIME 规范的这份文件,让 base64 的填充和行长度规则成为持久的标准。
- 2006 年,RFC 4648:编码从 MIME 里被抽出来单独标准化,加上 URL 安全字母表,以及 "解码器应当拒绝无效输入" 的建议。你在 Dart 里拿到的两套字母表选择来自这份文件。
- 2015 年,RFC 7515:JSON Web Signatures 规定不带填充的 base64url,每一个 JWT 背后都是这个惯例。
- 2015 年 11 月,Dart 1.13:base64 进入
dart:convert;URL 安全变体在第二年春天的 Dart 1.16 跟进,你上面用到的顶层base64Encode和base64UrlEncode调用则落在 2018 年的 Dart 2.0。 - 今天,Dart 3.13:两套字母表,永远带填充,一个 import 之遥,自 2015 年以来同一台严格而简单的机器。
自 1993 年以来,33%的开销也没有变过。它是数学的属性,三个字节换四个符号,你未来会用到的每一个实现、每一种语言,缴纳的都分毫不差。
来自编码工作台的趣闻
- 编码器关不掉:SDK 里没有不带填充输出的标志,这就是为什么 "剥掉填充" 永远是你的代码,在你的边界上,明晃晃地。
- 一个字节变成四个字符:
base64Encode([65])是QQ==。最短的 base64 字符串长四个字符,其中只有前两个携带信息,后两个是填充。 - Dart 两个编码器都带填充,包括
base64UrlEncode。base64url 里的 "不带填充" 来自 RFC 7515 的消费者惯例,不是字母表的属性。 - 标准字母表被设计成 7 位可打印,从那以后一直是默认值;
+和/最终换上了 URL 安全的替身,这是它变得多核心的标志,不是缺陷。 - 同样 20 个 UTF-8 字节的
Héllo Wörld 世界打包成SMOpbGxvIFfDtnJsZCDkuJbnlYw=,而这串字符串的 14 个码元会在下标 12 处让编码器崩溃。同样的字符,两个完全不同的输出,其中一个还是错误。 - PEM 文件,每个 TLS 证书里的
-----BEGIN CERTIFICATE-----块,是带头部、按 64 字符折行的 base64,格式出自 1987 年,比 MIME 为电子邮件发布 base64 早了六年。
现在你拥有了完整的编码这一侧:API 面、先定字节的纪律、填充与字母表的决定,以及 JWT、data URI、文件、HTTP、MIME、配置、流和 shell 的工作模式。反方向,把这些字符串拆开,连同解码器全部的严格性、它的百分号转义惊喜和修复工具,会在 Base64 解码指南里讲解,链接就在下面。
最后更新: 2026-09-08
相关文章: Dart 中的 Base64 解码:完整指南