引子:二维码的黑白方块从哪来

扫码支付、加好友、打开链接——二维码每天都在用。但大多数人不知道二维码是怎么生成的:输入一段文字,输出一堆黑白方块,中间经历了什么?

这个二维码生成器是工具箱里技术含量最高的工具。代码不到 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) 把数字 vn 位二进制表示,逐位推入数组。比如 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 的绘图上下文,提供 fillRectfillStyle 等方法。.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 叠加、保存到相册、扫描功能等内容。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐