HarmonyOS ArkTS 二维码生成器的编码原理与 Canvas 渲染
引子:二维码的黑白方块从哪来
扫码支付、加好友、打开链接——二维码每天都在用。但大多数人不知道二维码是怎么生成的:输入一段文字,输出一堆黑白方块,中间经历了什么?
这个二维码生成器是工具箱里技术含量最高的工具。代码不到 300 行,但涉及了有限域运算、多项式除法、里德-所罗门纠错、模块矩阵铺设、Canvas 渲染等多个知识点。今天就从代码出发,拆解二维码生成的完整流程。
完整效果

二维码的编码流程
生成二维码分五步:
输入文字 → 字节编码 → 选择版本 → 计算纠错码 → 铺设模块矩阵
每一步都有明确的输入和输出,最终得到一个二维数组,1 表示黑色,0 表示白色。
第一步:字节编码
const data = new Int32Array(text.length);
for (let i = 0; i < text.length; i++) data[i] = text.charCodeAt(i);
把每个字符转成 ASCII 码。比如 “HELLO” → [72, 69, 76, 76, 79]。
为什么用字节模式
QR 码有多种编码模式:数字模式、字母数字模式、字节模式、汉字模式。字节模式最通用,能编码任意字符,虽然效率不是最高,但实现最简单。
对于工具箱这种"输入内容不确定"的场景,字节模式是最佳选择。
第二步:构造比特流
let bits: number[] = [];
const pushBits = (v: number, n: number) => {
for (let i = n - 1; i >= 0; i--) bits.push((v >> i) & 1);
};
pushBits(4, 4); // 模式指示器:0100(字节模式)
pushBits(data.length, 8); // 字符计数:8 位
for (let i = 0; i < data.length; i++) pushBits(data[i], 8); // 数据
pushBits(0, 4); // 终止符:0000

比特流的结构
┌──────────┬──────────┬──────────┬──────────┐
│ 模式指示器 │ 字符计数 │ 数据内容 │ 终止符 │
│ 4 bit │ 8 bit │ N×8 bit │ 4 bit │
└──────────┴──────────┴──────────┴──────────┘
- 模式指示器:
0100表示字节模式 - 字符计数:数据的字节数
- 数据内容:每个字符的 ASCII 码
- 终止符:
0000标记结束
pushBits 的位操作
pushBits(v, n) 把数字 v 用 n 位二进制表示,逐位推入数组。比如 pushBits(72, 4) 把 72(01001000)的低 4 位(1000)推入。
第三步:选择版本
let ver = 0;
const reqBits = 4 + 8 + data.length * 8 + 16;
for (let v = 0; v < 10; v++) {
if (ECC_BLOCK[v][0] * 8 >= reqBits) { ver = v; break; }
}
QR 码有 40 个版本(1-40),版本越大,模块越多,能存的数据越多。这个实现只支持版本 1-10。
版本和模块数的关系
版本号 × 4 + 17 = 模块数
版本 1 是 21×21,版本 2 是 25×25,以此类推。
版本选择的逻辑
计算需要的总比特数(reqBits),然后找第一个能装下的版本。ECC_BLOCK[v][0] * 8 是版本 v 的总数据容量(比特)。
第四步:计算纠错码
这是整个算法最复杂的部分——里德-所罗门(Reed-Solomon)纠错码。
什么是纠错码
QR 码在使用中可能被遮挡、污损、模糊。纠错码的作用是"即使部分数据损坏,也能恢复原始内容"。
M 级纠错能纠正约 15% 的错误。比如一个 25×25 的二维码,被遮住一小块,扫码软件依然能正确识别。
GF(256) 有限域
function gfLog(): Int32Array {
const l = new Int32Array(256);
const a = new Int32Array(256);
let x = 1;
for (let i = 0; i < 256; i++) {
a[i] = x;
l[x] = i;
x <<= 1;
if (x >= 256) x ^= 0x11D;
}
l[1] = 0;
return l;
}
QR 码的纠错码基于 GF(256)——一个有 256 个元素的有限域。在这个域里,加法是异或(XOR),乘法是查表。
为什么用 0x11D
0x11D 是 GF(256) 的不可约多项式 x^8 + x^4 + x^3 + x^2 + 1。当乘法结果超过 255 时,用这个多项式取模,保证结果还在 0-255 范围内。
多项式类
class Poly {
terms: Int32Array;
mul(other: Poly): Poly { ... }
mod(other: Poly): Poly { ... }
}
Poly 表示一个多项式,terms 是系数数组。mul 做多项式乘法,mod 做多项式除法取余。
生成多项式
function rsGenPoly(degree: number): Poly {
const exp = gfExp();
let p = new Poly(new Int32Array([1]));
for (let i = 0; i < degree; i++)
p = p.mul(new Poly(new Int32Array([1, exp[i]])));
return p;
}
生成 degree 阶的里德-所罗门生成多项式。比如 degree=10 时,生成 (x-1)(x-2)...(x-10)。
纠错码的计算
const gen = rsGenPoly(eccg);
for (let i = 0; i < numBlocks; i++) {
const block = dc.slice(i * dcg, (i + 1) * dcg);
const msg = new Int32Array(block.length + eccg);
for (let j = 0; j < block.length; j++) msg[j] = block[j];
const rem = new Poly(msg).mod(gen);
// rem 就是纠错码
}
把数据码字作为多项式的系数,除以生成多项式,余数就是纠错码。
第五步:铺设模块矩阵
功能图案
// 定位图案(三个角)
const fp = [[0,0],[0,size-7],[size-7,0]];
for (let fi = 0; fi < fp.length; fi++) {
const fr = fp[fi][0]; const fc = fp[fi][1];
rect(fr,fc,7,7,1); // 外圈黑色
rect(fr+1,fc+1,5,5,0); // 中间白色
rect(fr+2,fc+2,3,3,1); // 内圈黑色
}
// 时序图案(第6行和第6列)
for (let i = 8; i < size - 8; i++) {
set(6, i, i % 2 === 0 ? 1 : 0);
set(i, 6, i % 2 === 0 ? 1 : 0);
}
定位图案是二维码的"锚点",扫码软件通过它们确定二维码的位置和方向。时序图案是黑白交替的线,用于校准模块大小。
数据铺设
let col = size - 1;
while (col > 0) {
if (col === 6) col--; // 跳过时序图案列
for (let row = size - 1; row >= 0; row--) {
for (let c = col; c >= col - 1; c--) {
if (reserved[row * size + c]) continue;
// 放置数据位
}
}
col -= 2;
}
数据从右下角开始,按"之"字形向上铺设。跳过功能图案区域。
掩码
const m0 = (r: number, c: number) => ((r + c) % 2) === 0;
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (!reserved[r * size + c])
mods[r * size + c] ^= m0(r, c) ? 1 : 0;
掩码的作用是"打散"数据中的连续黑白块,让扫码软件更容易识别。掩码 0 的规则是"行号+列号为偶数时翻转"。
Canvas 渲染
Canvas(this.ctx)
.onReady(() => {
const cell = Math.min(260, 300) / size;
// 白色背景
this.ctx.fillStyle = '#FFFFFF';
this.ctx.fillRect(0, 0, total + 16, total + 16);
// 绘制黑色模块
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (this.qrData![r * size + c] === 1) {
this.ctx.fillStyle = '#000000';
this.ctx.fillRect(8 + c * cell, 8 + r * cell, cell, cell);
}
}
}
})
Canvas 的用法
CanvasRenderingContext2D 是 Canvas 的绘图上下文,提供 fillRect、fillStyle 等方法。.onReady() 在 Canvas 准备好后执行绘制。
cell 的计算
cell = Math.min(260, 300) / size 计算每个模块的像素大小。260 是最大显示宽度,除以模块数得到每个模块的大小。
8px 的边距
绘制时加了 8px 的边距(8 + c * cell),这是 QR 码的"静区"(quiet zone),确保二维码周围有空白,扫码软件能正确识别。
交互流程
输入和生成
TextInput({placeholder:'请输入文字、链接或任意内容…', text: this.inputText})
.onChange((v: string) => { this.inputText = v; })
Button('生成二维码')
.onClick(() => this.generate())
用户输入内容,点击按钮生成。onChange 实时更新输入值。
错误处理
if (text.length === 0) {
this.showToast = true;
this.showResult = false;
return;
}
输入为空时显示错误提示,不执行生成。
结果显示
if (this.showResult && this.qrData !== null) {
Column() {
Canvas(this.ctx) // 渲染二维码
Text(this.inputText) // 显示原始内容
}
}
生成成功后显示二维码和原始文本。
踩坑记录
坑 1:GF(256) 表的重复计算
gfLog() 和 gfExp() 在每次乘法和取模时都会重新计算。可以优化成全局缓存:
let _log: Int32Array | null = null;
let _exp: Int32Array | null = null;
function gfLog(): Int32Array {
if (!_log) { _log = gfLogImpl(); }
return _log;
}
坑 2:版本限制
当前只支持版本 1-10,最多容纳 174 个字节。如果输入超过这个长度,ver 会是 0,导致错误。可以加版本上限判断。
坑 3:Canvas 的 onReady 时机
Canvas 的绘制必须在 .onReady() 里执行,否则上下文还没准备好。如果在 build() 里直接绘制,会报错。
坑 4:静区大小
当前静区是 8px。标准 QR 码要求静区至少 4 个模块宽。对于小版本(21×21),8px 足够;对于大版本,可能不够。
坑 5:掩码的选择
当前固定使用掩码 0。标准 QR 码应该根据数据选择最优掩码(评估 8 种掩码,选得分最低的)。固定掩码可能导致某些数据的扫码效果不好。
代码改进建议
1. 缓存 GF(256) 表
把 gfLog() 和 gfExp() 的结果缓存起来,避免重复计算。
2. 支持更多版本
扩展 ECC_BLOCK 数组,支持版本 11-40,能编码更长的文本。
3. 自动选择最优掩码
实现 8 种掩码的评估函数,自动选择最优掩码。
4. 带Logo的二维码
在二维码中间加一个 Logo,需要先计算纠错码(Logo会遮挡部分模块),再叠加 Logo。
5. 保存到相册
生成的二维码可以保存到手机相册。可以用 photoAccessHelper 模块。
总结
二维码生成器的核心是"编码流程"——从文字到比特流,从比特流到纠错码,从纠错码到模块矩阵。每一步都有明确的数学原理:字节编码用 ASCII,纠错码用 GF(256) 有限域,模块铺设用之字形扫描,掩码用异或运算。
适用边界:这个部分适合用作 QR 码编码原理的学习案例,涵盖了 GF(256) 运算、多项式除法、Reed-Solomon 纠错、模块矩阵铺设、Canvas 渲染等核心知识点。但如果要上架应用商店,还需要补充更多版本支持、最优掩码选择、Logo 叠加、保存到相册、扫描功能等内容。
更多推荐


所有评论(0)