简介:佳能打印机SDK(V3.03版)面向需要调用佳能打印机能力的软件开发人员,尤其适合从事打印、扫描、复印及设备状态管理功能集成的程序开发者。包内提供接口头文件与动态链接库,便于在C/C++项目中调用打印参数设置、图像扫描、份数与缩放控制、墨盒与纸张状态查询等能力,并配套两份PDF接口手册说明调用方式与错误处理机制,另有说明文本和压缩子包用于补充开发资料。资源共9个文件,以4个dll动态库、2个pdf文档、1个h头文件为主,辅以txt说明和zip压缩包,整体约1.61MB。已有1060人学习,适合希望快速理解SDK组成、对照API文档搭建打印应用原型的开发者参考,也可作为排查设备通信与打印流程问题的资料。对于需要在本机或网络环境中接入佳能打印设备的项目,可据此了解SDK的文件构成与接口调用边界。
1. 一份打印机 SDK 包,拆开之前先想清楚要解决什么
需求往往很具体:业务系统要在标签、小票或者自定义版式上直接出纸,走系统打印驱动改不动版式,也拿不到卡纸、缺纸这类设备状态,于是转向厂商的打印机 SDK。这类包本质是层封装——把设备枚举、任务下发、状态回传、耗材信息做成 DLL 加头文件,让上层代码绕开驱动直接跟设备对话。它适合收银小票、仓储标签、检测报告这种版式固定、量大、必须实时知道成败的场景;偶尔打一份 A4 文档,用系统打印反而更省事。决定这份包能不能用的不是功能列表有多长,而是三件事:进程位宽和 DLL 对不对得上、状态拿不拿得到、异常时有没有可读的错误码。下面按拆包、跑通、排错、进阶的顺序走一遍。
2. 解包先看什么:四个目录与三项环境对位
压缩包解压出来的第一眼通常是十几个文件夹,新手容易从 doc 里的手册正文开始啃,啃两天还没跑起来。我的习惯反过来:先看 sample 的工程属性,再看 bin 和 lib 的位宽,最后才翻手册——而且只翻错误码附录。原因很实际,SDK 包的文档质量参差不齐,但示例工程一定是厂商自己编译过、能跑通的那一份,它里面的附加包含目录、附加库目录、附加依赖项、运行库选项这四项,照抄过来能省掉一半的编译期报错。手册正文讲的是能力边界,错误码附录讲的是你半夜被叫起来时真正要看的东西,两者的优先级不在一个量级上。
2.1 目录分层:哪些是要引用的,哪些只是给你看的
先把解压后的顶层目录过一遍,大多数打印机 SDK 包的骨架都差不多,只是命名略有差异。分清楚「编译期要引用」和「只是参考资料」这两类,能避免把整个包塞进版本库这种笨操作。
| 目录 | 典型内容 | 编译期是否需要 | 备注 |
|---|---|---|---|
| include | .h 头文件 | 需要 | 加进附加包含目录,别复制散落各处 |
| lib | .lib 导入库 | 需要 | 按位宽分目录,x86 与 x64 绝不能混 |
| bin | .dll 运行时 | 运行期需要 | 部署时整目录拷贝,不要只挑一个 |
| sample | 示例工程 | 不需要 | 优先看它的初始化与收尾顺序 |
| doc | 手册、错误码表 | 不需要 | 错误码附录价值高于正文 |
| driver | inf / sys | 视方案而定 | 装系统驱动和走 SDK 是两条路线 |
| tools | 诊断小工具 | 不需要 | 排查端口占用时很好用 |
有一点容易被忽略:bin 目录下的 DLL 往往不止一个。厂商的主 DLL 可能还依赖同目录下的图像处理库、通信库,甚至是某个特定版本的运行库。部署时只把主 DLL 拷到 exe 旁边,开发机能跑、现场机器一启动就报「找不到指定的模块」,这类翻车我见过不止一次。稳妥做法是整个 bin 子目录一起带走。
2.2 位宽对位:三分钟排掉一半的加载失败
位宽不匹配是这类 SDK 最高频的失败原因,而且报错信息极具误导性。32 位进程去加载 64 位 DLL,Windows 抛的是「不是有效的 Win32 应用程序」,HRESULT 0x800700C1;C# 侧则表现为 BadImageFormatException。看到这个错,很多人的第一反应是 DLL 损坏或者下载不完整,重新解压一遍,问题照旧。
与其猜,不如直接读 PE 头。下面这段 PowerShell 不依赖任何第三方工具,把 DLL 拖进去就知道它是几位的:
param([Parameter(Mandatory=$true)][string]$Path) $fs = [System.IO.File]::OpenRead($Path) $br = New-Object System.IO.BinaryReader($fs) # 0x3C 处存放的是 PE 头偏移(e_lfanew) $fs.Seek(0x3C, 'Begin') | Out-Null $peOffset = $br.ReadInt32() # 跳过 4 字节的 "PE\0\0" 签名,读 IMAGE_FILE_HEADER.Machine $fs.Seek($peOffset + 4, 'Begin') | Out-Null $machine = $br.ReadUInt16() switch ($machine) { 0x014c { Write-Output "x86 (32 位)" } 0x8664 { Write-Output "x64 (64 位)" } 0xAA64 { Write-Output "ARM64" } default { Write-Output ("未知架构: 0x{0:X4}" -f $machine) } } $br.Close() $fs.Close()逻辑不复杂:PE 文件在 0x3C 偏移处存了一个 4 字节整数,指向真正的 PE 签名位置;签名占 4 字节,紧跟其后的 2 字节就是 Machine 字段,0x014c 代表 x86,0x8664 代表 x64。参数只有一个-Path,指向你要检查的 DLL 或 EXE。把这个脚本存成check-pe.ps1,之后每接一个新包,先把 bin 下所有 DLL 扫一遍,再把宿主进程的 exe 扫一遍,两边对得上再谈后面的事。
还有个更隐蔽的情况:你的主程序是 64 位,但真正加载 SDK 的不是主程序,而是某个 32 位的插件宿主、某个办公软件的加载项、某个浏览器控件。这时候哪怕你的封装层写得再干净,位宽对不上照样起不来。判断方法是看错误发生时到底是哪个进程在加载 DLL,任务管理器里加一列「平台」就能看清楚。
2.3 运行库与部署路径:开发机能跑不等于现场能跑
开发机装过完整的开发工具链,调试版运行库齐全,所以很多依赖问题在开发机上根本暴露不出来。等你把程序拷到一台刚装完系统的机器上,第一次启动就提示缺少 MSVCP140.dll 或者 VCRUNTIME140.dll,这就是运行库没跟上。
两种处理方式:一是编译时选 MD(动态链接运行库),部署时在目标机装对应版本的运行库安装包,体积小但要额外一步;二是选 MT(静态链接),把运行库打进 exe,省事但体积大、多个模块之间不能共享堆。我一般对交付到客户现场的小工具选 MT,对内部系统选 MD 统一装运行库。选哪一种要和 sample 保持一致,别自己另起一套,混用是堆损坏的经典来源。
DLL 的搜索顺序也值得记一下:exe 所在目录 → 系统目录 → 环境变量 PATH 里的目录。所以 DLL 放 exe 同目录最稳,千万别图省事往系统目录里丢——不同版本的软件互相覆盖,最后谁都跑不起来,这类账很难算清。另外,如果目标机是受管控的环境,杀毒软件可能会拦截未签名 DLL 的加载,表现是加载失败但事件日志里有记录,排查时记得翻一眼系统日志。
3. 打通第一张纸:从设备枚举到出纸的调用链
跑通第一张纸的意义比看上去大得多。它一次性验证了四件事:DLL 能加载、设备能发现、参数能被接受、数据格式没搞错。这四件事任何一个出问题,后面的状态回调、批量队列全是空中楼阁。所以这一章不追求功能完整,追求的是最短路径跑到出纸。
提示:下面的函数名、结构体名以包内头文件为准,各家命名不同但调用范式高度相似——初始化、枚举、打开、设参、开文档、写页、结束文档、关闭、反初始化,这条链基本跑不掉。
3.1 初始化与枚举:句柄是一切的前提
先拿 SDK 句柄,再拿设备句柄,两级句柄别搞混。初始化函数通常要求进程内只调一次,并且必须和反初始化配对,重复初始化的行为各家不一,有的返回「已初始化」,有的直接崩,不值得赌。
#include "printer_sdk.h" // 以包内实际头文件名为准 PRN_HANDLE hSdk = nullptr; // 版本号用头文件里的宏,不要手写数字 int rc = PrnInit(&hSdk, PRN_API_VERSION); if (rc != PRN_OK) { // 初始化失败就直接返回,后面所有调用都没有意义 LogError("PrnInit", rc); return false; } // 第一次传 nullptr 探设备数量,这是这类 SDK 的通用套路 int count = 0; rc = PrnEnumDevices(hSdk, nullptr, &count); if (rc != PRN_OK || count <= 0) { LogError("PrnEnumDevices(count)", rc); PrnUninit(hSdk); // 失败路径也要配对释放 return false; } std::vector<PRN_DEV_INFO> devs(count); rc = PrnEnumDevices(hSdk, devs.data(), &count); // 第二次填数据 if (rc != PRN_OK) { LogError("PrnEnumDevices(fill)", rc); PrnUninit(hSdk); return false; }几个点值得单独说。第一,PrnInit的第二个参数是 API 版本,一定要用头文件里定义的宏,手写一个数字看着没错,实际可能对应另一个结构体布局,运行时表现是参数莫名其妙被截断,非常难查。第二,枚举函数调用两次是行业惯例:第一次只回数量,第二次按数量分配缓冲区再填充。也有 SDK 支持传固定大小的数组并返回实际条数,但如果你的设备数可能超过预设值,两次调用更安全。第三,count在第二次调用里既是入参也是出参,如果中途有设备掉线,返回值会小于你申请的数量,遍历时以返回值为准,不要用vector的size()。
设备信息结构体里通常有设备 ID、型号、当前状态、固件版本几个字段。设备 ID 是后面打开设备要用的,别拿型号当 ID 用——同型号多台设备在仓库场景里太常见了。
3.2 送图前的三个参数:DPI、通道顺序、可打印区
参数设错是「能出纸但出得不对」的主要原因。纸出来了,内容偏了、糊了、颜色反了,八成不是 SDK 的问题,是这三个参数。
| 参数 | 常见取值 | 影响 | 典型误用 |
|---|---|---|---|
| DPI | 203 / 300 / 600 | 决定像素密度与传输耗时 | 用 600 打条码,速度掉一半还没更清晰 |
| 通道顺序 | BGRA / RGB / 单色 | 决定颜色是否颠倒 | Windows 位图是 BGRA,直接当 RGB 送进去 |
| 可打印区 | 各机型固定,常见边缘留 2~4 mm | 决定内容会不会被裁 | 按整张纸排版,边框被切掉 |
DPI 与像素尺寸的换算是必须掌握的:像素宽 = 物理宽(mm)÷ 25.4 × DPI。300 DPI 下要打一张 100 mm 宽的标签,图像宽度应该是 1181 像素左右。如果你送进去的是 800 像素,SDK 要么按比例缩放(内容变形),要么按 1:1 贴(内容只占左边一块),两种结果都不对。
PRN_HANDLE hDev = nullptr; rc = PrnOpenDevice(hSdk, devs[0].szId, &hDev); if (rc != PRN_OK) { LogError("PrnOpenDevice", rc); return false; } // 参数按「键-值」方式设置,键名以头文件宏为准 PrnSetJobParam(hDev, PRN_PARAM_DPI, 300); PrnSetJobParam(hDev, PRN_PARAM_COPIES, 1); PrnSetJobParam(hDev, PRN_PARAM_MARGIN, 0); // 单位通常为 0.1mm PrnStartDoc(hDev, "job-20240101-001"); // bgra 缓冲:width x height,每像素 4 字节,stride 为一行字节数 int stride = width * 4; rc = PrnWritePage(hDev, bgra, width, height, stride); if (rc != PRN_OK) { LogError("PrnWritePage", rc); PrnAbortDoc(hDev); // 中途失败要显式中止,别留着半截任务 PrnCloseDevice(hDev); return false; }stride这个参数容易被当成冗余。它存在的意义是每行字节数可以大于width × 4——有些 SDK 为了对齐会要求 4 字节或 8 字节边界,有些则接受紧凑排列。传错 stride,图像会呈现斜向撕裂,看起来像数据损坏,实际是每行错位。如果出纸结果是斜的,先怀疑 stride。
3.3 收尾顺序:EndDoc 之后别急着 CloseDevice
收尾顺序错了,最典型的现象是「代码返回成功,但打印机没出纸」,或者「第一张正常,第二张丢内容」。原因是任务还在 SDK 内部队列里异步发送,你一句CloseDevice把设备句柄拆了,任务自然被截断。
// 结束文档并等待任务下发完成,超时单位通常为毫秒 rc = PrnEndDoc(hDev, 5000); if (rc != PRN_OK) { LogError("PrnEndDoc", rc); } PrnCloseDevice(hDev); // 确认任务已提交后再关设备 PrnUninit(hSdk); // 与 PrnInit 严格配对PrnEndDoc的第二个参数是超时。这个值不要设得太小,尤其是 600 DPI 的整页图像,几百 KB 到几 MB 的数据走 USB 或网络传过去需要时间;也不要设成无限等待,设备掉线时线程会永远挂在那里。实践中 5000 到 10000 毫秒是常用区间,超出就按失败处理并记录,别静默放弃。
还有一种情况:SDK 提供异步模式,PrnEndDoc立即返回,真正的完成状态要通过回调或轮询获取。这时候CloseDevice必须等完成通知,不能紧接着调用。判断当前是同步还是异步,看头文件里有没有PRN_FLAG_ASYNC之类的标志位,或者看 sample 里是不是在回调里才关设备。这块搞错了,压测的时候会看到零星丢单,复现概率极低,属于玄学级别的问题。
4. 状态回传与异常处理:把「打印没反应」变成可读的错误码
现场最常见的反馈是「打印机没反应」,这句话对排查毫无价值。是把 SDK 接通却没有任何异常反馈,还是 SDK 返回了错误码而没人记录?区别很大。这一章要做的事,就是把设备状态和调用返回值都变成能落到日志里的东西,让每次失败都留下证据。
4.1 回调注册时机与线程模型
状态回调的注册位置有讲究。太早——设备还没打开,回调没有归属句柄;太晚——开文档之后才注册,可能错过第一波状态变化。合理的顺序是:打开设备成功之后、开始文档之前注册,关闭设备之前注销。
// 回调由 SDK 内部线程调用,不要在里做耗时操作 void OnPrinterEvent(const PRN_EVENT* ev, void* user) { if (!ev || !user) return; auto* ctx = static_cast<JobContext*>(user); // 只做拷贝与投递,重活交给业务线程 EventItem item; item.type = ev->nType; // 状态类型 item.code = ev->nCode; // 错误码 item.tick = ev->dwTick; // 事件时间 ctx->queue.Post(item); // 线程安全队列 } PrnSetEventCallback(hDev, OnPrinterEvent, ctx); // ... 打印流程 ... PrnSetEventCallback(hDev, nullptr, nullptr); // 关闭前注销 PrnCloseDevice(hDev);线程模型必须提前搞清楚,否则会踩两类坑。第一类是 UI 线程问题:回调线程不是主线程,在里面直接更新界面控件,轻则界面卡顿,重则直接崩溃,而且崩的位置千奇百怪。第二类是重入问题:回调里再去调用 SDK 的接口(比如查一下详细状态),有些实现会死锁,表现是打印任务永远卡住,进程还活着但不干活。稳妥做法是回调里只做数据拷贝和投递,真正的处理放到自己的业务线程里。
还有一点:队列要有界。设备异常时可能短时间内抛出大量重复事件,无界队列会一路吃内存。定长队列加丢弃策略,比事后看内存曲线要省心。
4.2 错误码分层:把 0x 开头的数字翻译成人话
各家 SDK 的错误码定义不一样,但分层思路很接近。搞清楚一个码属于哪一层,就能判断该找谁:传输层找 IT 查线查网络,机械层派人去现场看纸,数据层回来改代码。
| 层级 | 典型码段(示意) | 含义 | 处理方向 |
|---|---|---|---|
| 传输层 | 0x1xxx | 设备不在线、端口占用、超时 | 查连接、查是否被其他程序占用 |
| 介质层 | 0x2xxx | 缺纸、卡纸、盖板未关 | 现场处理,程序提示操作员 |
| 耗材层 | 0x3xxx | 碳带用尽、标签间隙检测失败 | 更换耗材、重新校准 |
| 数据层 | 0x4xxx | 图像格式错误、参数越界、缓冲区不足 | 改代码,检查 stride 与 DPI |
| 内部层 | 0x5xxx 及以上 | SDK 内部异常、句柄非法 | 检查调用顺序,抓日志找厂商支持 |
码段具体数值必须以包内错误码表为准,上面这张表是分层方法,不是对照表。有了分层,判断路径就短了:数据层的错误别派工程师跑去现场,介质层的错误别在代码里死循环重试。
一个实操细节:把错误码翻译成人话的函数单独写一个,别散落在各处。打印机型号一多,错误码表会长得很快,集中维护才改得动。
const char* DescribeError(int code) { // 只需覆盖业务里真正会遇到的那些,其余回落到通用描述 switch (code) { case 0x2001: return "缺纸"; case 0x2002: return "卡纸,请开盖取出"; case 0x3001: return "碳带用尽"; case 0x4001: return "图像尺寸与 DPI 不匹配"; default: return "未分类错误,见错误码表"; } }4.3 日志字段:出问题时能自证
日志不是写给自己看的,是写给三个月后凌晨三点被叫起来的那个人看的——那个人很可能还是你。所以字段要固定、可解析、能聚合。
我的习惯是每条与打印相关的日志至少带这几项:时间戳(毫秒级)、设备 ID、任务 ID(自己生成的业务号)、调用阶段(init/enum/open/write/end)、返回码的十六进制形式、该次调用耗时、重试次数。任务 ID 尤其重要,它把一次打印的七八条日志串成一条链,没有它,多任务并发时日志就是一团浆糊。
struct JobLog { const char* stage; // "open" / "write" / "end" const char* devId; const char* jobId; int rc; // 返回码,输出时转成 0x%04X long costMs; // 该阶段耗时 int retry; // 第几次重试 }; // 输出示例:ts=... stage=write dev=PRN01 job=J20240101-001 rc=0x4001 cost=12 retry=0有了这个格式,后面用一行正则就能统计出「哪个阶段失败最多」「哪个设备错误率最高」,不用再靠肉眼翻几百行文本。
5. 避坑清单:位宽、句柄、字符集与驱动冲突的五个翻车点
这一节记录的是真金白银换来的教训。每一条都按「现象 → 原因 → 解决」写,遇到类似症状可以直接对号入座。
5.1 现象:启动就报「不是有效的 Win32 应用程序」
原因几乎只有一个:进程位宽与 DLL 位宽不匹配。32 位宿主加载 64 位 DLL,或者反过来。别急着怀疑文件损坏,先用 2.2 节的脚本把两边都扫一遍。
解决方式是统一位数,而不是想办法「兼容」。如果你的主程序是 64 位但必须调用 32 位 DLL,正规做法是做一个 32 位的中间进程,用进程间通信把打印任务转发过去,让中间进程去加载 SDK。这个方案多一层,但边界清晰,比在 64 位进程里想办法加载 32 位 DLL 靠谱得多。
5.2 现象:第一次打印正常,第二次提示设备被占用
原因是设备句柄没释放。很多人只在成功路径上写了PrnCloseDevice,一旦PrnWritePage失败就提前 return,句柄就泄漏了。第二次调用时设备仍被自己占用,SDK 返回「忙」或者「已打开」。
解决办法是把释放动作放进 RAII 或者统一的清理函数里。C++ 里写个小的守卫对象,析构时按「中止文档 → 关设备 → 反初始化」的顺序收尾;C# 里用 try/finally。顺序不能反,先关设备再中止文档,多数实现会返回「句柄非法」。
5.3 现象:中文任务名或备注变成乱码,严重时直接崩
原因是字符集不匹配。头文件里如果用的是char*接口,通常是 ANSI 版本,你传一个 UTF-8 编码的中文字符串进去,轻则乱码,重则因为字节长度计算错误越界。反过来,宽字符接口传窄字符,同样出问题。
判断方法很直接:看头文件里参数类型是char*还是wchar_t*,看 sample 里字符串有没有L前缀或者_T()宏。确定之后,在自己的代码里统一转换一次,别让编码在层与层之间反复漂移。业务侧一律用宽字符,只在调用 SDK 的那一行做转换,是最不容易出错的做法。
5.4 现象:装了系统打印驱动之后,SDK 打不开设备
原因是端口或设备被抢占。系统驱动和 SDK 是两条访问路径,同一时刻通常只能有一条生效。有些机型在安装驱动时会把设备切到驱动模式,SDK 侧就枚举不到或者打开失败。
处理方式取决于业务诉求:要么全程走 SDK,装驱动时选择「仅安装端口、不设为默认打印机」;要么全程走驱动,SDK 只用来读状态。两者混用是麻烦的根源。切换模式后记得重新枚举,别拿着缓存的设备 ID 硬打开。
5.5 现象:压测时零星丢单,日志里看不出错
原因是回调里做了耗时操作,或者异步模式下CloseDevice调用过早。回调线程被阻塞之后,SDK 内部的事件队列积压,超时后任务被丢弃,但返回值已经是成功,日志里一片祥和。
排查方法是给回调加个耗时打点:进出各记一次时间,超过 50 毫秒就告警。同时确认异步模式下的完成通知有没有真正等到。这两处修完之后,丢单基本会消失。这类问题最考验日志质量,字段设计得糙,就只能靠复现,而它偏偏复现不了。
6. 进阶:批量标签打印的队列设计与一次自检脚本
单张打印跑通之后,真上生产就是另一回事:一天几千张标签,不能一张一张同步等,得有队列;出了问题不能靠人盯着,得有自检。我一般把批量打印拆成「生产—消费」两段:业务线程只管往有界队列里塞任务,一个专职打印线程按顺序取任务、调 SDK、等结果,失败进重试队列。重试不是无脑重试,按第 4 章的分层来——介质层和耗材层的错误要停下来等人处理,重试只会浪费纸;数据层和传输层的错误才值得重试,且重试次数要卡死在 2~3 次。
队列的容量要设上限,满了就让生产端阻塞或者快速失败,别无限堆积。打印是典型的慢设备操作,生产速度远高于消费速度,队列不设限的结果就是内存曲线一路向上,最后在某个不相干的地方崩掉。
自检脚本我通常用 Python 写一个,作用是把日志里的事后排查变成班前检查。它读当天日志,按返回码和调用阶段聚合,输出一张失败分布表;同时检查 bin 目录下的 DLL 是否齐全、位数是否与宿主一致。
import re from collections import Counter # 日志格式:ts=... stage=write dev=PRN01 job=J001 rc=0x4001 cost=12 retry=0 LINE = re.compile(r"stage=(\w+).*?rc=0x([0-9A-Fa-f]{4})") def audit(path): by_stage, by_code = Counter(), Counter() with open(path, encoding="utf-8", errors="ignore") as f: for line in f: m = LINE.search(line) if not m: continue stage, code = m.group(1), m.group(2).upper() if code == "0000": # 0 表示成功,不计入失败统计 continue by_stage[stage] += 1 by_code[code] += 1 print("按阶段失败次数:", dict(by_stage)) print("按错误码 Top5:", by_code.most_common(5)) # 数据层错误(0x4xxx)出现即代表代码有问题,直接判定自检不通过 data_err = sum(v for k, v in by_code.items() if k.startswith("4")) return data_err == 0 if __name__ == "__main__": ok = audit("print.log") print("自检结果:", "通过" if ok else "存在数据层错误,需人工介入")脚本不长,但把两件事固化了:一是失败分布自动汇总,不用再人工 grep;二是数据层错误直接判定不通过,逼着人在上班前把参数问题解决掉,而不是等到业务高峰期才发现图像尺寸不对。参数上,audit只依赖一个日志路径,日志格式一变就要同步改正则,所以那个格式最好写进团队的约定里,别让每个人各写各的。
| 自检项 | 检查方式 | 通过标准 |
|---|---|---|
| DLL 齐全 | 比对 bin 目录文件清单 | 与交付清单一致 |
| 位数一致 | 读 PE 头 Machine 字段 | 与宿主进程相同 |
| 设备在线 | 枚举返回数量大于 0 | 至少 1 台 |
| 数据层错误 | 日志中 0x4xxx 计数 | 等于 0 |
| 队列积压 | 队列长度采样 | 峰值不超过容量 70% |
刚开始接这类包的时候,我习惯先写业务代码,把异常处理留到后面补,结果每次上线前一周都在还债。后来改成先把位宽检查、句柄释放守卫、日志格式这三件事做成模板,再动打印逻辑,返工量直接砍掉一大半。这份包我按目录整理过,需要的话按文末入口自取,先跑通第一张纸,再往里加功能。希望帮到你。
本文还有配套的精品资源,点击获取