Dart 中的 Base64 解码:完整指南
它可能出现在 API 响应里、URL 中,或者被贴进一张支持工单:一长串字母和数字,偶尔夹杂 +、/、- 或 _,末尾还可能拖着一对 =。有人说它是 Base64,而你需要的是它里面装的东西。本指南就是把它取回来的 Dart 配方。先快速定向一下,因为主页会深入讲解这个格式:Base64 把每三个输入字节改写为取自 64 字符字母表的四个字符,当最后一个块偏短时,还会在末尾补上一个或两个 = 填充。解码就是这笔交易里缩水的那个方向:四个字符进去,三个字节出来,所以结果总是比输入少占大约四分之一的空间。
好消息:没有任何东西需要安装。自 2015 年的 Dart 1.13 起,Base64 就随 dart:convert 库一起发布了,API 自 2018 年的 Dart 2.0 起一直保持稳定。一个 import 就给你一个快速、严格的解码器,标准字母表和 URL 安全字母表它都认。
先划一条诚实的边界:这是故事里解码器这一侧的内容。你将学会解码器接受什么、拒绝什么,填充是怎么运作的,如何把字节变回文本而不产生乱码,以及如何在 JWT、data URI、文件、流、电子邮件、配置和命令行里与 Base64 打交道。反方向,把字节打包成字符串,有它自己的指南,链接在本文末尾。
通往同一台严格机器的四扇门
这就是你会用到的全部公开接口,它们全都在 dart:convert 里:
| 入口 | 它是什么 | 什么时候用它 |
|---|---|---|
base64Decode(source) |
顶层函数,解码为 Uint8List |
日常解码,几乎总是用它 |
base64.decode(source) |
编解码器的 decode 方法,行为完全相同 | 当你要为 fuse 或流转换拿编解码器时 |
base64Url.decode(source) |
URL 安全编解码器的 decode 方法 | 输入被声明为 URL 安全时(机器是同一台) |
base64Url.normalize(source) |
校验并修复字符串,返回补齐填充后的版本 | 输入可能缺少填充、混用字母表或带百分号转义 |
有两件事值得注意。第一,四条路都通向同一个解码器:一台严格的、只有一张查找表的状态机。第二,最后一行根本不是一个解码器。它是一个修复站,等第一个被剥掉填充的 JWT 或半清理过的配置值出现时,它就能证明自己的价值。
你的第一次解码
解码日常生活的百分之九十,都装得进五行。下面这个最小例子展示了这项工作的完整形态:
import 'dart:convert';
void main() {
final bytes = base64Decode('TWFu');
final text = utf8.decode(bytes);
print(text); // Man
}
用三句话说明刚才发生了什么。第一,入口交出的是字节,不是文本:base64Decode 返回一个 Uint8List,这是故意的,因为载荷可能是一句话、一张 JPEG 或一个哈希,在你知道自己拿到的是什么之前,它们中的任何一个都不该被一视同仁地对待。第二,从字节跳到文本是独立的一步、显式的一步,带着显式的编码,而 "café" 变乱码正是在这一步上发生的,不小心就会中招。第三,空字符串是一等公民:base64Decode('') 会给你一个零长度列表,没有异常,也不大惊小怪。
解码器接受什么、拒绝什么
Dart 的解码器在设计上就是严格的。RFC 4648 说,实现应当拒绝含有字母表之外字符的输入,Dart 一字不差地照办了:不跳过空格,不忽略换行,也不给第二次机会。当输入不对时,你会得到一个 FormatException,它打印出输入并指向那个确切的字符。下面是它在经典麻烦制造者身上的表现:
| 输入 | 哪里错了 | 精确的错误 |
|---|---|---|
'SGVs bG8s' |
混进了一个空格 | FormatException: Invalid character (at character 5) |
'SGVs\nbG8s' |
混进了一个换行 | FormatException: Invalid character (at character 5) |
'SGVs$bG8s' |
美元符号不在字母表里 | FormatException: Invalid character (at character 5) |
'Zm8' |
完全没有填充 | FormatException: Invalid length, must be multiple of four (at character 4) |
'Zm8==' |
该补一个填充的地方补了两个 | FormatException: Invalid padding character (at character 5) |
'Zm=8' |
填充出现在数据中间 | FormatException: Invalid encoding before padding (at character 3) |
'Zm8=xx' |
填充后面跟着垃圾 | FormatException: Invalid padding character (at character 5) |
'Zé' |
一个非 ASCII 字符 | FormatException: Invalid character (at character 2) |
消息里的位置是从 1 开始的字符计数,输入就打印在脱字符(^)下方,所以二分定位一个损坏的载荷很快。严格性里还藏着一个令人惊喜的点:解码器两种字母表都收。标准字符串中间出现 - 或 _ 没问题,URL 安全字符串里出现 + 或 / 也没问题。字母表的选择只在你生产文本时才有意义,读取时则无所谓。
填充:不容商量的规则
这里是让大多数人意外的规则:Dart 解码器要求正确的填充。输入长度必须是 4 的倍数,末尾的 = 必须不多不少。没有宽松模式,没有可以放松它的标志,也没有可以更改的设置。理由很充分:不带填充的解码在边角情况下是有歧义的,RFC 也警告过宽松的解码可能打开隐蔽信道,所以严格的读法才是安全的那一个。这在实际中意味着:
| 输入 | 结果 |
|---|---|
'' |
空的 Uint8List,不报错 |
'QQ==' |
1 个字节:A |
'QUI=' |
2 个字节:AB |
'QUJD' |
3 个字节:ABC |
'Zm8' |
FormatException:长度无效 |
'Zm8==' |
FormatException:填充字符无效 |
当输入来自一个会剥掉填充的系统,而 JWT 里到处都是不带填充的值,修复步骤就是调用一次 normalize。它校验字符串,把 URL 安全字符换成标准字母表,并补上缺失的填充:
import 'dart:convert';
void main() {
final stripped = '-__--Q';
final repaired = base64Url.normalize(stripped);
print(repaired); // +//++Q==
final bytes = base64Decode(repaired);
print('decoded ${bytes.length} bytes'); // decoded 4 bytes
}
百分号的惊喜
这一条是 Dart 的原创。当 Base64 出现在 data URI 里时,有些工具会对填充做百分号编码,写 %3D 而不是 =,因为裸的 = 在 URL 语法里可能意味着 "参数分隔符"。大多数语言都会要求你先解转义。Dart 的解码器不用:它的查找表把 %3D 当作填充字符的原生写法,所以你可以直接把原始载荷交给它:
import 'dart:convert';
void main() {
final fromDataUri = 'SGVsbG8%3D';
final bytes = base64Decode(fromDataUri);
print(utf8.decode(bytes)); // Hello
}
转义只在填充合法的位置被接受,也就是末尾位置。把 %3D 放在 = 会被拒绝的地方,它也会被同样拒绝;%25 则会在填充检查上失败 - % 是 Dart 原生的填充转义字符,所以解码器把它读作转义过的 =,然后用 Invalid padding character 拒绝那个 2。实际意义是:从浏览器开发者工具里直接复制出来的 ;base64, 载荷可以不做任何预处理直接解码,一个小而真正方便的技巧。
URL 安全的 Base64
RFC 4648 出于一个原因定义了第二套字母表:标准字母表里有三个字符,+、/ 和 =,会与 URL 语法冲突。URL 安全字母表在 RFC 里叫 base64url,它把 + 换成 -,把 / 换成 _,通常还会去掉填充。它是 JWT、对象 ID、分享链接以及一切住在 URL 或文件名里的东西所用的字母表。
在解码这一侧,Dart 给你一个统一的答案:两种字母表由同一台机器读取。base64Decode 和 base64Url.decode 是同一个解码器的两个名字,所以真正要做的只有填充一件事,因为 URL 安全的生产者经常不带填充发货。normalize 正是为此而生的:
import 'dart:convert';
void main() {
final bytes = [0xfb, 0xff, 0xfe, 0xf9];
final urlSafe = base64UrlEncode(bytes);
print(urlSafe); // -__--Q==
final repaired = base64Url.normalize(urlSafe.replaceAll('=', ''));
print(repaired); // +//++Q==
print(base64Decode(repaired).length); // 4
}
留下两个坑。第一,解码前不要手写 - 换 + 的替换;那是多余的,normalize 需要时本来就会做字母表转换。第二,不要假设 URL 安全字符串一定不带填充:有些生产者保留填充,解码器两种都收,只要填充正确。
从字节到文本:字符集的选择
Base64 解码交给你的是字节。如果这些字节是文本,你必须选择把它们变回 String 的编码,而这个选择必须由你显式做出。现代系统的默认假设是 UTF-8,utf8.decode 就是主力:
import 'dart:convert';
void main() {
final payload = base64Encode(utf8.encode('Héllo Wörld'));
final bytes = base64Decode(payload);
print(utf8.decode(bytes)); // Héllo Wörld
final legacy = base64Encode(latin1.encode('Héllo'));
print(latin1.decode(base64Decode(legacy))); // Héllo
}
当字节不是合法的 UTF-8 时,utf8.decode 会抛出 FormatException,这是正确的行为,比无声的乱码好得多。如果你知道数据是遗留的单字节文本,就用对应的编码:
| 编码 | 用于 | 用……解码 |
|---|---|---|
utf8 |
现代文本、JSON、网络上一切 | utf8.decode(bytes) |
latin1 |
遗留的西式单字节数据 | latin1.decode(bytes) |
ascii |
纯 7 位文本 | ascii.decode(bytes) |
有一个陷阱值得单独警告:String.fromCharCodes 不是字符集。它把字节当作 UTF-16 码元来读,所以喂给它 Héllo 的 UTF-8 字节,它会一脸正经地打印出 Héllo。如果你在输出里看到这个乱码模式,修复方案几乎总是 utf8.decode。
JWT:读懂令牌
一个 JSON Web Token 是由点连接的三个 base64url 部分:头部、载荷、签名。这里用 Base64 是为了紧凑和 URL 安全,不是为了保密。任何拿到令牌的人都能读出头部和载荷,这是设计使然。签名才是你要验证的东西,用共享密钥或签发方的公钥。在 Dart 里解码可读的部分只需要几行:
import 'dart:convert';
Map<String, dynamic> readJwtPayload(String token) {
final parts = token.split('.');
if (parts.length != 3) {
throw FormatException('Not a compact JWT');
}
final padded = base64Url.normalize(parts[1]);
final bytes = base64Decode(padded);
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
}
void main() {
const token =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
'.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkRhcnQgRGV2IiwiaWF0IjoxNTE2MjM5MDIyfQ'
'.c2lnbmF0dXJl';
print(readJwtPayload(token)['name']); // Dart Dev
}
注意这场填充的舞蹈:JWT 是在没有填充的情况下构建的,所以只要某个部分的长度不是 4 的倍数,直接 base64Decode 就会失败。(上面例子里的头部碰巧是 36 个字符长,可以直接解码;载荷有 74 个字符,就不行。)normalize 调用让修复统一起来,与长度无关。还有两个警告。解码不等于验证:检查签名和 exp 声明是独立的、必经的一步,HMAC 算法通常用 crypto 包。另外,对声称 alg: none 的令牌保持怀疑;接受它们的解析器是漏洞,不是特性。
data URI:穿了 URL 外衣的文件
data URI 由 RFC 2397 定义,是一种载荷就是数据本身的 URL:data:image/png;base64, 后面跟着编码后的字节。它们存在,是为了让纯文本通道 - HTML 属性、CSS 规则、JSON 文档 - 能不靠单独的文件就携带二进制。Base64 是首选的载荷格式,因为替代方案百分号编码对二进制数据要长得多。
而且 Dart 能原生解析它们:data URI 支持自 2016 年起就在 dart:core 里,所以不需要任何 URI 库:
import 'dart:convert';
void main() {
final uri = Uri.parse('data:image/png;base64,iVBORw0KGgo=');
final data = uri.data!;
print(data.mimeType); // image/png
print(data.isBase64); // true
print('decoded ${data.contentAsBytes().length} bytes');
final textUri = Uri.parse('data:text/plain;base64,SGVsbG8sIERhcnQh');
print(textUri.data!.contentAsString()); // Hello, Dart!
}
UriData 对象给你 MIME 类型、isBase64 标志、原始载荷文本,以及解码后的内容(字符串或字节)。两个坑:声明的 MIME 类型可能说谎,所以安全敏感的代码要检查实际的 magic bytes;data URI 是为小资源准备的,因为整个载荷都跟着引用它的文档一起走。
文件:磁盘上的 Base64
Base64 文件会出现在导出格式、预配置包里,以及任何需要携带二进制的纯文本传输中。配方是:读文本、展平、解码、写字节:
import 'dart:convert';
import 'dart:io';
Future<void> main() async {
final encoded = await File('image.b64').readAsString();
final flat = encoded.replaceAll(RegExp(r'\s+'), '');
final bytes = base64Decode(flat);
await File('image.png').writeAsBytes(bytes);
print('wrote ${bytes.length} bytes');
}
那个 replaceAll 在干实打实的活。文本文件里满是换行,常常是 76 字符的 MIME 折行,而严格解码器会拒绝它们,所以先展平。这个正则移除每一个空白字符,对纯 base64 文件来说正是你要的。如果文件可能包含其他注释,比如 PEM 头,就在解码前显式剥掉它们,让解码器的错误去捕获真正损坏的东西。
HTTP 与 API
Base64 在 HTTP 里穿两件外衣。第一件是 API 响应:一个把二进制装成字符串的 JSON 字段。第二件是 Authorization: Basic 头,凭证在这里用标准字母表和填充做 base64 编码:
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<void> main() async {
final response = await http.get(
Uri.parse('https://httpbin.org/get?attachment=TWFuIGlzIGhlcmU%3D&name=man.txt'),
);
final payload = jsonDecode(response.body) as Map<String, dynamic>;
final args = payload['args'] as Map<String, dynamic>;
final bytes = base64Decode(args['attachment'] as String);
print('got ${bytes.length} bytes');
final credentials = utf8.decode(base64Decode('b2N0b2NhdDpzZWNyZXQ='));
print(credentials.split(':').first); // octocat
}
http 包是标准客户端,一条 dart pub add http 就能装上。对 Basic 认证,你解码 Basic 前缀后面的部分。两个坑:有些 API 在文档说 base64 的地方发的是 URL 安全或不带填充的值,所以如果直接解码抛错,先让值过一遍 base64Url.normalize;记住 Basic 认证是混淆,不是保护,这就是它只配出现在 TLS 连接上的原因。
电子邮件与 MIME:换行符的问题
电子邮件是 base64 最老的客户。MIME 在 76 个字符处折行 base64 行 - 76 加上 CRLF 在 80 列的显示上放得很舒服 - RFC 2045 告诉解码器忽略换行。Dart 的解码器故意不忽略:它会拒绝换行。修复方法是在解码前展平:
import 'dart:convert';
List<int> decodeMimeBody(String wrapped) {
final flat = wrapped.replaceAll(RegExp(r'\s+'), '');
return base64Decode(flat);
}
void main() {
const wrapped =
'SGVsbG8gZnJvbSBhbiBlbWFpbCBhdHRhY2htZW50LCB3cmFwcGVkIGF0IDc2IGNoYXJhY3RlcnMg'
'\r\n'
'dGhlIHdheSBNSU1FIHdhbnRzIGl0IHRvIGJlLCB3aXRoIENSTEYgYmV0d2VlbiB0aGUgbGluZXMu';
print(utf8.decode(decodeMimeBody(wrapped)));
}
规则很简单:剥掉空白,别的什么都不动。不要为了 "帮忙" 而剥掉其他字符;解码器才是校验器,你要的是它对真正的损坏发出抱怨。如果你在大规模处理电子邮件,展平这一步很便宜,一次正则扫描而已,而且它让整个管道保持诚实。
配置文件与环境变量
住在文本型配置里的令牌和凭证,有时会被 base64 编码,让它们能待在一行里、看起来像令牌。诚实的说法是:base64 是混淆,不是加密,所以这个模式是为了整齐,绝不是为了保密。模式本身很简单:
import 'dart:convert';
import 'package:dotenv/dotenv.dart';
Future<void> main() async {
final env = DotEnv()..load();
final encoded = env['API_TOKEN_B64'];
if (encoded == null) {
return;
}
final token = utf8.decode(base64Decode(encoded));
print('loaded a ${token.length}-char token');
}
用 dotenv 包时,值以 API_TOKEN_B64=c2stbGl2ZS1hYmMxMjM= 的形式待在 .env 文件里,解码后以纯文本形式回来。同样的形状也适用于 String.fromEnvironment 在编译期的 dart-define 值,但有一个警告:dart-define 的值会被烘焙进编译后的二进制里,所以任何机密都属于运行时配置或密钥管理器,不属于那里。
流:一块一块地来
当编码文本分片到达 - 一个网络流、一个按块读取的大文件 - 解码器也能应付。它的状态机把不完整的组带到分块边界之外,所以分块不需要对齐到 4 字符的边界:
import 'dart:convert';
Future<void> main() async {
final incoming = Stream.fromIterable(['TWF', 'uaGVsbG8=']);
final text = await incoming
.transform(base64.decoder)
.map(utf8.decode)
.join();
print(text); // Manhello
}
transform 调用把解码器用作流转换器;第一个分块三个字符,它的比特停在解码器的状态里,第二个分块补全了这一组。错误会以流错误的形式浮出来,带着同样的 FormatException 细节,空流则干脆没有输出。如果你更喜欢 sink,base64.decoder.startChunkedConversion 给你一个接到同一台状态机上的 StringConversionSink。
大数据:数学与内存
解码是缩水的:四个字符变成三个字节,所以输出总是略小于输入长度的四分之三。这意味着解码前就能知道输出大小,内存因此可预测。一个小助手只凭字符串就能算出来:
import 'dart:convert';
int decodedLength(String encoded) {
var padding = 0;
for (var i = encoded.length - 1; i >= 0 && padding < 2; i--) {
if (encoded.codeUnitAt(i) == 0x3d) {
padding++;
} else {
break;
}
}
return (encoded.length ~/ 4) * 3 - padding;
}
void main() {
print(decodedLength('QQ==')); // 1
print(decodedLength('QUI=')); // 2
print(decodedLength('QUJD')); // 3
}
内置解码器很快:一次遍历查找表,没有逐字符的字符串分配,所以几兆字节的字符串是家常便饭。base64 让你付出代价的是输入端:编码文本比数据大约大 33%,而且它是字符串,在 VM 上以 UTF-16 码元存放,大约是编码字符字节长度的两倍。对于可能长得很大的载荷,用流式解码,而不是拼成一个巨大的字符串。
从命令行
Dart 的 VM 能让解码器变成一个干净的 CLI。这个小工具读取文件参数或标准输入,展平空白,把原始字节写到标准输出:
import 'dart:convert';
import 'dart:io';
Future<void> main(List<String> args) async {
String encoded;
if (args.isNotEmpty) {
encoded = await File(args[0]).readAsString();
} else {
encoded = await stdin
.transform(utf8.decoder)
.join();
}
final flat = encoded.replaceAll(RegExp(r'\s+'), '');
stdout.add(base64Decode(flat));
await stdout.flush();
}
把它存成 bin/decode.dart,运行 dart run bin/decode.dart image.b64 > image.png,或者用管道:cat token.b64 | dart run bin/decode.dart。stdout.add 调用直接接收 Uint8List,没有中间字符串,这正是二进制在管道里应该流动的方式。
咬过 Dart 开发者的坑
- 填充之墙。JWT 风格和 URL 工具产生的输入经常不带
=,解码器会用Invalid length, must be multiple of four拒收。先让不可信的输入过一遍base64Url.normalize。 - 空白陷阱。文本文件、电子邮件和复制粘贴都会引入换行,而解码器从不跳过它们。解码前先用
replaceAll(RegExp(r'\s+'), '')展平。 - 字母表自信。既然两种字母表在任何地方都能解码,就不要基于 "哪个解码器生成了这个字符串" 来构建逻辑。字符串才是契约,不是生产者的设置。
- String.fromCharCodes 不是字符集。它读的是 UTF-16 码元,所以会把 UTF-8 文本变成乱码。用
utf8.decode或一个显式的编码。 - 两种不同的错误类型。解码问题是
FormatException;编码器对 0 到 255 范围之外的值抛出ArgumentError。如果你在构建边界,就分别捕获它们。 - 结果是定长的。
Uint8List不能增长,所以bytes.add(1)会抛出UnsupportedError。需要可变列表时用List<int>.from(bytes)复制一份。 - 不要手工解转义 %3D。解码器原生读取百分号转义的填充;过早的
replaceAll('%3D', '=')会把你的代码绑死在一个 SDK 已经负责的细节上。 - 解码 JWT 载荷不等于验证它。读出声明就相信它们,是一个在等一个坚定用户的漏洞。
最佳实践:简短清单
- 默认用
base64Decode;只在输入不可信的边界上才伸手拿normalize。 - 用
utf8.decode(bytes)显式声明字符集,哪怕你假设的是 UTF-8。 - 在知道字节是什么之前,让它们保持字节的形态;
Uint8List能干净地走进File.writeAsBytes们。 - 在信任边界上,捕获
FormatException,并记录消息给你的输入位置。 - 任何可能超过几兆字节的东西都用流。
- 把 base64 当作格式,而不是保护:它对任何知道这是 base64 的人都藏不住什么。
Dart 中 Base64 的简史
你刚见到的这台解码器,比 Dart 3、空安全和 Flutter 时代都要年长。简版是这样的:
- 2015 年 11 月 18 日,Dart 1.13:Base64 作为
BASE64常量,加上Base64Codec、Base64Encoder和Base64Decoder类,进入dart:convert。在这个版本之前,SDK 里完全没有 base64。 - 2016 年 1 月 28 日,Dart 1.14:
Base64Decoder.convert获得start和end范围参数,同一版本还给dart:core加上了 data URI 支持,也就是本文所依赖的Uri.parse路径。 - 2016 年 4 月 26 日,Dart 1.16:URL 安全字母表作为
BASE64URL和Base64Codec.urlSafe构造函数加入。 - 2018 年 8 月 7 日,Dart 2.0:常量改名为小写的
base64和base64Url,顶层的base64Decode们登场,解码返回Uint8List而不是可增长的List<int>,Base64Codec.normalize加入家族,把校验和修复变成一步调用。 - 2021 年,Dart 2.12:空安全落地,整个
dart:convert的故事,包括 base64,变成空安全的。 - 今天,Dart 3.13:类被标记为
final,你在上面遇到的行为,就是自 2015 年以来一直在跑的那台严格的、双字母表的、懂百分号的机器。
严格不是实现的意外。它是解码器在照做 RFC 4648 的指示:实现应当拒绝非字母表字符,MIME 式的宽松则留给需要的应用去做,而在 Dart 里,那意味着解码前的一个展平步骤。
趣闻
- 解码器把
%3D当作原生填充来读。把 data URI 的原始载荷连转义一起交给它,它照样解码。很少有语言运行时不需要预处理步骤就能做到这一点。 base64.decoder和base64Url.decoder字面上是同一个对象:两者都是规范化的const Base64Decoder()实例。"URL 安全解码器"只是换了装的标准解码器。- 整个解码器装得进一张 128 项的查找表,一个解释器和 AOT 编译代码共享的
Int8List,+和-都指向字母表槽位 62,/和_都指向 63。 - Dart 的 base64 和它的 data URI 支持相隔两个版本落地,分别是 1.13 和 1.14,显然是一对规划出来的搭档:一个读格式,一个直接从 URL 里读出来。
- 空字符串解码为空
Uint8List,不报错;空字符串编码为空字符串:base64 把 "没有数据" 当作一条完全合法的消息。 - 2018 年,当 Dart 2.0 改名常量时,
BASE64变成base64,这是整个 SDK 向小写常量名迁移的一部分,同一波改名还给了你ascii、json和utf8。
现在你拥有了完整的解码器:它接受什么、拒绝什么、如何修复受损的输入,以及如何在 JWT、data URI、文件、流、电子邮件和 shell 里与它相遇。这笔交易的反方向,把字节变成两套字母表之一,连同填充的决定和大小数学,会在 Base64 编码指南里详细讲解,链接就在这一页的末尾。
最后更新: 2026-09-08
相关文章: Dart 中的 Base64 编码:完整指南