简介:面向C# WPF开发者的二维码生成与识别示例工程,基于zxing.net(zxing.dll)完成QR码编码,并引入EPFMediaKit.dll辅助摄像头/图像处理,解决桌面应用快速集成二维码交互的需求。压缩包体积22.48MB,共148个文件,以dll依赖库、cs源码、xaml界面布局、sln工程文件为主,附带pdb调试符号、xml注释、config配置与packages.config依赖声明,目录结构完整,可直接在Visual Studio中打开运行。已有1002人学习下载。示例不仅演示了BarcodeWriter创建二维码并绑定Image控件,还给出了从图像或摄像头读取识别码的参考思路;通过阅读MainWindow相关代码和xaml布局,可快速掌握WPF下的UI绑定、按钮交互与二维码模块集成方式,并借鉴项目中的错误处理与工程组织方法,便于迁移到自己的应用中。
1. 这个 demo 解决什么问题
C# WPF 桌面端接入二维码功能,最大的障碍往往不是业务逻辑,而是 ZXing 与 WPF 图像体系之间的适配。ZXing 的原始 API 面向 Java 和 WinForms 设计,到了 WPF 里,输出要对齐 WriteableBitmap,输入要做 BitmapSource 到 System.Drawing.Bitmap 的像素拷贝,每一步都有版本兼容的坑。这个 demo 把二维码的生成和识别两条链路完整跑通:生成侧配置 ZXing.Net 的 BarcodeWriter,输出 WriteableBitmap 直接绑定界面上的 Image;识别侧支持本地图片和剪贴板两个入口,解码后返回文本内容与定位点坐标。适合做桌面工具、需要给业务单据加二维码标签或者做扫码录入的开发者,直接照抄关键方法就能落地,不用再花一个下午去翻 ZXing 的英文文档。
2. 技术选型与项目结构:ZXing.Net 在 WPF 中的落地姿势
2.1 为什么选 ZXing.Net 而不是其他库
.NET 生态里二维码库的现状是这样的:QRCoder 简洁但只管生成、不支持识别,做“扫码录入”这类功能还得另找解码方案;ThoughtWorks.QRCode 是前些年的老库,到今天已经没有维护,在 .NET 6/8 上编译横竖都是问题。ZXing.Net 是 ZXing 的 .NET 移植版,NuGet 包名很直白,搜 ZXing.Net 就能拿到。它从生成到识别都有统一的抽象,不只支持 QRCode,还覆盖 Code128、EAN-13、DataMatrix 等几十种条码格式,同一个 BarcodeFormat 枚举在生成和解码两侧共用。
选型的时候我一般会先确认一件事:这个库在 WPF 下有没有现成的渲染器。ZXing.Net 官方带了一个 WriteableBitmapRenderer,所以 BarcodeWriter 可以直接泛型实例化为 BarcodeWriter ,输出就是 WPF 原生位图类型,不需要自己再去把 Bitmap 转一遍 BitmapSource。这个 demo 虽然只用了 QR_CODE 一种格式,但枚举的扩展点留在那里,后续想做商品条码扫描工具也不用换库。
2.2 项目目录结构与分层
拆开 demo 源码后,合理的组织方式是这样的:界面事件只做编排,把 ZXing 相关的初始化、参数配置全部收进 Service 层,避免 MainWindow.xaml.cs 里堆一两百行库代码。目录结构如下:
QrCodeDemo/ └─ QrCodeDemo/ ├─ App.xaml 应用入口,统一资源与窗口配置 ├─ MainWindow.xaml 主窗体:左侧生成区 + 右侧识别区 ├─ MainWindow.xaml.cs 事件处理,只负责调用 Service、刷新界面 ├─ Services/ │ ├─ QrCodeEncoder.cs 生成封装,BarcodeWriter 与二维码参数集中管理 │ └─ QrCodeDecoder.cs 识别封装,统一文件/剪贴板解码入口 ├─ Extensions/ │ └─ BitmapConverter.cs BitmapSource 与 System.Drawing.Bitmap 互转 └─ Models/ └─ QrDecodeResult.cs 识别结果模型,含文本、格式、定位点这个分层的价值在你二次开发的时候才会体现出来。比如你要加一个“批量生成工单二维码”的功能,只需要在某个 ViewModel 里循环调用 QrCodeEncoder.Generate,不需要再碰任何 ZXing 代码;要换解码策略,也只需要改 QrCodeDecoder 一个类。demo 规模小,不强上 MVVM 框架,但至少做到“界面不直接依赖第三方库”,这是我拿到源码后第一个会检查的点。
2.3 主窗体布局与两条调用链路
主窗体是标准的左右分栏,左侧输入内容生成二维码,右侧打开图片或读剪贴板做识别。XAML 里关键的几个控件如下:
<Grid Margin="12"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <GroupBox Header="二维码生成" Padding="8"> <StackPanel> <TextBox x:Name="txtContent" Height="80" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto"/> <Button x:Name="btnGenerate" Content="生成" Click="BtnGenerate_Click" HorizontalAlignment="Left" Padding="16,6" Margin="0,8,0,0"/> <Image x:Name="imgQrCode" Width="240" Height="240" Stretch="Uniform" RenderOptions.BitmapScalingMode="NearestNeighbor" Margin="0,12,0,0"/> <Button x:Name="btnSave" Content="保存 PNG" Click="BtnSave_Click" HorizontalAlignment="Left" Padding="16,6" Margin="0,8,0,0"/> </StackPanel> </GroupBox> <GroupBox Header="二维码识别" Grid.Column="1" Padding="8"> <StackPanel> <Button x:Name="btnOpenImage" Content="打开图片识别" Click="BtnOpenImage_Click" HorizontalAlignment="Left" Padding="16,6"/> <Button x:Name="btnDecodeClipboard" Content="从剪贴板识别" Click="BtnDecodeClipboard_Click" HorizontalAlignment="Left" Padding="16,6" Margin="0,8,0,0"/> <TextBox x:Name="txtResult" Height="80" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" IsReadOnly="True"/> </StackPanel> </GroupBox> </Grid>生成链路是:点击生成按钮 → QrCodeEncoder.Generate 返回 WriteableBitmap → 赋值给 imgQrCode.Source;识别链路是:打开图片文件 → BitmapImage 加载 → 转成 System.Drawing.Bitmap → BarcodeReader.Decode → 结果回填到 txtResult。两条链路都不经过磁盘中间文件,数据全在内存里流动,这也是这个 demo 比网上那些“生成图片保存到临时目录再读回来识别”的示例干净的地方。
3. 生成模块拆解:从内容字符串到可保存的二维码图片
3.1 QrCodeEncoder 的封装与关键参数
生成侧的入口是 Services/QrCodeEncoder.cs,核心代码不长,但每一行参数都有自己的讲究:
// Services/QrCodeEncoder.cs using ZXing; using ZXing.QrCode; using System.Windows.Media.Imaging; public class QrCodeEncoder { private readonly BarcodeWriter<WriteableBitmap> _writer; public QrCodeEncoder() { _writer = new BarcodeWriter<WriteableBitmap> { Format = BarcodeFormat.QR_CODE, Options = new QrCodeEncodingOptions { Width = 360, Height = 360, Margin = 1, CharacterSet = "UTF-8", ErrorCorrection = ZXing.QrCode.Internal.ErrorCorrectionLevel.M, QrVersion = 0 } }; } public WriteableBitmap Generate(string content) { if (string.IsNullOrWhiteSpace(content)) throw new ArgumentException("二维码内容不能为空", nameof(content)); return _writer.Write(content); } }这里最关键的是泛型参数 WriteableBitmap,它决定了 ZXing 输出哪种位图。BarcodeWriter 会根据泛型类型自动选择对应的渲染器,在 WPF 项目里用它输出 WriteableBitmap,可以直接赋给 Image.Source,不需要额外的类型转换。
参数层面的几个取值,我整理成一张表方便你对照调整:
| 参数 | 作用 | demo 取值 | 什么时候需要改 |
|---|---|---|---|
| Width / Height | 输出位图的像素尺寸 | 360 | 需要打印大标签时按比例加大,比如 600 |
| Margin | 静区宽度,单位是“模块”而不是像素 | 1 | 要贴在包装上时建议加到 2,防止裁切 |
| CharacterSet | 内容编码方式 | UTF-8 | 内容只含纯 ASCII 时无所谓,含中文必须为 UTF-8 |
| ErrorCorrection | 容错等级 L/M/Q/H | M | 打印在易磨损介质上时改成 H |
| QrVersion | 指定二维码版本,0 表示自动选择 | 0 | 一般不动,交给库自动算 |
ErrorCorrection 是容错等级,从低到高是 L、M、Q、H,对应约 7%、15%、25%、30% 的损毁容忍度。等级越高,二维码图案越密,但抗污抗折能力越强。demo 里取 M 是日常屏幕显示和普通打印的平衡点,如果扫码场景是包装箱、标签纸这种容易脏的环境,直接改 H。
3.2 生成按钮的调用链与界面绑定
按钮事件里的逻辑很薄,就是把文本交给 Encoder,把返回的 WriteableBitmap 丢给 Image:
private void BtnGenerate_Click(object sender, RoutedEventArgs e) { try { var qr = _encoder.Generate(txtContent.Text); imgQrCode.Source = qr; btnSave.IsEnabled = true; } catch (ArgumentException ex) { MessageBox.Show(ex.Message, "输入检查"); } catch (Exception ex) { MessageBox.Show($"生成失败:{ex.Message}", "提示"); } }WriteableBitmap 继承自 BitmapSource,所以它本身就是 WPF 图像链路上的一等公民,赋给 Image.Source 不需要再转成 BitmapImage。有一点值得注意:WriteableBitmap 默认是“可写”状态,所以除非你要跨线程共享这个位图,否则不要调用 Freeze();一旦冻结,后续想再改像素就会抛 InvalidOperationException。
这里我把异常分开了两层处理,ArgumentException 是业务校验,其他异常归为系统异常。实际项目里不要直接吞掉异常,至少要先记日志再弹窗,方便用户把错误信息带给你。
3.3 保存为 PNG 文件的实现
生成二维码之后下一步通常是落盘,WPF 自带的 PngBitmapEncoder 就够了,不用引入 System.Drawing 的 Save 方法:
private void BtnSave_Click(object sender, RoutedEventArgs e) { var dlg = new SaveFileDialog { Filter = "PNG 图片|*.png", FileName = $"QR_{DateTime.Now:yyyyMMddHHmmss}.png" }; if (dlg.ShowDialog() != true) return; var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create((BitmapSource)imgQrCode.Source)); using (var fs = new FileStream(dlg.FileName, FileMode.Create)) { encoder.Save(fs); } }这里选择 PNG 而不是 JPEG,是因为二维码是二值图像,PNG 的无损压缩能完整保留模块边缘,JPEG 的压缩反而会在黑白边界上产生振铃效应,影响扫码枪识别。从 Image.Source 拿位图而不是自己再存一份 WriteableBitmap,是为了保证保存的图和用户看到的是同一份数据,避免“预览正常、存下来花掉”这种诡异问题。
4. 识别模块拆解:从图片文件到识别结果的完整链路
4.1 绕不开的类型转换:BitmapSource 到 System.Drawing.Bitmap
识别侧第一道坎是类型转换。ZXing.Net 的 BarcodeReader.Decode 方法接受的是 System.Drawing.Bitmap、RGBLuminanceSource 或原始像素数组,它不认识 WPF 的 BitmapSource。所以在 WPF 项目里必须先做一个像素拷贝,把 BitmapSource 的像素拷进一个 System.Drawing.Bitmap 的缓冲区。这里封装了一个扩展方法:
// Extensions/BitmapConverter.cs using System.Drawing; using System.Drawing.Imaging; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; public static class BitmapConverter { public static Bitmap ToGdiBitmap(this BitmapSource source) { // 统一转成 Bgra32,保证后面按 4 字节/像素计算 stride 不会出错 var converted = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0); int width = converted.PixelWidth; int height = converted.PixelHeight; int stride = width * 4; var bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb); var data = bmp.LockBits( new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); converted.CopyPixels( new Int32Rect(0, 0, width, height), data.Scan0, height * stride, stride); bmp.UnlockBits(data); return bmp; } }逻辑拆开讲三步:先通过 FormatConvertedBitmap 把任意来源的 BitmapSource 统一转成 Bgra32,这一步解决了 InteropBitmap、灰度图、不同 DPI 带来的像素格式差异;然后 new 一个 Format32bppArgb 的 Bitmap,它的内部像素布局和 Bgra32 完全对应;最后用 LockBits 锁定内存区,把 data.Scan0 这个首地址交给 CopyPixels,WPF 的像素缓冲就被直接写进 Bitmap 的内存里。UnlockBits 必须在 CopyPixels 之后调用,否则数据没有真正落地。
这段代码里最值得留意的是 stride 的计算。stride 是 bitmap 每行像素占用的字节数,Bgra32 每像素固定 4 字节,所以 width 乘 4 就是 stride。如果你在网上抄到“height 乘 data.Stride”的写法,那要求源图像的 stride 和目标完全一致,一旦源是灰度图就会踩坑。统一先转 Bgra32 是最稳的路径。
4.2 文件识别的完整流程
文件识别流程要处理两个问题:一是图片加载不能锁文件,二是解码参数要配置到适合二维码识别的状态。完整实现如下:
// Services/QrCodeDecoder.cs using ZXing; using ZXing.Common; public class QrCodeDecoder { public QrDecodeResult DecodeFile(string filePath) { var bitmapImage = new BitmapImage(); using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read)) { bitmapImage.BeginInit(); bitmapImage.CacheOption = BitmapCacheOption.OnLoad; bitmapImage.StreamSource = fs; bitmapImage.EndInit(); } // 走到这里文件流已经释放,图片数据完整驻留内存 using (var bmp = bitmapImage.ToGdiBitmap()) { return DecodeCore(bmp); } } private QrDecodeResult DecodeCore(Bitmap bmp) { var reader = new BarcodeReader { Options = new DecodingOptions { TryHarder = true, PossibleFormats = new List<BarcodeFormat> { BarcodeFormat.QR_CODE } } }; var result = reader.Decode(bmp); if (result == null) return null; return new QrDecodeResult { Text = result.Text, FormatName = result.BarcodeFormat.ToString(), Points = result.ResultPoints }; } }BitmapImage 加载这段有两个细节值得展开。CacheOption 设置为 OnLoad,意思是 EndInit 时立刻把图像完全解码进内存,而不是等 Image 控件渲染时才读取。加上 StreamSource 直接接 FileStream,EndInit 之后 using 块退出,文件流被释放,文件句柄也就释放了。如果你用 UriSource 加载,Windows 下会持有文件锁,识别完想删图或改名就会报“文件被占用”。
DecodingOptions 里的 TryHarder 是一个很有用的选项。它的作用是让解码器在第一次扫描失败后,继续尝试更多亮度阶梯和旋转角度,对倾斜、偏暗、反光的图片友好很多。代价是耗时大约翻倍,单张图几十毫秒到一两百毫秒不等,桌面交互场景完全可以接受。PossibleFormats 限制成 QR_CODE 后,解码器不需要去匹配条形码的形态,误识别率会低不少,速度也更快。
4.3 剪贴板图片识别
剪贴板识别是桌面工具里被用得很多的功能,用户截图后直接点按钮就能读出二维码,不用先存文件再打开。实现上要处理剪贴板返回的 InteropBitmap:
public QrDecodeResult DecodeFromClipboard() { if (!Clipboard.ContainsImage()) return null; try { var source = Clipboard.GetImage(); // InteropBitmap 的像素格式不稳定,统一走 Bgra32 再解码 var converted = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0); using (var bmp = converted.ToGdiBitmap()) { return DecodeCore(bmp); } } catch (Exception ex) { Debug.WriteLine($"剪贴板取图失败:{ex}"); return null; } }Clipboard.GetImage() 返回的是一个特殊类型,它在内部持有剪贴板数据的引用,像素格式和 DPI 都取决于截图来源,不稳定。直接拿它去做像素拷贝,轻则 stride 不匹配导致图像错位,重则 CopyPixels 抛“参数无效”。所以在进 ToGdiBitmap 之前,先 FormatConvertedBitmap 统一转一遍,后面所有逻辑就和文件识别路径完全一致了。
另外注意一点,如果剪贴板里的图是一张 4K 截图,转换成 Bitmap 再解码的耗时可能接近一秒。这个环节不该放 UI 线程上裸跑。demo 里为了保持简单直接同步调用,你如果要做成产品,至少包一层 Task.Run,解码完成再通过 Dispatcher 回到 UI 线程更新文本框。
5. 常见问题排查:WPF 集成 ZXing 时最容易踩的坑
5.1 Decode 参数类型不匹配,编译直接报错
现象:把 WPF 的 BitmapImage 直接传给 reader.Decode(bitmapImage),编辑器直接标红,提示没有匹配的重载。
原因:ZXing.Net 的 BarcodeReader 只吃 System.Drawing.Bitmap、LuminanceSource 或原始像素数组,不认 BitmapSource 家族。这是两条图像技术栈,一个是 WPF 的 Freezable 体系,一个是 GDI+ 的非托管包装,二者没有隐式转换。
解决:写一个 BitmapConverter 扩展方法做像素拷贝,如 4.1 节所示。注意 System.Drawing.Common 在 .NET 5+ 下要显式添加 NuGet 包引用,WPF Windows 桌面项目可以正常用,但不要忘记在 csproj 里加 PackageReference。
5.2 识别一次后图片文件被占用
现象:程序打开某张图片识别成功,之后在资源管理器里删除或重命名该文件,弹出“文件正在被另一个进程使用”。
原因:BitmapImage 的默认 CacheOption 是 OnDemand,图像数据延迟加载,底层 FileStream 一直被 BitmapImage 持有不释放。如果用的是 Bitmap 构造函数直接读文件,也会因为流没释放导致句柄卡住。
解决:用 FileStream 显式打开文件,设置 CacheOption = BitmapCacheOption.OnLoad,EndInit 后再退出 using 块。OnLoad 的语义就是图片数据立即解码进内存,流用完就可以扔。
5.3 中文内容生成后扫出来乱码
现象:二维码内容里带中文,比如“工单A001”,用手机扫出来是“G-CON…”或者直接空缺一块。
原因:ZXing 编码字符串时默认走的不是 UTF-8,而是按系统区域的默认编码。解码端手机普遍按 UTF-8 解读字节流,两边不一致就出现乱码或空字符。
解决:生成时在 QrCodeEncodingOptions 里显式设置 CharacterSet = "UTF-8",保证写入二维码的字节流用的是 UTF-8。这里不用自己去 UTF8.GetBytes,ZXing 内部会按 CharacterSet 处理字符串,手动转字节反而会破坏输入的字符串类型约束。
5.4 打印后手机扫不出来
现象:屏幕显示识别正常,打印到标签纸上一扫就失败,用微信和扫码枪都试过。
原因:最常见的两类,一是静区被裁掉,Margin 设为 0 时二维码模块贴边,裁切线或折痕直接吃掉定位角;二是容错等级太低,打印墨迹扩散导致相邻模块粘连,识别器无法区分黑白边界。
解决:Margin 至少设 1,推荐 2;ErrorCorrection 从 M 改成 H。注意 Margin 单位是模块,360 像素的图 Margin=2 大约在四边各留约 25 像素空白。改完重新生成、重新打印,识别率提升非常明显。
5.5 小尺寸二维码放大显示边缘发虚
现象:生成 120×120 的二维码,界面 Image 控件按 240 显示,边缘全是灰阶过渡,扫码偶尔失败。
原因:WPF 默认用线性插值对位图做缩放,二维码是纯黑白二值图,线性插值会在黑白边界产生灰色像素,破坏了二维码的锐利边缘。
解决:要么直接把 Width / Height 设成最终显示尺寸,从源头避免放大;要么给 Image 设置 RenderOptions.BitmapScalingMode="NearestNeighbor",让 WPF 用最近邻采样,保持像素边缘的清晰度。打印场景下建议用第一种,生成多大就是多大,不做任何缩放。
6. 二次开发思路:把 demo 改造成批量识别工具
6.1 批量识别文件夹中的图片
demo 的识别入口是单张图片,改成批量识别只需要在 QrCodeDecoder 外面套一层循环。核心代码可以这样组织:
public async Task BatchDecodeAsync(IEnumerable<string> files, IProgress<BatchProgress> progress) { await Task.Run(() => { var decoder = new QrCodeDecoder(); foreach (var file in files) { var result = decoder.DecodeFile(file); progress.Report(new BatchProgress { FilePath = file, Text = result?.Text, Succeeded = result != null }); } }); }批量场景最大的坑是 UI 卡死。解码单张图几十毫秒,一百张图就是好几秒,放在 UI 线程上会直接失去响应。用 Task.Run 把解码放到线程池,再通过 IProgress 回传进度,界面用 DataGrid 或者 ListBox 绑定结果列表,每处理完一张刷新一行。如果一次要处理上万张图,还需要考虑内存,每张图用完后立即释放,DecodeFile 里的 using 已经保证了这一点。
拿到这份 demo 源码后,第一件事不是打开工程,而是先找到 src 目录下 csproj 里的 PackageReference,确认 ZXing.Net 的版本,再去看 QrCodeEncoder 和 QrCodeDecoder 两个类,把 3.1 和 4.2 的参数表打印出来贴显示器旁边,后面改参数随时对照。
6.2 三个值得继续扩展的方向
第一个方向是摄像头实时识别。用 MediaCapture 或者 AForge.NET 抓帧,每一帧转成 BitmapSource,再走 ToGdiBitmap + DecodeCore 这条链路。注意控制帧率,ZXing 单帧解码耗时 30 到 200 毫秒,实时识别建议用独立线程,解码一帧的同时丢弃后续帧,避免队列堆积。
第二个方向是带 Logo 的二维码。生成后把 BitmapSource 转成 System.Drawing.Bitmap,用 Graphics 把 Logo 画到中心区域。Logo 面积不要超过整个二维码的四分之一,容错等级必须调到 H,不然遮挡的模块会超过纠错能力,扫码直接失败。
第三个方向是失败重试机制。Decode 返回 null 时,把图像旋转 90 度和 180 度再各试一次,手机上拍摄的二维码经常带角度,靠 TryHarder 不一定能拉回来。这是一个很有效的补丁,批量识别的成功率能提升不少。
以前我在某个桌面项目里为了能删掉识别过的图片,把缓存目录整体挪到了临时文件夹,治标不治本;后来把 BitmapCacheOption 的原理搞明白,才发现文件句柄问题从源头就该用 OnLoad 解决。从那以后我每次在 WPF 里碰图像 I/O,都强制走一遍“FileStream + OnLoad → 用完即释放 → 识别完再解绑”这个流程,省下的调试时间远比多敲的几行代码多。希望帮到你。
本文还有配套的精品资源,点击获取