需要使用 Base64 格式吗?那么本网站正好适合您!使用我们的在线工具对数据进行编码或解码,便捷好用。

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 跟进,你上面用到的顶层 base64Encodebase64UrlEncode 调用则落在 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 解码:完整指南