Unity网络基础之FTP上传数据(异步版)
第一部分:核心概念大升级 (三大法宝)
在看代码之前,我们要先理解这节课引入的三个“超强武器”:
-
单例模式 (static FTPMgr instance)
-
概念:整个游戏里,负责 FTP 管理的“大管家”只需要一个。无论你在主菜单界面,还是在战斗界面,想上传文件时,直接找这位管家即可,不需要每次都 new 一个新管家。
-
-
异步与多线程 (async, await Task.Run)
-
概念:Unity 的主线程就像是“前台接待员”,负责每秒画 60 次画面。以前我们让前台去搬砖(上传文件),所以前台没空画画面(游戏卡死)。现在,我们用 Task.Run 雇佣了一个“后台搬运工”(子线程) 去传文件。前台照常工作,两者互不干扰!
-
-
委托回调 (UnityAction action)
-
概念:搬运工(子线程)传完文件后,怎么通知前台呢?action 就像是一个**“留言条”**。你告诉管家:“传完之后,帮我执行这张纸条上的事情(比如在屏幕上弹个‘上传成功’)”。
-
第二部分:老师修正后的代码与详细注释
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events; // 引入 UnityAction 所需的命名空间
// 注意:这个类没有继承 MonoBehaviour,它是一个纯 C# 管理类
public class FTPMgr
{
// ================== 1. 单例模式 ==================
// 私有静态实例,类加载时就创建好
private static FTPMgr instance = new FTPMgr();
// 公开的属性,供外部访问(箭头函数 => 是 return instance; 的简写)
public static FTPMgr Instance => instance;
// ================== 2. 账号配置 ==================
// 固定的服务器地址、账号、密码
private string FTP_PATH = "ftp://127.0.0.1/";
private string USER_NAME = "ddd";
private string PASSWORD = "13246";
// ================== 3. 异步上传方法 ==================
// async 关键字:告诉编译器这个方法内部会有耗时操作,不要一直等它
// fileName: 存到服务器上的名字 (例如 "icon.png")
// localPath: 电脑上文件的路径 (例如 "C:/xxx/icon.png")
// action: 上传结束后的回调函数(默认值为 null,表示可以不传)
public async void UpLoadFile(string fileName, string localPath, UnityAction action = null)
{
// await: 前台执行到这里时,把任务丢给后台,前台自己去忙别的(游戏继续运行不卡顿)
// Task.Run: 开辟一个子线程(雇佣搬运工)
await Task.Run(() =>
{
// 以下代码全部在【子线程】中运行!
try
{
// 1. 拼接完整路径并创建请求 (例如 ftp://127.0.0.1/icon.png)
FtpWebRequest req = FtpWebRequest.Create(new Uri(FTP_PATH + fileName)) as FtpWebRequest;
req.Credentials = new NetworkCredential(USER_NAME, PASSWORD);
req.KeepAlive = false;
req.UseBinary = true;
req.Method = WebRequestMethods.Ftp.UploadFile;
req.Proxy = null;
// 2. 获取上传管道
Stream upLoadStream = req.GetRequestStream();
// 3. 打开本地文件
using (FileStream fileStream = File.OpenRead(localPath))
{
byte[] bytes = new byte[1024]; // 1KB 的水桶
int contentLength = fileStream.Read(bytes, 0, bytes.Length);
while (contentLength != 0)
{
// ★ 老师重点修正!!!致命 Bug!!!
// 错:upLoadStream.Write(bytes, 0, bytes.Length);
// 对:upLoadStream.Write(bytes, 0, contentLength);
// 原因:如果是最后一次舀水,水桶可能没装满(比如只装了 200 字节)。
// 如果你写 bytes.Length (1024),就会把水桶里剩下的 824 字节“空气(乱码)”也传给服务器!导致文件损坏!
upLoadStream.Write(bytes, 0, contentLength);
// 继续舀下一桶
contentLength = fileStream.Read(bytes, 0, bytes.Length);
}
// 管道关掉 (fileStream 因为有 using 会自动关,这里写不写都行)
upLoadStream.Close();
}
// Debug.Log 在较新的 Unity 版本中是可以在子线程调用的
Debug.Log("后台传输完成!");
}
catch (Exception e)
{
Debug.Log("上传文件出错: " + e.Message);
}
});
// --- Task.Run 的大括号结束 ---
// ================== 4. 回到主线程执行回调 ==================
// 因为前面有 await,所以走到这里时,Task 已经彻底执行完了。
// 并且!await 会自动把执行权【切回 Unity 主线程】。
// ?. 语法:如果 action 不是 null,就 Invoke() 执行它。
action?.Invoke();
}
}
第三部分:如何使用这个管理器?(实战演示)
using UnityEngine;
public class TestUpload : MonoBehaviour
{
void Start()
{
// 假设我们有一张图片要上传
string myLocalFile = Application.streamingAssetsPath + "/myHero.png";
string targetName = "hero_backup.png";
// 调用单例 (FTPMgr.Instance)
// 传入一个“回调方法” (当上传完成时,帮我打印一段话)
FTPMgr.Instance.UpLoadFile(targetName, myLocalFile, () =>
{
// 这段代码只有在文件真正传完后才会被触发!
print("UI 提示:太棒了!文件已经成功上传到服务器!");
// 这里可以安全地更新 UI,比如隐藏“正在上传”的转圈图标
// loadingIcon.SetActive(false);
});
print("发起上传请求完毕,游戏继续流畅运行,我不卡!");
}
}
第四部分:总结、利弊与扩展
总结与利弊
-
优点:
-
全局通用:任何脚本都可以通过 FTPMgr.Instance 调用,代码极其干净。
-
不卡主线程:Task.Run 完美解决了网络请求假死的问题。
-
UI 线程安全:await 机制极其优雅,它保证了 action?.Invoke() 是在 Unity 主线程执行的。这意味着你可以在 action 里去操作 Unity 的 UI(如果直接在 Task.Run 里操作 UI,Unity 会报错并崩溃)。
-
-
缺点:
-
如果玩家点击上传后,立马关闭了游戏或切到了其他场景,后台的 Task 还在默默运行,可能会导致找不到对象而报错。
-
缺乏取消机制。大文件传到一半想取消,目前的架构不支持。
-
扩展学习 (CancellationToken)
在未来的真实项目中,如果你想中途“取消”上传,你需要给 UpLoadFile 方法传入一个叫 CancellationToken 的参数。并在 while 循环里不断检查 if (token.IsCancellationRequested) break;。
第五部分:随堂测试 (Q&A)
检验学习成果的时候到了!
问题 1:为什么在原来的错误代码中,用 upLoadStream.Write(bytes,0,bytes.Length) 会导致图片传上去之后打不开(损坏)?
答案:假设最后一次读取本地文件时,只剩 100 个字节了。fileStream.Read 会把 100 字节放入水桶,此时 contentLength = 100。如果你坚持写 bytes.Length (1024),程序就会把水桶里那真实的 100 字节,加上原来残留在水桶里的 924 字节“旧数据”一起写进服务器。导致服务器收到的文件比本地文件大,且末尾全是垃圾数据,图片当然打不开。
问题 2:FTPMgr 为什么没有写 : MonoBehaviour?
答案:因为它不需要挂载到 Unity 的游戏物体(GameObject)上。它只是一个纯粹的逻辑管理类,通过 static 单例保存在内存中。这样更加轻量且易于管理。
问题 3:如果我想在上传成功后,把屏幕上的一个 Text 文本改成“上传成功”,这段改文本的代码应该写在 Task.Run 的大括号里面,还是通过传入 action 来执行?为什么?
答案:必须通过传入 action 来执行(或者写在 await Task.Run 之后)。绝不能写在 Task.Run 里面! 因为 Unity 有一个铁律:“所有与 Unity 引擎相关的组件(如 Transform, UI Text, GameObject 等),都只能在 Unity 的主线程中操作”。子线程修改 UI 会直接报错崩溃。
问题 4:action?.Invoke(); 里的 ?. 是什么意思?
答案:它是 C# 的“空值传播运算符”。相当于:
if (action != null) { action.Invoke(); }
这是为了防止调用者没有传回调函数(action 为空)时,直接调用 Invoke() 导致空指针异常报错。
第六部分:下载文件函数 DownLoadFile
下载和上传是镜像操作,区别在于我们要向服务器“索取”文件,而不是“发送”文件。
public async void DownLoadFile(string fileName, string localPath, UnityAction action = null)
{
try // 注意:这里的try包在了Task.Run外面
{
await Task.Run(() =>
{
// 1. 依然是填写“快递单”
FtpWebRequest req = FtpWebRequest.Create(new Uri(FTP_PATH + fileName)) as FtpWebRequest;
req.Credentials = new NetworkCredential(USER_NAME, PASSWORD);
req.KeepAlive = false;
req.UseBinary = true;
// 【关键区别】2. 告诉服务器,这次我要“下载文件” (DownloadFile)
req.Method = WebRequestMethods.Ftp.DownloadFile;
req.Proxy = null;
// 3. 获取响应 (Response):向服务器递交单子后,服务器给我们的回应
FtpWebResponse res = req.GetResponse() as FtpWebResponse;
// 4. 从回应中获取下载流(接通一根从服务器通向我们本地的“进水管”)
Stream downLoadStream = res.GetResponseStream();
// 5. 在本地创建一个空文件,准备把水灌进去
using(FileStream fileStream = File.Create(localPath))
{
byte[] bytes = new byte[1024]; // 再次准备1024的小推车
// 从服务器水管里接水
int contentLength = downLoadStream.Read(bytes, 0, bytes.Length);
while(contentLength != 0) // 只要还能接到水
{
// 把接到的水,倒进本地新建的文件里
fileStream.Write(bytes, 0, contentLength);
// 继续接下一车水
contentLength = downLoadStream.Read(bytes, 0, bytes.Length);
}
fileStream.Close(); // 关掉本地文件
downLoadStream.Close(); // 关掉服务器的水管
}
});
}
catch (Exception e)
{
// 【老师纠错】:原代码这里是空的!如果出错,你根本不知道发生了什么。
Debug.LogError("下载文件失败啦:" + e.Message);
}
// 执行回调
action?.Invoke();
}
2. 参数与变量的意义
-
res (FtpWebResponse): 服务器的回复。你可以从里面提取出数据(流)、甚至状态码(比如问问服务器:你允许我下载吗?)。
-
downLoadStream: 从服务器源源不断传送过来数据的输入流。
-
File.Create(localPath): 创建一个全新的文件。如果同名文件已存在,它会把原来的覆盖掉。
3. 如何使用这个函数?
假设你需要从服务器下载最新的游戏配置文件:
string saveToLocal = Application.persistentDataPath + "/GameConfig.xml";
FTPMgr.Instance.DownLoadFile("LatestConfig.xml", saveToLocal, () =>
{
Debug.Log("配置文件下载完毕,准备开始解析!");
// 在这里写加载配置文件的代码...
});
4. 总结与扩展 (下载篇)
-
弊端: 如果下载到 99% 断网了,下次调用这个函数又会从 0% 重新下载(因为用的是 File.Create)。
-
扩展(断点续传): 要实现断点续传,你需要把 File.Create 换成 File.Append (追加模式),并且在向服务器发请求时,告诉它:req.ContentOffset = 本地已经下载的文件大小,这样服务器就会从断开的地方接着发给你。
5. 随堂测试 QA
Q2:代码里出现了 req.GetRequestStream() 和 res.GetResponseStream(),这两个有什么区别?
答案:
RequestStream 是你向外发送数据的通道(用于上传,相当于你的嘴巴在说话)。
ResponseStream 是你接收服务器数据的通道(用于下载,相当于你的耳朵在听服务器说话)。
Q3:为什么空的 catch (Exception e) { } 非常不好?
答案: 这叫“吞掉异常”。就像病人(代码)生病了在惨叫(报错),但是医生把他的嘴捂上了。如果下载失败了,游戏界面没反应,开发者看控制台也看不到报错,会导致查 Bug 极其困难!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)