多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Unity PC端跨平台打印解决方案:从PDF生成到系统调用的完整实践

Unity PC端跨平台打印解决方案:从PDF生成到系统调用的完整实践 1. 项目概述为什么Unity开发者需要关注打印在Unity开发中我们常常将精力集中在渲染管线、物理模拟、UI交互和跨平台发布上打印功能似乎是一个被遗忘的角落。很多开发者尤其是刚接触企业级应用、教育软件、数据可视化或者需要生成实体报告项目的朋友可能会遇到一个棘手的需求如何让我的Unity应用在Windows或macOS的PC端直接调用打印机把游戏内的数据、排行榜、资产清单或者自定义的报表打印出来你可能会想这不是很简单吗用System.Drawing或者调用Windows API不就行了但实际操作过你就会发现Unity的运行时环境、跨平台特性以及安全沙箱让直接调用本地打印接口变得异常复杂。网上能找到的代码片段往往年久失修或者只针对特定版本的Windows在macOS上完全失效更别提处理不同型号打印机的驱动兼容性问题了。这就是为什么一个专门为Unity设计的、稳定可靠的PC端打印解决方案如此重要。它解决的不仅仅是一个“打印”动作而是连接虚拟数字内容与实体物理世界的关键桥梁对于开发工具、后台管理系统、模拟训练报告生成等场景至关重要。2. 核心思路与方案选型绕过沙箱与系统对话Unity本身并没有提供官方的打印API这是因为其核心定位是实时内容创作而非办公自动化。因此我们的核心思路必须是如何让Unity应用程序与宿主操作系统Windows/macOS的打印子系统进行安全、可靠的通信。经过多年的项目实践主流且稳定的方案可以归纳为以下三种每种都有其适用的场景和需要避开的“坑”。2.1 方案一进程调用与命令行控制最通用这是最灵活、跨平台兼容性相对最好的方法。核心思想是Unity不直接处理打印而是生成一个可打印的文件如PDF、图片然后启动一个外部进程调用系统默认的打印命令或工具来打印这个文件。在Windows上我们可以使用System.Diagnostics.Process启动printto命令。例如如果你已经生成了一份PDF报告可以这样操作using System.Diagnostics; public void PrintPDFOnWindows(string pdfFilePath) { // 安全检查文件是否存在 if (!System.IO.File.Exists(pdfFilePath)) { Debug.LogError($打印文件不存在: {pdfFilePath}); return; } try { Process process new Process(); // 核心使用 rundll32 调用 SHELL32 的 PrintTo 功能 process.StartInfo.FileName rundll32.exe; // 参数格式 \SHELL32.DLL,ShellExec_RunDLL\ \文件路径\ \PrintTo\ \打印机名称\ // 如果打印机名称为空则使用默认打印机 string printerName ; // 留空使用默认打印机或指定如 \Microsoft Print to PDF\ process.StartInfo.Arguments $\SHELL32.DLL,ShellExec_RunDLL\ \{pdfFilePath}\ \PrintTo\ \{printerName}\; process.StartInfo.UseShellExecute false; process.StartInfo.CreateNoWindow true; // 不创建命令行窗口 process.Start(); } catch (System.Exception e) { Debug.LogError($打印进程启动失败: {e.Message}); } }注意rundll32和PrintTo是Windows的“传统艺能”虽然稳定但在某些高安全策略的系统上可能受限。CreateNoWindow设置为true很重要否则会闪出一个黑框用户体验很糟糕。在macOS上我们则使用lpr行式打印机命令。macOS和Linux系统通常更友好地支持命令行打印。public void PrintPDFOnMac(string pdfFilePath) { if (!System.IO.File.Exists(pdfFilePath)) { Debug.LogError($打印文件不存在: {pdfFilePath}); return; } try { Process process new Process(); process.StartInfo.FileName /usr/bin/lpr; // -o landscape 可以设置横向打印 -P 指定打印机 process.StartInfo.Arguments $-o fit-to-page \{pdfFilePath}\; process.StartInfo.UseShellExecute false; process.StartInfo.RedirectStandardError true; process.StartInfo.CreateNoWindow true; process.Start(); string error process.StandardError.ReadToEnd(); process.WaitForExit(); if (!string.IsNullOrEmpty(error)) { Debug.LogWarning($打印命令返回警告: {error}); } } catch (System.Exception e) { Debug.LogError($Mac打印失败: {e.Message}); } }这个方案的优劣分析优点实现相对简单依赖系统原生能力无需引入额外的原生插件跨平台逻辑清晰。缺点文件中间态必须先生成物理文件涉及磁盘I/O有性能开销和文件清理问题。权限与路径应用程序需要有对目标目录的写权限并且文件路径不能包含特殊字符或中文尤其在Windows上否则命令执行会失败。错误处理粗糙进程调用的错误信息捕获比较间接很难判断是“打印机缺纸”还是“驱动错误”。2.2 方案二使用系统原生打印对话框用户体验佳如果我们希望用户能在打印前进行一些设置比如选择打印机、设置份数、纸张方向等那么调用系统原生的打印对话框是最佳选择。这通常需要平台特定的原生插件。对于Windows我们可以通过编写一个C的DLL调用Win32 API中的PrintDlg函数然后将这个DLL作为插件导入Unity。Unity C#脚本通过[DllImport]来调用其中的函数。// C# 侧定义 using System.Runtime.InteropServices; public class WindowsNativePrint { [DllImport(YourPrintPlugin.dll)] private static extern bool ShowPrintDialog(byte[] documentData, int dataSize); public static bool PrintBytes(byte[] pdfData) { return ShowPrintDialog(pdfData, pdfData.Length); } }C DLL内部则比较复杂需要处理PRINTDLG结构体、设备上下文DC、以及将传入的数据如PDF字节流通过GDI等方式渲染并发送给打印机。这是一个工程量较大的方案但能提供最专业的打印体验。对于macOS可以通过Objective-C/C插件调用AppKit框架中的NSPrintOperation和NSPrintInfo。同样需要编写桥接代码。这个方案的优劣分析优点提供完整的、用户熟悉的打印交互体验支持所有高级打印选项。缺点开发复杂度极高需要深厚的Windows/macOS原生开发知识调试困难。维护成本高需要为每个平台单独开发和维护插件且随着操作系统更新可能需要适配。二进制依赖插件DLL/SO文件需要随项目分发增加包体和管理复杂度。2.3 方案三依赖第三方库生成打印指令专业定向对于一些特定类型的打印机如热敏小票打印机喵喵蓝牙打印机等、标签打印机它们往往有自己专用的指令集ESC/POS、CPCL、ZPL等。这时方案的核心就变成了在Unity中生成这些原始打印指令然后通过串口、USB或网络发送给打印机。例如打印一张简单的小票// 模拟 ESC/POS 指令 string escPosCommands ; escPosCommands \x1b; // 初始化打印机 escPosCommands \x1b!\x38; // 设置字体加倍高宽 escPosCommands 销售单 \n; escPosCommands \x1b!\x00; // 恢复标准字体 escPosCommands 商品测试商品 x1\n; escPosCommands 金额10.00元\n; escPosCommands \x1d\x56\x41\x00; // 切纸 // 将指令字符串转换为字节流 byte[] printData System.Text.Encoding.GetEncoding(GB18030).GetBytes(escPosCommands); // 注意中文编码 // 然后通过 System.IO.Ports.SerialPort (串口) 或 TCP/IP 发送 printData这个方案的优劣分析优点直接控制打印机无需驱动响应速度快特别适合嵌入式或专用设备集成。缺点极度专业化代码与打印机型号/指令集强绑定通用性差。硬件接口依赖需要处理串口、USB HID或网络通信在Unity中这些接口的支持并不完善通常也需要插件。无预览直接发送指令用户无法预览打印效果。综合建议对于大多数需要通用文档打印的Unity PC项目方案一进程调用是性价比最高的起点。它平衡了开发难度、跨平台需求和功能性。本博文后续的实操也将围绕方案一展开并解决其中遇到的各种实际问题。3. 核心实现从UI到纸张的完整链路理解了方案我们来搭建一个从Unity UI界面点击按钮到最终纸张输出的完整、健壮的打印模块。我们将采用方案一并重点解决PDF生成、跨平台适配和错误处理。3.1 第一步生成可打印内容——PDF是关键中间件既然要调用系统命令打印我们需要一个通用的打印格式。图片PNG/JPG虽然简单但文字清晰度差且无法包含多页信息。PDF是事实上的标准。Unity本身不生成PDF我们需要借助第三方库。一个强大的选择是PDFSharp或它的跨平台版本MigraDoc Foundation。我们可以将它的DLL针对.NET Standard 2.0编译的放入Unity的Plugins文件夹。然后我们可以编写一个类来将Unity中的文本、表格甚至简单图形转换为PDF文档。// 示例使用 PdfSharp 生成一个简单的PDF using PdfSharp.Pdf; using PdfSharp.Drawing; using System.IO; public class PdfReportGenerator { public string CreateSimplePdf(string content, string savePath) { // 创建新PDF文档 PdfDocument document new PdfDocument(); // 添加一页 PdfPage page document.AddPage(); XGraphics gfx XGraphics.FromPdfPage(page); // 创建字体 XFont font new XFont(Microsoft YaHei, 12, XFontStyle.Regular); // 注意中文字体 // 绘制文本 gfx.DrawString(Unity打印测试报告, new XFont(Microsoft YaHei, 20, XFontStyle.Bold), XBrushes.Black, new XRect(0, 40, page.Width, page.Height), XStringFormats.TopCenter); gfx.DrawString($生成时间{System.DateTime.Now}, font, XBrushes.Black, 50, 80); gfx.DrawString(content, font, XBrushes.Black, 50, 120); // 画一条线 gfx.DrawLine(XPens.Black, 50, 110, page.Width - 50, 110); // 保存文件 document.Save(savePath); document.Close(); return savePath; } }实操心得1字体陷阱。在非Windows系统上Microsoft YaHei字体可能不存在。稳妥的做法是1将字体文件.ttf作为资源放入StreamingAssets运行时加载到内存并传递给PDF库如果库支持2使用更通用的字体别名如Arial但可能不支持中文。这是一个需要根据项目部署环境仔细测试的点。3.2 第二步构建跨平台打印管理器这是核心桥梁类它需要判断当前操作系统调用不同的打印逻辑并处理文件生成和清理。using UnityEngine; using System.IO; using System.Diagnostics; using System.Runtime.InteropServices; // 用于判断平台 public class CrossPlatformPrintManager : MonoBehaviour { // 单例模式便于访问 private static CrossPlatformPrintManager _instance; public static CrossPlatformPrintManager Instance { get { if (_instance null) { GameObject go new GameObject(PrintManager); _instance go.AddComponentCrossPlatformPrintManager(); DontDestroyOnLoad(go); } return _instance; } } // 临时文件目录 private string _tempPrintFolder; void Awake() { // 在持久化数据路径下创建临时打印文件夹 _tempPrintFolder Path.Combine(Application.persistentDataPath, TempPrint); if (!Directory.Exists(_tempPrintFolder)) { Directory.CreateDirectory(_tempPrintFolder); } // 可选定期清理旧文件例如启动时清理1小时前的文件 CleanOldTempFiles(3600); } /// summary /// 打印文本内容 /// /summary public void PrintText(string content, string jobName UnityPrintJob) { // 1. 生成PDF PdfReportGenerator generator new PdfReportGenerator(); string tempPdfPath Path.Combine(_tempPrintFolder, ${jobName}_{System.DateTime.Now:yyyyMMddHHmmss}.pdf); string finalPdfPath generator.CreateSimplePdf(content, tempPdfPath); // 2. 根据平台调用打印 if (Application.platform RuntimePlatform.WindowsPlayer || Application.platform RuntimePlatform.WindowsEditor) { PrintWithWindows(finalPdfPath); } else if (Application.platform RuntimePlatform.OSXPlayer || Application.platform RuntimePlatform.OSXEditor) { PrintWithMac(finalPdfPath); } else if (Application.platform RuntimePlatform.LinuxPlayer) { PrintWithLinux(finalPdfPath); } else { Debug.LogError($不支持的打印平台: {Application.platform}); } // 3. 延迟清理文件可选建议在打印成功后进行 // StartCoroutine(DeleteFileAfterDelay(finalPdfPath, 10f)); } private void PrintWithWindows(string filePath) { // 使用上文提到的 rundll32 方法 // 更健壮的做法先尝试用默认程序打开然后发送打印快捷键CtrlP但这更复杂且不稳定。 // 这里提供一个备选方案使用 System.Drawing.Printing (仅限Windows且需要兼容性设置) // 鉴于其复杂性我们仍推荐进程调用。 try { ProcessStartInfo psi new ProcessStartInfo { FileName rundll32.exe, Arguments $\SHELL32.DLL,ShellExec_RunDLL\ \{filePath}\ \PrintTo\ \\, UseShellExecute false, CreateNoWindow true, WindowStyle ProcessWindowStyle.Hidden }; Process.Start(psi); Debug.Log($已发送打印任务到默认打印机: {filePath}); } catch (System.Exception e) { Debug.LogError($Windows打印失败: {e.Message}); // 可以在这里回退到让用户手动打开文件打印 System.Diagnostics.Process.Start(explorer.exe, $/select,\{filePath}\); } } private void PrintWithMac(string filePath) { // 使用 lpr 命令 try { ProcessStartInfo psi new ProcessStartInfo { FileName /usr/bin/lpr, Arguments $\{filePath}\, UseShellExecute false, RedirectStandardError true, CreateNoWindow true }; Process process Process.Start(psi); string error process.StandardError.ReadToEnd(); process.WaitForExit(5000); // 等待最多5秒 if (process.ExitCode ! 0 || !string.IsNullOrEmpty(error)) { Debug.LogError($Mac打印命令错误: ExitCode{process.ExitCode}, Error{error}); } else { Debug.Log($Mac打印任务已发送: {filePath}); } } catch (System.Exception e) { Debug.LogError($Mac打印失败: {e.Message}); // Mac回退方案用预览打开 System.Diagnostics.Process.Start(open, $\{filePath}\); } } private void PrintWithLinux(string filePath) { // Linux通常也使用lpr或lp命令 try { ProcessStartInfo psi new ProcessStartInfo { FileName /usr/bin/lp, Arguments $\{filePath}\, UseShellExecute false, RedirectStandardError true, CreateNoWindow true }; Process.Start(psi); } catch { // 尝试lpr try { ProcessStartInfo psi new ProcessStartInfo { FileName /usr/bin/lpr, Arguments $\{filePath}\, UseShellExecute false, CreateNoWindow true }; Process.Start(psi); } catch (System.Exception e) { Debug.LogError($Linux打印失败: {e.Message}); } } } private void CleanOldTempFiles(int secondsOld) { try { DirectoryInfo dir new DirectoryInfo(_tempPrintFolder); foreach (FileInfo file in dir.GetFiles()) { if ((System.DateTime.Now - file.LastWriteTime).TotalSeconds secondsOld) { file.Delete(); } } } catch { /* 忽略清理错误 */ } } }实操心得2路径与空格。文件路径中的空格和特殊字符是命令行的大敌。始终用双引号将完整路径包裹起来如\{filePath}\。Application.persistentDataPath在不同平台路径不同但通常是安全的可写目录。3.3 第三步Unity中的调用与UI集成最后我们创建一个简单的UI来触发打印。using UnityEngine; using UnityEngine.UI; public class PrintDemoUI : MonoBehaviour { public InputField contentInputField; public Button printButton; void Start() { printButton.onClick.AddListener(OnPrintButtonClicked); } void OnPrintButtonClicked() { if (string.IsNullOrEmpty(contentInputField.text)) { Debug.LogWarning(打印内容为空); return; } // 调用打印管理器 CrossPlatformPrintManager.Instance.PrintText(contentInputField.text, MyUnityPrint); // 可以给用户一个提示例如“打印任务已发送” } }4. 深入问题排查与性能优化即使代码写完了在实际部署中你一定会遇到各种问题。下面是我在多个项目中总结的“血泪”经验。4.1 常见错误与解决方案速查表问题现象可能原因排查步骤与解决方案Windows上无任何反应不报错1.rundll32命令参数错误或路径问题。2. 系统默认打印机设置异常或驱动问题错误0x00000709、0x00000bcb常见。3. 安全软件拦截。1.日志输出在Process.Start前后添加详细日志检查文件路径是否被正确引用。2.手动测试在CMD中手动执行拼接好的命令看是否弹出打印对话框或错误。3.检查打印机去系统“设置”-“打印机和扫描仪”确认默认打印机在线且无感叹号警告。尝试打印一个记事本文件测试系统打印是否正常。4.以管理员身份运行某些系统策略下需要以管理员权限运行Unity构建出的exe。Mac/Linux上命令执行失败1.lpr/lp命令不存在或路径不对。2. 文件权限不足。3. CUPS打印服务未运行或配置错误。1.检查命令在终端输入which lpr和which lp确认路径。Unity中应使用绝对路径/usr/bin/lpr。2.检查文件权限确保生成的PDF文件其他用户有读权限chmod。3.检查CUPS在终端输入lpstat -p查看打印机状态。使用lpadmin配置打印机。打印内容乱码尤其是中文1.PDF生成时字体缺失。2.打印机驱动语言设置不对。3. 直接发送ESC/POS指令时编码错误。1.嵌入字体在生成PDF时将中文字体文件.ttf作为资源嵌入到PDF中。PdfSharp支持此功能。2.系统编码确保字符串转换为字节流时使用正确的编码如GB18030中文简体或UTF-8。3.打印机设置在打印机属性中将默认语言改为与发送数据匹配的编码。生成PDF文件成功但打印出来是空白1. PDF内容坐标超出页面范围。2. 使用的颜色空间打印机不支持如用了RGB白色背景但打印机默认为透明。3. PDF版本过高打印机驱动无法解析。1.预览PDF先用Acrobat Reader等软件打开生成的PDF确认内容可见。2.检查坐标确认绘制内容的Y坐标没有为负或大于页面高度。3.简化PDF尝试生成一个只有黑色文字、无复杂图形的PDF测试。4.设置PDF版本在PdfSharp创建文档时设置一个较低的兼容版本如Pdf 1.4。在编辑器里正常打包后失败1. 依赖的DLL如PdfSharp未正确包含在构建中。2. 临时文件路径在打包后权限不足。3. 目标平台如macOS ARM64的插件兼容性问题。1.检查Plugins文件夹确保第三方DLL放在Assets/Plugins/[平台]下并设置正确的导入设置如x86/x64。2.使用Application.persistentDataPath这是打包后唯一保证可写的目录不要用Application.dataPath。3.测试独立构建在目标平台上进行充分的构建后测试编辑器环境和真实环境差异很大。打印队列卡住任务无法取消进程调用后打印任务卡在Windows打印队列中显示“错误 - 正在打印”。1.重启打印服务在服务中重启Print Spooler。2.清空打印队列删除C:\Windows\System32\spool\PRINTERS目录下的所有文件。3.代码层面在调用打印后可以尝试延迟获取并关闭创建的进程句柄但效果有限。更可靠的是提示用户去系统打印队列管理。4.2 性能优化与进阶技巧异步操作与用户体验生成PDF和调用外部进程都是耗时操作会阻塞主线程。务必使用协程Coroutine或异步任务async/await需.NET 4.x来避免界面卡顿并显示一个“正在生成打印任务...”的提示。public void PrintTextAsync(string content) { StartCoroutine(PrintTextCoroutine(content)); } IEnumerator PrintTextCoroutine(string content) { UIManager.ShowLoading(正在准备打印...); // 在后台线程生成PDF简化示例实际需用ThreadPool或Task string pdfPath ; yield return new WaitForBackgroundTask(() { pdfPath pdfGenerator.CreateComplexPdf(content); }); UIManager.UpdateLoading(正在发送到打印机...); // 调用打印 if (Application.platform RuntimePlatform.WindowsPlayer) { PrintWithWindows(pdfPath); } // ... 其他平台 yield return new WaitForSeconds(1); // 给系统一点时间处理 UIManager.HideLoading(); }打印任务队列如果用户快速连续点击打印可能会生成大量临时文件和进程。实现一个简单的打印队列管理器将任务序列化处理确保同一时间只有一个打印任务在执行。内容模板化对于格式固定的报告如发票、成绩单不要每次都从头用代码“画”PDF。可以预先设计好PDF模板使用工具生成一个背景PDF然后在代码中只向特定位置填充文本和图片。PdfSharp支持读取现有PDF并修改这比完全从头创建要高效和稳定得多。直接打印纹理如果只是打印游戏内的一张截图或UI可以跳过PDF直接将Texture2D保存为图片文件PNG然后调用系统命令打印图片。Windows上可以用mspaint命令但兼容性不如PDF。更推荐先转PDF因为PDF对文字和矢量图形的支持更好。5. 针对特定场景的扩展思路基础的文档打印满足后我们可以看看更复杂的场景。场景一打印3D模型截图或示意图你可以使用ScreenCapture.CaptureScreenshot或Camera.Render到RenderTexture然后将渲染结果保存为图片再嵌入到PDF中。甚至可以环绕模型拍摄多张图片生成一个多页的PDF报告。场景二与专用硬件集成如小票打印机这需要放弃通用的系统打印路径。你需要确定通信协议USBHID/CDC、串口COM、网络TCP/IP还是蓝牙寻找或开发原生插件Unity对串口和TCP的支持尚可System.IO.Ports.SerialPortSystem.Net.Sockets但USB和蓝牙通常需要平台特定的插件。在Asset Store可以找到一些成熟的插件。实现指令组装与发送根据打印机手册编写C#类来组装ESC/POS等指令集并通过插件接口发送字节流。务必处理发送缓冲和硬件响应超时。场景三静默打印无对话框方案一本身在Windows上通过PrintTo参数可以实现静默打印到默认打印机。但如果要静默打印到指定打印机且不弹出任何窗口就需要更底层的Win32 API调用如StartDoc,StartPage,EndPage等这回到了方案二的复杂度。一个折中办法是在系统设置里预先配置好一个专用的“打印队列”你的程序始终打印到那个队列。关于网络热词中提到的“打印机错误0x00000709”等这些通常是Windows系统级的打印机配置或驱动问题与Unity代码关系不大。在你的应用中最好的处理方式是捕获异常给出友好的用户指引例如“打印失败可能是默认打印机设置有问题。请检查Windows设置中的打印机状态错误代码0x00000709”。将专业的错误代码反馈给用户比一个笼统的“打印失败”更有助于他们自行搜索解决。最后我想分享一个深刻的体会在Unity中实现打印最难的不是代码本身而是对目标操作系统打印生态的理解和兼容性处理。你写的每一行C#代码最终都要去和Windows的GDI、macOS的CUPS或者打印机的固件指令集打交道。因此充分的测试——尤其是在最终用户的实际环境不同的Windows 10/11版本不同的macOS版本不同的打印机型号下测试——是保证功能可用的唯一途径。从最简单的“打印一段文本”开始逐步增加复杂性并建立完善的日志系统记录下每一次打印操作的文件路径、调用的命令和系统反馈这样当问题出现时你才能快速定位到是PDF生成、文件系统、进程调用还是打印机驱动层面的问题。
返回列表