简介:一份面向网络编程学习者的实战资源包,围绕Flash游戏客户端与服务器端通信展开,涉及TCP连接与select I/O多路复用模型。压缩包内包含一个SWF赛车游戏、一个控制台版服务器程序,以及解释双方数据包格式的PPT材料,可直接运行exe查看效果,也可阅读cpp源码理解select模型如何并发处理多个客户端连接。资源共15个文件,以C++源码、工程配置文件和调试辅助文件为主,既有可执行程序也有pdb、obj等中间产物,便于对照学习编译与排错,整体仅649KB,内容精炼。目前已有235人学习浏览,适合正在学习Windows网络编程、或需要参考select模型做课程设计/毕业设计的开发者。通过分析服务器与Flash游戏的数据包交互,可以掌握自定义协议头、数据体设计以及非阻塞I/O事件处理的常见写法,具有较强的实践参考价值。
1. flash游戏还在,服务器怎么接住它
浏览器早就自动停用了Flash插件,但老游戏站点上那一批SWF小游戏并没有消失。很多从业者手里还有这类老资产:要么是怀旧游戏站的站长,要么是网吧维护、学校机房管理,想把一台服务器重新架起来,让局域网里的人还能打开这些游戏。这件事可以拆成两半:一半是静态托管,也就是把SWF文件放到Web服务器上,让浏览器能加载;另一半是联机通信,也就是Flash客户端通过Socket或HTTP去访问一个后端服务,做登录、对战、排行榜这类交互。本文就是沿着这两条线,把“Flash游戏 + 服务器”从选型、配置到排错讲清楚,目标是让新手照着做能跑通,让熟手看完能避开那些老生常谈的坑。
2. 静态托管SWF:Nginx的MIME与局域网访问配置
2.1 SWF文件在服务器上是什么角色:MIME与浏览器加载路径
把Flash游戏部署到服务器,本质上和部署一个普通网页没有太大区别:服务器提供一个HTTP地址,浏览器通过<object>或<embed>标签加载SWF。但在实际运维里,最容易翻车的恰恰是最不起眼的MIME类型。
浏览器在拿到服务器的响应时,会看Content-Type头来决定怎么处理这个文件。SWF的标准MIME是application/x-shockwave-flash,Nginx自带的mime.types文件里通常已经有这一项。但有些精简安装、或者手动编译的Nginx,mime.types不全,或者服务器管理员把所有未知文件都按application/octet-stream返回,这时浏览器会把SWF当成二进制下载,而不是交给Flash插件渲染。
排查方法很简单,用curl看响应头:
curl -I http://127.0.0.1/game.swf正常情况应该看到:
Content-Type: application/x-shockwave-flash如果看到的是application/octet-stream或text/plain,就需要在Nginx配置里强制指定MIME。我一般会加上这段:
location ~* \.swf$ { root /data/flash-games; add_header Content-Type application/x-shockwave-flash always; add_header Cache-Control "public, max-age=86400"; }add_header ... always里的always参数表示不管响应状态码是什么都带上这个头,能避免某些重定向场景下头丢失。Cache-Control设置一天的缓存,是因为SWF体积往往不小,用户重复访问时服务器压力能小一些。这里有个细节:如果站里SWF文件名带版本号,比如game_v2.swf,那缓存时长可以拉到七天;如果文件名不变但内容经常更新,建议缓存时间短一些,或者用Cache-Control: no-cache,否则用户端拿到的永远是旧文件。
2.2 Nginx最小配置:让局域网内的人都能打开游戏
如果只是本机玩,直接双击SWF就行。但“Flash游戏以及服务器”这个需求里,真实场景多数是让局域网内其他电脑访问,这就涉及监听地址、目录权限、防火墙三件事。
先写一个完整的、能跑起来的最小server块:
server { listen 80; server_name _; root /data/flash-games; index index.html; location / { try_files $uri $uri/ =404; } location ~* \.swf$ { add_header Content-Type application/x-shockwave-flash always; add_header Cache-Control "public, max-age=86400"; } }server_name _是Nginx里“匹配所有Host头”的写法,局域网用户拿IP直接访问时不需要改hosts文件。root指向SWF所在的物理目录,try_files $uri $uri/ =404确保找不到文件时返回404而不是目录列表。这里要特别注意:默认情况下Nginx的index只对/请求生效,如果用户访问http://192.168.1.10/game.swf,走的是location /,SWF文件能正常返回;但如果用户访问的是http://192.168.1.10/,Nginx会去找index.html,而这个文件不存在就返回404,所以目录里至少要放一个跳转页或者直接用绝对路径访问SWF。
局域网访问还有一个高频坑:监听的是127.0.0.1还是0.0.0.0。Nginx默认监听80端口,没有写IP时实际上绑定的是所有网卡,也就是0.0.0.0,局域网访问没问题。但有些发行版自带的Nginx配置文件里写了listen 127.0.0.1:80;,这时候局域网其他机器就访问不到,需要在配置里改成listen 80;或listen 0.0.0.0:80;。改完配置记得先测试再重载:
nginx -t systemctl reload nginx2.3 验证与快速换服:curl、hosts与wamp的差异
配置完成后,不要急着开浏览器,先用curl在服务器本机做三步验证。第一步验证文件存在,第二步验证MIME,第三步验证大小:
curl -sI http://127.0.0.1/game.swf | head -n 1 curl -sI http://127.0.0.1/game.swf | grep -i content-type curl -sI http://127.0.0.1/game.swf | grep -i content-length如果本机能通,局域网内其他机器却打不开,依次检查三件事:一是服务器防火墙是否有放行80端口,二是客户端访问的IP是否正确,三是Nginx监听的地址是否为0.0.0.0。我处理过的案例里,最典型的是阿里云安全组放行了80但系统防火墙没放行,两边都要看。放行命令在不同发行版上不一样,CentOS用systemctl管理firewalld,Ubuntu用ufw,但排查思路一致,都是先确认监听、再确认防火墙规则。
有人会用wamp在Windows上搭服务器,逻辑和Nginx完全一样,但Apache的目录权限配置和Nginx差别较大。Apache 2.4默认对没有Require指令的目录是拒绝访问的,必须显式加:
<Directory "D:/flash-games"> Options Indexes AllowOverride All Require ip 192.168.1.0/24 </Directory>这段配置的意思是允许192.168.1.0这个网段访问,其他来源一律拒绝。这里的Require ip写的是你局域网的实际网段,如果路由器的DHCP分配的网段是10.0.0.x,那就得写10.0.0.0/24,照抄别人的配置最容易在这里翻车。
3. 让Flash游戏联机:XMLSocket客户端与Python服务器从握手到收发
3.1 Flash的网络能力边界:为什么联机游戏都用Socket而不是HTTP
大部分单机Flash游戏只需要静态托管就够了,但一旦涉及排行榜、多人在线、实时对战,就得让SWF和服务器通信。Flash提供了三种网络能力:URLLoader走HTTP,适合登录、提交分数这种低频请求;XMLSocket走TCP长连接,适合实时交互;早期还有AMF(Action Message Format)走RTMP协议,配合Adobe的Media Server或Red5使用。
常见做法是联机Flash游戏优先用XMLSocket,因为HTTP每次请求都要重新建立连接,服务器要处理大量无意义的握手开销。XMLSocket建立后保持长连接,服务器主动推送消息到客户端,延迟低得多。它的协议也简单:数据以\n(换行符)作为消息分隔符,服务端按行读取,客户端按行解析。缺点是没有框架帮你做序列化和路由,所有消息都要自己定义格式,比如login|username|room、move|x|y。
在动手写代码之前,先明确XMLSocket的一个重要特性:它不能跨域连接,也不允许连接除843端口以外任意端口的安全策略请求。换句话说,客户端连接服务器的任意端口之前,Flash播放器会先到目标服务器的843端口去拉取crossdomain.xml,如果拉不到或者内容不允许,Socket连接会直接被拦截并抛出SecurityError。这个问题在3.4节单独展开,因为它是联机Flash项目里最常见、也最容易让人误判的问题。
3.2 服务端:Python Socket服务器的最小实现
先实现一个最小的Socket服务器,功能是接收客户端的文本行消息,回一个确认。选择Python是因为它的标准库socket就能完成,不需要安装第三方依赖。代码如下:
import socket import threading HOST = '0.0.0.0' PORT = 9100 def handle(conn, addr): print(f"新连接: {addr}") buffer = b"" try: while True: data = conn.recv(1024) if not data: break buffer += data # 按 \n 拆行,Flash XMLSocket 默认行分隔协议 while b"\n" in buffer: line, buffer = buffer.split(b"\n", 1) text = line.decode(encoding="utf-8", errors="ignore") print(f"收到: {text}") conn.sendall(b"pong\n") except Exception as e: print(f"连接异常: {e}") finally: conn.close() srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((HOST, PORT)) srv.listen(5) print(f"Flash Socket 服务器已启动: {HOST}:{PORT}") while True: conn, addr = srv.accept() threading.Thread(target=handle, args=(conn, addr), daemon=True).start()这段代码里有几个参数需要说明。HOST设为0.0.0.0表示监听所有网卡,这样局域网内任何一台机器都能连上来,如果只想允许本机连接才用127.0.0.1。PORT=9100是自定义的,范围只要不是80、443这种常用端口就行,但要注意别和服务器上其他服务冲突。recv(1024)是每次读取最多1024字节,实际能处理的游戏消息已经足够,因为Flash的XMLSocket消息体通常只有几百字节。SO_REUSEADDR用来解决一个问题:服务器崩溃退出后端口还处于TIME_WAIT状态,不设置这个选项,几秒内重启会报“Address already in use”。
行拆分逻辑里的buffer.split(b"\n", 1)很重要。TCP是流式协议,客户端发送hello\nworld\n,服务器可能第一次recv就收到完整的两行,也可能只收到半行,剩下半行在第二次recv才到。所以必须把未拆完的数据留在buffer里,等下一次数据到达时继续拆,这就是循环里while b"\n" in buffer的意义。如果漏了这一步,消息一多就会出现乱拼、黏包的现象,这也是很多新手写TCP程序最容易踩的坑。
3.3 客户端:AS3里连服务器并处理数据帧
服务端就绪后,写一个简单的AS3客户端。这段代码用纯AS3编写,不需要任何第三方库,也刻意避开了Flex框架的UI组件:
package { import flash.display.Sprite; import flash.events.Event; import flash.events.IOErrorEvent; import flash.events.ProgressEvent; import flash.events.SecurityErrorEvent; import flash.net.Socket; import flash.utils.ByteArray; public class GameClient extends Sprite { private var sock:Socket; public function GameClient() { sock = new Socket(); sock.timeout = 5000; sock.addEventListener(Event.CONNECT, onConnect); sock.addEventListener(IOErrorEvent.IO_ERROR, onIOError); sock.addEventListener(SecurityErrorEvent.SECURITY_ERROR, onSecurityError); sock.addEventListener(ProgressEvent.SOCKET_DATA, onData); // 这里改成你服务器的实际IP sock.connect("192.168.1.10", 9100); } private function onConnect(e:Event):void { trace("已连接服务器"); // 发一条登录消息,以 \n 结尾 sock.writeUTFBytes("login|player1\n"); sock.flush(); } private function onData(e:ProgressEvent):void { var buf:ByteArray = new ByteArray(); sock.readBytes(buf, 0, sock.bytesAvailable); var line:String = buf.readUTFBytes(buf.length); trace("收到: " + line); } private function onIOError(e:IOErrorEvent):void { trace("IO错误: " + e.text); } private function onSecurityError(e:SecurityErrorEvent):void { trace("安全沙箱错误: " + e.text); } } }这里几个参数值得展开。sock.timeout = 5000是连接超时时间,单位毫秒,超过5秒连不上就触发IOError。writeUTFBytes("login|player1\n")后面的flush()必须写,Socket在写入时内部有缓冲区,不flush就不能保证数据立刻发出去。ProgressEvent.SOCKET_DATA是Flash监听socket数据的唯一正确事件,不能用Event.DATA,那是旧版写法。
收到数据时,sock.bytesAvailable表示缓冲区里有多少字节可读,readUTFBytes按UTF-8编码读完整个缓冲区。这个写法适合每条消息都比较短的场景。如果消息可能很长,或者一个事件里包含多条消息,就需要像Python服务端那样维护一个buffer,在AS3里用indexOf("\n")去拆行。
AS3编译需要Flex SDK或旧版Flash Builder,命令行编译的命令是:
mxmlc -use-network=true GameClient.as -output GameClient.swf-use-network=true这个参数非常关键。默认的编译选项里,如果SWF要访问网络,必须显式开启这个标记,否则Flash Player会认为这个SWF只在本地使用,所有网络功能都被禁用,连connect都会静默失败。
3.4 crossdomain.xml与安全沙箱:联机失败的第一嫌疑
联机失败时,十个里有八个是沙箱问题。Flash Player默认不允许一个来自A域的SWF去连接B域的Socket,需要B域通过跨域策略文件显式授权。这个文件就是crossdomain.xml,要放在被连接服务器的843端口上。
很多人搞混两种情况:如果Flash用URLLoader请求HTTP数据,crossdomain.xml可以放在Web服务器的80端口根目录下;但如果用XMLSocket连接TCP端口,Flash Player会先请求目标服务器的843端口,策略文件必须由服务端直接返回。开发环境最省事的办法是,在一个临时脚本里监听843端口并返回策略文件:
import socket POLICY = b'''<?xml version="1.0"?> <cross-domain-policy> <allow-access-from domain="*" /> </cross-domain-policy>''' srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("0.0.0.0", 843)) srv.listen(10) print("policy server start at 843") while True: conn, addr = srv.accept() conn.sendall(POLICY) conn.close()生产环境不要用domain="*",这里只为开发方便,上线时要改成具体的域名或IP。这条843策略服务器可以直接和业务服务器跑在同一台机器上,互不影响。如果Flash报SecurityError 2046,但脚本又确认策略文件返回正常,还有个容易被忽略的点:策略文件里的<allow-access-from>标签写法错误。注意是cross-domain-policy是根元素,allow-access-from的子节点,中间漏掉一行都会让Flash解析失败。
4. 服务器选型和协议取舍:Flash时代留下的三类方案
4.1 静态+HTTP轮询:最简单但不适合实时
如果游戏只是单机玩法加一个排行榜,服务端用HTTP就够。AS3里的URLLoader配合URLRequest,POST一个JSON或键值对到服务器,服务器接收后写进数据库,然后返回结果。这种方案的优点是没有长连接,服务器实现简单,任何语言都能做,Nginx加上一个PHP脚本就能跑。缺点也明显:Flash客户端要定时去请求服务器拉数据,时间间隔短了服务器压力大,时间间隔长了实时性差。Flash时代的页游里,很多聊天功能就是这么做的,两秒轮询一次,服务器挂着几百个连接反复请求,CPU和带宽消耗都很可观。
这种方案最早期的部署其实不需要单独写Socket服务器,直接用已有的Web服务器就行。适合的场景是:几个好友之间玩个联机答题,或者比赛的最终结果只在结束页展示,不需要落地的实时交互。
4.2 XMLSocket/文本行协议:联机小游戏的主路径
第3章里的Python服务器就是一条主路径。文本行协议最大的好处是调试直观,服务端收到的就是明文,遇到问题能立刻看出是哪条消息出问题。网络游戏服务器开发里有一个通用做法叫“消息路由”,即每条消息的第一个字段是消息类型,比如login、move、attack,服务器根据类型分发到不同的处理函数。这个思路用XMLSocket实现起来很自然:
login|player1 move|player1|100|200 attack|player1|player2文本协议的缺陷是消息体积比二进制大,一条move消息可能四五十字节,如果服务器每秒处理几千条消息,网络开销会明显上去。对Flash小游戏来说这不算问题,但如果做的是一整个MMORPG的主干通信,就应该考虑用AMF或直接自定义二进制格式。文本协议的另一个坑是处理中文编码:Flash端writeUTFBytes按UTF-8编码,Python端decode("utf-8")也要对应,两边的编码如果一边UTF-8一边GBK,就等着看乱码吧。
4.3 AMF/Red5/WebSocket:中大型Flash项目的遗产
Flash联机游戏曾经有一套更“正统”的服务器方案:Adobe的Flash Media Server(FMS)和开源的Red5。这套方案用的是RTMP协议,客户端连上后可以发布视频流、共享对象、远程调用服务器方法,比XMLSocket封装的层级更高。Red5是Java写的,部署时动手改的通常是web.xml里配置的端口和JVM内存参数。对现在的读者来说,如果不是接手一个老项目,不建议从零用Red5,因为Java环境、Red5版本、Flash播放器兼容性三个条件凑齐并不容易。
WebSocket在Flash里也能用,AS3有一个WebSocket类,需要Flash Player 11.0以上版本。但浏览器禁用Flash之后,WebSocket方案的价值更多体现在把老Flash游戏迁移到HTML5的方向上,很少有人还会新写一个Flash客户端去连WebSocket服务器。这里要特别说明:如果你是在维护一个还在运营的Flash页游,服务端是Java或C++写的自定义Socket协议,客户端是AS3,最稳妥的做法是保持XMLSocket不动,只改服务器IP和端口配置,不要去改协议。
4.4 三种方案对比
| 方案 | 实时性 | 服务端复杂度 | 适用场景 | 缺点 |
|---|---|---|---|---|
| HTTP轮询 | 低,秒级延迟 | 低,普通Web服务器即可 | 排行榜、非实时交互 | 服务器压力大,不适合高频通信 |
| XMLSocket文本协议 | 高,毫秒级 | 中,需自行处理拆包粘包 | 联机对战、聊天室 | 消息体积大,需要定义协议 |
| AMF/Red5/RTMP | 高 | 高,Java环境部署复杂 | 老页游、视频直播类 | 运维重,客户端依赖Flash,停用后难维护 |
这个表格不是一个静态结论,而是选型前的思考框架。接手的项目如果已经是AMF体系,迁移成本远大于维护成本,那就继续用;如果是新项目,我的判断是文本协议最容易落地,出了问题也最好排查。任何协议都有代价,关键是代价是否在你能控制的范围里。
5. Flash游戏服务器搭建的常见问题排查:2046到跨域策略
5.1 一连接就触发SecurityError:crossdomain.xml没生效
现象:AS3代码在本地测试能连上服务器的9100端口,但把SWF放到Nginx上之后,客户端连接直接报SecurityError: Error #2046。
原因:Flash Player认为SWF所在的域(Nginx的IP)和连接的Socket服务器不是同一个源,需要跨域策略授权。而策略文件要么没有部署,要么部署到了80端口而不是843端口。XMLSocket和URLLoader的跨域策略请求端口是不同的,后者走HTTP的80端口根目录,前者固定走843端口。
解决:在目标服务器的843端口上跑一个返回<cross-domain-policy><allow-access-from domain="*"/></cross-domain-policy>的服务。先用浏览器访问http://服务器IP/crossdomain.xml确认文件内容没问题,再用命令行工具去测843端口有没有响应。我一般是先在本机测通,再让客户端从另一台机器连,这样能快速区分是策略问题还是网络问题。
5.2 局域网连不上服务器:防火墙、IP和沙箱
现象:服务器本机访问游戏页正常,局域网里其他电脑却一直转圈打不开,或者打开页面但Socket连不上。
原因:第一种是Nginx监听地址不对,配置里写了127.0.0.1而不是0.0.0.0。第二种是系统防火墙没放行端口,CentOS的firewalld会拦截非本机IP的入站连接。第三种是Flash播放器本身的本地沙箱限制,这个最隐蔽:如果客户端SWF是直接在浏览器地址栏输入file:///D:/game.swf打开的,Flash Player会认为这是一个本地文件,Socket默认连不到远程服务器,除非在Flash Player设置里把该文件加入受信任目录。
解决:先改Nginx监听地址并重载,再检查防火墙放行规则:
firewall-cmd --permanent --add-port=80/tcp firewall-cmd --permanent --add-port=9100/tcp firewall-cmd --reload最后把SWF放到Web服务器上通过HTTP访问,而不是用File协议直接打开。这一步最大的价值在于:开发时要尽早让客户端从Web服务器加载,而不是本地双击测完之后再移上去,两者的沙箱行为完全不同。
5.3 搜索里被反复提起的2046错误:先查MIME再查缓存
现象:网上很多人在搜“flash 2046错误”,实际报错文本是Error #2046,位置在网络加载或Socket连接阶段。
原因:2046这个错误代码在Flash Player里涉及网络和跨域访问的多个场景。最常见的是跨域策略文件缺失或解析失败,其次是加载SWF时服务器返回的MIME类型不对,导致Flash Player拒绝执行。还有一种情况是Nginx开了缓存,客户端拿到旧策略文件,但服务器策略已经换了。
解决:按先后顺序排查:先确认SWF能通过HTTP正常下载且Content-Type正确,再看843端口的策略文件是否能被访问,最后清掉浏览器缓存,强制刷新重新加载一次。如果这都已经做了仍然报2046,用Charles或Fiddler抓包看Flash Player实际请求了哪些地址、收到了什么响应,十有八九是策略文件的域名和Flash内的连接地址不匹配。比如SWF里写的是localhost,但用户访问用的是局域网IP,策略文件里却没有放行这个IP的规则。
5.4 浏览器直接拦截Flash插件:三条退路
现象:Google Chrome、Microsoft Edge在2020年底之后默认禁用Flash,如果用户双击一个包含SWF的老网页,会看到“Adobe Flash Player已不再受支持”的提示。
原因:Adobe宣布生命周期终止,浏览器厂商也把NPAPI插件支持移除,这是生态层面的问题,不是服务器配置问题。
解决:三条常见路径,按维护成本从低到高排列。第一,用Flash Player Projector,也就是独立播放器,双击SWF文件就能本地播放,服务器端只需要把SWF下载下来。第二,用第三方兼容方案,最常见的是Ruffle,它用Rust写的Flash模拟器,通过WebAssembly在浏览器里运行,对ActionScript 1和2的老游戏支持得比较好,ActionScript 3的Socket和网络功能目前还受限,做联机调试时要谨慎。第三,做迁移,把游戏重新用HTML5实现。前两条只能解决“能玩”,第三条才能解决“能长期维护”。很多老游戏站现在的策略是保留服务器端逻辑不动,前端用Ruffle播放SWF,核心逻辑从AS3迁到TypeScript。
6. 浏览器停用后,用Projector把SWF客户端重新跑起来
6.1 把SWF加进受信任目录再运行
Flash Player Projector是Adobe官方出过的独立播放器,双击SWF就能运行,不需要浏览器。但直接从网上下载的SWF用Projector打开,访问本地服务器Socket时同样会触发沙箱限制。解决办法是在Flash Player设置管理器里把这个SWF所在目录加入“受信任的位置”。具体操作路径是右键点击播放器画面,打开“全局设置”,切到“高级”标签页,找到“受信任位置”,添加SWF所在文件夹。所有受信任目录里的SWF会被视为可信内容,允许网络访问。
6.2 用flashvars把服务器地址参数化
服务器运维里最常见的需求是联调时切换测试服和正式服。如果SWF里写死了服务器IP,每次都要重新编译。常见做法是让SWF在启动时读取flashvars参数:
var params:Object = LoaderInfo(this.root.loaderInfo).parameters; var serverHost:String = params.host || "127.0.0.1"; var serverPort:int = int(params.port || 9100); sock.connect(serverHost, serverPort);Projector播放器本身不直接传flashvars,这时候可以做一个简单的HTML页用<object>标签播放SWF并传参数,也可以直接用swf方式配合参数,但浏览器已经不能播放,所以更省事的做法是改SWF的启动逻辑:如果params.host为空,默认走项目配置里的地址。我手里维护过的老Flash项目,都用这种办法做到了一套SWF部署多种环境,省去了大量重复编译。
关于把旧SWF从浏览器缓存里捞出来,这一步很多怀旧站用过:浏览器虽然不显示Flash,但缓存目录里仍有历史下载的SWF文件。找的时候用文件头部特征,SWF未压缩时以FWS开头,压缩时以CWS开头。可以在缓存目录里搜索这两个标记:
grep -rl "CWS\|FWS" ~/.cache/google/chrome/Default/Cache 2>/dev/null | head找到文件后改成.swf后缀,再用Projector打开。这个方法对接手老站的运维人员很有用,省得去旧服务器重新导数据。
做Flash游戏服务器这件事,最大的教训就是别高估兼容性。浏览器会更新、插件会停用、老协议会被淘汰,能留住的只有那批SWF资源和服务器上的数据。我自己的习惯是,做这类老项目尽量把逻辑和资源都放到自己能控制的环境里,要么用Projector跑本地播放,要么把核心逻辑迁出去。想起来要升级的时候,手里有退路。希望帮到你。
本文还有配套的精品资源,点击获取