鸿蒙 ArkUI QRCode 二维码组件:动态生成、自定义样式与扫码应用
鸿蒙版 Flutter QRCode 二维码组件:动态生成、自定义样式与扫码应用
本文代码均为完整可运行片段,新建 Flutter 工程后整段复制即可
运行载体:鸿蒙真机(Mate 60 / Pura 70),基于 OHOS 适配版 Flutter SDK
特别说明:Flutter 官方无内置二维码组件,本文引入社区标准方案 qr_flutter(本系列唯一第三方依赖,原因见第 3 章)
本文技术栈速览
| 项目 | 取值 |
|---|---|
| Flutter SDK | 3.27.5-ohos-1.0.1(OpenHarmony 适配版,非 Google 官方版) |
| 运行设备 | 鸿蒙真机(Mate 60 / Pura 70),不支持 DevEco 模拟器 |
| 二维码组件 | qr_flutter 4.1.0(QrImageView) |
| 演示载体 | 二维码生成器:实时生成、颜色定制、纠错等级、模拟扫码 |
一、引言:一个方格子,装下整个世界
二维码大概是移动互联网时代最"不起眼却最了不起"的交互载体。支付码、分享名片、小程序跳转、身份认证、物流追踪、电子票券——一个由黑白方块组成的方格子,承载了从"我是谁"到"我要付多少钱"的几乎所有信息交换。它不像按钮那样需要被点击,不像输入框那样需要被填写,用户只需要举起手机扫一下,信息就完成了从"纸面/屏幕"到"设备"的跃迁。
二维码的三大应用场景各占一角:分享场景用它传递内容(链接、名片、Wi-Fi 凭证),支付场景用它承载交易凭据(收款码、付款码,动态更新防伪),身份识别场景用它绑定实体身份(电子证件、门票、工牌)。三个场景的共同点是:信息需要"离线可读"——扫描方不需要预先安装与你相同的 App,一个摄像头就能读取。这就是二维码相比其他信息传递方式(蓝牙、NFC、推送)的底层优势:读取门槛趋近于零。
三个场景对二维码的要求略有差异,值得先建立认知:分享场景追求"内容准确"(信息一字不差地还原),支付场景追求"读取快"(收款台前不能等两秒,所以支付码都是短码、低版本、模块稀疏),身份识别场景追求"耐损"(票券会被折叠、褪色、反光,纠错等级要选 Q/H)。生成二维码的工程,本质上是在"内容、速度、耐损"三个目标间做调配——而调配的工具,就是第 5 章要讲的版本、纠错等级与编码模式三件套。
ArkUI 把 QRCode 内置进框架(value / color / backgroundColor / width / height 五个参数即可生成),而 Flutter 官方没有对应的内置组件——这是鸿蒙原生体系与 Flutter 生态的一个典型差异。社区的标准方案是 qr_flutter 包:一个基于 CustomPaint 的纯 Dart 实现,不依赖原生插件,渲染性能与可定制性都足够生产使用。本文的任务,就是用 qr_flutter 实现一个"二维码生成器"——文本输入实时生成、颜色自定义、纠错等级切换、保存相册(模拟)、扫码结果展示,并在最后讲透二维码的容错机制与数据容量这两个底层原理。
术语解释:二维码(QR Code)是 Quick Response Code 的缩写,由日本 Denso Wave 公司 1994 年发明;矩阵式条码的一种,通过二维平面上的黑白模块编码信息。纠错(Error Correction)指码中冗余的纠错字节,允许部分模块被遮挡或污损时仍能正确解码。
二、环境准备
环境与系列前文一致,要点速览:
| 组件 | 版本 / 说明 |
|---|---|
| Flutter SDK | 3.27.5-ohos-1.0.1(OpenHarmony 适配版) |
| Dart SDK | 3.6.2(随适配版内置) |
| qr_flutter | 4.1.0(本文唯一第三方依赖) |
| DevEco Studio | 5.0 及以上(管理真机连接) |
| 鸿蒙真机 | Mate 60 / Pura 70,开启开发者模式与 USB 调试 |
三步开工:flutter create 生成工程 → USB 连接真机并确认设备在线 → flutter run -d <deviceId> 首构建。本文工程引入 qr_flutter 为唯一第三方依赖,其余全部为 Flutter 内置组件。
qr_flutter 是纯 Dart 包(基于 CustomPaint 绘制),不涉及原生插件,ohos/ 目录无需改动,鸿蒙适配版可以直接运行。真机验证的三个要点:一是二维码的绘制清晰度——CustomPaint 在鸿蒙渲染路径下的抗锯齿表现,真机放大看黑白交界是否锐利;二是颜色自定义后的可扫描性——浅前景色或深背景色会显著降低扫码成功率,演示页默认黑前景白背景,改色后可用真实相机验证;三是"保存相册"为模拟操作(不引入相册权限插件),真实项目需要按系统权限流程接入,踩坑指南章节会说明。
三、鸿蒙版 Flutter 与官方 Flutter 的差异对比
二维码是差异最大的一篇文章——因为 Flutter 官方没有这个组件:
| 对比维度 | ArkUI 原生 | Flutter(OHOS 适配版) |
|---|---|---|
| 组件来源 | QRCode 内置组件 | 官方无内置,社区标准方案 qr_flutter |
| 生成参数 | value / color / backgroundColor / width / height | data / color(eye+module) / backgroundColor / size / errorCorrectionLevel |
| 渲染方式 | 原生 Canvas | CustomPaint 纯 Dart 绘制 |
| 依赖情况 | 零依赖 | 一个第三方包(纯 Dart,无原生插件) |
| 实时更新 | 改 value 重建 | setState 重建 QrImageView |
| 扫码能力 | 需单独接入扫码模块 | 需 camera + 扫码库(本文用模拟) |
两点重点说明:
- 为什么引入 qr_flutter 这个"例外":本系列坚持零第三方插件,是因为鸿蒙适配版的插件生态不完整、原生插件迁移成本高。但二维码生成是个特殊场景——它不涉及任何原生能力(不需要相机、不需要权限、不需要系统 API),纯计算 + 绘制就能完成,qr_flutter 正是这样一个纯 Dart 实现。引入它的成本仅是"多一个 pub 依赖",收益却是"开箱即用的完整二维码编码器"(QR 编码涉及 Reed-Solomon 纠错算法,手写实现约千行且极易出错)。判断标准:纯 Dart 能力 → 可以用社区包;涉及原生能力 → 优先手写或插件适配。这也是本系列唯一一次破例,之后的文章继续零插件;
- ArkUI 与 qr_flutter 的参数对照:ArkUI 的 value 对应 qr_flutter 的 data,color 被拆成 eyeStyle(定位角)+ dataModuleStyle(数据模块)两个对象,backgroundColor 与 width/height 一一对应,qr_flutter 还多出 errorCorrectionLevel(纠错等级)与 version(版本)。ArkUI 的五参数是"够用型"封装,qr_flutter 的可定制性更深——本文演示页正是用这些参数做颜色与纠错的自定义。
除此之外,qr_flutter 是纯 Dart + CustomPaint,在鸿蒙适配版上的行为与官方一致,无系统级差异,渲染路径随 OHOS 的 Skia 后端走,真机实测清晰度达标。
四、核心 API 解析:QrImageView 与二维码生成模型
4.1 ArkUI QRCode 与 qr_flutter 参数对照总表
| ArkUI QRCode | qr_flutter QrImageView | 说明 |
|---|---|---|
| value | data | 二维码承载的内容 |
| color | eyeStyle.color + dataModuleStyle.color | 前景色(定位角与数据模块可分别设置) |
| backgroundColor | backgroundColor | 背景色 |
| width / height | size | 边长(正方形) |
| — | errorCorrectionLevel | 纠错等级 L / M / Q / H |
| — | version | 版本(auto 自动选最小) |
| — | eyeStyle.eyeShape | 定位角形状(方块 / 圆点) |
| — | dataModuleStyle.dataModuleShape | 数据模块形状(方块 / 圆点) |
4.2 核心参数逐一拆解
data(value):二维码的内容,文本或链接皆可。编码时按"字节模式"处理,中文每字占 3 字节(UTF-8),ASCII 每字符 1 字节——容量判断的基准就是字节数,本文的 _byteLength 估算器就是按这个规则算的。
color(前景色):qr_flutter 把前景色拆成 eyeStyle.color 与 dataModuleStyle.color 两处,本文演示页让两者共用同一个 _foreground,保持视觉统一。颜色自定义的边界是可扫描性:前景与背景的对比度必须足够(黑白对比最佳),反色(白前景黑背景)或低对比度配色会导致扫码失败——演示页在改色后保留了默认黑白的选项,就是给"可扫描性"留了退路。
backgroundColor(背景色):与前景色同理,浅色背景(白、米色)可扫描性最佳,深色背景会干扰定位角的识别。
size(宽高):正方形边长,本文用 Slider 在 120~320 px 间实时调节。尺寸的底线是"模块可被相机识别"——二维码过小(<100 px)时模块密度过高,扫码枪容易失败;过大则白白占用屏幕。尺寸的调节对"扫码稳定性"的影响比直觉更大:同一内容,320 px 的码比 120 px 的码在同等距离下识别快得多——演示页把滑条范围放在 120~320,就是这个规律的实用区间。
errorCorrectionLevel(纠错等级):qr_flutter 比 ArkUI 多出的关键参数,四个档位对应不同的容错率与容量:
| 等级 | 容错率 | 容量(版本 40 字节模式) | 适用 |
|---|---|---|---|
| L | 约 7% | 2953 字节 | 屏幕显示,污染风险低 |
| M | 约 15% | 2331 字节 | 通用默认(本文默认值) |
| Q | 约 25% | 1663 字节 | 打印物料,易污损场景 |
| H | 约 30% | 1273 字节 | 高损毁风险场景(标签、户外) |
version(版本):QR 码有 1~40 共 40 个版本,版本决定矩阵尺寸(21+4×(版本−1)21 + 4 \times (\text{版本}-1)21+4×(版本−1) 模块)。QrVersions.auto 让编码器自动选容纳数据的最小版本——容量与密度自动平衡,通常无需手动指定。版本的视觉特征很直观:内容越短版本越低,模块越大、码越"稀疏";内容越长版本越高,模块越小、码越"密"。这个视觉特征在演示页的"不同内容二维码"对比截图(图 5)里一眼可见——稀疏的码好扫,密集的码难认,这就是"短码即稳"的视觉证据。
4.3 实时生成的机制
本文的"实时生成"依赖受控输入 + setState 重建:
TextField(
controller: _controller,
onChanged: (_) => setState(() {}), // 输入变化触发重建
),
// ...
QrImageView(
data: _data, // 内容取自输入框
version: QrVersions.auto,
size: _size,
errorCorrectionLevel: _errorLevel,
...
)
每次输入变化,QrImageView 用新 data 重新编码并绘制——用户体验上就是"打字即出码"。受控模型与 Select 一文的结论一致:显示完全由外部状态决定,状态一变、二维码自动更新,没有中间缓存。
4.4 绘制性能与实时更新
实时生成的性能取决于编码 + 绘制两步的开销:
| 阶段 | 成本 | 优化 |
|---|---|---|
| 编码 | 内容长度 × 纠错等级,毫秒级 | 内容短时无感,长内容(>1KB)时降低更新频率 |
| 绘制 | 模块数 × 2(黑白),CustomPaint 轻量 | 尺寸越大绘制越重,但模块数才是主要因素 |
实测结论:日常内容(链接、短文本)在真机上"打字即出码"毫无压力;超长内容(接近容量上限)时每次重建会有一瞬卡顿——优化手段是"防抖"(输入停顿 200ms 再重建)或"内容长度前置限制"。本文演示页用 maxLines: 2 的输入框天然限制了超长粘贴,配合容量提示前置,规避了"超长编码失败卡死"的路径——演示页的克制,本身就是一种性能设计。
4.5 常用属性拾遗
除核心参数外,qr_flutter 还有两个常用属性值得知道:
| 属性 | 作用 | 场景 |
|---|---|---|
| embeddedImage | 在码中央嵌入图片(Logo) | 品牌二维码、票券装饰 |
| gapless | 模块间不留缝隙 | 打印前设置 true 保边缘齐整 |
embeddedImage 是"美化二维码"最常用的入口——把品牌 Logo 塞进码中央。注意一个关键约束:嵌入图片会遮挡中央模块,必须用高纠错等级(Q/H)补偿,否则遮挡部分超过容错率会直接扫不出——这正是"容错与美化的平衡"在工程里的落地:想美,就得先扛损。
五、二维码的容错与数据容量
二维码的底层原理是两把锁:纠错锁与容量锁。本节把两者讲透,这也是演示页"纠错等级切换 + 容量提示"背后的理论依据。
5.1 容错:Reed-Solomon 纠错码
QR 码的容错机制是数学上的 Reed-Solomon 纠错码:编码时在数据字节之外附加冗余的纠错字节,解码时即使部分模块被遮挡、污损、褪色,也能从剩余信息中还原完整数据。容错能力用"可被遮挡的模块比例"衡量:
| 等级 | 可损毁比例 |
|---|---|
| L | $ \approx 7% $ |
| M | $ \approx 15% $ |
| Q | $ \approx 25% $ |
| H | $ \approx 30% $ |
举个例子:一张海报右下角被贴纸盖住了 20% 的二维码,H 等级(30%)能完整解码,Q 等级(25%)勉强,M 等级(15%)直接失败——这就是"打印物料选 Q/H"的原因。纠错码的原理可以概括为"信息冗余换抗损能力":纠错字节越多,能容忍的损伤越大,但能装的有效数据越少——容错与容量是一对跷跷板,第 5.2 节的公式会给出精确关系。
5.2 容量:版本、纠错与编码模式的三角关系
二维码的最大数据容量由三个变量决定:
- 版本(1~40):决定模块总数,矩阵边长 S=21+4(v−1)S = 21 + 4(v-1)S=21+4(v−1),模块总数 S2S^2S2;
- 纠错等级(L/M/Q/H):决定数据区与纠错区的分配比例;
- 编码模式(数字/字母数字/字节/汉字):决定每个字符占的位数。
字节模式(通用模式,覆盖文本与链接)的容量上限随版本增长,在版本 40、纠错 L 下达 2953 字节。容量与纠错的关系可以写成:
Cmax(v,e)=数据区容量(v)×(1−纠错开销(e))C_{\text{max}}(v, e) = \text{数据区容量}(v) \times (1 - \text{纠错开销}(e))Cmax(v,e)=数据区容量(v)×(1−纠错开销(e))
其中 纠错开销(e)\text{纠错开销}(e)纠错开销(e) 随等级升高而增大——这就是演示页容量提示"L 2953 / M 2331 / Q 1663 / H 1273"的出处。选纠错等级的本质是在"多装一点"与"耐损一点"之间做取舍:纯数字短码(支付收款码)选 M 足够;易损场景(户外标签)选 Q/H,宁可少装也要扫得出。
5.3 容错与容量的工程结论
四条工程结论,直接指导二维码生成器的设计:
- 默认 M:大多数场景(屏幕展示、聊天分享)污染风险低,M 在容量与容错间最均衡——本文默认 M;
- 长内容升版本:内容变长时编码器自动升版本(auto),版本升高、模块变密——内容超过约 2000 字节时,建议换成"短链"或分块二维码;
- 短码即"稳":内容越短,版本越低,模块越大,扫码越稳——能放短链就别放长文本,这是二维码生成器最重要的实用建议;
- 容错不是万能的:遮挡超过容错率、二维码本身残缺、反色低对比,任何一项都足以让扫码失败——容错对抗的是"部分损伤",对抗不了"整体失效"。
5.4 二维码的防伪与安全边界
二维码"人人都能扫"的另一面是"人人都能伪造"——它本身不含任何加密或签名机制,任何人都能生成一个内容相同的码。安全边界要讲清楚三条:
- 防伪靠内容设计而非编码:二维码不加密,防伪依赖内容本身——支付码用动态更新 + 时效过期(30~60 秒刷新一次)防重放,防伪溯源用一物一码 + 服务端校验防伪造;
- 扫码前先验"头":二维码内容可能是恶意链接或钓鱼页——客户端扫码后应展示内容域名/类型再跳转(本文演示页的"类型识别"就是这个安全环节的最小版),重大操作(支付、登录)还需二次确认;
- 别把敏感信息直接进码:二维码内容可被任意人扫读,身份证号、银行卡号这类信息一旦入码等于公开展示——敏感数据应入码前先加密或只放"指向安全的取用凭证"(token)。
三条边界合起来是一句原则:二维码是公开信道,公开信道里只放公开内容。安全设计做在码外(服务端校验、时效、加密),而不是码内。
六、完整代码实现:二维码生成器
本文代码全部内嵌,先给依赖配置,再给完整入口代码,最后分模块讲解。
6.1 pubspec.yaml
name: qr_generator
description: "二维码生成器:Flutter 鸿蒙版(OHOS)QRCode 二维码组件实战配套工程"
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.6.2
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
# Flutter 官方无内置二维码组件,社区标准方案为 qr_flutter(本系列唯一第三方依赖)
qr_flutter: ^4.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
6.2 完整入口代码
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
void main() {
runApp(const QrGeneratorApp());
}
/// 二维码生成器:实时生成、颜色定制、纠错等级、模拟扫码
class QrGeneratorApp extends StatelessWidget {
const QrGeneratorApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: '二维码生成器',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0A59F7)),
useMaterial3: true,
),
home: const QrGeneratorPage(),
);
}
}
class QrGeneratorPage extends StatefulWidget {
const QrGeneratorPage({super.key});
State<QrGeneratorPage> createState() => _QrGeneratorPageState();
}
class _QrGeneratorPageState extends State<QrGeneratorPage> {
final _controller = TextEditingController(text: 'https://flutter.cn');
Color _foreground = Colors.black;
Color _background = Colors.white;
int _errorLevel = QrErrorCorrectLevel.M;
double _size = 220;
static const List<Color> _palette = [
Colors.black,
Color(0xFF0A59F7),
Color(0xFFE53935),
Color(0xFF43A047),
Color(0xFF8E24AA),
Color(0xFFF4511E),
];
String get _data => _controller.text.trim();
bool get _hasData => _data.isNotEmpty;
int get _byteLength {
// UTF-8 字节数估算(中文 3 字节/字,ASCII 1 字节/字)
var bytes = 0;
for (final c in _data.codeUnits) {
bytes += c <= 0x7F ? 1 : 3;
}
return bytes;
}
// 纠错等级 → 数据容量上限(字节模式,版本 1~40 示例)
String get _capacityHint {
final limits = switch (_errorLevel) {
QrErrorCorrectLevel.L => '2953 字节(版本 40)',
QrErrorCorrectLevel.H => '1273 字节(版本 40)',
QrErrorCorrectLevel.Q => '1663 字节(版本 40)',
_ => '2331 字节(版本 40)',
};
return '当前内容 $_byteLength 字节,纠错 $limits';
}
void dispose() {
_controller.dispose();
super.dispose();
}
void _saveToGallery() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已保存到相册(演示环境为模拟操作)')),
);
}
void _showScanResult() {
if (!_hasData) return;
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (ctx) => Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('扫码结果', style: Theme.of(ctx).textTheme.titleLarge),
const SizedBox(height: 16),
Row(
children: [
const Icon(Icons.link, color: Colors.blue),
const SizedBox(width: 8),
Expanded(
child: SelectableText(
_data,
style: Theme.of(ctx).textTheme.bodyLarge,
),
),
],
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(ctx).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'识别结果:${_data.startsWith('http') ? '链接类型' : '纯文本类型'},'
'内容 $_byteLength 字节',
style: Theme.of(ctx).textTheme.bodyMedium,
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('关闭'),
),
],
),
),
);
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('二维码生成器'),
centerTitle: true,
actions: [
IconButton(
tooltip: '模拟扫码',
icon: const Icon(Icons.qr_code_scanner),
onPressed: _showScanResult,
),
IconButton(
tooltip: '保存到相册',
icon: const Icon(Icons.save_alt),
onPressed: _saveToGallery,
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// 二维码展示区
Center(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _background,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: _hasData
? QrImageView(
data: _data,
version: QrVersions.auto,
size: _size,
errorCorrectionLevel: _errorLevel,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: _foreground,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: _foreground,
),
)
: const SizedBox(
width: 220,
height: 220,
child: Center(child: Text('输入内容后生成二维码')),
),
),
),
const SizedBox(height: 16),
// 内容输入
TextField(
controller: _controller,
maxLines: 2,
decoration: const InputDecoration(
labelText: '二维码内容',
hintText: '输入文本或链接',
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
Text(
_capacityHint,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
// 纠错等级
Text('纠错等级', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(
value: QrErrorCorrectLevel.L,
label: Text('L'),
icon: Icon(Icons.looks_one),
),
ButtonSegment(
value: QrErrorCorrectLevel.M,
label: Text('M'),
icon: Icon(Icons.looks_two),
),
ButtonSegment(
value: QrErrorCorrectLevel.Q,
label: Text('Q'),
icon: Icon(Icons.looks_3),
),
ButtonSegment(
value: QrErrorCorrectLevel.H,
label: Text('H'),
icon: Icon(Icons.looks_4),
),
],
selected: {_errorLevel},
onSelectionChanged: (s) =>
setState(() => _errorLevel = s.first),
),
const SizedBox(height: 16),
// 前景色自定义
Text('前景色', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Wrap(
spacing: 12,
children: _palette
.map((c) => _ColorDot(
color: c,
selected: _foreground == c,
onTap: () => setState(() => _foreground = c),
))
.toList(),
),
const SizedBox(height: 16),
// 背景色自定义
Text('背景色', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Wrap(
spacing: 12,
children: [
for (final c in [Colors.white, Colors.black, const Color(0xFFFFF8E1)])
_ColorDot(
color: c,
selected: _background == c,
onTap: () => setState(() => _background = c),
),
],
),
const SizedBox(height: 16),
// 尺寸调节
Text('尺寸:${_size.round()} px',
style: Theme.of(context).textTheme.titleSmall),
Slider(
value: _size,
min: 120,
max: 320,
divisions: 20,
label: _size.round().toString(),
onChanged: (v) => setState(() => _size = v),
),
const SizedBox(height: 8),
// 操作区
Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.qr_code_scanner),
label: const Text('模拟扫码'),
onPressed: _hasData ? _showScanResult : null,
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.save_alt),
label: const Text('保存相册'),
onPressed: _hasData ? _saveToGallery : null,
),
),
],
),
],
),
);
}
}
/// 颜色选择圆点
class _ColorDot extends StatelessWidget {
final Color color;
final bool selected;
final VoidCallback onTap;
const _ColorDot({
required this.color,
required this.selected,
required this.onTap,
});
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: selected
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
width: selected ? 3 : 1,
),
),
child: selected
? const Icon(Icons.check, color: Colors.white, size: 20)
: null,
),
);
}
}
6.3 分模块讲解
实时生成:输入框 onChanged 触发 setState,QrImageView 用新 _data 重建——打字即出码。空内容时展示占位提示,_hasData 控制扫码/保存按钮的可用性(与 Select 一文"动作可用性与状态对齐"同源)。
颜色自定义:前景色六个色块 + 背景色三个色块,_ColorDot 组件统一渲染选中态(主色描边 + 对勾)。前景色同时传给 eyeStyle 与 dataModuleStyle,保证定位角与数据模块颜色一致。默认黑白为安全起点,改色后保持前景深、背景浅的原则(可扫描性优先)。
纠错等级:SegmentedButton 四档切换 L/M/Q/H,选中的等级实时作用于二维码重建。容量提示栏随等级变化显示不同的容量上限——把"容错与容量跷跷板"可视化,用户肉眼可见"切到 H,码变密、容量变小"。
模拟扫码:弹层展示"扫码结果"——内容、类型识别(http 前缀判链接/纯文本)、字节数。真实扫码需要相机能力,演示用弹层模拟结果展示,架构上保持"扫码结果消费"逻辑独立,接入真实相机时只替换"输入源"。
保存相册(模拟):SnackBar 提示"已保存",不引入相册权限插件。真实项目接入方案在踩坑指南章节说明。
6.4 生成器的界面分层
生成器页面的布局可以拆成"展示区 + 控制区 + 操作区"三层:展示区(二维码卡片)永远是页面焦点,控制区(输入、颜色、纠错、尺寸)按"影响度从大到小"排列——内容决定一切,颜色次之,纠错再次,尺寸最后;操作区(扫码、保存)固定在底部,用 _hasData 统一约束可用性。这个分层与 Select 一文的"数据展示 + 控制 + 动作"结构同构——系列反复出现的布局模式:焦点在上、控制在中间、动作在底部,用户视线自上而下完成"看 → 调 → 动"的完整链路。
七、真机运行与效果展示
7.1 运行步骤
- USB 连接鸿蒙真机,DevEco Studio 设备列表确认在线(图 1);
flutter run -d <deviceId>首构建,hvigor 编译原生层;- 真机呈现二维码生成器主页(图 2);
- 按演示脚本逐项操作(图 3~图 6);
- 终端确认编译日志无 error(图 7)。
7.2 截图占位
截图占位共 7 张,覆盖生成器的核心交互:
图 1:DevEco Studio 设备列表(鸿蒙真机在线)

图 2:二维码生成器主页(默认内容)

7.3 演示脚本
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 输入框改为"https://example.com" | 二维码实时更新为链接内容 |
| 2 | 点前景色蓝色色块 | 二维码变蓝,背景仍白 |
| 3 | 输入单字"码" | 二维码模块稀疏、版本最低 |
| 4 | 粘贴一段 100 字文本 | 二维码模块密集、版本升高 |
| 5 | 切换纠错 H | 二维码模块数增加(纠错冗余变多),容量提示变 1273 |
| 6 | 切回纠错 M | 模块数回落,容量提示变 2331 |
| 7 | 拖动尺寸滑条到 320 | 二维码放大,模块清晰度提升 |
| 8 | 点"模拟扫码" | 弹层显示内容 + “链接类型” + 字节数 |
| 9 | 点"保存相册" | SnackBar 提示已保存(模拟) |
| 10 | 清空输入 | 二维码变占位提示,操作按钮禁用 |
7.4 生成器验证的三条金线
二维码生成器的验证,三条金线要逐一过:
- 实时性:改输入、改颜色、改纠错、改尺寸,四种操作后二维码都必须立刻更新——漏一种就漏一种"状态脱节";
- 可扫描性:默认黑白的码用真实相机扫一遍,确认能正常解析——这是生成器存在的意义,界面再漂亮,扫不出来就是废码;
- 容量边界:输入从短到长,容量提示数字要随纠错等级正确变化——容量提示是"纠错 vs 容量跷跷板"的可视化,数字错了,理论讲得再好也是空中楼阁。
三条金线分别对应用户体验(实时)、产品价值(可扫)、理论正确(容量)。金线全过,生成器的工程闭环才算合上。
八、二维码应用场景总结
二维码的适用场景由它的两个特性决定:离线可读与读取门槛低。
8.1 用二维码的场景
| 场景 | 二维码承载 | 关键点 |
|---|---|---|
| 分享 | 链接、名片、Wi-Fi 凭证 | 离线可读,无需预装 App |
| 支付 | 收款码 / 付款码 | 动态更新 + 短码保证扫码速度 |
| 身份识别 | 电子票券、证件、工牌 | 纠错等级选 Q/H 抗污损 |
| 防伪溯源 | 一物一码 | 内容短、密度低、易扫描 |
| 导流 | 小程序码、App 下载页 | 内容为短链,模块稀疏 |
8.2 不用二维码的场景
| 场景 | 替代 | 理由 |
|---|---|---|
| 大段文本(>2KB) | 链接 / 分块 | 二维码装不下或扫不动 |
| 动态内容频繁更新 | 推送 / 实时刷新 | 二维码是静态快照 |
| 需双向交互 | NFC / 蓝牙 / App 内 | 二维码只能单向读 |
| 高安全敏感数据 | 加密通道 | 二维码内容可被任何人扫到 |
一条口诀收束:二维码装"短而公开"的信息——短(容量与稳定性)、公开(无隐私要求)。需要保密或超长的内容,都不该进二维码。
8.3 动态二维码:二维码的"活"用法
静态二维码(内容固定不变)是二维码的默认形态,而支付、门禁、票务场景用的是"动态二维码"——内容随状态变化。两种形态的工程差异:
| 维度 | 静态码 | 动态码 |
|---|---|---|
| 内容 | 固定(链接、文本) | 定期刷新(含时效参数) |
| 生成时机 | 一次生成长期使用 | 每次使用重新生成 |
| 安全 | 无时效,易被复制重放 | 时效过期即失效,防重放 |
| 典型场景 | 分享、导流、名片 | 支付码、动态门禁、临时票券 |
动态码的工程实现就是把"内容生成"从一次性变成周期性:定时刷新数据(本文生成器改为定时换内容即可演示)、过期置灰、刷新动画。它体现了二维码"内容可编程"的本质——码是静态的,生成码的逻辑是活的,安全与时效都在生成逻辑里。
九、无障碍与二维码语义
二维码本身是"给机器看的",无障碍价值在配套界面。四项要求:
| 要求 | 做法 | 落点 |
|---|---|---|
| 二维码语义 | Semantics 标注内容摘要 | 读屏播报"二维码,内容为链接" |
| 操作可达 | 扫码/保存按钮语义清晰 | 动词 + 图标 |
| 颜色可辨 | 改色后保留对比度提示 | 低对比警告 |
| 结果可读 | 扫码结果弹层文字化 | SelectableText 全文可复制 |
两个容易被忽略的细节:
- 二维码的读屏语义:QrImageView 是纯绘制,读屏感知不到"这里有个码"——应包一层
Semantics(label: '二维码,内容为 $_data'),让读屏用户知道这块区域是什么。本文演示页未加(保持代码最小),真实项目务必补上; - 改色的可扫描性警告:用户把前景色改浅、背景色改深后,扫码大概率失败——设计上应在颜色切换处给出"浅色前景可能影响扫码"的提示,把"可扫描性"这个技术约束转成用户可理解的引导。本文演示页保持默认黑白为安全起点,改色后可用真实相机验证。
十、真机调试踩坑指南
| 症状 | 根因 | 解法 |
|---|---|---|
| 生成二维码扫不出来 | 前景/背景对比度不足 | 保持黑白对比,浅前景深背景必失败 |
| 改色后扫码失败 | 反色或低对比配色 | 恢复默认配色或加对比度警告 |
| 二维码模糊 | 尺寸过小,模块密度高 | 调大 size,内容改短链 |
| 中文内容乱码 | 未按 UTF-8 字节计算容量 | 中文 3 字节/字,超容换短链 |
| 保存相册失败 | 演示为模拟操作 | 真实项目按系统权限流程接入 |
| 弹层键盘遮挡扫码结果 | isScrollControlled 未设 | 弹层加 isScrollControlled: true |
| 切换颜色瞬间卡顿 | 高版本二维码重建开销 | 内容短、版本低时无感,长内容降低频率 |
| 输入超长内容卡死 | 超容量编码失败 | 容量提示前置 + 内容长度限制 |
| qr_flutter 版本兼容问题 | 依赖版本冲突 | 锁定 4.1.0,纯 Dart 包兼容 OHOS |
| 真机日志找不到 Flutter 输出 | 日志走 hdc 而非 adb | hdc shell hilog 过滤 flutter 关键字 |
10.1 一段典型的踩坑实录
初版把前景色默认设成了品牌蓝、背景色保持白,自认为"品牌感十足"。结果用真机相机一扫——识别率明显低于黑白,个别场景(光线一般时)直接失败。复盘:相机识别依赖黑白模块的明度对比,品牌蓝虽深,但与纯黑的明度差仍不够(尤其屏幕反光时)。修复:默认色改回黑白,蓝色作为可选色保留在调色盘里,并在改色后提示"浅色前景可能影响扫码"。这个坑的教训是:二维码的视觉设计必须让位于可扫描性——装饰可以有,但不能以牺牲识别为代价,技术约束(对比度)要先于审美约束。
10.2 保存相册的真实接入路径
演示页的"保存相册"是模拟操作,真实项目的路径说明:需要把 QrImageView 绘制的码转成图片文件——用 RepaintBoundary + toImage 将渲染节点导出为 PNG,再通过系统相册能力保存;鸿蒙适配版上相册写入需要申请媒体权限,流程与官方 Android 的相册写入一致但权限弹窗由系统管理。零插件方案可先写应用私有目录(无需权限),相册持久化再按系统权限接入——先本地可用,再相册可见,是权限敏感功能的稳妥接入次序。
10.3 二维码逻辑的测试姿势
二维码生成器的逻辑密度不高(主要靠 qr_flutter 编码),可测的点集中在"内容与参数":三个高频用例:
- 容量估算:中文内容字节数(3 字节/字)与 ASCII(1 字节/字)的估算正确性——喂入已知字符串,断言
_byteLength期望值; - 类型识别:http 前缀判链接、无前缀判文本——喂入两类内容,断言扫码结果弹层的类型文案;
- 空态保护:清空输入后扫码/保存按钮禁用、二维码区域显示占位——断言"无内容不可操作"。
三条用例锁定的都是"生成器的自洽性":容量提示、类型识别、空态处理。至于 qr_flutter 编码正确性(扫得出),属于依赖库的职责,用真实相机验证即可,不必写进单元测试。
十一、总结与扩展
二维码是"离线可读"的信息载体,本文用 qr_flutter 实现了完整的二维码生成器:输入实时生成、前景/背景色自定义、纠错等级 L/M/Q/H 切换、尺寸调节、模拟扫码与保存。底层原理讲透了两把锁——纠错锁(Reed-Solomon 冗余,容错率 7%~30%)与容量锁(版本 × 纠错 × 编码模式共同决定字节上限)。四条工程结论值得背下来:默认纠错 M、长内容升版本、能放短链别放长文本、视觉设计让位于可扫描性。
回看引言的问题:一个方格子,凭什么装下整个世界?答案在本文的原理章节——因为它在"容量与容错"之间做了精密的平衡,在"内容与形式"之间留了灵活的接口。生成器的每一处设置(内容、颜色、纠错、尺寸),都是对这个平衡与接口的操作;而理解底层原理(为什么短码稳、为什么深色优先、为什么纠错吃容量),才能让这些设置不流于表面。
本文是系列唯一引入第三方依赖的文章,这个"例外"本身也值得记住:纯 Dart 能力的缺失,用社区包补齐是合理的(成本仅是一个 pub 依赖);涉及原生能力时,手写或插件适配才是主线。判断标准一句话:能纯计算解决的能力,社区包可用;需要系统能力的,谨慎评估。这条判断标准,比 qr_flutter 本身更值得带走。
从本文工程出发可以扩展的方向:
- 二维码美化:嵌入 Logo(qr_flutter 的 embeddedImage)、圆点定位角(eyeShape 切圆)、渐变模块;
- 真实扫码:接入 camera + 扫码识别库,把模拟弹层替换为相机取景,扫码结果消费逻辑直接复用;
- 批量生成:列表数据 → 批量生成二维码(会议签到、一物一码),配合导出;
- 保存相册真实化:RepaintBoundary 导出 PNG + 系统相册权限接入;
- 动态二维码:支付场景的时效二维码(定时刷新防重放),把"内容生成"与"定时刷新"组合;
- 二维码识别录入:把扫码能力做成"扫入"组件——扫到文本进输入框、扫到链接可打开,与生成器组成"生成/识别"闭环。
最后用甘特图回顾二维码生成器的开发节奏,延续本系列(Button → TextInput → Search → Grid → CustomDialog → Stepper → Select → QRCode)的工程化节奏:
二维码的哲学是"把信息藏进方格里,让机器替人读"。它把"传递信息"的成本压缩到"扫一下"——不需要打字、不需要确认、不需要双方约定协议。而生成二维码的工程,要处理的恰恰是反向问题:把用户给的一行文字,变成机器能稳读的方块阵列。从容量到容错,从颜色到尺寸,本文的每一步都在回答同一个问题:怎么让这个方格子,扫得又快又稳。
回看全篇,可以提炼出本文区别于前八篇的特质:这是系列里"组件能力依赖生态"的第一篇——它展示了一种诚实的态度:不因为"零插件"的洁癖而回避生态协作,也不因为"第三方"的便利而放弃原理理解。生成器背后是对容错、容量、编码模式的完整掌握——工具是 qr_flutter,功夫在原理里。这种"工具可以借,原理必须懂"的态度,比任何一个组件都更能陪你走完整个移动开发生涯。
更多推荐


所有评论(0)