简介:围绕魔兽世界Lua插件开发整理的教程文档,面向Lua零基础到中级水平的玩家与插件作者,帮助读者从语法入门逐步走到独立设计、调试并发布插件。内容自Lua基础起步,覆盖变量、数据类型、运算符、控制结构与函数定义,再延伸到插件结构、事件处理与UI框架搭建,并深入数据库读写、数据加密、网络交互与多线程优化等进阶主题,最后以项目实践串联插件设计、测试到CurseForge上架的完整链路,知识点前后衔接、可直接对照游戏内API动手验证。整包仅含1个PDF文件,约2.7MB,单册结构清晰,便于按章节顺序通读,也方便按知识点随时查阅。目前已有2537人学习下载,适合希望系统掌握插件开发、把想法落地为可用插件的读者参考。
1. 从一行 print 说起:这套 Lua 插件教程到底能带你做到什么
把.toc文件和一行print丢进插件目录,进游戏后聊天框一声不响,这是很多人做魔兽世界插件的第一小时。这份《魔兽世界 Lua 插件开发教程》就是从这个最容易受挫的节点切入的:它并不打算把 Lua 语法讲成语言规范,而是按「先让一行字显示出来,再让界面动起来,最后让插件之间能通话」的顺序推进,把 Lua 基础、魔兽世界插件开发的事件机制、XML 界面、SavedVariables 持久化、插件间通信、战斗日志解析和库的使用串成一条完整的开发链路。
适合两类人:写过别的语言、想快速进入插件开发的熟手,可以只看目录结构、TOC 格式和事件分发这几章;完全没接触过 Lua 的新手,则顺着变量、表、函数、元表一路读下去也不会断层。书里的实战案例很具体——拍卖计时、牌局客户端通信、冷却监控——都是能直接跑起来再改的小工程,而不是只讲概念的示例。
2. 工程骨架拆解:TOC 文件、目录结构和一次完整的加载链路
插件加载失败,十个里有八个问题出在目录结构和.toc文件。游戏在读取插件列表时,先扫插件目录下与文件夹同名的.toc,再按.toc里列出的顺序把文件逐个执行。整个过程没有任何提示,文件路径写错、大小写不对、扩展名不对,结果都是「插件列表里能看到名字,但功能完全不生效」。因此,把插件骨架的关键编译单元先讲透,比急着写业务逻辑更值。
2.1 一个插件最小目录长什么样
插件目录的名字必须和.toc同名,两个名字差一个下划线都会导致加载失败。文件名建议全部小写,避免在跨平台压缩包解压后大小写不一致。下面是一个可以正常加载的最小骨架:
AuctionTimer/ ├── AuctionTimer.toc -- 必须与文件夹同名 ├── core.lua -- 入口逻辑 ├── ui.xml -- 界面声明(可选) ├── locale/ │ └── zhCN.lua -- 本地化文本 └── libs/ └── LibStub/ └── LibStub.lua -- 第三方库,按目录层级引入libs不是为了好看,而是为了和业务代码分层:第三方库更新时直接整个目录替换,不会碰到自己的core.lua。我一般把「纯数据」和「纯逻辑」也拆开,比如把物品 ID 表放进data.lua,这样改数值不用动逻辑文件。目录里出现的每个.lua是否会被执行,完全取决于.toc里有没有写它——没写进去的文件等于不存在,这是新手最常见的疑问来源。
2.2 TOC 文件逐字段拆解
.toc是纯文本,##开头的是元信息,没有##的行是待加载文件列表。元信息里真正影响行为的只有几个,其余是给人看的:
## Interface: 110000 ## Title: 拍卖计时器 ## Notes: 记录拍卖剩余时间并在聊天框提示 ## Author: 某开发者 ## Version: 0.1.0 ## SavedVariables: AuctionTimerDB ## SavedVariablesPerCharacter: AuctionTimerCharDB ## OptionalDeps: LibStub, CallbackHandler-1.0 ## X-Custom: 自定义字段,插件自己读 core.lua locale\zhCN.lua ui.xml libs\LibStub\LibStub.luaInterface填当前客户端的接口版本号,填错不会导致插件崩溃,只会被标记为过期插件、默认不加载,这个「玄学不加载」坑过不少人。SavedVariables声明的是全局表名,退出游戏或/reload时引擎会把这张表序列化写盘,下次登录自动还原,这是存档的唯一正规入口。SavedVariablesPerCharacter则是按角色分开存。OptionalDeps表示「如果对方存在就先加载它」,用 LibStub 这类库时必填,否则库还没加载、业务代码已经执行,直接报 nil。
文件列表里的路径要用反斜杠,扩展名不能省,顺序就是执行顺序。把依赖库写在业务代码后面,等于让业务代码在库存在之前跑一遍,症状是「偶尔正常、偶尔报错」,非常难查。
2.3 从加载到打印:Hello World 的执行链
每个.lua文件被引擎执行时,顶部都可以接收两个可变参数:插件名,以及一张属于本插件的私有表。这张私有表是所有文件共享的命名空间,机制很轻但足够用:
-- core.lua local addonName, ns = ... -- 插件名 + 私有命名空间表 ns.timers = {} -- 挂在私有表上,避免污染全局 local f = CreateFrame("Frame") -- 一个不带界面的帧,用作事件载体 f:RegisterEvent("PLAYER_LOGIN") -- 只关心登录完成这一个事件 f:SetScript("OnEvent", function(self, event, ...) DEFAULT_CHAT_FRAME:AddMessage(("|cff33ff99%s|r 已加载,版本 %s") :format(addonName, "0.1.0")) ns.Ready = true end) SLASH_AUCTIONTIMER1 = "/at" -- 定义斜杠命令 SlashCmdList["AUCTIONTIMER"] = function(msg) if msg == "reset" then AuctionTimerDB = nil -- 清空存档,下次保存时重建 print("存档已清空,请 /reload") else print("用法:/at reset") end endlocal addonName, ns = ...这一行是整个插件工程的地基:addonName用于日志和报错定位,ns用于跨文件共享函数与状态,不写local就会变成全局变量,和别的插件撞名后就是互相覆盖的血泪经验。CreateFrame("Frame")创建的是没有视觉元素的帧,纯粹当事件接收器用,尺寸和位置都不用设。SLASH_XXX1是固定前缀加序号,SlashCmdList的 key 要和前缀去掉SLASH_和后缀数字后保持一致,写错就是「命令没反应」。
DEFAULT_CHAT_FRAME:AddMessage比print更可控,支持颜色代码,也能被其他聊天增强插件正常处理。生产环境里我一般包一层ns:Print(),统一加插件名前缀,出问题时一眼能看出是谁在说话。
2.4 存档表:初始化顺序决定数据会不会丢
存档表最容易出问题的地方不在读写,而在「什么时候初始化」。引擎把存档还原发生在ADDON_LOADED之后、PLAYER_LOGIN之前,因此初始化逻辑要挂在ADDON_LOADED上,并且判断事件参数是不是自己:
local addonName, ns = ... local defaults = { minPrice = 100, notify = true, history = {} } local function InitDB() AuctionTimerDB = AuctionTimerDB or {} for k, v in pairs(defaults) do if AuctionTimerDB[k] == nil then -- 只补缺失项,不覆盖用户设置 AuctionTimerDB[k] = v end end end local f = CreateFrame("Frame") f:RegisterEvent("ADDON_LOADED") f:SetScript("OnEvent", function(self, event, name) if name ~= addonName then return end InitDB() self:UnregisterEvent("ADDON_LOADED") -- 用完就注销,减少回调 end)for k, v in pairs(defaults)做的是「字段级补全」,这样以后新增配置项,老用户的存档也能自动补上默认值。反过来,如果写成AuctionTimerDB = defaults,等于每次登录都把用户设置清空,属于典型的后悔药都来不及吃的写法。self:UnregisterEvent在初始化完成后必须调用,ADDON_LOADED会为每个插件各触发一次,不注销就是在别的插件加载时反复执行初始化。
3. 事件驱动与计时:OnEvent 分发、战斗日志解析和 OnUpdate 调度器
插件的本质是「被动响应」:游戏告诉你发生了什么,你去处理。新手常把逻辑塞进一个大OnEvent里,用if event == "X" then ... elseif一长串判断,几十个事件之后基本没法维护。教程里给出的方向是把帧当事件总线,再做一层分发表,业务代码只注册关心的事件;同一套思路可以复用到战斗日志解析和插件间通信上。
3.1 事件监听:把 Frame 当事件总线
分发层的核心是一张event -> 处理函数数组的表,注册时顺带调用RegisterEvent,避免手写漏注册:
local addonName, ns = ... local bus = CreateFrame("Frame") local handlers = {} -- handlers[event] = { fn1, fn2, ... } local function Dispatch(self, event, ...) local list = handlers[event] if not list then return end for i = 1, #list do local ok, err = pcall(list[i], event, ...) -- 单个回调出错不拖垮整条链 if not ok then DEFAULT_CHAT_FRAME:AddMessage(("|cffff5555[%s] %s: %s|r") :format(addonName, event, tostring(err))) end end end function ns:On(event, fn) handlers[event] = handlers[event] or {} table.insert(handlers[event], fn) bus:RegisterEvent(event) -- 第一次注册时才真正订阅 return fn end bus:SetScript("OnEvent", Dispatch)pcall包一层是关键:一个回调报错,pcall会吞掉错误并继续执行后面的回调,代价是错误信息不再自动打到默认错误框,所以要自己AddMessage出来,否则整个插件就等于失去错误提示,很难排查。
ns:On返回fn本身,方便后续做注销:ns:Off(event, fn)时用table.remove找到并删除,同时判断数组是否为空,空了就UnregisterEvent,避免空跑。
3.2 战斗日志:多事件之外最容易读错参数的地方
战斗日志是同一个事件的「事件中事件」:只有一个COMBAT_LOG_EVENT_UNFILTERED,具体发生了什么要看第二个返回值subevent。参数通过CombatLogGetCurrentEventInfo()一次性取出:
ns:On("COMBAT_LOG_EVENT_UNFILTERED", function() local _, subevent, _, sourceGUID, _, _, _, destGUID, _, _, _, spellID, _, _, amount = CombatLogGetCurrentEventInfo() if sourceGUID ~= UnitGUID("player") then return end -- 只关心自己 if subevent == "SPELL_DAMAGE" or subevent == "SPELL_PERIODIC_DAMAGE" then ns.lastSpellID = spellID ns.lastAmount = amount elseif subevent == "SPELL_CAST_SUCCESS" then ns:After(0.5, function() -- 施法成功后延迟判定 if ns.lastSpellID == spellID then DEFAULT_CHAT_FRAME:AddMessage(("技能 %d 命中,伤害 %d") :format(spellID, ns.lastAmount or 0)) end end) end end)sourceGUID与UnitGUID("player")比较是最稳的「是不是我」判断方式,用名字比较在重名和变形场景下会翻车。subevent判断要注意SPELL_DAMAGE和SPELL_PERIODIC_DAMAGE是两个不同值,只判断第一个会漏掉所有持续伤害。
提示:战斗日志的参数列表在多个大版本里改动过,老教程里的
SelectCombatLog...写法在当前接口下拿到的全是 nil。以上面的CombatLogGetCurrentEventInfo()为准,其余返回值按需解包,不要照抄旧参数顺序。
3.3 OnUpdate 与一个够用的 Scheduler
OnUpdate每帧调用一次,参数是距上一帧的秒数。直接在里面写业务逻辑,等于把逻辑和帧率绑死;包装成调度器之后,业务侧只需要「多少秒后做一件事」:
local addonName, ns = ... local driver = CreateFrame("Frame") local clock = 0 local timers = {} -- timers[token] = { at = 触发时刻, fn = 回调 } driver:SetScript("OnUpdate", function(self, elapsed) clock = clock + elapsed for token, t in pairs(timers) do if clock >= t.at then timers[token] = nil -- 先摘掉,防止回调里重复触发 local ok, err = pcall(t.fn) if not ok then print("timer error:", err) end end end end) function ns:After(seconds, fn) local token = {} -- 用表做唯一 key timers[token] = { at = clock + seconds, fn = fn } return token end function ns:Cancel(token) timers[token] = nil endelapsed是浮点秒,每帧都累加,因此调度精度约等于一帧(十几到几十毫秒),对界面提示完全够用。token用空表而不是数字,避免了「数字 key 被复用导致误取消」的问题。
性能上要注意两点:pairs遍历整张表在定时器很多时是纯浪费,实际项目里我会按「下一个到期时刻」维护最小堆,或者按 0.1 秒一档分桶;另外回调里不要创建临时表、不要做字符串拼接,这些都会推高 GC 压力。教程里特别提到OnUpdate与性能的关系,这一节值得反复读——掉帧问题九成来自这里。
3.4 插件间通信:消息前缀必须先注册
多个插件之间交换数据靠CHAT_MSG_ADDON,发消息之前必须注册前缀,否则对方收不到:
local PREFIX = "AT1" -- 短、唯一,别用通用词 C_ChatInfo.RegisterAddonMessagePrefix(PREFIX) ns:On("CHAT_MSG_ADDON", function(event, prefix, text, channel, sender) if prefix ~= PREFIX then return end -- 过滤别人的消息 local cmd, payload = text:match("^(%u+):(.*)$") if cmd == "BID" then ns:HandleBid(sender, tonumber(payload)) end end) function ns:Broadcast(cmd, value, channel) C_ChatInfo.SendAddonMessage(PREFIX, cmd .. ":" .. tostring(value), channel or "PARTY") endprefix建议控制在 16 字节以内并且带上自己的缩写,通用词如TEST会和别人冲突。消息体是纯字符串,协议自己定——用命令:载荷这种最简格式就够,复杂结构再上序列化。频道参数决定谁能收到:PARTY、RAID、GUILD、INSTANCE_CHAT,跨阵营和跨服场景要额外判断,否则就是「本地测试一切正常,组队就收不到」。
4. XML 与 Frame 模板:把界面从硬编码里拆出来
界面代码是最容易写成面条的一段。全用 LuaCreateFrame硬堆,改一个按钮位置要翻几十行;全用 XML 又不利于动态生成。教程的做法是折中:静态骨架和模板放 XML,动态内容用 Lua 实例化模板。理解 Frame 的层级、锚点和继承之后,这套分工就很自然。
4.1 Frame 的生命周期与锚点规则
一个 Frame 从CreateFrame开始,到SetPoint才有位置,到Show才可见,父级隐藏时子级一起隐藏。锚点是相对另一个对象的:
local panel = CreateFrame("Frame", "AT_Panel", UIParent, "BackdropTemplate") panel:SetSize(260, 140) panel:SetPoint("CENTER", UIParent, "CENTER", 0, -120) -- 相对屏幕中心下移 panel:SetBackdrop({ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 12, insets = { left = 2, right = 2, top = 2, bottom = 2 }, }) panel:SetBackdropColor(0, 0, 0, 0.85) panel:SetMovable(true) panel:EnableMouse(true) panel:RegisterForDrag("LeftButton") panel:SetScript("OnDragStart", panel.StartMoving) panel:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() ns:SavePosition(self) -- 拖完立刻记位置,别等退出 end)第四个参数是模板名,BackdropTemplate在新接口下必须显式传入,否则SetBackdrop直接报「attempt to call a nil value」。这是老代码迁移时最常见的报错之一。
SetPoint的三个相对参数含义是「相对于哪个对象、那个对象的哪个点、偏移多少」,同一方向重复调用会覆盖而不是叠加,想同时贴左边和右边只能用SetPoint两次但方向不同。SetMovable加RegisterForDrag是拖动的最小组合,少了任何一个都拖不动。
4.2 XML 声明与模板继承
XML 文件的价值在模板。virtual="true"表示这个定义只是模板,不会实例化;parentKey会自动把子元素挂到父对象的同名字段上,省掉一堆CreateFrame加查找:
<Ui xmlns="http://www.blizzard.com/wow/ui/"> <Button name="AT_RowTemplate" virtual="true"> <Size y="24"/> <Layers> <Layer level="ARTWORK"> <FontString parentKey="Label" inherits="GameFontHighlight" justifyH="LEFT"> <Anchors> <Anchor point="LEFT" x="6"/> </Anchors> </FontString> </Layer> </Layers> <HighlightTexture file="Interface\\QuestFrame\\UI-QuestTitleHighlight" alphaMode="ADD"/> <Scripts> <OnClick> if self.OnSelected then self:OnSelected(self) end </OnClick> </Scripts> </Button> </Ui>name加virtual="true"是模板的标准写法;parentKey="Label"让 Lua 侧可以直接写row.Label:SetText(...),不用row:GetFontString()。Scripts块里写的是 Lua,self指向这个按钮本身,因此可以挂一个自定义字段OnSelected当回调,把「谁被点了」交回业务层处理。inherits="GameFontHighlight"继承的是字体样式,改全局字体时所有继承它的文本一起变,比自己设字号省事。
4.3 用模板拼一个可滚动列表
有了模板,动态列表就是「创建 + 复用」两件事。不要在滚动时不停新建帧,Hide掉的行留着复用,行数一多帧率差别非常明显:
local scroll = CreateFrame("ScrollFrame", "AT_Scroll", panel, "UIPanelScrollFrameTemplate") scroll:SetPoint("TOPLEFT", panel, "TOPLEFT", 12, -34) scroll:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -30, 12) local content = CreateFrame("Frame", nil, scroll) content:SetSize(1, 1) -- 宽度交给 ScrollFrame 撑开 scroll:SetScrollChild(content) local rows = {} local ROW_H = 24 local function AcquireRow(index) local row = rows[index] if not row then row = CreateFrame("Button", nil, content, "AT_RowTemplate") row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -(index - 1) * ROW_H) row:SetPoint("RIGHT", content, "RIGHT", 0, 0) rows[index] = row end return row end function ns:RefreshList(items) for i, item in ipairs(items) do local row = AcquireRow(i) row.Label:SetText(item.name) row.OnSelected = function(self) ns:OnRowClick(item) end row:Show() end for i = #items + 1, #rows do rows[i]:Hide() -- 多出来的隐藏,不销毁 end content:SetHeight(math.max(#items * ROW_H, 1)) endSetScrollChild之后,content的高度决定滚动条范围,所以每次刷新都要同步SetHeight,忘了这一步的表现是「列表能滚但滚不到底」。
AcquireRow用索引缓存,索引存在的行只更新文本和回调,回调里闭包捕获的是item,每次刷新都会替换,不会出现「点了第三行触发第一行数据」的错位。行数超过几百时再考虑虚拟化(只渲染可见区),一般配置面板用不上。
5. 避坑与排查:五个真实翻车现场和它们的定位手法
插件的错误分两类:加载期完全静默,运行期报错但信息不全。前者靠「二分法禁用插件」定位,后者靠错误信息里的文件名和行号。下面五个坑是复现率最高的,每一个都建议在自己的小工程里故意踩一次。
5.1 加载期:插件列表有名字,功能却没生效
现象:插件管理界面能看到插件名和描述,进游戏后聊天框没有任何输出,斜杠命令也提示「未知命令」。
原因:.toc里的文件列表路径写错。常见的有三种:core.lua写成Core.lua但实际文件是小写;写成core漏了扩展名;写成core/Lua这种正斜杠混用。引擎找不到文件就跳过,不报错、不提示。
解决:先把.toc精简到只剩一个core.lua,里面只放一行print("loaded"),能打印出来再逐个加文件。加到哪个出问题,就是那个文件路径或语法有错。这个二分法比翻文档快得多。
5.2 加载期:SetBackdrop 报 nil
现象:进游戏弹出 Lua 错误,提示attempt to call a nil value (method 'SetBackdrop'),整个插件后续代码全部中断。
原因:CreateFrame时没有传BackdropTemplate模板。较新的接口把SetBackdrop系列方法从 Frame 基类移到了该模板里,老教程里的四参数CreateFrame("Frame", name, parent)写法不再具备这个方法。
解决:CreateFrame("Frame", name, parent, "BackdropTemplate");XML 里则写inherits="BackdropTemplate"。迁移老插件时全局搜一遍SetBackdrop,逐个补模板参数即可。
5.3 运行期:全局变量污染,两个插件互相覆盖
现象:单装正常,和某个插件一起装就随机报错,错误位置指向别人的文件;或者自己的配置项莫名其妙被改成别的值。
原因:某个文件里漏了local,比如timers = {}、function Update(),这些直接落进全局环境。两张插件用了同名全局,后加载的覆盖先加载的。
解决:所有文件顶部统一写local addonName, ns = ...,模块状态一律挂ns上,函数能用local就用local。排查时可以在登录后遍历_G,把值类型是 table 且 key 名和自己插件相关的项打印出来,能捞出不少漏网的全局。
5.4 运行期:进出主城就掉到个位数帧率
现象:野外正常,人多的场景帧率骤降,关掉自己插件立刻恢复。
原因:OnUpdate里做了重活——遍历全表、字符串拼接、每帧SetText。还有人把OnUpdate当定时器用,注册了十几个帧各自跑回调。
解决:全插件只保留一个OnUpdate驱动(第 3 章那个调度器),其余逻辑改成「到期才执行」;SetText前先比较新值和旧值,相同就不调用;同帧内的多个界面更新合并到下一个OnUpdate里批量做。改完再进主城对比,差距通常很直观。
5.5 运行期:插件间通信发出去收不到
现象:自己双开测试,A 号发消息,B 号完全没反应;单机用频道模拟又一切正常。
原因:忘了RegisterAddonMessagePrefix,或者前缀超过长度限制被截断;也可能是频道选错,队伍频道发给团队里的其他人自然收不到。
解决:发送和接收两端都注册同一个前缀;消息前缀保持短且唯一;调试时先在CHAT_MSG_ADDON回调最外层无条件print(prefix, text, sender),确认消息是否到达,再判断是不是自己解析逻辑的问题。加一行无条件打印,比猜十分钟快。
6. 二次开发:在示例插件上改出一个自己的功能并验证
拿到这份教程之后,最有效的用法不是从头读到尾,而是挑一个示例插件当底座,改出一个自己真的会用的功能。我一般按「改配置 → 改逻辑 → 改界面」的顺序推进,每一步都能独立验证,出问题也容易回退。下面这张表是我改任何插件的固定路线:
| 改造点 | 落手位置 | 验证方式 |
|---|---|---|
| 增加一个配置项 | .toc无需改,改defaults表 | 删存档重进,看默认值是否生效;老存档看是否自动补齐 |
| 换触发条件 | 事件分发层ns:On(...) | 打印事件名与参数,确认在预期时机进入 |
| 改界面布局 | XML 模板或SetPoint | /reload后看位置,拖动后重登验证位置是否被记住 |
| 加新命令 | SLASH_前缀 +SlashCmdList | 输错参数看是否走默认分支,不会报错 |
| 提升性能 | 统一OnUpdate驱动 | 人多场景前后对比帧率,确认回调数量下降 |
具体到代码,最省事的切入点是给现有插件加一个「配置面板 + 一个开关」。先在defaults里加字段,再在初始化里补全,最后在业务逻辑里读它:
-- 1) defaults 里加一项 local defaults = { notify = true, minPrice = 100, showOnLogin = false } -- 2) 业务逻辑里读,不直接读全局,包一层更安全 function ns:Conf(key) if AuctionTimerDB and AuctionTimerDB[key] ~= nil then return AuctionTimerDB[key] end return defaults[key] end -- 3) 登录时按开关决定是否提示 ns:On("PLAYER_LOGIN", function() if ns:Conf("showOnLogin") then DEFAULT_CHAT_FRAME:AddMessage("拍卖计时器已就绪") end end)ns:Conf这一层看着多余,实际上是给自己留退路:以后存档结构变了、字段改名了,只改这一个函数,业务代码一行不动。直接AuctionTimerDB.xxx散落在几十个文件里,迁移时就是灾难。
界面改造我建议先只动 XML,不碰业务逻辑。把模板里的Size、Anchors调一遍,/reload看效果,确认没问题再去接数据。这样界面问题和逻辑问题永远不在同一时间出现,排查范围小一半。
验证环节有三个必做动作:一是删掉存档重进,确认首次使用的默认值路径没问题;二是用老存档进一次,确认字段补全逻辑生效;三是/reload三次以上,看有没有重复注册事件导致的重复输出。重复注册这个坑极其隐蔽,表现是「提示刷了两遍、三遍」,很多人以为是显示问题,其实是每次重载都往handlers里又插了一条。
从那以后我每次加新功能,都强制走一遍「删存档 → 老存档 → 连续 reload 三次」这个三连,哪怕只是加了一个布尔开关。插件这门手艺里,能稳定复现的验证步骤比任何技巧都值钱。希望帮到你。
本文还有配套的精品资源,点击获取